feat(bell): add alert acknowledgement vertical slice
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-11 17:01:53 +08:00
parent 477afa6ba2
commit 8208118904
29 changed files with 1820 additions and 56 deletions
+17
View File
@@ -0,0 +1,17 @@
"use strict";
let token="",state="",cursor=null,selected=null,busy=false;
const $=id=>document.getElementById(id);
const escTime=value=>new Intl.DateTimeFormat("zh-CN",{dateStyle:"medium",timeStyle:"medium",hour12:false}).format(new Date(value));
function announce(message,error=false){const box=$(error?"errors":"status");box.textContent=message;box.hidden=false;window.setTimeout(()=>box.hidden=true,5000)}
async function api(path,options={}){const response=await fetch(`/bell-console/api/v1${path}`,{...options,headers:{"Authorization":`Bearer ${token}`,...options.headers}});let body={};try{body=await response.json()}catch{}if(!response.ok){const error=new Error(body.message||`请求失败(${response.status})`);error.status=response.status;error.body=body;throw error}return body}
function setBusy(value){busy=value;$("alerts").setAttribute("aria-busy",String(value));for(const id of ["refresh","more","ack","close"])$(id).disabled=value}
function row(item){const button=document.createElement("button");button.type="button";button.className="alert-row"+(selected===item.id?" selected":"");button.dataset.id=item.id;const left=document.createElement("span"),right=document.createElement("span"),title=document.createElement("strong"),meta=document.createElement("small"),badge=document.createElement("span");title.textContent=item.title;meta.textContent=`${item.rule_key} · v${item.rule_version} · ${escTime(item.created_at)}`;badge.className="state";badge.dataset.state=item.state;badge.textContent={open:"待确认",acknowledged:"处理中",closed:"已关闭"}[item.state]||item.state;left.append(title,meta);right.append(badge);button.append(left,right);button.addEventListener("click",()=>loadDetail(item.id));return button}
async function loadAlerts(append=false){if(busy)return;setBusy(true);if(!append){cursor=null;$("alerts").replaceChildren(...[1,2,3].map(()=>Object.assign(document.createElement("div"),{className:"skeleton"})))}try{const query=new URLSearchParams({limit:"16"});if(state)query.set("state",state);if(append&&cursor)query.set("cursor",cursor);const data=await api(`/alerts?${query}`);if(!append)$("alerts").replaceChildren();for(const item of data.items)$("alerts").append(row(item));if(!append&&!data.items.length){const empty=document.createElement("p");empty.className="offline";empty.textContent="当前筛选条件下没有预警。";$("alerts").append(empty)}cursor=data.next_cursor;$("more").hidden=!cursor;$("connectionText").textContent="已连接 · 显示真实状态";document.querySelector(".connection").classList.add("connected")}catch(error){if(!append){const retry=document.createElement("button");retry.className="secondary full";retry.textContent="加载失败,重试";retry.addEventListener("click",()=>loadAlerts());$("alerts").replaceChildren(retry)}announce(error.message,true);if(error.status===401)disconnect()}finally{setBusy(false)}}
function fact(label,value){const box=document.createElement("div"),dt=document.createElement("dt"),dd=document.createElement("dd");dt.textContent=label;dd.textContent=value;box.append(dt,dd);return box}
async function loadDetail(id){if(busy)return;selected=id;document.querySelectorAll(".alert-row").forEach(el=>el.classList.toggle("selected",el.dataset.id===id));setBusy(true);try{const data=await api(`/alerts/${encodeURIComponent(id)}`);$("detailEmpty").hidden=true;$("detailContent").hidden=false;$("detailSeverity").textContent=data.severity.toUpperCase();$("detailName").textContent=data.title;$("detailId").textContent=data.id;$("detailState").textContent={open:"待确认",acknowledged:"处理中",closed:"已关闭"}[data.state];$("detailState").dataset.state=data.state;$("facts").replaceChildren(fact("规则",data.rule_key),fact("规则版本",`v${data.rule_version}`),fact("创建时间",escTime(data.created_at)));$("events").replaceChildren(...data.events.map(event=>{const box=document.createElement("div");box.className="event";const a=document.createElement("span"),b=document.createElement("span");a.textContent=`${event.kind} · 设备 ${event.device_id}`;b.textContent=`${event.severity} · ${escTime(event.occurred_at)}`;box.append(a,b);return box}));$("timeline").replaceChildren(...data.transitions.map(item=>{const li=document.createElement("li"),strong=document.createElement("strong"),small=document.createElement("small");strong.textContent=`${item.to_state} · ${item.actor_ref}`;small.textContent=escTime(item.occurred_at)+(item.note?` · ${item.note}`:"");li.append(strong,small);return li}));$("ack").hidden=data.state!=="open";$("close").hidden=data.state!=="acknowledged"}catch(error){announce(error.message,true)}finally{setBusy(false)}}
async function command(name){if(!selected||busy)return;setBusy(true);const button=$(name);button.textContent=name==="ack"?"确认中…":"关闭中…";try{const result=await api(`/alerts/${selected}:${name}`,{method:"POST",headers:{"Content-Type":"application/json","Idempotency-Key":crypto.randomUUID()},body:JSON.stringify({note:$("note").value||null})});announce(name==="ack"?`已由 ${result.actor_ref} 接手`:"预警已关闭");$("note").value="";setBusy(false);await loadDetail(selected);await loadAlerts()}catch(error){if(error.status===409&&error.body?.code==="already_acknowledged")announce("该预警已被其他值班员确认,正在刷新实际处置人",true);else announce(error.message,true);setBusy(false);await loadDetail(selected)}finally{button.textContent=name==="ack"?"确认接手":"关闭预警";setBusy(false)}}
function disconnect(){$("workspace").hidden=true;$("authPanel").hidden=false;token="";$("token").value="";$("connectionText").textContent="等待授权";document.querySelector(".connection").classList.remove("connected")}
$("connect").addEventListener("click",()=>{const value=$("token").value;if(value.length<32){announce("令牌长度不足,请检查外部 token 文件",true);return}token=value;$("token").value="";$("authPanel").hidden=true;$("workspace").hidden=false;loadAlerts()});
$("token").addEventListener("keydown",event=>{if(event.key==="Enter")$("connect").click()});
$("refresh").addEventListener("click",()=>loadAlerts());$("more").addEventListener("click",()=>loadAlerts(true));$("ack").addEventListener("click",()=>command("ack"));$("close").addEventListener("click",()=>command("close"));
document.querySelectorAll(".filter").forEach(button=>button.addEventListener("click",()=>{state=button.dataset.state;document.querySelectorAll(".filter").forEach(other=>{other.classList.toggle("active",other===button);other.setAttribute("aria-pressed",String(other===button))});loadAlerts()}));
+54
View File
@@ -0,0 +1,54 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>YoVision Bell 值班台</title>
<link rel="stylesheet" href="/bell-console/assets/style.css">
<script defer src="/bell-console/assets/app.js"></script>
</head>
<body>
<a class="skip" href="#alerts">跳到预警列表</a>
<header>
<div><p class="eyebrow">YOVISION · BELL</p><h1>预警处置值班台</h1></div>
<div class="connection"><span class="dot" aria-hidden="true"></span><span id="connectionText">等待授权</span></div>
</header>
<main>
<section id="authPanel" class="auth card" aria-labelledby="authTitle">
<div><h2 id="authTitle">连接本机工程控制台</h2><p>令牌仅保存在当前页面内存,刷新后需重新输入。</p></div>
<label>控制台令牌<input id="token" type="password" autocomplete="off" spellcheck="false"></label>
<button id="connect" type="button">连接并加载</button>
</section>
<div id="workspace" class="workspace" hidden>
<section class="queue card" aria-labelledby="queueTitle">
<div class="section-head"><div><p class="eyebrow">真实 PostgreSQL 状态</p><h2 id="queueTitle">预警队列</h2></div><button id="refresh" class="secondary" type="button">刷新</button></div>
<div class="filters" role="group" aria-label="按状态筛选">
<button class="filter active" data-state="" aria-pressed="true">全部</button>
<button class="filter" data-state="open" aria-pressed="false">待确认</button>
<button class="filter" data-state="acknowledged" aria-pressed="false">处理中</button>
<button class="filter" data-state="closed" aria-pressed="false">已关闭</button>
</div>
<div id="alerts" tabindex="-1" aria-busy="false"></div>
<button id="more" class="secondary full" type="button" hidden>加载更多</button>
</section>
<section class="detail card" aria-labelledby="detailTitle">
<div id="detailEmpty" class="empty"><span aria-hidden="true">◎</span><h2 id="detailTitle">选择一条预警</h2><p>查看关联事件、规则版本与不可变处置时间线。</p></div>
<div id="detailContent" hidden>
<div class="section-head"><div><p id="detailSeverity" class="badge"></p><h2 id="detailName"></h2><code id="detailId"></code></div><span id="detailState" class="state"></span></div>
<dl id="facts" class="facts"></dl>
<div class="notice"><strong>证据切片尚未启用</strong><span>当前只展示事件事实,不虚构截图或录像。</span></div>
<div class="notice muted"><strong>升级与通知尚未启用</strong><span>当前没有倒计时、短信、语音或送达状态。</span></div>
<h3>关联事件</h3><div id="events"></div>
<h3>处置时间线</h3><ol id="timeline" class="timeline"></ol>
<label for="note">处置备注(可选,最多 500 字)</label><textarea id="note" maxlength="500" rows="3"></textarea>
<div class="actions"><button id="ack" type="button">确认接手</button><button id="close" type="button">关闭预警</button></div>
</div>
</section>
</div>
</main>
<div id="status" class="toast" role="status" aria-live="polite" hidden></div>
<div id="errors" class="toast error" role="alert" aria-live="assertive" hidden></div>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
:root{color-scheme:light;--ink:#17202a;--muted:#607080;--line:#d8e0e7;--paper:#fff;--canvas:#eef3f7;--blue:#075ea8;--blue-soft:#e8f2fb;--red:#b42318;--red-soft:#fff0ee;--amber:#8a4b08;--green:#167348;--shadow:0 8px 24px rgba(23,32,42,.08);font-family:"Segoe UI","Microsoft YaHei",sans-serif}
*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;background:var(--canvas);color:var(--ink)}button,input,textarea{font:inherit}button{min-height:44px;border:0;border-radius:8px;padding:0 16px;background:var(--blue);color:#fff;font-weight:650;cursor:pointer}button:hover{filter:brightness(.94)}button:focus-visible,input:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid #67b7ff;outline-offset:2px}button:disabled{cursor:not-allowed;opacity:.55}.secondary{background:#fff;color:var(--blue);border:1px solid #9bb9d3}.skip{position:fixed;left:12px;top:-60px;z-index:10;background:#fff;padding:10px}.skip:focus{top:12px}header{height:82px;padding:12px clamp(16px,4vw,48px);background:#12283b;color:#fff;display:flex;align-items:center;justify-content:space-between;box-shadow:var(--shadow)}h1,h2,h3,p{margin-top:0}h1{font-size:22px;margin-bottom:0}h2{font-size:19px;margin-bottom:7px}h3{font-size:15px;margin:24px 0 10px}.eyebrow{font-size:11px;letter-spacing:.13em;color:#79b9ed;margin-bottom:5px;font-weight:700}.connection{display:flex;gap:8px;align-items:center;font-size:13px}.dot{width:9px;height:9px;border-radius:50%;background:#f2b84b}.connected .dot{background:#45c58a}main{padding:24px clamp(16px,4vw,48px)}.card{background:var(--paper);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.auth{max-width:720px;margin:7vh auto;padding:24px;display:grid;grid-template-columns:1fr minmax(220px,300px) auto;gap:18px;align-items:end}.auth p,.empty p{color:var(--muted);margin-bottom:0}label{font-size:13px;font-weight:650;display:grid;gap:7px}input,textarea{width:100%;border:1px solid #aebcc8;border-radius:7px;padding:10px 11px;background:#fff;color:var(--ink)}.workspace{display:grid;grid-template-columns:minmax(330px,.82fr) minmax(420px,1.18fr);gap:20px;max-width:1440px;margin:auto}.queue,.detail{min-height:calc(100vh - 130px);padding:20px}.section-head{display:flex;justify-content:space-between;gap:14px;align-items:flex-start}.filters{display:flex;gap:7px;overflow:auto;padding:12px 0}.filter{background:#fff;color:var(--muted);border:1px solid var(--line);white-space:nowrap}.filter.active{background:var(--blue-soft);border-color:#74a9d3;color:#084e87}.alert-row{width:100%;height:auto;min-height:78px;text-align:left;background:#fff;color:var(--ink);border:1px solid var(--line);padding:13px;margin:0 0 8px;display:grid;grid-template-columns:1fr auto;gap:7px}.alert-row.selected{border-color:var(--blue);box-shadow:0 0 0 2px #d5ebff}.alert-row strong{display:block}.alert-row small{color:var(--muted)}.badge,.state{display:inline-block;width:max-content;border-radius:999px;padding:4px 9px;background:var(--red-soft);color:var(--red);font-size:12px;font-weight:700}.state[data-state=acknowledged]{background:#fff4db;color:var(--amber)}.state[data-state=closed]{background:#e9f7ef;color:var(--green)}.full{width:100%;margin-top:5px}.empty{text-align:center;color:var(--muted);padding:18vh 10px}.empty span{font-size:40px}.facts{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:20px 0}.facts div{background:#f5f8fa;padding:10px;border-radius:7px}.facts dt{font-size:11px;color:var(--muted)}.facts dd{margin:4px 0 0;font-weight:650;overflow-wrap:anywhere}.notice{display:grid;gap:3px;border-left:4px solid var(--red);background:var(--red-soft);padding:11px 13px;margin:10px 0;font-size:13px}.notice span{color:var(--muted)}.notice.muted{border-color:#82909d;background:#f3f5f6}.event{padding:10px;border:1px solid var(--line);border-radius:7px;margin-bottom:7px;display:flex;justify-content:space-between;gap:10px;font-size:13px}.timeline{padding-left:22px}.timeline li{padding:0 0 14px 5px}.timeline small{display:block;color:var(--muted);margin-top:3px}.actions{display:flex;gap:10px;margin-top:12px}.toast{position:fixed;right:22px;bottom:22px;max-width:430px;background:#173b2c;color:#fff;padding:13px 16px;border-radius:8px;box-shadow:var(--shadow);z-index:5}.toast.error{background:#7d201a}.skeleton{height:76px;background:linear-gradient(90deg,#edf1f4,#f7f9fa,#edf1f4);border-radius:7px;margin-bottom:8px;background-size:200% 100%;animation:pulse 1.4s infinite}.offline{padding:22px;text-align:center;color:var(--muted)}code{font-size:12px;overflow-wrap:anywhere}
@keyframes pulse{to{background-position:-200% 0}}
@media(max-width:850px){.auth{grid-template-columns:1fr}.workspace{grid-template-columns:1fr}.queue,.detail{min-height:auto}.detail{min-height:520px}.facts{grid-template-columns:1fr 1fr}}
@media(max-width:480px){header{height:auto;min-height:82px;align-items:flex-start;gap:12px}.connection{padding-top:5px}main{padding:12px}.queue,.detail{padding:14px}.facts{grid-template-columns:1fr}.actions{display:grid}.toast{left:12px;right:12px;bottom:12px}.event{display:grid}}
@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important}}
+206
View File
@@ -0,0 +1,206 @@
// Package web exposes the loopback-only Bell engineering console.
package web
import (
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"yovision/bell/internal/alert"
)
//go:embed assets/*
var assets embed.FS
type Config struct {
Token string
TenantID int64
SiteID int64
ActorRef string
}
type Handler struct {
repository alert.Repository
config Config
tokenHash [sha256.Size]byte
}
var alertIDPattern = regexp.MustCompile(`^alt_[0-9A-HJKMNP-TV-Z]{26}$`)
func NewHandler(repository alert.Repository, config Config) (*Handler, error) {
if repository == nil || len(config.Token) < 32 || len(config.Token) > 256 || config.TenantID < 1 || config.SiteID < 1 || !alert.ValidActorRef(config.ActorRef) {
return nil, errors.New("invalid Bell alert console configuration")
}
return &Handler{repository: repository, config: config, tokenHash: sha256.Sum256([]byte(config.Token))}, nil
}
func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("GET /bell-console/", h.page)
mux.HandleFunc("GET /bell-console/assets/{name}", h.asset)
mux.HandleFunc("GET /bell-console/api/v1/alerts", h.list)
mux.HandleFunc("GET /bell-console/api/v1/alerts/{id}", h.detail)
mux.HandleFunc("POST /bell-console/api/v1/alerts/{action}", h.command)
}
func secureHeaders(writer http.ResponseWriter) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
}
func (h *Handler) page(writer http.ResponseWriter, _ *http.Request) {
secureHeaders(writer)
writer.Header().Set("Content-Type", "text/html; charset=utf-8")
value, _ := assets.ReadFile("assets/index.html")
_, _ = writer.Write(value)
}
func (h *Handler) asset(writer http.ResponseWriter, request *http.Request) {
secureHeaders(writer)
name := request.PathValue("name")
if name != "app.js" && name != "style.css" {
http.NotFound(writer, request)
return
}
value, err := assets.ReadFile("assets/" + name)
if err != nil {
http.NotFound(writer, request)
return
}
if strings.HasSuffix(name, ".js") {
writer.Header().Set("Content-Type", "text/javascript; charset=utf-8")
} else {
writer.Header().Set("Content-Type", "text/css; charset=utf-8")
}
_, _ = writer.Write(value)
}
func (h *Handler) authorize(writer http.ResponseWriter, request *http.Request) bool {
secureHeaders(writer)
prefix := "Bearer "
value := request.Header.Get("Authorization")
if !strings.HasPrefix(value, prefix) {
writeError(writer, http.StatusUnauthorized, "unauthorized", "需要控制台令牌")
return false
}
digest := sha256.Sum256([]byte(strings.TrimPrefix(value, prefix)))
if subtle.ConstantTimeCompare(digest[:], h.tokenHash[:]) != 1 {
writeError(writer, http.StatusUnauthorized, "unauthorized", "控制台令牌无效")
return false
}
return true
}
func (h *Handler) list(writer http.ResponseWriter, request *http.Request) {
if !h.authorize(writer, request) {
return
}
limit := alert.DefaultPageSize
state := request.URL.Query().Get("state")
if state != "" && state != "open" && state != "acknowledged" && state != "closed" {
writeError(writer, http.StatusBadRequest, "invalid_state", "state 筛选无效")
return
}
cursor := request.URL.Query().Get("cursor")
if cursor != "" && !alertIDPattern.MatchString(cursor) {
writeError(writer, http.StatusBadRequest, "invalid_cursor", "cursor 无效")
return
}
if raw := request.URL.Query().Get("limit"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 || value > alert.MaxPageSize {
writeError(writer, http.StatusBadRequest, "invalid_limit", "limit 必须在 1 到 100 之间")
return
}
limit = value
}
page, err := h.repository.ListAlerts(request.Context(), h.config.TenantID, h.config.SiteID, state, limit, cursor)
if err != nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 列表失败,可重试")
return
}
writeJSON(writer, http.StatusOK, page)
}
func (h *Handler) detail(writer http.ResponseWriter, request *http.Request) {
if !h.authorize(writer, request) {
return
}
id := request.PathValue("id")
if !alertIDPattern.MatchString(id) {
writeError(writer, http.StatusBadRequest, "invalid_alert_id", "Alert ID 无效")
return
}
value, err := h.repository.GetAlert(request.Context(), h.config.TenantID, h.config.SiteID, id)
if errors.Is(err, alert.ErrNotFound) {
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
return
}
if err != nil {
writeError(writer, http.StatusInternalServerError, "internal_error", "读取 Alert 失败,可重试")
return
}
writeJSON(writer, http.StatusOK, value)
}
func (h *Handler) command(writer http.ResponseWriter, request *http.Request) {
if !h.authorize(writer, request) {
return
}
action := request.PathValue("action")
id, command, found := strings.Cut(action, ":")
if !found || !alertIDPattern.MatchString(id) || (command != "ack" && command != "close") {
writeError(writer, http.StatusBadRequest, "invalid_command", "Alert 命令无效")
return
}
key := request.Header.Get("Idempotency-Key")
if !alert.ValidIdempotencyKey(key) {
writeError(writer, http.StatusBadRequest, "invalid_idempotency_key", "Idempotency-Key 必须为 8 到 128 个安全字符")
return
}
request.Body = http.MaxBytesReader(writer, request.Body, 2048)
decoder := json.NewDecoder(request.Body)
decoder.DisallowUnknownFields()
var body struct {
Note *string `json:"note"`
}
if err := decoder.Decode(&body); err != nil {
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体必须是 JSON 对象")
return
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
writeError(writer, http.StatusBadRequest, "invalid_json", "请求体只能包含一个 JSON 对象")
return
}
status, response, err := h.repository.Command(request.Context(), h.config.TenantID, h.config.SiteID, id, command, key, h.config.ActorRef, body.Note)
switch {
case errors.Is(err, alert.ErrNotFound):
writeError(writer, http.StatusNotFound, "not_found", "Alert 不存在")
case errors.Is(err, alert.ErrIdempotencyConflict):
writeError(writer, http.StatusConflict, "idempotency_conflict", "该幂等键已用于另一条命令")
case err != nil:
writeError(writer, http.StatusInternalServerError, "internal_error", "写入处置状态失败,可使用同一幂等键重试")
default:
writeJSON(writer, status, response)
}
}
func writeJSON(writer http.ResponseWriter, status int, value any) {
writer.Header().Set("Content-Type", "application/json; charset=utf-8")
writer.WriteHeader(status)
_ = json.NewEncoder(writer).Encode(value)
}
func writeError(writer http.ResponseWriter, status int, code, message string) {
writeJSON(writer, status, map[string]string{"code": code, "message": message})
}
+93
View File
@@ -0,0 +1,93 @@
package web
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"yovision/bell/internal/alert"
)
type fakeRepository struct {
limit int
actor string
}
func (*fakeRepository) AlertReady(context.Context) error { return nil }
func (*fakeRepository) PublishRule(context.Context, alert.RuleSpec) (bool, error) { return true, nil }
func (*fakeRepository) EvaluateNext(context.Context) (bool, error) { return false, nil }
func (f *fakeRepository) ListAlerts(_ context.Context, _, _ int64, _ string, limit int, _ string) (alert.Page, error) {
f.limit = limit
return alert.Page{Items: []alert.Summary{}}, nil
}
func (*fakeRepository) GetAlert(context.Context, int64, int64, string) (alert.Detail, error) {
return alert.Detail{}, alert.ErrNotFound
}
func (f *fakeRepository) Command(_ context.Context, _, _ int64, id, command, key, actor string, _ *string) (int, alert.CommandResponse, error) {
f.actor = actor
return 200, alert.CommandResponse{AlertID: id, State: "acknowledged", ActorRef: actor}, nil
}
func testMux(t *testing.T) (*http.ServeMux, *fakeRepository) {
t.Helper()
repository := &fakeRepository{}
handler, err := NewHandler(repository, Config{Token: "12345678901234567890123456789012", TenantID: 1, SiteID: 2, ActorRef: "operator:local"})
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
handler.Register(mux)
return mux, repository
}
func TestPageIsNoStoreAndDoesNotEmbedToken(t *testing.T) {
mux, _ := testMux(t)
request := httptest.NewRequest(http.MethodGet, "/bell-console/", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != 200 || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("page response: %d %#v", response.Code, response.Header())
}
if body := response.Body.String(); body == "" || strings.Contains(body, "12345678901234567890123456789012") {
t.Fatal("page missing or leaked console token")
}
}
func TestAPIRequiresBearerAndDefaultsToSixteen(t *testing.T) {
mux, repository := testMux(t)
request := httptest.NewRequest(http.MethodGet, "/bell-console/api/v1/alerts", nil)
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized status %d", response.Code)
}
request = httptest.NewRequest(http.MethodGet, "/bell-console/api/v1/alerts", nil)
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
response = httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK || repository.limit != alert.DefaultPageSize {
t.Fatalf("authorized list: status=%d limit=%d", response.Code, repository.limit)
}
}
func TestCommandUsesServerActorAndRequiresIdempotencyKey(t *testing.T) {
mux, repository := testMux(t)
path := "/bell-console/api/v1/alerts/alt_01J8XQ2K7M3P5R9T0V4W6Y8Z2C:ack"
request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"note":null}`))
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
response := httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusBadRequest {
t.Fatalf("missing idempotency status %d", response.Code)
}
request = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"note":null}`))
request.Header.Set("Authorization", "Bearer 12345678901234567890123456789012")
request.Header.Set("Idempotency-Key", "command-0001")
response = httptest.NewRecorder()
mux.ServeHTTP(response, request)
if response.Code != http.StatusOK || repository.actor != "operator:local" {
t.Fatalf("command status=%d actor=%q", response.Code, repository.actor)
}
}