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
+26 -4
View File
@@ -1,6 +1,6 @@
# Bell 事件存储、审计与 Brain 事件入口
# Bell 事件、规则与预警处置纵切
Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的 Brain 事件 ingress:
Bell 当前实现 M3 的事件域基础、Sense 审计 relay、默认关闭的 Brain 事件 ingress,以及默认关闭的规则→Alert→ack/close 工程纵切:
- Bell 在可信 ingress 内为不含 `id` 的候选事实生成 `evt_` ULID。
- 最终事件同时通过冻结 v0.1 JSON Schema 与六项代码级断言。
@@ -10,8 +10,11 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
- `(key_id, nonce)` 收据保存 10 分钟;相同摘要重放原结果,不同摘要返回冲突。非回环监听必须配置 TLS 证书和私钥。
- T-019 可选 `/internal/v1/event-candidates` 把 HMAC key 绑定到一个 `producer_id`,通过 `event_ingress_bindings` 解析数字事件身份并复查当前 Site/Area/Sense Device;缺失、删除、Area 不一致或 `non_imaging_only` 均失败关闭。
- `(producer_id, source_event_id)` 永久收据、最终事件和成功 nonce 响应同事务提交;相同 canonical candidate 返回原 Bell ID,不同 candidate 返回 `source_event_conflict`。
- 规则配置按 canonical hash 幂等发布不可变版本;worker 为每个 Event 写 durable sweep/evaluation,命中时由 Bell 生成 `alt_` ULID 并关联 Event。
- Alert 的 `open → acknowledged → closed` 状态从 append-only transition 推导;8 路并发 ack 只有首个成功,后到者得到实际首位处置人/时间,同一幂等键跨重启重放原响应。
- `/bell-console/` 是自包含、无 CDN 的回环工程值班台,显示真实规则版本、关联 Event 和处置时间线;证据与升级/通知未实现时明确显示不可用,不伪造成功事实。
公共认证/事件 API、规则、Alert 和证据对象存储仍需后续任务冻结。两条 HMAC ingress 都是内部适配器,不得当成 Bell 公共协议或共用 key。
公共认证/事件 API、正式规则管理、证据对象存储、升级/通知和正式前端框架仍需后续任务冻结。两条 HMAC ingress 与回环控制台都是工程适配器,不得当成 Bell 公共协议或共用 key/token。
启动内部 receiver 前必须私下设置 `BELL_DB_DSN` 和仓库外绝对路径 `BELL_AUDIT_KEYS_FILE`。远端监听还必须设置 `BELL_TLS_CERT_FILE`、`BELL_TLS_KEY_FILE`;仓库不保存 DSN、key 或证书:
@@ -19,7 +22,7 @@ Bell 当前实现 M3 的事件域基础、Sense 审计 relay 与默认关闭的
go -C Bell run ./cmd/bell-api
```
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`017` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
事件 ingress 默认关闭。启用前,管理员先在专用数据库执行 `001`~`019` migration,并用受控 SQL 创建与现有 Bell Site/Area、Sense Device 一致的绑定;运行角色不能写绑定。然后私下设置:
```powershell
$env:BELL_EVENT_INGRESS_ENABLED = 'true'
@@ -46,6 +49,25 @@ INSERT INTO bell.event_ingress_bindings(
外键只负责资源存在;runtime 还会复查 Area 属于同 Site、设备当前 Area/modality 一致、Site/Area 未删除且策略允许成像。换绑或停用由管理员显式更新/删除 binding,不能修改永久来源收据。
规则 worker 和工程值班台分别由 feature flag 开启;启用值班台必须同时启用规则 worker,整个 Bell 监听地址必须为显式回环。规则和 token 文件均在仓库外,规则文件结构如下(示例值不是客户配置):
```json
{"version":1,"rules":[{"tenant_id":1,"site_id":1,"rule_key":"zone-entry","display_name":"区域闯入","event_kind":"zone_entry","minimum_severity":"medium","enabled":true,"effective_from":"2026-08-11T00:00:00Z"}]}
```
```powershell
$env:BELL_ALERTS_ENABLED = 'true'
$env:BELL_ALERT_RULES_FILE = 'D:\private\bell-alert-rules.json'
$env:BELL_ALERT_CONSOLE_ENABLED = 'true'
$env:BELL_ALERT_CONSOLE_TOKEN_FILE = 'D:\private\bell-console.token'
$env:BELL_ALERT_CONSOLE_TENANT_ID = '1'
$env:BELL_ALERT_CONSOLE_SITE_ID = '1'
$env:BELL_ALERT_CONSOLE_ACTOR_REF = 'operator:local'
go -C Bell run ./cmd/bell-api
```
token 文件去除首尾换行后必须为 32~256 个非空白字符。浏览器访问 `http://127.0.0.1:8081/bell-console/` 后手动输入 token;页面只保存在内存,刷新即丢失。工程 API 冻结在 `docs/contracts/bell-alert-console-v1.openapi.json`,tenant/Site/actor 只来自启动上下文。
## 验证
```powershell
+123 -15
View File
@@ -11,6 +11,7 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@@ -19,23 +20,32 @@ import (
"github.com/jackc/pgx/v5/stdlib"
"yovision/bell/contracts"
"yovision/bell/internal/alert"
"yovision/bell/internal/audit"
"yovision/bell/internal/event"
"yovision/bell/internal/ingress"
"yovision/bell/internal/store"
bellweb "yovision/bell/web"
)
var version = "dev"
type configuration struct {
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
address string
dsn string
keyFile string
tlsCert string
tlsKey string
eventIngressEnabled bool
eventKeyFile string
forbiddenNamesFile string
alertsEnabled bool
alertRulesFile string
alertConsoleEnabled bool
alertConsoleTokenFile string
alertConsoleTenantID int64
alertConsoleSiteID int64
alertConsoleActorRef string
}
func main() {
@@ -48,13 +58,16 @@ func main() {
func loadConfiguration() (configuration, error) {
value := configuration{
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
address: envOr("BELL_HTTP_ADDR", "127.0.0.1:8081"),
dsn: os.Getenv("BELL_DB_DSN"),
keyFile: os.Getenv("BELL_AUDIT_KEYS_FILE"),
tlsCert: os.Getenv("BELL_TLS_CERT_FILE"),
tlsKey: os.Getenv("BELL_TLS_KEY_FILE"),
eventKeyFile: os.Getenv("BELL_EVENT_INGRESS_KEYS_FILE"),
forbiddenNamesFile: os.Getenv("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE"),
alertRulesFile: os.Getenv("BELL_ALERT_RULES_FILE"),
alertConsoleTokenFile: os.Getenv("BELL_ALERT_CONSOLE_TOKEN_FILE"),
alertConsoleActorRef: os.Getenv("BELL_ALERT_CONSOLE_ACTOR_REF"),
}
switch os.Getenv("BELL_EVENT_INGRESS_ENABLED") {
case "", "false":
@@ -89,6 +102,36 @@ func loadConfiguration() (configuration, error) {
return configuration{}, errors.New("BELL_EVIDENCE_FORBIDDEN_NAMES_FILE must be an absolute external path when event ingress is enabled")
}
}
value.alertsEnabled, err = strictBoolEnv("BELL_ALERTS_ENABLED")
if err != nil {
return configuration{}, err
}
value.alertConsoleEnabled, err = strictBoolEnv("BELL_ALERT_CONSOLE_ENABLED")
if err != nil {
return configuration{}, err
}
if value.alertsEnabled && (value.alertRulesFile == "" || !filepath.IsAbs(value.alertRulesFile)) {
return configuration{}, errors.New("BELL_ALERT_RULES_FILE must be an absolute external path when alerts are enabled")
}
if value.alertConsoleEnabled {
if !value.alertsEnabled || !loopback || os.Getenv("BELL_HTTP_ADDR") == "" {
return configuration{}, errors.New("Bell alert console requires alerts and an explicit loopback bind")
}
if value.alertConsoleTokenFile == "" || !filepath.IsAbs(value.alertConsoleTokenFile) {
return configuration{}, errors.New("BELL_ALERT_CONSOLE_TOKEN_FILE must be an absolute external path")
}
value.alertConsoleTenantID, err = positiveEnv("BELL_ALERT_CONSOLE_TENANT_ID")
if err != nil {
return configuration{}, err
}
value.alertConsoleSiteID, err = positiveEnv("BELL_ALERT_CONSOLE_SITE_ID")
if err != nil {
return configuration{}, err
}
if value.alertConsoleActorRef == "" {
return configuration{}, errors.New("BELL_ALERT_CONSOLE_ACTOR_REF is required")
}
}
return value, nil
}
@@ -156,6 +199,34 @@ func run(logger *slog.Logger) error {
}
mux.Handle(ingress.Path, eventHandler)
}
if cfg.alertsEnabled {
if err := repository.AlertReady(ctx); err != nil {
return err
}
rules, err := alert.LoadRules(cfg.alertRulesFile)
if err != nil {
return err
}
if err := alert.Publish(ctx, repository, rules); err != nil {
return err
}
go alert.RunWorker(ctx, repository, func(workerErr error) {
logger.Error("Bell alert worker retrying", "error", workerErr)
})
}
if cfg.alertConsoleEnabled {
token, err := loadConsoleToken(cfg.alertConsoleTokenFile)
if err != nil {
return err
}
console, err := bellweb.NewHandler(repository, bellweb.Config{
Token: token, TenantID: cfg.alertConsoleTenantID, SiteID: cfg.alertConsoleSiteID, ActorRef: cfg.alertConsoleActorRef,
})
if err != nil {
return err
}
console.Register(mux)
}
mux.HandleFunc("GET /healthz", func(writer http.ResponseWriter, _ *http.Request) {
writeStatus(writer, http.StatusOK, "ok")
})
@@ -170,6 +241,12 @@ func run(logger *slog.Logger) error {
return
}
}
if cfg.alertsEnabled {
if err := repository.AlertReady(request.Context()); err != nil {
writeStatus(writer, http.StatusServiceUnavailable, "not_ready")
return
}
}
writeStatus(writer, http.StatusOK, "ready")
})
server := &http.Server{Addr: cfg.address, Handler: mux, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}}
@@ -225,3 +302,34 @@ func envOr(name, fallback string) string {
}
return fallback
}
func strictBoolEnv(name string) (bool, error) {
switch os.Getenv(name) {
case "", "false":
return false, nil
case "true":
return true, nil
default:
return false, fmt.Errorf("%s must be true or false", name)
}
}
func positiveEnv(name string) (int64, error) {
value, err := strconv.ParseInt(os.Getenv(name), 10, 64)
if err != nil || value < 1 {
return 0, fmt.Errorf("%s must be a positive integer", name)
}
return value, nil
}
func loadConsoleToken(path string) (string, error) {
raw, err := os.ReadFile(path)
if err != nil || len(raw) > 4096 {
return "", errors.New("read Bell alert console token file")
}
value := strings.TrimSpace(string(raw))
if len(value) < 32 || len(value) > 256 || strings.ContainsAny(value, " \t\r\n") {
return "", errors.New("Bell alert console token must contain 32 to 256 non-whitespace characters")
}
return value, nil
}
+44
View File
@@ -1,6 +1,7 @@
package main
import (
"os"
"path/filepath"
"testing"
)
@@ -18,6 +19,49 @@ func TestConfigurationRequiresDatabaseAndExternalKey(t *testing.T) {
}
}
func TestAlertsAreDisabledByDefaultAndConsoleRequiresLoopbackContext(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "audit.json"))
value, err := loadConfiguration()
if err != nil || value.alertsEnabled || value.alertConsoleEnabled {
t.Fatalf("default alert configuration: %+v %v", value, err)
}
t.Setenv("BELL_ALERTS_ENABLED", "true")
if _, err := loadConfiguration(); err == nil {
t.Fatal("alerts without an external rule file were accepted")
}
t.Setenv("BELL_ALERT_RULES_FILE", filepath.Join(t.TempDir(), "rules.json"))
t.Setenv("BELL_ALERT_CONSOLE_ENABLED", "true")
if _, err := loadConfiguration(); err == nil {
t.Fatal("console without external context was accepted")
}
t.Setenv("BELL_ALERT_CONSOLE_TOKEN_FILE", filepath.Join(t.TempDir(), "token"))
t.Setenv("BELL_ALERT_CONSOLE_TENANT_ID", "1")
t.Setenv("BELL_ALERT_CONSOLE_SITE_ID", "2")
t.Setenv("BELL_ALERT_CONSOLE_ACTOR_REF", "operator:local")
t.Setenv("BELL_HTTP_ADDR", "127.0.0.1:8081")
value, err = loadConfiguration()
if err != nil || !value.alertsEnabled || !value.alertConsoleEnabled {
t.Fatalf("valid alert console configuration: %+v %v", value, err)
}
}
func TestLoadConsoleToken(t *testing.T) {
path := filepath.Join(t.TempDir(), "token")
if err := os.WriteFile(path, []byte("12345678901234567890123456789012\n"), 0o600); err != nil {
t.Fatal(err)
}
if value, err := loadConsoleToken(path); err != nil || len(value) != 32 {
t.Fatalf("valid token: %q %v", value, err)
}
if err := os.WriteFile(path, []byte("short"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadConsoleToken(path); err == nil {
t.Fatal("short token was accepted")
}
}
func TestConfigurationRequiresTLSOutsideLoopback(t *testing.T) {
t.Setenv("BELL_DB_DSN", "postgres://bell@127.0.0.1/yovision")
t.Setenv("BELL_AUDIT_KEYS_FILE", filepath.Join(t.TempDir(), "keys.json"))
+221
View File
@@ -0,0 +1,221 @@
// Package alert owns Bell's small, deterministic rule and Alert domain.
package alert
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"strings"
"time"
"unicode/utf8"
)
const (
DefaultPageSize = 16
MaxPageSize = 100
MaxRulesFile = 256 << 10
)
var (
ErrNotFound = errors.New("alert not found")
ErrIdempotencyConflict = errors.New("idempotency key was used for another command")
ruleKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{2,63}$`)
eventKindPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{2,63}$`)
actorRefPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:@/-]*$`)
idempotencyKeyPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`)
)
type RuleSpec struct {
TenantID int64 `json:"tenant_id"`
SiteID *int64 `json:"site_id,omitempty"`
RuleKey string `json:"rule_key"`
DisplayName string `json:"display_name"`
EventKind string `json:"event_kind"`
MinimumSeverity string `json:"minimum_severity"`
Enabled bool `json:"enabled"`
EffectiveFrom time.Time `json:"effective_from"`
}
func (r RuleSpec) Validate() error {
if r.TenantID < 1 || (r.SiteID != nil && *r.SiteID < 1) {
return errors.New("rule scope must use positive identifiers")
}
if !ruleKeyPattern.MatchString(r.RuleKey) || !eventKindPattern.MatchString(r.EventKind) {
return errors.New("rule key or event kind is invalid")
}
if strings.TrimSpace(r.DisplayName) == "" || utf8.RuneCountInString(r.DisplayName) > 120 || r.EffectiveFrom.IsZero() {
return errors.New("rule name or effective time is invalid")
}
if _, ok := SeverityRank(r.MinimumSeverity); !ok {
return errors.New("rule minimum severity is invalid")
}
return nil
}
func (r RuleSpec) Digest() ([sha256.Size]byte, error) {
value, err := json.Marshal(r)
if err != nil {
return [sha256.Size]byte{}, err
}
return sha256.Sum256(value), nil
}
type rulesDocument struct {
Version int `json:"version"`
Rules []RuleSpec `json:"rules"`
}
func LoadRules(path string) ([]RuleSpec, error) {
file, err := os.Open(path)
if err != nil {
return nil, errors.New("open Bell alert rules file")
}
defer file.Close()
info, err := file.Stat()
if err != nil || info.Size() > MaxRulesFile {
return nil, errors.New("Bell alert rules file is too large")
}
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
var document rulesDocument
if err := decoder.Decode(&document); err != nil {
return nil, errors.New("decode Bell alert rules file")
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return nil, errors.New("Bell alert rules file contains multiple JSON values")
}
if document.Version != 1 || len(document.Rules) < 1 || len(document.Rules) > 256 {
return nil, errors.New("Bell alert rules file must contain 1 to 256 v1 rules")
}
seen := make(map[string]bool, len(document.Rules))
for _, rule := range document.Rules {
if err := rule.Validate(); err != nil {
return nil, fmt.Errorf("invalid Bell alert rule %q: %w", rule.RuleKey, err)
}
key := fmt.Sprintf("%d:%s", rule.TenantID, rule.RuleKey)
if seen[key] {
return nil, fmt.Errorf("duplicate Bell alert rule %q", rule.RuleKey)
}
seen[key] = true
}
return document.Rules, nil
}
func SeverityRank(value string) (int, bool) {
for rank, severity := range []string{"low", "medium", "high", "critical"} {
if value == severity {
return rank, true
}
}
return 0, false
}
func ValidActorRef(value string) bool {
return len(value) <= 80 && actorRefPattern.MatchString(value)
}
func ValidIdempotencyKey(value string) bool {
return len(value) >= 8 && len(value) <= 128 && idempotencyKeyPattern.MatchString(value)
}
type Summary struct {
ID string `json:"id"`
Severity string `json:"severity"`
Title string `json:"title"`
State string `json:"state"`
RuleKey string `json:"rule_key"`
RuleVersion int `json:"rule_version"`
CreatedAt time.Time `json:"created_at"`
}
type EventRef struct {
ID string `json:"id"`
DeviceID int64 `json:"device_id"`
Kind string `json:"kind"`
Severity string `json:"severity"`
OccurredAt time.Time `json:"occurred_at"`
}
type Transition struct {
Sequence int `json:"sequence"`
FromState *string `json:"from_state"`
ToState string `json:"to_state"`
ActorRef string `json:"actor_ref"`
Note *string `json:"note"`
OccurredAt time.Time `json:"occurred_at"`
}
type Detail struct {
Summary
Events []EventRef `json:"events"`
Transitions []Transition `json:"transitions"`
EvidenceStatus string `json:"evidence_status"`
DeliveryStatus string `json:"delivery_status"`
}
type Page struct {
Items []Summary `json:"items"`
NextCursor *string `json:"next_cursor"`
}
type CommandResponse struct {
AlertID string `json:"alert_id"`
State string `json:"state"`
ActorRef string `json:"actor_ref"`
OccurredAt time.Time `json:"occurred_at"`
Code string `json:"code,omitempty"`
}
type Repository interface {
AlertReady(context.Context) error
PublishRule(context.Context, RuleSpec) (bool, error)
EvaluateNext(context.Context) (bool, error)
ListAlerts(context.Context, int64, int64, string, int, string) (Page, error)
GetAlert(context.Context, int64, int64, string) (Detail, error)
Command(context.Context, int64, int64, string, string, string, string, *string) (int, CommandResponse, error)
}
func Publish(ctx context.Context, repository Repository, rules []RuleSpec) error {
for _, rule := range rules {
if _, err := repository.PublishRule(ctx, rule); err != nil {
return fmt.Errorf("publish Bell alert rule %q: %w", rule.RuleKey, err)
}
}
return nil
}
func RunWorker(ctx context.Context, repository Repository, onError func(error)) {
idle := time.NewTicker(250 * time.Millisecond)
defer idle.Stop()
for {
worked, err := repository.EvaluateNext(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
if onError != nil {
onError(err)
}
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
continue
}
if worked {
continue
}
select {
case <-ctx.Done():
return
case <-idle.C:
}
}
}
+43
View File
@@ -0,0 +1,43 @@
package alert
import (
"os"
"path/filepath"
"testing"
)
func TestLoadRulesRejectsUnknownAndDuplicateValues(t *testing.T) {
path := filepath.Join(t.TempDir(), "rules.json")
valid := `{"version":1,"rules":[{"tenant_id":1,"site_id":2,"rule_key":"zone-entry","display_name":"区域闯入","event_kind":"zone_entry","minimum_severity":"medium","enabled":true,"effective_from":"2026-08-11T00:00:00Z"}]}`
if err := os.WriteFile(path, []byte(valid), 0o600); err != nil {
t.Fatal(err)
}
rules, err := LoadRules(path)
if err != nil || len(rules) != 1 {
t.Fatalf("load valid rules: %v %#v", err, rules)
}
if err := os.WriteFile(path, []byte(`{"version":1,"unknown":true,"rules":[]}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadRules(path); err == nil {
t.Fatal("unknown rule document property was accepted")
}
if err := os.WriteFile(path, []byte(valid+` trailing`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadRules(path); err == nil {
t.Fatal("trailing rule file data was accepted")
}
}
func TestSeverityRankIsStable(t *testing.T) {
for index, value := range []string{"low", "medium", "high", "critical"} {
rank, ok := SeverityRank(value)
if !ok || rank != index {
t.Fatalf("rank %q: %d %v", value, rank, ok)
}
}
if _, ok := SeverityRank("urgent"); ok {
t.Fatal("unknown severity was accepted")
}
}
+355
View File
@@ -0,0 +1,355 @@
package store
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/oklog/ulid/v2"
"yovision/bell/internal/alert"
)
func (p *Postgres) AlertReady(ctx context.Context) error {
var version int64
if err := p.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0) FROM bell.schema_migrations`).Scan(&version); err != nil || version < 6 {
return errors.New("postgres Bell schema migration v6 is required for alerts")
}
for _, table := range []string{"rule_versions", "event_rule_sweeps", "rule_evaluations", "alerts", "alert_events", "alert_transitions", "alert_command_receipts"} {
var selectAllowed, insertAllowed, updateAllowed, deleteAllowed, truncateAllowed bool
if err := p.db.QueryRowContext(ctx, `SELECT
has_table_privilege(current_user,$1,'SELECT'), has_table_privilege(current_user,$1,'INSERT'),
has_table_privilege(current_user,$1,'UPDATE'), has_table_privilege(current_user,$1,'DELETE'),
has_table_privilege(current_user,$1,'TRUNCATE')`, "bell."+table).Scan(
&selectAllowed, &insertAllowed, &updateAllowed, &deleteAllowed, &truncateAllowed,
); err != nil || !selectAllowed || !insertAllowed || updateAllowed || deleteAllowed || truncateAllowed {
return fmt.Errorf("Bell runtime alert privileges violate append-only boundary for %s", table)
}
}
return nil
}
func (p *Postgres) PublishRule(ctx context.Context, rule alert.RuleSpec) (bool, error) {
if err := rule.Validate(); err != nil {
return false, err
}
digest, err := rule.Digest()
if err != nil {
return false, err
}
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return false, errors.New("begin Bell rule publish")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("rule:%d:%s", rule.TenantID, rule.RuleKey)); err != nil {
return false, errors.New("lock Bell rule key")
}
var existing string
err = tx.QueryRowContext(ctx, `SELECT id FROM bell.rule_versions WHERE tenant_id=$1 AND rule_key=$2 AND config_hash=$3`, rule.TenantID, rule.RuleKey, digest[:]).Scan(&existing)
if err == nil {
return false, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return false, errors.New("read Bell rule version")
}
var version int
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(version),0)+1 FROM bell.rule_versions WHERE tenant_id=$1 AND rule_key=$2`, rule.TenantID, rule.RuleKey).Scan(&version); err != nil {
return false, errors.New("allocate Bell rule version")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.rule_versions(
id,tenant_id,site_id,rule_key,version,display_name,event_kind,minimum_severity,enabled,effective_from,config_hash
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)`, newPrefixedID("ruv_"), rule.TenantID, rule.SiteID,
rule.RuleKey, version, rule.DisplayName, rule.EventKind, rule.MinimumSeverity, rule.Enabled, rule.EffectiveFrom, digest[:]); err != nil {
return false, fmt.Errorf("insert Bell rule version: %w", err)
}
if err := tx.Commit(); err != nil {
return false, errors.New("commit Bell rule version")
}
return true, nil
}
func (p *Postgres) EvaluateNext(ctx context.Context) (bool, error) {
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return false, errors.New("begin Bell rule evaluation")
}
defer tx.Rollback()
var eventID, kind, severity string
var tenantID, siteID, deviceID int64
var occurredAt time.Time
err = tx.QueryRowContext(ctx, `SELECT e.id,e.tenant_id,e.site_id,e.device_id,e.kind,e.severity,e.occurred_at
FROM bell.events e WHERE NOT EXISTS(SELECT 1 FROM bell.event_rule_sweeps s WHERE s.event_id=e.id)
ORDER BY e.created_at,e.id LIMIT 1`).Scan(&eventID, &tenantID, &siteID, &deviceID, &kind, &severity, &occurredAt)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, errors.New("select Bell event for rules")
}
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "event-rule:"+eventID); err != nil {
return false, errors.New("lock Bell event evaluation")
}
result, err := tx.ExecContext(ctx, `INSERT INTO bell.event_rule_sweeps(event_id) VALUES($1) ON CONFLICT DO NOTHING`, eventID)
if err != nil {
return false, errors.New("claim Bell event evaluation")
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected == 0 {
return false, tx.Commit()
}
rows, err := tx.QueryContext(ctx, `SELECT id,rule_key,version,display_name,event_kind,minimum_severity,enabled
FROM (SELECT DISTINCT ON (rule_key) id,rule_key,version,display_name,event_kind,minimum_severity,enabled,effective_from
FROM bell.rule_versions WHERE tenant_id=$1 AND effective_from <= $3 AND (site_id IS NULL OR site_id=$2)
ORDER BY rule_key,version DESC,effective_from DESC) latest ORDER BY rule_key`, tenantID, siteID, occurredAt)
if err != nil {
return false, errors.New("read effective Bell rules")
}
defer rows.Close()
type effectiveRule struct {
id, key, name, kind, minimum string
version int
enabled bool
}
rules := make([]effectiveRule, 0)
for rows.Next() {
var rule effectiveRule
if err := rows.Scan(&rule.id, &rule.key, &rule.version, &rule.name, &rule.kind, &rule.minimum, &rule.enabled); err != nil {
return false, errors.New("scan effective Bell rule")
}
rules = append(rules, rule)
}
if err := rows.Err(); err != nil {
return false, errors.New("iterate effective Bell rules")
}
if err := rows.Close(); err != nil {
return false, errors.New("close effective Bell rules")
}
eventRank, _ := alert.SeverityRank(severity)
for _, rule := range rules {
matched, reason := true, "matched"
minimumRank, _ := alert.SeverityRank(rule.minimum)
switch {
case !rule.enabled:
matched, reason = false, "disabled"
case rule.kind != kind:
matched, reason = false, "event_kind"
case eventRank < minimumRank:
matched, reason = false, "severity"
}
value := "no_match"
if matched {
value = "matched"
}
evaluationID := newPrefixedID("eva_")
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.rule_evaluations(id,event_id,rule_version_id,result,reason) VALUES($1,$2,$3,$4,$5)`, evaluationID, eventID, rule.id, value, reason); err != nil {
return false, fmt.Errorf("append Bell rule evaluation: %w", err)
}
if !matched {
continue
}
alertID := newPrefixedID("alt_")
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alerts(id,tenant_id,site_id,rule_evaluation_id,rule_version_id,severity,title) VALUES($1,$2,$3,$4,$5,$6,$7)`, alertID, tenantID, siteID, evaluationID, rule.id, severity, rule.name); err != nil {
return false, errors.New("create Bell alert")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_events(alert_id,event_id) VALUES($1,$2)`, alertID, eventID); err != nil {
return false, errors.New("link Bell alert event")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_transitions(id,alert_id,sequence,from_state,to_state,actor_ref) VALUES($1,$2,1,NULL,'open','system:rule-worker')`, newPrefixedID("trn_"), alertID); err != nil {
return false, errors.New("open Bell alert")
}
_ = deviceID
}
if err := tx.Commit(); err != nil {
return false, errors.New("commit Bell rule evaluation")
}
return true, nil
}
func (p *Postgres) ListAlerts(ctx context.Context, tenantID, siteID int64, state string, limit int, cursor string) (alert.Page, error) {
if limit < 1 || limit > alert.MaxPageSize {
return alert.Page{}, errors.New("invalid alert page size")
}
if state != "" && state != "open" && state != "acknowledged" && state != "closed" {
return alert.Page{}, errors.New("invalid alert state")
}
rows, err := p.db.QueryContext(ctx, `SELECT a.id,a.severity,a.title,t.to_state,r.rule_key,r.version,a.created_at
FROM bell.alerts a JOIN bell.rule_versions r ON r.id=a.rule_version_id
JOIN LATERAL(SELECT to_state FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
WHERE a.tenant_id=$1 AND a.site_id=$2 AND ($3='' OR t.to_state=$3)
AND ($4='' OR (a.created_at,a.id) < (SELECT c.created_at,c.id FROM bell.alerts c WHERE c.id=$4 AND c.tenant_id=$1 AND c.site_id=$2))
ORDER BY a.created_at DESC,a.id DESC LIMIT $5`, tenantID, siteID, state, cursor, limit+1)
if err != nil {
return alert.Page{}, errors.New("list Bell alerts")
}
defer rows.Close()
page := alert.Page{Items: make([]alert.Summary, 0, limit)}
for rows.Next() {
var item alert.Summary
if err := rows.Scan(&item.ID, &item.Severity, &item.Title, &item.State, &item.RuleKey, &item.RuleVersion, &item.CreatedAt); err != nil {
return alert.Page{}, errors.New("scan Bell alert list")
}
page.Items = append(page.Items, item)
}
if err := rows.Err(); err != nil {
return alert.Page{}, errors.New("iterate Bell alert list")
}
if len(page.Items) > limit {
cursor := page.Items[limit-1].ID
page.NextCursor = &cursor
page.Items = page.Items[:limit]
}
return page, nil
}
func (p *Postgres) GetAlert(ctx context.Context, tenantID, siteID int64, alertID string) (alert.Detail, error) {
var detail alert.Detail
err := p.db.QueryRowContext(ctx, `SELECT a.id,a.severity,a.title,t.to_state,r.rule_key,r.version,a.created_at
FROM bell.alerts a JOIN bell.rule_versions r ON r.id=a.rule_version_id
JOIN LATERAL(SELECT to_state FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
WHERE a.tenant_id=$1 AND a.site_id=$2 AND a.id=$3`, tenantID, siteID, alertID).Scan(
&detail.ID, &detail.Severity, &detail.Title, &detail.State, &detail.RuleKey, &detail.RuleVersion, &detail.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return alert.Detail{}, alert.ErrNotFound
}
if err != nil {
return alert.Detail{}, errors.New("read Bell alert")
}
detail.EvidenceStatus = "not_enabled"
detail.DeliveryStatus = "not_enabled"
detail.Events = []alert.EventRef{}
detail.Transitions = []alert.Transition{}
rows, err := p.db.QueryContext(ctx, `SELECT e.id,e.device_id,e.kind,e.severity,e.occurred_at FROM bell.alert_events ae JOIN bell.events e ON e.id=ae.event_id WHERE ae.alert_id=$1 ORDER BY e.occurred_at,e.id`, alertID)
if err != nil {
return alert.Detail{}, errors.New("read Bell alert events")
}
for rows.Next() {
var value alert.EventRef
if err := rows.Scan(&value.ID, &value.DeviceID, &value.Kind, &value.Severity, &value.OccurredAt); err != nil {
rows.Close()
return alert.Detail{}, errors.New("scan Bell alert event")
}
detail.Events = append(detail.Events, value)
}
if err := rows.Close(); err != nil {
return alert.Detail{}, errors.New("close Bell alert events")
}
rows, err = p.db.QueryContext(ctx, `SELECT sequence,from_state,to_state,actor_ref,note,occurred_at FROM bell.alert_transitions WHERE alert_id=$1 ORDER BY sequence`, alertID)
if err != nil {
return alert.Detail{}, errors.New("read Bell alert transitions")
}
defer rows.Close()
for rows.Next() {
var value alert.Transition
if err := rows.Scan(&value.Sequence, &value.FromState, &value.ToState, &value.ActorRef, &value.Note, &value.OccurredAt); err != nil {
return alert.Detail{}, errors.New("scan Bell alert transition")
}
detail.Transitions = append(detail.Transitions, value)
}
return detail, rows.Err()
}
func (p *Postgres) Command(ctx context.Context, tenantID, siteID int64, alertID, command, key, actorRef string, note *string) (int, alert.CommandResponse, error) {
if (command != "ack" && command != "close") || tenantID < 1 || siteID < 1 || !alert.ValidIdempotencyKey(key) || !alert.ValidActorRef(actorRef) {
return 0, alert.CommandResponse{}, errors.New("invalid Bell alert command")
}
if note != nil && len([]rune(*note)) > 500 {
return 0, alert.CommandResponse{}, errors.New("Bell alert command note is too long")
}
digest := sha256.Sum256([]byte(strings.Join([]string{alertID, command, actorRef, valueOrEmpty(note)}, "\x00")))
tx, err := p.db.BeginTx(ctx, nil)
if err != nil {
return 0, alert.CommandResponse{}, errors.New("begin Bell alert command")
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, fmt.Sprintf("alert-key:%d:%s", tenantID, key)); err != nil {
return 0, alert.CommandResponse{}, errors.New("lock Bell alert command key")
}
var storedHash, storedBody []byte
var storedStatus int
err = tx.QueryRowContext(ctx, `SELECT command_hash,response_status,response_body::text FROM bell.alert_command_receipts WHERE tenant_id=$1 AND idempotency_key=$2`, tenantID, key).Scan(&storedHash, &storedStatus, &storedBody)
if err == nil {
if !bytes.Equal(storedHash, digest[:]) {
return 0, alert.CommandResponse{}, alert.ErrIdempotencyConflict
}
var response alert.CommandResponse
if err := json.Unmarshal(storedBody, &response); err != nil {
return 0, alert.CommandResponse{}, errors.New("decode Bell alert command receipt")
}
return storedStatus, response, tx.Commit()
}
if !errors.Is(err, sql.ErrNoRows) {
return 0, alert.CommandResponse{}, errors.New("read Bell alert command receipt")
}
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, "alert:"+alertID); err != nil {
return 0, alert.CommandResponse{}, errors.New("lock Bell alert")
}
var current, currentActor string
var currentTime time.Time
var sequence int
err = tx.QueryRowContext(ctx, `SELECT t.to_state,t.actor_ref,t.occurred_at,t.sequence FROM bell.alerts a
JOIN LATERAL(SELECT to_state,actor_ref,occurred_at,sequence FROM bell.alert_transitions WHERE alert_id=a.id ORDER BY sequence DESC LIMIT 1)t ON true
WHERE a.id=$1 AND a.tenant_id=$2 AND a.site_id=$3`, alertID, tenantID, siteID).Scan(&current, &currentActor, &currentTime, &sequence)
if errors.Is(err, sql.ErrNoRows) {
return 0, alert.CommandResponse{}, alert.ErrNotFound
}
if err != nil {
return 0, alert.CommandResponse{}, errors.New("read Bell alert state")
}
status := 200
response := alert.CommandResponse{AlertID: alertID, State: current, ActorRef: currentActor, OccurredAt: currentTime}
allowed := (command == "ack" && current == "open") || (command == "close" && current == "acknowledged")
if !allowed {
status = 409
switch {
case command == "ack" && (current == "acknowledged" || current == "closed"):
response.Code = "already_acknowledged"
_ = tx.QueryRowContext(ctx, `SELECT actor_ref,occurred_at FROM bell.alert_transitions WHERE alert_id=$1 AND to_state='acknowledged' ORDER BY sequence LIMIT 1`, alertID).Scan(&response.ActorRef, &response.OccurredAt)
case command == "close" && current == "open":
response.Code = "acknowledgement_required"
case command == "close" && current == "closed":
response.Code = "already_closed"
default:
response.Code = "invalid_state"
}
} else {
next := "acknowledged"
if command == "close" {
next = "closed"
}
response.State = next
response.ActorRef = ""
response.OccurredAt = time.Time{}
if err := tx.QueryRowContext(ctx, `INSERT INTO bell.alert_transitions(id,alert_id,sequence,from_state,to_state,actor_ref,note)
VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING actor_ref,occurred_at`,
newPrefixedID("trn_"), alertID, sequence+1, current, next, actorRef, note).Scan(&response.ActorRef, &response.OccurredAt); err != nil {
return 0, alert.CommandResponse{}, errors.New("append Bell alert transition")
}
}
body, err := json.Marshal(response)
if err != nil {
return 0, alert.CommandResponse{}, errors.New("encode Bell alert command response")
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bell.alert_command_receipts(tenant_id,idempotency_key,command_hash,response_status,response_body) VALUES($1,$2,$3,$4,$5::jsonb)`, tenantID, key, digest[:], status, body); err != nil {
return 0, alert.CommandResponse{}, errors.New("append Bell alert command receipt")
}
if err := tx.Commit(); err != nil {
return 0, alert.CommandResponse{}, errors.New("commit Bell alert command")
}
return status, response, nil
}
func newPrefixedID(prefix string) string { return prefix + ulid.Make().String() }
func valueOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}
+174
View File
@@ -0,0 +1,174 @@
package store
import (
"context"
"crypto/sha256"
"database/sql"
"errors"
"fmt"
"os"
"sync"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"yovision/bell/internal/alert"
)
func TestPostgresAlertRuleEvaluationAndFirstAckWins(t *testing.T) {
dsn := os.Getenv("YOVISION_TEST_BELL_POSTGRES_DSN")
adminDSN := os.Getenv("YOVISION_TEST_POSTGRES_ADMIN_DSN")
if dsn == "" || adminDSN == "" {
t.Skip("Bell runtime and admin PostgreSQL DSNs are not set")
}
ctx := context.Background()
admin, err := sql.Open("pgx", adminDSN)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
defer db.Close()
repository, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
if err := repository.AlertReady(ctx); err != nil {
t.Fatal(err)
}
siteID := int64(902)
rule := alert.RuleSpec{TenantID: 901, SiteID: &siteID, RuleKey: "zone-entry", DisplayName: "区域闯入", EventKind: "zone_entry", MinimumSeverity: "medium", Enabled: true, EffectiveFrom: time.Now().Add(-time.Hour).UTC()}
created, err := repository.PublishRule(ctx, rule)
if err != nil || !created {
t.Fatalf("publish rule: created=%v err=%v", created, err)
}
created, err = repository.PublishRule(ctx, rule)
if err != nil || created {
t.Fatalf("idempotent rule publish: created=%v err=%v", created, err)
}
eventID := "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2Q"
payload := fmt.Sprintf(`{"id":%q,"tenant_id":901,"site_id":902,"device_id":903,"source_event_id":"T020-EVENT-1","kind":"zone_entry","severity":"high"}`, eventID)
payloadHash := sha256.Sum256([]byte(payload))
if _, err := admin.ExecContext(ctx, `INSERT INTO bell.events(id,tenant_id,site_id,device_id,source_event_id,kind,severity,occurred_at,detected_at,payload_hash,payload)
VALUES($1,901,902,903,'T020-EVENT-1','zone_entry','high',clock_timestamp(),clock_timestamp(),$3,$2::jsonb)`, eventID, payload, payloadHash[:]); err != nil {
t.Fatal(err)
}
noMatchID := "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2R"
noMatchPayload := fmt.Sprintf(`{"id":%q,"tenant_id":901,"site_id":902,"device_id":903,"source_event_id":"T020-EVENT-2","kind":"crowd","severity":"high"}`, noMatchID)
noMatchHash := sha256.Sum256([]byte(noMatchPayload))
if _, err := admin.ExecContext(ctx, `INSERT INTO bell.events(id,tenant_id,site_id,device_id,source_event_id,kind,severity,occurred_at,detected_at,payload_hash,payload)
VALUES($1,901,902,903,'T020-EVENT-2','crowd','high',clock_timestamp(),clock_timestamp(),$3,$2::jsonb)`, noMatchID, noMatchPayload, noMatchHash[:]); err != nil {
t.Fatal(err)
}
var evaluators sync.WaitGroup
errorsSeen := make(chan error, 8)
for index := 0; index < 8; index++ {
evaluators.Add(1)
go func() {
defer evaluators.Done()
for attempts := 0; attempts < 32; attempts++ {
worked, err := repository.EvaluateNext(ctx)
if err != nil {
errorsSeen <- err
return
}
if !worked {
return
}
}
}()
}
evaluators.Wait()
close(errorsSeen)
for err := range errorsSeen {
t.Error(err)
}
var alertID string
var alertCount, evaluationCount, sweepCount int
if err := db.QueryRowContext(ctx, `SELECT
(SELECT count(*) FROM bell.alerts WHERE tenant_id=901 AND site_id=902),
(SELECT count(*) FROM bell.rule_evaluations e JOIN bell.events v ON v.id=e.event_id WHERE v.tenant_id=901),
(SELECT count(*) FROM bell.event_rule_sweeps s JOIN bell.events v ON v.id=s.event_id WHERE v.tenant_id=901),
(SELECT id FROM bell.alerts WHERE tenant_id=901 AND site_id=902)`).Scan(&alertCount, &evaluationCount, &sweepCount, &alertID); err != nil {
t.Fatal(err)
}
if alertCount != 1 || evaluationCount != 2 || sweepCount != 2 {
t.Fatalf("evaluation did not converge: alerts=%d evaluations=%d sweeps=%d", alertCount, evaluationCount, sweepCount)
}
preStatus, preResponse, err := repository.Command(ctx, 901, 902, alertID, "close", "close-before-ack", "operator:closer", nil)
if err != nil || preStatus != 409 || preResponse.Code != "acknowledgement_required" || preResponse.State != "open" {
t.Fatalf("close-before-ack: status=%d response=%+v err=%v", preStatus, preResponse, err)
}
type outcome struct {
status int
response alert.CommandResponse
err error
}
results := make(chan outcome, 8)
var acknowledgers sync.WaitGroup
for index := 0; index < 8; index++ {
acknowledgers.Add(1)
go func(index int) {
defer acknowledgers.Done()
status, response, err := repository.Command(ctx, 901, 902, alertID, "ack", fmt.Sprintf("ack-key-%02d", index), fmt.Sprintf("operator:%d", index), nil)
results <- outcome{status, response, err}
}(index)
}
acknowledgers.Wait()
close(results)
winners := 0
var winner alert.CommandResponse
losers := make([]alert.CommandResponse, 0, 7)
for result := range results {
if result.err != nil {
t.Fatal(result.err)
}
if result.status == 200 {
winners++
winner = result.response
} else if result.status == 409 {
losers = append(losers, result.response)
}
}
if winners != 1 || len(losers) != 7 {
t.Fatalf("ack winners=%d losers=%d", winners, len(losers))
}
for _, loser := range losers {
if loser.ActorRef != winner.ActorRef || !loser.OccurredAt.Equal(winner.OccurredAt) || loser.Code != "already_acknowledged" {
t.Fatalf("late ack did not expose first winner: winner=%+v loser=%+v", winner, loser)
}
}
status, replayed, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", nil)
if err != nil || status != 409 || replayed.ActorRef != winner.ActorRef {
t.Fatalf("first replay receipt: status=%d response=%+v err=%v", status, replayed, err)
}
status2, replayed2, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", nil)
if err != nil || status2 != status || replayed2 != replayed {
t.Fatalf("stable receipt replay: status=%d response=%+v err=%v", status2, replayed2, err)
}
note := "different"
if _, _, err := repository.Command(ctx, 901, 902, alertID, "ack", "ack-replay-key", "operator:replay", &note); !errors.Is(err, alert.ErrIdempotencyConflict) {
t.Fatalf("expected idempotency conflict, got %v", err)
}
closeStatus, closeResponse, err := repository.Command(ctx, 901, 902, alertID, "close", "close-after-ack", "operator:closer", nil)
if err != nil || closeStatus != 200 || closeResponse.State != "closed" || closeResponse.ActorRef != "operator:closer" {
t.Fatalf("close-after-ack: status=%d response=%+v err=%v", closeStatus, closeResponse, err)
}
if _, err := db.ExecContext(ctx, `UPDATE bell.alert_transitions SET actor_ref=actor_ref WHERE alert_id=$1`, alertID); err == nil {
t.Fatal("runtime updated immutable alert transition")
}
reopened, err := OpenPostgres(ctx, db)
if err != nil {
t.Fatal(err)
}
detail, err := reopened.GetAlert(ctx, 901, 902, alertID)
if err != nil || detail.State != "closed" || len(detail.Transitions) != 3 || detail.EvidenceStatus != "not_enabled" {
t.Fatalf("restart detail: %+v %v", detail, err)
}
}
+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)
}
}