mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
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>
This commit is contained in:
parent
4039b94980
commit
c139acc3c6
7 changed files with 747 additions and 1 deletions
242
backend/package-lock.json
generated
242
backend/package-lock.json
generated
|
|
@ -37,6 +37,7 @@
|
|||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
@ -968,6 +969,42 @@
|
|||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
|
||||
|
|
@ -977,6 +1014,30 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
|
|
@ -1488,6 +1549,16 @@
|
|||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
|
|
@ -1495,6 +1566,17 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
|
|
@ -3502,6 +3584,37 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.10",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.2",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.10",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||
|
|
@ -3716,6 +3829,18 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz",
|
||||
"integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
|
|
@ -4671,6 +4796,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
|
|
@ -4723,6 +4858,13 @@
|
|||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
|
|
@ -4883,6 +5025,45 @@
|
|||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
|
|
@ -4892,6 +5073,13 @@
|
|||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
|
|
@ -5278,6 +5466,34 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz",
|
||||
"integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz",
|
||||
|
|
@ -6062,6 +6278,19 @@
|
|||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
|
|
@ -6293,6 +6522,19 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.90.0",
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.14.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.8.3",
|
||||
|
|
|
|||
163
backend/src/lib/__tests__/access.test.ts
Normal file
163
backend/src/lib/__tests__/access.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
81
backend/src/lib/__tests__/chatTypes.test.ts
Normal file
81
backend/src/lib/__tests__/chatTypes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
149
backend/src/lib/__tests__/storage.test.ts
Normal file
149
backend/src/lib/__tests__/storage.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
73
backend/src/lib/__tests__/userApiKeys.test.ts
Normal file
73
backend/src/lib/__tests__/userApiKeys.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
36
backend/vitest.config.mts
Normal file
36
backend/vitest.config.mts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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
|
||||
// small, tested libs (access, storage keys/dispositions,
|
||||
// downloadTokens, userApiKeys provider/env checks, chat doc
|
||||
// resolution) AND the large, still-untested feature libs
|
||||
// (courtlistener, mcp, chat tool dispatch, llm providers,
|
||||
// spreadsheet/docx handling), so the global number is low.
|
||||
// Measured on this tree: 2.58% statements, 2.00% branches,
|
||||
// 4.61% functions, 2.58% lines. These floors sit just below
|
||||
// that (rounded down to whole percents) so CI fails on a
|
||||
// *drop*; raise them as the feature-lib backlog gets covered.
|
||||
thresholds: {
|
||||
statements: 2,
|
||||
branches: 2,
|
||||
functions: 4,
|
||||
lines: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue