Bind DOC0 upload receipts to the original request #424
@@ -142,11 +142,18 @@ export class Doc0UploadClient {
|
||||
) {
|
||||
throw new Error('A trusted HTTPS DOC0 endpoint, service credential and valid upload limits are required');
|
||||
}
|
||||
// Keep the validated values even if the caller later changes its options object.
|
||||
this.options = { ...options };
|
||||
this.baseUrl = url.toString().replace(/\/+$/, '');
|
||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
async upload(input: Doc0UploadInput): Promise<Doc0UploadReceipt> {
|
||||
if (!(input.bytes instanceof Uint8Array) || input.bytes.byteLength > this.options.maxBytes) {
|
||||
throw new Error('Invalid DOC0 upload input');
|
||||
}
|
||||
// Bind the outbound request and receipt to the same immutable-by-caller input.
|
||||
input = { ...input, bytes: new Uint8Array(input.bytes) };
|
||||
const identity = prepareDoc0UploadIdentity(input);
|
||||
if (identity.sourceSizeBytes > this.options.maxBytes) throw new Error('Invalid DOC0 upload input');
|
||||
const expectedHash = identity.sourceSha256;
|
||||
|
||||
@@ -27,6 +27,43 @@ const receipt = {
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user