Files
0pt/tests/document-workbench/doc0-source-client.test.ts
admin b3239487b7
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 10s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (push) Successful in 13s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Successful in 26s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 19s
CI (SIAX Cloud) / quality (push) Successful in 1m9s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m26s
CI (SIAX Cloud) / integration (pull_request) Successful in 2m33s
feat(document-workbench): wire customer upload, structuring, credits and readiness
- POST /api/0pt/documents/upload: real multipart bytes into DOC0 through the
  durable attempt journal and the existing coordinator; verified tenant/actor
  only, project authorization fail-closed.
- POST /api/0pt/documents/structure: DOC0 segments -> INF0 -> evidence-bound
  validation -> optional JSON/CSV export, with credit reserve/settle.
- Credits: balance, server-owned price book, Stripe Checkout and idempotent
  webhook reconciliation via a Stripe re-read of session + payment intent.
- /portal/dokument and /portal/krediter: SIAX-styled workspace.
- @siax/access: universal, framework-neutral OpenFGA project relationship
  primitive reusable by DOC0/N0D/other products.
- Readiness: real Redis PING probe instead of presence-only; queue is now a
  live dependency when configured.
- Dockerfile: copy every workspace manifest so --frozen-lockfile works.
- Format/build hygiene: .prettierignore, .worktrees ignored, valid manifest YAML.
2026-11-02 14:46:10 +01:00

70 lines
4.6 KiB
TypeScript

import { createHash } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { Doc0SourceClient, Doc0SourceUnavailableError } from '../../src/document-workbench/doc0-source-client';
const hash = (value: string) => createHash('sha256').update(value).digest('hex');
const receipt = { documentId: 'doc-a', versionId: 'version-a', checksumSha256: 'a'.repeat(64), pipelineStage: 'parsed' };
const input = { tenantId: 'tenant-a', projectId: 'project-a', actorId: 'user-a', requestId: 'request-a', receipt };
const text = 'Acme 😀';
const metadata = { tenantId: input.tenantId, documentId: receipt.documentId, versionId: receipt.versionId,
documentTextId: 'text-a', parserRunId: 'run-a', segmentsTruncated: false, segmentCount: 1, charCount: text.length, textSha256: hash(text) };
const segment = { tenantId: input.tenantId, documentId: receipt.documentId, versionId: receipt.versionId,
documentTextId: 'text-a', parserRunId: 'run-a', segmentId: 'segment-a', sequence: 0, text, charStart: 0,
charEnd: text.length, segmentSha256: hash(text), sourceVersionSha256: receipt.checksumSha256 };
const payload = { offset: 0, texts: [metadata], segments: [segment] };
describe('DOC0 extracted source client', () => {
it('reads scoped, checksum-bound segment text without caching or redirects', async () => {
const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
expect(String(url)).toContain('/v1/doc0/documents/doc-a/versions/version-a/segments?limit=100&offset=0');
expect(new Headers(init?.headers).get('x-tenant-id')).toBe('tenant-a');
expect(new Headers(init?.headers).get('x-actor-id')).toBe('user-a');
expect(init?.redirect).toBe('error');
expect(init?.cache).toBe('no-store');
return Response.json(payload);
});
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl });
expect(await client.read(input)).toEqual({ sources: [{ id: 'segment-a', text }], reportedTextSha256: hash(text) });
});
it('rejects incomplete, forged, cross-tenant or inconsistent text instead of passing it to a model', async () => {
const bad = [
{ ...payload, texts: [{ ...metadata, segmentsTruncated: true }] },
{ ...payload, texts: [{ ...metadata, segmentCount: 2 }] },
{ ...payload, texts: [{ ...metadata, tenantId: 'tenant-b' }] },
{ ...payload, texts: [metadata, metadata] },
{ ...payload, segments: [{ ...segment, sourceVersionSha256: 'b'.repeat(64) }] },
{ ...payload, segments: [{ ...segment, text: 'fabricated' }] },
{ ...payload, segments: [{ ...segment, tenantId: 'tenant-b' }] },
{ ...payload, segments: [{ ...segment, sequence: 1 }] },
{ ...payload, segments: [{ ...segment, charEnd: 5 }] },
{ ...payload, offset: 1 },
];
for (const candidate of bad) {
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl: async () => Response.json(candidate) });
await expect(client.read(input)).rejects.toBeInstanceOf(Doc0SourceUnavailableError);
}
});
it('rejects empty, oversized, unavailable and unparsed sources', async () => {
const fetchImpl = vi.fn(async () => Response.json(payload));
const client = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl });
await expect(client.read({ ...input, receipt: { ...receipt, pipelineStage: 'parse_failed' } })).rejects.toBeInstanceOf(Doc0SourceUnavailableError);
expect(fetchImpl).not.toHaveBeenCalled();
for (const response of [Response.json({ ...payload, texts: [] }), new Response('x'.repeat(1024 * 1024 + 1)), new Response('', { status: 503 })]) {
const bad = new Doc0SourceClient({ baseUrl: 'https://doc0.test', serviceToken: 'test', fetchImpl: async () => response });
await expect(bad.read(input)).rejects.toBeInstanceOf(Doc0SourceUnavailableError);
}
});
it('pins the original scope and validated credential during an in-flight read', async () => {
const mutable = structuredClone(input);
const config = { baseUrl: 'https://doc0.test', serviceToken: 'original', fetchImpl: async (_url: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get('authorization')).toBe('Bearer original');
mutable.tenantId = 'tenant-b';
mutable.receipt.checksumSha256 = 'b'.repeat(64);
return Response.json(payload);
} };
const client = new Doc0SourceClient(config);
config.serviceToken = 'changed';
expect(await client.read(mutable)).toMatchObject({ sources: [{ id: 'segment-a', text }] });
});
});