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
212 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|