Files
Cyrene_v2/internal/services/gateway/admin_backthink_push_test.go
T
AskaEth decf59c1b4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,admin-web): Skill 运行时安装 + backthink 打扰原因 + 建提醒入口(P0-19/P0-20/P0-22,审计 §4.3)
- P0-19:POST /admin/plugins/install 支持 `kind:"skill"`(此前一律 400)——yaml 或 path 二选一、
  name 一致性校验、显式错误码(bad_request/name_mismatch/skill_parse_failed/name_conflict)、
  装后以 kind:"skill" 出现在 GET /admin/plugins;**文件来源解析错误绝不回显文件内容**(变异验证实测确认
  该泄露向量真实存在:yaml 解码器会带出出错值前几字符 ⇒ 只回显路径 + 通用原因,细节进日志)
- P0-20:AdminBackthinkItem 补 route/detail/bypassed(DB 早已存,零迁移),面板 Backthink 展示
- P0-22:新增 POST /admin/automations/reminders(owner-only + 审计),at/message 必填、RFC3339 校验、
  409 already_exists;过去时间不作 400(下次轮询立即触发)
- 顺手修真缺陷:新建 id 原用 time.Now().UnixNano(),Windows 时钟粒度粗到毫秒 ⇒ 并行创建撞 id 得假 409;
  改用 crypto/rand 128 位 hex(仓库既有 skills/store.go 范式)
- 复核:gateway 包 build/vet/测试绿 + 前端 tsc 0 错;新增 12 用例(含 8 条非法输入显式码);
  **4 条变异反证**均已回滚
- 未做(登记):WS 帧侧的 route/detail 未在前端展示(`lib/wsParse.ts` 3 行,需放行);skill 未落
  skills.Store(重启后消失,注入点在 cmd/cyrene);提醒无删除入口(CancelReminder 已存在、无路由)
2026-10-04 14:21:41 +08:00

103 lines
3.8 KiB
Go

package gateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
)
// listBackthinkItems 调用管理端点并把线上结构解码回来。
func listBackthinkItems(t *testing.T, ts *httptest.Server, tok string) (int, []byte, []AdminBackthinkItem) {
t.Helper()
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/backthink", nil, tok)
var list struct {
Data []AdminBackthinkItem `json:"data"`
}
if resp.StatusCode == http.StatusOK {
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
}
return resp.StatusCode, raw, list.Data
}
// TestAdminBackthink_ExposesPushRouteDetailBypass 是 P0-20 的真断言:
// GET /api/v1/admin/backthink 必须把后端已存的 PushResult 里
// route/detail/bypassed 真的送到客户端 —— 面板才能解释「为什么打扰 /
// 为什么没打扰」。断言同时落在原始 JSON 与解码后的值上。
func TestAdminBackthink_ExposesPushRouteDetailBypass(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{
ID: 11, Trigger: "event", At: time.UnixMilli(1700), Conclusion: "服务器告警",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "2 active clients"},
},
{
ID: 10, Trigger: "heartbeat", At: time.UnixMilli(1600), Conclusion: "只是想了一下",
ShouldPush: false,
PushResult: backthink.PushResult{Pushed: false, Route: backthink.RouteRateLimited, Detail: "proactive push rate limited"},
},
}}
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
for _, want := range []string{
`"route":"active_client"`,
`"route":"rate_limited"`,
`"detail":"proactive push rate limited"`,
`"bypassed":true`,
} {
if !strings.Contains(string(raw), want) {
t.Fatalf("response is missing %s: %s", want, raw)
}
}
if len(items) != 2 {
t.Fatalf("items = %d, want 2", len(items))
}
got := items[0]
if got.ID != "11" || got.Route != string(backthink.RouteActiveClient) || got.Detail != "2 active clients" || !got.Bypassed || !got.Pushed {
t.Fatalf("item[0] = %+v", got)
}
silent := items[1]
if silent.Route != string(backthink.RouteRateLimited) || silent.Detail == "" || silent.Bypassed || silent.Pushed {
t.Fatalf("item[1] = %+v", silent)
}
}
// TestAdminBackthink_OmitsEmptyRouteAndDetail 钉住「没有值就不编」:未尝试
// 推送(零值 PushResult)的记录不带 route/detail 键,而 bypassed 作为
// 布尔始终显式出现 —— 面板因此能区分「这次没有路由」与「字段没接线」。
func TestAdminBackthink_OmitsEmptyRouteAndDetail(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{ID: 9, Trigger: "idle", At: time.UnixMilli(900), Conclusion: "什么都没有", ShouldPush: false},
}}
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
it := items[0]
if it.Route != "" || it.Detail != "" || it.Bypassed || it.Pushed || it.ShouldNotify {
t.Fatalf("item = %+v, want zero push fields", it)
}
if strings.Contains(string(raw), `"route"`) || strings.Contains(string(raw), `"detail"`) {
t.Fatalf("empty route/detail must be omitted: %s", raw)
}
if !strings.Contains(string(raw), `"bypassed":false`) {
t.Fatalf("bypassed must always be present: %s", raw)
}
}