import { createHash } from 'node:crypto'; import { describe, expect, it, vi } from 'vitest'; import { Doc0UploadClient, Doc0UploadOutcomeUnknownError, Doc0InspectionUnavailableError, prepareDoc0UploadIdentity } from '../../src/document-workbench/doc0-upload-client'; const bytes = new TextEncoder().encode('source bytes'); const input = { tenantId: 'tenant-a', projectId: 'project-a', actorId: 'user-a', requestId: 'req-a', idempotencyKey: 'idem-a', filename: 'test.txt', mimeType: 'text/plain', classification: 'confidential' as const, bytes, }; const receipt = { document: { documentId: 'doc-a', tenantId: 'tenant-a', projectId: 'project-a', currentVersionId: 'v1' }, currentVersion: { versionId: 'v1', tenantId: 'tenant-a', documentId: 'doc-a', checksumSha256: createHash('sha256').update(bytes).digest('hex'), sizeBytes: bytes.length, pipelineStage: 'parsed', }, }; describe('DOC0 upload client', () => { it('validates the receipt against the original scope even if the caller mutates its input during dispatch', async () => { const mutable = { ...input, bytes: new Uint8Array(input.bytes) }; const fetchImpl = async () => { mutable.tenantId = 'tenant-b'; mutable.projectId = 'project-b'; return Response.json({ ...receipt, document: { ...receipt.document, tenantId: 'tenant-b', projectId: 'project-b' }, currentVersion: { ...receipt.currentVersion, tenantId: 'tenant-b' }, }, { status: 201 }); }; const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); await expect(client.upload(mutable)).rejects.toBeInstanceOf(Doc0UploadOutcomeUnknownError); }); it('accepts a matching receipt for the bytes and scope actually dispatched', async () => { const mutable = { ...input, bytes: new Uint8Array(input.bytes) }; const fetchImpl = async (_url: string | URL | Request, init?: RequestInit) => { mutable.tenantId = 'tenant-b'; mutable.bytes.fill(0); const sentFile = (init?.body as FormData).get('file') as Blob; expect(await sentFile.text()).toBe('source bytes'); return Response.json(receipt, { status: 201 }); }; const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); await expect(client.upload(mutable)).resolves.toMatchObject({ documentId: 'doc-a' }); }); it('pins validated client configuration rather than trusting later mutations', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { expect(new Headers(init?.headers).get('authorization')).toBe('Bearer test'); return Response.json(receipt, { status: 201 }); }); const config = { baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, timeoutMs: 1000, fetchImpl }; const client = new Doc0UploadClient(config); config.serviceToken = 'changed'; config.maxBytes = 1; config.timeoutMs = 0; await expect(client.upload(input)).resolves.toMatchObject({ documentId: 'doc-a' }); }); it('sends real bytes and validates a tenant-scoped checksum-bound receipt', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { expect(new Headers(init?.headers).get('x-tenant-id')).toBe('tenant-a'); expect(new Headers(init?.headers).get('x-idempotency-key')).toBe('idem-a'); expect(init?.redirect).toBe('error'); const form = init?.body as FormData; expect(await (form.get('file') as Blob).text()).toBe('source bytes'); return new Response(JSON.stringify(receipt), { status: 201 }); }); const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl: fetchImpl as typeof fetch, }); expect(await client.upload(input)).toMatchObject({ documentId: 'doc-a', versionId: 'v1', pipelineStage: 'parsed', }); }); it.each(['rapport åäö.xlsx', 'folder/name.txt', 'Back\\path.txt', 'A"B.txt', 'row\r\nname.txt'])( 'uses stable multipart metadata while retaining original name: %s', async (filename) => { const fixture = { ...input, filename, mimeType: 'APPLICATION/PDF' }; const identity = prepareDoc0UploadIdentity(fixture); const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { const form = init?.body as FormData; expect(JSON.parse(form.get('input') as string).displayName).toBe(filename); expect((form.get('file') as File).name).toBe(identity.wireFilename); expect((form.get('file') as File).type).toBe('application/pdf'); return Response.json(receipt, { status: 201 }); }); await new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }).upload(fixture); expect(identity.sourceSha256).toBe(receipt.currentVersion.checksumSha256); expect(identity.requestFingerprint).toMatch(/^[0-9a-f]{64}$/); expect(fetchImpl).toHaveBeenCalledOnce(); }, ); it('rejects non-canonical identity headers before any upload', async () => { const fetchImpl = vi.fn(); const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); for (const tenantId of [' tenant-a', 'tenant-a ', 'tenant-å', 'tenant-\n']) { await expect(client.upload({ ...input, tenantId })).rejects.toThrow(/Invalid/); } expect(fetchImpl).not.toHaveBeenCalled(); }); it('rejects ambiguous MIME values before any upload', async () => { const fetchImpl = vi.fn(); const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); for (const mimeType of ['text/plain; charset=utf-8', 'text/å', ' text/plain', 'not-a-mime']) { await expect(client.upload({ ...input, mimeType })).rejects.toThrow(/MIME/); } expect(fetchImpl).not.toHaveBeenCalled(); }); it.each([ { document: { ...receipt.document, tenantId: 'tenant-b' } }, { document: { ...receipt.document, projectId: 'other' } }, { currentVersion: { ...receipt.currentVersion, checksumSha256: 'bad' } }, { currentVersion: { ...receipt.currentVersion, documentId: 'other' } }, ])('rejects an incorrect receipt', async (change) => { const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl: async () => new Response(JSON.stringify({ ...receipt, ...change }), { status: 201 }), }); await expect(client.upload(input)).rejects.toThrow(/receipt/); }); it('rejects empty or oversized inputs before calling DOC0', async () => { const fetchImpl = vi.fn(); const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 2, fetchImpl, }); await expect(client.upload(input)).rejects.toThrow(/Invalid/); await expect(client.upload({ ...input, bytes: new Uint8Array() })).rejects.toThrow(/Invalid/); expect(fetchImpl).not.toHaveBeenCalled(); }); it('rejects insecure endpoints, embedded credentials and invalid timeouts', () => { const base = { serviceToken: 'test', maxBytes: 100 }; for (const baseUrl of ['http://doc0.test', 'https://doc0.test?token=x', 'file:///tmp/doc0', 'not a URL']) { expect(() => new Doc0UploadClient({ ...base, baseUrl })).toThrow(/HTTPS/); } const credentialUrl = new URL('https://doc0.test'); credentialUrl.username = 'fixture'; credentialUrl.password = 'fixture'; expect(() => new Doc0UploadClient({ ...base, baseUrl: credentialUrl.toString() })).toThrow(/HTTPS/); expect(() => new Doc0UploadClient({ ...base, baseUrl: 'https://doc0.test', timeoutMs: 0 })).toThrow(/limits/); }); it('treats transport failure and non-success responses as unknown outcomes without retrying', async () => { for (const response of [new Error('connection reset'), new Response('unavailable', { status: 503 })]) { const fetchImpl = vi.fn(async () => { if (response instanceof Error) throw response; return response; }); const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); await expect(client.upload(input)).rejects.toBeInstanceOf(Doc0UploadOutcomeUnknownError); expect(fetchImpl).toHaveBeenCalledOnce(); } }); it('rejects broken, malformed and oversized receipts as unknown outcomes', async () => { const broken = new ReadableStream({ start(controller) { controller.error(new Error('broken')); } }); const locked = new Response('{}'); const lock = locked.body!.getReader(); for (const response of [new Response(broken), new Response('{'), new Response('x'.repeat(128 * 1024 + 1)), locked]) { const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl: async () => response }); await expect(client.upload(input)).rejects.toBeInstanceOf(Doc0UploadOutcomeUnknownError); } lock.releaseLock(); }); }); describe('DOC0 upload inspection', () => { const inspection = { tenantId: 'tenant-a', projectId: 'project-a', actorId: 'actor-a', requestId: 'req-a', idempotencyKey: 'idem-a', sourceSha256: 'a'.repeat(64), sourceSizeBytes: 12, requestFingerprint: 'b'.repeat(64) }; it('sends only scoped metadata and returns the recorded journal state without retry', async () => { const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { expect(init?.redirect).toBe('error'); expect(init?.cache).toBe('no-store'); expect(new Headers(init?.headers).get('x-idempotency-key')).toBe('idem-a'); expect(JSON.parse(String(init?.body))).toEqual({ projectId: 'project-a', sourceSha256: 'a'.repeat(64), sourceSizeBytes: 12, requestFingerprint: 'b'.repeat(64) }); return Response.json({ status: 'completed_recorded', documentId: 'doc', versionId: 'v1' }); }); const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); expect(await client.inspect(inspection)).toEqual({ status: 'completed_recorded', documentId: 'doc', versionId: 'v1' }); expect(fetchImpl).toHaveBeenCalledOnce(); }); it('preserves weaker states without turning them into successful uploads', async () => { for (const status of ['not_found', 'in_progress', 'reconciliation_required'] as const) { const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl: async () => Response.json({ status }) }); expect(await client.inspect(inspection)).toEqual({ status }); } }); it('fails closed on unknown, malformed, excessive or unavailable responses', async () => { const responses = [Response.json({ status: 'completed' }), Response.json({ status: 'completed_recorded', documentId: 'doc' }), Response.json({ status: 'not_found', documentId: 'unexpected' }), new Response('x'.repeat(128 * 1024 + 1)), new Response('unavailable', { status: 503 })]; for (const response of responses) { const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl: async () => response }); await expect(client.inspect(inspection)).rejects.toBeInstanceOf(Doc0InspectionUnavailableError); } }); it('rejects invalid fingerprints before sending a request', async () => { const fetchImpl = vi.fn(); const client = new Doc0UploadClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', maxBytes: 100, fetchImpl }); await expect(client.inspect({ ...inspection, requestFingerprint: 'bad' })).rejects.toThrow(/Invalid/); expect(fetchImpl).not.toHaveBeenCalled(); }); });