Files
Cyrene_v2/internal/services/gateway/admin_write_personas_users_test.go
T
AskaEth adef36538f
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(auth,gateway,user): X-API-Key 真接线 + key 一律超管 + owner 档案真落盘(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- 15.4.2:管理 API 与 Bearer 共用同一 Authorize 路径,X-API-Key 命中签发表即过门;未知/已吊销/空值 401 且不回落 Bearer(fail-closed)
- 15.4.3:key 表去掉角色维度,任一有效 key 鉴权后即 owner;签发接口取消 role 分级(不再读请求体)
- 10.4.5:新增 GET/PUT /api/v1/admin/owner-profile(owner-only,PUT 带审计 owner.profile.update),0600 原子落盘 + 活档案热替换;前端主人档案改接真实 API,读不到即禁用保存,删掉硬编码默认档案
- 08.4.2:persona 的 identity.systemPrompt 必填,缺失/空白 400 显式报错,不再派生回落
2026-10-04 12:07:51 +08:00

362 lines
14 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 (
"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)
}
}