diff --git a/backend/package-lock.json b/backend/package-lock.json index 65b2c63..29bd3f9 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index d540717..d01ff88 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/lib/__tests__/access.test.ts b/backend/src/lib/__tests__/access.test.ts new file mode 100644 index 0000000..ddc4656 --- /dev/null +++ b/backend/src/lib/__tests__/access.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from "vitest"; +import { + checkProjectAccess, + ensureDocAccess, + ensureReviewAccess, + filterAccessibleDocumentIds, + listAccessibleProjectIds, +} from "../access"; + +type Row = Record; + +function makeDb(tables: Record) { + 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 }); + }); +}); diff --git a/backend/src/lib/__tests__/chatTypes.test.ts b/backend/src/lib/__tests__/chatTypes.test.ts new file mode 100644 index 0000000..5da834a --- /dev/null +++ b/backend/src/lib/__tests__/chatTypes.test.ts @@ -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"); + }); +}); diff --git a/backend/src/lib/__tests__/storage.test.ts b/backend/src/lib/__tests__/storage.test.ts new file mode 100644 index 0000000..7a70eab --- /dev/null +++ b/backend/src/lib/__tests__/storage.test.ts @@ -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"); + }); +}); diff --git a/backend/src/lib/__tests__/userApiKeys.test.ts b/backend/src/lib/__tests__/userApiKeys.test.ts new file mode 100644 index 0000000..c7dbe72 --- /dev/null +++ b/backend/src/lib/__tests__/userApiKeys.test.ts @@ -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); + }); +}); diff --git a/backend/vitest.config.mts b/backend/vitest.config.mts new file mode 100644 index 0000000..2f39f1d --- /dev/null +++ b/backend/vitest.config.mts @@ -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, + }, + }, + }, +});