Files
Cyrene_v2/internal/services/gateway/rbac_viewer_test.go
T
AskaEth c1fa1e9b9a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(admin): 面板「重置为 env 默认」动作(materialize 的反方向)+ 契约 v0.62
- 语义:只把 .env **确实声明过**的键覆盖回 env 值;env 未声明的键(fallback/multimodal/maxTokens/reasoning/personaModels)原样保留;**组集合不增减**(不复活被面板删的组、不删面板新增的组);无键可改时不写盘。成功即热重载 LLM 栈。返回 {reset:[{group,keys}], skipped:[{group,reason}], path},**只回键名、不回显凭据值**。

- 护栏:必须 confirm:true(缺失/false/非法 body ⇒ 400,且**先于任何读写**);**owner-only**(与同页 POST /admin/llm、/admin/llm/test 同门 —— 这是破坏性写,门不比同族更宽);审计动作 llm.resetFromEnv(camelCase,全仓多词动作的既有约定)。失败路径明确报错(env_defaults_unreadable / models_unreadable / llm_unavailable),不假成功。

- 组合根注入 Deps.EnvPath(网关不 fopen .env):请求时按既有解析入口重读,语义 =「退回 .env 现在声明的默认值」;.env 层里的 provider 是同义反复,不计入「env 为该组声明了什么」的判定(有专门用例)。

- 前端:LLM 页「重置为 env 默认」卡片(中文二次确认 → 提交 → 按 reset/skipped 渲染 → 成功后重拉配置),标签 owner 专属(与 System.tsx 同款)。

