Compare commits

...

25 commits
v0.4.0 ... main

Author SHA1 Message Date
Will Chen
c0ff4404b3
Merge pull request #233 from amal66/olp-pr/backend-integration-tests
Some checks are pending
CI / Backend build and tests (push) Waiting to run
CI / Frontend build and tests (push) Waiting to run
CI / Eval harness (push) Waiting to run
[Testing 08] test: route-level integration tests + real-Supabase auth/RLS suites
2026-07-23 21:48:55 +08:00
willchen96
b2dbb39c62 narrow service role schema grants 2026-07-23 21:37:11 +08:00
willchen96
9399fce86d fix stack test database initialization 2026-07-23 19:08:08 +08:00
Will Chen
7460b065e5
Merge pull request #240 from amal66/olp-pr/lockfile-merge-guard
ci: guard against silently merge-corrupted lockfiles
2026-07-23 18:27:51 +08:00
QA Runner
e25c2637ec ci: guard against silently merge-corrupted lockfiles
PR #233's CI failed with "npm ci can only install with an existing
package-lock.json" even though the file existed: a "Merge branch 'main'"
commit had auto-merged backend/package.json and package-lock.json into
invalid JSON with no conflict raised, and npm reports an unparseable
lockfile as if it were missing.

Two guards: .gitattributes marks package-lock.json/bun.lock merge=binary so
concurrent lockfile changes surface as explicit conflicts (resolve by
regenerating, never hand-merging), and CI parse-checks package.json and the
lockfile before npm ci so any corruption that still lands fails with the
real reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:23:15 -07:00
QA Runner
6aed350a3c fix: repair JSON corrupted by the merge of main (package.json + lockfile)
The "Merge branch 'main'" commit (71a7aba) auto-merged backend/package.json
and backend/package-lock.json without raising a conflict, but git's
line-level merge produced invalid JSON in both files: package.json lost the
comma after the new "test:stack" script, and package-lock.json lost the two
closing-brace lines of the supertest/cookie-signature entry. npm treats an
unparseable lockfile as absent, which is why CI failed with the misleading
"npm ci can only install with an existing package-lock.json".

package.json: restore the comma. package-lock.json: rebuilt from main's
known-good copy via `npm install --package-lock-only` against this branch's
package.json. Verified locally: npm ci, npm test (259 passed), npm run build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 10:22:26 -07:00
Will Chen
71a7aba337
Merge branch 'main' into olp-pr/backend-integration-tests 2026-07-22 19:56:19 +08:00
Will Chen
2ec89a7c52
Merge pull request #230 from amal66/olp-pr/frontend-unit-tests
Some checks failed
CI / Backend build and tests (push) Has been cancelled
CI / Frontend build and tests (push) Has been cancelled
CI / Eval harness (push) Has been cancelled
[Testing 07] test: component and hook unit tests for the web app
2026-07-22 16:59:39 +08:00
Will Chen
7d2ba4b87f
Merge pull request #235 from amal66/olp-pr/lint-burndown
[Lint 04] fix: burn down frontend eslint errors; make CI lint blocking
2026-07-22 16:33:52 +08:00
Will Chen
73b9cd1692
Merge pull request #232 from amal66/olp-pr/ci-workflows
[CI 03] ci: build and test workflow for backend and frontend
2026-07-22 16:07:57 +08:00
Will Chen
39dc609f95
Merge pull request #234 from amal66/olp-pr/testing-docs
[Docs 02] docs: testing policy in CONTRIBUTING + PR template
2026-07-21 22:16:01 +08:00
Will Chen
948e9bdd4d
Merge pull request #237 from amal66/olp-pr/coverage-critical-libs
[Testing 09] test: unit-cover the highest-risk backend libs + raise the coverage ratchet
2026-07-21 18:16:40 +08:00
Will Chen
eda088e143
Merge pull request #238 from amal66/olp-pr/directory-fetch-storm
[Fix 01] Batch the directory modal's project fetch (N+1 getProject storm)
2026-07-21 16:51:36 +08:00
cosimoastrada
8a34a6457f
Merge pull request #228 from amal66/olp-pr/test-harness
[Testing 05] test: minimal vitest harness for backend and frontend
2026-07-21 16:21:11 +08:00
QA Runner
36cddb2566 fix: batch directory-modal project fetch instead of N+1 getProject burst
Every directory picker (AddDocumentsModal, UseWorkflowModal, the assistant
project selector) loads its "Projects" tab through useDirectoryData, which
fired one GET /projects/:id for EVERY existing project the moment the modal
opened. Each of those requests costs an auth verification against GoTrue
plus ~6 PostgREST queries, so an account with N projects produced an
~8xN-request burst on the Supabase gateway per modal open.

Under that burst the gateway genuinely falls over: measured locally against
the Supabase CLI stack, overlapping modal-open storms drove GoTrue into
Postgres connection exhaustion ("failed to connect ... context deadline
exceeded") and produced 467x 500 + 641x 504 on /auth/v1/user in a single
run — surfacing to users as failed project creates/loads, and to the e2e
suite as the intermittent Kong 502s its specs currently retry around.

Fix: GET /projects now accepts ?include=documents and returns each
project's documents from one batched query (same attach helpers as
GET /projects/:id, run once across all documents), and useDirectoryData
uses it. A modal open is now 1 API request and a fixed number of DB
queries regardless of project count. After the change the same storm
harness produced zero 5xx and zero auth failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 22:05:48 -07:00
cosimoastrada
0843e2909a
Merge pull request #212 from jmooves/upstream-pr/chat-messages-workflow-column
fix(db): add missing chat_messages.workflow column (user prompts dropped on reload)
2026-07-21 12:49:06 +08:00
QA Runner
2053ca15c8 test: cover critical backend libs, raise coverage ratchet, add roadmap
Six new unit suites (102 tests) for the highest-risk untested libs:
userDataCleanup (destructive account/project deletes), documentVersions
(document integrity), chat/citations (legal-citation parsing), safeError
(secret redaction), llm/models (model resolution), and userLookup
(profile email sync). Coverage floors ratchet up 2/2/4/2 ->
11/10/14/10 (measured 11.18/10.98/14.43/10.91), and
docs/testing-coverage.md gives contributors a prioritized backlog for
the remaining untested libs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:21:35 -07:00
QA Runner
0740f656e6 fix: burn down frontend eslint errors; make CI lint blocking
Takes frontend/npm run lint from 23 errors / 40 warnings to 0 errors /
40 warnings, then removes continue-on-error from the CI lint step so it
gates merges.

Errors fixed outright:
- @typescript-eslint/no-explicit-any (5, ChatView.tsx): dropped
  redundant (msg as any) casts — Message already declares files,
  workflow, and error with the exact shapes the props expect.
- react/no-unescaped-entities (1, support/page.tsx): "We'll" ->
  "We&apos;ll".

Targeted disables (17, all react-hooks/set-state-in-effect): every site
is an intentional effect-driven state pattern — SSR/hydration mount
gates (Modal, useSelectedModel), reset-on-prop/identity-change
(CaseLawPanel x3, ChatView chat switch, CitationQuotesHeader,
ChatHistoryContext logout, useFetchDocxBytes), sync fast paths of async
fetch/check effects (CaseLawPanel, MfaLoginGate x2), DOM-measured state
(ChatView scroll button, message-visibility restore), and timed UI
latches (PreResponseWrapper, TRChatPanel, AskInputPopup auto-submit).
Rewriting any of them would change runtime behavior, so each carries a
// eslint-disable-next-line with a one-line reason instead of a fix or
a repo-wide rule change.

