Files
0pt/tests/document-workbench/doc0-upload-client.test.ts
admin c1244a298c
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 3s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 5s
CI (SIAX Cloud) / security (push) Successful in 22s
Security Scan (SIAX Cloud) / security (push) Successful in 22s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 25s
CI (SIAX Cloud) / integration (pull_request) Successful in 32s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
CI (SIAX Cloud) / quality (push) Successful in 47s
CI (SIAX Cloud) / quality (pull_request) Successful in 1m18s
fix: bind DOC0 upload receipts to original request
2026-11-02 08:24:18 +01:00

212 lines
12 KiB
TypeScript

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<Uint8Array>({ 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();
});
});