package gateway import ( "encoding/json" "net/http" "strings" "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" ) // 本文件锁定「空 id 条目删得掉」以及它**必须显式**: // // - `?id=` 仍要求非空(老契约); // - `?empty=1` 才表示「删无 id 的历史条目」,且只删这些条目; // - 绝不能把「id 缺失/为空」默认读成「删掉所有无 id 条目」—— // 那是一次没有任何字面证据的批量删除。 // TestAdminDeleteChannelAccountEmptyExplicit 走 `?empty=1` 删除无 id 的历史条目: // 文件里只剩带 id 的账号,且该平台被热重载。 func TestAdminDeleteChannelAccountEmptyExplicit(t *testing.T) { t.Parallel() path := t.TempDir() + "/channels.json" store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "qq-obv11": { {Config: map[string]any{"mode": "server", "listen_addr": ""}}, // 无 id 的历史条目 {ID: "9616b27a", Config: map[string]any{"mode": "client", "remote_url": "ws://x/y"}}, {ID: "conf1", Config: map[string]any{"mode": "client", "remote_url": "ws://x/z"}}, }, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("delete status = %d, want 200 (body=%s)", resp.StatusCode, body) } var res struct { Persisted bool `json:"persisted"` Platform string `json:"platform"` Empty bool `json:"empty"` Removed int `json:"removed"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil { t.Fatalf("decode delete response: %v (body=%s)", err, body) } if !res.Persisted || res.Platform != "qq-obv11" || !res.Empty || res.Removed != 1 { t.Fatalf("delete response = %+v, want persisted/qq-obv11/empty/removed=1", res) } if len(store.reloaded) != 1 || store.reloaded[0] != "qq-obv11" { t.Fatalf("reloaded = %v, want [qq-obv11]", store.reloaded) } accounts, warn := runtimeconfig.LoadChannels(path, nil) if warn != nil { t.Fatalf("load channels warn = %v", warn) } kept := accounts["qq-obv11"] if len(kept) != 2 { t.Fatalf("kept accounts = %+v, want the two id'd accounts only", kept) } for _, acct := range kept { if strings.TrimSpace(acct.ID) == "" { t.Fatalf("an id-less account survived the explicit empty delete: %+v", kept) } } if strings.Contains(string(body), "ws://x/y") || strings.Contains(string(body), "ws://x/z") { t.Fatalf("delete response leaked config values: %s", body) } } // TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag 是核心回归:没有任何 // 字面证据时,空 id 请求一律 400,且盘上的无 id 条目**原封不动**(一次都不许写)。 func TestAdminDeleteChannelAccountEmptyRequiresExplicitFlag(t *testing.T) { t.Parallel() for _, query := range []string{"", "?id=", "?id=%20", "?empty=", "?empty=0", "?empty=false"} { query := query t.Run("query="+query, func(t *testing.T) { t.Parallel() path := t.TempDir() + "/channels.json" store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "qq-obv11": {{Config: map[string]any{"mode": "server"}}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config"+query, nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } if len(store.reloaded) != 0 { t.Fatalf("reloaded = %v, want none", store.reloaded) } if store.accounts["qq-obv11"] == nil || len(store.accounts["qq-obv11"]) != 1 { t.Fatalf("accounts = %+v, want the id-less entry untouched", store.accounts) } }) } } // TestAdminDeleteChannelAccountRejectsBothParams 断言 id 与 empty=1 同时给出时 // 一律 400:一次请求只针对一种条目,不做「两个都删」这种猜测。 func TestAdminDeleteChannelAccountRejectsBothParams(t *testing.T) { t.Parallel() path := t.TempDir() + "/channels.json" store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "qq-obv11": { {Config: map[string]any{"mode": "server"}}, {ID: "9616b27a", Config: map[string]any{"mode": "client"}}, }, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a&empty=1", nil, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body) } if len(store.reloaded) != 0 || len(store.accounts["qq-obv11"]) != 2 { t.Fatalf("nothing may be deleted: reloaded=%v accounts=%+v", store.reloaded, store.accounts) } } // TestAdminDeleteChannelAccountByIdStillTargeted 老契约照旧:?id=<非空> 只删那一条, // 无 id 的历史条目**不受影响**。 func TestAdminDeleteChannelAccountByIdStillTargeted(t *testing.T) { t.Parallel() path := t.TempDir() + "/channels.json" store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "qq-obv11": { {Config: map[string]any{"mode": "server"}}, {ID: "9616b27a", Config: map[string]any{"mode": "client"}}, }, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?id=9616b27a", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } kept := store.accounts["qq-obv11"] if len(kept) != 1 || strings.TrimSpace(kept[0].ID) != "" { t.Fatalf("kept = %+v, want only the id-less historical entry", kept) } } // TestAdminDeleteChannelAccountsWithoutCandidatesIs404 断言显式请求了删除、但平台 // 上没有无 id 条目时诚实回 404(不写盘、不重载、不假装删过)。 func TestAdminDeleteChannelAccountsWithoutCandidatesIs404(t *testing.T) { t.Parallel() path := t.TempDir() + "/channels.json" store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "qq-obv11": {{ID: "9616b27a", Config: map[string]any{"mode": "client"}}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404 (body=%s)", resp.StatusCode, body) } if len(store.reloaded) != 0 { t.Fatalf("reloaded = %v, want none", store.reloaded) } if len(store.accounts["qq-obv11"]) != 1 { t.Fatalf("accounts = %+v, want untouched", store.accounts) } } // TestAdminDeleteChannelAccountEmptyDropsPlatformKey 断言只有无 id 条目时, // 删除后整个平台键消失(与按 id 删除的收尾口径一致)。 func TestAdminDeleteChannelAccountEmptyDropsPlatformKey(t *testing.T) { t.Parallel() path := t.TempDir() + "/channels.json" store := &stubChannelConfigStore{path: path, accounts: map[string][]config.ChannelAccount{ "qq-obv11": {{Config: map[string]any{"mode": "server"}}}, "telegram": {{ID: "conf0", Config: map[string]any{"bot_token": "tk"}}}, }} _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Channels: store}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/channels/qq-obv11/config?empty=1", nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) } if _, ok := store.accounts["qq-obv11"]; ok { t.Fatalf("qq-obv11 key survived an empty delete: %+v", store.accounts) } if len(store.accounts["telegram"]) != 1 { t.Fatalf("other platforms must be untouched: %+v", store.accounts) } if bytes := string(body); strings.Contains(bytes, "tk") { t.Fatalf("delete response leaked a token: %s", bytes) } }