package gateway import ( "context" "encoding/json" "io" "net/http" "os" "path/filepath" "runtime" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/loader" personareg "git.yeij.top/AskaEth/Cyrene_v2/internal/persona/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // 本文件钉住阶段 3-G3D 的四条裁决:主人档案真实读写(10.4.5)、API key 真能 // 用来鉴权(15.4.2)、key 一律超管(15.4.3)、identity.systemPrompt 必填 // (08.4.2)。涉及落盘的用例先把工作目录切到临时目录,因此它们**不碰仓库里的 // configs/**;(t.Chdir 与 t.Parallel 互斥,这些用例刻意不加 t.Parallel)。 const ownerProfileURL = "/api/v1/admin/owner-profile" // liveOwnerRegistrar 是组合根 ownerRegistrar 的测试替身:除满足 OwnerRegistrar // 外,还实现可选扩展 LiveOwnerProfile,使热替换路径可被端到端演练。 type liveOwnerRegistrar struct{ live *user.OwnerProfile } func (r *liveOwnerRegistrar) RegisterChannelOwner(context.Context, string, string) error { return nil } func (r *liveOwnerRegistrar) LiveOwnerProfile() *user.OwnerProfile { return r.live } // decodeOwnerProfile 从 {data} 信封解出一份主人档案。 func decodeOwnerProfile(t *testing.T, body []byte) ownerProfileResponse { t.Helper() var got ownerProfileResponse if err := json.Unmarshal(decodeBody(t, body)["data"], &got); err != nil { t.Fatalf("decode owner profile: %v (body=%s)", err, body) } return got } // doAPIKeyReq 发一个只带 `X-API-Key` 头的请求,模拟用 key 救火的管理员客户端。 func doAPIKeyReq(t *testing.T, method, url, key string) (*http.Response, []byte) { t.Helper() req, err := http.NewRequest(method, url, nil) if err != nil { t.Fatalf("new request: %v", err) } if key != "" { req.Header.Set("X-API-Key", key) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read response: %v", err) } return resp, b } // TestOwnerProfile_GetPutRoundTrip 钉住 10.4.5:面板的「保存」走真实 API —— PUT // 真的落盘(0600 原子写,内容可被重新载入),GET 读回同一份,不再有「只改本地 // state」的假成功。 func TestOwnerProfile_GetPutRoundTrip(t *testing.T) { t.Chdir(t.TempDir()) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // 全新安装(还没人配过主人):200 + 空白档案,而不是 404,也不假装档已存在。 resp, body := doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("GET status = %d, want 200 (body=%s)", resp.StatusCode, body) } if got := decodeOwnerProfile(t, body); got.ID != "" || got.DisplayName != "" || len(got.Accounts) != 0 { t.Fatalf("fresh profile = %+v, want an empty profile", got) } if _, err := os.Stat(ownerProfilePath); err == nil { t.Fatal("a fresh install must not have a profile on disk yet") } want := ownerProfileResponse{ ID: "owner", DisplayName: "叶酱", DefaultPersona: "cyrene", IdentityFile: "configs/users/owner_identity.md", Notes: "主人。", Accounts: []ownerAccountRequest{{Platform: " telegram ", AccountID: " 10086 "}}, } resp, body = doReq(t, http.MethodPut, ts.URL+ownerProfileURL, want, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body) } saved := decodeOwnerProfile(t, body) if saved.ID != "owner" || saved.DisplayName != "叶酱" || len(saved.Accounts) != 1 { t.Fatalf("PUT data = %+v, want the saved profile", saved) } if saved.Accounts[0].Platform != "telegram" || saved.Accounts[0].AccountID != "10086" { t.Fatalf("accounts = %+v, want whitespace-trimmed platform/accountId", saved.Accounts[0]) } // 真保存的第一判据:磁盘上能重新载入出一模一样的档案(含白名单)。 onDisk, err := user.LoadOwnerProfile(ownerProfilePath) if err != nil { t.Fatalf("reload %s: %v", ownerProfilePath, err) } if onDisk.DisplayName != "叶酱" || !onDisk.IsOwner("telegram", "10086") { t.Fatalf("disk profile = %+v, want the saved whitelist", onDisk) } info, err := os.Stat(ownerProfilePath) if err != nil { t.Fatalf("stat saved profile: %v", err) } if runtime.GOOS != "windows" { // Windows 没有常规文件的 POSIX 权限位(Chmod 只切只读标志),因此 // 0600 只在类 Unix 平台可断言;save.go 仍然请求 0600。 if perm := info.Mode().Perm(); perm != 0o600 { t.Errorf("saved profile mode = %o, want 0600", perm) } } // 第二判据:GET 与 PUT 对同一份档案的读法一致(面板可信任任一方回填)。 resp, body = doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("GET after PUT status = %d, want 200 (body=%s)", resp.StatusCode, body) } reloaded := decodeOwnerProfile(t, body) if reloaded.ID != saved.ID || reloaded.Notes != saved.Notes || len(reloaded.Accounts) != 1 { t.Fatalf("GET after PUT = %+v, want %+v", reloaded, saved) } } // TestOwnerProfile_PutRejectsInvalidProfile 断言坏档案被显式拒绝且**不落盘**: // 校验失败绝不能留下半份档案。 func TestOwnerProfile_PutRejectsInvalidProfile(t *testing.T) { t.Chdir(t.TempDir()) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) tok := signAny(t, jwt, "acct", auth.RoleOwner) cases := []struct { name string body ownerProfileResponse }{ {"missing id", ownerProfileResponse{DisplayName: "叶酱"}}, {"missing displayName", ownerProfileResponse{ID: "owner"}}, {"half-filled account", ownerProfileResponse{ ID: "owner", DisplayName: "叶酱", Accounts: []ownerAccountRequest{{Platform: "telegram"}}, }}, } for _, tc := range cases { resp, body := doReq(t, http.MethodPut, ts.URL+ownerProfileURL, tc.body, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("%s: PUT status = %d, want 400 (body=%s)", tc.name, resp.StatusCode, body) } wantErrorShape(t, body) if _, err := os.Stat(ownerProfilePath); err == nil { t.Fatalf("%s: a rejected profile must not be persisted", tc.name) } } } // TestOwnerProfile_OwnerOnly 断言这份档案只给 owner:它是身份红线(design/11 // §0),admin 也不能读、不能写。 func TestOwnerProfile_OwnerOnly(t *testing.T) { t.Chdir(t.TempDir()) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) for _, role := range []auth.Role{auth.RoleAdmin, auth.RoleViewer} { tok := signAny(t, jwt, "acct", role) resp, body := doReq(t, http.MethodGet, ts.URL+ownerProfileURL, nil, tok) if resp.StatusCode != http.StatusForbidden { t.Fatalf("%s GET status = %d, want 403 (body=%s)", role, resp.StatusCode, body) } resp, body = doReq(t, http.MethodPut, ts.URL+ownerProfileURL, ownerProfileResponse{ID: "owner", DisplayName: "x"}, tok) if resp.StatusCode != http.StatusForbidden { t.Fatalf("%s PUT status = %d, want 403 (body=%s)", role, resp.StatusCode, body) } wantErrorShape(t, body) } } // TestOwnerProfile_PutHotSwapsLiveProfile 断言保存后活档案被**原地**替换:渠道 // bridge 持有的是同一个指针,因此新白名单立即生效、被移除的账号立即失效,无需 // 重启(design/11 §9「可编辑、热重载」)。 func TestOwnerProfile_PutHotSwapsLiveProfile(t *testing.T) { t.Chdir(t.TempDir()) live := &user.OwnerProfile{ ID: "owner", DisplayName: "旧名", Accounts: []user.OwnerAccount{{Platform: "kook", AccountID: "1"}}, } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{OwnerRegistrar: &liveOwnerRegistrar{live: live}}) tok := signAny(t, jwt, "acct", auth.RoleOwner) resp, body := doReq(t, http.MethodPut, ts.URL+ownerProfileURL, ownerProfileResponse{ ID: "owner", DisplayName: "叶酱", Accounts: []ownerAccountRequest{{Platform: "telegram", AccountID: "10086"}}, }, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("PUT status = %d, want 200 (body=%s)", resp.StatusCode, body) } if live.DisplayName != "叶酱" { t.Errorf("live DisplayName = %q, want 叶酱 (in-place swap)", live.DisplayName) } if !live.IsOwner("telegram", "10086") { t.Error("newly whitelisted account must be live immediately") } if live.IsOwner("kook", "1") { t.Error("removed account must stop being the owner") } } // TestAdminApiKey_IssuedKeyReallyAuthenticates 是 15.4.2 的端到端断言:签发的 // key 真的能过 owner-only 门(不再是「可签发、无中间件接受」的死端),列表里恒为 // owner(15.4.3),吊销后立刻失效。 func TestAdminApiKey_IssuedKeyReallyAuthenticates(t *testing.T) { t.Parallel() keys := auth.NewAPIKey(config.AuthConfig{}) _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys}) tok := signAny(t, jwt, "acct", auth.RoleOwner) // 签发不带任何 role:key 一律超管(15.4.3)。 resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/users/apikey", nil, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("issue status = %d, want 201 (body=%s)", resp.StatusCode, body) } var issued struct { ID string `json:"id"` Key string `json:"key"` Role string `json:"role"` } if err := json.Unmarshal(decodeBody(t, body)["data"], &issued); err != nil { t.Fatalf("decode issued key: %v", err) } if issued.Key == "" || issued.ID == "" { t.Fatalf("issued = %+v, want id and one-time plaintext", issued) } if issued.Role != string(auth.RoleOwner) { t.Fatalf("issued role = %q, want owner", issued.Role) } if issued.ID == issued.Key { t.Error("the returned id must be the masked form, not the plaintext") } // 只带 X-API-Key(无 Bearer)访问 owner-only 读端点 —— 这就是「key 能用」。 resp, body = doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", issued.Key) if resp.StatusCode != http.StatusOK { t.Fatalf("GET /admin/users with X-API-Key status = %d, want 200 (body=%s)", resp.StatusCode, body) } // 管理列表里这条 key 恒为 owner(表已无角色维度)。 var listEnv struct { Data []struct { ID string `json:"id"` Username string `json:"username"` Role string `json:"role"` } `json:"data"` } if err := json.Unmarshal(body, &listEnv); err != nil { t.Fatalf("decode users list: %v", err) } found := false for _, u := range listEnv.Data { if u.ID == issued.ID { found = true if u.Username != "apikey" || u.Role != string(auth.RoleOwner) { t.Fatalf("key entry = %+v, want apikey/owner", u) } } } if !found { t.Fatalf("issued key %s missing from the users list", issued.ID) } // 吊销后同一把 key 立即 401:能发也能废。 resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/admin/users/apikey/"+issued.ID, nil, tok) if resp.StatusCode != http.StatusOK { t.Fatalf("revoke status = %d, want 200 (body=%s)", resp.StatusCode, body) } resp, body = doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", issued.Key) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("revoked key status = %d, want 401 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) } // TestAdminApiKey_BadKeyIsFailClosed 断言未知 key 一律 401,且**即使同时带着一份 // 合法 Bearer 也不回落** —— 坏 key 不能借道另一份凭据进门。 func TestAdminApiKey_BadKeyIsFailClosed(t *testing.T) { t.Parallel() keys := auth.NewAPIKey(config.AuthConfig{}) if _, err := keys.Generate(); err != nil { t.Fatalf("generate: %v", err) } _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Users: keys}) resp, body := doAPIKeyReq(t, http.MethodGet, ts.URL+"/api/v1/admin/users", "not-a-key") if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("unknown key status = %d, want 401 (body=%s)", resp.StatusCode, body) } wantErrorShape(t, body) tok := signAny(t, jwt, "acct", auth.RoleOwner) req, err := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/admin/users", nil) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+tok) req.Header.Set("X-API-Key", "not-a-key") withBoth, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } defer withBoth.Body.Close() if withBoth.StatusCode != http.StatusUnauthorized { t.Fatalf("bad key + valid bearer status = %d, want 401 (fail-closed)", withBoth.StatusCode) } } // TestPersonaWrite_RequiresSystemPrompt 钉住 08.4.2:identity.systemPrompt 必填, // 缺失/空白显式报 400(不回落、不派生),且被拒的请求不落盘;带上它则写出的 // YAML 能被载入期用同一套规则重新载入。 func TestPersonaWrite_RequiresSystemPrompt(t *testing.T) { t.Chdir(t.TempDir()) reg := personareg.New() _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Personas: reg}) tok := signAny(t, jwt, "acct", auth.RoleOwner) url := ts.URL + "/api/v1/admin/personas" for _, req := range []map[string]any{ {"id": "xilian", "name": "昔涟", "role": "r"}, {"id": "xilian", "name": "昔涟", "role": "r", "systemPrompt": " "}, } { resp, body := doReq(t, http.MethodPost, url, req, tok) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("POST %v status = %d, want 400 (body=%s)", req, resp.StatusCode, body) } wantErrorShape(t, body) if _, ok := reg.Persona("xilian"); ok { t.Fatal("a persona with no systemPrompt must not be registered") } } resp, body := doReq(t, http.MethodPost, url, map[string]any{ "id": "xilian", "name": "昔涟", "role": "r", "systemPrompt": "你是昔涟。", }, tok) if resp.StatusCode != http.StatusCreated { t.Fatalf("POST with systemPrompt status = %d, want 201 (body=%s)", resp.StatusCode, body) } reloaded, err := loader.Load(filepath.Join(personaDir, "xilian.yaml")) if err != nil { t.Fatalf("reload the saved persona (loader enforces the same rule): %v", err) } if reloaded.Identity.SystemPrompt != "你是昔涟。" { t.Fatalf("systemPrompt = %q, want the written value", reloaded.Identity.SystemPrompt) } }