CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 5s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
CI (SIAX Cloud) / integration (pull_request) Successful in 37s
CI (SIAX Cloud) / quality (pull_request) Successful in 43s
A client retry with the same idempotency key replayed the journal and got 'outcome_unknown' even when the first upload had succeeded, because the verified DOC0 receipt was never persisted. Store document/version/checksum/ stage on the attempt (sql/021) and return it on replay; only a missing receipt falls back to observation. No second POST, no duplicate document.
144 lines
6.4 KiB
TypeScript
144 lines
6.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
Doc0UploadOutcomeUnknownError,
|
|
prepareDoc0UploadIdentity,
|
|
type Doc0UploadClient,
|
|
} from '../../src/document-workbench/doc0-upload-client';
|
|
import { DocumentUploadCoordinator } from '../../src/document-workbench/upload-coordinator';
|
|
import type { PostgresUploadAttemptStore } from '../../src/document-workbench/upload-attempt-store';
|
|
|
|
const input = {
|
|
tenantId: 't1',
|
|
projectId: 'p1',
|
|
actorId: 'a1',
|
|
requestId: 'r1',
|
|
idempotencyKey: 'i1',
|
|
filename: 'rapport åäö.txt',
|
|
mimeType: 'text/plain',
|
|
classification: 'confidential' as const,
|
|
bytes: new TextEncoder().encode('original'),
|
|
};
|
|
const receipt = { documentId: 'd1', versionId: 'v1', checksumSha256: 'test', pipelineStage: 'accepted' };
|
|
function fixture() {
|
|
const attempts = {
|
|
prepare: vi.fn<PostgresUploadAttemptStore['prepare']>(async () => ({
|
|
state: 'prepared' as const,
|
|
replay: false,
|
|
})),
|
|
startDispatch: vi.fn<PostgresUploadAttemptStore['startDispatch']>(async () => true),
|
|
recordReceipt: vi.fn<PostgresUploadAttemptStore['recordReceipt']>(async () => undefined),
|
|
getReceipt: vi.fn<PostgresUploadAttemptStore['getReceipt']>(async () => undefined),
|
|
};
|
|
const doc0 = {
|
|
upload: vi.fn<Doc0UploadClient['upload']>(async () => receipt),
|
|
inspect: vi.fn<Doc0UploadClient['inspect']>(async () => ({ status: 'not_found' as const })),
|
|
};
|
|
const authorize = vi.fn(async () => true);
|
|
const coordinator = new DocumentUploadCoordinator({ attempts, doc0, authorize, maxBytes: 100 });
|
|
return { attempts, doc0, authorize, coordinator };
|
|
}
|
|
describe('document upload coordination', () => {
|
|
it('authorizes and persists the exact fingerprint before a single write', async () => {
|
|
const f = fixture();
|
|
expect(await f.coordinator.execute(input)).toEqual({ status: 'receipt_verified', receipt });
|
|
expect(f.authorize).toHaveBeenCalledWith({ tenantId: 't1', projectId: 'p1', actorId: 'a1' });
|
|
const attempt = f.attempts.prepare.mock.calls[0][0];
|
|
expect(attempt).toEqual({
|
|
tenantId: 't1',
|
|
projectId: 'p1',
|
|
actorId: 'a1',
|
|
attemptId: 'i1',
|
|
sourceSha256: prepareDoc0UploadIdentity(input).sourceSha256,
|
|
sourceSizeBytes: input.bytes.length,
|
|
requestFingerprint: prepareDoc0UploadIdentity(input).requestFingerprint,
|
|
});
|
|
expect(f.attempts.startDispatch).toHaveBeenCalledWith(attempt);
|
|
expect(f.attempts.prepare.mock.invocationCallOrder[0]).toBeLessThan(
|
|
f.attempts.startDispatch.mock.invocationCallOrder[0],
|
|
);
|
|
expect(f.attempts.startDispatch.mock.invocationCallOrder[0]).toBeLessThan(
|
|
f.doc0.upload.mock.invocationCallOrder[0],
|
|
);
|
|
});
|
|
it('denies before persistence or network when authorization fails', async () => {
|
|
const f = fixture();
|
|
f.authorize.mockResolvedValueOnce(false);
|
|
await expect(f.coordinator.execute(input)).rejects.toThrow(/not authorized/);
|
|
expect(f.attempts.prepare).not.toHaveBeenCalled();
|
|
expect(f.doc0.upload).not.toHaveBeenCalled();
|
|
});
|
|
it('rejects truthy but non-boolean authorization results', async () => {
|
|
const f = fixture();
|
|
f.authorize.mockResolvedValueOnce('allow' as unknown as boolean);
|
|
await expect(f.coordinator.execute(input)).rejects.toThrow(/not authorized/);
|
|
expect(f.attempts.prepare).not.toHaveBeenCalled();
|
|
});
|
|
it('does not dispatch after a failed or ambiguous database transition', async () => {
|
|
const f = fixture();
|
|
f.attempts.startDispatch.mockRejectedValueOnce(new Error('connection lost'));
|
|
await expect(f.coordinator.execute(input)).rejects.toThrow(/connection lost/);
|
|
expect(f.doc0.upload).not.toHaveBeenCalled();
|
|
expect(f.doc0.inspect).not.toHaveBeenCalled();
|
|
});
|
|
it('only observes a previously started attempt and never sends a second POST', async () => {
|
|
const f = fixture();
|
|
f.attempts.startDispatch.mockResolvedValueOnce(false);
|
|
expect(await f.coordinator.execute(input)).toEqual({
|
|
status: 'outcome_unknown',
|
|
observation: { status: 'not_found' },
|
|
});
|
|
expect(f.doc0.upload).not.toHaveBeenCalled();
|
|
expect(f.doc0.inspect).toHaveBeenCalledOnce();
|
|
});
|
|
it('persists the verified receipt after a successful upload', async () => {
|
|
const f = fixture();
|
|
expect(await f.coordinator.execute(input)).toEqual({ status: 'receipt_verified', receipt });
|
|
expect(f.attempts.recordReceipt).toHaveBeenCalledWith(
|
|
expect.objectContaining({ attemptId: 'i1' }),
|
|
receipt,
|
|
);
|
|
});
|
|
it('returns the SAME verified receipt on an idempotent retry without a second POST', async () => {
|
|
const f = fixture();
|
|
f.attempts.startDispatch.mockResolvedValueOnce(false);
|
|
f.attempts.getReceipt.mockResolvedValueOnce(receipt);
|
|
expect(await f.coordinator.execute(input)).toEqual({ status: 'receipt_verified', receipt });
|
|
expect(f.doc0.upload).not.toHaveBeenCalled();
|
|
expect(f.doc0.inspect).not.toHaveBeenCalled();
|
|
});
|
|
it('keeps a journaled completion distinct from a verified receipt', async () => {
|
|
const f = fixture();
|
|
f.attempts.startDispatch.mockResolvedValueOnce(false);
|
|
f.doc0.inspect.mockResolvedValueOnce({
|
|
status: 'completed_recorded',
|
|
documentId: 'doc',
|
|
versionId: 'version',
|
|
});
|
|
expect(await f.coordinator.execute(input)).toEqual({
|
|
status: 'outcome_unknown',
|
|
observation: { status: 'completed_recorded', documentId: 'doc', versionId: 'version' },
|
|
});
|
|
expect(f.doc0.upload).not.toHaveBeenCalled();
|
|
});
|
|
it('keeps a possibly committed upload unknown even when inspection is unavailable', async () => {
|
|
const f = fixture();
|
|
f.doc0.upload.mockRejectedValueOnce(new Doc0UploadOutcomeUnknownError());
|
|
f.doc0.inspect.mockRejectedValueOnce(new Error('unavailable'));
|
|
expect(await f.coordinator.execute(input)).toEqual({ status: 'outcome_unknown' });
|
|
expect(f.doc0.upload).toHaveBeenCalledOnce();
|
|
expect(f.doc0.inspect).toHaveBeenCalledOnce();
|
|
});
|
|
it('snapshots bytes and metadata before asynchronous authorization', async () => {
|
|
const f = fixture();
|
|
const mutable = { ...input, bytes: new Uint8Array(input.bytes) };
|
|
f.authorize.mockImplementationOnce(async () => {
|
|
mutable.bytes.fill(0);
|
|
mutable.filename = 'changed.txt';
|
|
return true;
|
|
});
|
|
await f.coordinator.execute(mutable);
|
|
expect(f.doc0.upload.mock.calls[0][0].filename).toBe(input.filename);
|
|
expect(new TextDecoder().decode(f.doc0.upload.mock.calls[0][0].bytes)).toBe('original');
|
|
});
|
|
});
|