CI: lint step is now blocking; comments updated to say the backlog is
at zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:17:24 -07:00
QA Runner
45a4f7508c test: route-level integration tests + app/index split
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>
2026-07-20 11:00:01 -07:00
QA Runner
a29e67deac test: component and hook unit tests for the web app
Ported from the amal66 fork (index: Open-Legal-Products/mike#205), adapted
to this repo's frontend/ layout and current component behavior. Adds the
jsdom + testing-library harness on top of the existing vitest setup:
vitest.config.mts (jsdom environment, @/ alias mirroring tsconfig paths,
dummy Supabase env for modules that build a client at import time) and
vitest.setup.ts (jest-dom matchers). New devDependencies: jsdom,
@testing-library/react, @testing-library/jest-dom,
@testing-library/user-event, @vitejs/plugin-react.

Ported suites (30 new tests):
- FileTypeIcon.test.tsx (11) — fileTypeKind mapping + icon rendering
- TRTable.test.tsx (1) — header/row render smoke test; the fork's ARIA
  role assertions (table/columnheader, "Tabular review" label) target
  fork-only markup — this repo's grid is div-based, so the test asserts
  rendered content instead
- button.test.tsx (4), pill-button.test.tsx (8), cite-button.test.tsx (3)
- useSmoothedReveal.test.ts (3) — passes against this repo's early-return
  snap behavior unchanged

Dropped (subjects don't exist here):
- HistoryDropdown.test.tsx — no tr-chat-panel/HistoryDropdown component
- applyAssistantStreamEvent.test.ts — no such module
- useProjectsQuery/useTabularReviewsQuery/useWorkflowsQuery tests —
  react-query is fork-only
- lib/toast.test.ts — no frontend/src/lib/toast.ts
- useAssistantChat.parsers.test.ts — the parser helpers exist inside
  useAssistantChat.ts but are not exported, and the fork's extracted
  module doesn't exist here

Verified: frontend npm test 38/38 passing (30 new + 8 pre-existing cn()
utils tests); npx tsc --noEmit clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:50:11 -07:00
QA Runner
c139acc3c6 test: unit tests for access, storage, userApiKeys, chat doc resolution
Ported from the amal66 fork (see index Open-Legal-Products/mike#205)
onto current main, adapted to this repo's backend/ layout
(apps/api/src/lib -> backend/src/lib), plus a v8 coverage ratchet.

Suites ported (51 new tests, verified locally):
- access.test.ts (7): owner/shared/private project access, doc access,
  review sharing, document-ID filtering. Dropped the fork's "org RBAC"
  describe block (7 cases) — org_id/org_members multi-tenancy and the
  role/canManage fields do not exist in this repo's access.ts.
- storage.test.ts (25): filename normalization/sanitization, RFC 5987
  encoding, Content-Disposition, storage key helpers. Dropped the fork's
  vi.mock of lib/env — this repo has no env module; storage reads
  process.env directly and the tested helpers are pure.
- userApiKeys.test.ts (10): normalizeApiKeyProvider + hasEnvApiKey.
  Added a beforeEach env clear so shell-exported API keys can't leak
  into assertions.
- chatTypes.test.ts (9): resolveDoc/resolveDocLabel, which live in
  lib/chat/types.ts here (the fork's lib/chatTools.ts equivalent).
  Dropped generateSpotlightNonce cases (2) — no such export here.

Suites dropped entirely (subject not present in this repo):
- upload.test.ts — tested hasMagicBytes; this repo's lib/upload.ts is
  only the multer middleware and exports no magic-byte checker.
- userSettings.test.ts — tested resolveTabularModel (fork-only keyed-
  provider fallback); this repo resolves tabular_model via
  resolveModel with a static default.

Coverage ratchet: vitest.config.mts adds v8 coverage over src/lib/**
with floors measured against this tree (2.58% stmts, 2.00% branches,
4.61% funcs, 2.58% lines -> floors 2/2/4/2). Full suite: 5 files,
63 tests passing (incl. the pre-existing 12 in downloadTokens.test.ts);
npm run test:coverage and npm run build both pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:49:27 -07:00
QA Runner
15b7b4c925 docs: testing policy in CONTRIBUTING + PR template with verification checklist
Adds a Testing section documenting every suite the testing PR series
introduces (unit/integration via vitest, Playwright e2e, offline evals,
gated real-Supabase stack tests), the expectation that changes carry tests
at the lowest layer that catches the regression, and a PR template (ported
from the amal66 fork, Open-Legal-Products/mike#205) whose checklist asks how
the change was verified. Intended as the capstone of the series — the
commands it documents are introduced by the sibling test PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:46:47 -07:00
QA Runner
b20109ac6c ci: build and test workflow for backend and frontend
Ported from amal66/mike#46 onto current main, extended for this tree:
backend job runs npm ci, tests (--if-present, so it is safe to merge in any
order relative to the test-harness PR) and tsc build; frontend job runs
tests, eslint as an advisory step (main currently carries 23 lint errors —
flip to blocking once burned down), and a production next build with
placeholder NEXT_PUBLIC_* env (verified locally that the build succeeds and
contacts nothing); evals job runs node evals/run.mjs when present, else
skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:45:48 -07:00
QA Runner
4039b94980 test: minimal vitest harness for backend and frontend
Ported from amal66/mike#24 onto current main; lockfiles regenerated against
this tree. Adds vitest as a dev dependency with a `test` script in both
packages, excludes test files from the backend tsc build, and seeds one
suite per package (backend: downloadTokens, 12 tests; frontend: cn() utils,
8 tests). Verified locally: backend 12/12, frontend 8/8 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:42:28 -07:00
JJ
cfdcda6d2c fix(db): add missing chat_messages.workflow column
User messages are persisted with a `workflow` field
(backend/src/routes/chat.ts, projectChat.ts) and it is read back when
rendering chat history (ChatView -> UserMessage) to show which workflow a
message was sent under. But the column is never created by schema.sql or any
migration, so every user-message insert fails with PostgREST PGRST204
("Could not find the 'workflow' column of 'chat_messages'") and is dropped
silently (the insert result is not checked). The assistant insert has no
workflow column, so it succeeds — the net effect on a self-hosted install is
that reloading a thread shows the assistant reply but not the user's prompt.

Add the `workflow jsonb` column to schema.sql (fresh installs) and a
migration (existing installs), mirroring the adjacent content/files jsonb
columns.
2026-07-16 07:44:25 +10:00
60 changed files with 10872 additions and 221 deletions

8
.gitattributes vendored Normal file
View file

@ -0,0 +1,8 @@
# Lockfiles are generated files. Git's line-level text merge can combine
# both sides' insertions into syntactically invalid JSON *without raising a
# conflict* (this silently broke backend/package.json + package-lock.json in
# PR #233). Merge them as binary so any concurrent change surfaces as an
# explicit conflict; resolve by taking main's copy and regenerating:
# git checkout origin/main -- package-lock.json && npm install
package-lock.json merge=binary
bun.lock merge=binary

30
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,30 @@
## Summary
<!-- What does this PR do, in one or two sentences? -->
## Why / Motivation
<!-- What problem does this solve? Why now, and why this approach?
Link the issue or context that prompted it. -->
## Changes
<!-- The notable changes, at a high level. Describe the outcome, not the diff. -->
## Tradeoffs & risks
<!-- What did you weigh? What could break, what's out of scope, and what
follow-ups (if any) does this leave behind? Note any security or
migration implications. -->
## How verified
<!-- How do you know this works? Tests added/run, manual steps, commands,
screenshots. Include the exact commands where relevant. -->
## Checklist
- [ ] Ran the relevant build/test command for the area changed.
- [ ] Reviewed `git diff` and removed unrelated changes.
- [ ] Updated docs / env examples if setup, config, or behavior changed.
- [ ] No secrets, API keys, real documents, or `.env` files committed.

112
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,112 @@
# CI: build and test.
#
# Adapted from the amal66/mike fork's .github/workflows/ci.yml (monorepo
# layout) to this repository's backend/ + frontend/ layout. Test steps use
# `npm test --if-present`, and the eval job checks for evals/run.mjs, so this
# workflow is safe to merge before or after the test-harness and evals PRs:
# on a tree without those pieces the test steps no-op and the build still
# gates the merge. Beyond the fork version this also builds the frontend
# (placeholder NEXT_PUBLIC_* env — verified sufficient for `next build`) and
# runs eslint as a blocking gate (the error backlog is at zero).
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend build and tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: backend/package-lock.json
# Git's line-level merge can splice both sides of package(-lock).json
# into invalid JSON without a conflict (bit PR #233). npm's own error
# for an unparseable lockfile is the misleading "npm ci can only
# install with an existing package-lock.json" — fail fast with the real
# reason instead.
- name: Validate package.json and lockfile parse
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"
- run: npm ci
# No-ops on a tree without a "test" script (e.g. before the vitest
# harness PR merges); runs the suite once it exists.
- run: npm test --if-present
- run: npm run build
frontend:
name: Frontend build and tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
# Same silent-merge-corruption guard as the backend job.
- name: Validate package.json and lockfile parse
run: node -e "for (const f of ['package.json','package-lock.json']) JSON.parse(require('fs').readFileSync(f, 'utf8'))"
- run: npm ci
# No-ops on a tree without a "test" script (e.g. before the vitest
# harness PR merges); runs the suite once it exists.
- run: npm test --if-present
# Blocking gate: the eslint error backlog was burned down in this PR
# (0 errors; warnings do not fail the step), so any new error fails CI.
- run: npm run lint
# Production build. NEXT_PUBLIC_* values are inlined at build time and
# only need to be well-formed here — nothing is contacted during build.
# This catches type errors (next build runs tsc) and broken routes/imports.
- run: npm run build
env:
NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: sb_publishable_placeholder
NEXT_PUBLIC_API_BASE_URL: http://localhost:3001
# -----------------------------------------------------------------------
# Offline eval harness: deterministic scorecard for citation accuracy,
# prompt-injection resistance, and privilege/PII leakage. Runs against
# committed fixtures (no network, no LLM calls, no secrets), so it is cheap
# enough to gate every PR. --threshold 1.0 = every case must pass.
# Skips gracefully until the evals PR merges.
# -----------------------------------------------------------------------
evals:
name: Eval harness
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Run eval harness (skips if evals/ not present)
run: |
if [ -f evals/run.mjs ]; then
node evals/run.mjs --threshold 1.0
else
echo "evals/run.mjs not present on this tree; skipping"
fi

View file

@ -54,3 +54,22 @@ Frontend:
```bash
npm run build --prefix frontend
```
## Testing
```bash
npm test --prefix backend # backend unit + route integration tests (vitest)
npm test --prefix frontend # frontend component/hook tests (vitest + jsdom)
npm run test:e2e # Playwright end-to-end suite — see docs/e2e-ci.md
node evals/run.mjs --threshold 1.0 # offline eval harness (no network, no API keys)
npm run test:stack --prefix backend # gated: real-Supabase auth/access tests (run `supabase start` first)
```
- New features and bug fixes should come with a test at the lowest layer that
can catch the regression: unit first, then route-level integration, then
end-to-end only for flows a browser is genuinely needed to prove.
- CI runs the build, unit/integration tests, and the eval harness on every PR
(`.github/workflows/ci.yml`), and the Playwright suite in a full local stack
(`.github/workflows/e2e.yml`).
- Tests that need a live Supabase or an LLM key are env-gated and skip cleanly
when the environment is absent — a plain `npm test` should always be green.

View file

@ -0,0 +1,12 @@
-- Add chat_messages.workflow.
--
-- The app persists a `workflow` field on user messages
-- (backend/src/routes/chat.ts, projectChat.ts) and reads it back when rendering
-- chat history (frontend ChatView -> UserMessage, to show which workflow the
-- message was sent under). The column was referenced in code but never created
-- by schema.sql or any migration, so every user-message insert failed with
-- PostgREST PGRST204 ("Could not find the 'workflow' column") and was dropped
-- silently — user prompts vanished on reload while the assistant reply
-- persisted. Mirrors the existing content/files jsonb columns.
alter table public.chat_messages
add column if not exists workflow jsonb;

1764
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,10 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
"start": "node dist/index.js",
"test": "vitest run",
"test:stack": "bash scripts/test-stack.sh",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
@ -36,9 +39,13 @@
"@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"
"typescript": "^5.8.3",
"vitest": "^4.1.9"
},
"license": "AGPL-3.0-only"
}

View file

@ -550,6 +550,7 @@ create table if not exists public.chat_messages (
role text not null,
content jsonb,
files jsonb,
workflow jsonb,
citations jsonb,
created_at timestamptz not null default now()
);
@ -884,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
backend/scripts/test-stack.sh Executable file
View file

@ -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 \
"$@"

View file

@ -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]);
}
});
});

View file

@ -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");
});
});

View file

@ -0,0 +1,108 @@
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
function mockSupabase() {
const result = { data: null, error: null };
const q: Record<string, unknown> = {};
const chain = [
"select", "insert", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "lt", "order", "limit",
];
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) =>
Promise.resolve(result).then(resolve);
return {
from: vi.fn(() => q),
rpc: vi.fn(() => Promise.resolve(result)),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
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(),
}));
// Stub the storage IO functions so a request that clears validation never
// touches R2/S3, while keeping the rest of the storage module (key builders,
// disposition helpers) real. The validation tests below reject before storage
// is reached, but this guards against accidental real IO regardless.
vi.mock("../../lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/storage")>();
return {
...actual,
uploadFile: vi.fn(async () => {}),
downloadFile: vi.fn(async () => null),
deleteFile: vi.fn(async () => {}),
};
});
import { app } from "../../app";
describe("POST /single-documents — upload validation", () => {
it("rejects an unsupported file extension with 400", async () => {
const res = await request(app)
.post("/single-documents")
.set("Authorization", "Bearer test")
.attach("file", Buffer.from("hello world"), {
filename: "notes.txt",
contentType: "text/plain",
});
expect(res.status).toBe(400);
expect(res.body.detail).toMatch(/unsupported file type/i);
});
it("rejects a request with no file attached with 400", async () => {
const res = await request(app)
.post("/single-documents")
.set("Authorization", "Bearer test")
.field("note", "no file here");
expect(res.status).toBe(400);
expect(res.body.detail).toBe("file is required");
});
});
describe("POST /single-documents/download-zip — bounds", () => {
it("returns 400 when document_ids is empty", async () => {
const res = await request(app)
.post("/single-documents/download-zip")
.set("Authorization", "Bearer test")
.send({ document_ids: [] });
expect(res.status).toBe(400);
expect(res.body.detail).toMatch(/document_ids is required/i);
});
it("returns 404 when none of the requested documents are accessible", async () => {
// The documents lookup resolves to no rows (stubbed DB), so the
// access filter leaves nothing to zip.
const res = await request(app)
.post("/single-documents/download-zip")
.set("Authorization", "Bearer test")
.send({ document_ids: ["d-other-user"] });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("No documents found");
});
});

View file

@ -0,0 +1,80 @@
import { describe, it, expect, vi } from "vitest";
import request from "supertest";
// requireAuth reads SUPABASE_URL / SUPABASE_SECRET_KEY from process.env at
// request time (not import time), so setting them here is early enough even
// though imported modules evaluate before this assignment runs.
process.env.SUPABASE_URL = "http://supabase.test.local";
process.env.SUPABASE_SECRET_KEY = "test-service-key";
// Mock the supabase-js client factory so the real requireAuth middleware never
// makes a network call: auth.getUser() resolves to no user for any token,
// simulating an invalid/expired JWT.
vi.mock("@supabase/supabase-js", () => ({
createClient: vi.fn(() => ({
from: () => {
const q: Record<string, unknown> = {};
const chain = [
"select", "insert", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "filter",
"order", "limit",
];
for (const m of chain) q[m] = () => q;
q.single = () => Promise.resolve({ data: null, error: null });
q.maybeSingle = () => Promise.resolve({ data: null, error: null });
q.then = (resolve: (v: unknown) => unknown) =>
Promise.resolve({ data: null, error: null }).then(resolve);
return q;
},
rpc: () => Promise.resolve({ data: null, error: null }),
auth: {
getUser: () =>
Promise.resolve({ data: { user: null }, error: null }),
},
})),
}));
// Vitest hoists vi.mock() calls before all imports, so this regular import
// receives the mocked supabase-js module even though it appears after the
// vi.mock() call in source order.
import { app } from "../../app";
describe("GET /health", () => {
it("returns 200 with { ok: true }", async () => {
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
});
});
describe("requireAuth middleware", () => {
it("rejects requests with no Authorization header (401)", async () => {
const res = await request(app).get("/chat");
expect(res.status).toBe(401);
expect(res.body).toHaveProperty("detail");
});
it("rejects requests with a non-Bearer Authorization header (401)", async () => {
const res = await request(app)
.get("/chat")
.set("Authorization", "Basic dXNlcjpwYXNz");
expect(res.status).toBe(401);
});
it("rejects requests with an invalid Bearer token (401)", async () => {
// The mocked createClient().auth.getUser returns { user: null } for
// any token — simulating an expired/invalid token.
const res = await request(app)
.get("/chat")
.set("Authorization", "Bearer invalid-token");
expect(res.status).toBe(401);
expect(res.body.detail).toMatch(/invalid|expired/i);
});
});
describe("404 handling", () => {
it("returns 404 for unknown routes", async () => {
const res = await request(app).get("/this-route-does-not-exist");
expect(res.status).toBe(404);
});
});

View file

@ -0,0 +1,149 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
const { runLLMStream, checkProjectAccess } = vi.hoisted(() => ({
runLLMStream: vi.fn(),
checkProjectAccess: vi.fn(),
}));
function makeQuery() {
const result = {
data: { id: "chat-1", title: null, project_id: "p1" },
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"),
}));
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(),
}));
vi.mock("../../lib/chat", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/chat")>();
return {
...actual,
buildProjectDocContext: vi.fn(async () => ({
docIndex: {},
docStore: new Map(),
folderPaths: 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 () => ({})),
}));
vi.mock("../../lib/access", () => ({
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids),
listAccessibleProjectIds: vi.fn(async () => []),
}));
import { app } from "../../app";
const VALID_BODY = { messages: [{ role: "user", content: "hello" }] };
describe("POST /projects/:projectId/chat", () => {
beforeEach(() => {
vi.clearAllMocks();
runLLMStream.mockResolvedValue({
fullText: "",
events: [],
citations: [],
});
checkProjectAccess.mockResolvedValue({
ok: true,
isOwner: true,
project: { id: "p1", user_id: "u1", shared_with: null },
});
});
it("returns 404 and never streams when project access is denied", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/projects/p1/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
// The guard fires before any LLM stream.
expect(runLLMStream).not.toHaveBeenCalled();
});
it("streams SSE on the happy path with project access granted", async () => {
const res = await request(app)
.post("/projects/p1/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("/projects/p1/chat")
.set("Authorization", "Bearer test")
.send(VALID_BODY);
expect(res.status).toBe(200);
expect(res.text).toContain('"type":"error"');
expect(res.text).toContain("[DONE]");
});
});

View file

@ -0,0 +1,415 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// ---------------------------------------------------------------------------
// Hoisted mock fns we want to reconfigure per-test.
// ---------------------------------------------------------------------------
const { checkProjectAccess, deleteUserProjects } = vi.hoisted(() => ({
checkProjectAccess: vi.fn(),
deleteUserProjects: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Configurable Supabase stub. Each test seeds `supabaseState` in beforeEach;
// terminal query operations (.single()/.maybeSingle()/thenable) resolve to the
// per-table result, and rpc() resolves to a per-call result. Insert payloads
// are recorded so tests can assert on normalisation (lowercasing / dedupe).
// ---------------------------------------------------------------------------
type QueryResult = { data: unknown; error: unknown };
let supabaseState: {
rpc: QueryResult;
tables: Record<string, QueryResult>;
inserts: { table: string; payload: unknown }[];
};
function resetSupabaseState() {
supabaseState = {
rpc: { data: [], error: null },
tables: {},
inserts: [],
};
}
resetSupabaseState();
function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}
function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
"select", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.insert = vi.fn((payload: unknown) => {
supabaseState.inserts.push({ table, payload });
return q;
});
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(resultForTable(table)).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn((table: string) => makeQuery(table)),
rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
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(),
}));
// Every export of lib/access must be present — other routers (chat, documents,
// downloads, tabular) import from it at app load.
vi.mock("../../lib/access", () => ({
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
ensureReviewAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
filterAccessibleDocumentIds: vi.fn(async (ids: string[]) => ids),
listAccessibleProjectIds: vi.fn(async () => []),
}));
// user router imports all four cleanup helpers at module load.
vi.mock("../../lib/userDataCleanup", () => ({
deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args),
deleteAllUserChats: vi.fn(async () => {}),
deleteAllUserTabularReviews: vi.fn(async () => {}),
deleteUserAccountData: vi.fn(async () => {}),
}));
// Version-path enrichment hits the DB in real life; no-op it so the route
// responses are driven purely by the documents/projects table stubs.
vi.mock("../../lib/documentVersions", () => ({
attachActiveVersionPaths: vi.fn(async () => {}),
attachLatestVersionNumbers: vi.fn(async () => {}),
loadActiveVersion: vi.fn(async () => null),
}));
import { app } from "../../app";
const AUTH = ["Authorization", "Bearer test"] as const;
describe("projects.routes", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSupabaseState();
checkProjectAccess.mockResolvedValue({
ok: true,
isOwner: true,
project: { id: "p1", user_id: "u1", shared_with: null },
});
deleteUserProjects.mockResolvedValue(1);
});
// ── GET /projects (overview) ──────────────────────────────────────────
describe("GET /projects", () => {
it("returns the overview rows from the RPC", async () => {
supabaseState.rpc = {
data: [{ id: "p1", name: "Alpha" }],
error: null,
};
const res = await request(app).get("/projects").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "p1", name: "Alpha" }]);
});
it("returns 500 with detail when the RPC errors", async () => {
supabaseState.rpc = { data: null, error: { message: "boom" } };
const res = await request(app).get("/projects").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("boom");
});
});
// ── POST /projects (create) ───────────────────────────────────────────
describe("POST /projects", () => {
it("returns 400 when name is missing/blank", async () => {
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: " " });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("name is required");
});
it("returns 400 when sharing the project with yourself", async () => {
// The authed user's email is u1@test.local; supplying it (in any
// case) must be rejected.
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Beta", shared_with: ["U1@Test.Local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a project with yourself.",
);
});
it("creates the project (201) and normalises shared_with", async () => {
// Sharing requires each recipient to have a mirrored user_profiles
// row (findMissingUserEmails); seed both emails so validation
// passes and the create path proceeds.
supabaseState.tables.user_profiles = {
data: [{ email: "a@x.com" }, { email: "b@x.com" }],
error: null,
};
supabaseState.tables.projects = {
data: {
id: "p9",
name: "Gamma",
user_id: "u1",
shared_with: ["a@x.com", "b@x.com"],
},
error: null,
};
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({
name: " Gamma ",
shared_with: ["A@x.com", "a@x.com", "B@X.com", "", " "],
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "p9", documents: [] });
// The insert payload should be lowercased, deduped, trimmed and
// the name trimmed.
const insert = supabaseState.inserts.find(
(i) => i.table === "projects",
);
expect(insert?.payload).toMatchObject({
name: "Gamma",
shared_with: ["a@x.com", "b@x.com"],
});
});
it("returns 400 when a shared_with recipient is not a Mike user", async () => {
// No user_profiles rows seeded → findMissingUserEmails reports the
// recipient as unknown and the create is rejected before insert.
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Gamma", shared_with: ["ghost@x.com"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"ghost@x.com does not belong to a Mike user.",
);
expect(
supabaseState.inserts.find((i) => i.table === "projects"),
).toBeUndefined();
});
it("returns 500 when the insert errors", async () => {
supabaseState.tables.projects = {
data: null,
error: { message: "insert failed" },
};
const res = await request(app)
.post("/projects")
.set(...AUTH)
.send({ name: "Delta" });
expect(res.status).toBe(500);
expect(res.body.detail).toBe("insert failed");
});
});
// ── GET /projects/:projectId (detail, inline access) ──────────────────
describe("GET /projects/:projectId", () => {
it("returns 404 when the project does not exist", async () => {
supabaseState.tables.projects = { data: null, error: null };
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 404 when the caller is neither owner nor shared", async () => {
supabaseState.tables.projects = {
data: {
id: "p1",
user_id: "someone-else",
shared_with: ["other@x.com"],
},
error: null,
};
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("grants access to a shared member (is_owner false)", async () => {
supabaseState.tables.projects = {
data: {
id: "p1",
user_id: "someone-else",
shared_with: ["u1@test.local"],
},
error: null,
};
supabaseState.tables.documents = { data: [], error: null };
supabaseState.tables.project_subfolders = { data: [], error: null };
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ id: "p1", is_owner: false });
});
it("returns 200 with documents/folders/is_owner when owned", async () => {
supabaseState.tables.projects = {
data: { id: "p1", user_id: "u1", shared_with: null },
error: null,
};
supabaseState.tables.documents = {
data: [{ id: "d1", user_id: "u1" }],
error: null,
};
supabaseState.tables.project_subfolders = {
data: [{ id: "f1" }],
error: null,
};
const res = await request(app).get("/projects/p1").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "p1",
is_owner: true,
documents: [{ id: "d1" }],
folders: [{ id: "f1" }],
});
});
});
// ── GET /projects/:projectId/documents (checkProjectAccess guard) ─────
describe("GET /projects/:projectId/documents", () => {
it("returns 404 when checkProjectAccess denies access", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.get("/projects/p1/documents")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
});
it("returns 200 with documents when access is granted", async () => {
supabaseState.tables.documents = {
data: [{ id: "d1" }, { id: "d2" }],
error: null,
};
const res = await request(app)
.get("/projects/p1/documents")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "d1" }, { id: "d2" }]);
expect(checkProjectAccess).toHaveBeenCalledTimes(1);
});
});
// ── PATCH /projects/:projectId (sharing normalisation) ────────────────
describe("PATCH /projects/:projectId", () => {
it("returns 400 when sharing the project with yourself", async () => {
const res = await request(app)
.patch("/projects/p1")
.set(...AUTH)
.send({ shared_with: ["u1@test.local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a project with yourself.",
);
});
it("returns 404 when the update matches no owned project", async () => {
supabaseState.tables.projects = { data: null, error: null };
const res = await request(app)
.patch("/projects/p1")
.set(...AUTH)
.send({ name: "Renamed" });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
});
// ── DELETE /projects/:projectId ───────────────────────────────────────
describe("DELETE /projects/:projectId", () => {
it("returns 404 when nothing was deleted", async () => {
deleteUserProjects.mockResolvedValue(0);
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 204 when the project is deleted", async () => {
deleteUserProjects.mockResolvedValue(1);
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(204);
// Signature is deleteUserProjects(db, userId, [projectId]).
expect(deleteUserProjects).toHaveBeenCalledWith(
expect.anything(),
"u1",
["p1"],
);
});
it("returns 500 when deletion throws", async () => {
deleteUserProjects.mockRejectedValue(new Error("cascade failed"));
const res = await request(app).delete("/projects/p1").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("cascade failed");
});
});
});

View file

@ -0,0 +1,146 @@
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([]);
});
});

View file

@ -0,0 +1,713 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// ---------------------------------------------------------------------------
// Hoisted mock fns reconfigured per-test. Access helpers + model settings are
// mocked so the tests drive review-access decisions, document-access filtering
// and the missing-API-key guard without touching real Supabase / LLM IO. The
// streaming endpoints (chat/generate) are only exercised up to their GUARDS —
// the SSE loop itself is never reached in these tests.
// ---------------------------------------------------------------------------
const {
ensureReviewAccess,
checkProjectAccess,
filterAccessibleDocumentIds,
getUserModelSettings,
loadActiveVersion,
} = vi.hoisted(() => ({
ensureReviewAccess: vi.fn(),
checkProjectAccess: vi.fn(),
filterAccessibleDocumentIds: vi.fn(),
getUserModelSettings: vi.fn(),
loadActiveVersion: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Configurable Supabase stub (mirrors projects.routes.test). Each test seeds
// `supabaseState` in beforeEach; terminal query operations resolve to the
// per-table result, rpc() resolves to a per-call result. Insert payloads are
// recorded so tests can assert on what got persisted.
// ---------------------------------------------------------------------------
type QueryResult = { data: unknown; error: unknown };
let supabaseState: {
rpc: QueryResult;
tables: Record<string, QueryResult>;
inserts: { table: string; payload: unknown }[];
};
function resetSupabaseState() {
supabaseState = {
rpc: { data: [], error: null },
tables: {},
inserts: [],
};
}
resetSupabaseState();
function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}
function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
"select", "update", "delete", "upsert",
"eq", "neq", "in", "is", "or", "not", "lt", "gt", "gte", "lte",
"filter", "order", "limit", "range", "contains",
];
for (const m of chain) q[m] = vi.fn(() => q);
q.insert = vi.fn((payload: unknown) => {
supabaseState.inserts.push({ table, payload });
return q;
});
q.single = vi.fn(() => Promise.resolve(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.then = (resolve: (v: unknown) => unknown, reject?: (e: unknown) => unknown) =>
Promise.resolve(resultForTable(table)).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn((table: string) => makeQuery(table)),
rpc: vi.fn(() => Promise.resolve(supabaseState.rpc)),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
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(),
}));
vi.mock("../../lib/access", () => ({
ensureReviewAccess: (...args: unknown[]) => ensureReviewAccess(...args),
checkProjectAccess: (...args: unknown[]) => checkProjectAccess(...args),
filterAccessibleDocumentIds: (...args: unknown[]) =>
filterAccessibleDocumentIds(...args),
ensureDocAccess: vi.fn(async () => ({ ok: true, isOwner: true })),
listAccessibleProjectIds: vi.fn(async () => []),
}));
vi.mock("../../lib/userSettings", () => ({
getUserModelSettings: (...args: unknown[]) => getUserModelSettings(...args),
getUserApiKeys: vi.fn(async () => ({})),
}));
// Version-path enrichment + active-version resolution hit the DB in real life;
// no-op them so route responses are driven purely by the table stubs.
vi.mock("../../lib/documentVersions", () => ({
attachActiveVersionPaths: vi.fn(async () => {}),
attachLatestVersionNumbers: vi.fn(async () => {}),
loadActiveVersion: (...args: unknown[]) => loadActiveVersion(...args),
}));
import { app } from "../../app";
const AUTH = ["Authorization", "Bearer test"] as const;
describe("tabular.routes", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSupabaseState();
// Default: caller is the owner with full access.
ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: true });
checkProjectAccess.mockResolvedValue({
ok: true,
isOwner: true,
project: { id: "p1", user_id: "u1", shared_with: null },
});
// Default: every requested doc is accessible (identity passthrough).
filterAccessibleDocumentIds.mockImplementation(
async (ids: string[]) => ids,
);
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: { claude: "sk-test" },
});
loadActiveVersion.mockResolvedValue(null);
});
// ── GET /tabular-review (overview) ────────────────────────────────────
describe("GET /tabular-review", () => {
it("returns the overview rows from the RPC", async () => {
supabaseState.rpc = {
data: [{ id: "r1", title: "Alpha" }],
error: null,
};
const res = await request(app).get("/tabular-review").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([{ id: "r1", title: "Alpha" }]);
});
it("returns 500 with detail when the RPC errors", async () => {
supabaseState.rpc = { data: null, error: { message: "boom" } };
const res = await request(app).get("/tabular-review").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("boom");
});
});
// ── POST /tabular-review (create) ─────────────────────────────────────
describe("POST /tabular-review", () => {
it("creates a review (201) and only persists accessible documents", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r9", title: "Gamma", document_ids: ["d1"] },
error: null,
};
// d2 is not accessible — it must be filtered out of the insert.
filterAccessibleDocumentIds.mockResolvedValue(["d1"]);
const res = await request(app)
.post("/tabular-review")
.set(...AUTH)
.send({
title: "Gamma",
document_ids: ["d1", "d2"],
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ id: "r9" });
const reviewInsert = supabaseState.inserts.find(
(i) => i.table === "tabular_reviews",
);
expect(reviewInsert?.payload).toMatchObject({
document_ids: ["d1"],
});
// Cells are created for accessible docs × columns only (1 × 1).
const cellInsert = supabaseState.inserts.find(
(i) => i.table === "tabular_cells",
);
expect(cellInsert?.payload).toEqual([
{
review_id: "r9",
document_id: "d1",
column_index: 0,
status: "pending",
},
]);
});
it("returns 404 when project access is denied", async () => {
checkProjectAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review")
.set(...AUTH)
.send({
project_id: "p-nope",
document_ids: [],
columns_config: [],
});
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Project not found");
});
it("returns 500 when the review insert errors", async () => {
supabaseState.tables.tabular_reviews = {
data: null,
error: { message: "insert failed" },
};
const res = await request(app)
.post("/tabular-review")
.set(...AUTH)
.send({ document_ids: [], columns_config: [] });
expect(res.status).toBe(500);
expect(res.body.detail).toBe("insert failed");
});
});
// ── GET /tabular-review/:reviewId (detail) ────────────────────────────
describe("GET /tabular-review/:reviewId", () => {
it("returns 404 when the review does not exist", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.get("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.get("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 200 with review/cells/documents + is_owner", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
document_ids: ["d1"],
columns_config: [],
},
error: null,
};
supabaseState.tables.tabular_cells = {
data: [
{
id: "c1",
document_id: "d1",
column_index: 0,
content: null,
status: "pending",
},
],
error: null,
};
supabaseState.tables.documents = {
data: [{ id: "d1", current_version_id: null }],
error: null,
};
const res = await request(app)
.get("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body.review).toMatchObject({ id: "r1", is_owner: true });
expect(res.body.cells).toHaveLength(1);
expect(res.body.documents).toEqual([
{ id: "d1", current_version_id: null },
]);
});
});
// ── PATCH /tabular-review/:reviewId ───────────────────────────────────
describe("PATCH /tabular-review/:reviewId", () => {
it("returns 400 when project_id is an invalid type", async () => {
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ project_id: 123 });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"project_id must be a non-empty string or null",
);
});
it("returns 400 when sharing the review with yourself", async () => {
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ shared_with: ["U1@Test.Local"] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"You cannot share a tabular review with yourself.",
);
});
it("returns 404 when the review does not exist", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ title: "Renamed" });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 403 when a non-owner edits columns_config", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: "p1" },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: true, isOwner: false });
const res = await request(app)
.patch("/tabular-review/r1")
.set(...AUTH)
.send({ columns_config: [{ index: 0, name: "X", prompt: "p" }] });
expect(res.status).toBe(403);
expect(res.body.detail).toBe("Only the review owner can change columns");
});
});
// ── DELETE /tabular-review/:reviewId ──────────────────────────────────
describe("DELETE /tabular-review/:reviewId", () => {
it("returns 204 on success", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.delete("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(204);
});
it("returns 500 when the delete errors", async () => {
supabaseState.tables.tabular_reviews = {
data: null,
error: { message: "delete failed" },
};
const res = await request(app)
.delete("/tabular-review/r1")
.set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("delete failed");
});
});
// ── POST /tabular-review/:reviewId/clear-cells ────────────────────────
describe("POST /tabular-review/:reviewId/clear-cells", () => {
it("returns 400 when document_ids is missing", async () => {
const res = await request(app)
.post("/tabular-review/r1/clear-cells")
.set(...AUTH)
.send({});
expect(res.status).toBe(400);
expect(res.body.detail).toBe("document_ids is required");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/clear-cells")
.set(...AUTH)
.send({ document_ids: ["d1"] });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 204 on success", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "u1", project_id: null },
error: null,
};
const res = await request(app)
.post("/tabular-review/r1/clear-cells")
.set(...AUTH)
.send({ document_ids: ["d1"] });
expect(res.status).toBe(204);
});
});
// ── POST /tabular-review/:reviewId/regenerate-cell ────────────────────
describe("POST /tabular-review/:reviewId/regenerate-cell", () => {
it("returns 400 when document_id / column_index are missing", async () => {
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({});
expect(res.status).toBe(400);
expect(res.body.detail).toBe(
"document_id and column_index are required",
);
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d1", column_index: 0 });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 400 when the column is not configured", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 5, name: "Other", prompt: "p" }],
},
error: null,
};
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d1", column_index: 0 });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("Column not found");
});
it("returns 404 when the document is not accessible", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
},
error: null,
};
filterAccessibleDocumentIds.mockResolvedValue([]);
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d-forbidden", column_index: 0 });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Document not found");
});
it("returns 422 with missing_api_key when the model key is absent", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
},
error: null,
};
supabaseState.tables.documents = {
data: { id: "d1", current_version_id: null },
error: null,
};
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: {},
});
const res = await request(app)
.post("/tabular-review/r1/regenerate-cell")
.set(...AUTH)
.send({ document_id: "d1", column_index: 0 });
expect(res.status).toBe(422);
expect(res.body.code).toBe("missing_api_key");
expect(res.body.provider).toBe("claude");
});
});
// ── POST /tabular-review/:reviewId/generate (streaming GUARDS only) ───
describe("POST /tabular-review/:reviewId/generate", () => {
it("returns 404 when the review does not exist", async () => {
supabaseState.tables.tabular_reviews = { data: null, error: null };
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 400 when no columns are configured", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [],
},
error: null,
};
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(400);
expect(res.body.detail).toBe("No columns configured");
});
it("returns 422 missing_api_key before streaming when the key is absent", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [{ index: 0, name: "Col", prompt: "p" }],
},
error: null,
};
supabaseState.tables.tabular_cells = { data: [], error: null };
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: {},
});
const res = await request(app)
.post("/tabular-review/r1/generate")
.set(...AUTH);
expect(res.status).toBe(422);
expect(res.body.code).toBe("missing_api_key");
});
});
// ── POST /tabular-review/:reviewId/chat (streaming GUARDS only) ───────
describe("POST /tabular-review/:reviewId/chat", () => {
it("returns 400 when no user message is present", async () => {
const res = await request(app)
.post("/tabular-review/r1/chat")
.set(...AUTH)
.send({ messages: [{ role: "assistant", content: "hi" }] });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("messages must include a user message");
});
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.post("/tabular-review/r1/chat")
.set(...AUTH)
.send({ messages: [{ role: "user", content: "hello" }] });
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns 422 missing_api_key before streaming when the key is absent", async () => {
supabaseState.tables.tabular_reviews = {
data: {
id: "r1",
user_id: "u1",
project_id: null,
columns_config: [],
},
error: null,
};
supabaseState.tables.tabular_cells = { data: [], error: null };
getUserModelSettings.mockResolvedValue({
title_model: "claude-haiku-4-5",
tabular_model: "claude-sonnet-4-5",
legal_research_us: false,
api_keys: {},
});
const res = await request(app)
.post("/tabular-review/r1/chat")
.set(...AUTH)
.send({ messages: [{ role: "user", content: "hello" }] });
expect(res.status).toBe(422);
expect(res.body.code).toBe("missing_api_key");
});
});
// ── GET /tabular-review/:reviewId/chats ───────────────────────────────
describe("GET /tabular-review/:reviewId/chats", () => {
it("returns 404 when review access is denied", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "other", project_id: null },
error: null,
};
ensureReviewAccess.mockResolvedValue({ ok: false });
const res = await request(app)
.get("/tabular-review/r1/chats")
.set(...AUTH);
expect(res.status).toBe(404);
expect(res.body.detail).toBe("Review not found");
});
it("returns the chat list when access is granted", async () => {
supabaseState.tables.tabular_reviews = {
data: { id: "r1", user_id: "u1", project_id: null },
error: null,
};
supabaseState.tables.tabular_review_chats = {
data: [{ id: "chat-1", title: "T", user_id: "u1" }],
error: null,
};
const res = await request(app)
.get("/tabular-review/r1/chats")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual([
{ id: "chat-1", title: "T", user_id: "u1" },
]);
});
});
});

View file

@ -0,0 +1,588 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
// ---------------------------------------------------------------------------
// Hoisted mock fns we reconfigure per-test. These cover the three security
// surfaces this suite baselines:
// - the MFA route guard (requireMfaIfEnrolled)
// - the API-key crypto boundary (userApiKeys)
// - the destructive data export / deletion helpers (userDataExport /
// userDataCleanup)
// Each is a vi.fn so we can both reconfigure behaviour and assert call args.
// ---------------------------------------------------------------------------
const {
requireMfaIfEnrolled,
getUserApiKeyStatus,
saveUserApiKey,
hasEnvApiKey,
normalizeApiKeyProvider,
deleteAllUserChats,
deleteAllUserTabularReviews,
deleteUserAccountData,
deleteUserProjects,
buildUserAccountExport,
buildUserChatsExport,
buildUserTabularReviewsExport,
} = vi.hoisted(() => ({
requireMfaIfEnrolled: vi.fn(),
getUserApiKeyStatus: vi.fn(),
saveUserApiKey: vi.fn(),
hasEnvApiKey: vi.fn(),
normalizeApiKeyProvider: vi.fn(),
deleteAllUserChats: vi.fn(),
deleteAllUserTabularReviews: vi.fn(),
deleteUserAccountData: vi.fn(),
deleteUserProjects: vi.fn(),
buildUserAccountExport: vi.fn(),
buildUserChatsExport: vi.fn(),
buildUserTabularReviewsExport: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Configurable Supabase stub. The only route in this suite that reaches the
// DB directly is GET /user/profile (via loadProfile → selectProfile). Tests
// seed `supabaseState.tables.user_profiles`; terminal query ops resolve to the
// per-table result and auth.admin methods are stubbed where routes call them.
// ---------------------------------------------------------------------------
type QueryResult = { data: unknown; error: unknown };
let supabaseState: {
tables: Record<string, QueryResult>;
adminGetUserById: QueryResult;
adminDeleteUser: { error: unknown };
};
function resetSupabaseState() {
supabaseState = {
tables: {},
adminGetUserById: {
data: { user: { id: "u1", factors: [] } },
error: null,
},
adminDeleteUser: { error: null },
};
}
resetSupabaseState();
function resultForTable(table: string): QueryResult {
return supabaseState.tables[table] ?? { data: null, error: null };
}
function makeQuery(table: string) {
const q: Record<string, unknown> = {};
const chain = [
"select", "update", "delete", "upsert", "insert",
"eq", "neq", "in", "is", "or", "not", "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(resultForTable(table)));
q.maybeSingle = vi.fn(() => Promise.resolve(resultForTable(table)));
q.then = (
resolve: (v: unknown) => unknown,
reject?: (e: unknown) => unknown,
) => Promise.resolve(resultForTable(table)).then(resolve, reject);
return q;
}
function mockSupabase() {
return {
from: vi.fn((table: string) => makeQuery(table)),
rpc: vi.fn(() => Promise.resolve({ data: null, error: null })),
auth: {
getUser: () =>
Promise.resolve({ data: { user: { id: "u1" } }, error: null }),
admin: {
getUserById: vi.fn(() =>
Promise.resolve(supabaseState.adminGetUserById),
),
deleteUser: vi.fn(() =>
Promise.resolve(supabaseState.adminDeleteUser),
),
},
},
};
}
vi.mock("../../lib/supabase", () => ({
createServerSupabase: vi.fn(() => mockSupabase()),
getUserIdFromRequest: vi.fn(async () => "u1"),
}));
// requireAuth always authenticates u1. requireMfaIfEnrolled is a reconfigurable
// guard so we can drive both the satisfied (next()) and rejected
// (403 mfa_verification_required) paths.
vi.mock("../../middleware/auth", () => ({
requireAuth: (
_req: unknown,
res: { locals: Record<string, unknown> },
next: () => void,
) => {
res.locals.userId = "u1";
res.locals.userEmail = "u1@test.local";
res.locals.token = "test-token";
next();
},
requireMfaIfEnrolled: (req: unknown, res: unknown, next: () => void) =>
requireMfaIfEnrolled(req, res, next),
}));
// API-key crypto boundary: the route must funnel writes through saveUserApiKey
// (which encrypts) and never echo plaintext — getUserApiKeyStatus returns
// presence-only booleans. getUserApiKeys must be exported too — lib/userSettings
// imports it at module load.
vi.mock("../../lib/userApiKeys", () => ({
getUserApiKeyStatus: (...args: unknown[]) => getUserApiKeyStatus(...args),
saveUserApiKey: (...args: unknown[]) => saveUserApiKey(...args),
hasEnvApiKey: (...args: unknown[]) => hasEnvApiKey(...args),
normalizeApiKeyProvider: (...args: unknown[]) =>
normalizeApiKeyProvider(...args),
getUserApiKeys: vi.fn(async () => ({})),
}));
vi.mock("../../lib/userDataCleanup", () => ({
deleteAllUserChats: (...args: unknown[]) => deleteAllUserChats(...args),
deleteAllUserTabularReviews: (...args: unknown[]) =>
deleteAllUserTabularReviews(...args),
deleteUserAccountData: (...args: unknown[]) =>
deleteUserAccountData(...args),
deleteUserProjects: (...args: unknown[]) => deleteUserProjects(...args),
}));
vi.mock("../../lib/userDataExport", () => ({
buildUserAccountExport: (...args: unknown[]) =>
buildUserAccountExport(...args),
buildUserChatsExport: (...args: unknown[]) => buildUserChatsExport(...args),
buildUserTabularReviewsExport: (...args: unknown[]) =>
buildUserTabularReviewsExport(...args),
userExportFilename: (kind: string, userId: string) =>
`mike-${kind}-export-${userId.slice(0, 8)}.json`,
}));
import { app } from "../../app";
const AUTH = ["Authorization", "Bearer test"] as const;
// A complete user_profiles row with credits_reset_date in the future so the
// monthly-reset branch in loadProfile is not triggered.
function profileRow(overrides: Record<string, unknown> = {}) {
return {
display_name: "Ada",
organisation: "Acme",
message_credits_used: 3,
credits_reset_date: "2999-01-01T00:00:00.000Z",
tier: "Pro",
title_model: null,
tabular_model: "gemini-3-flash-preview",
mfa_on_login: false,
legal_research_us: true,
...overrides,
};
}
const STATUS = { claude: true, openai: false, gemini: false, sources: {} };
// The exact 403 body the web client's MFA gate consumes (mirrors the real
// requireMfaIfEnrolled). Used by tests that simulate an unsatisfied factor.
function rejectMfa(_req: unknown, res: any) {
res.status(403).json({
code: "mfa_verification_required",
detail: "MFA verification required",
});
}
describe("user.routes", () => {
beforeEach(() => {
vi.clearAllMocks();
resetSupabaseState();
// Default: MFA satisfied (guard passes through).
requireMfaIfEnrolled.mockImplementation(
(_req: unknown, _res: unknown, next: () => void) => next(),
);
getUserApiKeyStatus.mockResolvedValue(STATUS);
saveUserApiKey.mockResolvedValue(undefined);
hasEnvApiKey.mockReturnValue(false);
normalizeApiKeyProvider.mockImplementation((v: string) =>
["claude", "openai", "gemini"].includes(v) ? v : null,
);
deleteAllUserChats.mockResolvedValue(undefined);
deleteAllUserTabularReviews.mockResolvedValue(undefined);
deleteUserAccountData.mockResolvedValue(undefined);
deleteUserProjects.mockResolvedValue(undefined);
buildUserAccountExport.mockResolvedValue({ account: "data" });
buildUserChatsExport.mockResolvedValue({ chats: "data" });
buildUserTabularReviewsExport.mockResolvedValue({ reviews: "data" });
});
// ── GET /user/profile (MFA bootstrap path) ────────────────────────────
describe("GET /user/profile", () => {
it("returns the serialized profile plus apiKeyStatus", async () => {
supabaseState.tables.user_profiles = {
data: profileRow(),
error: null,
};
const res = await request(app).get("/user/profile").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
displayName: "Ada",
organisation: "Acme",
messageCreditsUsed: 3,
tier: "Pro",
legalResearchUs: true,
mfaOnLogin: false,
apiKeyStatus: STATUS,
});
// Presence-only key status — never plaintext.
expect(JSON.stringify(res.body)).not.toContain("sk-");
});
it("is NOT guarded by requireMfaIfEnrolled (bootstrap route)", async () => {
// Even if the MFA factor were unsatisfied, profile must remain
// reachable so the client can render the verification gate.
requireMfaIfEnrolled.mockImplementation(rejectMfa);
supabaseState.tables.user_profiles = {
data: profileRow(),
error: null,
};
const res = await request(app).get("/user/profile").set(...AUTH);
expect(res.status).toBe(200);
expect(requireMfaIfEnrolled).not.toHaveBeenCalled();
});
it("returns 500 with detail when the profile load errors", async () => {
supabaseState.tables.user_profiles = {
data: null,
error: { message: "db down" },
};
const res = await request(app).get("/user/profile").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("db down");
});
});
// ── POST /user/profile (bootstrap upsert) ─────────────────────────────
describe("POST /user/profile", () => {
it("ensures the profile row and returns ok", async () => {
const res = await request(app).post("/user/profile").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ ok: true });
expect(requireMfaIfEnrolled).not.toHaveBeenCalled();
});
});
// ── GET /user/api-keys (presence without plaintext) ───────────────────
describe("GET /user/api-keys", () => {
it("returns the boolean key-status map", async () => {
const res = await request(app).get("/user/api-keys").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual(STATUS);
expect(getUserApiKeyStatus).toHaveBeenCalledWith(
"u1",
expect.anything(),
);
});
});
// ── PUT /user/api-keys/:provider (crypto + MFA guard) ─────────────────
describe("PUT /user/api-keys/:provider", () => {
it("stores the key via the encryption helper and returns status", async () => {
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-secret-value" });
expect(res.status).toBe(200);
expect(res.body).toEqual(STATUS);
// The plaintext key must go through saveUserApiKey (the encryption
// boundary), keyed by provider + value, never persisted by the route.
expect(saveUserApiKey).toHaveBeenCalledWith(
"u1",
"claude",
"sk-secret-value",
expect.anything(),
);
});
it("deletes the key when api_key is omitted (null value)", async () => {
const res = await request(app)
.put("/user/api-keys/openai")
.set(...AUTH)
.send({});
expect(res.status).toBe(200);
expect(saveUserApiKey).toHaveBeenCalledWith(
"u1",
"openai",
null,
expect.anything(),
);
});
it("returns 400 for an unsupported provider", async () => {
const res = await request(app)
.put("/user/api-keys/bogus")
.set(...AUTH)
.send({ api_key: "x" });
expect(res.status).toBe(400);
expect(res.body.detail).toBe("Unsupported provider");
expect(saveUserApiKey).not.toHaveBeenCalled();
});
it("returns 409 when the provider is configured by the server env", async () => {
hasEnvApiKey.mockReturnValue(true);
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-x" });
expect(res.status).toBe(409);
expect(saveUserApiKey).not.toHaveBeenCalled();
});
it("returns 500 when saving the key throws", async () => {
saveUserApiKey.mockRejectedValue(new Error("kms unavailable"));
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-x" });
expect(res.status).toBe(500);
expect(res.body.detail).toBe("kms unavailable");
});
it("is rejected with 403 mfa_verification_required when MFA is unsatisfied", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app)
.put("/user/api-keys/claude")
.set(...AUTH)
.send({ api_key: "sk-x" });
expect(res.status).toBe(403);
expect(res.body).toEqual({
code: "mfa_verification_required",
detail: "MFA verification required",
});
// Guarded: the crypto path is never reached.
expect(saveUserApiKey).not.toHaveBeenCalled();
});
});
// ── Data export endpoints (MFA-guarded, attachment headers) ───────────
describe("data export endpoints", () => {
it("GET /user/export returns the account export as a JSON attachment", async () => {
const res = await request(app).get("/user/export").set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ account: "data" });
expect(res.headers["content-type"]).toContain("application/json");
expect(res.headers["content-disposition"]).toContain("attachment");
expect(res.headers["content-disposition"]).toContain(
"mike-account-export-u1.json",
);
expect(buildUserAccountExport).toHaveBeenCalledWith(
expect.anything(),
"u1",
"u1@test.local",
);
});
it("GET /user/chats/export returns the chats export", async () => {
const res = await request(app)
.get("/user/chats/export")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ chats: "data" });
expect(res.headers["content-disposition"]).toContain(
"mike-chats-export-u1.json",
);
expect(buildUserChatsExport).toHaveBeenCalledTimes(1);
});
it("GET /user/tabular-reviews/export returns the reviews export", async () => {
const res = await request(app)
.get("/user/tabular-reviews/export")
.set(...AUTH);
expect(res.status).toBe(200);
expect(res.body).toEqual({ reviews: "data" });
expect(res.headers["content-disposition"]).toContain(
"mike-tabular-reviews-export-u1.json",
);
expect(buildUserTabularReviewsExport).toHaveBeenCalledTimes(1);
});
it("GET /user/export returns 500 when the builder throws", async () => {
buildUserAccountExport.mockRejectedValue(new Error("export boom"));
const res = await request(app).get("/user/export").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("export boom");
});
it("GET /user/export is rejected when MFA is unsatisfied", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app).get("/user/export").set(...AUTH);
expect(res.status).toBe(403);
expect(res.body.code).toBe("mfa_verification_required");
expect(buildUserAccountExport).not.toHaveBeenCalled();
});
});
// ── Data deletion endpoints (MFA-guarded, cleanup helpers) ────────────
describe("data deletion endpoints", () => {
it("DELETE /user/chats invokes deleteAllUserChats and returns 204", async () => {
const res = await request(app).delete("/user/chats").set(...AUTH);
expect(res.status).toBe(204);
expect(deleteAllUserChats).toHaveBeenCalledWith(
expect.anything(),
"u1",
);
});
it("DELETE /user/projects invokes deleteUserProjects and returns 204", async () => {
const res = await request(app)
.delete("/user/projects")
.set(...AUTH);
expect(res.status).toBe(204);
expect(deleteUserProjects).toHaveBeenCalledWith(
expect.anything(),
"u1",
);
});
it("DELETE /user/tabular-reviews invokes the cleanup helper and returns 204", async () => {
const res = await request(app)
.delete("/user/tabular-reviews")
.set(...AUTH);
expect(res.status).toBe(204);
expect(deleteAllUserTabularReviews).toHaveBeenCalledWith(
expect.anything(),
"u1",
);
});
it("DELETE /user/account purges data then deletes the auth user (204)", async () => {
const res = await request(app).delete("/user/account").set(...AUTH);
expect(res.status).toBe(204);
// Account purge runs the cleanup helper with id + email.
expect(deleteUserAccountData).toHaveBeenCalledWith(
expect.anything(),
"u1",
"u1@test.local",
);
});
it("DELETE /user/account returns 500 when the auth-user delete errors", async () => {
supabaseState.adminDeleteUser = { error: { message: "auth boom" } };
const res = await request(app).delete("/user/account").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("auth boom");
});
it("DELETE /user/chats returns 500 when cleanup throws", async () => {
deleteAllUserChats.mockRejectedValue(new Error("cascade failed"));
const res = await request(app).delete("/user/chats").set(...AUTH);
expect(res.status).toBe(500);
expect(res.body.detail).toBe("cascade failed");
});
it("DELETE /user/account is rejected when MFA is unsatisfied (no cleanup)", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app).delete("/user/account").set(...AUTH);
expect(res.status).toBe(403);
expect(res.body.code).toBe("mfa_verification_required");
expect(deleteUserAccountData).not.toHaveBeenCalled();
});
});
// ── PATCH /user/security/mfa-login (factor-gated, MFA-guarded) ────────
describe("PATCH /user/security/mfa-login", () => {
it("returns 400 when enabling without a verified TOTP factor", async () => {
supabaseState.adminGetUserById = {
data: { user: { id: "u1", factors: [] } },
error: null,
};
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: true });
expect(res.status).toBe(400);
expect(res.body.detail).toContain("authenticator app");
});
it("enables MFA-on-login when a verified TOTP factor exists", async () => {
supabaseState.adminGetUserById = {
data: {
user: {
id: "u1",
factors: [
{ factor_type: "totp", status: "verified" },
],
},
},
error: null,
};
supabaseState.tables.user_profiles = {
data: profileRow({ mfa_on_login: true }),
error: null,
};
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ mfaOnLogin: true });
});
it("returns 400 on a non-boolean enabled field", async () => {
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: "yes" });
expect(res.status).toBe(400);
});
it("is rejected with 403 when MFA is unsatisfied", async () => {
requireMfaIfEnrolled.mockImplementation(rejectMfa);
const res = await request(app)
.patch("/user/security/mfa-login")
.set(...AUTH)
.send({ enabled: false });
expect(res.status).toBe(403);
expect(res.body.code).toBe("mfa_verification_required");
});
});
});

161
backend/src/app.ts Normal file
View file

@ -0,0 +1,161 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { chatRouter } from "./routes/chat";
import { projectsRouter } from "./routes/projects";
import { projectChatRouter } from "./routes/projectChat";
import { documentsRouter } from "./routes/documents";
import { libraryRouter } from "./routes/library";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { downloadsRouter } from "./routes/downloads";
import { caseLawRouter } from "./routes/caseLaw";
export const app = express();
const isProduction = process.env.NODE_ENV === "production";
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function minutes(value: number): number {
return value * 60 * 1000;
}
function hours(value: number): number {
return minutes(value * 60);
}
function makeLimiter(options: {
windowMs: number;
max: number;
message?: string;
}) {
return rateLimit({
windowMs: options.windowMs,
max: options.max,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.method === "OPTIONS",
message: {
detail:
options.message ?? "Too many requests. Please try again later.",
},
});
}
const generalLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
});
const chatLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
message: "Too many chat requests. Please try again later.",
});
const chatCreateLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
});
const uploadLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
message: "Too many upload requests. Please try again later.",
});
const exportLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_EXPORT_MAX", 10),
message: "Too many export requests. Please try again later.",
});
const dataDeleteLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20),
message: "Too many data deletion requests. Please try again later.",
});
function jsonLimitForPath(path: string): string {
return "50mb";
}
app.disable("x-powered-by");
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
baseUri: ["'none'"],
frameAncestors: ["'none'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: isProduction
? {
maxAge: 15552000,
includeSubDomains: true,
}
: false,
referrerPolicy: { policy: "no-referrer" },
}),
);
app.use(
cors({
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
credentials: true,
}),
);
app.use(generalLimiter);
app.post("/chat", chatLimiter);
app.post("/projects/:projectId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/generate", chatLimiter);
app.post("/chat/create", chatCreateLimiter);
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/library/:kind/documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.put(
"/single-documents/:documentId/versions/:versionId/file",
uploadLimiter,
);
app.post("/projects/:projectId/documents", uploadLimiter);
app.get("/user/export", exportLimiter);
app.get("/user/chats/export", exportLimiter);
app.get("/user/tabular-reviews/export", exportLimiter);
app.delete("/user/account", dataDeleteLimiter);
app.delete("/user/chats", dataDeleteLimiter);
app.delete("/user/projects", dataDeleteLimiter);
app.delete("/user/tabular-reviews", dataDeleteLimiter);
app.use((req, res, next) =>
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),
);
app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
app.use("/library", libraryRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);
app.use("/users", userRouter);
app.use("/download", downloadsRouter);
app.use("/case-law", caseLawRouter);
app.get("/health", (_req, res) => res.json({ ok: true }));

View file

@ -1,165 +1,6 @@
import "dotenv/config";
import express from "express";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
import { chatRouter } from "./routes/chat";
import { projectsRouter } from "./routes/projects";
import { projectChatRouter } from "./routes/projectChat";
import { documentsRouter } from "./routes/documents";
import { libraryRouter } from "./routes/library";
import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { downloadsRouter } from "./routes/downloads";
import { caseLawRouter } from "./routes/caseLaw";
import { app } from "./app";
const app = express();
const PORT = process.env.PORT ?? 3001;
const isProduction = process.env.NODE_ENV === "production";
function envInt(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function minutes(value: number): number {
return value * 60 * 1000;
}
function hours(value: number): number {
return minutes(value * 60);
}
function makeLimiter(options: {
windowMs: number;
max: number;
message?: string;
}) {
return rateLimit({
windowMs: options.windowMs,
max: options.max,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.method === "OPTIONS",
message: {
detail:
options.message ?? "Too many requests. Please try again later.",
},
});
}
const generalLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_GENERAL_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_GENERAL_MAX", 300),
});
const chatLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_MAX", 30),
message: "Too many chat requests. Please try again later.",
});
const chatCreateLimiter = makeLimiter({
windowMs: minutes(envInt("RATE_LIMIT_CHAT_CREATE_WINDOW_MINUTES", 15)),
max: envInt("RATE_LIMIT_CHAT_CREATE_MAX", 60),
});
const uploadLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_UPLOAD_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_UPLOAD_MAX", 50),
message: "Too many upload requests. Please try again later.",
});
const exportLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_EXPORT_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_EXPORT_MAX", 10),
message: "Too many export requests. Please try again later.",
});
const dataDeleteLimiter = makeLimiter({
windowMs: hours(envInt("RATE_LIMIT_DATA_DELETE_WINDOW_HOURS", 1)),
max: envInt("RATE_LIMIT_DATA_DELETE_MAX", 20),
message: "Too many data deletion requests. Please try again later.",
});
function jsonLimitForPath(path: string): string {
return "50mb";
}
app.disable("x-powered-by");
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
baseUri: ["'none'"],
frameAncestors: ["'none'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: isProduction
? {
maxAge: 15552000,
includeSubDomains: true,
}
: false,
referrerPolicy: { policy: "no-referrer" },
}),
);
app.use(
cors({
origin: process.env.FRONTEND_URL ?? "http://localhost:3000",
credentials: true,
}),
);
app.use(generalLimiter);
app.post("/chat", chatLimiter);
app.post("/projects/:projectId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/generate", chatLimiter);
app.post("/chat/create", chatCreateLimiter);
app.post("/chat/:chatId/generate-title", chatCreateLimiter);
app.post("/single-documents", uploadLimiter);
app.post("/library/:kind/documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.put(
"/single-documents/:documentId/versions/:versionId/file",
uploadLimiter,
);
app.post("/projects/:projectId/documents", uploadLimiter);
app.get("/user/export", exportLimiter);
app.get("/user/chats/export", exportLimiter);
app.get("/user/tabular-reviews/export", exportLimiter);
app.delete("/user/account", dataDeleteLimiter);
app.delete("/user/chats", dataDeleteLimiter);
app.delete("/user/projects", dataDeleteLimiter);
app.delete("/user/tabular-reviews", dataDeleteLimiter);
app.use((req, res, next) =>
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),
);
app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
app.use("/single-documents", documentsRouter);
app.use("/library", libraryRouter);
app.use("/tabular-review", tabularRouter);
app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);
app.use("/users", userRouter);
app.use("/download", downloadsRouter);
app.use("/case-law", caseLawRouter);
app.get("/health", (_req, res) => res.json({ ok: true }));
app.listen(PORT, () => {
console.log(`Mike backend running on port ${PORT}`);

View file

@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import {
checkProjectAccess,
ensureDocAccess,
ensureReviewAccess,
filterAccessibleDocumentIds,
listAccessibleProjectIds,
} from "../access";
type Row = Record<string, unknown>;
function makeDb(tables: Record<string, Row[]>) {
return {
from(table: string) {
let rows = [...(tables[table] ?? [])];
const query = {
select: () => query,
eq: (column: string, value: unknown) => {
rows = rows.filter((row) => row[column] === value);
return query;
},
neq: (column: string, value: unknown) => {
rows = rows.filter((row) => row[column] !== value);
return query;
},
in: (column: string, values: unknown[]) => {
rows = rows.filter((row) => values.includes(row[column]));
return query;
},
filter: (column: string, operator: string, value: string) => {
if (operator !== "cs") return query;
const expected = (JSON.parse(value) as string[]).map((item) =>
item.toLowerCase(),
);
rows = rows.filter((row) => {
const actual = row[column];
const normalizedActual = Array.isArray(actual)
? actual.map((item) => String(item).toLowerCase())
: [];
return (
Array.isArray(actual) &&
expected.every((item) => normalizedActual.includes(item))
);
});
return query;
},
single: async () => ({ data: rows[0] ?? null, error: null }),
then: (
resolve: (value: { data: Row[]; error: null }) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
};
return query;
},
} as any;
}
describe("access helpers", () => {
const db = makeDb({
projects: [
{ id: "own-project", user_id: "owner", shared_with: [] },
{
id: "shared-project",
user_id: "other-owner",
shared_with: ["Reviewer@Example.com"],
},
{ id: "private-project", user_id: "other-owner", shared_with: [] },
],
documents: [
{ id: "own-doc", user_id: "owner", project_id: null },
{
id: "shared-doc",
user_id: "other-owner",
project_id: "shared-project",
},
{
id: "private-doc",
user_id: "other-owner",
project_id: "private-project",
},
],
});
it("allows project owners", async () => {
await expect(
checkProjectAccess("own-project", "owner", "owner@example.com", db),
).resolves.toMatchObject({ ok: true, isOwner: true });
});
it("allows shared project access case-insensitively", async () => {
await expect(
checkProjectAccess(
"shared-project",
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: false });
});
it("denies private project access", async () => {
await expect(
checkProjectAccess(
"private-project",
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toEqual({ ok: false });
});
it("allows document owners and shared-project readers", async () => {
await expect(
ensureDocAccess(
{ user_id: "owner", project_id: null },
"owner",
"owner@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: true });
await expect(
ensureDocAccess(
{ user_id: "other-owner", project_id: "shared-project" },
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: false });
});
it("filters user-supplied document IDs to accessible documents only", async () => {
await expect(
filterAccessibleDocumentIds(
["own-doc", "shared-doc", "private-doc", "missing-doc"],
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toEqual(["shared-doc"]);
});
it("lists own and directly shared projects", async () => {
await expect(
listAccessibleProjectIds("owner", "reviewer@example.com", db),
).resolves.toEqual(expect.arrayContaining(["own-project", "shared-project"]));
});
it("allows direct review sharing without project access", async () => {
await expect(
ensureReviewAccess(
{
user_id: "other-owner",
project_id: null,
shared_with: ["Reviewer@Example.com"],
},
"reviewer",
"reviewer@example.com",
db,
),
).resolves.toMatchObject({ ok: true, isOwner: false });
});
});

View file

@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import {
resolveDoc,
resolveDocLabel,
type DocIndex,
type DocStore,
} from "../chat/types";
// ---------------------------------------------------------------------------
// resolveDoc
// ---------------------------------------------------------------------------
describe("resolveDoc", () => {
const index: DocIndex = {
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
};
it("returns the doc entry for a known label", () => {
expect(resolveDoc("doc-1", index)).toEqual({
document_id: "uuid-aaa",
filename: "contract.pdf",
});
});
it("returns undefined for an unknown label", () => {
expect(resolveDoc("doc-99", index)).toBeUndefined();
});
it("returns undefined for an empty string", () => {
expect(resolveDoc("", index)).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// resolveDocLabel
// ---------------------------------------------------------------------------
describe("resolveDocLabel", () => {
const store: DocStore = new Map([
["doc-1", { storage_path: "path/a", file_type: "pdf", filename: "contract.pdf" }],
["doc-2", { storage_path: "path/b", file_type: "pdf", filename: "nda.pdf" }],
]);
const index: DocIndex = {
"doc-1": { document_id: "uuid-aaa", filename: "contract.pdf" },
"doc-2": { document_id: "uuid-bbb", filename: "nda.pdf" },
};
it("resolves by label when the label is in the store", () => {
expect(resolveDocLabel("doc-1", store, index)).toBe("doc-1");
});
it("resolves by filename when the filename matches a store entry", () => {
expect(resolveDocLabel("contract.pdf", store, index)).toBe("doc-1");
});
it("resolves by document UUID via the docIndex", () => {
expect(resolveDocLabel("uuid-bbb", store, index)).toBe("doc-2");
});
it("returns null when nothing matches", () => {
expect(resolveDocLabel("unknown-id", store, index)).toBeNull();
});
it("returns null when docIndex is omitted and only UUID matches", () => {
// Without the index there is no fallback for raw UUIDs.
expect(resolveDocLabel("uuid-aaa", store)).toBeNull();
});
it("prioritises exact label match over filename match", () => {
// If a label happens to equal a filename of a different doc,
// the label match wins.
const storeWithCrossMatch: DocStore = new Map([
["nda.pdf", { storage_path: "path/c", file_type: "pdf", filename: "contract.pdf" }],
]);
// "nda.pdf" is a label here, and it IS in the store, so it should
// be returned directly without the filename-fallback loop.
expect(resolveDocLabel("nda.pdf", storeWithCrossMatch)).toBe("nda.pdf");
});
});

View file

@ -0,0 +1,397 @@
import { describe, it, expect } from "vitest";
import {
parseCitations,
parseCitationsWithDiagnostics,
parsePartialCitationObjects,
createCitation,
CITATIONS_OPEN_TAG,
CITATIONS_CLOSE_TAG,
} from "../chat/citations";
import type { DocIndex } from "../chat/types";
function citationsBlock(json: string) {
return `Answer text.\n${CITATIONS_OPEN_TAG}\n${json}\n${CITATIONS_CLOSE_TAG}`;
}
// ---------------------------------------------------------------------------
// parseCitationsWithDiagnostics
// ---------------------------------------------------------------------------
describe("parseCitationsWithDiagnostics", () => {
it("reports no block when the tags are absent", () => {
const { citations, diagnostics } =
parseCitationsWithDiagnostics("plain answer");
expect(citations).toEqual([]);
expect(diagnostics).toEqual({ hasBlock: false, rawLength: 0, error: null });
});
it("reports a JSON parse error for malformed block content", () => {
const { citations, diagnostics } = parseCitationsWithDiagnostics(
citationsBlock("[{not json"),
);
expect(citations).toEqual([]);
expect(diagnostics.hasBlock).toBe(true);
expect(diagnostics.rawLength).toBeGreaterThan(0);
expect(diagnostics.error).toBeTruthy();
});
it("reports an error when the block JSON is not an array", () => {
const { citations, diagnostics } = parseCitationsWithDiagnostics(
citationsBlock('{"ref": 1}'),
);
expect(citations).toEqual([]);
expect(diagnostics.error).toBe("CITATIONS block JSON was not an array.");
});
});
// ---------------------------------------------------------------------------
// parseCitations — document citations
// ---------------------------------------------------------------------------
describe("parseCitations (document citations)", () => {
it("parses a minimal document citation", () => {
const [citation] = parseCitations(
citationsBlock(
'[{"ref": 1, "doc_id": "doc-1", "page": 3, "quote": "the term"}]',
),
);
expect(citation).toMatchObject({
kind: "document",
ref: 1,
doc_id: "doc-1",
page: 3,
quote: "the term",
});
expect(citation.quotes).toHaveLength(1);
});
it("derives ref from a [N] marker when ref is missing", () => {
const [citation] = parseCitations(
citationsBlock(
'[{"marker": "[7]", "doc_id": "doc-1", "page": 1, "quote": "q"}]',
),
);
expect(citation.ref).toBe(7);
});
it("drops entries without a usable ref or marker", () => {
expect(
parseCitations(
citationsBlock('[{"doc_id": "doc-1", "quote": "q", "marker": "nope"}]'),
),
).toEqual([]);
});
it("drops document entries without doc_id or quote", () => {
expect(
parseCitations(citationsBlock('[{"ref": 1, "doc_id": "doc-1"}]')),
).toEqual([]);
expect(
parseCitations(citationsBlock('[{"ref": 1, "quote": "q"}]')),
).toEqual([]);
});
it("drops non-object entries but keeps valid ones", () => {
const citations = parseCitations(
citationsBlock(
'[null, "junk", {"ref": 2, "doc_id": "doc-2", "page": 4, "quote": "kept"}]',
),
);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 2, doc_id: "doc-2" });
});
it("accepts a text field as a quote alias", () => {
const [citation] = parseCitations(
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 2, "text": "aliased"}]'),
);
expect(citation.kind).toBe("document");
expect((citation as { quote: string }).quote).toBe("aliased");
});
it("normalizes pages: numbers kept, ranges kept, junk becomes 1", () => {
const citations = parseCitations(
citationsBlock(
'[{"ref": 1, "doc_id": "d", "page": 5, "quote": "a"},' +
'{"ref": 2, "doc_id": "d", "page": "3-5", "quote": "b"},' +
'{"ref": 3, "doc_id": "d", "page": "12", "quote": "c"},' +
'{"ref": 4, "doc_id": "d", "page": "unknown", "quote": "d"}]',
),
);
expect(citations.map((c) => (c as { page: number | string }).page)).toEqual([
5,
"3-5",
12,
1,
]);
});
it("keeps at most 3 quotes and inherits top-level page/sheet/cell", () => {
const [citation] = parseCitations(
citationsBlock(
JSON.stringify([
{
ref: 1,
doc_id: "doc-1",
page: 9,
sheet: "Summary",
cell: "B7",
quotes: [
{ quote: "one" },
{ quote: "two", page: 2 },
{ quote: "three", sheet: "Detail", cell: "C1" },
{ quote: "four" },
],
},
]),
),
);
expect(citation.kind).toBe("document");
const doc = citation as {
page: number | string;
quotes: { page: number | string; quote: string; sheet?: string; cell?: string }[];
};
expect(doc.quotes).toHaveLength(3);
expect(doc.quotes[0]).toEqual({
page: 9,
quote: "one",
sheet: "Summary",
cell: "B7",
});
expect(doc.quotes[1].page).toBe(2);
expect(doc.quotes[2]).toMatchObject({ sheet: "Detail", cell: "C1" });
// Top-level fields mirror the first quote.
expect(doc.page).toBe(9);
});
it("skips quote rows without text", () => {
const [citation] = parseCitations(
citationsBlock(
'[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
' "quotes": [{"page": 2}, {"quote": " "}, {"quote": "real"}]}]',
),
);
expect((citation as { quotes: unknown[] }).quotes).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// parseCitations — case citations
// ---------------------------------------------------------------------------
describe("parseCitations (case citations)", () => {
it("parses a case citation from a numeric cluster_id", () => {
const [citation] = parseCitations(
citationsBlock('[{"ref": 1, "cluster_id": 12345, "quote": "held that"}]'),
);
expect(citation).toMatchObject({ kind: "case", ref: 1, cluster_id: 12345 });
expect((citation as { quotes: unknown[] }).quotes).toEqual([
{ opinionId: null, type: null, author: null, quote: "held that" },
]);
});
it("accepts clusterId camelCase and string cluster ids", () => {
const citations = parseCitations(
citationsBlock(
'[{"ref": 1, "clusterId": 7, "quote": "a"},' +
'{"ref": 2, "cluster_id": "42", "quote": "b"}]',
),
);
expect(citations.map((c) => (c as { cluster_id: number }).cluster_id)).toEqual([
7, 42,
]);
});
it("floors fractional cluster ids", () => {
const [citation] = parseCitations(
citationsBlock('[{"ref": 1, "cluster_id": 12.9, "quote": "q"}]'),
);
expect((citation as { cluster_id: number }).cluster_id).toBe(12);
});
it("treats non-positive cluster ids as document citations", () => {
// cluster_id 0 fails the > 0 check, so the entry needs a doc_id.
expect(
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 0, "quote": "q"}]')),
).toEqual([]);
});
it("normalizes structured case quotes with opinion metadata", () => {
const [citation] = parseCitations(
citationsBlock(
JSON.stringify([
{
ref: 3,
cluster_id: 99,
quotes: [
{
quote: "majority text",
opinion_id: 11.7,
type: "majority",
author: "Judge A",
},
{ text: "concurrence text", opinionId: 12 },
{ type: "no quote text, dropped" },
],
},
]),
),
);
expect((citation as { quotes: unknown[] }).quotes).toEqual([
{
opinionId: 11,
type: "majority",
author: "Judge A",
quote: "majority text",
},
{ opinionId: 12, type: null, author: null, quote: "concurrence text" },
]);
});
it("drops case citations with no quotes at all", () => {
expect(
parseCitations(citationsBlock('[{"ref": 1, "cluster_id": 5}]')),
).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// parsePartialCitationObjects
// ---------------------------------------------------------------------------
describe("parsePartialCitationObjects", () => {
it("returns [] when no array has started", () => {
expect(parsePartialCitationObjects("streaming <CITATIONS>")).toEqual([]);
});
it("extracts complete objects and ignores a trailing incomplete one", () => {
const partial =
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 2, "quote": "done"},' +
' {"ref": 2, "doc_id": "doc-2", "page": 3, "quote": "still stream';
const citations = parsePartialCitationObjects(partial);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 1, doc_id: "doc-1" });
});
it("handles braces and escaped quotes inside string values", () => {
const partial =
'<CITATIONS>[{"ref": 1, "doc_id": "doc-1", "page": 1,' +
' "quote": "clause {a} says \\"stop\\""}';
const citations = parsePartialCitationObjects(partial);
expect(citations).toHaveLength(1);
expect((citations[0] as { quote: string }).quote).toBe(
'clause {a} says "stop"',
);
});
it("ignores content after the closing tag", () => {
const text =
'<CITATIONS>[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}]</CITATIONS>' +
'[{"ref": 9, "doc_id": "b", "page": 1, "quote": "after"}]';
const citations = parsePartialCitationObjects(text);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 1 });
});
it("stops at the array close bracket", () => {
const text =
'[{"ref": 1, "doc_id": "a", "page": 1, "quote": "q"}] {"ref": 2, "doc_id": "b", "page": 1, "quote": "outside"}';
const citations = parsePartialCitationObjects(text);
expect(citations).toHaveLength(1);
});
it("skips malformed objects but keeps later valid ones", () => {
const text =
'[{"ref": bad}, {"ref": 2, "doc_id": "doc-2", "page": 1, "quote": "ok"}';
const citations = parsePartialCitationObjects(text);
expect(citations).toHaveLength(1);
expect(citations[0]).toMatchObject({ ref: 2 });
});
});
// ---------------------------------------------------------------------------
// createCitation
// ---------------------------------------------------------------------------
describe("createCitation", () => {
const docIndex: DocIndex = {
"doc-1": {
document_id: "uuid-aaa",
filename: "contract.pdf",
version_id: "ver-1",
version_number: 2,
},
};
it("builds a document citation payload from the doc index", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 1, "doc_id": "doc-1", "page": 4, "quote": "q"}]'),
);
expect(createCitation(parsed, docIndex)).toMatchObject({
type: "citation_data",
kind: "document",
ref: 1,
doc_id: "doc-1",
document_id: "uuid-aaa",
version_id: "ver-1",
version_number: 2,
filename: "contract.pdf",
page: 4,
quote: "q",
});
});
it("falls back to the raw doc_id as filename when unresolvable", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 1, "doc_id": "doc-9", "page": 1, "quote": "q"}]'),
);
const citation = createCitation(parsed, docIndex);
expect(citation).toMatchObject({
filename: "doc-9",
document_id: undefined,
version_id: null,
});
});
it("enriches a case citation from the cluster map", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
);
const cases = new Map([
[
55,
{
caseName: "Smith v. Jones",
citations: ["123 U.S. 456", "alt cite"],
url: "https://example.test/case",
pdfUrl: null,
dateFiled: "1990-01-02",
},
],
]);
expect(createCitation(parsed, docIndex, cases)).toMatchObject({
type: "citation_data",
kind: "case",
ref: 2,
cluster_id: 55,
case_name: "Smith v. Jones",
citation: "123 U.S. 456",
url: "https://example.test/case",
pdfUrl: null,
dateFiled: "1990-01-02",
});
});
it("nulls case metadata when the cluster map has no entry", () => {
const [parsed] = parseCitations(
citationsBlock('[{"ref": 2, "cluster_id": 55, "quote": "held"}]'),
);
expect(createCitation(parsed, docIndex)).toMatchObject({
case_name: null,
citation: null,
url: null,
pdfUrl: null,
dateFiled: null,
});
});
});

View file

@ -0,0 +1,315 @@
import { describe, it, expect } from "vitest";
import {
loadActiveVersion,
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../documentVersions";
type Row = Record<string, unknown>;
/**
* Read-only Supabase mock covering the query chains documentVersions uses:
* select/eq/in/is/not filters plus single() and awaiting the builder.
*/
function makeDb(tables: Record<string, Row[]>) {
return {
from(table: string) {
let rows = [...(tables[table] ?? [])];
const query: any = {
select: () => query,
eq: (column: string, value: unknown) => {
rows = rows.filter((row) => row[column] === value);
return query;
},
in: (column: string, values: unknown[]) => {
rows = rows.filter((row) => values.includes(row[column]));
return query;
},
is: (column: string, value: unknown) => {
rows = rows.filter((row) => (row[column] ?? null) === value);
return query;
},
not: (column: string, operator: string, value: unknown) => {
if (operator === "is" && value === null) {
rows = rows.filter((row) => row[column] != null);
}
return query;
},
single: async () => ({ data: rows[0] ?? null, error: null }),
then: (
resolve: (value: { data: Row[]; error: null }) => unknown,
reject?: (reason: unknown) => unknown,
) => Promise.resolve({ data: rows, error: null }).then(resolve, reject),
};
return query;
},
} as any;
}
/** Shape of the mutable doc rows the attach* helpers annotate in place. */
type TestDoc = {
id: string;
current_version_id?: string | null;
latest_version_number?: number | null;
[k: string]: unknown;
};
const FULL_VERSION = {
id: "ver-1",
document_id: "doc-1",
storage_path: "documents/u/doc-1/source.pdf",
pdf_storage_path: "documents/u/doc-1/converted.pdf",
version_number: 3,
filename: "contract.pdf",
source: "upload",
file_type: "application/pdf",
size_bytes: 1024,
page_count: 12,
deleted_at: null,
};
// ---------------------------------------------------------------------------
// loadActiveVersion
// ---------------------------------------------------------------------------
describe("loadActiveVersion", () => {
it("resolves the document's current version by default", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [FULL_VERSION],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
id: "ver-1",
storage_path: "documents/u/doc-1/source.pdf",
pdf_storage_path: "documents/u/doc-1/converted.pdf",
version_number: 3,
filename: "contract.pdf",
source: "upload",
file_type: "application/pdf",
size_bytes: 1024,
page_count: 12,
});
});
it("prefers an explicit versionId over current_version_id", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [
FULL_VERSION,
{ ...FULL_VERSION, id: "ver-2", version_number: 2 },
],
});
const version = await loadActiveVersion("doc-1", db, "ver-2");
expect(version?.id).toBe("ver-2");
expect(version?.version_number).toBe(2);
});
it("returns null when neither versionId nor current_version_id exist", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: null }],
document_versions: [FULL_VERSION],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
});
it("returns null when the version belongs to a different document", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-other" }],
document_versions: [
{ ...FULL_VERSION, id: "ver-other", document_id: "doc-2" },
],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
// Also guards against a spoofed explicit versionId.
await expect(
loadActiveVersion("doc-1", db, "ver-other"),
).resolves.toBeNull();
});
it("returns null for soft-deleted versions", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
});
it("returns null when the version has no storage_path", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [{ ...FULL_VERSION, storage_path: null }],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toBeNull();
});
it("defaults optional metadata fields to null", async () => {
const db = makeDb({
documents: [{ id: "doc-1", current_version_id: "ver-1" }],
document_versions: [
{
id: "ver-1",
document_id: "doc-1",
storage_path: "documents/u/doc-1/source.docx",
deleted_at: null,
},
],
});
await expect(loadActiveVersion("doc-1", db)).resolves.toEqual({
id: "ver-1",
storage_path: "documents/u/doc-1/source.docx",
pdf_storage_path: null,
version_number: null,
filename: null,
source: null,
file_type: null,
size_bytes: null,
page_count: null,
});
});
});
// ---------------------------------------------------------------------------
// attachActiveVersionPaths
// ---------------------------------------------------------------------------
describe("attachActiveVersionPaths", () => {
it("returns the same empty array untouched", async () => {
const db = makeDb({ document_versions: [] });
const docs: TestDoc[] = [];
await expect(attachActiveVersionPaths(db, docs)).resolves.toBe(docs);
});
it("nulls all fields when no document has a current version", async () => {
const db = makeDb({ document_versions: [FULL_VERSION] });
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: null },
]);
expect(doc).toMatchObject({
filename: "Untitled document",
storage_path: null,
pdf_storage_path: null,
file_type: null,
size_bytes: null,
page_count: null,
});
});
it("merges active-version metadata onto each row", async () => {
const db = makeDb({
document_versions: [
FULL_VERSION,
{
...FULL_VERSION,
id: "ver-2",
storage_path: "documents/u/doc-2/source.docx",
pdf_storage_path: null,
filename: "nda.docx",
version_number: 1,
},
],
});
const docs = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: "ver-1" },
{ id: "doc-2", current_version_id: "ver-2" },
{ id: "doc-3", current_version_id: null },
]);
expect(docs[0]).toMatchObject({
storage_path: "documents/u/doc-1/source.pdf",
pdf_storage_path: "documents/u/doc-1/converted.pdf",
active_version_number: 3,
filename: "contract.pdf",
file_type: "application/pdf",
size_bytes: 1024,
page_count: 12,
});
expect(docs[1]).toMatchObject({
storage_path: "documents/u/doc-2/source.docx",
pdf_storage_path: null,
active_version_number: 1,
filename: "nda.docx",
});
// Mixed list: the version-less doc still gets explicit nulls.
expect(docs[2]).toMatchObject({
storage_path: null,
filename: "Untitled document",
});
});
it("falls back to 'Untitled document' for blank filenames", async () => {
const db = makeDb({
document_versions: [{ ...FULL_VERSION, filename: " " }],
});
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: "ver-1" },
]);
expect(doc.filename).toBe("Untitled document");
});
it("ignores soft-deleted versions", async () => {
const db = makeDb({
document_versions: [
{ ...FULL_VERSION, deleted_at: "2026-01-01T00:00:00Z" },
],
});
const [doc] = await attachActiveVersionPaths<TestDoc>(db, [
{ id: "doc-1", current_version_id: "ver-1" },
]);
expect(doc.storage_path).toBeNull();
expect(doc.filename).toBe("Untitled document");
});
});
// ---------------------------------------------------------------------------
// attachLatestVersionNumbers
// ---------------------------------------------------------------------------
describe("attachLatestVersionNumbers", () => {
const versionRow = (
document_id: string,
version_number: number | null,
overrides: Row = {},
) => ({
document_id,
version_number,
source: "assistant_edit",
deleted_at: null,
...overrides,
});
it("returns the same empty array untouched", async () => {
const db = makeDb({ document_versions: [] });
const docs: TestDoc[] = [];
await expect(attachLatestVersionNumbers(db, docs)).resolves.toBe(docs);
});
it("attaches the max assistant_edit version number per document", async () => {
const db = makeDb({
document_versions: [
versionRow("doc-1", 1),
versionRow("doc-1", 4),
versionRow("doc-1", 2),
versionRow("doc-2", 7),
],
});
const docs = await attachLatestVersionNumbers<TestDoc>(db, [
{ id: "doc-1" },
{ id: "doc-2" },
{ id: "doc-3" },
]);
expect(docs.map((d) => d.latest_version_number)).toEqual([4, 7, null]);
});
it("ignores non-assistant_edit and soft-deleted versions", async () => {
const db = makeDb({
document_versions: [
versionRow("doc-1", 9, { source: "upload" }),
versionRow("doc-1", 8, { deleted_at: "2026-01-01T00:00:00Z" }),
versionRow("doc-1", 2),
],
});
const docs = await attachLatestVersionNumbers<TestDoc>(db, [{ id: "doc-1" }]);
expect(docs[0].latest_version_number).toBe(2);
});
});

