refactor(connect): validate the Peppol connector with the shared contract schemas (#2193)
The hosted Peppol route now parses requests with the schemas published in @accounted/connect-contract instead of its own copies, and the instance transport validates every hosted answer against the contract's response schemas (a shape mismatch is a non-retryable protocol error) and reads the error envelope through connectorErrorSchema. Paths come from the operation table. No behaviour change for a conforming peer; the two sides can no longer drift apart silently. Signed-off-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
f31eeaa603
commit
3f9a21c64d
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import type { z } from 'zod'
|
||||
import { CONNECTOR_HEADERS, PEPPOL_OPERATIONS, peppolParticipantSchema } from '@accounted/connect-contract'
|
||||
import { withConnectorAuth, type ConnectorContext } from '@/lib/connect/hosted/with-connector-auth'
|
||||
import { reserveUpstream } from '@/lib/connect/hosted/upstream-budget'
|
||||
import {
|
||||
@@ -63,53 +64,19 @@ import { QVALIA_PROVIDER, createQvaliaTransport, readQvaliaConfigFromEnv } from
|
||||
* operation answers 403.
|
||||
*/
|
||||
|
||||
const MAX_DOCUMENT_CHARS = 5_000_000
|
||||
const COMPANY_HEADER = 'x-connector-company'
|
||||
const COMPANY_HEADER = CONNECTOR_HEADERS.company.toLowerCase()
|
||||
const PENDING_STATE_PREFIX = 'peppol:'
|
||||
|
||||
const participantSchema = z.object({
|
||||
// Peppol participant scheme (ISO 6523 ICD), four digits; not a BAS account.
|
||||
scheme: z.string().length(4).regex(/^\d+$/),
|
||||
identifier: z.string().trim().min(1).max(64),
|
||||
})
|
||||
const documentTypeSchema = z.enum(['Invoice', 'CreditNote'])
|
||||
|
||||
const lookupSchema = z.object({ participant: participantSchema })
|
||||
const submissionSchema = z.object({
|
||||
idempotencyKey: z.string().trim().min(1).max(128),
|
||||
tenantReference: z.string().trim().min(1).max(128),
|
||||
sender: participantSchema,
|
||||
recipient: participantSchema,
|
||||
documentTypeId: z.string().trim().min(1).max(512),
|
||||
processId: z.string().trim().min(1).max(512),
|
||||
filename: z.string().trim().min(1).max(255),
|
||||
contentType: z.literal('application/xml'),
|
||||
document: z.string().min(1).max(MAX_DOCUMENT_CHARS),
|
||||
documentSha256: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
const submissionRefSchema = z.object({ providerSubmissionId: z.string().trim().min(1).max(128) })
|
||||
const registrationSchema = z.object({
|
||||
participant: participantSchema,
|
||||
businessCard: z.object({
|
||||
companyName: z.string().trim().min(1).max(200),
|
||||
countryCode: z.string().trim().length(2),
|
||||
geographicalInformation: z.string().max(500).nullish(),
|
||||
vatNumber: z.string().max(64).nullish(),
|
||||
orgNumber: z.string().max(64).nullish(),
|
||||
}),
|
||||
documentTypes: z.array(z.object({ processId: z.string().min(1).max(512), documentTypeId: z.string().min(1).max(512) })).min(1).max(20),
|
||||
description: z.string().max(200).nullish(),
|
||||
tenantReference: z.string().max(128).nullish(),
|
||||
})
|
||||
const inboundListSchema = z.object({
|
||||
documentType: documentTypeSchema,
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
includeRead: z.boolean().optional(),
|
||||
})
|
||||
const inboundXmlSchema = z.object({
|
||||
providerDocumentId: z.string().trim().min(1).max(128),
|
||||
documentType: documentTypeSchema,
|
||||
})
|
||||
// Request shapes come from the published contract so this route and the
|
||||
// instance transport (and any third-party implementation of either side)
|
||||
// validate with the same schemas.
|
||||
const participantSchema = peppolParticipantSchema
|
||||
const lookupSchema = PEPPOL_OPERATIONS.lookup.request
|
||||
const submissionSchema = PEPPOL_OPERATIONS.submit.request
|
||||
const submissionRefSchema = PEPPOL_OPERATIONS.status.request
|
||||
const registrationSchema = PEPPOL_OPERATIONS.register.request
|
||||
const inboundListSchema = PEPPOL_OPERATIONS.inboundList.request
|
||||
const inboundXmlSchema = PEPPOL_OPERATIONS.inboundXml.request
|
||||
|
||||
function hostedTransport(): PeppolTransport | null {
|
||||
const config = readQvaliaConfigFromEnv()
|
||||
|
||||
@@ -62,15 +62,24 @@ describe('connector Peppol transport', () => {
|
||||
})
|
||||
|
||||
it('polls status and evidence with the connector provider stamped on and the owning company resolved', async () => {
|
||||
const event = {
|
||||
provider: 'qvalia', providerTenantId: '5560000000', providerSubmissionId: 'int-1', providerEventId: 'e1', idempotencyKey: null,
|
||||
eventCode: 'status_poll', normalizedStatus: 'transport_succeeded', isTerminal: false, detail: null, occurredAt: 't',
|
||||
rawPayload: {}, eventSha256: 'a'.repeat(64), verificationMethod: 'provider_poll',
|
||||
}
|
||||
const evidence = {
|
||||
provider: 'qvalia', evidenceType: 'qvalia_message_record', payload: {}, exactDocument: null, exactDocumentSha256: null,
|
||||
evidenceSha256: 'b'.repeat(64), retrievedAt: 't',
|
||||
}
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', eventCode: 'status_poll' }]))
|
||||
.mockResolvedValueOnce(jsonResponse([{ provider: 'qvalia', evidenceType: 'qvalia_message_record' }]))
|
||||
.mockResolvedValueOnce(jsonResponse([event]))
|
||||
.mockResolvedValueOnce(jsonResponse([evidence]))
|
||||
const transport = createConnectorPeppolTransport(upstream, {
|
||||
fetch: fetchMock as unknown as typeof fetch,
|
||||
companyFor: async (id) => (id === 'int-1' ? 'company-7' : null),
|
||||
})
|
||||
expect(await transport.pollDeliveryStatus!('int-1')).toEqual([{ provider: 'connector', eventCode: 'status_poll' }])
|
||||
expect(await transport.retrieveEvidence('int-1')).toEqual([{ provider: 'connector', evidenceType: 'qvalia_message_record' }])
|
||||
expect(await transport.pollDeliveryStatus!('int-1')).toEqual([{ ...event, provider: 'connector' }])
|
||||
expect(await transport.retrieveEvidence('int-1')).toEqual([{ ...evidence, provider: 'connector' }])
|
||||
for (const call of fetchMock.mock.calls as Array<[string, RequestInit]>) {
|
||||
expect((call[1].headers as Record<string, string>)['X-Connector-Company']).toBe('company-7')
|
||||
}
|
||||
@@ -109,3 +118,13 @@ describe('transport security', () => {
|
||||
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy((e: unknown) => isPeppolTransportError(e) && e.retryable === true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contract validation', () => {
|
||||
it('rejects a hosted answer that does not match the contract as a non-retryable protocol error', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ reachable: 'maybe' }))
|
||||
const transport = build(fetchMock as unknown as typeof fetch)
|
||||
await expect(transport.lookupRecipient(participant)).rejects.toSatisfy(
|
||||
(e: unknown) => isPeppolTransportError(e) && e.retryable === false && /unexpected response shape/.test(e.message),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { PEPPOL_OPERATIONS, connectorErrorSchema } from '@accounted/connect-contract'
|
||||
import type { z } from 'zod'
|
||||
import { CONNECTOR_COMPANY_HEADER, type ConnectorUpstream } from '@/lib/connect/instance/upstreams'
|
||||
import {
|
||||
CONNECTOR_PEPPOL_PROVIDER,
|
||||
PeppolTransportError,
|
||||
type PeppolDeliveryEvidence,
|
||||
type PeppolInboundListOptions,
|
||||
type PeppolInboundMessage,
|
||||
type PeppolParticipant,
|
||||
type PeppolRecipientLookup,
|
||||
type PeppolRecipientRegistration,
|
||||
type PeppolSubmissionReceipt,
|
||||
type PeppolVerifiedEvent,
|
||||
type PeppolInboundListOptions,
|
||||
type PeppolParticipant,
|
||||
type PeppolRecipientRegistrationInput,
|
||||
type PeppolSubmission,
|
||||
type PeppolSubmissionReceipt,
|
||||
type PeppolTransport,
|
||||
type PeppolVerifiedEvent,
|
||||
type PeppolWebhookRequest,
|
||||
} from '@/lib/invoices/peppol-transport'
|
||||
|
||||
@@ -49,13 +51,6 @@ export interface ConnectorTransportDeps {
|
||||
companyForParticipant?: (participant: PeppolParticipant) => Promise<string | null>
|
||||
}
|
||||
|
||||
interface ConnectorErrorBody {
|
||||
error?: string
|
||||
code?: string
|
||||
retryable?: boolean
|
||||
detail?: string | null
|
||||
}
|
||||
|
||||
async function readJson(response: Response): Promise<unknown> {
|
||||
const text = await response.text()
|
||||
if (!text) return null
|
||||
@@ -67,14 +62,25 @@ async function readJson(response: Response): Promise<unknown> {
|
||||
}
|
||||
|
||||
function failureFromResponse(status: number, body: unknown): PeppolTransportError {
|
||||
const parsed = (body && typeof body === 'object' ? body : {}) as ConnectorErrorBody
|
||||
const code = typeof parsed.code === 'string' ? parsed.code : `HTTP_${status}`
|
||||
const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : `Connector answered ${status}`
|
||||
const retryable = typeof parsed.retryable === 'boolean' ? parsed.retryable : status === 429 || status >= 500
|
||||
const detail = [code, typeof parsed.detail === 'string' ? parsed.detail : null].filter(Boolean).join(': ')
|
||||
const parsed = connectorErrorSchema.safeParse(body)
|
||||
const envelope = parsed.success ? parsed.data : null
|
||||
const code = envelope?.code ?? `HTTP_${status}`
|
||||
const message = envelope?.error || `Connector answered ${status}`
|
||||
const retryable = typeof envelope?.retryable === 'boolean' ? envelope.retryable : status === 429 || status >= 500
|
||||
const detail = [code, envelope?.detail ?? null].filter(Boolean).join(': ')
|
||||
return new PeppolTransportError(`Connector: ${message}`, { retryable, detail: detail || null })
|
||||
}
|
||||
|
||||
/** Validate a hosted answer against the contract; a shape mismatch is a protocol error, never retried. */
|
||||
function parseResponse<T>(schema: z.ZodType<T>, json: unknown, operation: string): T {
|
||||
const parsed = schema.safeParse(json)
|
||||
if (parsed.success) return parsed.data
|
||||
throw new PeppolTransportError(`Connector: unexpected response shape from ${operation}`, {
|
||||
retryable: false,
|
||||
detail: parsed.error.issues.slice(0, 3).map((i) => `${i.path.join('.')}: ${i.message}`).join('; '),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The connector key travels as a bearer token, so the hosted origin must be
|
||||
* https. Plain http is tolerated for loopback only (local development against
|
||||
@@ -104,6 +110,8 @@ export function createConnectorPeppolTransport(
|
||||
assertTransportSecurity(baseUrl)
|
||||
|
||||
async function call<T>(
|
||||
operation: string,
|
||||
schema: z.ZodType<T>,
|
||||
method: 'POST' | 'PUT' | 'DELETE',
|
||||
path: string,
|
||||
body: unknown,
|
||||
@@ -130,7 +138,7 @@ export function createConnectorPeppolTransport(
|
||||
})
|
||||
const json = await readJson(response)
|
||||
if (!response.ok) throw failureFromResponse(response.status, json)
|
||||
return json as T
|
||||
return parseResponse(schema, json, operation)
|
||||
} catch (error) {
|
||||
if (error instanceof PeppolTransportError) throw error
|
||||
throw new PeppolTransportError('Connector: could not reach the hosted service', { retryable: true, cause: error })
|
||||
@@ -144,11 +152,11 @@ export function createConnectorPeppolTransport(
|
||||
}
|
||||
|
||||
async function lookupRecipient(participant: PeppolParticipant): Promise<PeppolRecipientLookup> {
|
||||
return call<PeppolRecipientLookup>('POST', '/lookup', { participant })
|
||||
return call('lookup', PEPPOL_OPERATIONS.lookup.response, 'POST', PEPPOL_OPERATIONS.lookup.path, { participant })
|
||||
}
|
||||
|
||||
async function submit(submission: PeppolSubmission): Promise<PeppolSubmissionReceipt> {
|
||||
const receipt = await call<PeppolSubmissionReceipt>('POST', '/submit', submission, {
|
||||
const receipt = await call('submit', PEPPOL_OPERATIONS.submit.response, 'POST', PEPPOL_OPERATIONS.submit.path, submission, {
|
||||
companyRef: submission.tenantReference,
|
||||
})
|
||||
return withProvider(receipt)
|
||||
@@ -168,14 +176,14 @@ export function createConnectorPeppolTransport(
|
||||
}
|
||||
|
||||
async function retrieveEvidence(providerSubmissionId: string): Promise<PeppolDeliveryEvidence[]> {
|
||||
const items = await call<PeppolDeliveryEvidence[]>('POST', '/evidence', { providerSubmissionId }, {
|
||||
const items = await call('evidence', PEPPOL_OPERATIONS.evidence.response, 'POST', PEPPOL_OPERATIONS.evidence.path, { providerSubmissionId }, {
|
||||
companyRef: await companyFor(providerSubmissionId),
|
||||
})
|
||||
return (items ?? []).map(withProvider)
|
||||
}
|
||||
|
||||
async function pollDeliveryStatus(providerSubmissionId: string): Promise<PeppolVerifiedEvent[]> {
|
||||
const events = await call<PeppolVerifiedEvent[]>('POST', '/status', { providerSubmissionId }, {
|
||||
const events = await call('status', PEPPOL_OPERATIONS.status.response, 'POST', PEPPOL_OPERATIONS.status.path, { providerSubmissionId }, {
|
||||
companyRef: await companyFor(providerSubmissionId),
|
||||
})
|
||||
return (events ?? []).map(withProvider)
|
||||
@@ -187,7 +195,7 @@ export function createConnectorPeppolTransport(
|
||||
retryable: false,
|
||||
})
|
||||
}
|
||||
const result = await call<PeppolRecipientRegistration>('PUT', '/recipient', input, {
|
||||
const result = await call('register', PEPPOL_OPERATIONS.register.response, 'PUT', PEPPOL_OPERATIONS.register.path, input, {
|
||||
companyRef: input.tenantReference,
|
||||
})
|
||||
return { ...result, participant: input.participant }
|
||||
@@ -195,13 +203,13 @@ export function createConnectorPeppolTransport(
|
||||
|
||||
async function unregisterRecipient(participant: PeppolParticipant): Promise<void> {
|
||||
const query = new URLSearchParams({ scheme: participant.scheme, identifier: participant.identifier })
|
||||
await call<unknown>('DELETE', `/recipient?${query.toString()}`, undefined, {
|
||||
await call('unregister', PEPPOL_OPERATIONS.unregister.response, 'DELETE', `${PEPPOL_OPERATIONS.unregister.path}?${query.toString()}`, undefined, {
|
||||
companyRef: deps.companyForParticipant ? await deps.companyForParticipant(participant) : null,
|
||||
})
|
||||
}
|
||||
|
||||
async function listInboundDocuments(options: PeppolInboundListOptions): Promise<PeppolInboundMessage[]> {
|
||||
const items = await call<PeppolInboundMessage[]>('POST', '/inbound/list', options)
|
||||
const items = await call('inboundList', PEPPOL_OPERATIONS.inboundList.response, 'POST', PEPPOL_OPERATIONS.inboundList.path, options)
|
||||
return (items ?? []).map(withProvider)
|
||||
}
|
||||
|
||||
@@ -209,7 +217,7 @@ export function createConnectorPeppolTransport(
|
||||
providerDocumentId: string,
|
||||
documentType: PeppolInboundListOptions['documentType'],
|
||||
): Promise<string | null> {
|
||||
const result = await call<{ xml: string | null }>('POST', '/inbound/xml', { providerDocumentId, documentType })
|
||||
const result = await call('inboundXml', PEPPOL_OPERATIONS.inboundXml.response, 'POST', PEPPOL_OPERATIONS.inboundXml.path, { providerDocumentId, documentType })
|
||||
return typeof result?.xml === 'string' && result.xml.trim().startsWith('<') ? result.xml : null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user