Files
Cyrene_v2/internal/services/gateway/admin_write_reminder_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

186 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}