Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 289 additions & 3 deletions backend/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"build": "tsc",
"start": "node dist/index.js",
"test": "vitest run",
"test:stack": "bash scripts/test-stack.sh",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
Expand Down Expand Up @@ -38,8 +39,10 @@
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.14.1",
"@types/supertest": "^7.2.1",
"@vitest/coverage-v8": "^4.1.9",
"prettier": "^3.8.1",
"supertest": "^7.2.2",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^4.1.9"
Expand Down
11 changes: 11 additions & 0 deletions backend/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -885,3 +885,14 @@ revoke all on public.user_mcp_connector_tools from anon, authenticated;
revoke all on public.user_mcp_tool_audit_logs from anon, authenticated;
revoke all on public.courtlistener_citation_index from anon, authenticated;
revoke all on public.courtlistener_opinion_cluster_index from anon, authenticated;

-- Tables created by this file are owned by the database bootstrap role. The
-- backend connects as service_role, so grant it only the data privileges that
-- the direct browser roles above intentionally do not have. RLS is still
-- enabled as defense in depth; service_role bypasses it for the backend path.
grant select, insert, update, delete
on all tables in schema public
to service_role;
grant usage, select
on all sequences in schema public
to service_role;
63 changes: 63 additions & 0 deletions backend/scripts/test-stack.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Run the gated stack-level integration tests against a local Supabase stack.
#
# These tests exercise the REAL stack (GoTrue auth + Postgres RLS) instead of
# mocks. They are the harness you re-run on every Supabase image bump to prove
# the auth↔API contract and the deny-all RLS firewall still hold.
#
# Usage: supabase start # in the repo, once
# npm run test:stack (from backend/)
set -euo pipefail

SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
BACKEND_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
SCHEMA_FILE="$BACKEND_DIR/schema.sql"

if ! command -v supabase >/dev/null 2>&1; then
echo "supabase CLI not found. Install: brew install supabase/tap/supabase" >&2
exit 1
fi

STATUS="$(supabase status -o json 2>/dev/null)" || {
echo "No running Supabase stack. Start one with: supabase start" >&2
exit 1
}

read_key() { node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(String(JSON.parse(s)['$1']??'')))" <<<"$STATUS"; }

SUPABASE_TEST_URL="$(read_key API_URL)"
SUPABASE_TEST_SERVICE_ROLE_KEY="$(read_key SERVICE_ROLE_KEY)"
SUPABASE_TEST_ANON_KEY="$(read_key ANON_KEY)"
SUPABASE_TEST_DB_URL="$(read_key DB_URL)"

if [[ -z "$SUPABASE_TEST_URL" || -z "$SUPABASE_TEST_SERVICE_ROLE_KEY" || -z "$SUPABASE_TEST_ANON_KEY" || -z "$SUPABASE_TEST_DB_URL" ]]; then
echo "Could not read API_URL/DB_URL/SERVICE_ROLE_KEY/ANON_KEY from 'supabase status'." >&2
exit 1
fi
export SUPABASE_TEST_URL SUPABASE_TEST_SERVICE_ROLE_KEY SUPABASE_TEST_ANON_KEY

if ! command -v psql >/dev/null 2>&1; then
echo "psql not found. Install PostgreSQL's client tools before running stack tests." >&2
exit 1
fi

# A newly started local stack contains Supabase's system schemas but none of
# Mike's application tables. Initialize only an empty stack: silently resetting
# or modifying an existing application database would be surprising.
PROJECTS_TABLE="$(
psql "$SUPABASE_TEST_DB_URL" -XAtq \
-c "select to_regclass('public.projects');"
)"
if [[ "$PROJECTS_TABLE" != "projects" ]]; then
echo "Mike schema not found; loading $SCHEMA_FILE"
psql "$SUPABASE_TEST_DB_URL" -X \
--set ON_ERROR_STOP=1 \
--file "$SCHEMA_FILE"
fi

echo "Running stack integration tests against $SUPABASE_TEST_URL"
cd "$BACKEND_DIR"
exec npx vitest run \
src/__tests__/integration/stack.supabase.test.ts \
src/__tests__/integration/access.supabase.test.ts \
"$@"
97 changes: 97 additions & 0 deletions backend/src/__tests__/integration/access.supabase.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createClient } from "@supabase/supabase-js";
import { describe, expect, it } from "vitest";
import {
filterAccessibleDocumentIds,
listAccessibleProjectIds,
} from "../../lib/access";

// Gated: runs only against a real (local) Supabase stack.
// supabase start, then export:
// SUPABASE_TEST_URL, SUPABASE_TEST_SERVICE_ROLE_KEY
// or use scripts/test-stack.sh which reads them from `supabase status`.
const url = process.env.SUPABASE_TEST_URL;
const serviceKey = process.env.SUPABASE_TEST_SERVICE_ROLE_KEY;

const maybeDescribe = url && serviceKey ? describe : describe.skip;