View file

@ -0,0 +1,109 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { signDownload, verifyDownload, buildDownloadUrl } from "../downloadTokens";
const SECRET = "test-secret-32-bytes-long-enough!!";
beforeAll(() => {
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
});
afterAll(() => {
delete process.env.DOWNLOAD_SIGNING_SECRET;
});
describe("signDownload", () => {
it("returns a two-part dot-separated token", () => {
const token = signDownload("documents/user/doc.pdf", "contract.pdf");
const parts = token.split(".");
expect(parts).toHaveLength(2);
expect(parts[0].length).toBeGreaterThan(0);
expect(parts[1].length).toBeGreaterThan(0);
});
it("produces different tokens for different paths", () => {
const t1 = signDownload("documents/a/file.pdf", "a.pdf");
const t2 = signDownload("documents/b/file.pdf", "b.pdf");
expect(t1).not.toBe(t2);
});
it("uses base64url characters only (no +, /, =)", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
expect(token).not.toMatch(/[+/=]/);
});
});
describe("verifyDownload", () => {
it("round-trips a valid token", () => {
const path = "documents/user123/doc456/source.pdf";
const filename = "Contract Final v2.pdf";
const token = signDownload(path, filename);
const result = verifyDownload(token);
expect(result).not.toBeNull();
expect(result!.path).toBe(path);
expect(result!.filename).toBe(filename);
});
it("returns null for a tampered payload", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
const [, sig] = token.split(".");
const fakePayload = Buffer.from(
JSON.stringify({ p: "documents/attacker/file.pdf", f: "file.pdf" }),
)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
expect(verifyDownload(`${fakePayload}.${sig}`)).toBeNull();
});
it("returns null for a tampered signature", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
const [enc] = token.split(".");
const fakeSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
expect(verifyDownload(`${enc}.${fakeSig}`)).toBeNull();
});
it("returns null for a token with too many parts", () => {
expect(verifyDownload("a.b.c")).toBeNull();
});
it("returns null for a token with too few parts", () => {
expect(verifyDownload("onlyonepart")).toBeNull();
});
it("returns null when payload JSON is missing required fields", () => {
const bad = Buffer.from(JSON.stringify({ x: 1 }))
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
const sig = Buffer.alloc(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
expect(verifyDownload(`${bad}.${sig}`)).toBeNull();
});
it("returns null when signed with a different secret", () => {
const token = signDownload("documents/user/file.pdf", "file.pdf");
process.env.DOWNLOAD_SIGNING_SECRET = "different-secret-value-!!";
const result = verifyDownload(token);
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
expect(result).toBeNull();
});
});
describe("buildDownloadUrl", () => {
it("returns a path starting with /download/", () => {
const url = buildDownloadUrl("documents/user/file.pdf", "file.pdf");
expect(url).toMatch(/^\/download\//);
});
it("embeds a verifiable token in the URL", () => {
const path = "documents/user/file.pdf";
const filename = "file.pdf";
const url = buildDownloadUrl(path, filename);
const token = url.replace("/download/", "");
const result = verifyDownload(token);
expect(result).not.toBeNull();
expect(result!.path).toBe(path);
expect(result!.filename).toBe(filename);
});
});

View file

@ -0,0 +1,119 @@
import { describe, it, expect } from "vitest";
import {
CLAUDE_MAIN_MODELS,
GEMINI_MAIN_MODELS,
OPENAI_MAIN_MODELS,
CLAUDE_MID_MODELS,
GEMINI_MID_MODELS,
OPENAI_MID_MODELS,
CLAUDE_LOW_MODELS,
GEMINI_LOW_MODELS,
OPENAI_LOW_MODELS,
DEFAULT_MAIN_MODEL,
DEFAULT_TITLE_MODEL,
DEFAULT_TABULAR_MODEL,
providerForModel,
resolveModel,
} from "../llm/models";
// ---------------------------------------------------------------------------
// providerForModel
// ---------------------------------------------------------------------------
describe("providerForModel", () => {
it("maps claude-* ids to the claude provider", () => {
for (const model of [...CLAUDE_MAIN_MODELS, ...CLAUDE_MID_MODELS, ...CLAUDE_LOW_MODELS]) {
expect(providerForModel(model)).toBe("claude");
}
});
it("maps gemini-* ids to the gemini provider", () => {
for (const model of [...GEMINI_MAIN_MODELS, ...GEMINI_MID_MODELS, ...GEMINI_LOW_MODELS]) {
expect(providerForModel(model)).toBe("gemini");
}
});
it("maps gpt-* ids to the openai provider", () => {
for (const model of [...OPENAI_MAIN_MODELS, ...OPENAI_MID_MODELS, ...OPENAI_LOW_MODELS]) {
expect(providerForModel(model)).toBe("openai");
}
});
it("throws on an unknown model id", () => {
expect(() => providerForModel("llama-3")).toThrow(/Unknown model id/);
expect(() => providerForModel("")).toThrow(/Unknown model id/);
});
it("infers by prefix only, without validating against the catalog", () => {
// Documents current behavior: any claude-/gemini-/gpt- prefix is
// accepted even if the id is not a canonical model.
expect(providerForModel("claude-nonexistent")).toBe("claude");
expect(providerForModel("gpt-nonexistent")).toBe("openai");
});
});
// ---------------------------------------------------------------------------
// resolveModel
// ---------------------------------------------------------------------------
describe("resolveModel", () => {
it("returns a known model id unchanged", () => {
expect(resolveModel("claude-sonnet-4-6", DEFAULT_MAIN_MODEL)).toBe(
"claude-sonnet-4-6",
);
expect(resolveModel("gpt-5.4-lite", DEFAULT_TITLE_MODEL)).toBe(
"gpt-5.4-lite",
);
});
it("falls back for unknown model ids", () => {
expect(resolveModel("gpt-3.5-turbo", DEFAULT_MAIN_MODEL)).toBe(
DEFAULT_MAIN_MODEL,
);
});
it("falls back for null, undefined, and empty ids", () => {
expect(resolveModel(null, DEFAULT_MAIN_MODEL)).toBe(DEFAULT_MAIN_MODEL);
expect(resolveModel(undefined, DEFAULT_TABULAR_MODEL)).toBe(
DEFAULT_TABULAR_MODEL,
);
expect(resolveModel("", DEFAULT_TITLE_MODEL)).toBe(DEFAULT_TITLE_MODEL);
});
it("accepts models from every tier of the catalog", () => {
const catalog = [
...CLAUDE_MAIN_MODELS,
...GEMINI_MAIN_MODELS,
...OPENAI_MAIN_MODELS,
...CLAUDE_MID_MODELS,
...GEMINI_MID_MODELS,
...OPENAI_MID_MODELS,
...CLAUDE_LOW_MODELS,
...GEMINI_LOW_MODELS,
...OPENAI_LOW_MODELS,
];
for (const model of catalog) {
expect(resolveModel(model, "fallback-model")).toBe(model);
}
});
});
// ---------------------------------------------------------------------------
// Default model sanity
// ---------------------------------------------------------------------------
describe("default models", () => {
it("every default resolves to itself (defaults are in the catalog)", () => {
expect(resolveModel(DEFAULT_MAIN_MODEL, "x")).toBe(DEFAULT_MAIN_MODEL);
expect(resolveModel(DEFAULT_TITLE_MODEL, "x")).toBe(DEFAULT_TITLE_MODEL);
expect(resolveModel(DEFAULT_TABULAR_MODEL, "x")).toBe(
DEFAULT_TABULAR_MODEL,
);
});
it("every default has a resolvable provider", () => {
expect(providerForModel(DEFAULT_MAIN_MODEL)).toBe("gemini");
expect(providerForModel(DEFAULT_TITLE_MODEL)).toBe("gemini");
expect(providerForModel(DEFAULT_TABULAR_MODEL)).toBe("gemini");
});
});

View file

@ -0,0 +1,154 @@
import { describe, it, expect } from "vitest";
import {
redactSensitiveText,
safeErrorMessage,
safeErrorLog,
} from "../safeError";
// ---------------------------------------------------------------------------
// redactSensitiveText
// ---------------------------------------------------------------------------
describe("redactSensitiveText", () => {
it('redacts the OpenAI "Incorrect API key provided" message', () => {
expect(
redactSensitiveText(
"Incorrect API key provided: sk-proj-abc123def456ghi789.",
),
).toBe("Incorrect API key provided: [redacted].");
});
it("keeps the trailing period optional in the incorrect-key message", () => {
expect(
redactSensitiveText("Incorrect API key provided: badkey123"),
).toBe("Incorrect API key provided: [redacted]");
});
it("redacts secrets after api_key labels", () => {
expect(redactSensitiveText("api_key: mysecret123")).toBe(
"api_key: [redacted]",
);
expect(redactSensitiveText("api key = mysecret123")).toBe(
"api key = [redacted]",
);
});
it("redacts secrets after token/secret/authorization labels", () => {
expect(redactSensitiveText("token: abcdef123456")).toBe(
"token: [redacted]",
);
expect(redactSensitiveText("secret is abcdef123456")).toBe(
"secret is [redacted]",
);
expect(redactSensitiveText("authorization: abcdef123456")).toBe(
"authorization: [redacted]",
);
});
it("leaves short values after labels alone (below 6 chars)", () => {
expect(redactSensitiveText("token: abc")).toBe("token: abc");
});
it("redacts bare OpenAI-style sk- keys anywhere in the text", () => {
expect(
redactSensitiveText("request failed for sk-abc123def456ghi789 today"),
).toBe("request failed for [redacted] today");
});
it("redacts bare Anthropic-style sk-ant- keys", () => {
expect(
redactSensitiveText("used sk-ant-api03-abc123def456"),
).toBe("used [redacted]");
});
it("redacts bare Google AIza keys", () => {
expect(
redactSensitiveText("key AIzaSyA1234567890abcdefghij failed"),
).toBe("key [redacted] failed");
});
it("redacts multiple secrets in one string", () => {
const result = redactSensitiveText(
"first sk-abc123def456ghi789 then AIzaSyA1234567890abcdefghij",
);
expect(result).toBe("first [redacted] then [redacted]");
});
it("leaves ordinary text unchanged", () => {
expect(redactSensitiveText("Document not found")).toBe(
"Document not found",
);
});
});
// ---------------------------------------------------------------------------
// safeErrorMessage
// ---------------------------------------------------------------------------
describe("safeErrorMessage", () => {
it("uses the message of an Error instance", () => {
expect(safeErrorMessage(new Error("boom"))).toBe("boom");
});
it("redacts secrets inside an Error message", () => {
expect(
safeErrorMessage(new Error("bad key sk-abc123def456ghi789")),
).toBe("bad key [redacted]");
});
it("passes plain strings through (redacted)", () => {
expect(safeErrorMessage("token: abcdef123456")).toBe(
"token: [redacted]",
);
});
it("falls back for non-Error, non-string values", () => {
expect(safeErrorMessage(42)).toBe("Unexpected error");
expect(safeErrorMessage(null)).toBe("Unexpected error");
expect(safeErrorMessage({ message: "obj" })).toBe("Unexpected error");
});
it("falls back for an Error with an empty message", () => {
expect(safeErrorMessage(new Error(""))).toBe("Unexpected error");
});
it("honors a custom fallback", () => {
expect(safeErrorMessage(undefined, "Chat failed")).toBe("Chat failed");
});
});
// ---------------------------------------------------------------------------
// safeErrorLog
// ---------------------------------------------------------------------------
describe("safeErrorLog", () => {
it("captures name, message, and stack for an Error", () => {
const error = new Error("boom");
const log = safeErrorLog(error);
expect(log.name).toBe("Error");
expect(log.message).toBe("boom");
expect(log.stack).toContain("boom");
});
it("redacts secrets in the message and stack", () => {
const error = new Error("bad key sk-abc123def456ghi789");
const log = safeErrorLog(error);
expect(log.message).toBe("bad key [redacted]");
expect(log.stack).not.toContain("sk-abc123def456ghi789");
});
it("falls back to 'Unexpected error' for an empty Error message", () => {
expect(safeErrorLog(new Error("")).message).toBe("Unexpected error");
});
it("omits the stack when the Error has none", () => {
const error = new Error("boom");
error.stack = undefined;
expect(safeErrorLog(error).stack).toBeUndefined();
});
it("handles non-Error values with a null name and no stack", () => {
const log = safeErrorLog("plain failure");
expect(log).toEqual({ name: null, message: "plain failure" });
});
});

View file

@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import {
normalizeDownloadFilename,
sanitizeDispositionFilename,
encodeRFC5987,
buildContentDisposition,
storageKey,
pdfStorageKey,
generatedDocKey,
versionStorageKey,
} from "../storage";
describe("normalizeDownloadFilename", () => {
it("trims surrounding whitespace", () => {
expect(normalizeDownloadFilename(" file.pdf ")).toBe("file.pdf");
});
it("falls back to 'download' for empty string", () => {
expect(normalizeDownloadFilename("")).toBe("download");
expect(normalizeDownloadFilename(" ")).toBe("download");
});
it("replaces control characters with underscore", () => {
expect(normalizeDownloadFilename("file\x00name.pdf")).toBe("file_name.pdf");
expect(normalizeDownloadFilename("file\x1fname.pdf")).toBe("file_name.pdf");
});
it("replaces forward and backward slashes with underscore", () => {
expect(normalizeDownloadFilename("dir/file.pdf")).toBe("dir_file.pdf");
expect(normalizeDownloadFilename("dir\\file.pdf")).toBe("dir_file.pdf");
});
it("preserves normal filenames unchanged", () => {
expect(normalizeDownloadFilename("Contract v2 (Final).pdf")).toBe(
"Contract v2 (Final).pdf",
);
});
});
describe("sanitizeDispositionFilename", () => {
it("strips double-quote characters", () => {
expect(sanitizeDispositionFilename('file"name.pdf')).toBe("file_name.pdf");
});
it("strips backslash characters", () => {
expect(sanitizeDispositionFilename("file\\name.pdf")).toBe("file_name.pdf");
});
it("strips non-ASCII characters", () => {
expect(sanitizeDispositionFilename("filéname.pdf")).toBe("fil_name.pdf");
});
it("still applies normalizeDownloadFilename rules first", () => {
expect(sanitizeDispositionFilename(" ")).toBe("download");
});
});
describe("encodeRFC5987", () => {
it("encodes spaces as %20", () => {
expect(encodeRFC5987("hello world")).toBe("hello%20world");
});
it("encodes single-quote as %27", () => {
expect(encodeRFC5987("it's")).toContain("%27");
});
it("encodes ( and ) as %28 and %29", () => {
const result = encodeRFC5987("a(b)c");
expect(result).toContain("%28");
expect(result).toContain("%29");
});
it("encodes * as %2A", () => {
expect(encodeRFC5987("a*b")).toContain("%2A");
});
it("leaves safe ASCII characters unencoded", () => {
expect(encodeRFC5987("file.pdf")).toBe("file.pdf");
});
});
describe("buildContentDisposition", () => {
it("produces an attachment header with ASCII filename", () => {
const header = buildContentDisposition("attachment", "contract.pdf");
expect(header).toMatch(/^attachment;/);
expect(header).toContain('filename="contract.pdf"');
expect(header).toContain("filename*=UTF-8''contract.pdf");
});
it("produces an inline header", () => {
const header = buildContentDisposition("inline", "preview.pdf");
expect(header).toMatch(/^inline;/);
});
it("encodes unicode filename in filename* param", () => {
const header = buildContentDisposition("attachment", "Ünïcödé.pdf");
expect(header).toContain("filename*=UTF-8''");
expect(header).not.toContain("Ü");
});
});
describe("storageKey", () => {
it("includes userId, docId, and correct extension", () => {
const key = storageKey("user1", "doc1", "contract.pdf");
expect(key).toBe("documents/user1/doc1/source.pdf");
});
it("falls back to .bin for extensions longer than 16 chars", () => {
const key = storageKey("user1", "doc1", "file.toolongextension1234");
expect(key).toBe("documents/user1/doc1/source.bin");
});
it("falls back to .bin when no extension", () => {
const key = storageKey("user1", "doc1", "noextension");
expect(key).toBe("documents/user1/doc1/source.bin");
});
});
describe("pdfStorageKey", () => {
it("places PDF in the correct path with stem", () => {
const key = pdfStorageKey("user1", "doc1", "contract");
expect(key).toBe("documents/user1/doc1/contract.pdf");
});
});
describe("generatedDocKey", () => {
it("uses generated/ prefix and .docx extension for docx files", () => {
const key = generatedDocKey("user1", "doc1", "output.docx");
expect(key).toBe("generated/user1/doc1/generated.docx");
});
it("falls back to .docx for extensions longer than 16 chars", () => {
const key = generatedDocKey("user1", "doc1", "output.toolongextension1234");
expect(key).toBe("generated/user1/doc1/generated.docx");
});
});
describe("versionStorageKey", () => {
it("includes userId, docId, versionSlug, and extension", () => {
const key = versionStorageKey("user1", "doc1", "v2", "contract.pdf");
expect(key).toBe("documents/user1/doc1/versions/v2.pdf");
});
it("falls back to .bin for unknown extensions", () => {
const key = versionStorageKey("user1", "doc1", "v2", "file");
expect(key).toBe("documents/user1/doc1/versions/v2.bin");
});
});

View file

@ -0,0 +1,73 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { normalizeApiKeyProvider, hasEnvApiKey } from "../userApiKeys";
describe("normalizeApiKeyProvider", () => {
it('returns "claude" for "claude"', () => {
expect(normalizeApiKeyProvider("claude")).toBe("claude");
});
it('returns "openai" for "openai"', () => {
expect(normalizeApiKeyProvider("openai")).toBe("openai");
});
it('returns "gemini" for "gemini"', () => {
expect(normalizeApiKeyProvider("gemini")).toBe("gemini");
});
it("returns null for unknown provider strings", () => {
expect(normalizeApiKeyProvider("unknown")).toBeNull();
expect(normalizeApiKeyProvider("")).toBeNull();
expect(normalizeApiKeyProvider("Claude")).toBeNull();
expect(normalizeApiKeyProvider("OPENAI")).toBeNull();
});
});
describe("hasEnvApiKey", () => {
const envVars = [
"ANTHROPIC_API_KEY",
"CLAUDE_API_KEY",
"OPENAI_API_KEY",
"GEMINI_API_KEY",
];
// Clear before AND after each test so keys exported in the developer's
// shell (or CI) can't leak into assertions.
beforeEach(() => {
for (const v of envVars) delete process.env[v];
});
afterEach(() => {
for (const v of envVars) delete process.env[v];
});
it("returns true for claude when ANTHROPIC_API_KEY is set", () => {
process.env.ANTHROPIC_API_KEY = "sk-ant-test";
expect(hasEnvApiKey("claude")).toBe(true);
});
it("returns true for claude when CLAUDE_API_KEY is set as fallback", () => {
process.env.CLAUDE_API_KEY = "sk-claude-test";
expect(hasEnvApiKey("claude")).toBe(true);
});
it("returns true for openai when OPENAI_API_KEY is set", () => {
process.env.OPENAI_API_KEY = "sk-openai-test";
expect(hasEnvApiKey("openai")).toBe(true);
});
it("returns true for gemini when GEMINI_API_KEY is set", () => {
process.env.GEMINI_API_KEY = "gemini-key-test";
expect(hasEnvApiKey("gemini")).toBe(true);
});
it("returns false when no env key is set for the provider", () => {
expect(hasEnvApiKey("claude")).toBe(false);
expect(hasEnvApiKey("openai")).toBe(false);
expect(hasEnvApiKey("gemini")).toBe(false);
});
it("ignores whitespace-only env values", () => {
process.env.ANTHROPIC_API_KEY = " ";
expect(hasEnvApiKey("claude")).toBe(false);
});
});

View file

@ -0,0 +1,432 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../storage", () => ({
deleteFile: vi.fn(async () => {}),
listFiles: vi.fn(async () => [] as string[]),
}));
import { deleteFile, listFiles } from "../storage";
import {
deleteAllUserChats,
deleteAllUserTabularReviews,
deleteUserProjects,
deleteUserAccountData,
} from "../userDataCleanup";
const deleteFileMock = vi.mocked(deleteFile);
const listFilesMock = vi.mocked(listFiles);
type Row = Record<string, unknown>;
/**
* Stateful Supabase mock: deletes and updates mutate `tables`, so tests can
* assert on exactly which rows survived a cleanup call. Supports the chains
* userDataCleanup uses (select/delete/update + eq/in/filter-cs) and can
* inject a delete error per table to exercise error propagation.
*/
function makeDb(
initialTables: Record<string, Row[]>,
options: { deleteErrors?: Record<string, string> } = {},
) {
const tables: Record<string, Row[]> = {};
for (const [name, rows] of Object.entries(initialTables)) {
tables[name] = rows.map((row) => ({ ...row }));
}
const db = {
from(table: string) {
const rowsOf = () => tables[table] ?? (tables[table] = []);
let predicate: (row: Row) => boolean = () => true;
let mode: "select" | "delete" | "update" = "select";
let patch: Row = {};
const narrow = (next: (row: Row) => boolean) => {
const prev = predicate;
predicate = (row) => prev(row) && next(row);
};
const query: any = {
select: () => query,
delete: () => {
mode = "delete";
return query;
},
update: (value: Row) => {
mode = "update";
patch = value;
return query;
},
eq: (column: string, value: unknown) => {
narrow((row) => row[column] === value);
return query;
},
in: (column: string, values: unknown[]) => {
narrow((row) => values.includes(row[column]));
return query;
},
filter: (column: string, operator: string, value: string) => {
if (operator !== "cs") return query;
const expected = (JSON.parse(value) as string[]).map((item) =>
item.toLowerCase(),
);
narrow((row) => {
const actual = row[column];
if (!Array.isArray(actual)) return false;
const normalized = actual.map((item) =>
String(item).toLowerCase(),
);
return expected.every((item) => normalized.includes(item));
});
return query;
},
then: (
resolve: (value: { data: Row[] | null; error: unknown }) => unknown,
reject?: (reason: unknown) => unknown,
) => {
let result: { data: Row[] | null; error: unknown };
if (mode === "delete") {
const message = options.deleteErrors?.[table];
if (message) {
result = { data: null, error: { message } };
} else {
tables[table] = rowsOf().filter((row) => !predicate(row));
result = { data: null, error: null };
}
} else if (mode === "update") {
for (const row of rowsOf().filter(predicate)) {
Object.assign(row, patch);
}
result = { data: null, error: null };
} else {
result = {
data: rowsOf().filter(predicate).map((row) => ({ ...row })),
error: null,
};
}
return Promise.resolve(result).then(resolve, reject);
},
};
return query;
},
};
return { db: db as any, tables };
}
const ids = (rows: Row[] | undefined) => (rows ?? []).map((row) => row.id);
beforeEach(() => {
deleteFileMock.mockClear();
deleteFileMock.mockResolvedValue(undefined as never);
listFilesMock.mockClear();
listFilesMock.mockResolvedValue([]);
});
// ---------------------------------------------------------------------------
// deleteAllUserChats
// ---------------------------------------------------------------------------
describe("deleteAllUserChats", () => {
it("deletes only the target user's assistant and tabular chats", async () => {
const { db, tables } = makeDb({
chats: [
{ id: "c1", user_id: "u1" },
{ id: "c2", user_id: "u2" },
],
tabular_review_chats: [
{ id: "tc1", user_id: "u1" },
{ id: "tc2", user_id: "u2" },
],
});
await deleteAllUserChats(db, "u1");
expect(ids(tables.chats)).toEqual(["c2"]);
expect(ids(tables.tabular_review_chats)).toEqual(["tc2"]);
});
it("surfaces delete failures with context", async () => {
const { db } = makeDb(
{ chats: [{ id: "c1", user_id: "u1" }], tabular_review_chats: [] },
{ deleteErrors: { chats: "boom" } },
);
await expect(deleteAllUserChats(db, "u1")).rejects.toThrow(
"Failed to delete assistant chats: boom",
);
});
});
// ---------------------------------------------------------------------------
// deleteAllUserTabularReviews
// ---------------------------------------------------------------------------
describe("deleteAllUserTabularReviews", () => {
const fixture = () =>
makeDb({
tabular_reviews: [
{ id: "r1", user_id: "u1" },
{ id: "r2", user_id: "u1" },
{ id: "r-other", user_id: "u2" },
],
tabular_review_chats: [
{ id: "rc1", review_id: "r1" },
{ id: "rc-other", review_id: "r-other" },
],
tabular_review_chat_messages: [
{ id: "rm1", chat_id: "rc1" },
{ id: "rm-other", chat_id: "rc-other" },
],
tabular_cells: [
{ id: "cell1", review_id: "r1" },
{ id: "cell-other", review_id: "r-other" },
],
});
it("cascades messages, chats, and cells before the reviews", async () => {
const { db, tables } = fixture();
await expect(deleteAllUserTabularReviews(db, "u1")).resolves.toBe(2);
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
});
it("returns 0 and deletes nothing for a user with no reviews", async () => {
const { db, tables } = fixture();
await expect(deleteAllUserTabularReviews(db, "u3")).resolves.toBe(0);
expect(tables.tabular_reviews).toHaveLength(3);
expect(tables.tabular_cells).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// deleteUserProjects
// ---------------------------------------------------------------------------
describe("deleteUserProjects", () => {
const fixture = () =>
makeDb({
projects: [
{ id: "p1", user_id: "u1" },
{ id: "p2", user_id: "u1" },
{ id: "p-other", user_id: "u2" },
],
documents: [
{ id: "d1", user_id: "u1", project_id: "p1" },
{ id: "d-loose", user_id: "u1", project_id: null },
{ id: "d-other", user_id: "u2", project_id: "p-other" },
],
document_versions: [
{
id: "v1",
document_id: "d1",
storage_path: "documents/u1/d1/source.pdf",
pdf_storage_path: "documents/u1/d1/converted.pdf",
},
{
id: "v-other",
document_id: "d-other",
storage_path: "documents/u2/d-other/source.pdf",
pdf_storage_path: null,
},
],
chats: [
{ id: "c1", project_id: "p1" },
{ id: "c-other", project_id: "p-other" },
],
chat_messages: [
{ id: "m1", chat_id: "c1" },
{ id: "m-other", chat_id: "c-other" },
],
tabular_reviews: [
{ id: "r1", project_id: "p1" },
{ id: "r-other", project_id: "p-other" },
],
tabular_review_chats: [
{ id: "rc1", review_id: "r1" },
{ id: "rc-other", review_id: "r-other" },
],
tabular_review_chat_messages: [
{ id: "rm1", chat_id: "rc1" },
{ id: "rm-other", chat_id: "rc-other" },
],
tabular_cells: [
{ id: "cell1", review_id: "r1" },
{ id: "cell-other", review_id: "r-other" },
],
project_subfolders: [
{ id: "f1", project_id: "p1" },
{ id: "f-other", project_id: "p-other" },
],
});
it("cascades project contents and storage files for owned projects", async () => {
const { db, tables } = fixture();
await expect(deleteUserProjects(db, "u1")).resolves.toBe(2);
expect(ids(tables.projects)).toEqual(["p-other"]);
expect(ids(tables.documents)).toEqual(["d-loose", "d-other"]);
expect(ids(tables.chats)).toEqual(["c-other"]);
expect(ids(tables.chat_messages)).toEqual(["m-other"]);
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
expect(ids(tables.tabular_review_chats)).toEqual(["rc-other"]);
expect(ids(tables.tabular_review_chat_messages)).toEqual(["rm-other"]);
expect(ids(tables.tabular_cells)).toEqual(["cell-other"]);
expect(ids(tables.project_subfolders)).toEqual(["f-other"]);
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
expect(deletedPaths.sort()).toEqual([
"documents/u1/d1/converted.pdf",
"documents/u1/d1/source.pdf",
]);
});
it("restricts deletion to the requested owned projects", async () => {
const { db, tables } = fixture();
// p-other belongs to u2, so requesting it must not delete anything of theirs.
await expect(
deleteUserProjects(db, "u1", ["p2", "p-other", "p2"]),
).resolves.toBe(1);
expect(ids(tables.projects)).toEqual(["p1", "p-other"]);
expect(ids(tables.documents)).toEqual(["d1", "d-loose", "d-other"]);
});
it("returns 0 for an explicitly empty project list", async () => {
const { db, tables } = fixture();
await expect(deleteUserProjects(db, "u1", [])).resolves.toBe(0);
expect(tables.projects).toHaveLength(3);
});
it("returns 0 when the user owns no projects", async () => {
const { db, tables } = fixture();
await expect(deleteUserProjects(db, "u3")).resolves.toBe(0);
expect(tables.projects).toHaveLength(3);
expect(deleteFileMock).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// deleteUserAccountData
// ---------------------------------------------------------------------------
describe("deleteUserAccountData", () => {
const fixture = () =>
makeDb({
projects: [
{ id: "p1", user_id: "u1", shared_with: [] },
{
id: "p-other",
user_id: "u2",
shared_with: ["u1@example.com", " U1@Example.com ", "keep@example.com"],
},
],
tabular_reviews: [
{ id: "r1", user_id: "u1", shared_with: [] },
{ id: "r-other", user_id: "u2", shared_with: ["u1@example.com"] },
],
documents: [
{ id: "d1", user_id: "u1", project_id: null },
// Guest doc uploaded by another user into u1's project: deleted too.
{ id: "d-guest", user_id: "u2", project_id: "p1" },
{ id: "d-other", user_id: "u2", project_id: "p-other" },
],
document_versions: [
{
id: "v1",
document_id: "d1",
storage_path: "documents/u1/d1/source.pdf",
pdf_storage_path: "documents/u1/d1/converted.pdf",
},
{
id: "v-guest",
document_id: "d-guest",
storage_path: "documents/u2/d-guest/source.docx",
pdf_storage_path: null,
},
{
id: "v-other",
document_id: "d-other",
storage_path: "documents/u2/d-other/source.pdf",
pdf_storage_path: null,
},
],
chats: [
{ id: "c1", user_id: "u1" },
{ id: "c-other", user_id: "u2" },
],
tabular_review_chats: [{ id: "rc1", user_id: "u1" }],
project_subfolders: [{ id: "f1", user_id: "u1" }],
hidden_workflows: [{ id: "h1", user_id: "u1" }],
workflow_open_source_submissions: [
{ id: "s1", submitted_by_user_id: "u1" },
],
workflow_shares: [
{ id: "ws-by", shared_by_user_id: "u1", shared_with_email: "x@y.z" },
{
id: "ws-to",
shared_by_user_id: "u2",
shared_with_email: "u1@example.com",
},
{
id: "ws-keep",
shared_by_user_id: "u2",
shared_with_email: "keep@example.com",
},
],
workflows: [
{ id: "w1", user_id: "u1" },
{ id: "w-other", user_id: "u2" },
],
});
it("removes the user's rows, files, and share references everywhere", async () => {
const { db, tables } = fixture();
listFilesMock.mockResolvedValue(["documents/u1/orphan.bin"]);
await deleteUserAccountData(db, "u1", " U1@Example.COM ");
// Owned docs and guest docs inside owned projects are gone.
expect(ids(tables.documents)).toEqual(["d-other"]);
expect(ids(tables.projects)).toEqual(["p-other"]);
expect(ids(tables.chats)).toEqual(["c-other"]);
expect(tables.tabular_review_chats).toEqual([]);
expect(ids(tables.tabular_reviews)).toEqual(["r-other"]);
expect(tables.project_subfolders).toEqual([]);
expect(tables.hidden_workflows).toEqual([]);
expect(tables.workflow_open_source_submissions).toEqual([]);
expect(ids(tables.workflows)).toEqual(["w-other"]);
// Shares by the user and shares to the user's email are both removed.
expect(ids(tables.workflow_shares)).toEqual(["ws-keep"]);
// The email is scrubbed from other users' shared_with lists
// (case-insensitively), preserving other collaborators.
expect(tables.projects[0].shared_with).toEqual(["keep@example.com"]);
expect(tables.tabular_reviews[0].shared_with).toEqual([]);
// Version files for deleted docs plus orphans under the user's prefix.
const deletedPaths = deleteFileMock.mock.calls.map(([path]) => path);
expect(deletedPaths.sort()).toEqual([
"documents/u1/d1/converted.pdf",
"documents/u1/d1/source.pdf",
"documents/u1/orphan.bin",
"documents/u2/d-guest/source.docx",
]);
expect(listFilesMock).toHaveBeenCalledWith("documents/u1/");
});
it("treats storage prefix cleanup as best-effort", async () => {
const { db, tables } = fixture();
listFilesMock.mockRejectedValue(new Error("storage unavailable"));
await expect(
deleteUserAccountData(db, "u1", "u1@example.com"),
).resolves.toBeUndefined();
expect(ids(tables.documents)).toEqual(["d-other"]);
});
it("skips shared_with scrubbing when no email is known", async () => {
const { db, tables } = fixture();
await deleteUserAccountData(db, "u1", null);
// Rows referencing the email by value are left in place...
expect(tables.projects.find((row) => row.id === "p-other")?.shared_with)
.toContain("u1@example.com");
expect(ids(tables.workflow_shares)).toEqual(["ws-to", "ws-keep"]);
// ...but the user's own data is still deleted.
expect(ids(tables.documents)).toEqual(["d-other"]);
expect(tables.workflows.map((row) => row.id)).toEqual(["w-other"]);
});
});

View file

@ -0,0 +1,267 @@
import { describe, it, expect } from "vitest";
import {
normalizeEmail,
normalizeDisplayName,
loadProfileUsersByEmail,
findProfileUserByEmail,
findMissingUserEmails,
syncProfileEmail,
} from "../userLookup";
type Row = Record<string, unknown>;
/**
* Minimal user_profiles-shaped Supabase mock. Supports the query chains
* userLookup uses (select/eq/in/not + single-row readers) plus insert and
* update so syncProfileEmail can be exercised end to end.
*/
function makeDb(initialRows: Row[]) {
const tables: Record<string, Row[]> = {
user_profiles: initialRows.map((row) => ({ ...row })),
};
return {
tables,
from(table: string) {
const all = () => tables[table] ?? [];
let predicate: (row: Row) => boolean = () => true;
let mode: "select" | "insert" | "update" = "select";
let pendingRow: Row = {};
const narrow = (next: (row: Row) => boolean) => {
const prev = predicate;
predicate = (row) => prev(row) && next(row);
};
const query: any = {
select: () => query,
insert: (row: Row) => {
mode = "insert";
pendingRow = row;
return query;
},
update: (patch: Row) => {
mode = "update";
pendingRow = patch;
return query;
},
eq: (column: string, value: unknown) => {
narrow((row) => row[column] === value);
return query;
},
in: (column: string, values: unknown[]) => {
narrow((row) => values.includes(row[column]));
return query;
},
not: (column: string, operator: string, value: unknown) => {
if (operator === "is" && value === null) {
narrow((row) => row[column] != null);
}
return query;
},
maybeSingle: async () => ({
data: all().filter(predicate)[0] ?? null,
error: null,
}),
then: (
resolve: (value: { data: Row[] | null; error: null }) => unknown,
reject?: (reason: unknown) => unknown,
) => {
if (mode === "insert") {
all().push({ ...pendingRow });
return Promise.resolve({ data: null, error: null }).then(
resolve,
reject,
);
}
if (mode === "update") {
for (const row of all().filter(predicate)) {
Object.assign(row, pendingRow);
}
return Promise.resolve({ data: null, error: null }).then(
resolve,
reject,
);
}
return Promise.resolve({
data: all().filter(predicate),
error: null,
}).then(resolve, reject);
},
};
return query;
},
};
}
// ---------------------------------------------------------------------------
// normalizeEmail / normalizeDisplayName
// ---------------------------------------------------------------------------
describe("normalizeEmail", () => {
it("trims and lowercases", () => {
expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com");
});
it("returns empty string for non-strings", () => {
expect(normalizeEmail(null)).toBe("");
expect(normalizeEmail(undefined)).toBe("");
expect(normalizeEmail(42)).toBe("");
});
});
describe("normalizeDisplayName", () => {
it("trims usable names", () => {
expect(normalizeDisplayName(" Ada Lovelace ")).toBe("Ada Lovelace");
});
it("returns null for empty or non-string values", () => {
expect(normalizeDisplayName(" ")).toBeNull();
expect(normalizeDisplayName("")).toBeNull();
expect(normalizeDisplayName(null)).toBeNull();
expect(normalizeDisplayName(7)).toBeNull();
});
});
// ---------------------------------------------------------------------------
// loadProfileUsersByEmail
// ---------------------------------------------------------------------------
describe("loadProfileUsersByEmail", () => {
it("indexes profiles by normalized email and by id", async () => {
const db = makeDb([
{ user_id: "u1", email: "Alice@Example.com", display_name: " Alice " },
{ user_id: "u2", email: "bob@example.com", display_name: null },
]);
const { userByEmail, userById } = await loadProfileUsersByEmail(
db as any,
);
expect(userByEmail.get("alice@example.com")).toEqual({
id: "u1",
email: "alice@example.com",
display_name: "Alice",
});
expect(userById.get("u2")).toEqual({
id: "u2",
email: "bob@example.com",
display_name: null,
});
expect(userByEmail.size).toBe(2);
});
it("skips rows whose email normalizes to empty", async () => {
const db = makeDb([
{ user_id: "u1", email: " ", display_name: null },
{ user_id: "u2", email: "ok@example.com", display_name: null },
]);
const { userByEmail } = await loadProfileUsersByEmail(db as any);
expect(userByEmail.size).toBe(1);
expect(userByEmail.has("ok@example.com")).toBe(true);
});
});
// ---------------------------------------------------------------------------
// findProfileUserByEmail
// ---------------------------------------------------------------------------
describe("findProfileUserByEmail", () => {
const rows = [
{ user_id: "u1", email: "alice@example.com", display_name: "Alice" },
];
it("finds a profile by normalized email", async () => {
const db = makeDb(rows);
await expect(
findProfileUserByEmail(db as any, " ALICE@example.com "),
).resolves.toEqual({
id: "u1",
email: "alice@example.com",
display_name: "Alice",
});
});
it("returns null when no profile matches", async () => {
const db = makeDb(rows);
await expect(
findProfileUserByEmail(db as any, "missing@example.com"),
).resolves.toBeNull();
});
it("returns null without querying for empty input", async () => {
const db = makeDb(rows);
await expect(findProfileUserByEmail(db as any, " ")).resolves.toBeNull();
});
});
// ---------------------------------------------------------------------------
// findMissingUserEmails
// ---------------------------------------------------------------------------
describe("findMissingUserEmails", () => {
const db = makeDb([
{ user_id: "u1", email: "alice@example.com", display_name: null },
]);
it("returns only emails with no matching profile", async () => {
await expect(
findMissingUserEmails(db as any, [
"Alice@Example.com",
"carol@example.com",
]),
).resolves.toEqual(["carol@example.com"]);
});
it("dedupes and drops empty entries before querying", async () => {
await expect(
findMissingUserEmails(db as any, [
"carol@example.com",
" CAROL@example.com ",
"",
" ",
]),
).resolves.toEqual(["carol@example.com"]);
});
it("returns [] for an empty input list", async () => {
await expect(findMissingUserEmails(db as any, [])).resolves.toEqual([]);
});
});
// ---------------------------------------------------------------------------
// syncProfileEmail
// ---------------------------------------------------------------------------
describe("syncProfileEmail", () => {
it("inserts a profile row when none exists", async () => {
const db = makeDb([]);
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
expect(result).toBeNull();
expect(db.tables.user_profiles).toEqual([
{ user_id: "u1", email: "new@example.com" },
]);
});
it("is a no-op when the stored email already matches (case-insensitive)", async () => {
const db = makeDb([
{ user_id: "u1", email: "Same@Example.com", display_name: null },
]);
const result = await syncProfileEmail(db as any, "u1", "same@example.com");
expect(result).toBeNull();
expect(db.tables.user_profiles[0].email).toBe("Same@Example.com");
});
it("updates the stored email when it changed", async () => {
const db = makeDb([
{ user_id: "u1", email: "old@example.com", display_name: null },
]);
const result = await syncProfileEmail(db as any, "u1", "New@Example.com");
expect(result).toBeNull();
expect(db.tables.user_profiles[0].email).toBe("new@example.com");
expect(db.tables.user_profiles[0].updated_at).toEqual(expect.any(String));
});
it("returns null without touching the table for missing inputs", async () => {
const db = makeDb([]);
await expect(syncProfileEmail(db as any, "", "a@b.com")).resolves.toBeNull();
await expect(syncProfileEmail(db as any, "u1", null)).resolves.toBeNull();
await expect(syncProfileEmail(db as any, "u1", " ")).resolves.toBeNull();
expect(db.tables.user_profiles).toEqual([]);
});
});

View file

@ -152,9 +152,16 @@ async function attachChatCreatorLabels(
}
// GET /projects
// Pass ?include=documents to also receive each project's documents in the
// same response. The directory pickers (useDirectoryData) previously fanned
// out one GET /projects/:id per project to obtain those documents; with N
// projects that burst — auth check plus several DB queries per request —
// could overwhelm the Supabase gateway. Batching keeps it at one request
// and a fixed number of queries regardless of project count.
projectsRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const includeDocuments = req.query.include === "documents";
const db = createServerSupabase();
const { data, error } = await db.rpc("get_projects_overview", {
@ -163,7 +170,45 @@ projectsRouter.get("/", requireAuth, async (req, res) => {
});
if (error) return void res.status(500).json({ detail: error.message });
res.json(data ?? []);
const projects = (data ?? []) as { id: string }[];
if (!includeDocuments || projects.length === 0) {
return void res.json(projects);
}
const { data: docs, error: docsError } = await db
.from("documents")
.select("*")
.in(
"project_id",
projects.map((p) => p.id),
)
.order("created_at", { ascending: true });
if (docsError)
return void res.status(500).json({ detail: docsError.message });
const docsTyped = (docs ?? []) as unknown as {
id: string;
project_id?: string | null;
user_id?: string | null;
current_version_id?: string | null;
}[];
await attachLatestVersionNumbers(db, docsTyped);
await attachActiveVersionPaths(db, docsTyped);
await attachDocumentOwnerLabels(db, docsTyped);
const docsByProject = new Map<string, typeof docsTyped>();
for (const doc of docsTyped) {
if (!doc.project_id) continue;
const bucket = docsByProject.get(doc.project_id);
if (bucket) bucket.push(doc);
else docsByProject.set(doc.project_id, [doc]);
}
res.json(
projects.map((p) => ({
...p,
documents: docsByProject.get(p.id) ?? [],
})),
);
});
// POST /projects

View file

@ -16,5 +16,5 @@
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
}

38
backend/vitest.config.mts Normal file
View file

@ -0,0 +1,38 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
// Generous timeouts so cold-start module transform/import latency
// can't cause spurious timeout failures on a cold CI runner. Warm
// tests finish in ~1s; this only guards the pathological cold case —
// it does not mask hangs.
testTimeout: 20000,
hookTimeout: 20000,
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
include: ["src/lib/**"],
// No-regression RATCHET floor, not a target. src/lib/** spans the
// tested libs (access, storage keys/dispositions, downloadTokens,
// userApiKeys provider/env checks, chat doc resolution, safeError,
// llm model resolution, chat citations, userLookup,
// documentVersions, userDataCleanup) AND the large, still-untested
// feature libs (courtlistener, mcp, chat tool dispatch, llm
// providers, spreadsheet/docx handling), so the global number is
// still low. Measured on this tree: 11.18% statements, 10.98%
// branches, 14.43% functions, 10.91% lines. These floors sit just
// below that (rounded down to whole percents) so CI fails on a
// *drop*. Floors only go up: when you add tests, raise them in the
// same PR. Backlog + per-area status: docs/testing-coverage.md.
thresholds: {
statements: 11,
branches: 10,
functions: 14,
lines: 10,
},
},
},
});

122
docs/testing-coverage.md Normal file
View file

@ -0,0 +1,122 @@
# Backend unit-test coverage
The backend has a Vitest unit-test harness over `backend/src/lib/**`. This doc
tracks what is covered, what still needs tests, and how the coverage ratchet
works — so you can pick up a checkbox below and land it as a small PR.
## Running the tests
```bash
cd backend
npm install
npm test # run all unit tests
npm run test:coverage # same, plus the per-file coverage table + floor check
```
Tests live in `backend/src/lib/__tests__/*.test.ts`. Read a couple of the
existing suites first (`access.test.ts`, `userDataCleanup.test.ts`) and match
their conventions: plain in-memory Supabase query mocks (no network, no real
database), one `describe` block per exported function, and tests that assert
current behavior.
## Current coverage (measured 2026-07)
Per-area statement coverage from `npm run test:coverage`:
| Lib area | % statements | Tested? |
| --- | ---: | :---: |
| `lib/safeError.ts` | 100 | ✓ |
| `lib/userDataCleanup.ts` | 100 | ✓ |
| `lib/llm/models.ts` | 100 | ✓ |
| `lib/documentVersions.ts` | 98 | ✓ |
| `lib/chat/citations.ts` | 98 | ✓ |
| `lib/userLookup.ts` | 91 | ✓ |
| `lib/downloadTokens.ts` | 87 | ✓ |
| `lib/chat/types.ts` | 85 | ✓ |
| `lib/access.ts` | 76 | ✓ |
| `lib/storage.ts` | 33 | partial — key/disposition helpers only |
| `lib/userApiKeys.ts` | 13 | partial — provider/env helpers only |
| `lib/documentTypes.ts`, `lib/userSettings.ts`, `lib/upload.ts`, `lib/officeText.ts` | 0 | ✗ |
| `lib/convert.ts`, `lib/spreadsheet.ts`, `lib/docxTrackedChanges.ts` | 0 | ✗ |
| `lib/userDataExport.ts` | 0 | ✗ |
| `lib/courtlistener.ts`, `lib/systemWorkflows.ts` | 0 | ✗ |
| `lib/chat/prompts.ts`, `lib/chat/contextBuilders.ts`, `lib/chat/streaming.ts` | 0 | ✗ |
| `lib/chat/tools/**` (schemas, documentOps, toolDispatcher) | 0 | ✗ |
| `lib/llm/**` (providers, tools, index, rawStreamLog) | ~4 | ✗ (only models.ts) |
| `lib/mcp/**` (client, servers, oauth, types) | 0 | ✗ |
Global: **11.18% statements / 10.98% branches / 14.43% functions / 10.91%
lines**. The global number is low because `src/lib/**` includes several very
large feature libs (toolDispatcher, documentOps, systemWorkflows,
courtlistener, docxTrackedChanges) that dominate the line count.
## TODO — untested libs, in priority order
Each item is meant to be one self-contained PR: add the suite, then raise the
floors in `backend/vitest.config.mts` to just below the new measured numbers.
Size is a rough guess: S ≈ an hour, M ≈ an afternoon.
- [ ] `lib/documentTypes.ts` — pure catalog/lookup of document types; assert
known types resolve and unknown inputs fall back sanely. (S)
- [ ] `lib/chat/prompts.ts` — pure prompt builders; assert key instructions and
interpolated values appear in the output strings. (S)
- [ ] `lib/userSettings.ts` — title/tabular model resolution from which API
keys a user has; reuse the Supabase mock pattern from
`userLookup.test.ts`. (S)
- [ ] `lib/upload.ts` — multer wrapper: assert LIMIT_FILE_SIZE maps to a 413
with the right message and other errors pass through. (S)
- [ ] `lib/officeText.ts` — office XML text extraction; build a tiny in-memory
zip fixture with JSZip and assert extracted/decoded text. (S)
- [ ] `lib/chat/tools/toolSchemas.ts` — assert every tool schema has a name,
description, and well-formed parameters (guards against schema drift). (S)
- [ ] `lib/userApiKeys.ts` (rest) — encrypt/decrypt round-trip and DB
load/store paths with a mocked Supabase client. (M)
- [ ] `lib/storage.ts` (rest) — S3 upload/download/list/delete wrappers with a
mocked AWS SDK client. (M)
- [ ] `lib/userDataExport.ts` — export assembly: given seeded mock tables,
assert the export contains the user's data and nobody else's. (M)
- [ ] `lib/spreadsheet.ts` — parse a small in-memory xlsx fixture; assert sheet
and cell extraction, including empty/edge cells. (M)
- [ ] `lib/chat/contextBuilders.ts` — context assembly from doc stores; assert
doc labels, truncation, and ordering. (M)
- [ ] `lib/docxTrackedChanges.ts` — tracked-changes XML round-trip on a minimal
docx fixture: insert/delete runs, accept/reject. High value: document
integrity. (M)
- [ ] `lib/courtlistener.ts` — API client with mocked fetch: query building,
pagination, and error paths. Legal-research correctness. (M)
- [ ] `lib/systemWorkflows.ts` — mostly data: assert workflow definitions are
well-formed (unique ids, non-empty skill markdown). (S)
- [ ] `lib/llm/tools.ts` + `lib/llm/index.ts` — provider-neutral tool plumbing
and provider selection with mocked provider modules. (M)
- [ ] `lib/mcp/types.ts` + `lib/mcp/servers.ts` — server config validation and
allow-listing logic; security relevant. (M)
- [ ] `lib/mcp/client.ts` + `lib/mcp/oauth.ts` — connection lifecycle and OAuth
token handling with a mocked MCP SDK; security relevant. (M)
- [ ] `lib/llm/rawStreamLog.ts` — log path construction and redaction with a
mocked fs. (S)
- [ ] `lib/chat/tools/documentOps.ts` — start with the pure helpers (diff/match
utilities), not the full tool handlers. (M)
- [ ] `lib/chat/tools/toolDispatcher.ts` — dispatch table routing and argument
validation with stubbed tools; don't try to cover every tool body. (M)
- [ ] `lib/chat/streaming.ts` + `lib/llm/{claude,gemini,openai}.ts` — streaming
loops and provider adapters; hardest to unit test, consider extracting
pure chunk-parsing helpers first. (M)
Not worth unit testing directly: `lib/supabase.ts` and `lib/convert.ts` are
thin wrappers around external services (Supabase auth, LibreOffice); they are
better exercised by the e2e suite.
## Ratchet policy
`backend/vitest.config.mts` enforces global coverage **floors** (currently
statements 11 / branches 10 / functions 14 / lines 10). They are a
no-regression ratchet, not a target:
- **Floors only go up.** Never lower them to get a PR green — that means your
change removed tested behavior or added a large untested lib; add tests
instead.
- **Raise them in the same PR that adds tests.** After your suite passes, run
`npm run test:coverage`, take the new global numbers, and set each floor to
the measured value rounded down to a whole percent.
- Keep the measured numbers in the config comment and the table above honest
when you do.

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
@ -61,18 +62,24 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/marked": "^5.0.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "^2.9.11",
"eslint": "^9",
"eslint-config-next": "^16.2.6",
"jsdom": "^27.0.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.1.9",
"wrangler": "^4.90.0"
},
"license": "AGPL-3.0-only"

View file

@ -242,6 +242,7 @@ export function AskInputPopup({
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- auto-submit when every question is answered; submit() sets state as part of the side effect
if (canSubmit) submit();
});

View file

@ -216,6 +216,7 @@ export function CaseLawPanel({
useEffect(() => {
if (tab.opinions?.length) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync path of an async fetch effect: serve prop/cache data without a loading flash
setOpinions(tab.opinions);
setLoading(false);
setError(null);
@ -269,10 +270,12 @@ export function CaseLawPanel({
orderOpinions(opinions).find(
({ opinion }) => typeof opinion.opinionId === "number",
)?.opinion.opinionId ?? null;
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset active opinion after opinions load
setActiveOpinionId(firstOpinionId);
}, [opinions]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync quote list when the tab prop changes
setRelevantQuotes(tab.quotes ?? []);
}, [tab.quotes]);
@ -321,6 +324,7 @@ export function CaseLawPanel({
);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset quote selection when the quote set changes
setQuoteIndexState({ cacheKey: quoteCacheKey, index: 0 });
const firstQuote = relevantQuotes[0];
setActiveQuoteKey(firstQuote ? relevantQuoteKey(firstQuote, 0) : null);

View file

@ -83,6 +83,7 @@ export function ChatView({
const panelCloseTimerRef = useRef<number | null>(null);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset per-chat UI state when switching chats
setHiddenAskInputKeys(new Set());
}, [chatId]);
@ -519,6 +520,7 @@ export function ChatView({
const c = messagesContainerRef.current;
if (!c) return;
c.addEventListener("scroll", updateScrollButton);
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial scroll-button state must be measured from the live DOM
updateScrollButton();
return () => c.removeEventListener("scroll", updateScrollButton);
}, [messages, updateScrollButton]);
@ -553,6 +555,7 @@ export function ChatView({
useEffect(() => {
if (messages.length === 0) {
hasScrolledRef.current = false;
// eslint-disable-next-line react-hooks/set-state-in-effect -- hide messages until scroll position is restored to avoid a visible jump
setMessagesVisible(false);
} else if (!hasScrolledRef.current) {
const userMsgCount = messages.filter(
@ -682,8 +685,8 @@ export function ChatView({
{msg.role === "user" ? (
<UserMessage
content={msg.content ?? ""}
files={(msg as any).files}
workflow={(msg as any).workflow}
files={msg.files}
workflow={msg.workflow}
/>
) : (
<AssistantMessage
@ -692,11 +695,11 @@ export function ChatView({
i === messages.length - 1 &&
isResponseLoading
}
isError={!!(msg as any).error}
isError={!!msg.error}
errorMessage={
typeof (msg as any)
.error === "string"
? (msg as any).error
typeof msg.error ===
"string"
? msg.error
: undefined
}
citations={msg.citations}

View file

@ -47,6 +47,7 @@ export function CitationQuotesHeader({
useEffect(() => {
if (!hasMultipleQuotes && viewMode === "list") {
// eslint-disable-next-line react-hooks/set-state-in-effect -- collapse list view when quotes drop to a single item
setViewMode("single");
}
}, [hasMultipleQuotes, viewMode]);

View file

@ -29,6 +29,7 @@ export function PreResponseWrapper({
useEffect(() => {
if (forceOpen) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- streaming open/minimize latch (see comment above)
setIsOpen(true);
return;
}

View file

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useSmoothedReveal } from "./useSmoothedReveal";
const FULL = "**Demo mode** — no AI provider key is configured.";
describe("useSmoothedReveal", () => {
it("snaps to the full text when the stream ends mid-reveal", async () => {
// Stream a long body in one chunk while active: the rAF pacer will only
// have revealed a prefix by the time the stream ends.
const { result, rerender } = renderHook(
({ text, active }) => useSmoothedReveal(text, active),
{ initialProps: { text: "", active: true } },
);
rerender({ text: FULL, active: true });
expect(result.current.length).toBeLessThan(FULL.length);
// Stream ends. The hook must snap to the full text — it drives the
// rendered slice off `revealedInt`, so updating only the internal ref
// would leave the reply frozen at a partial prefix (e.g. "**Demo mo").
await act(async () => {
rerender({ text: FULL, active: false });
});
expect(result.current).toBe(FULL);
});
it("returns the full text immediately for a replayed (non-streaming) message", () => {
const { result } = renderHook(() => useSmoothedReveal(FULL, false));
expect(result.current).toBe(FULL);
});
it("never returns more than the text it was given", async () => {
const { result, rerender } = renderHook(
({ text, active }) => useSmoothedReveal(text, active),
{ initialProps: { text: FULL, active: false } },
);
// Text replaced by something shorter (edited / retried turn).
await act(async () => {
rerender({ text: "short", active: false });
});
expect(result.current).toBe("short");
});
});

View file

@ -61,6 +61,7 @@ export function Modal({
// Portals can't render during SSR, so a keep-mounted modal only renders
// (hidden) after the first client mount.
const [hasMounted, setHasMounted] = useState(false);
// eslint-disable-next-line react-hooks/set-state-in-effect -- SSR portal gate: must flip after first client mount
useEffect(() => setHasMounted(true), []);
const hasHeader = breadcrumbs?.length;
const hasFooter =

View file

@ -0,0 +1,92 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { FileTypeIcon, fileTypeKind } from "./FileTypeIcon";
describe("fileTypeKind", () => {
it("maps bare file_type values to a kind", () => {
expect(fileTypeKind("pdf")).toBe("pdf");
expect(fileTypeKind("docx")).toBe("word");
expect(fileTypeKind("doc")).toBe("word");
expect(fileTypeKind("xlsx")).toBe("excel");
expect(fileTypeKind("xlsm")).toBe("excel");
expect(fileTypeKind("xls")).toBe("excel");
expect(fileTypeKind("pptx")).toBe("ppt");
expect(fileTypeKind("ppt")).toBe("ppt");
});
it("maps filenames by their extension", () => {
expect(fileTypeKind("report.pdf")).toBe("pdf");
expect(fileTypeKind("Quarterly Deck.PPTX")).toBe("ppt");
expect(fileTypeKind("model.final.xlsx")).toBe("excel");
});
it("is case-insensitive and trims whitespace", () => {
expect(fileTypeKind(" PDF ")).toBe("pdf");
expect(fileTypeKind("DOCX")).toBe("word");
});
it("falls back to other for unknown, empty, or nullish input", () => {
expect(fileTypeKind("txt")).toBe("other");
expect(fileTypeKind("")).toBe("other");
expect(fileTypeKind(null)).toBe("other");
expect(fileTypeKind(undefined)).toBe("other");
});
});
describe("FileTypeIcon", () => {
const svgOf = (container: HTMLElement) => container.querySelector("svg");
const imgOf = (container: HTMLElement) => container.querySelector("img");
it("renders the PDF icon image", () => {
const { container } = render(<FileTypeIcon fileType="pdf" />);
expect(imgOf(container)).toHaveAttribute(
"src",
expect.stringContaining("/icons/file-types/pdf.svg"),
);
});
it("renders the Word icon image", () => {
const { container } = render(<FileTypeIcon fileType="deck.docx" />);
expect(imgOf(container)).toHaveAttribute(
"src",
expect.stringContaining("/icons/file-types/word.svg"),
);
});
it("renders the Excel icon image", () => {
const { container } = render(<FileTypeIcon fileType="xlsx" />);
expect(imgOf(container)).toHaveAttribute(
"src",
expect.stringContaining("/icons/file-types/excel.svg"),
);
});
it("renders a grey icon for unknown types", () => {
const { container } = render(<FileTypeIcon fileType={null} />);
expect(svgOf(container)).toHaveClass("text-gray-500");
});
it("renders a muted grayscale image for a known kind", () => {
const { container } = render(<FileTypeIcon fileType="pdf" muted />);
const img = imgOf(container);
expect(img).toHaveClass("grayscale");
expect(img).toHaveClass("opacity-35");
});
it("renders a muted grey placeholder for unknown types", () => {
const { container } = render(<FileTypeIcon fileType={null} muted />);
const svg = svgOf(container);
expect(svg).toHaveClass("text-gray-300");
});
it("always applies shrink-0 and merges a custom className", () => {
const { container } = render(
<FileTypeIcon fileType="pdf" className="h-6 w-6" />,
);
const img = imgOf(container);
expect(img).toHaveClass("shrink-0");
expect(img).toHaveClass("h-6");
expect(img).toHaveClass("w-6");
});
});

View file

@ -21,6 +21,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
useEffect(() => {
if (!user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- sync fast paths of the async MFA check effect
setGateState("idle");
return;
}
@ -64,6 +65,7 @@ export function MfaLoginGate({ children }: { children: ReactNode }) {
if (gateState === "required" && !isVerifyPage) {
if (hasRecentMfaVerification()) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear gate when a recent MFA verification exists instead of redirecting
setGateState("verified");
return;
}

View file

@ -1,11 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
getLibrary,
getProject,
listProjects,
} from "@/app/lib/mikeApi";
import { getLibrary, listProjects } from "@/app/lib/mikeApi";
import type { Document, LibraryFolder, Project } from "./types";
export type DirectoryTab = "files" | "templates" | "projects";
@ -45,17 +41,14 @@ async function loadTemplates() {
}
async function loadProjects() {
const projects = await listProjects();
const fullProjects = await Promise.all(
projects.map((project) => getProject(project.id)),
);
const projectCounts = new Map(
projects.map((project) => [project.id, project.document_count ?? 0]),
);
return fullProjects.map((project) => ({
// One batched request. Fanning out getProject(id) per project caused an
// N+1 burst on every directory-modal open that could overwhelm the
// Supabase gateway once an account had accumulated projects.
const projects = await listProjects({ includeDocuments: true });
return projects.map((project) => ({
...project,
document_count:
project.documents?.length ?? projectCounts.get(project.id) ?? 0,
project.documents?.length ?? project.document_count ?? 0,
}));
}

View file

@ -164,6 +164,7 @@ function TRResponseStatus({ isActive }: { isActive: boolean }) {
useEffect(() => {
if (wasActiveRef.current && !isActive) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- timed 'Done' flash on the active->idle transition
setShowDone(true);
setDoneVisible(true);
const t = setTimeout(() => setDoneVisible(false), 1500);

View file

@ -0,0 +1,39 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TRTable } from "./TRTable";
import type { Document } from "../shared/types";
const doc = { id: "doc-1", filename: "report.pdf" } as Document;
function renderTable() {
return render(
<TRTable
loading={false}
columns={[]}
documents={[doc]}
cells={[]}
savingColumn={false}
savingColumnsConfig={false}
selectedDocIds={[]}
onSelectionChange={vi.fn()}
onExpand={vi.fn()}
onCitationClick={vi.fn()}
onUpdateColumn={vi.fn()}
onDeleteColumn={vi.fn()}
onAddColumn={vi.fn()}
onAddDocuments={vi.fn()}
/>,
);
}
describe("TRTable", () => {
// The grid here is div-based (no table/columnheader/rowheader roles), so
// this asserts on rendered content rather than ARIA table semantics.
it("renders the Document header and a row for each document", () => {
renderTable();
expect(screen.getByText("Document")).toBeInTheDocument();
expect(screen.getByText("report.pdf")).toBeInTheDocument();
// One select-all checkbox in the header plus one per document row.
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
});

View file

@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button", () => {
it("renders its children", () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: "Click me" }),
).toBeInTheDocument();
});
it("fires onClick when activated", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={onClick}>Submit</Button>);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("applies the variant class for destructive buttons", () => {
render(<Button variant="destructive">Delete</Button>);
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
"bg-destructive",
);
});
it("does not fire onClick while disabled", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(
<Button disabled onClick={onClick}>
Disabled
</Button>,
);
await user.click(screen.getByRole("button", { name: "Disabled" }));
expect(onClick).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,40 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CiteButton } from "./cite-button";
describe("CiteButton", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders the default 'Cite' label", () => {
render(<CiteButton quoteText="hello" citationText="Doe 2020" />);
expect(
screen.getByRole("button", { name: /cite/i }),
).toBeInTheDocument();
});
it("hides the label when showText is false", () => {
render(
<CiteButton
quoteText="hello"
citationText="Doe 2020"
showText={false}
/>,
);
expect(screen.queryByText("Cite")).not.toBeInTheDocument();
});
it("copies the quote and citation, then shows 'Copied'", async () => {
// userEvent.setup() installs a clipboard stub on navigator; spy on it.
const user = userEvent.setup();
const writeText = vi.spyOn(navigator.clipboard, "writeText");
render(<CiteButton quoteText={`he said "hi"`} citationText="Doe 2020" />);
await user.click(screen.getByRole("button"));
expect(writeText).toHaveBeenCalledWith(`"he said 'hi'" Doe 2020`);
expect(await screen.findByText("Copied")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,91 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { PillButton } from "./pill-button";
describe("PillButton", () => {
it("renders its children as a button by default", () => {
render(<PillButton tone="black">Save</PillButton>);
expect(
screen.getByRole("button", { name: "Save" }),
).toBeInTheDocument();
});
it("defaults to type=button", () => {
render(<PillButton tone="black">Save</PillButton>);
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute(
"type",
"button",
);
});
it("applies the tone class", () => {
render(<PillButton tone="danger">Delete</PillButton>);
expect(screen.getByRole("button", { name: "Delete" })).toHaveClass(
"bg-red-600/90",
);
});
it("applies the normal size class when requested", () => {
render(
<PillButton tone="blue" size="normal">
Continue
</PillButton>,
);
expect(screen.getByRole("button", { name: "Continue" })).toHaveClass(
"text-sm",
);
});
it("defaults to the sm size class", () => {
render(<PillButton tone="blue">Next</PillButton>);
expect(screen.getByRole("button", { name: "Next" })).toHaveClass(
"text-xs",
);
});
it("fires onClick when activated", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(
<PillButton tone="white" onClick={onClick}>
Click me
</PillButton>,
);
await user.click(screen.getByRole("button", { name: "Click me" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("does not fire onClick while disabled", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(
<PillButton tone="black" disabled onClick={onClick}>
Disabled
</PillButton>,
);
await user.click(screen.getByRole("button", { name: "Disabled" }));
expect(onClick).not.toHaveBeenCalled();
});
it("renders as its child element via asChild", () => {
render(
<PillButton tone="blue" asChild>
<a href="/docs">Docs</a>
</PillButton>,
);
const link = screen.getByRole("link", { name: "Docs" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/docs");
// asChild drops the intrinsic button type onto the child.
expect(link).not.toHaveAttribute("type");
// Pill styling still lands on the rendered child.
expect(link).toHaveClass("rounded-full");
});
});

View file

@ -73,6 +73,7 @@ export function ChatHistoryProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (!user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear chat state on logout inside the effect that loads chats
setChats([]);
setChatLimit(INITIAL_CHAT_LIMIT);
setHasMoreChats(false);

View file

@ -49,6 +49,7 @@ export function useFetchDocxBytes(
useEffect(() => {
if (!documentId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear stale bytes when documentId is removed, within the fetch effect
setBytes(null);
setDownloadUrl(null);
return;

View file

@ -16,6 +16,7 @@ export function useSelectedModel(): [string, (id: string) => void] {
const [model, setModelState] = useState<string>(DEFAULT_MODEL_ID);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydration-safe localStorage read; SSR must render the default model
setModelState(readStored());
}, []);

View file

@ -163,8 +163,11 @@ async function toApiError(response: Response, path: string) {
// Projects
// ---------------------------------------------------------------------------
export async function listProjects(): Promise<Project[]> {
return apiRequest<Project[]>("/projects");
export async function listProjects(options?: {
includeDocuments?: boolean;
}): Promise<Project[]> {
const query = options?.includeDocuments ? "?include=documents" : "";
return apiRequest<Project[]>(`/projects${query}`);
}
export async function createProject(

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { cn, diceCoefficient, isFuzzyMatch } from "./utils";
describe("cn", () => {
it("joins truthy class names and drops falsy ones", () => {
expect(cn("a", false && "b", undefined, "c")).toBe("a c");
});
it("merges conflicting tailwind classes, keeping the last", () => {
expect(cn("px-2", "px-4")).toBe("px-4");
});
});
describe("diceCoefficient", () => {
it("returns 1 for identical strings (ignoring case and punctuation)", () => {
expect(diceCoefficient("Hello, World!", "hello world")).toBe(1);
});
it("returns 0 when either input is empty", () => {
expect(diceCoefficient("", "anything")).toBe(0);
expect(diceCoefficient("anything", "")).toBe(0);
});
it("returns a partial score for partially overlapping strings", () => {
const score = diceCoefficient("night", "nacht");
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThan(1);
});
});
describe("isFuzzyMatch", () => {
it("matches near-identical strings above the default threshold", () => {
expect(isFuzzyMatch("organization", "organisation")).toBe(true);
});
it("rejects clearly different strings", () => {
expect(isFuzzyMatch("apple", "zebra")).toBe(false);
});
it("respects a custom threshold", () => {
// "night" vs "nacht" scores ~0.25 — passes a low bar, fails a high one.
expect(isFuzzyMatch("night", "nacht", 0.1)).toBe(true);
expect(isFuzzyMatch("night", "nacht", 0.9)).toBe(false);
});
});

View file

@ -228,7 +228,7 @@ export default function SupportPage() {
{/* Email Display (if logged in) */}
{user?.email && (
<div className="text-sm text-gray-500">
We'll respond to:{" "}
We&apos;ll respond to:{" "}
<span className="font-medium">
{user.email}
</span>

View file

@ -0,0 +1,45 @@
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
const resolvePath = (relative: string) =>
fileURLToPath(new URL(relative, import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
// Mirror the `@/*` path alias from tsconfig.json so unit tests resolve
// the same module specifiers the app uses.
alias: [
{
find: /^@\/(.*)$/,
replacement: resolvePath("./src/$1"),
},
],
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
// app/lib/supabase.ts creates its client at module load, so any
// component whose import graph reaches it needs these set. Dummy
// values — unit tests never talk to Supabase.
env: {
NEXT_PUBLIC_SUPABASE_URL: "http://localhost:54321",
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY: "test-anon-key",
},
// jsdom 27's CSS-color parser (@asamuzakjp/css-color) is CJS but
// require()s the ESM-only @csstools/css-calc. That require() happens
// in the worker process while the jsdom environment boots — before
// Vite's transform pipeline is involved — so deps.inline can't fix it.
// Instead, let Node itself handle require(esm): default on >=22.12,
// and enabled by this (there harmless) flag on 22.022.11.
execArgv: ["--experimental-require-module"],
// Unit tests only. Keep any Playwright e2e specs (*.spec.ts) out.
include: ["src/**/*.test.{ts,tsx}"],
exclude: ["node_modules/**", "e2e/**", "**/*.spec.ts"],
// Generous timeouts to absorb cold-start jsdom + transform latency on CI.
testTimeout: 20000,
hookTimeout: 20000,
},
});

1
frontend/vitest.setup.ts Normal file
View file

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";