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:
QA Runner 2026-07-20 10:48:53 -07:00
parent 4039b94980
commit c139acc3c6
7 changed files with 747 additions and 1 deletions

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,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);
});
});