maybeDescribe("Supabase access integration", () => {
it("proves tabular document filtering drops foreign document IDs", async () => {
const admin = createClient(url!, serviceKey!, {
auth: { persistSession: false },
});
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const ownerId = crypto.randomUUID();
const reviewerId = crypto.randomUUID();
const sharedProjectId = crypto.randomUUID();
const privateProjectId = crypto.randomUUID();
const sharedDocId = crypto.randomUUID();
const privateDocId = crypto.randomUUID();

try {
const projectsInsert = await admin.from("projects").insert([
{
id: sharedProjectId,
user_id: ownerId,
name: `shared-${suffix}`,
shared_with: [`reviewer-${suffix}@example.com`],
},
{
id: privateProjectId,
user_id: ownerId,
name: `private-${suffix}`,
shared_with: [],
},
]);
if (projectsInsert.error) {
throw new Error(
`Could not seed projects: ${projectsInsert.error.message}`,
{ cause: projectsInsert.error },
);
}

// filename/file_type live on document_versions in this schema —
// the documents rows only need identity + ownership columns.
const documentsInsert = await admin.from("documents").insert([
{
id: sharedDocId,
user_id: ownerId,
project_id: sharedProjectId,
},
{
id: privateDocId,
user_id: ownerId,
project_id: privateProjectId,
},
]);
if (documentsInsert.error) {
throw new Error(
`Could not seed documents: ${documentsInsert.error.message}`,
{ cause: documentsInsert.error },
);
}

await expect(
listAccessibleProjectIds(
reviewerId,
`reviewer-${suffix}@example.com`,
admin as any,
),
).resolves.toContain(sharedProjectId);

await expect(
filterAccessibleDocumentIds(
[sharedDocId, privateDocId],
reviewerId,
`reviewer-${suffix}@example.com`,
admin as any,
),
).resolves.toEqual([sharedDocId]);
} finally {
await admin.from("documents").delete().in("id", [sharedDocId, privateDocId]);
await admin
.from("projects")
.delete()
.in("id", [sharedProjectId, privateProjectId]);
}
});
});
173 changes: 173 additions & 0 deletions backend/src/__tests__/integration/chat.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";

// Hoisted mock fn so the vi.mock factory below (which is itself hoisted above
// the imports) can reference it. Lets each test drive the stream outcome.
const { runLLMStream } = vi.hoisted(() => ({
runLLMStream: vi.fn(),
}));

// A permissive, chainable Supabase stub. Every query-builder method returns the
// same object (so arbitrary chains work), the object is awaitable (thenable),
// and the terminal single()/maybeSingle() resolve to a chat row. The chat
// routes only read `.id`/`.title` and check `.error`, so this is enough to let
// a request flow through chat creation and message inserts without real IO.
function makeQuery() {
const result = { data: { id: "chat-1", title: null }, error: null };
const q: Record<string, unknown> = {};
const chain = [
"select", "insert", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.single = vi.fn(() => Promise.resolve(result));
q.maybeSingle = vi.fn(() => Promise.resolve(result));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(result).then(resolve, reject);
return q;
}

function mockSupabase() {
return {
from: vi.fn(() => makeQuery()),
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}

vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));

// Authenticate every request as user "u1" without exercising the real Supabase
// JWT path. requireMfaIfEnrolled must be exported too — userRouter (mounted by
// the app) imports it at module load.
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
next();
},
requireMfaIfEnrolled: (_req: unknown, _res: unknown, next: () => void) =>
next(),
}));

// Keep the real error helpers (the failure-path test relies on genuine
// isAbortError + AssistantStreamError behavior) but stub the functions that
// would otherwise hit the DB or the LLM.
vi.mock("../../lib/chat", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/chat")>();
return {
...actual,
buildDocContext: vi.fn(async () => ({ docIndex: {}, docStore: new Map() })),
enrichWithPriorEvents: vi.fn(async (messages: unknown) => messages),
buildWorkflowStore: vi.fn(async () => new Map()),
buildMessages: vi.fn(() => []),
runLLMStream: (...args: unknown[]) => runLLMStream(...args),
};
});

vi.mock("../../lib/userSettings", () => ({
getUserModelSettings: vi.fn(async () => ({
legal_research_us: false,
title_model: "test-model",
tabular_model: "test-model",
api_keys: {},
})),
getUserApiKeys: vi.fn(async () => ({})),
}));

import { app } from "../../app";

const VALID_BODY = { messages: [{ role: "user", content: "hello" }] };

describe("POST /chat — streaming endpoint", () => {
beforeEach(() => {
vi.clearAllMocks();
runLLMStream.mockResolvedValue({
fullText: "hi there",
events: [],
citations: [],
});
});

it("streams SSE with a chat_id event on the happy path", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);

expect(res.status).toBe(200);
expect(res.headers["content-type"]).toContain("text/event-stream");
expect(res.text).toContain('"type":"chat_id"');
expect(runLLMStream).toHaveBeenCalledTimes(1);
});

it("surfaces a stream failure as an in-stream error event, not an HTTP error", async () => {
runLLMStream.mockRejectedValue(new Error("upstream LLM failure"));

const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);

// Headers were already flushed (200) before the stream threw, so the
// failure surfaces as an in-stream error event + [DONE].
expect(res.status).toBe(200);
expect(res.text).toContain('"type":"error"');
expect(res.text).toContain("[DONE]");
});

it("returns 400 on an empty messages array (never starts a stream)", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send({ messages: [] });

expect(res.status).toBe(400);
expect(res.body).toHaveProperty("detail");
expect(runLLMStream).not.toHaveBeenCalled();
});

it("returns 400 when messages is missing entirely", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send({});

expect(res.status).toBe(400);
expect(runLLMStream).not.toHaveBeenCalled();
});

it("returns 400 when chat_id is not a non-empty string", async () => {
const res = await request(app)
.post("/chat")
.set("Authorization", "Bearer test")
.send({ ...VALID_BODY, chat_id: " " });

expect(res.status).toBe(400);
expect(res.body.detail).toBe("chat_id must be a non-empty string");
expect(runLLMStream).not.toHaveBeenCalled();
});
});

describe("PATCH /chat/:chatId", () => {
it("returns 400 when title is missing", async () => {
const res = await request(app)
.patch("/chat/chat-1")
.set("Authorization", "Bearer test")
.send({});

expect(res.status).toBe(400);
expect(res.body.detail).toBe("title is required");
});
});
Loading
Loading