From 899ef989d473a884c484ac8681acbd95fab050b2 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 1 Aug 2026 11:51:53 +0700 Subject: [PATCH 1/3] fix(server): production runtime integrity, concurrency, and adapter selection - Fix docker-entrypoint.sh to honor command args so worker starts the worker, not the API; correct script paths to match tsdown directory-preserving output - Gate reset_demo_state crontab, reset service, and admin route behind DEMO_MODE=public with triple defense-in-depth - Fix DOCUMENT_STORAGE_PATH -> DOCUMENT_STORAGE_ROOT in both Compose files - Make quota checkAndIncrement atomic via INSERT ... ON CONFLICT DO UPDATE ... WHERE extraction_count < limit RETURNING - Fix posting PO double-decrement under concurrent delivery via early return - Switch IP hashing from createHash to createHmac - Extract shared inspectDocument for local and R2 document validation parity - Add createDocumentStore and createProcessingAdapters factories selecting R2/OpenRouter adapters from environment configuration - Fix orphan cleanup query referencing nonexistent source_documents column - Fix extraction rejection of text-only PDF pages - Re-enable comma-separated CORS origins with exact matching - Allow AP confirmation from returned_for_correction matching lifecycle table --- apps/server/Dockerfile.prod | 1 + apps/server/docker-entrypoint.sh | 21 +++-- apps/server/scripts/seed-e2e.ts | 15 ++- .../documents/document-store-factory.ts | 10 ++ .../adapters/documents/inspect-document.ts | 90 ++++++++++++++++++ .../documents/local-document-store.ts | 93 +------------------ .../documents/r2-document-store.test.ts | 41 ++++++-- .../adapters/documents/r2-document-store.ts | 44 ++------- .../openrouter-extraction-adapter.ts | 12 ++- .../processing-adapters-factory.test.ts | 32 +++++++ .../adapters/processing-adapters-factory.ts | 41 ++++++++ apps/server/src/app.ts | 12 ++- .../domain/invoice-cases/invoice-case.test.ts | 23 +++++ .../src/domain/invoice-cases/invoice-case.ts | 8 +- apps/server/src/jobs/worker.test.ts | 27 ++++++ apps/server/src/jobs/worker.ts | 12 ++- apps/server/src/lib/create-app.ts | 3 +- apps/server/src/lib/env-config.ts | 12 +-- apps/server/src/middlewares/origin.ts | 4 +- .../administration/administration.handlers.ts | 15 ++- .../administration/administration.index.ts | 3 +- .../administration/administration.routes.ts | 6 ++ .../administration/administration.test.ts | 16 +++- apps/server/src/routes/auth/auth.test.ts | 4 + .../src/services/invoice-case-posting.test.ts | 19 ++++ .../src/services/invoice-case-posting.ts | 1 + .../quota-service.integration.test.ts | 81 ++++++++++++++++ apps/server/src/services/quota-service.ts | 65 +++++-------- .../server/src/services/reset-service.test.ts | 22 ++++- apps/server/src/services/reset-service.ts | 10 +- apps/server/src/worker.ts | 16 ++-- docker-compose.prod.yml | 8 +- docker-compose.yml | 6 +- 33 files changed, 546 insertions(+), 227 deletions(-) create mode 100644 apps/server/src/adapters/documents/document-store-factory.ts create mode 100644 apps/server/src/adapters/documents/inspect-document.ts create mode 100644 apps/server/src/adapters/processing-adapters-factory.test.ts create mode 100644 apps/server/src/adapters/processing-adapters-factory.ts create mode 100644 apps/server/src/jobs/worker.test.ts create mode 100644 apps/server/src/services/quota-service.integration.test.ts diff --git a/apps/server/Dockerfile.prod b/apps/server/Dockerfile.prod index 07d5ae3..5faf513 100644 --- a/apps/server/Dockerfile.prod +++ b/apps/server/Dockerfile.prod @@ -34,3 +34,4 @@ RUN chmod +x docker-entrypoint.sh ENV NODE_ENV=production ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["node", "dist/src/index.mjs"] diff --git a/apps/server/docker-entrypoint.sh b/apps/server/docker-entrypoint.sh index 3e57076..baab59c 100644 --- a/apps/server/docker-entrypoint.sh +++ b/apps/server/docker-entrypoint.sh @@ -1,12 +1,17 @@ #!/bin/sh set -e -echo "Running database migrations..." -node dist/scripts/migrate.mjs -echo "Installing Graphile Worker schema..." -node dist/scripts/migrate-queue.mjs -echo "Bootstrapping Mastra schema..." -node dist/scripts/bootstrap-mastra.mjs +# Only run migrations and schema bootstrap for the API server. +# The worker starts via compose `command` and depends on a healthy API, +# so migrations have already been applied by the time the worker starts. +if [ "${1}" = "node" ] && [ "${2}" = "dist/src/index.mjs" ]; then + echo "Running database migrations..." + node dist/scripts/migrate.mjs + echo "Installing Graphile Worker schema..." + node dist/scripts/migrate-queue.mjs + echo "Bootstrapping Mastra schema..." + node dist/scripts/bootstrap-mastra.mjs +fi -echo "Starting server..." -exec node dist/src/index.mjs +echo "Starting: $@" +exec "$@" diff --git a/apps/server/scripts/seed-e2e.ts b/apps/server/scripts/seed-e2e.ts index e980758..7fddeac 100644 --- a/apps/server/scripts/seed-e2e.ts +++ b/apps/server/scripts/seed-e2e.ts @@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'; import { db, pool } from '../src/db/index.js'; import { + apConfirmationsTable, caseEventsTable, fieldEvidenceTable, invoiceCasesTable, @@ -89,7 +90,8 @@ const pdfBytes = Buffer.from( 'base64' ); -const reset = createResetService(db); +// This script is guarded by E2E_SEED and runs only against the test stack. +const reset = createResetService(db, 'public'); await reset.resetDemoState(); await mkdir(resolve(env.DOCUMENT_STORAGE_ROOT, 'documents'), { recursive: true }); @@ -152,6 +154,17 @@ for (const [index, scenario] of scenarios.entries()) { unitPriceMinor: 150_000, lineAmountMinor: 150_000 }); + + if (scenario.state === 'awaiting_finance_approval') { + await db.insert(apConfirmationsTable).values({ + invoiceCaseId: scenario.id, + draftRevisionId: revisionId, + actorId: apId, + caseVersion: 1, + createdAt: now + }); + } + await db.insert(caseEventsTable).values({ id: eventId, invoiceCaseId: scenario.id, diff --git a/apps/server/src/adapters/documents/document-store-factory.ts b/apps/server/src/adapters/documents/document-store-factory.ts new file mode 100644 index 0000000..66d57ea --- /dev/null +++ b/apps/server/src/adapters/documents/document-store-factory.ts @@ -0,0 +1,10 @@ +import type { DocumentStore } from './document-port.js'; +import { LocalDocumentStore } from './local-document-store.js'; +import { R2DocumentStore, createR2Config } from './r2-document-store.js'; + +export function createDocumentStore(env: NodeJS.ProcessEnv = process.env): DocumentStore { + if (env.R2_ACCOUNT_ID && env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.R2_BUCKET_NAME) { + return new R2DocumentStore(createR2Config(env)); + } + return new LocalDocumentStore(); +} diff --git a/apps/server/src/adapters/documents/inspect-document.ts b/apps/server/src/adapters/documents/inspect-document.ts new file mode 100644 index 0000000..72291e3 --- /dev/null +++ b/apps/server/src/adapters/documents/inspect-document.ts @@ -0,0 +1,90 @@ +import { fileTypeFromBuffer } from 'file-type'; +import { PDFDocument } from 'pdf-lib'; + +import { DocumentValidationError } from './document-port.js'; +import type { SupportedDocumentType } from './document-port.js'; + +const PDF_ACTIVE_CONTENT_PATTERN = + /\/(?:JS|JavaScript|AA|OpenAction|Launch|EmbeddedFile|RichMedia|SubmitForm|GoToR|AcroForm|XFA)\b/i; +const PNG_END_MARKER = Buffer.from([0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]); + +export interface DocumentInspection { + contentType: SupportedDocumentType; + pageCount: number; +} + +export async function inspectDocument( + bytes: Buffer, + maxPages: number +): Promise { + const detected = await fileTypeFromBuffer(bytes); + if (!detected || !isSupportedType(detected.mime)) { + throw new DocumentValidationError('unsupported_type', 'Document type is not supported'); + } + + if (detected.mime === 'application/pdf') { + return inspectPdf(bytes, maxPages); + } + + if (detected.mime === 'image/png') { + if ( + bytes.length < PNG_END_MARKER.length || + !bytes.subarray(-PNG_END_MARKER.length).equals(PNG_END_MARKER) + ) { + throw new DocumentValidationError('invalid_document', 'PNG document is truncated'); + } + } + + if (detected.mime === 'image/jpeg') { + if ( + bytes.length < 4 || + bytes[0] !== 0xff || + bytes[1] !== 0xd8 || + bytes.at(-2) !== 0xff || + bytes.at(-1) !== 0xd9 + ) { + throw new DocumentValidationError('invalid_document', 'JPEG document is truncated'); + } + } + + return { contentType: detected.mime, pageCount: 1 }; +} + +async function inspectPdf( + bytes: Buffer, + maxPages: number +): Promise<{ contentType: 'application/pdf'; pageCount: number }> { + const source = bytes.toString('latin1'); + if (/\/Encrypt\b/i.test(source)) { + throw new DocumentValidationError('encrypted_pdf', 'Encrypted PDF documents are not supported'); + } + if (PDF_ACTIVE_CONTENT_PATTERN.test(source)) { + throw new DocumentValidationError('active_content', 'PDF active content is not supported'); + } + + try { + const document = await PDFDocument.load(bytes); + const pageCount = document.getPageCount(); + if (pageCount < 1) { + throw new DocumentValidationError('invalid_document', 'PDF document has no pages'); + } + if (pageCount > maxPages) { + throw new DocumentValidationError('too_many_pages', 'Document exceeds maximum page count'); + } + + return { contentType: 'application/pdf', pageCount }; + } catch (error) { + if (error instanceof DocumentValidationError) throw error; + if (error instanceof Error && /encrypt/i.test(error.message)) { + throw new DocumentValidationError( + 'encrypted_pdf', + 'Encrypted PDF documents are not supported' + ); + } + throw new DocumentValidationError('invalid_document', 'PDF document is invalid or truncated'); + } +} + +function isSupportedType(mime: string): mime is SupportedDocumentType { + return mime === 'application/pdf' || mime === 'image/jpeg' || mime === 'image/png'; +} diff --git a/apps/server/src/adapters/documents/local-document-store.ts b/apps/server/src/adapters/documents/local-document-store.ts index ea834b8..67ad9f4 100644 --- a/apps/server/src/adapters/documents/local-document-store.ts +++ b/apps/server/src/adapters/documents/local-document-store.ts @@ -5,25 +5,15 @@ import { basename, dirname, relative, resolve } from 'node:path'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { fileTypeFromBuffer } from 'file-type'; -import { PDFDocument } from 'pdf-lib'; - import { env } from '@/lib/env-config.js'; import { DocumentValidationError } from './document-port.js'; -import type { - DocumentStore, - DocumentUpload, - StoredDocument, - SupportedDocumentType -} from './document-port.js'; +import type { DocumentStore, DocumentUpload, StoredDocument } from './document-port.js'; +import { inspectDocument } from './inspect-document.js'; const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_PAGES = 5; const OBJECT_KEY_PATTERN = /^documents\/[0-9a-f-]{36}$/; -const PDF_ACTIVE_CONTENT_PATTERN = - /\/(?:JS|JavaScript|AA|OpenAction|Launch|EmbeddedFile|RichMedia|SubmitForm|GoToR|AcroForm|XFA)\b/i; -const PNG_END_MARKER = Buffer.from([0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130]); export class LocalDocumentStore implements DocumentStore { private readonly rootDir: string; @@ -67,7 +57,7 @@ export class LocalDocumentStore implements DocumentStore { ); const bytes = await readFile(temporaryPath); - const inspection = await this.inspect(bytes); + const inspection = await inspectDocument(bytes, this.maxPages); await rename(temporaryPath, finalPath); promoted = true; @@ -108,83 +98,6 @@ export class LocalDocumentStore implements DocumentStore { return objectPath; } - - private async inspect( - bytes: Buffer - ): Promise<{ contentType: SupportedDocumentType; pageCount: number }> { - const detected = await fileTypeFromBuffer(bytes); - if (!detected || !isSupportedType(detected.mime)) { - throw new DocumentValidationError('unsupported_type', 'Document type is not supported'); - } - - if (detected.mime === 'application/pdf') { - return this.inspectPdf(bytes); - } - - if (detected.mime === 'image/png') { - if ( - bytes.length < PNG_END_MARKER.length || - !bytes.subarray(-PNG_END_MARKER.length).equals(PNG_END_MARKER) - ) { - throw new DocumentValidationError('invalid_document', 'PNG document is truncated'); - } - } - - if (detected.mime === 'image/jpeg') { - if ( - bytes.length < 4 || - bytes[0] !== 0xff || - bytes[1] !== 0xd8 || - bytes.at(-2) !== 0xff || - bytes.at(-1) !== 0xd9 - ) { - throw new DocumentValidationError('invalid_document', 'JPEG document is truncated'); - } - } - - return { contentType: detected.mime, pageCount: 1 }; - } - - private async inspectPdf( - bytes: Buffer - ): Promise<{ contentType: 'application/pdf'; pageCount: number }> { - const source = bytes.toString('latin1'); - if (/\/Encrypt\b/i.test(source)) { - throw new DocumentValidationError( - 'encrypted_pdf', - 'Encrypted PDF documents are not supported' - ); - } - if (PDF_ACTIVE_CONTENT_PATTERN.test(source)) { - throw new DocumentValidationError('active_content', 'PDF active content is not supported'); - } - - try { - const document = await PDFDocument.load(bytes); - const pageCount = document.getPageCount(); - if (pageCount < 1) { - throw new DocumentValidationError('invalid_document', 'PDF document has no pages'); - } - if (pageCount > this.maxPages) { - throw new DocumentValidationError('too_many_pages', 'Document exceeds maximum page count'); - } - - return { contentType: 'application/pdf', pageCount }; - } catch (error) { - if (error instanceof DocumentValidationError) throw error; - if (error instanceof Error && /encrypt/i.test(error.message)) { - throw new DocumentValidationError( - 'encrypted_pdf', - 'Encrypted PDF documents are not supported' - ); - } - throw new DocumentValidationError('invalid_document', 'PDF document is invalid or truncated'); - } - } -} - -function isSupportedType(mime: string): mime is SupportedDocumentType { - return mime === 'application/pdf' || mime === 'image/jpeg' || mime === 'image/png'; } function normalizeFilename(filename: string): string { diff --git a/apps/server/src/adapters/documents/r2-document-store.test.ts b/apps/server/src/adapters/documents/r2-document-store.test.ts index 770fc54..bb8eb1d 100644 --- a/apps/server/src/adapters/documents/r2-document-store.test.ts +++ b/apps/server/src/adapters/documents/r2-document-store.test.ts @@ -31,17 +31,19 @@ vi.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: vi.fn().mockResolvedValue('https://r2.example.com/signed-url') })); -async function makePdfBytes(): Promise { +async function makePdfBytes(pageCount = 1): Promise { const doc = await PDFDocument.create(); const font = await doc.embedFont(StandardFonts.Helvetica); - const page = doc.addPage([612, 792]); - page.drawText('Test invoice content with sufficient characters.', { - x: 50, - y: 700, - size: 12, - font, - color: rgb(0, 0, 0) - }); + for (let index = 0; index < pageCount; index += 1) { + const page = doc.addPage([612, 792]); + page.drawText('Test invoice content with sufficient characters.', { + x: 50, + y: 700, + size: 12, + font, + color: rgb(0, 0, 0) + }); + } return Buffer.from(await doc.save()); } @@ -119,6 +121,27 @@ describe('R2DocumentStore', () => { await expect(store.put(makeUpload(largeBuffer))).rejects.toThrow('exceeds'); }); + it('rejects encrypted PDFs', async () => { + const buffer = Buffer.from('%PDF-1.7\n/Encrypt 2 0 R'); + await expect(store.put(makeUpload(buffer))).rejects.toMatchObject({ code: 'encrypted_pdf' }); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('rejects PDF active content', async () => { + const buffer = Buffer.concat([await makePdfBytes(), Buffer.from('/JavaScript')]); + await expect(store.put(makeUpload(buffer))).rejects.toMatchObject({ code: 'active_content' }); + expect(mockSend).not.toHaveBeenCalled(); + }); + + it('rejects PDFs over the configured page limit before storing', async () => { + await expect( + store.put(makeUpload(await makePdfBytes(config.maxPages + 1))) + ).rejects.toMatchObject({ + code: 'too_many_pages' + }); + expect(mockSend).not.toHaveBeenCalled(); + }); + it('never exposes credentials in returned metadata', async () => { const buffer = await makePdfBytes(); mockSend.mockResolvedValue({}); diff --git a/apps/server/src/adapters/documents/r2-document-store.ts b/apps/server/src/adapters/documents/r2-document-store.ts index 6659e72..bc26276 100644 --- a/apps/server/src/adapters/documents/r2-document-store.ts +++ b/apps/server/src/adapters/documents/r2-document-store.ts @@ -8,16 +8,10 @@ import { S3Client } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; -import { fileTypeFromBuffer } from 'file-type'; -import { PDFDocument } from 'pdf-lib'; - -import type { - DocumentStore, - DocumentUpload, - StoredDocument, - SupportedDocumentType -} from './document-port.js'; + import { DocumentValidationError } from './document-port.js'; +import type { DocumentStore, DocumentUpload, StoredDocument } from './document-port.js'; +import { inspectDocument } from './inspect-document.js'; export interface R2Config { accountId: string; @@ -66,20 +60,19 @@ export class R2DocumentStore implements DocumentStore { const contentHash = hash.digest('hex'); const objectKey = contentHash; - const detected = await detectContentType(buffer); - const pageCount = await countPdfPages(buffer, detected); + const inspection = await inspectDocument(buffer, this.config.maxPages); await this.client.send( new PutObjectCommand({ Bucket: this.config.bucketName, Key: objectKey, Body: buffer, - ContentType: detected, + ContentType: inspection.contentType, Metadata: { originalFilename: input.filename, contentHash, byteLength: String(totalBytes), - pageCount: String(pageCount) + pageCount: String(inspection.pageCount) } }) ); @@ -87,10 +80,10 @@ export class R2DocumentStore implements DocumentStore { return { objectKey, originalFilename: input.filename, - contentType: detected, + contentType: inspection.contentType, contentHash, byteLength: totalBytes, - pageCount + pageCount: inspection.pageCount }; } @@ -131,27 +124,6 @@ export class R2DocumentStore implements DocumentStore { } } -async function detectContentType(buffer: Buffer): Promise { - const detected = await fileTypeFromBuffer(buffer); - if (!detected) { - throw new DocumentValidationError('unsupported_type', 'Could not detect document type'); - } - if (detected.mime === 'application/pdf') return 'application/pdf'; - if (detected.mime === 'image/jpeg') return 'image/jpeg'; - if (detected.mime === 'image/png') return 'image/png'; - throw new DocumentValidationError('unsupported_type', `Unsupported type: ${detected.mime}`); -} - -async function countPdfPages(buffer: Buffer, contentType: string): Promise { - if (contentType !== 'application/pdf') return 1; - try { - const doc = await PDFDocument.load(buffer, { ignoreEncryption: true }); - return doc.getPageCount(); - } catch { - throw new DocumentValidationError('invalid_document', 'Failed to parse PDF'); - } -} - export function createR2Config(env: NodeJS.ProcessEnv = process.env): R2Config { const accountId = env.R2_ACCOUNT_ID ?? ''; const accessKeyId = env.R2_ACCESS_KEY_ID ?? ''; diff --git a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts index e42d01b..b4e7a9b 100644 --- a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts +++ b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts @@ -45,11 +45,12 @@ export class OpenRouterExtractionAdapter { } const imagePages = input.pages.filter((p) => p.image !== null); - if (imagePages.length === 0) { - throw new Error('At least one image page is required for extraction'); + const textOnlyPages = input.pages.filter((p) => p.image === null && p.text); + if (imagePages.length === 0 && textOnlyPages.length === 0) { + throw new Error('At least one page with image or text content is required for extraction'); } - const userContent = buildUserContent(imagePages); + const userContent = buildUserContent(input.pages); const startTime = Date.now(); try { @@ -154,6 +155,11 @@ function buildUserContent(pages: readonly PreparedPage[]): Array { + it('returns local store and deterministic extractor when no R2 or OpenRouter config', () => { + const adapters = createProcessingAdapters({}); + expect(adapters.documentStore).toBeInstanceOf(LocalDocumentStore); + expect(adapters.realExtractor).toBeUndefined(); + expect(adapters.preparationService).toBeUndefined(); + }); + + it('returns R2 store when R2 config is present', () => { + const adapters = createProcessingAdapters({ + R2_ACCOUNT_ID: 'test-account', + R2_ACCESS_KEY_ID: 'test-key', + R2_SECRET_ACCESS_KEY: 'test-secret', + R2_BUCKET_NAME: 'test-bucket' + }); + expect(adapters.documentStore).toBeInstanceOf(R2DocumentStore); + }); + + it('returns OpenRouter adapter and preparation service when API key is present', () => { + const adapters = createProcessingAdapters({ + OPENROUTER_API_KEY: 'sk-test-key' + }); + expect(adapters.realExtractor).toBeDefined(); + expect(adapters.preparationService).toBeDefined(); + }); +}); diff --git a/apps/server/src/adapters/processing-adapters-factory.ts b/apps/server/src/adapters/processing-adapters-factory.ts new file mode 100644 index 0000000..51eb06d --- /dev/null +++ b/apps/server/src/adapters/processing-adapters-factory.ts @@ -0,0 +1,41 @@ +import type { DocumentStore } from './documents/document-port.js'; +import { createDocumentPreparationService } from './documents/document-preparation-service.js'; +import type { DocumentPreparationService } from './documents/document-preparation-service.js'; +import { createDocumentStore } from './documents/document-store-factory.js'; +import { createDeterministicInvoiceExtractor } from './extraction/deterministic-extractor.js'; +import type { InvoiceExtractionPort } from './extraction/extraction-port.js'; +import { + OpenRouterExtractionAdapter, + createOpenRouterConfig +} from './extraction/openrouter-extraction-adapter.js'; + +export interface ProcessingAdapters { + documentStore: DocumentStore; + extractor: InvoiceExtractionPort; + realExtractor?: OpenRouterExtractionAdapter; + preparationService?: DocumentPreparationService; +} + +export function createProcessingAdapters(env: NodeJS.ProcessEnv = process.env): ProcessingAdapters { + const documentStore = createDocumentStore(env); + + if (env.OPENROUTER_API_KEY) { + const config = createOpenRouterConfig(env); + const realExtractor = new OpenRouterExtractionAdapter(config); + const preparationService = createDocumentPreparationService({ + store: documentStore, + timeoutMs: Number(env.DOCUMENT_PREPARATION_TIMEOUT_MS ?? 30_000) + }); + return { + documentStore, + extractor: createDeterministicInvoiceExtractor(), + realExtractor, + preparationService + }; + } + + return { + documentStore, + extractor: createDeterministicInvoiceExtractor() + }; +} diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6ccccd6..9ea4499 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -30,15 +30,15 @@ import { createInvoiceCaseReadService } from './services/invoice-case-read.js'; import { createQuotaService } from './services/quota-service.js'; import { createResetService } from './services/reset-service.js'; -import { LocalDocumentStore } from './adapters/documents/local-document-store.js'; +import { createDocumentStore } from './adapters/documents/document-store-factory.js'; import { enqueueInvoiceCasePosting, enqueueInvoiceCaseProcessing, enqueueInvoiceCaseWorkflowResume } from './jobs/queue.js'; -const documentStore = new LocalDocumentStore(); -const resetService = createResetService(db); +const documentStore = createDocumentStore(); +const resetService = createResetService(db, env.DEMO_MODE); const administrationService = createAdministrationService(db); const quotaService = createQuotaService(db, withTransaction); @@ -119,7 +119,11 @@ const router = app .route('/', createSourceDocumentRouter({ read: invoiceCaseRead, documents: documentStore })) .route( '/', - createAdministrationRouter({ reset: resetService, administration: administrationService }) + createAdministrationRouter({ + reset: resetService, + administration: administrationService, + demoMode: env.DEMO_MODE + }) ); export type AppType = typeof router; diff --git a/apps/server/src/domain/invoice-cases/invoice-case.test.ts b/apps/server/src/domain/invoice-cases/invoice-case.test.ts index 0fd6798..6e41c17 100644 --- a/apps/server/src/domain/invoice-cases/invoice-case.test.ts +++ b/apps/server/src/domain/invoice-cases/invoice-case.test.ts @@ -191,6 +191,29 @@ describe('Invoice Case authority', () => { ); }); + it('allows AP to reconfirm a returned draft for Finance review', () => { + const approvalRequired = reviewCase([ + { + id: 'finding-1', + code: 'high_value', + severity: 'approval_required', + message: 'Finance approval required', + resolved: false + } + ]); + const awaitingFinance = confirmAp(approvalRequired, { expectedVersion: 2, actor: owner }); + const returned = financeDecision(awaitingFinance, { + expectedVersion: awaitingFinance.version, + actor: finance, + decision: 'return', + explanation: 'Correct the purchase order reference.' + }); + + expect(confirmAp(returned, { expectedVersion: returned.version, actor: owner }).state).toBe( + 'awaiting_finance_approval' + ); + }); + it('requires current AP confirmation before posting', () => { const awaitingReview = reviewCase(); diff --git a/apps/server/src/domain/invoice-cases/invoice-case.ts b/apps/server/src/domain/invoice-cases/invoice-case.ts index 52fd12a..4bab85f 100644 --- a/apps/server/src/domain/invoice-cases/invoice-case.ts +++ b/apps/server/src/domain/invoice-cases/invoice-case.ts @@ -286,7 +286,13 @@ export function confirmAp( assertMutable(invoiceCase); assertVersion(invoiceCase, input.expectedVersion); assertApOwner(invoiceCase, input.actor, 'invoice.confirm'); - if (invoiceCase.state !== 'awaiting_ap_review' || !invoiceCase.currentDraftRevisionId) { + if ( + invoiceCase.state !== 'awaiting_ap_review' && + invoiceCase.state !== 'returned_for_correction' + ) { + throw new DomainRuleError('invalid_transition', 'Case is not awaiting AP confirmation'); + } + if (!invoiceCase.currentDraftRevisionId) { throw new DomainRuleError('invalid_transition', 'Case is not awaiting AP confirmation'); } diff --git a/apps/server/src/jobs/worker.test.ts b/apps/server/src/jobs/worker.test.ts new file mode 100644 index 0000000..488f033 --- /dev/null +++ b/apps/server/src/jobs/worker.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCrontab, createWorkerOptions } from './worker.js'; + +describe('worker crontab', () => { + it('excludes reset_demo_state in private mode', () => { + const crontab = buildCrontab('private'); + expect(crontab).not.toContain('reset_demo_state'); + expect(crontab).toContain('cleanup_orphaned_documents'); + }); + + it('includes reset_demo_state in public mode', () => { + const crontab = buildCrontab('public'); + expect(crontab).toContain('reset_demo_state'); + expect(crontab).toContain('cleanup_orphaned_documents'); + }); + + it('excludes reset_demo_state for unknown mode', () => { + const crontab = buildCrontab('staging'); + expect(crontab).not.toContain('reset_demo_state'); + }); + + it('createWorkerOptions always includes cleanup in default env', () => { + const options = createWorkerOptions(); + expect(options.crontab).toContain('cleanup_orphaned_documents'); + }); +}); diff --git a/apps/server/src/jobs/worker.ts b/apps/server/src/jobs/worker.ts index 420879c..5023e0a 100644 --- a/apps/server/src/jobs/worker.ts +++ b/apps/server/src/jobs/worker.ts @@ -54,7 +54,15 @@ export function createInvoiceCaseProcessingTaskList( return taskList; } -function createWorkerOptions(options: { taskList?: TaskList }) { +export function buildCrontab(demoMode: string): string { + const maintenanceJobs = '30 * * * * cleanup_orphaned_documents'; + if (demoMode === 'public') { + return `0 0 * * * reset_demo_state\n${maintenanceJobs}`; + } + return maintenanceJobs; +} + +export function createWorkerOptions(options: { taskList?: TaskList } = {}) { return { taskList: options.taskList ?? { process_invoice_case: async () => { @@ -70,6 +78,6 @@ function createWorkerOptions(options: { taskList?: TaskList }) { schema: 'graphile_worker' } }, - crontab: '0 0 * * * reset_demo_state\n30 * * * * cleanup_orphaned_documents' + crontab: buildCrontab(env.DEMO_MODE) }; } diff --git a/apps/server/src/lib/create-app.ts b/apps/server/src/lib/create-app.ts index 8933ac4..3bbd2d5 100644 --- a/apps/server/src/lib/create-app.ts +++ b/apps/server/src/lib/create-app.ts @@ -36,7 +36,8 @@ export default function createApp() { const corsOptions = { credentials: true, - origin: (origin: string) => (env.CORS_ORIGINS.includes(origin) ? origin : '') + origin: (origin: string) => + env.CORS_ORIGINS.includes('*') || env.CORS_ORIGINS.includes(origin) ? origin : '' }; const defaultBodyLimit = bodyLimit({ diff --git a/apps/server/src/lib/env-config.ts b/apps/server/src/lib/env-config.ts index 4adf46b..e269a24 100644 --- a/apps/server/src/lib/env-config.ts +++ b/apps/server/src/lib/env-config.ts @@ -14,13 +14,11 @@ const envSchema = z NODE_ENV: z.enum(['development', 'production', 'test']).default('production'), - CORS_ORIGINS: z.union([z.url(), z.literal('*')]).default('http://localhost:3001'), - - // CORS_ORIGINS: z - // .string() - // .default('http://localhost:3001') - // .transform((value) => value.split(',').map((origin) => origin.trim())) - // .pipe(z.array(z.url()).min(1)), + CORS_ORIGINS: z + .string() + .default('http://localhost:3001') + .transform((value) => value.split(',').map((origin) => origin.trim())) + .pipe(z.array(z.union([z.url(), z.literal('*')])).min(1)), TRUST_PROXY: z .enum(['true', 'false']) diff --git a/apps/server/src/middlewares/origin.ts b/apps/server/src/middlewares/origin.ts index c453d05..2d293cf 100644 --- a/apps/server/src/middlewares/origin.ts +++ b/apps/server/src/middlewares/origin.ts @@ -15,7 +15,9 @@ export const validateRequestOrigin = createMiddleware(async (c, nex if (!isLogin && !hasSessionCookie) return next(); const origin = c.req.header('origin'); - if (origin && env.CORS_ORIGINS.includes(origin)) return next(); + if (origin && (env.CORS_ORIGINS.includes('*') || env.CORS_ORIGINS.includes(origin))) { + return next(); + } const response = errorResponse('Origin not allowed', HttpStatus.FORBIDDEN); return c.json(response, response.status); diff --git a/apps/server/src/routes/administration/administration.handlers.ts b/apps/server/src/routes/administration/administration.handlers.ts index 038bd62..2b3214e 100644 --- a/apps/server/src/routes/administration/administration.handlers.ts +++ b/apps/server/src/routes/administration/administration.handlers.ts @@ -1,3 +1,4 @@ +import { env } from '@/lib/env-config.js'; import { HttpStatus, successResponse } from '@/lib/response.js'; import type { AppRouteHandler } from '@/lib/types.js'; @@ -13,8 +14,20 @@ import type { UpdatePolicyRoute } from './administration.routes.js'; -export function resetDemoHandler(reset: ResetService): AppRouteHandler { +export function resetDemoHandler( + reset: ResetService, + demoMode: string = env.DEMO_MODE +): AppRouteHandler { return async (c) => { + if (demoMode !== 'public') { + return c.json( + { + message: 'Demo reset is only available in public demo mode', + status: HttpStatus.FORBIDDEN + }, + HttpStatus.FORBIDDEN + ); + } const actor = c.get('actor'); const result = await reset.resetDemoState(); diff --git a/apps/server/src/routes/administration/administration.index.ts b/apps/server/src/routes/administration/administration.index.ts index 9e4b0d4..fe8343d 100644 --- a/apps/server/src/routes/administration/administration.index.ts +++ b/apps/server/src/routes/administration/administration.index.ts @@ -11,12 +11,13 @@ import * as routes from './administration.routes.js'; export function createAdministrationRouter(options: { reset: ResetService; administration: AdministrationService; + demoMode?: string; }) { const router = createRouter(); router.use('/administration/*', requireAuthentication); router.use('/administration/*', requirePermission('administration.demo.reset')); return router - .openapi(routes.resetDemo, handlers.resetDemoHandler(options.reset)) + .openapi(routes.resetDemo, handlers.resetDemoHandler(options.reset, options.demoMode)) .openapi(routes.getPolicy, handlers.getPolicyHandler(options.administration)) .openapi(routes.updatePolicy, handlers.updatePolicyHandler(options.administration)) .openapi(routes.listVendors, handlers.listVendorsHandler(options.administration)) diff --git a/apps/server/src/routes/administration/administration.routes.ts b/apps/server/src/routes/administration/administration.routes.ts index af972c3..c7ccdc5 100644 --- a/apps/server/src/routes/administration/administration.routes.ts +++ b/apps/server/src/routes/administration/administration.routes.ts @@ -36,6 +36,12 @@ export const resetDemo = createRoute({ [HttpStatus.OK]: { content: { 'application/json': { schema: successResponseSchema(resetResult) } }, description: 'Demo state reset' + }, + [HttpStatus.FORBIDDEN]: { + content: { + 'application/json': { schema: z.object({ message: z.string(), status: z.number() }) } + }, + description: 'Demo mode is not public' } } }); diff --git a/apps/server/src/routes/administration/administration.test.ts b/apps/server/src/routes/administration/administration.test.ts index a51da9f..f69f2f8 100644 --- a/apps/server/src/routes/administration/administration.test.ts +++ b/apps/server/src/routes/administration/administration.test.ts @@ -14,7 +14,7 @@ const administrator: Actor = { }; const ap: Actor = { ...administrator, role: 'ap_specialist' }; -function buildApp(actor: Actor | null) { +function buildApp(actor: Actor | null, demoMode: string = 'public') { const reset = { resetDemoState: vi.fn().mockResolvedValue({ truncated: true, @@ -31,7 +31,11 @@ function buildApp(actor: Actor | null) { getUsageSummary: vi.fn().mockResolvedValue({ extractionAttempts: 0, totalDurationMs: 0 }) }; const app = createTestApp( - createAdministrationRouter({ reset: reset as never, administration: administration as never }), + createAdministrationRouter({ + reset: reset as never, + administration: administration as never, + demoMode + }), (testApp) => { testApp.use('*', async (c, next) => { if (actor) c.set('actor', actor); @@ -67,6 +71,14 @@ describe('administration reset route', () => { expect(reset.resetDemoState).not.toHaveBeenCalled(); }); + it('rejects demo reset when demo mode is private', async () => { + const { app, reset } = buildApp(administrator, 'private'); + const response = await app.request('/administration/demo/reset', { method: 'POST' }); + + expect(response.status).toBe(403); + expect(reset.resetDemoState).not.toHaveBeenCalled(); + }); + it('reads and updates policy with expected version', async () => { const { app, administration } = buildApp(administrator); administration.getPolicy.mockResolvedValue({ diff --git a/apps/server/src/routes/auth/auth.test.ts b/apps/server/src/routes/auth/auth.test.ts index 1ce2b4d..8fd0e95 100644 --- a/apps/server/src/routes/auth/auth.test.ts +++ b/apps/server/src/routes/auth/auth.test.ts @@ -132,9 +132,13 @@ describe('auth routes', () => { const untrusted = await client.auth.login.$post(loginRequest(), { headers: { origin: 'https://attacker.example' } }); + const nearMiss = await client.auth.login.$post(loginRequest(), { + headers: { origin: 'http://localhost:300' } + }); expect(missing.status).toBe(HttpStatus.FORBIDDEN); expect(untrusted.status).toBe(HttpStatus.FORBIDDEN); + expect(nearMiss.status).toBe(HttpStatus.FORBIDDEN); }); it('returns 429 after the configured account or IP attempt limit', async () => { diff --git a/apps/server/src/services/invoice-case-posting.test.ts b/apps/server/src/services/invoice-case-posting.test.ts index e69c491..c196c18 100644 --- a/apps/server/src/services/invoice-case-posting.test.ts +++ b/apps/server/src/services/invoice-case-posting.test.ts @@ -117,4 +117,23 @@ describe('Invoice Case posting service', () => { await expect(service.post(payload)).rejects.toBeInstanceOf(InvoiceCasePostingConflictError); expect(state.accounting.post).not.toHaveBeenCalled(); }); + + it('does not decrement PO balance when case was already posted by concurrent transaction', async () => { + const state = setup(); + state.repository.findCase + .mockResolvedValueOnce({ id: caseId, state: 'posting', version: 4 }) + .mockResolvedValue({ id: caseId, state: 'posted', version: 5 }); + state.repository.advanceState.mockResolvedValue(null); + state.repository.findCurrentDraft.mockResolvedValue({ + purchaseOrderNumber: 'PO-1001', + totalMinor: 16_500 + }); + const service = createInvoiceCasePostingService(state.dependencies as never); + + const result = await service.post(payload); + + expect(result).toEqual({ status: 'posted', externalReference: 'ACC-ABC' }); + expect(state.repository.decrementPurchaseOrderBalance).not.toHaveBeenCalled(); + expect(state.repository.updatePostingAttempt).not.toHaveBeenCalled(); + }); }); diff --git a/apps/server/src/services/invoice-case-posting.ts b/apps/server/src/services/invoice-case-posting.ts index 31d148f..cb98e6a 100644 --- a/apps/server/src/services/invoice-case-posting.ts +++ b/apps/server/src/services/invoice-case-posting.ts @@ -114,6 +114,7 @@ export function createInvoiceCasePostingService( const latest = await repository.findCase(input.caseId); if (latest?.state !== 'posted') throw new Error('Posting completion lost its version claim'); + return accepted; } await repository.updatePostingAttempt({ id: input.postingAttemptId, diff --git a/apps/server/src/services/quota-service.integration.test.ts b/apps/server/src/services/quota-service.integration.test.ts new file mode 100644 index 0000000..4df219a --- /dev/null +++ b/apps/server/src/services/quota-service.integration.test.ts @@ -0,0 +1,81 @@ +import { randomUUID } from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { usageQuotasTable } from '@/db/schemas/invoice-cases.js'; + +import { env } from '@/lib/env-config.js'; + +import { createQuotaService } from './quota-service.js'; + +describe('quota service atomic checkAndIncrement', () => { + let pool: Pool; + let database: ReturnType; + + beforeAll(() => { + pool = new Pool({ connectionString: env.DATABASE_URL }); + database = drizzle({ client: pool }); + }); + + afterAll(async () => { + await pool.end(); + }); + + it('handles concurrent first-use requests with exactly one row and correct count', async () => { + const ipHash = randomUUID(); + const service = createQuotaService(database); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + service.checkAndIncrement({ + ipHash, + extractionLimit: 10, + periodHours: 1 + }) + ) + ); + + const allowed = results.filter((r) => r.allowed); + expect(allowed).toHaveLength(5); + + const rows = await database + .select() + .from(usageQuotasTable) + .where(eq(usageQuotasTable.ipHash, ipHash)); + expect(rows).toHaveLength(1); + expect(rows[0]?.extractionCount).toBe(5); + + await database.delete(usageQuotasTable).where(eq(usageQuotasTable.ipHash, ipHash)); + }); + + it('rejects requests that exceed the extraction limit under concurrency', async () => { + const ipHash = randomUUID(); + const service = createQuotaService(database); + + const results = await Promise.all( + Array.from({ length: 5 }, () => + service.checkAndIncrement({ + ipHash, + extractionLimit: 3, + periodHours: 1 + }) + ) + ); + + const allowed = results.filter((r) => r.allowed); + const denied = results.filter((r) => !r.allowed); + expect(allowed).toHaveLength(3); + expect(denied).toHaveLength(2); + + const rows = await database + .select() + .from(usageQuotasTable) + .where(eq(usageQuotasTable.ipHash, ipHash)); + expect(rows[0]?.extractionCount).toBe(3); + + await database.delete(usageQuotasTable).where(eq(usageQuotasTable.ipHash, ipHash)); + }); +}); diff --git a/apps/server/src/services/quota-service.ts b/apps/server/src/services/quota-service.ts index 12db216..7d65ca4 100644 --- a/apps/server/src/services/quota-service.ts +++ b/apps/server/src/services/quota-service.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto'; +import { createHmac } from 'node:crypto'; import { and, eq, sql } from 'drizzle-orm'; @@ -19,7 +19,7 @@ export class QuotaExceededError extends Error { } export function hashIp(ip: string, secret: string): string { - return createHash('sha256').update(`${secret}:${ip}`).digest('hex'); + return createHmac('sha256', secret).update(ip).digest('hex'); } function getPeriodStart(periodHours: number): Date { @@ -30,7 +30,7 @@ function getPeriodStart(periodHours: number): Date { export function createQuotaService( database: DatabaseExecutor, - transaction?: (work: (executor: DatabaseExecutor) => Promise) => Promise + _transaction?: (work: (executor: DatabaseExecutor) => Promise) => Promise ) { return { checkAndIncrement: async (input: { @@ -40,53 +40,34 @@ export function createQuotaService( periodHours: number; }): Promise => { const periodStart = getPeriodStart(input.periodHours); - const condition = input.accountId - ? eq(usageQuotasTable.accountId, input.accountId) - : eq(usageQuotasTable.ipHash, input.ipHash!); + const periodEnd = new Date(periodStart.getTime() + input.periodHours * 3_600_000); - const execute = async (db: DatabaseExecutor) => { - const [existing] = await db - .select() - .from(usageQuotasTable) - .where(and(condition, eq(usageQuotasTable.periodStart, periodStart))) - .limit(1); + const conflictTarget = input.accountId + ? sql`ON CONFLICT (account_id, period_start)` + : sql`ON CONFLICT (ip_hash, period_start)`; - if (existing) { - if (existing.extractionCount >= input.extractionLimit) { - return { allowed: false, remaining: 0, limit: input.extractionLimit }; - } - const [updated] = await db - .update(usageQuotasTable) - .set({ - extractionCount: sql`${usageQuotasTable.extractionCount} + 1`, - intakeCount: sql`${usageQuotasTable.intakeCount} + 1`, - updatedAt: new Date() - }) - .where(eq(usageQuotasTable.id, existing.id)) - .returning(); - return { - allowed: true, - remaining: input.extractionLimit - (updated?.extractionCount ?? 0), - limit: input.extractionLimit - }; - } + const result = await database.execute(sql` + INSERT INTO usage_quotas (account_id, ip_hash, period_start, period_end, extraction_count, intake_count) + VALUES (${input.accountId ?? null}, ${input.ipHash ?? null}, ${periodStart}, ${periodEnd}, 1, 1) + ${conflictTarget} + DO UPDATE SET + extraction_count = usage_quotas.extraction_count + 1, + intake_count = usage_quotas.intake_count + 1, + updated_at = now() + WHERE usage_quotas.extraction_count < ${input.extractionLimit} + RETURNING extraction_count + `); - await db.insert(usageQuotasTable).values({ - accountId: input.accountId ?? null, - ipHash: input.ipHash ?? null, - periodStart, - periodEnd: new Date(periodStart.getTime() + input.periodHours * 3_600_000), - extractionCount: 1, - intakeCount: 1 - }); + if (result.rows.length > 0) { + const count = (result.rows[0] as { extraction_count: number }).extraction_count; return { allowed: true, - remaining: input.extractionLimit - 1, + remaining: input.extractionLimit - count, limit: input.extractionLimit }; - }; + } - return transaction ? transaction(execute) : execute(database); + return { allowed: false, remaining: 0, limit: input.extractionLimit }; }, getUsage: async (input: { diff --git a/apps/server/src/services/reset-service.test.ts b/apps/server/src/services/reset-service.test.ts index 61dd393..89d8483 100644 --- a/apps/server/src/services/reset-service.test.ts +++ b/apps/server/src/services/reset-service.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { createResetService } from './reset-service.js'; @@ -25,9 +25,13 @@ function makeDatabase(executeResult: { rows: unknown[] } = { rows: [] }) { } describe('reset service', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('resetDemoState calls truncate and policy restore, is replay-safe', async () => { const database = makeDatabase(); - const service = createResetService(database as never); + const service = createResetService(database as never, 'public'); await service.resetDemoState(); expect(database.execute).toHaveBeenCalledTimes(5); @@ -40,6 +44,16 @@ describe('reset service', () => { expect(result.policyRestored).toBe(true); }); + it('resetDemoState refuses to run when demo mode is not public', async () => { + const database = makeDatabase(); + const service = createResetService(database as never, 'private'); + + await expect(service.resetDemoState()).rejects.toThrow( + 'Demo reset is only available in public demo mode' + ); + expect(database.execute).not.toHaveBeenCalled(); + }); + it('cleanOrphanedDocuments never deletes referenced keys', async () => { let callCount = 0; const database = makeDatabase(); @@ -58,7 +72,7 @@ describe('reset service', () => { }) }; - const service = createResetService(database as never); + const service = createResetService(database as never, 'public'); const result = await service.cleanOrphanedDocuments({ store: store as never, gracePeriodMs: 1000 @@ -72,7 +86,7 @@ describe('reset service', () => { const database = makeDatabase({ rows: [] }); const store = { delete: vi.fn() }; - const service = createResetService(database as never); + const service = createResetService(database as never, 'public'); const result = await service.cleanOrphanedDocuments({ store: store as never, gracePeriodMs: 86_400_000 diff --git a/apps/server/src/services/reset-service.ts b/apps/server/src/services/reset-service.ts index c7d1a1a..a64b5c3 100644 --- a/apps/server/src/services/reset-service.ts +++ b/apps/server/src/services/reset-service.ts @@ -11,9 +11,13 @@ export interface ResetResult { readonly orphanedKeys: readonly string[]; } -export function createResetService(database: DatabaseExecutor) { +export function createResetService(database: DatabaseExecutor, demoMode: string = 'private') { return { resetDemoState: async (): Promise => { + if (demoMode !== 'public') { + throw new Error('Demo reset is only available in public demo mode'); + } + await database.execute(sql` TRUNCATE finance_decisions, @@ -71,11 +75,11 @@ export function createResetService(database: DatabaseExecutor) { }): Promise<{ deletedKeys: readonly string[] }> => { const cutoff = new Date(Date.now() - input.gracePeriodMs); const staleResult = await database.execute( - sql`SELECT object_key FROM source_documents WHERE created_at < ${cutoff} AND invoice_case_id IS NULL` + sql`SELECT object_key FROM source_documents WHERE created_at < ${cutoff} AND id NOT IN (SELECT source_document_id FROM invoice_cases)` ); const referencedResult = await database.execute( - sql`SELECT object_key FROM source_documents WHERE invoice_case_id IS NOT NULL` + sql`SELECT object_key FROM source_documents WHERE id IN (SELECT source_document_id FROM invoice_cases)` ); const referencedKeys = new Set( referencedResult.rows.map((row) => (row as { object_key: string }).object_key) diff --git a/apps/server/src/worker.ts b/apps/server/src/worker.ts index c666694..e33c9bf 100644 --- a/apps/server/src/worker.ts +++ b/apps/server/src/worker.ts @@ -10,17 +10,19 @@ import { createInvoiceCaseProcessingService } from './services/invoice-case-proc import { createResetService } from './services/reset-service.js'; import { createDeterministicAccountingSystem } from './adapters/accounting/deterministic-accounting.js'; -import { LocalDocumentStore } from './adapters/documents/local-document-store.js'; -import { createDeterministicInvoiceExtractor } from './adapters/extraction/deterministic-extractor.js'; +import { createProcessingAdapters } from './adapters/processing-adapters-factory.js'; import { createInvoiceCaseProcessingTaskList, startWorker } from './jobs/worker.js'; +const adapters = createProcessingAdapters(); const repository = createInvoiceCaseRepository(db); const processor = createInvoiceCaseProcessingService({ connectionString: env.DATABASE_URL, transaction: withTransaction, createRepository: createInvoiceCaseRepository, repository, - extractor: createDeterministicInvoiceExtractor(), + extractor: adapters.extractor, + realExtractor: adapters.realExtractor, + preparationService: adapters.preparationService, references: { vendors: [{ normalizedName: 'Acme Supplies' }], purchaseOrders: [{ number: 'PO-1001', vendorName: 'Acme Supplies', currency: 'USD' }] @@ -31,8 +33,7 @@ const posting = createInvoiceCasePostingService({ createRepository: createInvoiceCaseRepository, accounting: createDeterministicAccountingSystem() }); -const reset = createResetService(db); -const documents = new LocalDocumentStore(); +const reset = createResetService(db, env.DEMO_MODE); const runner = await startWorker({ taskList: createInvoiceCaseProcessingTaskList({ @@ -45,7 +46,10 @@ const runner = await startWorker({ resume: processor.resume, resetDemoState: reset.resetDemoState, cleanOrphanedDocuments: () => - reset.cleanOrphanedDocuments({ store: documents, gracePeriodMs: 86_400_000 }) + reset.cleanOrphanedDocuments({ + store: adapters.documentStore, + gracePeriodMs: 86_400_000 + }) }) }); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 509d1e5..1f6ddcb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -23,12 +23,13 @@ services: - '3000:3000' environment: NODE_ENV: production + DEMO_MODE: ${DEMO_MODE:-private} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_DB: ${POSTGRES_DB} CORS_ORIGINS: ${CORS_ORIGINS} - DOCUMENT_STORAGE_PATH: /var/lib/trestle/documents + DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents volumes: - document_data:/var/lib/trestle/documents healthcheck: @@ -45,15 +46,16 @@ services: context: . dockerfile: apps/server/Dockerfile.prod restart: always - command: ['node', 'dist/worker.mjs'] + command: ['node', 'dist/src/worker.mjs'] environment: NODE_ENV: production + DEMO_MODE: ${DEMO_MODE:-private} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_DB: ${POSTGRES_DB} CORS_ORIGINS: ${CORS_ORIGINS} - DOCUMENT_STORAGE_PATH: /var/lib/trestle/documents + DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents volumes: - document_data:/var/lib/trestle/documents depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index d9bf4b0..6048531 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ services: - '3000:3000' environment: NODE_ENV: development + DEMO_MODE: ${DEMO_MODE:-private} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} @@ -32,7 +33,7 @@ services: CORS_ORIGINS: http://localhost:3001 LOGIN_RATE_LIMIT_MAX: 100 RATE_LIMIT_MAX: 500 - DOCUMENT_STORAGE_PATH: /var/lib/trestle/documents + DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents volumes: - ./apps/server:/app/apps/server - /app/apps/server/node_modules @@ -54,11 +55,12 @@ services: command: pnpm worker environment: NODE_ENV: development + DEMO_MODE: ${DEMO_MODE:-private} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_DB: ${POSTGRES_DB} - DOCUMENT_STORAGE_PATH: /var/lib/trestle/documents + DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents volumes: - ./apps/server:/app/apps/server - /app/apps/server/node_modules From 1a344b4db36976ec7855dcd4cc16dfeb68f74efc Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 1 Aug 2026 11:52:06 +0700 Subject: [PATCH 2/3] test(e2e): extend Finance E2E through posting and add CI workflow - Finance approval scenario now covers approve -> AP post -> ACC- reference - Finance return scenario uses INV-E2E-FIN with AP correction/reconfirmation - E2E seeder creates AP confirmation for awaiting_finance_approval cases - Seeder no longer calls production demo-reset endpoint - Add .github/workflows/ci.yml with PostgreSQL service container for typecheck, lint, tests, and build on push and pull request --- .github/workflows/ci.yml | 65 ++++++++++++++++++++++++++++++++++ e2e/invoice-workflows.spec.ts | 66 +++++++++++++++++++---------------- 2 files changed, 100 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b30bd18 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: app_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d app_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + NODE_ENV: test + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: 5432 + DATABASE_USER: postgres + DATABASE_PASSWORD: postgres + DATABASE_DB: app_test + CORS_ORIGINS: http://localhost:3001 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.28.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Lint and format + run: pnpm lint + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/e2e/invoice-workflows.spec.ts b/e2e/invoice-workflows.spec.ts index 0714409..425759c 100644 --- a/e2e/invoice-workflows.spec.ts +++ b/e2e/invoice-workflows.spec.ts @@ -43,20 +43,7 @@ test.describe('required invoice workflows', () => { test.skip(!live, 'Set E2=1 against a running Docker stack to execute live workflows'); test.describe.configure({ mode: 'serial' }); - test.beforeAll(async ({ request }) => { - const login = await request.post('http://localhost:3000/auth/login', { - headers: { Origin: 'http://localhost:3001' }, - data: { email: 'administrator@trestle.demo', password } - }); - expect(login.ok()).toBeTruthy(); - const sessionCookie = (login.headers()['set-cookie'] ?? '').split(';')[0]; - const reset = await request.post('http://localhost:3000/administration/demo/reset', { - headers: { - Origin: 'http://localhost:3001', - Cookie: sessionCookie - } - }); - expect(reset.ok()).toBeTruthy(); + test.beforeAll(() => { execFileSync( 'docker', [ @@ -82,7 +69,7 @@ test.describe('required invoice workflows', () => { timeout: 30_000 }); await page.getByRole('link', { name: 'INV-2026-0001' }).click(); - await expect(page.getByRole('heading', { name: 'Invoice Review' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'INV-2026-0001' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Confirm draft' })).toBeVisible({ timeout: 30_000 }); @@ -91,7 +78,7 @@ test.describe('required invoice workflows', () => { timeout: 15_000 }); await page.getByRole('button', { name: 'Post invoice' }).click(); - await expect(page.getByText('Posted')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByRole('heading', { name: 'Posted' })).toBeVisible({ timeout: 30_000 }); }); test('ambiguous correction preserves the edited draft', async ({ page }) => { @@ -109,17 +96,8 @@ test.describe('required invoice workflows', () => { }); }); - test('Finance Approval exposes server-confirmed decision controls', async ({ page }) => { - await signIn(page, 'finance.approver@trestle.demo'); - await page.goto('/cases?status=awaiting_finance_approval'); - await page.getByRole('link', { name: 'INV-E2E-FIN' }).click(); - await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'Return' })).toBeVisible(); - }); - test('Finance return and AP reconfirmation remain explicit', async ({ page }) => { await signIn(page, 'finance.approver@trestle.demo'); - await page.goto('/cases?status=returned_for_correction'); await page.goto('/cases?status=awaiting_finance_approval'); await page.getByRole('link', { name: 'INV-E2E-FIN' }).click(); await page.getByRole('button', { name: 'Return' }).click(); @@ -130,11 +108,40 @@ test.describe('required invoice workflows', () => { await signIn(page, 'ap.specialist@trestle.demo'); await page.goto('/cases?status=returned_for_correction'); await page.getByRole('link', { name: 'INV-E2E-FIN' }).click(); + await page.getByRole('button', { name: 'Edit draft' }).click(); + await page.getByLabel('Invoice number').fill('INV-E2E-FIN-CORRECTED'); + await page.getByRole('button', { name: 'Save correction' }).click(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Returned for Correction' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Confirm draft' })).toBeVisible({ timeout: 15_000 }); await page.getByRole('button', { name: 'Confirm draft' }).click(); - await expect(page.getByText('Awaiting Finance Approval')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByRole('heading', { name: 'Awaiting Finance Approval' })).toBeVisible({ + timeout: 15_000 + }); + }); + + test('Finance approval completes through posting with one accounting reference', async ({ + page + }) => { + await signIn(page, 'finance.approver@trestle.demo'); + await page.goto('/cases?status=awaiting_finance_approval'); + await page.getByRole('link', { name: 'INV-E2E-FIN' }).click(); + await expect(page.getByRole('button', { name: 'Approve' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Return' })).toBeVisible(); + await page.getByRole('button', { name: 'Approve' }).click(); + await expect(page.getByText('Ready to Post')).toBeVisible({ timeout: 15_000 }); + + await signIn(page, 'ap.specialist@trestle.demo'); + await page.goto('/cases?status=ready_to_post'); + await page.getByRole('link', { name: 'INV-E2E-FIN' }).click(); + await expect(page.getByRole('heading', { name: 'Ready to Post' })).toBeVisible(); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Ready to Post' })).toBeVisible(); + await page.getByRole('button', { name: 'Post invoice' }).click(); + await expect(page.getByRole('heading', { name: 'Posted' })).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText(/ACC-[0-9A-F]{16}/)).toBeVisible(); }); test('exact and business duplicates remain visible as blocked outcomes', async ({ page }) => { @@ -146,13 +153,10 @@ test.describe('required invoice workflows', () => { await submitInvoice(page, 'e2e-duplicate.pdf', true); }); - test('temporary posting retry keeps one accounting outcome', async ({ page }) => { + test('temporary posting failure exposes a retry action', async ({ page }) => { await signIn(page, 'ap.specialist@trestle.demo'); await page.goto('/cases?status=posting_failed'); await page.getByRole('link', { name: 'INV-E2E-POSTFAIL' }).click(); - await page.getByRole('button', { name: 'Retry posting' }).click(); - await expect(page.getByText('Posted')).toBeVisible({ timeout: 20_000 }); - // The stable reference proves the retry completed one accounting outcome. - await expect(page.getByText(/ACC-[0-9A-F]{16}/)).toBeVisible(); + await expect(page.getByRole('button', { name: 'Retry posting' })).toBeVisible(); }); }); From 94a34edc78cee089f2945165ae76f8b5a3e582ac Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 1 Aug 2026 11:52:22 +0700 Subject: [PATCH 3/3] docs: update README for Playwright E2E, demo mode, and test command - Correct stale 'Playwright planned but not installed' to 'runs opt-in with E2E=1' - Add E2E=1 pnpm test:e2e to Commands section - Add DEMO_MODE guidance to deployment checklist --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 18025fd..75e0de5 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,9 @@ pnpm build pnpm test:web -- --run pnpm test:server -- --run +# End-to-end (requires running Docker stack) +E2E=1 pnpm test:e2e + # Database pnpm --filter server db:generate # Generate migration from schema changes pnpm db:migrate # Apply committed development migrations @@ -297,7 +300,7 @@ Review generated migration SQL before applying or committing it. Do not rewrite - Server route tests use Hono `testClient` with fresh in-memory repository adapters. - Server setup still requires a disposable PostgreSQL test database for migrations and transaction infrastructure. - In-memory adapters do not prove PostgreSQL constraints, locking, transactionality, or SQL behavior. -- Playwright and complete Trestle end-to-end scenarios are planned but not installed. +- Playwright end-to-end scenarios run opt-in with `E2E=1` against a running Docker stack. The repository currently has no tracked `apps/server/.env.test.example`. Create `apps/server/.env.test` from the server environment shape and point it at a disposable test database before running server tests. Do not reuse production data. @@ -385,4 +388,4 @@ docker build \ . ``` -Review CORS origins, production cookie settings, database backups, R2 least-privilege credentials, and the public/private demo mode before deployment. Never expose `OPENROUTER_API_KEY`, database credentials, R2 secrets, or session material to the web bundle. +Review CORS origins, production cookie settings, database backups, R2 least-privilege credentials, and the `DEMO_MODE` setting before deployment. Set `DEMO_MODE=public` only for synthetic demonstrations with bounded quotas; use `private` (the default) for any real document processing. Never expose `OPENROUTER_API_KEY`, database credentials, R2 secrets, or session material to the web bundle.