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