package gateway import ( "net/http" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" ) // rbacRoute is one admin route plus the method needed to reach it. The name is // the subtest label, so a failure names the route without reading the path. type rbacRoute struct { name string method string path string } // viewerReadRoutes is the read-only monitoring tier: the exact admin route set // wired to viewerAuth in gateway/routes.go (owner/admin/viewer). It must stay in // lockstep with registerAdminRoutes — adding a route to viewerAuth without // adding it here leaves it untested, and moving one out of viewerAuth makes the // matching subtest fail (see the batch's mutation-verification records). 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 is the admin read tier that stays owner/admin: private // content, other accounts' data and the audit trail. A viewer must get 403; an // admin must pass the gate (the handler's own status is out of scope here, so // only "not 401/403" is asserted for admin). 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 is every route reserved for the owner, including the // credential-acquiring channel endpoints (login-qr/login-status) and the // state-mutating writes. An admin must be rejected too, so the list doubles as // the owner>admin boundary test. 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/{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"}, {"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 builds the gateway with a real JWT service, a capability registry // and an injected log ring buffer, so every viewer-readable route can answer // 200 (the log tail would otherwise be an honest 501). func newRBACEnv(t *testing.T) *testEnv { t.Helper() env := newEnv(t, nil, true) env.srv.SetLogBuffer(logger.NewRingBuffer(8)) return env } // rbacBody returns a request body valid enough that the RBAC gate — not a // decoder error — decides the status. A failed decode still answers 4xx, which // never turns a 403 into a false pass, but a valid body keeps the assertions // honest about what each route expects. func rbacBody(rt rbacRoute) any { switch rt.name { case "rag/query": return map[string]any{"query": "cyrene"} default: return nil } } // TestAdminRBAC_ViewerReadsMonitoringSurface locks the viewer tier: each of the // twelve read-only monitoring routes answers 200 for viewer, admin and owner // alike (owner>admin>viewer all satisfy 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 proves every admin route (both tiers) // fails closed with 401 — not 403 and never a silent success — when no bearer // token is presented. 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 locks the exclusion list: a // viewer is 403 on every admin read that carries private content, other // accounts' data or audit records, and on every owner-only route (writes plus // the credential-acquiring channel endpoints). 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 separates the two upper // tiers: an admin passes the gate on the owner/admin read routes (its handler // may still answer 404/501 — only the gate's verdict is asserted) but is 403 on // every owner-only route, proving admin never leaks into 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 pins the two write routes named in the // wiring brief so a future gate change cannot quietly hand a viewer a mutation. 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) }) } }