package gateway // 本文件锁定渠道静默窗口合并(M23)的读写端点 // (`GET|PUT /api/v1/admin/channels/coalesce`): // // ① 读侧如实回显:生效值(代码默认 + 盘上 default 段)+ 盘上原值 patch(缺位键不出现) // + 取值规则 + 「主人私聊永远不受影响」这条只读事实 + hot:true; // ② 写侧**三态**:键缺位 ⇒ 逐字节不动;合法值 ⇒ 采用且保留同文件其它键与其它会话; // 非法值(负数/未知键/类型错) ⇒ `400 invalid_payload` 且**盘上一个字节都不动**; // ③ 热生效是事实(同一 store 实例被桥每次发车前重读),响应 `hot:true`; // ④ RBAC 走 writeAuth(owner|admin),写落审计 `channels.coalesce`; // ⑤ 请求体里**没有任何**键能翻转「主人私聊不走窗口」——那由 actor 元数据决定。 import ( "encoding/json" "net/http" "os" "path/filepath" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" ) // coalesceEnv 是一个把 coalesce 配置文件放进临时数据目录的网关测试环境。 type coalesceEnv struct { url string path string jwt *auth.JWTService store *runtimeconfig.CoalesceStore } // coalesceStubChannels 是 ChannelConfigStore + CoalesceConfigStore 的生产形状替身: // 它把 coalesce 的读写直接委托给一个真的 runtimeconfig.CoalesceStore(因此落盘、 // 三态、热生效都是真实路径,而不是再写一套假的)。 type coalesceStubChannels struct { store *runtimeconfig.CoalesceStore } func (c *coalesceStubChannels) Path() string { return "" } func (c *coalesceStubChannels) Load() map[string][]config.ChannelAccount { return nil } func (c *coalesceStubChannels) Reload(string) error { return nil } func (c *coalesceStubChannels) CoalescePath() string { return c.store.Path() } func (c *coalesceStubChannels) CoalescePresent() bool { return c.store.Present() } func (c *coalesceStubChannels) CoalesceSnapshot() runtimeconfig.CoalesceFile { return c.store.Snapshot() } func (c *coalesceStubChannels) ApplyCoalesce(p runtimeconfig.CoalescePatch) (bool, error) { return c.store.Apply(p) } // newCoalesceEnv 构造环境:coalesce.json 落在 t.TempDir()/config/coalesce.json。 func newCoalesceEnv(t *testing.T) *coalesceEnv { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config", "coalesce.json") store := runtimeconfig.NewCoalesceStore(path, nil) deps := Deps{DataDir: dir, Channels: &coalesceStubChannels{store: store}} _, ts, jwt := buildEnv(t, cfgDefault(), deps) return &coalesceEnv{ url: ts.URL + "/api/v1/admin/channels/coalesce", path: path, jwt: jwt, store: store, } } // disk 读回盘上的原始字节(「非法值不落盘」的证据就是它与之前相等 / 文件仍不存在)。 func (e *coalesceEnv) disk(t *testing.T) ([]byte, bool) { t.Helper() raw, err := os.ReadFile(e.path) if err != nil { if os.IsNotExist(err) { return nil, false } t.Fatalf("read coalesce file: %v", err) } return raw, true } // view 解一次成功响应的 data。 func (e *coalesceEnv) view(t *testing.T, body []byte) AdminCoalesceView { t.Helper() var view AdminCoalesceView if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil { t.Fatalf("decode view: %v (body=%s)", err, body) } return view } func TestAdminCoalesceReadShowsDefaultsAndRules(t *testing.T) { t.Parallel() e := newCoalesceEnv(t) token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner) resp, body := doReq(t, http.MethodGet, e.url, nil, token) if resp.StatusCode != http.StatusOK { t.Fatalf("GET status = %d, want 200 (body=%s)", resp.StatusCode, body) } view := e.view(t, body) if !view.Hot || view.Present { t.Fatalf("view = %+v, want hot:true and present:false for a fresh deployment", view) } if view.OwnerPrivate != "never" { t.Fatalf("ownerPrivate = %q, want the read-only fact 'never'", view.OwnerPrivate) } // 缺位 ⇒ 生效值是代码默认(群聊窗口 2.0s / 绝对上限 6s / 单批 24)。 if view.Default.WindowMs != 2000 || view.Default.MaxWaitMs != 6000 || view.Default.MaxBatchMessages != 24 { t.Fatalf("default policy = %+v, want 2000/6000/24", view.Default) } if !view.Default.Enabled || !view.Default.ApplyToStrangers { t.Fatalf("default policy = %+v, want both switches on by default", view.Default) } if len(view.Patch) != 0 { t.Fatalf("patch = %v, want empty (nothing written on disk yet)", view.Patch) } if view.Rules.WindowMs != [2]int{runtimeconfig.CoalesceWindowMinMs, runtimeconfig.CoalesceWindowMaxMs} { t.Fatalf("rules.windowMs = %v, want [300 15000]", view.Rules.WindowMs) } if len(view.Rules.FlushReasons) == 0 { t.Fatalf("rules.flushReasons is empty, want the closed set of departure reasons") } } func TestAdminCoalesceWriteIsThreeState(t *testing.T) { t.Parallel() e := newCoalesceEnv(t) token := signAny(t, e.jwt, "acct-owner", auth.RoleOwner) // ① 合法值 ⇒ 采用并落盘。 resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, token) if resp.StatusCode != http.StatusOK { t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body) } if view := e.view(t, body); view.Default.WindowMs != 3000 || !view.Default.Enabled || !view.Present { t.Fatalf("after PUT default = %+v, want windowMs 3000, the rest at defaults and present:true", view.Default) } before, ok := e.disk(t) if !ok { t.Fatal("PUT reported success but no file was written") } // ② 缺位键 ⇒ 不动(同样的字节 ⇒ 连文件都不重写)。 resp, body = doRaw(t, http.MethodPut, e.url, `{}`, token) if resp.StatusCode != http.StatusOK { t.Fatalf("empty PUT status = %d, want 200", resp.StatusCode) } if after, _ := e.disk(t); string(after) != string(before) { t.Fatalf("empty patch rewrote the file:\nbefore=%s\nafter=%s", before, after) } // ③ 非法值(负数)⇒ 400 且盘上零变更。 resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":-5}}`, token) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("negative windowMs status = %d, want 400 (body=%s)", resp.StatusCode, body) } assertErrorCode(t, body, "invalid_payload") if after, _ := e.disk(t); string(after) != string(before) { t.Fatalf("invalid patch touched the file:\nbefore=%s\nafter=%s", before, after) } // ④ 未知键 ⇒ 400 且盘上零变更。 resp, body = doRaw(t, http.MethodPut, e.url, `{"default":{"windowMsTypo":1}}`, token) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("unknown key status = %d, want 400 (body=%s)", resp.StatusCode, body) } if after, _ := e.disk(t); string(after) != string(before) { t.Fatalf("unknown key touched the file:\nbefore=%s\nafter=%s", before, after) } } func TestAdminCoalesceConversationOverridesAndDelete(t *testing.T) { t.Parallel() e := newCoalesceEnv(t) token := signAny(t, e.jwt, "acct-admin", auth.RoleAdmin) // 每会话覆盖:只有给出的键被采用,其余跟随全局生效值。 resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":2500},"conversations":{"telegram|group:-20":{"enabled":false}}}`, token) if resp.StatusCode != http.StatusOK { t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body) } view := e.view(t, body) conv, ok := view.Conversations["telegram|group:-20"] if !ok { t.Fatalf("conversation override missing from the view: %+v", view.Conversations) } if conv.Enabled || conv.WindowMs != 2500 { t.Fatalf("conversation policy = %+v, want enabled:false and windowMs inherited (2500)", conv) } // null ⇒ 删掉该会话覆盖(回到全局)。 resp, body = doRaw(t, http.MethodPut, e.url, `{"conversations":{"telegram|group:-20":null}}`, token) if resp.StatusCode != http.StatusOK { t.Fatalf("delete override status = %d, want 200 (body=%s)", resp.StatusCode, body) } if view := e.view(t, body); len(view.Conversations) != 0 { t.Fatalf("conversations = %+v, want none after the null delete", view.Conversations) } } func TestAdminCoalesceWriteRequiresOwnerOrAdmin(t *testing.T) { t.Parallel() e := newCoalesceEnv(t) viewer := signAny(t, e.jwt, "acct-viewer", auth.RoleViewer) resp, body := doRaw(t, http.MethodPut, e.url, `{"default":{"windowMs":3000}}`, viewer) if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer PUT status = %d, want 403 (body=%s)", resp.StatusCode, body) } if _, ok := e.disk(t); ok { t.Fatal("a forbidden write still created the config file") } }