- 契约 v0.62:api-client(权限矩阵改回 12+10+38=60、端点表 9b、重置契约小节、写操作契约、changelog、banner)与 api-reference(端点表 #70、§5.13a 详情含 curl、§8.2/§8.3/§8.4)同步;字段名三方逐字对齐。

- 主会话独立验收:隔离 worktree(R1 的 19 文件)build/vet 绿、gateway+config 三包绿、R1 用例 10 PASS、reset_from_env 残留 0;**变异亲自复现**(去掉 confirm 校验 ⇒ RequiresConfirm 红:status = 200, want 400,且响应体显示面板配置真的被改)。

- 子代理自报并已处理:测试文件触 500 行上限 ⇒ 拆成语义侧 + 护栏侧两份;门改回 owner-only 后前端标签会误导 admin ⇒ 改「owner 专属」并重建 bundle。任务书本身仍是旧口径(owner|admin / snake_case)—— 已由主会话登记修正说明,避免压缩后有人按任务书回退。

- 未做(诚实边界):不支持按组重置;不纳入 channels.json/plugins.json;不自动重启(contextWindow 的预算推导仍只在启动时算一次)。新端点需下一次 build+restart 才在 :10942 生效。
2026-10-04 21:33:11 +08:00

240 lines
11 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 (
"net/http"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// rbacRoute 是一条管理路由加上抵达它所需的方法。name 是
// 子测试标签,因此失败时不必读路径就能指明是哪条路由。
type rbacRoute struct {
name string
method string
path string
}
// viewerReadRoutes 是只读监控层:gateway/routes.go 中接到 viewerAuth
// 的确切管理路由集合(owner/admin/viewer)。它必须与
// registerAdminRoutes 保持同步 —— 把一条路由加进 viewerAuth 而不
// 加进这里会让它未被测试,而把一条移出 viewerAuth 会让对应
// 子测试失败(见本批次的变异验证记录)。
var viewerReadRoutes = []rbacRoute{
{"personas", http.MethodGet, "/api/v1/admin/personas"},
{"sessions", http.MethodGet, "/api/v1/admin/sessions"},
{"plugins", http.MethodGet, "/api/v1/admin/plugins"},
{"plugins/installed", http.MethodGet, "/api/v1/admin/plugins/installed"},
{"channels", http.MethodGet, "/api/v1/admin/channels"},
{"iot", http.MethodGet, "/api/v1/admin/iot"},
{"automations", http.MethodGet, "/api/v1/admin/automations"},
{"rag", http.MethodGet, "/api/v1/admin/rag"},
{"observability/metrics", http.MethodGet, "/api/v1/admin/observability/metrics"},
{"observability/trace", http.MethodGet, "/api/v1/admin/observability/trace"},
{"observability/logs", http.MethodGet, "/api/v1/admin/observability/logs"},
{"notifications", http.MethodGet, "/api/v1/admin/notifications"},
}
// ownerAdminReadRoutes 是仍保持 owner/admin 的管理读层:私有
// 内容、其他账号的数据与审计链。viewer 必须得到 403;
// admin 必须通过该门(handler 自身的状态在此不在范围内,因此
// 对 admin 只断言「不是 401/403」)。
var ownerAdminReadRoutes = []rbacRoute{
{"memories", http.MethodGet, "/api/v1/admin/memories"},
{"channels/messages", http.MethodGet, "/api/v1/admin/channels/messages"},
{"channels/{platform}/messages", http.MethodGet, "/api/v1/admin/channels/kook/messages"},
{"llm", http.MethodGet, "/api/v1/admin/llm"},
{"users", http.MethodGet, "/api/v1/admin/users"},
{"backthink", http.MethodGet, "/api/v1/admin/backthink"},
{"observability/audit", http.MethodGet, "/api/v1/admin/observability/audit"},
{"sessions/{id}/export", http.MethodGet, "/api/v1/admin/sessions/web/export"},
{"rag/query", http.MethodPost, "/api/v1/admin/rag/query"},
}
// ownerOnlyRoutes 是保留给 owner 的每一条路由,包括
// 获取凭据的渠道端点(login-qr/login-status)与
// 改变状态的写操作。admin 也必须被拒绝,因此该列表同时用作
// owner>admin 边界测试。
var ownerOnlyRoutes = []rbacRoute{
{"channels/{platform}/login-qr", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-qr"},
{"channels/{platform}/login-status", http.MethodGet, "/api/v1/admin/channels/weixin-ilink/login-status"},
{"channels/{platform}/view", http.MethodGet, "/api/v1/admin/channels/kook/view"},
{"channels/{platform}/friends", http.MethodGet, "/api/v1/admin/channels/kook/friends"},
{"channels/{platform}/friend/handle", http.MethodPost, "/api/v1/admin/channels/kook/friend/handle"},
{"channels/{platform}/message/test", http.MethodPost, "/api/v1/admin/channels/kook/message/test"},
{"channels/{platform}/config PUT", http.MethodPut, "/api/v1/admin/channels/kook/config"},
{"channels/{platform}/config DELETE", http.MethodDelete, "/api/v1/admin/channels/kook/config"},
{"channels/open", http.MethodPost, "/api/v1/admin/channels/open"},
{"channels/close", http.MethodPost, "/api/v1/admin/channels/close"},
{"channels/test", http.MethodPost, "/api/v1/admin/channels/test"},
{"plugins/policy", http.MethodPost, "/api/v1/admin/plugins/policy"},
{"plugins/install", http.MethodPost, "/api/v1/admin/plugins/install"},
{"plugins/uninstall", http.MethodPost, "/api/v1/admin/plugins/uninstall"},
{"personas POST", http.MethodPost, "/api/v1/admin/personas"},
{"personas/{id} PUT", http.MethodPut, "/api/v1/admin/personas/cyrene"},
{"users POST", http.MethodPost, "/api/v1/admin/users"},
{"users/apikey", http.MethodPost, "/api/v1/admin/users/apikey"},
{"users/apikey/{id} DELETE", http.MethodDelete, "/api/v1/admin/users/apikey/abc123"},
{"iot/{id}/control", http.MethodPost, "/api/v1/admin/iot/dev1/control"},
{"iot/scenarios", http.MethodPost, "/api/v1/admin/iot/scenarios"},
{"iot/rules", http.MethodPost, "/api/v1/admin/iot/rules"},
{"automations POST", http.MethodPost, "/api/v1/admin/automations"},
{"automations/reminders POST", http.MethodPost, "/api/v1/admin/automations/reminders"},
{"automations/{id}/run", http.MethodPost, "/api/v1/admin/automations/a1/run"},
{"automations/{id} DELETE", http.MethodDelete, "/api/v1/admin/automations/a1"},
{"llm POST", http.MethodPost, "/api/v1/admin/llm"},
{"llm/test", http.MethodPost, "/api/v1/admin/llm/test"},
{"llm/reset-from-env", http.MethodPost, "/api/v1/admin/llm/reset-from-env"},
{"memories POST", http.MethodPost, "/api/v1/admin/memories"},
{"memories/search", http.MethodPost, "/api/v1/admin/memories/search"},
{"rag POST", http.MethodPost, "/api/v1/admin/rag"},
{"rag/delete", http.MethodPost, "/api/v1/admin/rag/delete"},
{"system/revoke-keys", http.MethodPost, "/api/v1/admin/system/revoke-keys"},
{"system/rotate-jwt", http.MethodPost, "/api/v1/admin/system/rotate-jwt"},
{"system/backup", http.MethodPost, "/api/v1/admin/system/backup"},
{"system/restore", http.MethodPost, "/api/v1/admin/system/restore"},
}
// newRBACEnv 用真实 JWT 服务、能力注册表
// 与注入的日志环形缓冲构建网关,使每条 viewer 可读路由都能返回
// 200(否则日志尾部会是诚实的 501)。
func newRBACEnv(t *testing.T) *testEnv {
t.Helper()
env := newEnv(t, nil, true)
env.srv.SetLogBuffer(logger.NewRingBuffer(8))
return env
}
// rbacBody 返回一个足够合法的请求体,使决定状态的是 RBAC 门 ——
// 而不是解码错误。解码失败仍返回 4xx,这绝不会把
// 403 变成假通过,但合法请求体让断言对每条路由
// 的预期保持诚实。
func rbacBody(rt rbacRoute) any {
switch rt.name {
case "rag/query":
return map[string]any{"query": "cyrene"}
default:
return nil
}
}
// TestAdminRBAC_ViewerReadsMonitoringSurface 锁定 viewer 层:十二条只读
// 监控路由对 viewer、admin 与 owner 都返回 200
// (owner>admin>viewer 都满足 viewerAuth)。
func TestAdminRBAC_ViewerReadsMonitoringSurface(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
for _, role := range []auth.Role{auth.RoleViewer, auth.RoleAdmin, auth.RoleOwner} {
role := role
t.Run(string(role), func(t *testing.T) {
tok := env.token(t, role)
for _, rt := range viewerReadRoutes {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s as %s: status = %d, want 200 (body=%s)", rt.path, role, resp.StatusCode, body)
}
})
}
})
}
}
// TestAdminRBAC_AnonymousIsUnauthorized 证明未出示 bearer token 时每条
// 管理路由(两层)都以 401 失败关闭 —— 不是 403,也绝不是
// 静默成功。
func TestAdminRBAC_AnonymousIsUnauthorized(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
routes := make([]rbacRoute, 0, len(viewerReadRoutes)+len(ownerAdminReadRoutes)+len(ownerOnlyRoutes))
routes = append(routes, viewerReadRoutes...)
routes = append(routes, ownerAdminReadRoutes...)
routes = append(routes, ownerOnlyRoutes...)
for _, rt := range routes {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s anonymous: status = %d, want 401 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}
// TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes 锁定排除列表:viewer 在
// 每一条携带私有内容、其他账号数据或审计记录的
// 管理读上都是 403,在每一条仅 owner 路由(写操作加上
// 获取凭据的渠道端点)上亦然。
func TestAdminRBAC_ViewerForbiddenOnRestrictedRoutes(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
viewer := env.token(t, auth.RoleViewer)
routes := make([]rbacRoute, 0, len(ownerAdminReadRoutes)+len(ownerOnlyRoutes))
routes = append(routes, ownerAdminReadRoutes...)
routes = append(routes, ownerOnlyRoutes...)
for _, rt := range routes {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}
// TestAdminRBAC_AdminPassesReadGateButNotOwnerGate 区分上面两层:admin
// 在 owner/admin 读路由上通过该门(其 handler 仍可能返回
// 404/501 —— 只断言门的判定)但在每一条仅 owner 路由上
// 是 403,证明 admin 绝不越入 owner。
func TestAdminRBAC_AdminPassesReadGateButNotOwnerGate(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
admin := env.token(t, auth.RoleAdmin)
for _, rt := range ownerAdminReadRoutes {
rt := rt
t.Run("read/"+rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), admin)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
t.Fatalf("admin %s: status = %d, want the gate to admit admin (body=%s)", rt.path, resp.StatusCode, body)
}
})
}
for _, rt := range ownerOnlyRoutes {
rt := rt
t.Run("owner-only/"+rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, rbacBody(rt), admin)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("admin %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}
// TestAdminRBAC_ViewerForbiddenOnWrites 钉住接线说明中点名的两条写
// 路由,使今后的门的改动无法悄悄把变更能力交给 viewer。
func TestAdminRBAC_ViewerForbiddenOnWrites(t *testing.T) {
t.Parallel()
env := newRBACEnv(t)
viewer := env.token(t, auth.RoleViewer)
for _, rt := range []rbacRoute{
{"POST /admin/personas", http.MethodPost, "/api/v1/admin/personas"},
{"DELETE /admin/users/apikey/{id}", http.MethodDelete, "/api/v1/admin/users/apikey/abc123"},
} {
rt := rt
t.Run(rt.name, func(t *testing.T) {
resp, body := doReq(t, rt.method, env.ts.URL+rt.path, nil, viewer)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer %s: status = %d, want 403 (body=%s)", rt.path, resp.StatusCode, body)
}
wantErrorShape(t, body)
})
}
}