- 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 已存在、无路由)
103 lines
3.8 KiB
Go
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)
|
|
}
|
|
}
|