- 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 显式报错,不再派生回落
362 lines
14 KiB
Go
362 lines
14 KiB
Go
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)
|
||
}
|
||
}
|