mirror of
https://github.com/willchen96/mike.git
synced 2026-07-26 23:51:08 +02:00
Ports the backend route-level integration suites from the amal66 fork (index: Open-Legal-Products/mike#205), adapted from the fork's apps/api modules/services layout to this repo's monolithic backend/src/routes/*.ts layout. app/index split (the only production change; mechanical, zero behavior change): backend/src/index.ts previously built the express app and called app.listen at the bottom. Everything except the listen call moved verbatim into backend/src/app.ts, which now exports `app` (same middleware order, same routes, same rate-limiter setup, dotenv/config still imported first). index.ts is now a tiny entry that imports { app } and calls listen with the same log line. `npm run build` still emits dist/index.js and the `start` script is unchanged. New suites under backend/src/__tests__/integration/ (94 tests): - health.test.ts (5): /health, requireAuth 401 paths, 404 fallthrough - chat.routes.test.ts (6): POST /chat validation + SSE happy/error paths - projects.routes.test.ts (18): overview/create/detail/patch/delete, sharing normalisation, ownership guards - projectChat.routes.test.ts (3): project access guard + SSE paths - tabular.routes.test.ts (31): review CRUD, access guards, document-access filtering, missing_api_key guards - user.routes.test.ts (27): profile, API-key crypto boundary, MFA guards, export/deletion endpoints - documentsUpload.routes.test.ts (4): upload validation + download-zip bounds/access - access.supabase.test.ts (1) + stack.supabase.test.ts (4): gated on SUPABASE_TEST_URL / SUPABASE_TEST_SERVICE_ROLE_KEY (stack suite also needs SUPABASE_TEST_ANON_KEY); describe.skip otherwise - scripts/test-stack.sh + `npm run test:stack`: reads a running `supabase status -o json` and runs the gated suites Adds supertest + @types/supertest as devDependencies. Dropped relative to the fork (subjects that do not exist in this repo): - orgs.routes, credits.concurrency.supabase, dmsConnectors suites (fork-only features) - all credit-reservation cases (429 CREDIT_LIMIT_EXCEEDED, reserve-then-refund) in chat/projectChat: no credit system here - health /ready case: no /ready endpoint here - org-membership project access case: no org model here - upload magic-byte validation cases: this repo validates extension only (the fork adds content sniffing; replaced with a missing-file 400 case) - download-zip 50-document cap case: no cap here (replaced with a no-accessible-documents 404 case) - stack.supabase PUBLIC_TABLES updated to this repo's schema; the fork's credit-RPC coverage is n/a Verified locally: backend `npm test` -> 8 files passed, 2 skipped; 106 tests passed, 5 skipped (gated suites skip without env). `npm run build` passes. The gated suites were additionally run against a live local Supabase stack: 2 files, 5/5 tests passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
146 lines
6.7 KiB
TypeScript
146 lines
6.7 KiB
TypeScript
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
// Stack-level integration test: exercises the REAL Supabase stack (GoTrue auth +
|
|
// Postgres RLS) rather than mocks. This is the harness that makes pinning a fixed
|
|
// Supabase version set safe — it's what you re-run on every image bump to prove
|
|
// the auth↔API contract and the deny-all RLS firewall still hold. It also anchors
|
|
// the security model's central claim: RLS denies the user/anon path, and the API
|
|
// reaches data only via the service-role key.
|
|
//
|
|
// Gated: skipped unless a stack is provided (default CI unit run skips it).
|
|
// Locally: `supabase start`, then export the printed keys as:
|
|
// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY, SUPABASE_TEST_ANON_KEY
|
|
const url = process.env.SUPABASE_TEST_URL;
|
|
const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY;
|
|
const anonKey = process.env.SUPABASE_TEST_ANON_KEY;
|
|
const maybeDescribe =
|
|
url && serviceKey && anonKey ? describe : describe.skip;
|
|
|
|
// Every public table the app owns (backend/schema.sql + migrations). The
|
|
// anon/user path must never return rows from any of these (deny-all); a
|
|
// regression that ships a table without RLS — or with a permissive policy —
|
|
// trips the leak sweep below. A table missing from an older local stack
|
|
// returns an error (no rows), which never counts as a leak.
|
|
const PUBLIC_TABLES = [
|
|
"chat_messages", "chats", "courtlistener_citation_index",
|
|
"courtlistener_opinion_cluster_index", "document_edits",
|
|
"document_versions", "documents", "hidden_workflows", "library_folders",
|
|
"project_subfolders", "projects", "tabular_cells",
|
|
"tabular_review_chat_messages", "tabular_review_chats", "tabular_reviews",
|
|
"user_api_keys", "user_mcp_connector_tools", "user_mcp_connectors",
|
|
"user_mcp_oauth_states", "user_mcp_oauth_tokens",
|
|
"user_mcp_tool_audit_logs", "user_profiles",
|
|
"workflow_open_source_submissions", "workflow_shares", "workflows",
|
|
];
|
|
|
|
maybeDescribe("Supabase stack — auth contract + RLS deny-all firewall", () => {
|
|
const password = "StackTest1!";
|
|
const emailA = `stack-a-${Date.now()}@test.local`;
|
|
const emailB = `stack-b-${Date.now()}@test.local`;
|
|
|
|
let admin: SupabaseClient; // service-role: BYPASSRLS, the app's data path
|
|
let userA = "";
|
|
let userB = "";
|
|
let tokenA = "";
|
|
let projectId = "";
|
|
|
|
// A client acting as a signed-in end user (anon key + the user's JWT): this is
|
|
// the path RLS must fence off.
|
|
const asUser = (token: string) =>
|
|
createClient(url!, anonKey!, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
global: { headers: { Authorization: `Bearer ${token}` } },
|
|
});
|
|
|
|
beforeAll(async () => {
|
|
admin = createClient(url!, serviceKey!, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
});
|
|
|
|
const a = await admin.auth.admin.createUser({
|
|
email: emailA, password, email_confirm: true,
|
|
});
|
|
const b = await admin.auth.admin.createUser({
|
|
email: emailB, password, email_confirm: true,
|
|
});
|
|
if (a.error || !a.data.user) throw a.error ?? new Error("no user A");
|
|
if (b.error || !b.data.user) throw b.error ?? new Error("no user B");
|
|
userA = a.data.user.id;
|
|
userB = b.data.user.id;
|
|
|
|
// Sign in as A to get a real access token (the token the API middleware
|
|
// validates via auth.getUser).
|
|
const signIn = await createClient(url!, anonKey!, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
}).auth.signInWithPassword({ email: emailA, password });
|
|
if (signIn.error || !signIn.data.session) {
|
|
throw signIn.error ?? new Error("no session for A");
|
|
}
|
|
tokenA = signIn.data.session.access_token;
|
|
|
|
// Seed one row owned by A via the service role (the app's real write path).
|
|
const proj = await admin
|
|
.from("projects")
|
|
.insert({ user_id: userA, name: "Stack Test Project" })
|
|
.select("id")
|
|
.single();
|
|
if (proj.error || !proj.data) throw proj.error ?? new Error("no project");
|
|
projectId = proj.data.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (projectId) await admin.from("projects").delete().eq("id", projectId);
|
|
if (userA) await admin.auth.admin.deleteUser(userA);
|
|
if (userB) await admin.auth.admin.deleteUser(userB);
|
|
});
|
|
|
|
it("auth contract: the access token resolves to its user (middleware path)", async () => {
|
|
const { data, error } = await admin.auth.getUser(tokenA);
|
|
expect(error).toBeNull();
|
|
expect(data.user?.id).toBe(userA);
|
|
expect(data.user?.email).toBe(emailA);
|
|
});
|
|
|
|
it("RLS: the service role sees seeded rows the owner cannot see via the user path", async () => {
|
|
// Service role (app data path) sees the project…
|
|
const svc = await admin
|
|
.from("projects").select("id").eq("id", projectId);
|
|
expect(svc.error).toBeNull();
|
|
expect(svc.data ?? []).toHaveLength(1);
|
|
|
|
// …but the owner, going through the user/anon path, sees zero rows —
|
|
// deny-all RLS is the firewall; the app must use the service role.
|
|
const owner = await asUser(tokenA)
|
|
.from("projects").select("id").eq("id", projectId);
|
|
expect(owner.data ?? []).toHaveLength(0);
|
|
|
|
// And the owner's profile (if any) is equally invisible to the user path.
|
|
const prof = await asUser(tokenA)
|
|
.from("user_profiles").select("user_id").eq("user_id", userA);
|
|
expect(prof.data ?? []).toHaveLength(0);
|
|
});
|
|
|
|
it("tenant isolation: user B cannot read user A's project via the user path", async () => {
|
|
const signInB = await createClient(url!, anonKey!, {
|
|
auth: { persistSession: false, autoRefreshToken: false },
|
|
}).auth.signInWithPassword({ email: emailB, password });
|
|
const tokenB = signInB.data.session!.access_token;
|
|
|
|
const cross = await asUser(tokenB)
|
|
.from("projects").select("id").eq("id", projectId);
|
|
expect(cross.data ?? []).toHaveLength(0);
|
|
});
|
|
|
|
it("leak sweep: no public table returns rows to the authenticated user path", async () => {
|
|
const client = asUser(tokenA);
|
|
const leaks: string[] = [];
|
|
for (const table of PUBLIC_TABLES) {
|
|
const { data } = await client.from(table).select("*").limit(1);
|
|
if ((data ?? []).length > 0) leaks.push(table);
|
|
}
|
|
// Any table returning rows to a normal user means RLS is missing or a
|
|
// policy is permissive — the exact regression this guards against.
|
|
expect(leaks).toEqual([]);
|
|
});
|
|
});
|