feat(gateway,admin-web): Skill 运行时安装 + backthink 打扰原因 + 建提醒入口(P0-19/P0-20/P0-22,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 已存在、无路由)
This commit is contained in:
AskaEth committed 2026-10-04 14:21:41 +08:00
1 parent 3638ed7708
commit decf59c1b4
12 files changed
+956 -24

No files matched your search

@@ -0,0 +1,185 @@
package gateway
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
)
// reminderTestEnv 构建一个注入了真实 automations.Service 的网关
// (P0-22 之前该服务在生产里没有任何创建入口,提醒因此不可达)。
func reminderTestEnv(t *testing.T) (*httptest.Server, *automations.Service, string) {
t.Helper()
auto, err := automations.NewService(automations.Config{}, automations.Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("new automations service: %v", err)
}
_, ts, jwt := buildEnv(t, nil, Deps{Automations: auto})
return ts, auto, signAny(t, jwt, "acct", auth.RoleOwner)
}
// postReminder 调用提醒创建端点并返回状态码与原始响应体。
func postReminder(t *testing.T, ts *httptest.Server, tok string, body any) (int, []byte) {
t.Helper()
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/automations/reminders", body, tok)
return resp.StatusCode, raw
}
// TestAdminCreateReminder_AppearsInServiceList 是 P0-22 的真断言:
// POST /api/v1/admin/automations/reminders 之后,该提醒真的出现在
// ReminderStore(Service.Reminders / List)里,且时间与文案一致 ——
// 而不是只回一个 201。
func TestAdminCreateReminder_AppearsInServiceList(t *testing.T) {
t.Parallel()
ts, auto, tok := reminderTestEnv(t)
at := time.Now().Add(2 * time.Hour).Truncate(time.Second)
status, raw := postReminder(t, ts, tok, map[string]any{"id": "rem-drink", "at": at.Format(time.RFC3339), "message": "提醒主人喝水"})
if status != http.StatusCreated {
t.Fatalf("status = %d, want 201, body = %s", status, raw)
}
var envelope struct {
Data struct {
ID string `json:"id"`
At string `json:"at"`
Message string `json:"message"`
Kind string `json:"kind"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if envelope.Data.ID != "rem-drink" || envelope.Data.Kind != "reminder" || envelope.Data.Message != "提醒主人喝水" {
t.Fatalf("created reminder = %+v", envelope.Data)
}
if envelope.Data.At != at.Format(time.RFC3339) {
t.Fatalf("at = %q, want %q", envelope.Data.At, at.Format(time.RFC3339))
}
list := auto.Reminders()
if len(list) != 1 {
t.Fatalf("Reminders() = %+v, want exactly the created one", list)
}
if list[0].ID != "rem-drink" || list[0].Message != "提醒主人喝水" || !list[0].At.Equal(at) {
t.Fatalf("reminder in store = %+v", list[0])
}
// 管理读面(面板的自动化页)也必须看到它,且 kind 为 reminder。
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/automations", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("automations list status = %d, body = %s", resp.StatusCode, raw)
}
var listEnv struct {
Data []AdminAutomationRecord `json:"data"`
}
if err := json.Unmarshal(raw, &listEnv); err != nil {
t.Fatalf("decode automations list: %v (%s)", err, raw)
}
found := false
for _, rec := range listEnv.Data {
if rec.ID == "rem-drink" && rec.Kind == "reminder" && rec.Name == "提醒主人喝水" {
found = true
}
}
if !found {
t.Fatalf("reminder not visible in /admin/automations: %+v", listEnv.Data)
}
}
// TestAdminCreateReminder_GeneratesIDWhenOmitted 证明面板不必自己保证
// id 唯一:省略 id 时后端生成,且两次创建得到两条不同提醒。
func TestAdminCreateReminder_GeneratesIDWhenOmitted(t *testing.T) {
t.Parallel()
ts, auto, tok := reminderTestEnv(t)
at := time.Now().Add(time.Hour).Truncate(time.Second).Format(time.RFC3339)
ids := make([]string, 0, 2)
for i := 0; i < 2; i++ {
status, raw := postReminder(t, ts, tok, map[string]any{"at": at, "message": fmt.Sprintf("第 %d 次", i)})
if status != http.StatusCreated {
t.Fatalf("status = %d, want 201, body = %s", status, raw)
}
var envelope struct {
Data struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &envelope); err != nil {
t.Fatalf("decode: %v (%s)", err, raw)
}
if !strings.HasPrefix(envelope.Data.ID, "rem-") {
t.Fatalf("generated id = %q, want rem- prefix", envelope.Data.ID)
}
ids = append(ids, envelope.Data.ID)
}
if ids[0] == ids[1] {
t.Fatalf("generated ids collide: %q", ids[0])
}
if n := len(auto.Reminders()); n != 2 {
t.Fatalf("Reminders() = %d, want 2", n)
}
}
// TestAdminCreateReminder_InvalidInputReturns400 逐条钉住非法输入的显式
// 400;重复 id 是 409(已有同名提醒),绝不静默覆盖。
func TestAdminCreateReminder_InvalidInputReturns400(t *testing.T) {
t.Parallel()
ts, auto, tok := reminderTestEnv(t)
at := time.Now().Add(time.Hour).Truncate(time.Second)
cases := []struct {
name string
body map[string]any
want int
code string
}{
{"missing message", map[string]any{"at": at.Format(time.RFC3339)}, http.StatusBadRequest, "bad_request"},
{"missing at", map[string]any{"message": "x"}, http.StatusBadRequest, "bad_request"},
{"at not rfc3339", map[string]any{"at": "明天早上", "message": "x"}, http.StatusBadRequest, "invalid_at"},
{"at unix millis", map[string]any{"at": "1760000000000", "message": "x"}, http.StatusBadRequest, "invalid_at"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
status, raw := postReminder(t, ts, tok, tc.body)
if status != tc.want {
t.Fatalf("status = %d, want %d, body = %s", status, tc.want, raw)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode error envelope: %v (%s)", err, raw)
}
if envErr.Error.Code != tc.code {
t.Fatalf("error code = %q, want %q (%s)", envErr.Error.Code, tc.code, raw)
}
})
}
if n := len(auto.Reminders()); n != 0 {
t.Fatalf("reminders after rejected writes = %d, want 0", n)
}
body := map[string]any{"id": "dup", "at": at.Format(time.RFC3339), "message": "第一次"}
if status, raw := postReminder(t, ts, tok, body); status != http.StatusCreated {
t.Fatalf("first status = %d, body = %s", status, raw)
}
status, raw := postReminder(t, ts, tok, body)
if status != http.StatusConflict {
t.Fatalf("duplicate status = %d, want 409, body = %s", status, raw)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode duplicate envelope: %v (%s)", err, raw)
}
if envErr.Error.Code != "already_exists" {
t.Fatalf("duplicate code = %q, want already_exists (%s)", envErr.Error.Code, raw)
}
if n := len(auto.Reminders()); n != 1 {
t.Fatalf("reminders = %d, want 1 (duplicate must not overwrite)", n)
}
}