- 语义:只把 .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 生效。
240 lines
11 KiB
Go
240 lines
11 KiB
Go
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)
|
||
})
|
||
}
|
||
}
|