Files
SnapOtter/tests/integration/platform/rbac-matrix-full.test.ts
T
SnapOtterandGitHub 44d8109486 fix: enforce settings authority boundaries (#618)
Close generic settings authorization bypasses and enforce per-setting authority, validation, redaction, transactional config import, and route-local write rate limiting.
2026-07-22 20:15:38 +08:00

389 lines
10 KiB
TypeScript
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.
/**
* Comprehensive RBAC route permission matrix.
*
* Tests every route × every role (admin, editor, user, unauthenticated)
* to verify the correct HTTP status code is returned. Also validates
* cross-role session isolation and token edge cases.
*/
import { eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { db, schema } from "../../../apps/api/src/db/index.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "../test-server.js";
let testApp: TestApp;
let adminToken: string;
let editorToken: string;
let userToken: string;
const runId = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
// Create editor
const editorUsername = `full_editor_${runId}`;
await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username: editorUsername, password: "EditorPass1", role: "editor" },
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, editorUsername));
const editorLogin = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: editorUsername, password: "EditorPass1" },
});
editorToken = JSON.parse(editorLogin.body).token;
// Create user
const userUsername = `full_user_${runId}`;
await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username: userUsername, password: "UserPass12", role: "user" },
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, userUsername));
const userLogin = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: userUsername, password: "UserPass12" },
});
userToken = JSON.parse(userLogin.body).token;
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
// ---------------------------------------------------------------------------
// Route permission matrix
// ---------------------------------------------------------------------------
interface RouteTest {
method: "GET" | "POST" | "PUT" | "DELETE";
url: string;
payload?: unknown | (() => unknown);
admin: number;
editor: number;
user: number;
unauth: number;
label?: string;
}
const routes: RouteTest[] = [
// --- Public routes (no auth required) ---
{
method: "GET",
url: "/api/v1/health",
admin: 200,
editor: 200,
user: 200,
unauth: 200,
label: "public health check",
},
{
method: "GET",
url: "/api/v1/config/auth",
admin: 200,
editor: 200,
user: 200,
unauth: 200,
label: "public auth config",
},
// --- Auth-only routes (any authenticated user) ---
{
method: "GET",
url: "/api/v1/settings",
admin: 200,
editor: 200,
user: 200,
unauth: 401,
label: "settings:read",
},
{
method: "GET",
url: "/api/v1/files",
admin: 200,
editor: 200,
user: 200,
unauth: 401,
label: "requireAuth",
},
{
method: "GET",
url: "/api/v1/pipeline/list",
admin: 200,
editor: 200,
user: 200,
unauth: 401,
label: "requireAuth",
},
{
method: "GET",
url: "/api/v1/api-keys",
admin: 200,
editor: 200,
user: 200,
unauth: 401,
label: "requireAuth",
},
{
method: "POST",
url: "/api/v1/api-keys",
payload: { name: `test-key-${runId}` },
admin: 201,
editor: 201,
user: 201,
unauth: 401,
label: "requireAuth (create api key)",
},
// --- Admin-only routes ---
{
method: "PUT",
url: "/api/v1/settings",
payload: { defaultTheme: "dark" },
admin: 200,
editor: 403,
user: 403,
unauth: 401,
label: "settings:write",
},
{
method: "GET",
url: "/api/auth/users",
admin: 200,
editor: 403,
user: 403,
unauth: 401,
label: "users:manage",
},
{
method: "POST",
url: "/api/auth/register",
payload: () => ({
username: `reg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
password: "TempPass1",
role: "user",
}),
admin: 201,
editor: 403,
user: 403,
unauth: 401,
label: "users:manage (register)",
},
{
method: "GET",
url: "/api/v1/teams",
admin: 200,
editor: 403,
user: 403,
unauth: 401,
label: "teams:manage",
},
{
method: "POST",
url: "/api/v1/teams",
payload: () => ({
name: `team_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
}),
admin: 201,
editor: 403,
user: 403,
unauth: 401,
label: "teams:manage (create)",
},
{
method: "GET",
url: "/api/v1/roles",
admin: 200,
editor: 403,
user: 403,
unauth: 401,
label: "audit:read (roles list)",
},
{
method: "POST",
url: "/api/v1/roles",
payload: () => ({
name: `role_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
permissions: ["tools:use", "files:own"],
}),
admin: 201,
editor: 403,
user: 403,
unauth: 401,
label: "users:manage (create role)",
},
{
method: "GET",
url: "/api/v1/audit-log",
admin: 200,
editor: 403,
user: 403,
unauth: 401,
label: "audit:read",
},
{
method: "GET",
url: "/api/v1/admin/health",
admin: 200,
editor: 403,
user: 403,
unauth: 401,
label: "system:health",
},
];
describe("RBAC route permission matrix (full)", () => {
for (const route of routes) {
for (const [role, expectedStatus] of Object.entries({
admin: route.admin,
editor: route.editor,
user: route.user,
unauth: route.unauth,
})) {
const suffix = route.label ? ` [${route.label}]` : "";
it(`${route.method} ${route.url} -> ${role} = ${expectedStatus}${suffix}`, async () => {
const headers: Record<string, string> = {};
const token =
role === "admin"
? adminToken
: role === "editor"
? editorToken
: role === "user"
? userToken
: undefined;
if (token) {
headers.authorization = `Bearer ${token}`;
}
const payload = typeof route.payload === "function" ? route.payload() : route.payload;
const res = await testApp.app.inject({
method: route.method,
url: route.url,
headers,
...(payload ? { payload } : {}),
});
expect(res.statusCode).toBe(expectedStatus);
});
}
}
});
// ---------------------------------------------------------------------------
// Cross-role isolation
// ---------------------------------------------------------------------------
describe("Cross-role isolation", () => {
it("editor session returns correct role and permissions", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/auth/session",
headers: { authorization: `Bearer ${editorToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.user.role).toBe("editor");
expect(body.user.permissions).toEqual(
expect.arrayContaining([
"tools:use",
"files:own",
"files:all",
"apikeys:own",
"pipelines:own",
"pipelines:all",
"settings:read",
]),
);
// Must NOT have admin-only permissions
expect(body.user.permissions).not.toContain("settings:write");
expect(body.user.permissions).not.toContain("users:manage");
expect(body.user.permissions).not.toContain("teams:manage");
expect(body.user.permissions).not.toContain("features:manage");
expect(body.user.permissions).not.toContain("system:health");
expect(body.user.permissions).not.toContain("audit:read");
});
it("user session returns correct role and permissions", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/auth/session",
headers: { authorization: `Bearer ${userToken}` },
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.user.role).toBe("user");
expect(body.user.permissions).toEqual(
expect.arrayContaining([
"tools:use",
"files:own",
"apikeys:own",
"pipelines:own",
"settings:read",
]),
);
// Must NOT have editor or admin permissions
expect(body.user.permissions).not.toContain("files:all");
expect(body.user.permissions).not.toContain("pipelines:all");
expect(body.user.permissions).not.toContain("settings:write");
expect(body.user.permissions).not.toContain("users:manage");
expect(body.user.permissions).not.toContain("teams:manage");
});
it("invalid token returns 401", async () => {
const res = await testApp.app.inject({
method: "GET",
url: "/api/auth/session",
headers: { authorization: "Bearer totally-bogus-token-value" },
});
expect(res.statusCode).toBe(401);
});
it("expired session returns 401", async () => {
// Create a session, then manually expire it in the DB
const expiredUsername = `expired_${runId}`;
await testApp.app.inject({
method: "POST",
url: "/api/auth/register",
headers: { authorization: `Bearer ${adminToken}` },
payload: { username: expiredUsername, password: "ExpiredPass1", role: "user" },
});
await db
.update(schema.users)
.set({ mustChangePassword: false })
.where(eq(schema.users.username, expiredUsername));
const loginRes = await testApp.app.inject({
method: "POST",
url: "/api/auth/login",
payload: { username: expiredUsername, password: "ExpiredPass1" },
});
const expiredToken = JSON.parse(loginRes.body).token;
// Manually expire the session
await db
.update(schema.sessions)
.set({ expiresAt: new Date(Date.now() - 60_000) })
.where(eq(schema.sessions.id, expiredToken));
const res = await testApp.app.inject({
method: "GET",
url: "/api/auth/session",
headers: { authorization: `Bearer ${expiredToken}` },
});
expect(res.statusCode).toBe(401);
});
});