Files
SnapOtter/tests/integration/rbac-matrix-full.test.ts
T
SnapOtterandGitHub 1c724d5d21 feat(db)!: SnapOtter 2.0 phase 1 foundation: postgres, migrator, compose stack (#216)
* feat(infra): add dev compose stack with postgres and redis

* fix(infra): comment dev env defaults until wired; harden dev compose restart and start_period

* chore(deps): add pg driver and testcontainers for postgres migration

* feat(db): translate schema to drizzle pg-core (timestamptz, boolean, pgEnum, jsonb)

Schema translation (apps/api/src/db/schema.ts):
- sqlite-core -> pg-core, all 10 tables preserved 1:1
- integer(mode:'timestamp') -> timestamp({ withTimezone: true })
- integer(mode:'boolean') -> boolean
- jobs.status text enum -> pgEnum('job_status') with same 4 values
- 7 columns changed from text to jsonb: jobs.inputFiles, jobs.settings,
  pipelines.steps, apiKeys.permissions, roles.permissions,
  auditLog.details, userFiles.toolChain
- settings.value stays text, jobs.error stays text, jobs.progress stays real

jsonb call-site sweep (removed JSON.stringify on writes, JSON.parse on reads):
- apps/api/src/routes/roles.ts: permissions read/write (3 sites)
- apps/api/src/routes/api-keys.ts: permissions write + read (2 sites)
- apps/api/src/routes/audit-log.ts: details read (1 site)
- apps/api/src/routes/pipeline.ts: steps write + read (2 sites)
- apps/api/src/routes/progress.ts: inputFiles write (2 sites)
- apps/api/src/routes/tool-factory.ts: toolChain read + write (2 sites)
- apps/api/src/routes/user-files.ts: toolChain read + write (4 sites)
- apps/api/src/permissions.ts: roles.permissions read (1 site)
- apps/api/src/lib/audit.ts: details write (1 site)
- apps/api/src/plugins/auth.ts: apiKeys.permissions read (1 site)

* refactor(db): type jsonb columns via $type and note raw CTE conversion requirements

* feat(db): archive sqlite migrations and generate postgres baseline

* chore(db): dockerignore legacy migrations, add archive breadcrumb, fix trailing newline

* feat(db): pg pool connection, advisory-locked boot migrations, DATABASE_URL config

* fix(db): friendly fatal on unreachable postgres, idempotent closeDb, lock-key convention note

* refactor(db): async drizzle calls in plugins, lib, permissions

* fix(api): analytics never throws, typed permission guard, single-query session invalidation

* refactor(db): async drizzle calls across all routes and bootstrap

Convert every route file and index.ts from sync SQLite drizzle
patterns to async node-postgres drizzle:

- .all() removed (bare await on select)
- .get() converted to destructured [row] = await ...
- .run() removed (bare await on insert/update/delete)
- .changes replaced with .rowCount (null-guarded) in progress.ts
- sqlite import removed from user-files.ts; raw CTEs converted to
  await db.execute(sql`...`) with postgres-dialect recursive CTEs
- ChainRow types updated: tool_chain is parsed jsonb (string[] | null),
  created_at is Date (timestamptz) with no * 1000 conversion
- All requirePermission() guard calls awaited (security: unawaited
  async guard returns truthy Promise, bypassing permission check)
- All hasEffectivePermission() and getPermissions() calls awaited
- All auditLog() calls awaited (preserves write-before-response order)
- trackEvent() and captureException() left un-awaited (fire-and-forget
  by design, guaranteed never-throw)
- ensureAnonymousUser(), startCleanupCron(), recoverStaleJobs() awaited
  in bootstrap sequence
- ensureInstanceId() and ensureDefaultSettings() made async

Files converted: 14 (index.ts + 12 route files + tools/index.ts)

* fix(db): await async checkStorageQuota in user-files upload/save routes

* fix(db): await checkStorageQuota in save-result route (missed second call site)

* feat(db): sqlite-to-postgres migrator with CLI and first-boot import

* fix(db): migrator error context, honest force semantics, boot-hook fatal, null-variance tests

* test: run suite against per-file postgres databases via testcontainers

- Add tests/global-setup.ts: spins up a Postgres testcontainer,
  creates a migrated template database once per vitest run.
- Rewrite tests/setup/per-fork-env.ts: each test file (forks pool)
  clones the template into its own database via CREATE DATABASE ...
  TEMPLATE, preserving the same per-file isolation granularity.
- Update vitest.config.ts: add globalSetup, pg alias, update comment.
- Fix tests/integration/test-server.ts: remove DB_PATH mkdir, async
  runMigrations, async db operations, remove SQLite WAL checkpoint.
- Fix 21 unit test db/index mocks: add pool and closeDb exports.
- Fix 8 unit test files: add async/await for now-async permission,
  audit, and analytics functions.
- Fix 18 integration test files: convert sync .run()/.all()/.get()
  to async drizzle patterns, add async to callbacks.
- Production change: apps/api/src/routes/teams.ts: cast COUNT(*)
  to ::int so Postgres returns a number instead of bigint string.

* fix(db): seed built-in roles, reject NUL bytes, cast COUNT, serialize job persists

- Seed built-in roles (admin, editor, user) at boot via ensureBuiltinRoles()
  with onConflictDoNothing, restoring data that legacy SQLite migration 0007
  provided via INSERT statements (the pg baseline is DDL-only).
- Reject NUL bytes in login credentials with 401 (postgres rejects \x00 in
  text columns; valid usernames never contain NUL, matching 1.x behavior).
- Cast COUNT(*)::int in user-files, audit-log, and roles listing queries so
  postgres returns a JS number instead of bigint-as-string.
- Serialize fire-and-forget job progress DB writes per jobId so the final
  "completed" status is never overwritten by a late-arriving "processing"
  write (race condition exposed by async postgres round-trips).

* test: fix teams race, seed roles in test server, poll for job status

- Add missing await to resetTeams() in teams PUT beforeEach (the async
  delete raced with the subsequent insert under postgres).
- Call ensureBuiltinRoles() in test server bootstrap so integration tests
  have the same built-in roles as production.
- Replace fixed 100ms flushPersist delay with a polling helper that waits
  for terminal job status, eliminating timing-dependent failures caused by
  postgres network round-trip latency.

* test: make heic temp-file cleanup assertion resilient to concurrent workers

Use a set-based diff instead of raw file count when checking that
decodeHeic cleans up temp files. Other concurrent test workers can
create heic-in-*/heic-out-* files in the shared tmpdir, inflating the
"after" count and causing spurious failures under full-suite load.

* fix(db): align builtin-role seed to post-0010 legacy state; test polish

* feat(docker): three-container compose (app, postgres, redis) with boot wait and migrations

* fix(docker): set TEST_DATABASE_URL so containerized tests skip testcontainers

* chore(docker): test compose project name, clearer 1.x upgrade comment, unref probe timer

* feat(enterprise): enforce D15 license boundary; move s3 storage into packages/enterprise

* fix(enterprise): restore lazy aws-sdk loading; community installs load no s3 code at boot

* fix(enterprise): boundary check catches dynamic imports; document getS3 concurrency

* feat(db)!: SnapOtter 2.0 phase 1 foundation: postgres, migrator, compose stack

BREAKING CHANGE: SQLite is no longer the runtime database. Deployments now
require Postgres (and Redis, used from phase 2). Existing installs migrate
with SQLITE_MIGRATE_PATH or 'pnpm --filter @snapotter/api migrate:sqlite'.

* fix(ci): postgres service + fresh e2e database per run; ignore unfixable torch CVE-2025-3000
2026-06-13 10:15:23 +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: { _test: "v" },
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);
});
});