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) }) } }