feat(api): Phase 6 PR-3 — substrate hardening (SKIP LOCKED + DNS pinning + test debt) (#500)
* feat(api): operations table immutability trigger BFNAR 2013:2 kap 8 § behandlingshistorik integrity: once an operations row is in a terminal status (succeeded / failed / cancelled) the audit record of what happened becomes immutable. Adds the BEFORE UPDATE and BEFORE DELETE triggers that the webhook_deliveries table already has (20260515170000 / 20260515190000), mirroring their predicate shape and error code exactly. Closes the Phase 4 PR-2 (PR #469) review-round carry-over flagged by Swedish-compliance: previously a future bug, a privileged operator, or a compromised service-role caller could rewrite "this year-end close succeeded" to "failed" by updating an already-terminal row. The running → succeeded/failed/cancelled transition itself stays legal because the trigger keys on OLD.status, which is non-terminal at the moment of the legitimate UPDATE. pg test covers all transitions (allowed and blocked) plus DELETE on both terminal and non-terminal rows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(api): atomic SKIP LOCKED claim for webhook dispatch Replaces the SELECT-then-UPDATE-intersect pattern in the dispatcher with a single-roundtrip SQL function using FOR UPDATE SKIP LOCKED. PostgREST can't express SKIP LOCKED through the JS client, so the previous shape relied on a CAS guard inside an UPDATE WHERE status IN ('pending','failed') to ensure only one of two overlapping cron ticks claimed any given row. The CAS pattern was correct (under load — receivers >60s could push a batch past the next minute's tick) but burned two round trips and forced the application to negotiate the locking semantics in JS. The function form moves the contention to the DB, where SKIP LOCKED makes a row held by a concurrent tick simply invisible to the second caller. One round trip, no JS-side intersect. All filter semantics are preserved verbatim inside the function: status IN ('pending','failed'), next_attempt_at <= now, webhook_id IS NOT NULL, ORDER BY next_attempt_at ASC, LIMIT batchSize. p_batch_size is bounded (0, 1000] to forestall a runaway lock-set in case a caller misconfigures it. pg test covers basic claim (pending + failed), future-due skip, dangling- row (webhook_id IS NULL) skip, terminal-status skip, batch-size limits, out-of-range argument rejection, and the SKIP LOCKED invariant itself using two concurrent pool clients in BEGIN — the second caller does not see the row A locked, no double-delivery. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(api): pinned-IP HTTPS dispatch (close DNS-rebinding window) The url-guard.ts file header openly flagged the remaining gap: "a separate DNS-rebinding window (between dispatch-time validation and the actual fetch) remains; closing that requires a custom HTTPS agent that pins the resolved IP — tracked for follow-up." This closes it. The previous shape was: 1. validateWebhookUrl() → DNS resolves to [public IP], returns ok 2. fetch(webhook_url) → re-resolves DNS; an attacker who flipped the A record in the interval gets a private-IP socket The new pinnedHttpsFetch helper validates DNS once, then opens a node:https.request to that pinned IP — but keeps the original hostname in the TLS SNI extension (so the receiver's cert validates) and in the HTTP Host header (so vhost routing still works). The request socket never re-resolves DNS, foreclosing the rebind race entirely. Built on node:https.request rather than undici's Agent so the project doesn't take on a new dep — the stdlib API is also more explicit about the SNI / Host / pinned-IP split. Test seam injects both validateUrl and httpsRequest so the unit tests verify the pinning shape without standing up an HTTPS server. The dispatcher's attemptDelivery is rewritten as a switch over the four PinnedFetchResult kinds (ok / unsafe_url / redirect_blocked / timeout / transport_error). The previous fetch-based code path that distinguished redirect rejection by string-matching err.message is gone — the new result type makes the distinction structural. 8 unit tests cover the SNI/Host/pinned-IP shape, port handling, redirect_blocked, transport_error, timeout, response-body truncation, first-IP determinism, and the validation short-circuit (never opens a socket when the URL fails the SSRF guard). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(api): pg tests for webhook substrate triggers (PR-1 test debt) CLAUDE.md ("Testing" + "Migration Rules") mandates a *.pg.test.ts for any PR touching a trigger / RPC / RLS / DEFERRABLE constraint. Phase 6 PR-1 (#496) shipped three webhook_deliveries triggers without the accompanying pg test; this closes that debt. Triggers covered: - enforce_webhook_delivery_immutability (BEFORE UPDATE) - block_webhook_delivery_terminal_delete (BEFORE DELETE) - assert_webhook_delivery_company_match (BEFORE INSERT) 13 cases verify the lifecycle the dispatcher depends on remains mutable (pending → in_flight, in_flight → failed, failed → in_flight, in_flight → delivered) while terminal-status rows (delivered / dead) are write- locked and the cross-tenant INSERT path is refused with the ERRCODE=check_violation contract documented in the migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(api): integration tests for webhook routes (PR-1 test debt) CLAUDE.md mandates integration tests under app/api/v1/ for every route. Phase 6 PR-1 (#496) shipped the eight v1 webhook routes (five under /companies/{companyId}/webhooks/ + the cross-tenant /webhook-deliveries/ {id}/retry) without them; closes that debt. 19 cases for the /webhooks/ verticals: POST /webhooks create + secret-once + payroll-scope gate + SSRF GET /webhooks list (no secret) + empty list GET /webhooks/:id detail (no secret) + 404 PATCH /webhooks/:id update + active=true re-enable + SSRF re-check + empty-body DELETE /webhooks/:id 204 hard delete POST /webhooks/:id/test enqueue + 404 + disabled-rejection GET /webhooks/:id/deliveries happy path + ownership 404 7 cases for the retry route: POST /webhook-deliveries/:id/retry dead → fresh pending row, live-status refusal, cross-tenant 404, disabled-webhook gate, SSRF re-check, delivery 404, webhook-gone 404 Both files mirror the suppliers/customers integration test pattern: Proxy-backed Supabase mock with per-table queues, validateApiKey + validateWebhookUrl stubbed to control auth and DNS deterministically. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(api): address PR-500 review round 1 — pg-real CI fix + 4 review items 1. pg-real CI was red on this PR: the new webhook trigger pg.test.ts and claim-due-webhook-deliveries pg.test.ts fixtures tried to INSERT into `webhooks.user_id`, which doesn't exist in the migration history. The column was never declared in automation_webhooks (20260415000000) nor added by webhooks_v2 (20260515170000) — so a fresh schema replay had no such column. The webhook create route (`webhooks.create`) was also referencing this non-existent column in its INSERT, so the production route was latent-broken since PR-1 and never exercised against a fresh DB. Drop the `user_id` field from both the route INSERT and the pg fixtures. Actor attribution lives on `created_by_api_key_id` (which leads back to the owning user via `api_keys.user_id`). 2. Greptile P2 #1 — `recoverStuckInFlight` carried a redundant `.not('status','in','(delivered,dead)')` filter alongside `.eq('status','in_flight')`, with a comment that incorrectly described PostgreSQL's UPDATE re-evaluation semantics. Under READ COMMITTED, UPDATE re-evaluates WHERE against each row's CURRENT value when it acquires the row lock — a row that raced to terminal status will fail `status='in_flight'` on re-evaluation and be skipped, no immutability trigger fires. Drop the redundant filter and rewrite the comment. 3. Greptile P2 #2 — added explicit pg test verifying `in_flight` rows are skipped by `claim_due_webhook_deliveries`. The status filter is what prevents double-delivery and is the entire point of the SKIP LOCKED substrate; making that invariant load-bearing in the test suite forecloses a future filter expansion silently regressing it. 4. Greptile P2 #3 — pinned-fetch registered both `res.on('end', finalize)` and `res.on('close', finalize)`. Node fires BOTH on normal completions, so finalize ran twice; the outer `settled` guard squashed the double-resolve but the header reconstruction still ran twice. Switch to `once` + self-removing pair so finalize runs exactly once on whichever event fires first (normal: end; truncation: close). 5. Compliance Swarm V8.2.1 — the retry route only checked `webhooks:manage` even when retrying `salary_run.* / agi.*` deliveries. Mirror the create-route elevated-scope gate so a key with only `webhooks:manage` cannot re-emit payroll payloads carrying personnummer / lönesummor / skatteavdrag. New integration test verifies the gate returns 403 INSUFFICIENT_SCOPE with `required_scope: payroll:read`. 35 tests pass locally (+1 vs pre-fix). Type-check clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(api): address PR-500 review round 2 — 2 small precision fixes 1. Compliance Swarm Art.32 / A.8.24 — response_body size cap was enforced only at the application layer (pinnedHttpsFetch's maxResponseBytes=4096 constant). A future refactor that bypassed the truncation, or a non- dispatcher write path into webhook_deliveries.response_body, would silently land large blobs in a column adjacent to event payloads carrying personal data. Add a CHECK constraint at the DB layer with a generous ceiling (8 KB — double the application cap so legitimate dispatcher writes never hit it; only a regression surfaces as a check_violation). 2. Compliance Swarm CC6.6 — pinned-fetch substitutes the validated IP for `host` while keeping the original hostname in `servername`. A reader could reasonably worry that the IP substitution weakens TLS hostname verification. Document explicitly that Node's default `checkServerIdentity` matches the cert's SAN/CN against `servername` (not `host`), so a forged endpoint at the pinned IP with a valid cert for a different hostname would fail the handshake. No code change — the default behavior is correct; the comment forecloses future "this looks dangerous" review-round noise on the same line. Items NOT addressed (with rationale documented elsewhere): - Compliance Swarm V8.2.1 (retry route 404-vs-404 information leak): delivery IDs are UUIDs; the "leak" is the ability to probe existence of an opaque 128-bit identifier the caller already has, which is not meaningfully different from probing for any opaque token. Both branches return the same structured 404 envelope. - Compliance Swarm CC7.2 (restore the .not() defense-in-depth filter): direct contradiction of last round's Greptile P2 fix. Greptile's PG-semantics analysis is correct — under READ COMMITTED, UPDATE re-evaluates WHERE against the row's current value when it acquires the lock, so .eq('status','in_flight') already handles the race. Adding a redundant .not() restores a misleading comment without closing a real gap. This is the documented Compliance Swarm oscillation pattern from the project's Phase 4 lessons. - Compliance Swarm CC6.1 (webhook secret encryption-at-rest): architectural choice from PR-1; not in PR-3 (substrate hardening) scope. Belongs to a future hardening PR. - Swedish-compliance review (operations queued/running rows hard- deletable): deliberate operability tradeoff — operators need to clear stuck/queued entries that crashed mid-flight. Blocking all deletes would force a manual DB intervention every time a worker crashed before reaching terminal status. The audit trail starts at terminal-state mutation, which IS blocked. - Swedish-compliance review (salary_run.* / agi.* payload anonymisation after 7 years): already on the deferred-list as part of the 90-day TTL cleanup cron item from the PR description. Belongs to a retention-policy follow-up PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
240412fd32
commit
afb21ea638
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* Integration tests for the v1 webhooks vertical (Phase 6 PR-1).
|
||||
*
|
||||
* Phase 6 PR-1 (#496) shipped the substrate with deferred integration
|
||||
* tests. This file closes the test debt for the company-scoped routes:
|
||||
*
|
||||
* POST /webhooks (create + secret-once)
|
||||
* GET /webhooks (list, no secret)
|
||||
* GET /webhooks/:id (detail)
|
||||
* PATCH /webhooks/:id (update + SSRF re-check)
|
||||
* DELETE /webhooks/:id (hard delete, audit trail survives)
|
||||
* POST /webhooks/:id/test (synthetic delivery)
|
||||
* GET /webhooks/:id/deliveries (delivery audit list)
|
||||
*
|
||||
* Retry (POST /webhook-deliveries/:id/retry) is covered in its sibling
|
||||
* test file under app/api/v1/webhook-deliveries/.
|
||||
*
|
||||
* Mirrors the suppliers vertical test pattern: a Proxy-backed Supabase
|
||||
* mock returns whatever the route awaits, keyed by table name. Focus is
|
||||
* on outcome (status / body shape) rather than query mechanics — the
|
||||
* wrapper already validates auth, scope, idempotency, and company
|
||||
* membership.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(
|
||||
`webhook route tests require NODE_ENV=test (got ${process.env.NODE_ENV ?? 'undefined'})`,
|
||||
)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
// SSRF DNS validation lives behind a network call (dns.resolve4/6). Stub
|
||||
// it so we can deterministically force ok-vs-rejected outcomes; otherwise
|
||||
// the test would actually resolve example.com and have flaky behavior in
|
||||
// air-gapped CI.
|
||||
vi.mock('@/lib/webhooks/url-guard', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/webhooks/url-guard')>(
|
||||
'@/lib/webhooks/url-guard',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
validateWebhookUrl: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { validateWebhookUrl } from '@/lib/webhooks/url-guard'
|
||||
import { GET as listWebhooks, POST as createWebhook } from '../route'
|
||||
import {
|
||||
GET as getWebhook,
|
||||
PATCH as updateWebhook,
|
||||
DELETE as deleteWebhook,
|
||||
} from '../[id]/route'
|
||||
import { POST as testWebhook } from '../[id]/test/route'
|
||||
import { GET as listDeliveries } from '../[id]/deliveries/route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockUrlGuard = validateWebhookUrl as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
count?: number | null
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const WEBHOOK_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const DELIVERY_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
'Idempotency-Key': 'b1aaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function companyParams(companyId: string) {
|
||||
return { params: Promise.resolve({ companyId }) }
|
||||
}
|
||||
|
||||
function detailParams(companyId: string, id: string) {
|
||||
return { params: Promise.resolve({ companyId, id }) }
|
||||
}
|
||||
|
||||
const SAMPLE_WEBHOOK = {
|
||||
id: WEBHOOK_ID,
|
||||
name: 'CRM sync',
|
||||
description: null,
|
||||
event_type: 'invoice.paid',
|
||||
webhook_url: 'https://example.com/hooks/gnubok',
|
||||
active: true,
|
||||
api_version_pinned: '2026-05-12',
|
||||
disabled_at: null,
|
||||
disabled_reason: null,
|
||||
created_at: '2026-05-15T12:00:00Z',
|
||||
updated_at: '2026-05-15T12:00:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['webhooks:manage', 'payroll:read'],
|
||||
mode: 'live',
|
||||
})
|
||||
// Default URL validation: always ok. Tests override per-case.
|
||||
mockUrlGuard.mockResolvedValue({
|
||||
ok: true,
|
||||
hostname: 'example.com',
|
||||
resolvedAddresses: ['203.0.113.42'],
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// POST /webhooks (create)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/webhooks', () => {
|
||||
it('returns 201 with the freshly-minted secret on success', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: SAMPLE_WEBHOOK, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_type: 'invoice.paid',
|
||||
webhook_url: 'https://example.com/hooks/gnubok',
|
||||
name: 'CRM sync',
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = await res.json()
|
||||
expect(body.data.id).toBe(WEBHOOK_ID)
|
||||
// Secret is returned EXACTLY ONCE on create. We don't pin the prefix
|
||||
// shape too tightly — the contract is "non-empty string with whsec_
|
||||
// prefix" and the schema documents the exact length elsewhere.
|
||||
expect(typeof body.data.secret).toBe('string')
|
||||
expect(body.data.secret).toMatch(/^whsec_/)
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when webhook_url is not https', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_type: 'invoice.paid',
|
||||
webhook_url: 'http://example.com/hooks',
|
||||
name: 'CRM sync',
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR when the SSRF guard rejects the URL', async () => {
|
||||
mockUrlGuard.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'private_address',
|
||||
detail: '10.0.0.1 is private',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_type: 'invoice.paid',
|
||||
webhook_url: 'https://internal.example/hooks',
|
||||
name: 'internal',
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.reason).toBe('private_address')
|
||||
})
|
||||
|
||||
it('requires payroll:read for salary_run.* event types (elevated-scope gate)', async () => {
|
||||
// Key has webhooks:manage but NOT payroll:read.
|
||||
mockValidate.mockResolvedValueOnce({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['webhooks:manage'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await createWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_type: 'salary_run.booked',
|
||||
webhook_url: 'https://example.com/hooks',
|
||||
name: 'payroll',
|
||||
}),
|
||||
}),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
expect(body.error.details.required_scope).toBe('payroll:read')
|
||||
})
|
||||
|
||||
it('returns 401 UNAUTHORIZED when no Bearer token is supplied', async () => {
|
||||
const req = new Request(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
event_type: 'invoice.paid',
|
||||
webhook_url: 'https://example.com/hooks',
|
||||
name: 'CRM',
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await createWebhook(req, companyParams(COMPANY_ID))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GET /webhooks (list)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/webhooks', () => {
|
||||
it('lists webhooks for the company without exposing secrets', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: [SAMPLE_WEBHOOK], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listWebhooks(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.webhooks).toHaveLength(1)
|
||||
expect(body.data.webhooks[0].id).toBe(WEBHOOK_ID)
|
||||
// Secret MUST never be in a list response — surfaced only on create.
|
||||
expect(body.data.webhooks[0]).not.toHaveProperty('secret')
|
||||
})
|
||||
|
||||
it('returns an empty list when no webhooks are registered', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: [], error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listWebhooks(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.webhooks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GET /webhooks/:id (detail)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/webhooks/:id', () => {
|
||||
it('returns the webhook detail without secret', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: SAMPLE_WEBHOOK, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}`),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.id).toBe(WEBHOOK_ID)
|
||||
expect(body.data).not.toHaveProperty('secret')
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the webhook does not exist for this company', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await getWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}`),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// PATCH /webhooks/:id (update)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('PATCH /api/v1/companies/:companyId/webhooks/:id', () => {
|
||||
it('updates the webhook and clears disabled_at when active=true', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: {
|
||||
data: { ...SAMPLE_WEBHOOK, disabled_at: null, disabled_reason: null },
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ active: true }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.disabled_at).toBeNull()
|
||||
expect(body.data.disabled_reason).toBeNull()
|
||||
})
|
||||
|
||||
it('re-runs the SSRF guard when webhook_url is changed', async () => {
|
||||
mockUrlGuard.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'metadata_address',
|
||||
detail: '169.254.169.254 is the cloud metadata endpoint',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ webhook_url: 'https://metadata.example/hooks' }),
|
||||
}),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.reason).toBe('metadata_address')
|
||||
})
|
||||
|
||||
it('returns 400 VALIDATION_ERROR for an empty body (no fields to update)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await updateWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// DELETE /webhooks/:id
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('DELETE /api/v1/companies/:companyId/webhooks/:id', () => {
|
||||
it('returns 204 NO_CONTENT after a successful delete', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await deleteWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(204)
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// POST /webhooks/:id/test (synthetic delivery)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/v1/companies/:companyId/webhooks/:id/test', () => {
|
||||
it('enqueues a synthetic delivery and returns its id', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: {
|
||||
data: { id: WEBHOOK_ID, api_version_pinned: '2026-05-12', active: true, disabled_at: null },
|
||||
error: null,
|
||||
},
|
||||
webhook_deliveries: { data: { id: DELIVERY_ID }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await testWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.webhook_delivery_id).toBe(DELIVERY_ID)
|
||||
expect(body.data.status).toBe('pending')
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the webhook does not exist', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await testWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('refuses to enqueue a test for a disabled webhook', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: {
|
||||
data: {
|
||||
id: WEBHOOK_ID,
|
||||
api_version_pinned: '2026-05-12',
|
||||
active: false,
|
||||
disabled_at: '2026-05-15T11:00:00Z',
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await testWebhook(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/test`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// GET /webhooks/:id/deliveries (list deliveries)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/companies/:companyId/webhooks/:id/deliveries', () => {
|
||||
it('returns deliveries for the webhook with status + response details', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: { id: WEBHOOK_ID }, error: null },
|
||||
webhook_deliveries: {
|
||||
data: [
|
||||
{
|
||||
id: DELIVERY_ID,
|
||||
webhook_id: WEBHOOK_ID,
|
||||
event_type: 'invoice.paid',
|
||||
status: 'delivered',
|
||||
attempts: 1,
|
||||
next_attempt_at: '2026-05-15T12:00:00Z',
|
||||
response_status: 200,
|
||||
response_body: 'ok',
|
||||
error: null,
|
||||
request_id: 'whfan_x',
|
||||
created_at: '2026-05-15T12:00:00Z',
|
||||
delivered_at: '2026-05-15T12:00:01Z',
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listDeliveries(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/deliveries`,
|
||||
),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data).toHaveLength(1)
|
||||
expect(body.data[0].id).toBe(DELIVERY_ID)
|
||||
expect(body.data[0].status).toBe('delivered')
|
||||
expect(body.data[0].response_status).toBe(200)
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the webhook does not exist for this company (clean signal vs empty list)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
|
||||
webhooks: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listDeliveries(
|
||||
makeRequest(
|
||||
`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks/${WEBHOOK_ID}/deliveries`,
|
||||
),
|
||||
detailParams(COMPANY_ID, WEBHOOK_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// Cross-tenant URL guard (wrapper level)
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('webhook routes — cross-tenant URL guard', () => {
|
||||
it('returns 404 NOT_FOUND when the caller is not a member of the company in the URL', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
// No membership row → wrapper short-circuits to NOT_FOUND.
|
||||
company_members: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await listWebhooks(
|
||||
makeRequest(`https://x.test/api/v1/companies/${COMPANY_ID}/webhooks`),
|
||||
companyParams(COMPANY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('NOT_FOUND')
|
||||
})
|
||||
})
|
||||
@@ -298,10 +298,14 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string }> }>(
|
||||
|
||||
const secret = `whsec_${generateWebhookSecret()}`
|
||||
|
||||
// No user_id field on the webhooks table — the column never existed
|
||||
// in the automation_webhooks predecessor (20260415000000_schema_sync.sql)
|
||||
// and webhooks_v2 (20260515170000) didn't add it. Actor attribution
|
||||
// lives on created_by_api_key_id instead (which leads back to the
|
||||
// owning user via api_keys.user_id).
|
||||
const { data, error } = await ctx.supabase
|
||||
.from('webhooks')
|
||||
.insert({
|
||||
user_id: ctx.userId,
|
||||
company_id: ctx.companyId!,
|
||||
name: body.name,
|
||||
description: body.description ?? null,
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Integration tests for POST /api/v1/webhook-deliveries/:id/retry.
|
||||
*
|
||||
* The route lives outside the /companies/{companyId}/ tree (deliveries
|
||||
* already carry their company_id; nesting would force callers to
|
||||
* round-trip company resolution from the delivery id). Tenancy is still
|
||||
* enforced — the route resolves the delivery's company_id, then verifies
|
||||
* the caller is a member of that company via company_members.
|
||||
*
|
||||
* Closes the Phase 6 PR-1 (#496) integration-test debt for the retry
|
||||
* route.
|
||||
*/
|
||||
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error(`retry route tests require NODE_ENV=test`)
|
||||
}
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL ||= 'http://localhost:54321'
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||= 'test-anon-key'
|
||||
})
|
||||
|
||||
vi.mock('@/lib/auth/api-keys', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/auth/api-keys')>('@/lib/auth/api-keys')
|
||||
return {
|
||||
...actual,
|
||||
validateApiKey: vi.fn(),
|
||||
createServiceClientNoCookies: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@supabase/supabase-js', async () => {
|
||||
const actual = await vi.importActual<typeof import('@supabase/supabase-js')>('@supabase/supabase-js')
|
||||
return { ...actual, createClient: vi.fn().mockReturnValue({}) }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/webhooks/url-guard', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/webhooks/url-guard')>(
|
||||
'@/lib/webhooks/url-guard',
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
validateWebhookUrl: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
|
||||
import { validateWebhookUrl } from '@/lib/webhooks/url-guard'
|
||||
import { POST as retryDelivery } from '../route'
|
||||
|
||||
const mockValidate = validateApiKey as ReturnType<typeof vi.fn>
|
||||
const mockServiceClient = createServiceClientNoCookies as ReturnType<typeof vi.fn>
|
||||
const mockUrlGuard = validateWebhookUrl as ReturnType<typeof vi.fn>
|
||||
|
||||
interface TableResp {
|
||||
data?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
function makeFlexibleSupabase(byTable: Record<string, TableResp | TableResp[]>) {
|
||||
const queues = new Map<string, TableResp[]>()
|
||||
for (const [t, val] of Object.entries(byTable)) {
|
||||
queues.set(t, Array.isArray(val) ? [...val] : [val])
|
||||
}
|
||||
const buildChain = (table: string): unknown => {
|
||||
const handler: ProxyHandler<object> = {
|
||||
get(_target, prop) {
|
||||
if (prop === 'then') {
|
||||
return (resolve: (v: unknown) => void) => {
|
||||
const q = queues.get(table)
|
||||
const next = q && q.length > 1 ? q.shift()! : (q?.[0] ?? { data: null, error: null })
|
||||
resolve(next)
|
||||
}
|
||||
}
|
||||
return (..._args: unknown[]) => buildChain(table)
|
||||
},
|
||||
}
|
||||
return new Proxy({}, handler)
|
||||
}
|
||||
return { from: vi.fn((table: string) => buildChain(table)) }
|
||||
}
|
||||
|
||||
const COMPANY_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
||||
const WEBHOOK_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
||||
const DELIVERY_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'
|
||||
const NEW_DELIVERY_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'
|
||||
const USER_ID = 'user-1'
|
||||
|
||||
function makeRequest(url: string, init?: RequestInit): Request {
|
||||
return new Request(url, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: 'Bearer test-fixture-not-a-real-key',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function idParams(id: string) {
|
||||
return { params: Promise.resolve({ id }) }
|
||||
}
|
||||
|
||||
const DEAD_DELIVERY = {
|
||||
id: DELIVERY_ID,
|
||||
webhook_id: WEBHOOK_ID,
|
||||
company_id: COMPANY_ID,
|
||||
event_type: 'invoice.paid',
|
||||
payload: { invoice_id: 'inv_x' },
|
||||
previous_attributes: null,
|
||||
api_version: '2026-05-12',
|
||||
status: 'dead' as const,
|
||||
}
|
||||
|
||||
const ACTIVE_WEBHOOK = {
|
||||
id: WEBHOOK_ID,
|
||||
webhook_url: 'https://example.com/hooks',
|
||||
active: true,
|
||||
disabled_at: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockValidate.mockResolvedValue({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['webhooks:manage'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockUrlGuard.mockResolvedValue({
|
||||
ok: true,
|
||||
hostname: 'example.com',
|
||||
resolvedAddresses: ['203.0.113.42'],
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/v1/webhook-deliveries/:id/retry', () => {
|
||||
it('re-enqueues a dead delivery as a fresh pending row', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: [
|
||||
{ data: DEAD_DELIVERY, error: null }, // lookup
|
||||
{ data: { id: NEW_DELIVERY_ID }, error: null }, // insert
|
||||
],
|
||||
company_members: { data: { company_id: COMPANY_ID }, error: null },
|
||||
webhooks: { data: ACTIVE_WEBHOOK, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.data.webhook_delivery_id).toBe(NEW_DELIVERY_ID)
|
||||
expect(body.data.status).toBe('pending')
|
||||
})
|
||||
|
||||
it('requires payroll:read for salary_run.* / agi.* retries (elevated-scope gate)', async () => {
|
||||
// Caller has webhooks:manage but NOT payroll:read. Original create
|
||||
// would have rejected the subscription; retry must reject the
|
||||
// re-emission identically so a stripped-down key can't replay payroll
|
||||
// payloads to its receiver.
|
||||
mockValidate.mockResolvedValueOnce({
|
||||
userId: USER_ID,
|
||||
companyId: COMPANY_ID,
|
||||
apiKeyId: 'ak_1',
|
||||
apiKeyName: 'CI key',
|
||||
scopes: ['webhooks:manage'],
|
||||
mode: 'live',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: {
|
||||
data: { ...DEAD_DELIVERY, event_type: 'salary_run.booked' },
|
||||
error: null,
|
||||
},
|
||||
company_members: { data: { company_id: COMPANY_ID }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(403)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
expect(body.error.details.required_scope).toBe('payroll:read')
|
||||
})
|
||||
|
||||
it('refuses to retry a live delivery (pending/in_flight/failed)', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: { data: { ...DEAD_DELIVERY, status: 'failed' }, error: null },
|
||||
company_members: { data: { company_id: COMPANY_ID }, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.message).toMatch(/dead or delivered/i)
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the caller is not a member of the delivery company', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: { data: DEAD_DELIVERY, error: null },
|
||||
// Non-member → 404, not 403, so we don't leak delivery existence.
|
||||
company_members: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('refuses to retry against a disabled webhook', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: { data: DEAD_DELIVERY, error: null },
|
||||
company_members: { data: { company_id: COMPANY_ID }, error: null },
|
||||
webhooks: {
|
||||
data: { ...ACTIVE_WEBHOOK, active: false, disabled_at: '2026-05-15T11:00:00Z' },
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
})
|
||||
|
||||
it('refuses to retry when the webhook URL fails the SSRF re-check', async () => {
|
||||
mockUrlGuard.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
reason: 'private_address',
|
||||
detail: '10.0.0.1 is private',
|
||||
})
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: { data: DEAD_DELIVERY, error: null },
|
||||
company_members: { data: { company_id: COMPANY_ID }, error: null },
|
||||
webhooks: { data: ACTIVE_WEBHOOK, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
const body = await res.json()
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR')
|
||||
expect(body.error.details.reason).toBe('private_address')
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the delivery does not exist', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: { data: null, error: null },
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 404 NOT_FOUND when the original webhook has been deleted', async () => {
|
||||
mockServiceClient.mockReturnValue(
|
||||
makeFlexibleSupabase({
|
||||
webhook_deliveries: { data: DEAD_DELIVERY, error: null },
|
||||
company_members: { data: { company_id: COMPANY_ID }, error: null },
|
||||
webhooks: { data: null, error: null }, // webhook deleted between dead and retry
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await retryDelivery(
|
||||
makeRequest(`https://x.test/api/v1/webhook-deliveries/${DELIVERY_ID}/retry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
idParams(DELIVERY_ID),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import { withApiV1 } from '@/lib/api/v1/with-api-v1'
|
||||
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
|
||||
import { minimisePayload } from '@/lib/webhooks/handler'
|
||||
import { validateWebhookUrl } from '@/lib/webhooks/url-guard'
|
||||
import { hasScope } from '@/lib/auth/api-keys'
|
||||
|
||||
registerEndpoint({
|
||||
operation: 'webhook_deliveries.retry',
|
||||
@@ -119,6 +120,23 @@ export const POST = withApiV1<{ params: Promise<{ id: string }> }>(
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror the create-route elevated-scope gate. A key with only
|
||||
// webhooks:manage must NOT be able to re-emit a salary_run.* / agi.*
|
||||
// payload — those carry personnummer, lönesummor, skatteavdrag, and
|
||||
// the original create call required webhooks:manage AND payroll:read.
|
||||
// Retry checks the SAME pair against the CALLING key's scopes (which
|
||||
// may differ from the key that created the webhook in the first place).
|
||||
const PAYROLL_SENSITIVE = /^(salary_run\.|agi\.)/
|
||||
if (PAYROLL_SENSITIVE.test(o.event_type) && !hasScope(ctx.scopes, 'payroll:read')) {
|
||||
return v1ErrorResponseFromCode('INSUFFICIENT_SCOPE', ctx.log, {
|
||||
requestId: ctx.requestId,
|
||||
details: {
|
||||
required_scope: 'payroll:read',
|
||||
reason: `Retrying ${o.event_type} requires payroll:read in addition to webhooks:manage.`,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Re-verify that the parent webhook still exists, still belongs to the
|
||||
// delivery's company, and is still active immediately before INSERT.
|
||||
// Closes the TOCTOU window between the membership check above and the
|
||||
|
||||
Reference in New Issue
Block a user