Storage paths stay UUID-flat per the established CRM pattern (every other content type — brochures, berth PDFs, invoices, reports, templates, expense receipts — uses the same shape). The new catch-all /api/v1/documents/[id]/download/[...slug] route serves files keyed on doc id but rebuilds the slug from current state and 404s on mismatch — a hand-edited or stale link can't render the wrong filename or fold a wrong-folder path into a forwarded URL. URLs in shared links / browser tabs read like 'Deals 2026/Q1/contract.pdf' even though storage keys remain UUIDs. listDocuments + getDocumentById now hydrate a `downloadUrl` field per row (null when no file is attached yet) so UI consumers don't reconstruct paths. Filename is batch-fetched via files-table join to keep the query builder shape unchanged. Tests: 5 integration cases — happy-path stream, wrong-folder slug, wrong-filename slug, orphaned doc (no fileId), cross-port (tenancy isolation). Storage backend swapped to a real FilesystemBackend in a tempdir so the byte-streaming path is exercised end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
214 lines
7.1 KiB
TypeScript
214 lines
7.1 KiB
TypeScript
/**
|
|
* Integration test: GET /api/v1/documents/[id]/download/[...slug]
|
|
*
|
|
* Verifies the slug truth-check and tenancy isolation on the path-style
|
|
* download route. The storage backend is swapped for a real FilesystemBackend
|
|
* rooted in a tempdir so the byte-streaming path is exercised end-to-end.
|
|
*/
|
|
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import * as path from 'node:path';
|
|
|
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { documents, files } from '@/lib/db/schema/documents';
|
|
import { user } from '@/lib/db/schema/users';
|
|
import { createFolder } from '@/lib/services/document-folders.service';
|
|
import { makeMockCtx } from '../helpers/route-tester';
|
|
import { makeFullPermissions, makePort } from '../helpers/factories';
|
|
|
|
let testUserId: string;
|
|
|
|
beforeAll(async () => {
|
|
const [u] = await db.select({ id: user.id }).from(user).limit(1);
|
|
testUserId = u!.id;
|
|
});
|
|
|
|
describe('GET /api/v1/documents/[id]/download/[...slug]', () => {
|
|
let storageRoot: string;
|
|
let backend: import('@/lib/storage/filesystem').FilesystemBackend;
|
|
|
|
beforeEach(async () => {
|
|
storageRoot = await mkdtemp(path.join(tmpdir(), 'pn-doc-dl-'));
|
|
const { FilesystemBackend } = await import('@/lib/storage/filesystem');
|
|
backend = await FilesystemBackend.create({
|
|
root: storageRoot,
|
|
proxyHmacSecretEncrypted: null,
|
|
});
|
|
vi.doMock('@/lib/storage', async () => {
|
|
const real = await vi.importActual<typeof import('@/lib/storage')>('@/lib/storage');
|
|
return { ...real, getStorageBackend: vi.fn(async () => backend) };
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.doUnmock('@/lib/storage');
|
|
await rm(storageRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function setupDoc(opts: {
|
|
portId: string;
|
|
folderId: string | null;
|
|
filename: string;
|
|
body: Buffer;
|
|
storagePath: string;
|
|
}) {
|
|
await backend.put(opts.storagePath, opts.body, {
|
|
contentType: 'application/pdf',
|
|
sizeBytes: opts.body.length,
|
|
});
|
|
const [fileRow] = await db
|
|
.insert(files)
|
|
.values({
|
|
portId: opts.portId,
|
|
filename: opts.filename,
|
|
originalName: opts.filename,
|
|
mimeType: 'application/pdf',
|
|
sizeBytes: String(opts.body.length),
|
|
storagePath: opts.storagePath,
|
|
uploadedBy: testUserId,
|
|
})
|
|
.returning();
|
|
const [docRow] = await db
|
|
.insert(documents)
|
|
.values({
|
|
portId: opts.portId,
|
|
documentType: 'other',
|
|
title: opts.filename,
|
|
createdBy: testUserId,
|
|
folderId: opts.folderId,
|
|
fileId: fileRow!.id,
|
|
})
|
|
.returning();
|
|
return { fileRow: fileRow!, docRow: docRow! };
|
|
}
|
|
|
|
it('streams the file when the slug matches the current folder path + filename', async () => {
|
|
const port = await makePort();
|
|
const folder = await createFolder(port.id, testUserId, { name: 'Deals 2026', parentId: null });
|
|
const sub = await createFolder(port.id, testUserId, { name: 'Q1', parentId: folder.id });
|
|
const body = Buffer.from('hello world');
|
|
const { docRow } = await setupDoc({
|
|
portId: port.id,
|
|
folderId: sub.id,
|
|
filename: 'contract.pdf',
|
|
body,
|
|
storagePath: 'test/contract.pdf',
|
|
});
|
|
|
|
const { downloadHandler } = await import(
|
|
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
|
);
|
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
|
const req = new Request('http://localhost/api/v1/documents/x/download/whatever') as never;
|
|
const res = await downloadHandler(req, ctx, {
|
|
id: docRow.id,
|
|
slug: ['Deals 2026', 'Q1', 'contract.pdf'],
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
expect(buf.toString()).toBe('hello world');
|
|
expect(res.headers.get('content-type')).toBe('application/pdf');
|
|
});
|
|
|
|
it('404s when the folder-path segments do not match current state', async () => {
|
|
const port = await makePort();
|
|
const folder = await createFolder(port.id, testUserId, { name: 'Real Folder', parentId: null });
|
|
const { docRow } = await setupDoc({
|
|
portId: port.id,
|
|
folderId: folder.id,
|
|
filename: 'spec.pdf',
|
|
body: Buffer.from('x'),
|
|
storagePath: 'test/spec.pdf',
|
|
});
|
|
|
|
const { downloadHandler } = await import(
|
|
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
|
);
|
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
|
const req = new Request('http://localhost/x') as never;
|
|
const res = await downloadHandler(req, ctx, {
|
|
id: docRow.id,
|
|
slug: ['Wrong Folder', 'spec.pdf'],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404s when the filename segment does not match current state', async () => {
|
|
const port = await makePort();
|
|
const folder = await createFolder(port.id, testUserId, { name: 'F', parentId: null });
|
|
const { docRow } = await setupDoc({
|
|
portId: port.id,
|
|
folderId: folder.id,
|
|
filename: 'real.pdf',
|
|
body: Buffer.from('x'),
|
|
storagePath: 'test/real.pdf',
|
|
});
|
|
|
|
const { downloadHandler } = await import(
|
|
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
|
);
|
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
|
const req = new Request('http://localhost/x') as never;
|
|
const res = await downloadHandler(req, ctx, {
|
|
id: docRow.id,
|
|
slug: ['F', 'wrong.pdf'],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404s when the document has no attached file (orphaned doc)', async () => {
|
|
const port = await makePort();
|
|
const [docRow] = await db
|
|
.insert(documents)
|
|
.values({
|
|
portId: port.id,
|
|
documentType: 'other',
|
|
title: 'No file',
|
|
createdBy: testUserId,
|
|
folderId: null,
|
|
})
|
|
.returning();
|
|
|
|
const { downloadHandler } = await import(
|
|
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
|
);
|
|
const ctx = makeMockCtx({ portId: port.id, permissions: makeFullPermissions() });
|
|
const req = new Request('http://localhost/x') as never;
|
|
const res = await downloadHandler(req, ctx, {
|
|
id: docRow!.id,
|
|
slug: ['anything.pdf'],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('404s when the document belongs to another port (tenant isolation)', async () => {
|
|
const portA = await makePort();
|
|
const portB = await makePort();
|
|
const folder = await createFolder(portA.id, testUserId, {
|
|
name: 'Cross',
|
|
parentId: null,
|
|
});
|
|
const { docRow } = await setupDoc({
|
|
portId: portA.id,
|
|
folderId: folder.id,
|
|
filename: 'a.pdf',
|
|
body: Buffer.from('x'),
|
|
storagePath: 'test/a.pdf',
|
|
});
|
|
|
|
const { downloadHandler } = await import(
|
|
'@/app/api/v1/documents/[id]/download/[...slug]/handlers'
|
|
);
|
|
const ctx = makeMockCtx({ portId: portB.id, permissions: makeFullPermissions() });
|
|
const req = new Request('http://localhost/x') as never;
|
|
const res = await downloadHandler(req, ctx, {
|
|
id: docRow.id,
|
|
slug: ['Cross', 'a.pdf'],
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
});
|