From a7fb88b21ea6b0fb4a7087e449b74ae1b48aee95 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:48:45 -0600 Subject: [PATCH 01/22] docs: plan security review remediation --- .../2026-08-23-security-review-remediation.md | 295 ++++++++++++++++++ ...8-23-security-review-remediation-design.md | 144 +++++++++ 2 files changed, 439 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-23-security-review-remediation.md create mode 100644 docs/superpowers/specs/2026-08-23-security-review-remediation-design.md diff --git a/docs/superpowers/plans/2026-08-23-security-review-remediation.md b/docs/superpowers/plans/2026-08-23-security-review-remediation.md new file mode 100644 index 00000000..3f36268a --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-security-review-remediation.md @@ -0,0 +1,295 @@ +# Production Security Review Remediation Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute this plan task-by-task. + +**Goal:** Remediate all eleven user-supplied production security review comments with behavior-level regression proof and no live deployment. + +**Architecture:** Strengthen existing integration and request boundaries. Provider readiness and response validation remain in service boundaries; distributed admissions and step-up login state live in D1; webhook collision classification stays in the Wompi repository/ingestion boundary; backup trust is anchored in live D1 export evidence; response/audience policies are enforced before data leaves the Worker. + +**Tech Stack:** TypeScript, Cloudflare Workers, D1/SQLite migrations, R2, React, Vitest, Miniflare, Wrangler. + +**Authoritative spec:** [docs/superpowers/specs/2026-08-23-security-review-remediation-design.md](../specs/2026-08-23-security-review-remediation-design.md) + +**Baseline:** `9712e933a0fc6c4cb93836c60d5bd53694e9e5cb`; `npm test` passed 143 files and 2510 tests (2 skipped) with an external MINIFLARE_CACHE_DIR and sandbox-free loopback. + +## Preflight conflict map + +| Tasks | Shared surface | Ruling | +|---|---|---| +| 1 and 5 | MH readiness/sanitization concepts and credentials | Task 1 may add a readiness helper; Task 5 owns provider-response sanitization and must reuse, not duplicate, secret selection. | +| 1 and 6 | Runtime configuration | Task 1 owns production mock/manifest rules. Task 6 may read config but must not relax Task 1. | +| 1 and 9 | `src/worker/index.ts` response paths indirectly consume Wompi links | Task 1 completes first and defines typed Wompi errors; Task 9 must preserve them. | +| 3 and 4 | New D1 migrations and rate-limit repository interfaces | Task 3 appends 0045. Task 4 appends 0046 and updates the immutable migration frontier through both; neither edits historical migrations. | +| 3, 4, 5, 7, 8 | `src/worker/index.ts` | Execute sequentially. Every later implementer starts from current HEAD and preserves earlier focused tests. | +| 4 and 6 | Repository facade and cleanup sweep | Task 4 owns provider-claim cleanup; Task 6 adds only the retention-anchor read method. | +| 5 and 7 | Audit logs | Task 5 adds bounded conflict evidence; Task 7 changes only read authorization/projection. | +| 6 and 7 | Audit repository | Task 6 adds a narrowly named retention-anchor query; Task 7 must preserve it. | +| 8 and all route tasks | Outer fetch return wrapper | HSTS is last so its structural wrapper sees all final route forms without causing repetitive merge work. | + +## Task 1: Fail closed before real Wompi checkout creation + +**Review comments:** 1, 2, 11. + +**Files:** + +- Modify: `src/worker/config.ts` +- Modify: `src/worker/domain/signer.ts` +- Modify: `src/worker/services/environmentPolicy.ts` +- Modify: `src/worker/services/wompiApi.ts` +- Modify: `scripts/private-wrangler-config.mjs` +- Modify: `test/worker/config.test.ts` +- Modify: `test/worker/signer.test.ts` +- Modify: `test/worker/wompiApi.test.ts` +- Modify: `test/scripts/privateWranglerConfig.test.ts` +- Modify only if assertions require it: `test/scripts/productionProvisioningDocs.test.ts` + +### Steps + +1. Add failing tests that: + - reject production shared mock mode at runtime; + - reject a production private manifest with shared mock enabled or omitted; + - prove missing/mismatched/inactive/unimportable MH signing material and missing matching MH credentials/endpoints make `createPaymentLink` fail before `fetch`; + - reject hostile/malformed link response values while accepting the known Wompi hosted-link shapes. +2. Run the focused tests and record the expected failures. +3. Extract a signer readiness check that performs the same active/password/private-key validation used by signing without producing or persisting a fiscal document. +4. Add one fiscal-collection readiness function using deployment policy, issuer config, matching MH lane endpoints/credentials, and signer readiness. Call it in the non-mock branch before token acquisition. +5. Make the shared mock gate throw when `APP_ENV=production`. +6. Extend private target-manifest validation so production requires `MOCK_EXTERNAL_SERVICES === "false"`. +7. Parse Wompi link responses from `unknown`; enforce positive safe ID, HTTPS, no userinfo/fragment/alternate port, exact hosts, and approved path/query shapes. Throw `WompiApiError` without returning an untrusted URL. +8. Run: + +```sh +npx vitest run test/worker/config.test.ts test/worker/signer.test.ts test/worker/wompiApi.test.ts test/scripts/privateWranglerConfig.test.ts +``` + +9. Commit with a message scoped to the provider/fiscal gate. + +## Task 2: Include local PDF artifacts in the private boundary + +**Review comment:** 3. + +**Files:** + +- Modify: `scripts/check-private-boundary.mjs` +- Modify: `test/scripts/privateBoundary.test.ts` +- Modify only if absent: `.gitignore` + +### Steps + +1. Add `tmp/pdfs/donor-render.pdf` to the synthetic failing table and to ignore-alignment coverage. Assert the checker reports only the path, not contents. +2. Run `npx vitest run test/scripts/privateBoundary.test.ts` and observe failure. +3. Add a recursive `collectTree("tmp/pdfs")` boundary check. Preserve the current symlink/non-directory behavior. +4. Mutation-prove the guard by temporarily removing the new collection call, observing the new test fail, and restoring it. +5. Run the focused test and `npm run security:check-private-boundary`. +6. Commit without deleting any artifact. + +## Task 3: Add non-locking account step-up MFA after distributed failures + +**Review comment:** 4. + +**Files:** + +- Create: `migrations/0045_login_step_up_mfa.sql` +- Modify: `src/worker/types.ts` +- Modify: `src/worker/services/auth.ts` +- Modify: `src/worker/services/email.ts` and the smallest existing template surface it requires +- Modify: `src/worker/storage/repository/rateLimits.ts` +- Modify: `src/worker/storage/repository/users.ts` or the existing user/session repository module +- Modify: `src/worker/storage/repository.ts` +- Modify: `src/worker/index.ts` +- Modify: `src/worker/routes/apiRoutes.ts` if route registration is separate +- Modify: `src/client/App.tsx` +- Modify: `test/worker/support/inMemoryD1.ts` +- Modify: `test/worker/workerFetch.auth-infra.test.ts` +- Add or modify a focused client login test + +### Steps + +1. Write failing worker tests for six account failures across distinct IPs followed by: + - correct credentials returning a verification challenge and no token/session; + - one-time code completion returning the session; + - wrong/expired/replayed codes failing and attempts being bounded; + - email delivery failure creating no usable session; + - a normal below-threshold login remaining unchanged. +2. Add a failing client behavior test for rendering and submitting the verification-code step in Spanish. +3. Run focused tests and capture red results. +4. Append migration 0045 with a short-lived login step-up challenge table. Store only random continuation/code hashes, auth-generation/credential binding, expiry, attempt count, and consumption state. Add expiry indexes. +5. Split credential verification from session creation inside `AuthService` so the challenged path can prove the password without minting a token. Preserve the existing constant-work missing/disabled-account behavior. +6. Count `LOGIN_FAILED` by normalized account identifier across IPs. Below threshold, keep the current path. At/above threshold, a correct password creates the bounded challenge, sends the code through the existing email integration, and returns only a challenge handle. +7. Add an atomic completion method that consumes a correct challenge once, rechecks auth generation/current credentials, and then creates the session. Bound wrong code attempts and clean expired challenges during the existing scheduled security cleanup. +8. Update the client login panel to support the code step without disclosing whether an arbitrary account exists. +9. Run focused worker/client tests, then `npm run build`. +10. Commit migration and code together. + +## Task 4: Bound provider creation globally and normalize IPv6 rate identities + +**Review comment:** 8. + +**Files:** + +- Create: `migrations/0046_provider_creation_budgets.sql` +- Modify: `src/worker/services/donations.ts` +- Modify: `src/worker/storage/repository/rateLimits.ts` +- Modify: `src/worker/storage/repository.ts` +- Modify: `src/worker/index.ts` +- Modify: `test/worker/support/inMemoryD1.ts` +- Modify: `test/worker/workerFetch.donation-intents.test.ts` +- Modify: `test/worker/stripeRoutes.test.ts` +- Modify: `test/worker/workerFetch.auth-infra.test.ts` +- Modify: `scripts/check-migration-immutability.mjs` +- Modify: `test/scripts/migrationImmutability.test.ts` +- Mirror migration-frontier docs in: `README.md`, `README.es.md` +- Modify relevant provisioning/migration tests. + +### Steps + +1. Add failing repository/route tests for: + - IPv6 textual variants and addresses in one /64 sharing the client ceiling; + - distinct IPs exhausting Wompi provider, Stripe provider, and shared global ceilings; + - atomic concurrent claims never exceeding a ceiling; + - Stripe request-id replay consuming no second claim; + - unused claims being releasable and expired claims being swept. +2. Run focused tests and record red. +3. Append migration 0046 with a provider creation claim table, CHECKed provider discriminator, timestamps/expiry, and indexes for client, provider, and global window counts. +4. Add a strict IP rate-identity normalizer: canonical IPv4, IPv6 /64, otherwise unknown. Keep raw audit/source IP handling separate. +5. Implement one atomic claim statement checking client, provider, and global counts. Use fixed, documented constants chosen above ordinary traffic and below provider/storage exhaustion. +6. Call it before new Wompi and fresh Stripe state/provider creation. Keep existing idempotent replay and release semantics. +7. Extend cleanup and in-memory/SQLite test support. +8. Pin only new migrations 0045 and 0046 in the immutability map and update the frontier assertions/docs in both languages. +9. Run focused tests, `npm run migrations:check-immutability`, and `npm run build`. +10. Commit. + +## Task 5: Distinguish Wompi replay from collision + +**Review comment:** 5. + +**Files:** + +- Modify: `src/worker/storage/repository/wompiIssuance.ts` +- Modify: `src/worker/storage/repository.ts` +- Modify: `src/worker/index.ts` +- Modify: `test/worker/support/inMemoryD1.ts` +- Modify: `test/worker/workerFetch.advanced-cde-webhook.test.ts` +- Modify if reconciliation coverage belongs there: `test/worker/workerFetch.donation-correlation-deferred.test.ts` + +### Steps + +1. Add failing end-to-end webhook tests for collisions in environment, result, amount, payment-link/intent, and normalized body. Each must prove no paid marker and no queue send. Retain and strengthen the legitimate alternate-transaction/payment-link replay test. +2. Run focused tests and observe the false-paid/queue behavior. +3. Replace the boolean insertion result with an explicit discriminated result: inserted, equivalent replay, conflict. +4. Canonicalize stored and incoming normalized payloads and compare every security-relevant field. Permit alternate transaction IDs only for an otherwise equivalent payment-link event. +5. In ingestion, use the payload reconstructed from `record.raw_body` for all replay environment, paid-marker, and queue decisions. +6. Audit conflicts with bounded field names/reasons only and return a conflict response; do not overwrite canonical storage. +7. Run focused tests and `npm run build`. +8. Commit. + +## Task 6: Sanitize MH response evidence and anchor backup verification + +This plan keeps two review comments as two independently reviewed commits; execute 6A before 6B. + +### Task 6A: Sanitize MH authentication and reception responses + +**Review comment:** 6. + +**Files:** + +- Modify: `src/worker/services/mhClient.ts` +- Modify: `test/worker/mhClient.test.ts` +- Modify only for durable sink proof: `test/worker/pipeline.issuance.test.ts` + +#### Steps + +1. Add failing tests whose MH auth/reception responses echo the configured username, password, and bearer token in status, description, observations, nested raw fields, and plain-text fallback. Assert no exact secret appears in errors or returned/persisted JSON. +2. Run the focused tests and capture red. +3. Replace auth body-bearing errors with status-only bounded constants and discard token-missing descriptions. +4. Add a boundary sanitizer that recursively replaces every configured exact secret inside strings before deriving estado, observaciones, raw, errors, or metadata. Keep fiscal verdict parsing on sanitized structure. +5. Run `npx vitest run test/worker/mhClient.test.ts test/worker/pipeline.issuance.test.ts` and `npm run build`. +6. Commit. + +### Task 6B: Require exact backup manifest and live D1 anchor + +**Review comment:** 7. + +**Files:** + +- Modify: `src/worker/services/retention.ts` +- Modify: `src/worker/services/backups.ts` +- Modify: `src/worker/storage/repository/audit.ts` +- Modify: `src/worker/storage/repository.ts` +- Modify: `test/worker/support/inMemoryD1.ts` +- Modify: `test/worker/retention.test.ts` +- Modify: `test/worker/workerFetch.retention-admin.test.ts` +- Modify: `docs/retention-restore.md` + +#### Steps + +1. Replace the existing partial-manifest success fixture with failing tests for empty, partial, extra, malformed, wrong-month/run/key/hash/count, no-anchor, anchor mismatch, and forged manifest plus matching forged bodies. +2. Add a valid exact-manifest plus D1-anchor happy path and run tests red. +3. Centralize the canonical table list and a strict `unknown -> RetentionManifest` parser. Require version 2 and exact run-scoped keys. +4. Add a narrow repository query for the live D1 `RETENTION_EXPORT_COMPLETED` anchor and parse its metadata defensively. +5. Before object hashing, compare the exact manifest table map to the anchor. Treat any failure as a verification failure with audit and alert, never a vacuous success. +6. Include runId, generatedAt, exact tables, total rows, and canonical manifest digest in new completion evidence; support existing correct anchors through exact table-map comparison. +7. Reuse strict parsing in list/table/download helpers so invalid manifests are not reported as archived. +8. Update the restore runbook and run focused tests plus build. +9. Commit. + +## Task 7: Enforce the account audit audience before query + +**Review comment:** 9. + +**Files:** + +- Modify: `src/worker/index.ts` +- Modify if a projection helper is cleaner: `src/worker/services/auditProjection.ts` +- Modify: `test/worker/workerFetch.audit-context-branding-analytics.test.ts` +- Modify the contingency-focused test file containing role fixtures. + +### Steps + +1. Add a failing chained test: VIEWER fetches contingency, extracts `created_by`, and scopes `/api/audit?entityType=user&entityId=...`. +2. Add ADMIN/OWNER preservation tests and run focused tests red. +3. Reject user-scoped filters before repository lookup for VIEWER/OPERATOR. +4. Project contingency events by role and omit `created_by` for VIEWER/OPERATOR. +5. Run focused tests and build. +6. Commit. + +## Task 8: Emit HSTS on every production response + +**Review comment:** 10. + +**Files:** + +- Modify: `public/_headers` +- Modify: `src/worker/index.ts` +- Modify: `test/worker/workerFetch.infra.test.ts` + +### Steps + +1. Add failing tests for production health JSON, asset HTML, redirect, webhook/API error, and 204 response headers. Assert the exact conservative value and absence of includeSubDomains/preload. +2. Run focused tests red. +3. Add the static header and one outer production response wrapper so every fetch return path is covered without duplicating route logic. +4. Mutation-prove at least the error/204 path by removing the wrapper temporarily, observing failure, then restoring. +5. Run focused tests and build. +6. Commit. + +## Task 9: Whole-branch verification and review + +**Files:** No planned product changes. Fix only validated review findings through the SDD fix loop. + +### Steps + +1. Run focused suites from Tasks 1-8. +2. Run: + +```sh +MINIFLARE_CACHE_DIR= npm test +npm run build +npm run security:check-private-boundary +npm run migrations:check-immutability +``` + +3. Inspect `git diff --check`, `git status --short`, and the complete merge-base diff. +4. Generate the SDD review package and dispatch a fresh whole-branch reviewer on the most capable model. +5. Resolve all load-bearing findings with the bounded SDD fix loop; record non-load-bearing rulings in the ledger. +6. Remove only this plan's ignored SDD workspace after clean review. +7. Use `superpowers:finishing-a-development-branch` and present its exact three-option handoff menu. Do not merge, push, deploy, or create a PR without the user's explicit choice. diff --git a/docs/superpowers/specs/2026-08-23-security-review-remediation-design.md b/docs/superpowers/specs/2026-08-23-security-review-remediation-design.md new file mode 100644 index 00000000..3f2a8e35 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-security-review-remediation-design.md @@ -0,0 +1,144 @@ +# Security Review Remediation Design + +**Date:** 2026-08-23 + +**Authority:** The eleven inline review comments supplied by the user in the Codex task. + +## Goal + +Close every reported production security gap without deploying, changing live Cloudflare state, editing historical D1 migrations, or weakening the fiscal environment boundary. The observable result is a reviewed branch whose focused regression tests, full unit/integration suite, build, migration immutability check, and private-boundary check pass. + +## Cross-cutting invariants + +- A real Wompi link must not be created until the active deployment can construct and sign a CDE and authenticate to the matching MH lane. +- Production must never run the shared external-service mock path. +- Donor-facing copy keeps the repository's voluntary-gift terminology and usted form. +- Existing migrations 0001 through 0044 remain byte-for-byte unchanged; new schema is appended. +- Security limits use atomic D1 admission, not read-then-write decisions in a Worker isolate. +- A conflicting provider event never inherits trust decisions from the incoming collision and never mutates the canonical event's intent or issuance state. +- Provider-controlled strings and bodies are sanitized at the integration boundary before any durable or logging sink can receive them. +- A positive backup-verification audit requires an exact current manifest and an independent D1 export anchor. +- Lower roles cannot use hidden identifiers as pre-projection filters for account audit history. +- HSTS is emitted on every production Worker response. The initial policy deliberately omits includeSubDomains and preload, so no unverified subdomain promise is made. +- No production deployment, WAF change, secret rotation, cleanup of user artifacts, push, or pull request is authorized by this implementation task. + +## Acceptance criteria by review comment + +### 1. Fiscal readiness before Wompi link creation + +For a non-mock create request, a shared readiness function must validate, before the first network call: + +- deployment APP_ENV maps to a permitted MH ambiente; +- EMISOR_CONFIG_JSON is valid; +- the matching MH auth, reception, and invalidation URLs are present and HTTPS; +- the matching MH user and password are present; +- MH_CERT_XML (or both parts) is parseable and active; +- MH_CERT_PASSWORD matches the certificate and the private key can be imported for RS512 signing. + +Invalid readiness leaves fetch uncalled and no real Wompi link is returned. + +### 2. Production mock mode fails closed + +- isMockMode (or an equivalent runtime gate used by every shared mock caller) throws for APP_ENV=production with MOCK_EXTERNAL_SERVICES=true. +- The checked-in example production block remains explicitly false. +- Private Wrangler target-manifest validation rejects a production manifest whose shared mock variable is anything except the literal string false. +- Tests prove the runtime and manifest rejection. + +Stripe's separately scoped STRIPE_MOCK_MODE remains governed by its existing policy; this finding concerns MOCK_EXTERNAL_SERVICES. + +### 3. Private-boundary coverage for PDF render artifacts + +- scripts/check-private-boundary.mjs inspects tmp/pdfs recursively, including ignored files and symlinks/non-directory replacements. +- A synthetic fixture containing tmp/pdfs/ fails without printing file contents. +- Git-ignore coverage for the local render tree stays aligned. +- The task does not delete any existing artifact. + +### 4. Account-targeted login protection under IP rotation + +- Account-wide recent login failures are counted independently of source IP. +- Crossing the account-wide threshold does not permanently or blindly lock the account. +- A correct password in the challenged state cannot create or return a session until a short-lived email verification code is completed. +- The code/challenge is one-time, expiry-bound, attempt-limited, stored only as hashes, and bound to the current user auth generation. +- Wrong credentials remain enumeration-safe. A disabled or unknown account does not receive a challenge. +- The client supports the verification-code step with actionable Spanish operator copy. +- Delivery failure creates no usable session. +- Existing per-IP and email-IP limits remain. + +This is a progressive step-up MFA control: ordinary logins below the aggregate-failure threshold retain their current one-step behavior. + +### 5. Conflicting Wompi event collisions + +- Event insertion returns an explicit inserted, equivalent replay, or conflict result. +- Comparison covers environment, result, amount, payment link, commerce intent identifier, and a canonicalized normalized payload/body. +- A legitimate equivalent payment-link replay may use Wompi's alternate transaction identifier only when every other security-relevant field is equivalent. +- Replay side effects use the canonical stored payload and environment, never the incoming collision. +- A conflict is audited with bounded non-sensitive metadata, returns a conflict response for the webhook, and does not mark an intent paid or queue issuance. +- Tests cover result, environment, amount, intent, and body conflicts plus the legitimate alternate-transaction replay. + +### 6. MH provider response sanitization + +- Non-2xx authentication errors and token-missing errors use bounded constant text and do not include the provider body or descripcionMsg. +- Before any MhResponse is returned, exact configured MH username, password, and bearer token values are replaced recursively in estado, observaciones, raw, and any text fallback. +- The redaction works when a secret is embedded inside a longer string. +- Tests prove none of the exact secrets can reach thrown error text or returned/persistable response fields. + +### 7. Backup completeness and authenticity + +- Version 2 manifests are parsed from unknown input with an exact schema. +- The table keys equal the canonical export set exactly; missing, extra, empty, malformed, wrong-month, wrong-run, wrong-key, invalid row-count, and invalid digest entries fail closed. +- Verification looks up the live D1 RETENTION_EXPORT_COMPLETED audit for the month and requires its canonical table map to match the R2 manifest before hashing objects. +- No anchor, malformed anchor, or mismatched anchor yields RETENTION_VERIFY_FAILED and never RETENTION_VERIFIED. +- Listing and download helpers do not label an invalid manifest as archived or use it to resolve an object. +- Newly produced completion evidence includes runId, generatedAt, and a canonical manifest digest while existing valid completion evidence can be matched by its exact table map. +- Tests include the formerly vacuous empty/partial cases and a forged manifest/body pair. + +### 8. Aggregate public provider-creation budgets and IPv6 normalization + +- A new append-only migration creates a provider-creation claim ledger with expiry and indexes. +- One atomic INSERT ... SELECT enforces a normalized-client budget, a Wompi-or-Stripe provider budget, and a global budget. +- IPv4 identities remain individual; valid IPv6 addresses are canonicalized and grouped by /64; malformed/missing values share an unknown bucket. +- Wompi and fresh Stripe checkout creation claim the correct provider budget before durable/provider work; Stripe idempotent replay does not consume a new claim. +- Existing releases' unattributed rows remain counted during the transition where needed. +- Expired and unused claims are cleaned/released safely. +- Tests show literal IPv6 rotation within one /64 cannot multiply the budget and different IPs/providers still meet the global/provider ceilings atomically. + +Cloudflare account-level WAF/Bot Management is outside this repository and is not mutated by this branch; the repository-owned global D1 ceiling is therefore the hard aggregate bound. The final handoff must not claim a live edge rule was verified. + +### 9. Account audit audience boundary + +- VIEWER and OPERATOR receive 403 for entityType=user scoped audit queries, regardless of entity ID. +- ADMIN and OWNER retain the scoped account-audit behavior. +- Contingency events omit created_by for VIEWER and OPERATOR. +- A chained regression test proves the lower role cannot obtain and reuse a stable account ID. + +### 10. HSTS + +- public/_headers includes Strict-Transport-Security: max-age=31536000. +- The Worker wraps every APP_ENV=production fetch response, including JSON, webhook, redirect, asset, 204, and error responses, with the same header. +- Non-production responses are not used as evidence for the production contract. +- includeSubDomains and preload are intentionally absent pending separate HTTPS inventory. + +### 11. Wompi checkout URL validation + +- Successful link JSON is parsed from unknown, not cast. +- idEnlace is a positive safe integer. +- urlEnlace and urlEnlaceLargo are absolute HTTPS URLs with no userinfo or fragment. +- The short URL uses only s.wompi.sv and its approved single-segment link path. +- The long URL uses only pagos.wompi.sv and an explicitly allowlisted hosted-payment path with the required identifier query. +- A host suffix, alternate port, encoded path confusion, credentials, HTTP, javascript/data URL, missing fields, and wrong types fail closed with WompiApiError. +- Invalid responses are never returned to or embedded for the donor. + +The current Wompi OpenAPI schema confirms the response fields but does not guarantee their host/path shapes; the allowlist is therefore based on the integration's known hosted-link shapes and is intentionally fail closed. + +## Verification standard + +Each implementation task starts with a failing behavior-level test and records the red/green commands. Guard tests must be mutation-proven. Final verification runs: + +```sh +MINIFLARE_CACHE_DIR= npm test +npm run build +npm run security:check-private-boundary +npm run migrations:check-immutability +``` + +Any test that needs localhost/Miniflare is run outside the filesystem/network sandbox after approval, as established by the clean baseline. From 316df2e84115012edf30fd0b9c2a658b995c7638 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:56:55 -0600 Subject: [PATCH 02/22] fix(worker): gate real Wompi links on fiscal readiness --- scripts/private-wrangler-config.mjs | 3 +- src/worker/config.ts | 6 +- src/worker/domain/signer.ts | 44 ++++++---- src/worker/services/environmentPolicy.ts | 24 ++++++ src/worker/services/wompiApi.ts | 67 ++++++++++++--- test/scripts/privateWranglerConfig.test.ts | 73 ++++++++++++++++ test/worker/config.test.ts | 4 + test/worker/signer.test.ts | 19 ++++- test/worker/wompiApi.test.ts | 99 +++++++++++++++++++++- 9 files changed, 309 insertions(+), 30 deletions(-) diff --git a/scripts/private-wrangler-config.mjs b/scripts/private-wrangler-config.mjs index 10017e17..33170984 100644 --- a/scripts/private-wrangler-config.mjs +++ b/scripts/private-wrangler-config.mjs @@ -205,7 +205,8 @@ export function assertPrivateWranglerTargetManifest(rawConfig, target, manifest) ownValue(producer, "queue") === manifest.resourceManifest.queueName && ownValue(mainConsumer, "dead_letter_queue") === manifest.resourceManifest.queueDlqName && Boolean(dlqConsumer) && - workersDev === manifest.resourceManifest.workersDev; + workersDev === manifest.resourceManifest.workersDev && + (target !== "production" || ownValue(vars, "MOCK_EXTERNAL_SERVICES") === "false"); if (!matches) { throw new Error("The selected private Wrangler config does not match the target resource manifest"); } diff --git a/src/worker/config.ts b/src/worker/config.ts index ce4b8347..44e0c82d 100644 --- a/src/worker/config.ts +++ b/src/worker/config.ts @@ -18,7 +18,11 @@ export function isMockMode(env: Env): boolean { // Explicit opt-in: external services are only mocked when MOCK_EXTERNAL_SERVICES // is exactly "true". Any other value — including unset — performs real calls, so // a forgotten flag fails safe toward production behavior rather than silent mocks. - return env.MOCK_EXTERNAL_SERVICES === "true"; + const enabled = env.MOCK_EXTERNAL_SERVICES === "true"; + if (enabled && env.APP_ENV?.trim().toLowerCase() === "production") { + throw new Error("MOCK_EXTERNAL_SERVICES no puede habilitarse en producción"); + } + return enabled; } export function getEmisorConfig(env: Env): EmisorConfig { diff --git a/src/worker/domain/signer.ts b/src/worker/domain/signer.ts index 0d7585fb..d801ddf1 100644 --- a/src/worker/domain/signer.ts +++ b/src/worker/domain/signer.ts @@ -9,29 +9,21 @@ export interface ParsedMhCertificate { } export async function signMhDocument(document: unknown, certXml: string, password: string): Promise { - const certificate = await parseMhCertificate(certXml); - if (!certificate.active) { - throw new Error("El certificado del Ministerio de Hacienda no está activo"); - } - const passwordHash = await sha512Hex(password); - if (passwordHash !== certificate.privateKeyPasswordHash.toLowerCase()) { - throw new Error("La contraseña de la llave privada del Ministerio de Hacienda no coincide"); - } + const key = await loadMhSigningKey(certXml, password); const header = base64UrlFromString(JSON.stringify({ alg: "RS512" })); const payload = base64UrlFromString(JSON.stringify(document, null, 2)); const signingInput = `${header}.${payload}`; - const key = await crypto.subtle.importKey( - "pkcs8", - base64ToBytes(certificate.privateKeyBase64), - { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, - false, - ["sign"] - ); const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, utf8Bytes(signingInput)); return `${signingInput}.${base64UrlFromBytes(new Uint8Array(signature))}`; } +export async function assertMhSigningMaterialReady(certXml: string, password: string): Promise { + const certificate = await parseMhCertificate(certXml); + await loadMhSigningKey(certXml, password, certificate); + return certificate; +} + export async function verifyMhJws(jws: string, certXml: string): Promise { const certificate = await parseMhCertificate(certXml); const [encodedHeader, encodedPayload, encodedSignature] = jws.split("."); @@ -113,6 +105,28 @@ async function sha512Hex(value: string): Promise { return hexFromBytes(new Uint8Array(digest)); } +async function loadMhSigningKey( + certXml: string, + password: string, + certificate?: ParsedMhCertificate +): Promise { + const parsedCertificate = certificate ?? await parseMhCertificate(certXml); + if (!parsedCertificate.active) { + throw new Error("El certificado del Ministerio de Hacienda no está activo"); + } + const passwordHash = await sha512Hex(password); + if (passwordHash !== parsedCertificate.privateKeyPasswordHash.toLowerCase()) { + throw new Error("La contraseña de la llave privada del Ministerio de Hacienda no coincide"); + } + return crypto.subtle.importKey( + "pkcs8", + base64ToBytes(parsedCertificate.privateKeyBase64), + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" }, + false, + ["sign"] + ); +} + function extractTag(xml: string, tag: string): string { const match = xml.match(new RegExp(`<${tag}>([\\s\\S]*?)`)); if (!match) { diff --git a/src/worker/services/environmentPolicy.ts b/src/worker/services/environmentPolicy.ts index 4f37eccd..6984ada3 100644 --- a/src/worker/services/environmentPolicy.ts +++ b/src/worker/services/environmentPolicy.ts @@ -1,4 +1,6 @@ import type { Ambiente, Env } from "../types"; +import { getEmisorConfig, getMhCertificateXml, mhEndpoint, requireSecret } from "../config"; +import { assertMhSigningMaterialReady } from "../domain/signer"; type DeploymentAppEnvironment = "local" | "staging" | "production" | "unknown"; @@ -61,3 +63,25 @@ export function assertDeploymentCanCollectPayments(env: Pick): A } return policy.allowedAmbiente; } + +export async function assertFiscalCollectionReady(env: Env): Promise { + const ambiente = assertDeploymentCanCollectPayments(env); + const emisor = getEmisorConfig(env); + const credentialLane = ambiente === "01" ? "PROD" : "TEST"; + requireSecret(env, `MH_USER_${credentialLane}` as keyof Env); + requireSecret(env, `MH_PASSWORD_${credentialLane}` as keyof Env); + mhEndpoint(env, "auth", ambiente); + mhEndpoint(env, "recepcion", ambiente); + const certificate = await assertMhSigningMaterialReady( + getMhCertificateXml(env), + requireSecret(env, "MH_CERT_PASSWORD") + ); + if (digits(emisor.numDocumento) !== digits(certificate.nit)) { + throw new Error("El NIT del certificado del Ministerio de Hacienda no coincide con el emisor configurado"); + } + return ambiente; +} + +function digits(value: string): string { + return value.replace(/\D/g, ""); +} diff --git a/src/worker/services/wompiApi.ts b/src/worker/services/wompiApi.ts index 25e1bba6..c8543779 100644 --- a/src/worker/services/wompiApi.ts +++ b/src/worker/services/wompiApi.ts @@ -1,9 +1,10 @@ import { CHECKOUT_WINDOW_MINUTES } from "../../shared/checkout"; -import { getEmisorConfig, isMockMode, requireSecret } from "../config"; +import { isMockMode, requireSecret } from "../config"; import { Repository } from "../storage/repository"; import type { DonationIntentRecord, Env, WompiPaymentLink } from "../types"; import { addHours, addMinutes, nowIso } from "../utils/dates"; import { loadWompiNotificationSettings } from "./wompiNotifications"; +import { assertFiscalCollectionReady } from "./environmentPolicy"; const TOKEN_URL = "https://id.wompi.sv/connect/token"; const ENLACE_PAGO_URL = "https://api.wompi.sv/EnlacePago"; @@ -38,12 +39,6 @@ interface WompiTokenResponse { token_type: string; } -interface WompiEnlacePagoResponse { - idEnlace: number; - urlEnlace: string; - urlEnlaceLargo: string; -} - export interface WompiPaymentLinkTransaction { idTransaccion: string | null; esAprobada: boolean; @@ -83,7 +78,7 @@ export class WompiApiService { // A real link can accept an irreversible entrega before the asynchronous CDE // pipeline runs. Validate the issuer now so configuration errors fail before // Wompi receives a link request rather than after the donor has completed it. - getEmisorConfig(this.env); + await assertFiscalCollectionReady(this.env); const start = nowIso(); const body = { @@ -109,8 +104,8 @@ export class WompiApiService { if (!response.ok) { throw new WompiApiError(`Wompi rechazó la creación del enlace de pago: ${response.status} ${await response.text()}`); } - const data = (await response.json()) as WompiEnlacePagoResponse; - return { idEnlace: data.idEnlace, urlEnlace: data.urlEnlace, urlEnlaceLargo: data.urlEnlaceLargo }; + const data: unknown = await response.json(); + return parseWompiPaymentLink(data); } async getPaymentLink(id: number): Promise { @@ -327,3 +322,55 @@ function mockLinkId(intentId: string): number { } return hash + 1; } + +function parseWompiPaymentLink(value: unknown): WompiPaymentLink { + if (!isRecord(value)) { + throw new WompiApiError("Wompi devolvió un enlace de pago inválido"); + } + const { idEnlace, urlEnlace, urlEnlaceLargo } = value; + if (typeof idEnlace !== "number" || !Number.isSafeInteger(idEnlace) || idEnlace <= 0 || typeof urlEnlace !== "string" || typeof urlEnlaceLargo !== "string") { + throw new WompiApiError("Wompi devolvió un enlace de pago inválido"); + } + if (!isWompiShortLink(urlEnlace, idEnlace) || !isWompiLongLink(urlEnlaceLargo)) { + throw new WompiApiError("Wompi devolvió URLs de enlace no permitidas"); + } + return { idEnlace, urlEnlace, urlEnlaceLargo }; +} + +function isWompiShortLink(value: string, idEnlace: number): boolean { + try { + const url = new URL(value); + return isApprovedWompiUrl(url, "s.wompi.sv") + && url.pathname === `/${idEnlace}` + && url.search === ""; + } catch { + return false; + } +} + +function isWompiLongLink(value: string): boolean { + try { + const url = new URL(value); + const parameters = [...url.searchParams.entries()]; + return isApprovedWompiUrl(url, "pagos.wompi.sv") + && (url.pathname === "/IntentoPago/Redirect" || url.pathname === "/L") + && parameters.length === 1 + && parameters[0]?.[0] === "id" + && parameters[0]?.[1].length > 0; + } catch { + return false; + } +} + +function isApprovedWompiUrl(url: URL, host: string): boolean { + return url.protocol === "https:" + && url.hostname === host + && url.port === "" + && url.username === "" + && url.password === "" + && url.hash === ""; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/test/scripts/privateWranglerConfig.test.ts b/test/scripts/privateWranglerConfig.test.ts index d30a1ceb..b2a98f5d 100644 --- a/test/scripts/privateWranglerConfig.test.ts +++ b/test/scripts/privateWranglerConfig.test.ts @@ -58,6 +58,28 @@ describe("private Wrangler configuration", () => { ).toThrow(/resource manifest/i); }); + it.each([ + ["enabled", "true"], + ["omitted", undefined] + ])("rejects a production target manifest with shared mock mode %s", (_label, mockExternalServices) => { + const rawConfig = productionTargetRawConfig(); + if (mockExternalServices === undefined) { + Reflect.deleteProperty(rawConfig.env.production.vars, "MOCK_EXTERNAL_SERVICES"); + } else { + rawConfig.env.production.vars.MOCK_EXTERNAL_SERVICES = mockExternalServices; + } + + expect(() => + assertPrivateWranglerTargetManifest(rawConfig, "production", productionTargetManifest()) + ).toThrow(/resource manifest/i); + }); + + it("accepts a production target manifest only when shared mock mode is the literal false string", () => { + expect(() => + assertPrivateWranglerTargetManifest(productionTargetRawConfig(), "production", productionTargetManifest()) + ).not.toThrow(); + }); + it.each([ ["Worker", (config: ReturnType) => { config.env.staging.name = "other-worker"; }], ["APP_ENV", (config: ReturnType) => { config.env.staging.vars.APP_ENV = "production"; }], @@ -517,6 +539,57 @@ function targetRawConfig() { }; } +function productionTargetManifest() { + return { + workerName: "diezmos-sv-production", + origin: "https://donar.example.invalid", + resourceManifest: { + accountId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + appEnv: "production" as const, + d1DatabaseName: "diezmos-sv-production-db", + d1DatabaseId: "44444444-4444-4444-4444-444444444444", + r2BucketName: "diezmos-sv-production-archive", + queueName: "diezmos-sv-production-issuance", + queueDlqName: "diezmos-sv-production-issuance-dlq", + workersDev: false + } + }; +} + +function productionTargetRawConfig() { + return { + account_id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + env: { + production: { + name: "diezmos-sv-production", + workers_dev: false, + vars: { + APP_ENV: "production", + APP_ORIGIN: "https://donar.example.invalid", + CLOUDFLARE_SCRIPT_NAME: "diezmos-sv-production", + MOCK_EXTERNAL_SERVICES: "false" + }, + d1_databases: [{ + binding: "DB", + database_name: "diezmos-sv-production-db", + database_id: "44444444-4444-4444-4444-444444444444" + }], + r2_buckets: [{ binding: "ARCHIVE", bucket_name: "diezmos-sv-production-archive" }], + queues: { + producers: [{ binding: "ISSUANCE_QUEUE", queue: "diezmos-sv-production-issuance" }], + consumers: [ + { + queue: "diezmos-sv-production-issuance", + dead_letter_queue: "diezmos-sv-production-issuance-dlq" + }, + { queue: "diezmos-sv-production-issuance-dlq" } + ] + } + } + } + }; +} + const isolatedValidationProgram = String.raw` import { existsSync, readdirSync } from "node:fs"; import { pathToFileURL } from "node:url"; diff --git a/test/worker/config.test.ts b/test/worker/config.test.ts index 1d74397b..7f8af126 100644 --- a/test/worker/config.test.ts +++ b/test/worker/config.test.ts @@ -15,6 +15,10 @@ describe("mock mode", () => { it("performs real external calls when MOCK_EXTERNAL_SERVICES is \"false\"", () => { expect(isMockMode(env({ MOCK_EXTERNAL_SERVICES: "false" }))).toBe(false); }); + + it("rejects shared mock mode in production", () => { + expect(() => isMockMode(env({ APP_ENV: "production", MOCK_EXTERNAL_SERVICES: "true" }))).toThrow(/mock/i); + }); }); describe("worker config", () => { diff --git a/test/worker/signer.test.ts b/test/worker/signer.test.ts index 73b9ac1f..8d83b488 100644 --- a/test/worker/signer.test.ts +++ b/test/worker/signer.test.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { certificateExpiry, parseMhCertificate, signMhDocument, verifyMhJws } from "../../src/worker/domain/signer"; +import { assertMhSigningMaterialReady, certificateExpiry, parseMhCertificate, signMhDocument, verifyMhJws } from "../../src/worker/domain/signer"; import { base64ToBytes, bytesToBase64, hexFromBytes, utf8Bytes } from "../../src/worker/utils/encoding"; // El certificado demo del firmador de MH vive en DTE/dte-firmador/, que está @@ -43,6 +43,18 @@ describe("native MH signer", () => { "La contraseña de la llave privada del Ministerio de Hacienda no coincide" ); }); + + it("validates active, password-matching, importable signing material without signing a document", async () => { + const password = "correct horse battery staple"; + + await expect(assertMhSigningMaterialReady(await generatedCertificateXml(password), password)).resolves.toMatchObject({ + nit: "12345678901234", + active: true + }); + await expect(assertMhSigningMaterialReady(await generatedCertificateXml(password, false), password)).rejects.toThrow(/no está activo/); + await expect(assertMhSigningMaterialReady(await generatedCertificateXml(password), "wrong-password")).rejects.toThrow(/no coincide/); + await expect(assertMhSigningMaterialReady(await unimportableCertificateXml(password), password)).rejects.toThrow(); + }); }); describe("certificate expiry", () => { @@ -102,3 +114,8 @@ async function generatedCertificateXml( const certificado = options.validity ? `${options.validity}` : ""; return `12345678901234${bytesToBase64(spki)}${bytesToBase64(pkcs8)}${passwordHash}${active ? "true" : "false"}${certificado}`; } + +async function unimportableCertificateXml(password: string): Promise { + const certificate = await generatedCertificateXml(password); + return certificate.replace(/[\s\S]*?<\/encodied>/, "not-a-pkcs8-key"); +} diff --git a/test/worker/wompiApi.test.ts b/test/worker/wompiApi.test.ts index 330d8fd6..7d109eeb 100644 --- a/test/worker/wompiApi.test.ts +++ b/test/worker/wompiApi.test.ts @@ -1,13 +1,20 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { WompiApiError, WompiApiService } from "../../src/worker/services/wompiApi"; import { CHECKOUT_WINDOW_MINUTES, WOMPI_INTERFAZ_MAX_MINUTES, WOMPI_INTERFAZ_MIN_MINUTES } from "../../src/shared/checkout"; import type { DonationIntentRecord, Env } from "../../src/worker/types"; +import { bytesToBase64, hexFromBytes, utf8Bytes } from "../../src/worker/utils/encoding"; afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); +let signingCertificateXml: string; + +beforeAll(async () => { + signingCertificateXml = await generatedCertificateXml("test-certificate-password"); +}); + // The cards-only forma de pago every create/deactivate body must carry. The // permitir/permite prefixes are intentionally inconsistent — they mirror the // Wompi swagger EnlaceFormaPago schema exactly and must not be "corrected". @@ -241,6 +248,70 @@ describe("Wompi API service", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it.each([ + ["missing", (env: Env) => { delete env.MH_CERT_XML; }], + ["mismatched", (env: Env) => { env.MH_CERT_XML = signingCertificateXml.replace("10000000000001", "99999999999999"); }], + ["inactive", (env: Env) => { env.MH_CERT_XML = signingCertificateXml.replace("true", "false"); }], + ["unimportable", (env: Env) => { env.MH_CERT_XML = signingCertificateXml.replace(/[\s\S]*?<\/encodied>/, "not-a-pkcs8-key"); }] + ])("fails closed before Wompi when MH signing material is %s", async (_label, makeInvalid) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const env = realEnv(); + makeInvalid(env); + + await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["credentials", (env: Env) => { delete env.MH_USER_TEST; }], + ["authentication endpoint", (env: Env) => { delete env.MH_AUTH_URL_TEST; }], + ["reception endpoint", (env: Env) => { delete env.MH_RECEPCION_URL_TEST; }] + ])("fails closed before Wompi when the MH TEST %s is missing", async (_label, makeInvalid) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const env = realEnv(); + makeInvalid(env); + + await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("uses the production MH credential lane before contacting Wompi in production", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const env = realEnv(); + env.APP_ENV = "production"; + env.MH_USER_PROD = "production-mh-user"; + env.MH_PASSWORD_PROD = "production-mh-password"; + env.MH_AUTH_URL_PROD = "https://api.dtes.mh.gob.sv/seguridad/auth"; + env.MH_RECEPCION_URL_PROD = "https://api.dtes.mh.gob.sv/fesv/recepciondte"; + delete env.MH_USER_PROD; + + await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(/MH_USER_PROD/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + ["a non-object response", []], + ["a non-positive link id", { idEnlace: 0, urlEnlace: "https://s.wompi.sv/1", urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1" }], + ["an unexpected short-link host", { idEnlace: 1, urlEnlace: "https://evil.example/1", urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1" }], + ["a short link with a mismatched id", { idEnlace: 1, urlEnlace: "https://s.wompi.sv/2", urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1" }], + ["a short link with a query", { idEnlace: 1, urlEnlace: "https://s.wompi.sv/1?next=evil", urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1" }], + ["a short link with userinfo", { idEnlace: 1, urlEnlace: "https://user@s.wompi.sv/1", urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1" }], + ["a short link with an alternate port", { idEnlace: 1, urlEnlace: "https://s.wompi.sv:444/1", urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1" }], + ["a long link with an unexpected query parameter", { idEnlace: 1, urlEnlace: "https://s.wompi.sv/1", urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1&next=evil" }], + ["a long link with a fragment", { idEnlace: 1, urlEnlace: "https://s.wompi.sv/1", urlEnlaceLargo: "https://pagos.wompi.sv/IntentoPago/Redirect?id=1#fragment" }] + ])("rejects %s from Wompi without returning a provider URL", async (_label, responseBody) => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ access_token: "wompi-access-token", expires_in: 3600, token_type: "Bearer" })) + .mockResolvedValueOnce(jsonResponse(responseBody)); + vi.stubGlobal("fetch", fetchMock); + + await expect(new WompiApiService(realEnv()).createPaymentLink(intent())).rejects.toBeInstanceOf(WompiApiError); + }); + it("throws a typed error with the response text on a non-2xx link response", async () => { const fetchMock = vi .fn() @@ -652,11 +723,18 @@ function realEnv(db: FakeD1 = new FakeD1()): Env { ISSUANCE_QUEUE: {} as Queue, ASSETS: {} as Fetcher, ARCHIVE: {} as R2Bucket, + APP_ENV: "local", MOCK_EXTERNAL_SERVICES: "false", APP_ORIGIN: "https://app.example.org", WOMPI_CLIENT_ID: "test-client-id", WOMPI_CLIENT_SECRET: "test-client-secret", - EMISOR_CONFIG_JSON: JSON.stringify(emisorConfig()) + EMISOR_CONFIG_JSON: JSON.stringify(emisorConfig()), + MH_CERT_XML: signingCertificateXml, + MH_CERT_PASSWORD: "test-certificate-password", + MH_USER_TEST: "test-mh-user", + MH_PASSWORD_TEST: "test-mh-password", + MH_AUTH_URL_TEST: "https://apitest.dtes.mh.gob.sv/seguridad/auth", + MH_RECEPCION_URL_TEST: "https://apitest.dtes.mh.gob.sv/fesv/recepciondte" }; } @@ -693,3 +771,20 @@ function emisorConfig() { function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); } + +async function generatedCertificateXml(password: string): Promise { + const pair = (await crypto.subtle.generateKey( + { + name: "RSASSA-PKCS1-v1_5", + modulusLength: 2048, + publicExponent: new Uint8Array([1, 0, 1]), + hash: "SHA-512" + }, + true, + ["sign", "verify"] + )) as CryptoKeyPair; + const pkcs8 = new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer); + const spki = new Uint8Array((await crypto.subtle.exportKey("spki", pair.publicKey)) as ArrayBuffer); + const passwordHash = hexFromBytes(new Uint8Array(await crypto.subtle.digest("SHA-512", utf8Bytes(password)))); + return `10000000000001${bytesToBase64(spki)}${bytesToBase64(pkcs8)}${passwordHash}true`; +} From 8238bdbc963c5dc10e5c6359f2cf2b907ca5ea3e Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:02:08 -0600 Subject: [PATCH 03/22] fix(worker): validate fiscal MH endpoint lanes --- src/worker/config.ts | 36 +++++++++++++++++++++++- src/worker/services/environmentPolicy.ts | 1 + test/worker/config.test.ts | 23 ++++++++++++++- test/worker/wompiApi.test.ts | 20 +++++++++++-- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/worker/config.ts b/src/worker/config.ts index 44e0c82d..b2043cb4 100644 --- a/src/worker/config.ts +++ b/src/worker/config.ts @@ -175,5 +175,39 @@ export function getMhCertificateXml(env: Env): string { export function mhEndpoint(env: Env, name: "auth" | "recepcion" | "anulacion", ambiente: "00" | "01"): string { const suffix = ambiente === "01" ? "PROD" : "TEST"; const key = `MH_${name.toUpperCase()}_URL_${suffix}` as keyof Env; - return requireSecret(env, key); + const value = requireSecret(env, key); + const expected = MH_ENDPOINTS[ambiente][name]; + if (!isExpectedMhEndpoint(value, expected)) { + throw new Error(`MH endpoint ${String(key)} debe ser ${expected}`); + } + return value; +} + +const MH_ENDPOINTS = { + "00": { + auth: "https://apitest.dtes.mh.gob.sv/seguridad/auth", + recepcion: "https://apitest.dtes.mh.gob.sv/fesv/recepciondte", + anulacion: "https://apitest.dtes.mh.gob.sv/fesv/anulardte" + }, + "01": { + auth: "https://api.dtes.mh.gob.sv/seguridad/auth", + recepcion: "https://api.dtes.mh.gob.sv/fesv/recepciondte", + anulacion: "https://api.dtes.mh.gob.sv/fesv/anulardte" + } +} as const; + +function isExpectedMhEndpoint(value: string, expected: string): boolean { + if (value !== value.trim()) return false; + try { + const url = new URL(value); + return url.protocol === "https:" + && url.username === "" + && url.password === "" + && url.port === "" + && url.search === "" + && url.hash === "" + && `${url.origin}${url.pathname}` === expected; + } catch { + return false; + } } diff --git a/src/worker/services/environmentPolicy.ts b/src/worker/services/environmentPolicy.ts index 6984ada3..e63654d6 100644 --- a/src/worker/services/environmentPolicy.ts +++ b/src/worker/services/environmentPolicy.ts @@ -72,6 +72,7 @@ export async function assertFiscalCollectionReady(env: Env): Promise { requireSecret(env, `MH_PASSWORD_${credentialLane}` as keyof Env); mhEndpoint(env, "auth", ambiente); mhEndpoint(env, "recepcion", ambiente); + mhEndpoint(env, "anulacion", ambiente); const certificate = await assertMhSigningMaterialReady( getMhCertificateXml(env), requireSecret(env, "MH_CERT_PASSWORD") diff --git a/test/worker/config.test.ts b/test/worker/config.test.ts index 7f8af126..c8a42434 100644 --- a/test/worker/config.test.ts +++ b/test/worker/config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getEmisorConfig, getMhCertificateXml, isMockMode } from "../../src/worker/config"; +import { getEmisorConfig, getMhCertificateXml, isMockMode, mhEndpoint } from "../../src/worker/config"; import type { Env } from "../../src/worker/types"; import { emisorConfig } from "./fixtures"; @@ -62,6 +62,27 @@ describe("worker config", () => { }); }); +describe("MH endpoints", () => { + it.each([ + ["auth", "00", "MH_AUTH_URL_TEST", "https://apitest.dtes.mh.gob.sv/seguridad/auth"], + ["recepcion", "00", "MH_RECEPCION_URL_TEST", "https://apitest.dtes.mh.gob.sv/fesv/recepciondte"], + ["anulacion", "00", "MH_ANULACION_URL_TEST", "https://apitest.dtes.mh.gob.sv/fesv/anulardte"], + ["auth", "01", "MH_AUTH_URL_PROD", "https://api.dtes.mh.gob.sv/seguridad/auth"], + ["recepcion", "01", "MH_RECEPCION_URL_PROD", "https://api.dtes.mh.gob.sv/fesv/recepciondte"], + ["anulacion", "01", "MH_ANULACION_URL_PROD", "https://api.dtes.mh.gob.sv/fesv/anulardte"] + ] as const)("accepts the %s endpoint for MH lane %s", (name, ambiente, key, endpoint) => { + expect(mhEndpoint(env({ [key]: endpoint }), name, ambiente)).toBe(endpoint); + }); + + it.each([ + ["HTTP", "auth", "00", "MH_AUTH_URL_TEST", "http://apitest.dtes.mh.gob.sv/seguridad/auth"], + ["production lane", "recepcion", "00", "MH_RECEPCION_URL_TEST", "https://api.dtes.mh.gob.sv/fesv/recepciondte"], + ["wrong service path", "anulacion", "01", "MH_ANULACION_URL_PROD", "https://api.dtes.mh.gob.sv/fesv/recepciondte"] + ] as const)("rejects an %s %s endpoint outside the requested lane", (_label, name, ambiente, key, endpoint) => { + expect(() => mhEndpoint(env({ [key]: endpoint }), name, ambiente)).toThrow(/MH endpoint/i); + }); +}); + function env(values: Partial): Env { return { DB: {} as D1Database, diff --git a/test/worker/wompiApi.test.ts b/test/worker/wompiApi.test.ts index 7d109eeb..a935dafd 100644 --- a/test/worker/wompiApi.test.ts +++ b/test/worker/wompiApi.test.ts @@ -266,7 +266,8 @@ describe("Wompi API service", () => { it.each([ ["credentials", (env: Env) => { delete env.MH_USER_TEST; }], ["authentication endpoint", (env: Env) => { delete env.MH_AUTH_URL_TEST; }], - ["reception endpoint", (env: Env) => { delete env.MH_RECEPCION_URL_TEST; }] + ["reception endpoint", (env: Env) => { delete env.MH_RECEPCION_URL_TEST; }], + ["invalidation endpoint", (env: Env) => { delete env.MH_ANULACION_URL_TEST; }] ])("fails closed before Wompi when the MH TEST %s is missing", async (_label, makeInvalid) => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -277,6 +278,20 @@ describe("Wompi API service", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it.each([ + ["an insecure authentication endpoint", (env: Env) => { env.MH_AUTH_URL_TEST = "http://apitest.dtes.mh.gob.sv/seguridad/auth"; }], + ["a cross-lane reception endpoint", (env: Env) => { env.MH_RECEPCION_URL_TEST = "https://api.dtes.mh.gob.sv/fesv/recepciondte"; }], + ["an invalidation endpoint for another MH service", (env: Env) => { env.MH_ANULACION_URL_TEST = "https://apitest.dtes.mh.gob.sv/fesv/recepciondte"; }] + ])("fails closed before Wompi when MH TEST has %s", async (_label, makeInvalid) => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const env = realEnv(); + makeInvalid(env); + + await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(/MH endpoint/i); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("uses the production MH credential lane before contacting Wompi in production", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -734,7 +749,8 @@ function realEnv(db: FakeD1 = new FakeD1()): Env { MH_USER_TEST: "test-mh-user", MH_PASSWORD_TEST: "test-mh-password", MH_AUTH_URL_TEST: "https://apitest.dtes.mh.gob.sv/seguridad/auth", - MH_RECEPCION_URL_TEST: "https://apitest.dtes.mh.gob.sv/fesv/recepciondte" + MH_RECEPCION_URL_TEST: "https://apitest.dtes.mh.gob.sv/fesv/recepciondte", + MH_ANULACION_URL_TEST: "https://apitest.dtes.mh.gob.sv/fesv/anulardte" }; } From 3598d56e2745a6d154ecc6e0d1268afd35738d03 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:06:22 -0600 Subject: [PATCH 04/22] fix: guard local pdf artifacts --- scripts/check-private-boundary.mjs | 1 + test/scripts/privateBoundary.test.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/check-private-boundary.mjs b/scripts/check-private-boundary.mjs index 1b9a0c2f..d1b436c0 100644 --- a/scripts/check-private-boundary.mjs +++ b/scripts/check-private-boundary.mjs @@ -48,6 +48,7 @@ collectTree("DTE/Credentials"); collectExact("WompiWebhookSample.json"); collectExact("node_modules/.cache/wrangler/wrangler-account.json"); collectExact("node_modules/.mf/cf.json"); +collectTree("tmp/pdfs"); collectMatchingTree("DTE", (name) => /\.(?:csv|xlsx|pdf)$/i.test(name) || /_OCR\.md$/i.test(name) || /_by_PaddleOCR.*\.md$/i.test(name)); collectMatchingTree("examples", (name) => /^DTE-.*\.(?:json|pdf)$/i.test(name)); diff --git a/test/scripts/privateBoundary.test.ts b/test/scripts/privateBoundary.test.ts index 7369081c..8334bdc6 100644 --- a/test/scripts/privateBoundary.test.ts +++ b/test/scripts/privateBoundary.test.ts @@ -39,7 +39,8 @@ describe("private artifact boundary checker", () => { "examples/archive/DTE-private.json", "examples/archive/deeper/DTE-private.pdf", "node_modules/.cache/wrangler/wrangler-account.json", - "node_modules/.mf/cf.json" + "node_modules/.mf/cf.json", + "tmp/pdfs/donor-render.pdf" ])("rejects %s without printing its contents", (path) => { const cwd = fixture({ [path]: sentinel }); const result = run(cwd); @@ -267,6 +268,7 @@ describe("private artifact boundary checker", () => { "examples/DTE-private.json", "examples/archive/DTE-private.json", "examples/archive/deeper/DTE-private.pdf", + "tmp/pdfs/donor-render.pdf", ".private-boundary-hosts" ])("keeps Git ignore coverage aligned at every depth: %s", (path) => { const cwd = fixture({ [path]: sentinel }); From 331eb76c5d6ad2d2461473ee98b5471d3215e477 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:34:10 -0600 Subject: [PATCH 05/22] fix: require step-up MFA after distributed login failures --- migrations/0045_login_step_up_mfa.sql | 34 ++ src/client/App.tsx | 79 +++- src/client/displayText.ts | 2 + src/client/loginMfa.tsx | 73 ++++ src/worker/index.ts | 86 +++- src/worker/services/auth.ts | 170 +++++++- src/worker/services/email.ts | 24 +- src/worker/services/emailHtml.ts | 19 + src/worker/storage/repository.ts | 45 ++ src/worker/storage/repository/identity.ts | 178 ++++++++ src/worker/storage/repository/rateLimits.ts | 19 + src/worker/types.ts | 18 + test/client/accountStateBoundary.test.ts | 4 +- test/client/displayText.test.ts | 2 + test/client/loginMfa.test.ts | 48 +++ test/worker/support/inMemoryD1.ts | 168 ++++++++ test/worker/workerFetch.auth-infra.test.ts | 442 +++++++++++++++++++- 17 files changed, 1373 insertions(+), 38 deletions(-) create mode 100644 migrations/0045_login_step_up_mfa.sql create mode 100644 src/client/loginMfa.tsx create mode 100644 test/client/loginMfa.test.ts diff --git a/migrations/0045_login_step_up_mfa.sql b/migrations/0045_login_step_up_mfa.sql new file mode 100644 index 00000000..cefeb4c4 --- /dev/null +++ b/migrations/0045_login_step_up_mfa.sql @@ -0,0 +1,34 @@ +-- A distributed password-guessing campaign must not force a blind account lockout. +-- After the account-wide failure threshold, valid credentials issue this short-lived +-- email challenge instead of a session. Raw continuation tokens and codes never land +-- in D1; the code hash is domain-separated and bound to the random continuation token. +CREATE TABLE login_step_up_challenges ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + continuation_token_hash TEXT NOT NULL UNIQUE CHECK ( + length(continuation_token_hash) = 64 + AND continuation_token_hash = lower(continuation_token_hash) + AND continuation_token_hash NOT GLOB '*[^0-9a-f]*' + ), + code_hash TEXT NOT NULL CHECK ( + length(code_hash) = 64 + AND code_hash = lower(code_hash) + AND code_hash NOT GLOB '*[^0-9a-f]*' + ), + expected_email TEXT NOT NULL, + expected_auth_generation INTEGER NOT NULL CHECK (expected_auth_generation >= 0), + expected_password_hash TEXT NOT NULL, + expected_password_salt TEXT NOT NULL, + expires_at TEXT NOT NULL, + failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts BETWEEN 0 AND 5), + consumed_at TEXT, + invalidated_at TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (consumed_at IS NULL OR invalidated_at IS NULL) +); + +CREATE INDEX idx_login_step_up_challenges_expires + ON login_step_up_challenges(expires_at); + +CREATE INDEX idx_login_step_up_challenges_user_expires + ON login_step_up_challenges(user_id, expires_at); diff --git a/src/client/App.tsx b/src/client/App.tsx index ed71b99a..2435528f 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -42,6 +42,7 @@ import { filterAuditEntries } from "./auditFilter"; import { createLatestRequestGate, filterPreCdeFailures } from "./preCdeFailures"; import { defaultInvalidationForm, invalidationFormValidationMessage, invalidationRequestBody, type InvalidationFormInput } from "./invalidationForm"; import { passwordResetConfirmValidationMessage } from "./passwordReset"; +import { LoginMfaStep, submitLoginMfa, type LoginMfaChallenge, type LoginSessionResult } from "./loginMfa"; import { isDonarGraciasPath, isDonarPath, isStripeResultPath } from "./donation"; import { DonarGraciasPage, DonarPage } from "./donarPage"; import { StripeResultPage } from "./stripeResultPage"; @@ -1261,8 +1262,7 @@ export function App({ initialResetToken = null }: { initialResetToken?: string | } } - async function login(email: string, password: string) { - const result = await api<{ user: User; token: string }>("/api/auth/login", "", { method: "POST", body: { email, password } }); + function establishSession(result: LoginSessionResult): void { resetAccountState(); localStorage.setItem("diezmos_token", result.token); localStorage.setItem("diezmos_user", JSON.stringify(result.user)); @@ -1271,6 +1271,27 @@ export function App({ initialResetToken = null }: { initialResetToken?: string | setAuthNotice(""); } + async function login(email: string, password: string): Promise { + const result = await api("/api/auth/login", "", { + method: "POST", + body: { email, password } + }); + if ("mfaRequired" in result) { + return result; + } + establishSession(result); + return null; + } + + async function completeLoginMfa(challenge: LoginMfaChallenge, code: string): Promise { + const result = await submitLoginMfa( + challenge, + code, + (path, options) => api(path, "", options) + ); + establishSession(result); + } + async function bootstrap(email: string, name: string, password: string, setupToken: string) { await api("/api/auth/bootstrap-owner", "", { method: "POST", @@ -2808,6 +2829,7 @@ export function App({ initialResetToken = null }: { initialResetToken?: string | notice={authNotice} branding={branding} onLogin={login} + onLoginMfa={completeLoginMfa} onBootstrap={bootstrap} onRequestReset={requestPasswordReset} onConfirmReset={confirmPasswordReset} @@ -3784,6 +3806,7 @@ function AuthScreen({ notice, branding, onLogin, + onLoginMfa, onBootstrap, onRequestReset, onConfirmReset, @@ -3792,19 +3815,23 @@ function AuthScreen({ initialResetToken: string | null; notice?: string; branding: Branding; - onLogin: (email: string, password: string) => Promise; + onLogin: (email: string, password: string) => Promise; + onLoginMfa: (challenge: LoginMfaChallenge, code: string) => Promise; onBootstrap: (email: string, name: string, password: string, setupToken: string) => Promise; onRequestReset: (email: string) => Promise; onConfirmReset: (token: string, password: string) => Promise; bootstrapAvailable: boolean; }) { const [resetToken] = useState(initialResetToken); - const [mode, setMode] = useState<"login" | "bootstrap" | "reset-request" | "reset-confirm">(resetToken ? "reset-confirm" : "login"); + const [mode, setMode] = useState<"login" | "login-mfa" | "bootstrap" | "reset-request" | "reset-confirm">(resetToken ? "reset-confirm" : "login"); const [email, setEmail] = useState(""); const [name, setName] = useState(""); const [password, setPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [setupToken, setSetupToken] = useState(""); + const [loginMfaChallenge, setLoginMfaChallenge] = useState(null); + const [loginMfaCode, setLoginMfaCode] = useState(""); + const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [localNotice, setLocalNotice] = useState(""); const authLogoSrc = brandingDonorLogoSrc(branding.donorLogoVersion) ?? brandingLogoSrc(branding.logoVersion); @@ -3821,6 +3848,8 @@ function AuthScreen({ setLocalNotice(""); setPassword(""); setConfirmPassword(""); + setLoginMfaChallenge(null); + setLoginMfaCode(""); } return ( @@ -3829,9 +3858,17 @@ function AuthScreen({ className="auth-card" onSubmit={async (event) => { event.preventDefault(); + if (busy) return; setError(""); + setBusy(true); try { - if (mode === "bootstrap") { + if (mode === "login-mfa") { + if (!loginMfaChallenge) { + switchMode("login"); + return; + } + await onLoginMfa(loginMfaChallenge, loginMfaCode); + } else if (mode === "bootstrap") { await onBootstrap(email, name, password, setupToken); } else if (mode === "reset-request") { await onRequestReset(email); @@ -3847,10 +3884,18 @@ function AuthScreen({ switchMode("login"); setLocalNotice("Contraseña actualizada. Inicie sesión con su nueva contraseña."); } else { - await onLogin(email, password); + const challenge = await onLogin(email, password); + if (challenge) { + setLoginMfaChallenge(challenge); + setLoginMfaCode(""); + setPassword(""); + setMode("login-mfa"); + } } } catch (err) { setError(userFacingErrorMessage(err instanceof Error ? err.message : String(err))); + } finally { + setBusy(false); } }} > @@ -3869,8 +3914,8 @@ function AuthScreen({ {mode === "reset-request" &&

Ingrese su correo y le enviaremos un enlace para restablecer la contraseña.

} {mode === "reset-confirm" &&

Cree su nueva contraseña para completar el restablecimiento.

} {mode === "bootstrap" && setName(event.target.value)} placeholder="Nombre" aria-label="Nombre" />} - {mode !== "reset-confirm" && setEmail(event.target.value)} placeholder="Correo" aria-label="Correo" type="email" />} - {mode !== "reset-request" && ( + {mode !== "reset-confirm" && mode !== "login-mfa" && setEmail(event.target.value)} placeholder="Correo" aria-label="Correo" type="email" />} + {mode !== "reset-request" && mode !== "login-mfa" && ( setPassword(event.target.value)} @@ -3897,12 +3942,17 @@ function AuthScreen({ type="password" /> )} + {mode === "login-mfa" && ( + + )} {(localNotice || notice) && !error &&

{localNotice || notice}

} {error &&

{error}

} - + {mode !== "login-mfa" && ( + + )} {mode === "login" && ( )} + {mode === "login-mfa" && ( + + )} ); diff --git a/src/client/displayText.ts b/src/client/displayText.ts index 6b4c5630..e6207b9e 100644 --- a/src/client/displayText.ts +++ b/src/client/displayText.ts @@ -105,6 +105,8 @@ const AUDIT_ACTION_LABELS: Record = { FISCAL_CORRECTION_STARTED: "Corrección fiscal iniciada", LOGIN: "Inicio de sesión", LOGIN_FAILED: "Inicio de sesión fallido", + LOGIN_MFA_CHALLENGE_ISSUED: "Código de inicio de sesión enviado", + LOGIN_MFA_EMAIL_FAILED: "Envío del código de inicio de sesión fallido", OWNER_BOOTSTRAPPED: "Propietario inicial creado", PASSWORD_RESET_COMPLETED: "Contraseña restablecida por enlace", PASSWORD_RESET_EMAIL_FAILED: "Correo de restablecimiento fallido", diff --git a/src/client/loginMfa.tsx b/src/client/loginMfa.tsx new file mode 100644 index 00000000..de35f92e --- /dev/null +++ b/src/client/loginMfa.tsx @@ -0,0 +1,73 @@ +import { KeyRound } from "lucide-react"; +import type { User } from "./types"; + +export interface LoginMfaChallenge { + mfaRequired: true; + challengeId: string; + continuationToken: string; + expiresAt: string; +} + +export interface LoginSessionResult { + user: User; + token: string; + expiresAt: string; +} + +interface LoginMfaRequestOptions { + method: "POST"; + body: { + challengeId: string; + continuationToken: string; + code: string; + }; +} + +export async function submitLoginMfa( + challenge: LoginMfaChallenge, + code: string, + request: (path: string, options: LoginMfaRequestOptions) => Promise +): Promise { + return request("/api/auth/login/mfa", { + method: "POST", + body: { + challengeId: challenge.challengeId, + continuationToken: challenge.continuationToken, + code: code.trim() + } + }); +} + +export function LoginMfaStep({ + code, + busy, + onCodeChange +}: { + code: string; + busy: boolean; + onCodeChange: (code: string) => void; +}) { + return ( + <> +

+ Ingrese el código de 6 dígitos que enviamos a su correo. Vence en 10 minutos. +

+ onCodeChange(event.target.value.replace(/\D/g, "").slice(0, 6))} + placeholder="Código de verificación" + aria-label="Código de verificación" + inputMode="numeric" + autoComplete="one-time-code" + pattern="[0-9]{6}" + maxLength={6} + required + autoFocus + /> + + + ); +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 794c31fd..1d82e917 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -3,7 +3,7 @@ import { buildAdvancedCdeDocument, buildDirectCdeDocument, buildInvalidacionEven import { certificateExpiry, signMhDocument } from "./domain/signer"; import { ambienteFromWompi, isApprovedDonation, normalizeWompiWebhook, verifyWompiHash, WompiPayloadError, wompiHashHeader, wompiWebhookFromPaymentLink } from "./domain/wompi"; import { ALERT_EMAIL_SETTING_KEY, normalizeAlertRecipients, sendOperationalAlert } from "./services/alerts"; -import { AuthError, AuthService, BootstrapUnavailableError, PASSWORD_RESET_TTL_MINUTES, PasswordPolicyError, PasswordResetError, requireRole, type AuthUser, type Role, UserNotFoundError } from "./services/auth"; +import { AuthError, AuthService, BootstrapUnavailableError, InvalidLoginStepUpChallengeError, LOGIN_STEP_UP_TTL_MINUTES, PASSWORD_RESET_TTL_MINUTES, PasswordPolicyError, PasswordResetError, requireRole, type AuthUser, type Role, UserNotFoundError } from "./services/auth"; import { CredentialWriterConfigError, StripeCredentialValidationError, @@ -639,6 +639,7 @@ async function handleScheduled(event: ScheduledEvent, env: Env): Promise { const now = nowIso(); await repo.deleteExpiredLoginRateLimits(now); await repo.deleteExpiredSecurityRateLimitClaims(now); + await repo.deleteExpiredLoginStepUpChallenges(now); if (env.STRIPE_MOCK_MODE === "1" || env.STRIPE_RESTRICTED_KEY?.trim()) { try { for (let processed = 0; processed < 25; processed += 1) { @@ -2116,9 +2117,53 @@ async function handleLogin(ctx: ApiRouteContext): Promise { // (email, caller IP) so only the abusing IP is throttled, not the victim. return jsonResponse({ error: "too_many_attempts", message: "Demasiados intentos. Espere 15 minutos e intente de nuevo." }, { status: 429 }); } + const accountFailures = await ctx.repo.countRecentAccountLoginFailures( + normalizedEmail, + authThrottleSinceIso() + ); let result; try { - result = await ctx.auth.login(body.email, body.password); + const credentials = await ctx.auth.verifyLoginCredentials(body.email, body.password); + if (accountFailures >= LOGIN_FAILED_LIMIT) { + const issued = await ctx.auth.issueLoginStepUpChallenge(credentials); + try { + const branding = await loadEmailBranding(ctx.repo, ctx.env); + await new EmailService(ctx.env, DEFAULT_EMAIL_TEMPLATES, branding).sendLoginStepUpCode( + issued.user.email, + issued.user.name, + issued.code, + LOGIN_STEP_UP_TTL_MINUTES + ); + } catch { + await ctx.auth.invalidateLoginStepUpChallenge( + issued.response.challengeId, + issued.response.continuationToken + ); + await ctx.repo.createAudit({ + action: "LOGIN_MFA_EMAIL_FAILED", + entityType: "user", + entityId: issued.user.id, + summary: "No se pudo enviar el código de verificación" + }); + return jsonResponse( + { + error: "login_mfa_unavailable", + message: "No se pudo enviar el código de verificación. Intente de nuevo en unos minutos." + }, + { status: 503 } + ); + } + await ctx.repo.createAudit({ + actorType: "USER", + actorId: issued.user.id, + action: "LOGIN_MFA_CHALLENGE_ISSUED", + entityType: "user", + entityId: issued.user.id, + summary: issued.user.email + }); + return jsonResponse(issued.response, { status: 202 }); + } + result = await ctx.auth.createSession(credentials); } catch (error) { await ctx.repo.createAudit({ action: "LOGIN_FAILED", entityType: "user", entityId: normalizedEmail, summary: error instanceof Error ? error.message : String(error) }); throw error; @@ -2127,6 +2172,42 @@ async function handleLogin(ctx: ApiRouteContext): Promise { return jsonResponse(result); } +async function handleLoginMfa(ctx: ApiRouteContext): Promise { + const rejected = rejectUnsafePublicJsonMutation(ctx.request, ctx.url); + if (rejected) return rejected; + const body = (await readJsonObject(ctx.request, { + limitBytes: PUBLIC_JSON_BODY_LIMIT_BYTES, + malformed: "throw" + })) as { challengeId?: unknown; continuationToken?: unknown; code?: unknown }; + try { + const result = await ctx.auth.completeLoginStepUpChallenge({ + challengeId: String(body.challengeId ?? ""), + continuationToken: String(body.continuationToken ?? ""), + code: String(body.code ?? "") + }); + await ctx.repo.createAudit({ + actorType: "USER", + actorId: result.user.id, + action: "LOGIN", + entityType: "user", + entityId: result.user.id, + summary: result.user.email + }); + return jsonResponse(result); + } catch (error) { + if (error instanceof InvalidLoginStepUpChallengeError) { + return jsonResponse( + { + error: "invalid_login_mfa_challenge", + message: error.message + }, + { status: 400 } + ); + } + throw error; + } +} + async function handleLogout(ctx: ApiRouteContext): Promise { await ctx.auth.logout(ctx.request); return new Response(null, { status: 204 }); @@ -3225,6 +3306,7 @@ const publicRoutes: Array> = [ const authRoutes: Array> = [ { method: "POST", pattern: "/api/auth/bootstrap-owner", handler: handleBootstrapOwner }, { method: "POST", pattern: "/api/auth/login", handler: handleLogin }, + { method: "POST", pattern: "/api/auth/login/mfa", handler: handleLoginMfa }, { method: "POST", pattern: "/api/auth/logout", handler: handleLogout }, { method: "POST", pattern: "/api/auth/password-reset/request", handler: handlePasswordResetRequest }, { method: "POST", pattern: "/api/auth/password-reset/confirm", handler: handlePasswordResetConfirm } diff --git a/src/worker/services/auth.ts b/src/worker/services/auth.ts index 21cd29de..617f31a9 100644 --- a/src/worker/services/auth.ts +++ b/src/worker/services/auth.ts @@ -1,6 +1,6 @@ import { Repository } from "../storage/repository"; -import type { Env } from "../types"; -import { addDays } from "../utils/dates"; +import type { Env, LoginSessionResponse, LoginStepUpChallengeResponse } from "../types"; +import { addDays, addMinutes } from "../utils/dates"; import { base64UrlFromBytes, hexFromBytes, sha256Hex as sha256HexBytes, timingSafeEqual, utf8Bytes } from "../utils/encoding"; import { passwordPolicyError } from "../../shared/passwordPolicy"; @@ -13,6 +13,21 @@ export interface AuthUser { role: Role; } +interface VerifiedLoginCredentials { + user: AuthUser; + userId: string; + expectedPasswordHash: string; + expectedPasswordSalt: string; + expectedEmail: string; + expectedAuthGeneration: number; +} + +export interface IssuedLoginStepUpChallenge { + response: LoginStepUpChallengeResponse; + code: string; + user: AuthUser; +} + const ROLE_RANK: Record = { VIEWER: 1, OPERATOR: 2, @@ -35,11 +50,17 @@ const DUMMY_PASSWORD_SALT = "diezmossv-login-dummy-v1"; const DUMMY_PASSWORD_RAW_HASH = "1368814a801077a2ccf4976bdedac3410ffb14c6c3193bbbdf203c6ae0c277db"; const DUMMY_PASSWORD_HASH = `${PASSWORD_HASH_CHAIN_SCHEME}$${PASSWORD_PBKDF2_ITERATIONS}$${DUMMY_PASSWORD_RAW_HASH}`; export const PASSWORD_RESET_TTL_MINUTES = 45; +export const LOGIN_STEP_UP_TTL_MINUTES = 10; +const LOGIN_STEP_UP_MAX_WRONG_ATTEMPTS = 5; +const LOGIN_STEP_UP_CODE_HASH_DOMAIN = "diezmossv-login-step-up-code-v1"; +const LOGIN_STEP_UP_CHALLENGE_ID_PATTERN = /^login_mfa_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const LOGIN_STEP_UP_CONTINUATION_PATTERN = /^[A-Za-z0-9_-]{43}$/; export class PasswordResetError extends Error {} export class PasswordPolicyError extends Error {} export class UserNotFoundError extends Error {} export class BootstrapUnavailableError extends Error {} +export class InvalidLoginStepUpChallengeError extends Error {} export class AuthService { private readonly repo: Repository; @@ -81,7 +102,11 @@ export class AuthService { } } - async login(email: string, password: string): Promise<{ user: AuthUser; token: string; expiresAt: string }> { + async login(email: string, password: string): Promise { + return this.createSession(await this.verifyLoginCredentials(email, password)); + } + + async verifyLoginCredentials(email: string, password: string): Promise { const row = await this.repo.getUserForLogin(email); if (!row || row.disabled_at) { await verifyPassword(password, DUMMY_PASSWORD_SALT, DUMMY_PASSWORD_HASH); @@ -113,21 +138,124 @@ export class AuthService { expectedPasswordHash = upgraded.hash; expectedPasswordSalt = upgraded.salt; } - const token = base64UrlFromBytes(crypto.getRandomValues(new Uint8Array(32))); - const expiresAt = addDays(new Date().toISOString(), 1); - const created = await this.repo.createSessionIfCredentialsCurrent({ + return { + user: publicUser(row), userId: row.id, expectedPasswordHash, expectedPasswordSalt, expectedEmail: row.email, - expectedAuthGeneration: Number(row.auth_generation ?? 0), + expectedAuthGeneration: Number(row.auth_generation ?? 0) + }; + } + + async createSession(credentials: VerifiedLoginCredentials): Promise { + const token = randomToken(); + const expiresAt = addDays(new Date().toISOString(), 1); + const created = await this.repo.createSessionIfCredentialsCurrent({ + userId: credentials.userId, + expectedPasswordHash: credentials.expectedPasswordHash, + expectedPasswordSalt: credentials.expectedPasswordSalt, + expectedEmail: credentials.expectedEmail, + expectedAuthGeneration: credentials.expectedAuthGeneration, tokenHash: await sha256HexBytes(utf8Bytes(token)), expiresAt }); if (!created) { throw invalidCredentialsError(); } - return { user: publicUser(row), token, expiresAt }; + return { user: credentials.user, token, expiresAt }; + } + + async issueLoginStepUpChallenge(credentials: VerifiedLoginCredentials): Promise { + const continuationToken = randomToken(); + const code = randomSixDigitCode(); + const expiresAt = addMinutes(new Date().toISOString(), LOGIN_STEP_UP_TTL_MINUTES); + const continuationTokenHash = await sha256HexBytes(utf8Bytes(continuationToken)); + const challengeId = await this.repo.createLoginStepUpChallenge({ + userId: credentials.userId, + expectedEmail: credentials.expectedEmail, + expectedAuthGeneration: credentials.expectedAuthGeneration, + expectedPasswordHash: credentials.expectedPasswordHash, + expectedPasswordSalt: credentials.expectedPasswordSalt, + continuationTokenHash, + codeHash: await loginStepUpCodeHash(continuationToken, code), + expiresAt + }); + if (!challengeId) { + throw invalidCredentialsError(); + } + return { + response: { + mfaRequired: true, + challengeId, + continuationToken, + expiresAt + }, + code, + user: credentials.user + }; + } + + async invalidateLoginStepUpChallenge(challengeId: string, continuationToken: string): Promise { + await this.repo.invalidateLoginStepUpChallenge( + challengeId, + await sha256HexBytes(utf8Bytes(continuationToken)), + new Date().toISOString() + ); + } + + async completeLoginStepUpChallenge(input: { + challengeId: string; + continuationToken: string; + code: string; + }): Promise { + const challengeId = input.challengeId.trim(); + const continuationToken = input.continuationToken.trim(); + if ( + !LOGIN_STEP_UP_CHALLENGE_ID_PATTERN.test(challengeId) + || !LOGIN_STEP_UP_CONTINUATION_PATTERN.test(continuationToken) + ) { + throw invalidLoginStepUpChallengeError(); + } + const now = new Date().toISOString(); + const continuationTokenHash = await sha256HexBytes(utf8Bytes(continuationToken)); + const codeHash = await loginStepUpCodeHash(continuationToken, input.code.trim()); + const consumed = await this.repo.consumeLoginStepUpChallenge({ + challengeId, + continuationTokenHash, + codeHash, + now, + maxWrongAttempts: LOGIN_STEP_UP_MAX_WRONG_ATTEMPTS + }); + if (!consumed) { + await this.repo.incrementLoginStepUpFailure({ + challengeId, + continuationTokenHash, + submittedCodeHash: codeHash, + now, + maxWrongAttempts: LOGIN_STEP_UP_MAX_WRONG_ATTEMPTS + }); + throw invalidLoginStepUpChallengeError(); + } + const row = await this.repo.getUserForLogin(consumed.expectedEmail); + if (!row || row.id !== consumed.userId || row.disabled_at) { + throw invalidLoginStepUpChallengeError(); + } + try { + return await this.createSession({ + user: publicUser(row), + userId: consumed.userId, + expectedEmail: consumed.expectedEmail, + expectedAuthGeneration: consumed.expectedAuthGeneration, + expectedPasswordHash: consumed.expectedPasswordHash, + expectedPasswordSalt: consumed.expectedPasswordSalt + }); + } catch (error) { + if (error instanceof AuthError) { + throw invalidLoginStepUpChallengeError(); + } + throw error; + } } async createPasswordResetToken(email: string): Promise<{ user: AuthUser; token: string; tokenId: string; expiresAt: string } | null> { @@ -207,6 +335,32 @@ function invalidCredentialsError(): AuthError { return new AuthError("Credenciales inválidas", 401); } +function invalidLoginStepUpChallengeError(): InvalidLoginStepUpChallengeError { + return new InvalidLoginStepUpChallengeError( + "El código no es válido o ya expiró. Inicie sesión nuevamente." + ); +} + +function randomToken(): string { + return base64UrlFromBytes(crypto.getRandomValues(new Uint8Array(32))); +} + +function randomSixDigitCode(): string { + const range = 1_000_000; + const unbiasedCeiling = Math.floor(0x1_0000_0000 / range) * range; + const random = new Uint32Array(1); + do { + crypto.getRandomValues(random); + } while (random[0] >= unbiasedCeiling); + return String(random[0] % range).padStart(6, "0"); +} + +async function loginStepUpCodeHash(continuationToken: string, code: string): Promise { + return sha256HexBytes( + utf8Bytes(`${LOGIN_STEP_UP_CODE_HASH_DOMAIN}\u0000${continuationToken}\u0000${code}`) + ); +} + export async function hashPassword( password: string, salt?: string, diff --git a/src/worker/services/email.ts b/src/worker/services/email.ts index 97a949b5..997806ad 100644 --- a/src/worker/services/email.ts +++ b/src/worker/services/email.ts @@ -2,7 +2,7 @@ import { isMockMode } from "../config"; import type { DteDocumentRecord, Env } from "../types"; import { bytesToBase64, sha256Hex, utf8Bytes } from "../utils/encoding"; import { isRecord } from "../utils/guards"; -import { dteEmailHtml, passwordResetEmailHtml } from "./emailHtml"; +import { dteEmailHtml, loginStepUpEmailHtml, passwordResetEmailHtml } from "./emailHtml"; import { resolveEmailReplyToAddress, resolveEmailSenderName } from "./emailSender"; import { assertSafeEmailSubject, DEFAULT_EMAIL_TEMPLATES, renderEmailTemplate, TRANSITORIO_RECEIPT_TEMPLATE, type EmailEvidenceType, type EmailTemplateSettings, type EmailTemplateValue } from "./emailTemplates"; import { DTE_PDF_RENDERER_VERSION, loadPdfBrandingLogo, renderDtePdf } from "./pdf"; @@ -398,6 +398,28 @@ export class EmailService { return this.dispatch(payload, []); } + async sendLoginStepUpCode(toEmail: string, name: string, code: string, expiresMinutes: number): Promise { + const branding = this.resolveBranding(); + const payload: EmailPayload = { + from: this.resolveFrom(), + to: toEmail, + subject: `Código de verificación - ${branding.organizationName}`, + text: + `Hola ${name},\n\n` + + `Para proteger su cuenta después de varios intentos fallidos, confirme este inicio de sesión con el código de un solo uso.\n\n` + + `Código de verificación: ${code}\n\n` + + `Vence en ${expiresMinutes} minutos. Si usted no intentó iniciar sesión, no comparta este código y puede ignorar este mensaje.`, + html: loginStepUpEmailHtml(name, code, expiresMinutes, { + organizationName: branding.organizationName, + brandColor: branding.brandColor, + supportEmail: branding.supportEmail, + logoUrl: branding.logoUrl + }), + attachments: [] + }; + return this.dispatch(payload, []); + } + async sendOperationalAlert( input: { to: string; subject: string; text: string; html: string }, beforeProviderDispatch?: () => void | Promise diff --git a/src/worker/services/emailHtml.ts b/src/worker/services/emailHtml.ts index 00f51f76..c84e79e4 100644 --- a/src/worker/services/emailHtml.ts +++ b/src/worker/services/emailHtml.ts @@ -112,6 +112,25 @@ export function passwordResetEmailHtml( ]); } +export function loginStepUpEmailHtml( + name: string, + code: string, + expiresMinutes: number, + options: BrandingEmailOptions = { organizationName: DEFAULT_ORGANIZATION_NAME } +): string { + const organizationName = options.organizationName || DEFAULT_ORGANIZATION_NAME; + const brandColor = options.brandColor ?? DEFAULT_BRAND_COLOR; + const codeBlock = ` +
${escapeHtml(code)}
`; + return emailDocument(organizationName, "Verificación de inicio de sesión", brandColor, options.supportEmail, options.logoUrl, [ + paragraphs( + `Hola ${name}:\n\nPara proteger su cuenta después de varios intentos fallidos, confirme este inicio de sesión con el código de un solo uso. Vence en ${expiresMinutes} minutos.` + ), + codeBlock, + footNote("Si usted no intentó iniciar sesión, no comparta este código y puede ignorar este mensaje.") + ]); +} + export interface OperationalAlertInput { kind: string; title: string; diff --git a/src/worker/storage/repository.ts b/src/worker/storage/repository.ts index 893a0a8f..877914ba 100644 --- a/src/worker/storage/repository.ts +++ b/src/worker/storage/repository.ts @@ -19,14 +19,19 @@ import { import { countUsers as countUsersRepository, createInitialOwner as createInitialOwnerRepository, + createLoginStepUpChallenge as createLoginStepUpChallengeRepository, createPasswordResetToken as createPasswordResetTokenRepository, createSessionIfCredentialsCurrent as createSessionIfCredentialsCurrentRepository, createUser as createUserRepository, + consumeLoginStepUpChallenge as consumeLoginStepUpChallengeRepository, + deleteExpiredLoginStepUpChallenges as deleteExpiredLoginStepUpChallengesRepository, getActivePasswordResetUser as getActivePasswordResetUserRepository, getSessionUser as getSessionUserRepository, getUserForLogin as getUserForLoginRepository, getUserRole as getUserRoleRepository, invalidatePasswordResetToken as invalidatePasswordResetTokenRepository, + invalidateLoginStepUpChallenge as invalidateLoginStepUpChallengeRepository, + incrementLoginStepUpFailure as incrementLoginStepUpFailureRepository, listUsers as listUsersRepository, resetPasswordWithToken as resetPasswordWithTokenRepository, revokeSession as revokeSessionRepository, @@ -41,6 +46,7 @@ import { claimStripeProviderRecoveryRead as claimStripeProviderRecoveryReadRepository, claimLoginAttempt as claimLoginAttemptRepository, claimPasswordResetBudgets as claimPasswordResetBudgetsRepository, + countRecentAccountLoginFailures as countRecentAccountLoginFailuresRepository, deleteExpiredLoginRateLimits as deleteExpiredLoginRateLimitsRepository, deleteExpiredSecurityRateLimitClaims as deleteExpiredSecurityRateLimitClaimsRepository, finalizeStripeProviderRecoveryRead as finalizeStripeProviderRecoveryReadRepository, @@ -1689,6 +1695,10 @@ export class Repository { ); } + async countRecentAccountLoginFailures(normalizedEmail: string, sinceIso: string): Promise { + return countRecentAccountLoginFailuresRepository(this.db, normalizedEmail, sinceIso); + } + async deleteExpiredLoginRateLimits(now: string): Promise { return deleteExpiredLoginRateLimitsRepository(this.db, now); } @@ -1709,6 +1719,41 @@ export class Repository { return createSessionIfCredentialsCurrentRepository(this.db, input); } + async createLoginStepUpChallenge( + input: Parameters[1] + ): Promise { + return createLoginStepUpChallengeRepository(this.db, input); + } + + async consumeLoginStepUpChallenge( + input: Parameters[1] + ): ReturnType { + return consumeLoginStepUpChallengeRepository(this.db, input); + } + + async incrementLoginStepUpFailure( + input: Parameters[1] + ): Promise { + return incrementLoginStepUpFailureRepository(this.db, input); + } + + async invalidateLoginStepUpChallenge( + challengeId: string, + continuationTokenHash: string, + invalidatedAt: string + ): Promise { + return invalidateLoginStepUpChallengeRepository( + this.db, + challengeId, + continuationTokenHash, + invalidatedAt + ); + } + + async deleteExpiredLoginStepUpChallenges(now: string): Promise { + return deleteExpiredLoginStepUpChallengesRepository(this.db, now); + } + async getSessionUser(tokenHash: string): Promise | null> { return getSessionUserRepository(this.db, tokenHash); } diff --git a/src/worker/storage/repository/identity.ts b/src/worker/storage/repository/identity.ts index dbd81399..b7e46918 100644 --- a/src/worker/storage/repository/identity.ts +++ b/src/worker/storage/repository/identity.ts @@ -19,6 +19,18 @@ export class UserMutationConflictError extends Error { } } +export interface LoginStepUpCredentialSnapshot { + userId: string; + expectedEmail: string; + expectedAuthGeneration: number; + expectedPasswordHash: string; + expectedPasswordSalt: string; +} + +export interface ConsumedLoginStepUpChallenge extends LoginStepUpCredentialSnapshot { + challengeId: string; +} + export async function getUserRole( db: D1Database, id: string @@ -336,6 +348,172 @@ export async function createSessionIfCredentialsCurrent( return Number(results[2]?.meta?.changes ?? 0) === 1; } +export async function createLoginStepUpChallenge( + db: D1Database, + input: LoginStepUpCredentialSnapshot & { + continuationTokenHash: string; + codeHash: string; + expiresAt: string; + } +): Promise { + const id = newId("login_mfa"); + const row = await db + .prepare( + `INSERT INTO login_step_up_challenges ( + id, user_id, continuation_token_hash, code_hash, + expected_email, expected_auth_generation, + expected_password_hash, expected_password_salt, expires_at + ) + SELECT ?, id, ?, ?, email, auth_generation, password_hash, password_salt, ? + FROM users + WHERE id = ? + AND disabled_at IS NULL + AND email = ? + AND auth_generation = ? + AND password_hash = ? + AND password_salt = ? + RETURNING id` + ) + .bind( + id, + input.continuationTokenHash, + input.codeHash, + input.expiresAt, + input.userId, + input.expectedEmail, + input.expectedAuthGeneration, + input.expectedPasswordHash, + input.expectedPasswordSalt + ) + .first<{ id: string }>(); + return row?.id ?? null; +} + +export async function consumeLoginStepUpChallenge( + db: D1Database, + input: { + challengeId: string; + continuationTokenHash: string; + codeHash: string; + now: string; + maxWrongAttempts: number; + } +): Promise { + const row = await db + .prepare( + `UPDATE login_step_up_challenges + SET consumed_at = ? + WHERE id = ? + AND continuation_token_hash = ? + AND code_hash = ? + AND consumed_at IS NULL + AND invalidated_at IS NULL + AND expires_at > ? + AND failed_attempts < ? + AND EXISTS ( + SELECT 1 FROM users + WHERE users.id = login_step_up_challenges.user_id + AND users.disabled_at IS NULL + AND users.email = login_step_up_challenges.expected_email + AND users.auth_generation = login_step_up_challenges.expected_auth_generation + AND users.password_hash = login_step_up_challenges.expected_password_hash + AND users.password_salt = login_step_up_challenges.expected_password_salt + ) + RETURNING id, user_id, expected_email, expected_auth_generation, + expected_password_hash, expected_password_salt` + ) + .bind( + input.now, + input.challengeId, + input.continuationTokenHash, + input.codeHash, + input.now, + input.maxWrongAttempts + ) + .first>(); + if (!row) return null; + return { + challengeId: String(row.id), + userId: String(row.user_id), + expectedEmail: String(row.expected_email), + expectedAuthGeneration: Number(row.expected_auth_generation), + expectedPasswordHash: String(row.expected_password_hash), + expectedPasswordSalt: String(row.expected_password_salt) + }; +} + +export async function incrementLoginStepUpFailure( + db: D1Database, + input: { + challengeId: string; + continuationTokenHash: string; + submittedCodeHash: string; + now: string; + maxWrongAttempts: number; + } +): Promise { + const row = await db + .prepare( + `UPDATE login_step_up_challenges + SET failed_attempts = failed_attempts + 1 + WHERE id = ? + AND continuation_token_hash = ? + AND code_hash <> ? + AND consumed_at IS NULL + AND invalidated_at IS NULL + AND expires_at > ? + AND failed_attempts < ? + AND EXISTS ( + SELECT 1 FROM users + WHERE users.id = login_step_up_challenges.user_id + AND users.disabled_at IS NULL + AND users.email = login_step_up_challenges.expected_email + AND users.auth_generation = login_step_up_challenges.expected_auth_generation + AND users.password_hash = login_step_up_challenges.expected_password_hash + AND users.password_salt = login_step_up_challenges.expected_password_salt + ) + RETURNING id` + ) + .bind( + input.challengeId, + input.continuationTokenHash, + input.submittedCodeHash, + input.now, + input.maxWrongAttempts + ) + .first<{ id: string }>(); + return Boolean(row); +} + +export async function invalidateLoginStepUpChallenge( + db: D1Database, + challengeId: string, + continuationTokenHash: string, + invalidatedAt: string +): Promise { + await db + .prepare( + `UPDATE login_step_up_challenges + SET invalidated_at = ? + WHERE id = ? + AND continuation_token_hash = ? + AND consumed_at IS NULL + AND invalidated_at IS NULL` + ) + .bind(invalidatedAt, challengeId, continuationTokenHash) + .run(); +} + +export async function deleteExpiredLoginStepUpChallenges( + db: D1Database, + now: string +): Promise { + await db + .prepare("DELETE FROM login_step_up_challenges WHERE expires_at <= ?") + .bind(now) + .run(); +} + export async function getSessionUser( db: D1Database, tokenHash: string diff --git a/src/worker/storage/repository/rateLimits.ts b/src/worker/storage/repository/rateLimits.ts index b05d68c4..97ce146a 100644 --- a/src/worker/storage/repository/rateLimits.ts +++ b/src/worker/storage/repository/rateLimits.ts @@ -319,6 +319,25 @@ export async function claimLoginAttempt( return row !== null; } +export async function countRecentAccountLoginFailures( + db: D1Database, + normalizedEmail: string, + sinceIso: string +): Promise { + const row = await db + .prepare( + `SELECT COUNT(*) AS count + FROM audit_logs + WHERE action = 'LOGIN_FAILED' + AND entity_type = 'user' + AND entity_id = ? + AND created_at >= ?` + ) + .bind(normalizedEmail, sinceIso) + .first<{ count: number }>(); + return Number(row?.count ?? 0); +} + export async function deleteExpiredLoginRateLimits( db: D1Database, now: string diff --git a/src/worker/types.ts b/src/worker/types.ts index 7561dc1f..c670c84e 100644 --- a/src/worker/types.ts +++ b/src/worker/types.ts @@ -7,6 +7,24 @@ export type { FiscalCorrectionStatus } from "../shared/fiscalCorrection"; export type Ambiente = "00" | "01"; +export interface LoginStepUpChallengeResponse { + mfaRequired: true; + challengeId: string; + continuationToken: string; + expiresAt: string; +} + +export interface LoginSessionResponse { + user: { + id: string; + email: string; + name: string; + role: "VIEWER" | "OPERATOR" | "ADMIN" | "OWNER"; + }; + token: string; + expiresAt: string; +} + // Wrangler owns configured binding/runtime types. Environment-dependent bindings // stay partial here so fail-closed paths can still model missing or invalid config; // dashboard-only secrets that Wrangler cannot infer are added explicitly below. diff --git a/test/client/accountStateBoundary.test.ts b/test/client/accountStateBoundary.test.ts index fab60b4d..cce018e6 100644 --- a/test/client/accountStateBoundary.test.ts +++ b/test/client/accountStateBoundary.test.ts @@ -7,7 +7,9 @@ const appSource = readFileSync(resolve(import.meta.dirname, "../../src/client/Ap describe("authenticated account state boundary", () => { it("uses one complete account-state reset on login, logout, and session expiry", () => { expect(appSource).toContain("function resetAccountState()"); - expect(appSource).toMatch(/async function login[\s\S]*?resetAccountState\(\);[\s\S]*?setToken\(result\.token\)/); + expect(appSource).toMatch(/function establishSession[\s\S]*?resetAccountState\(\);[\s\S]*?setToken\(result\.token\)/); + expect(appSource).toMatch(/async function login[\s\S]*?establishSession\(result\)/); + expect(appSource).toMatch(/async function completeLoginMfa[\s\S]*?establishSession\(result\)/); expect(appSource).toMatch(/async function logout[\s\S]*?resetAccountState\(\);[\s\S]*?setToken\(""\)/); expect(appSource).toMatch(/function expireSession[\s\S]*?resetAccountState\(\);[\s\S]*?setToken\(""\)/); diff --git a/test/client/displayText.test.ts b/test/client/displayText.test.ts index 6a93795e..d8491e11 100644 --- a/test/client/displayText.test.ts +++ b/test/client/displayText.test.ts @@ -55,6 +55,8 @@ describe("client display text", () => { it("localizes audit action codes and common backend errors", () => { expect(auditActionLabel("USER_PASSWORD_RESET")).toBe("Contraseña restablecida"); expect(auditActionLabel("LOGIN_FAILED")).toBe("Inicio de sesión fallido"); + expect(auditActionLabel("LOGIN_MFA_CHALLENGE_ISSUED")).toBe("Código de inicio de sesión enviado"); + expect(auditActionLabel("LOGIN_MFA_EMAIL_FAILED")).toBe("Envío del código de inicio de sesión fallido"); expect(auditActionLabel("PASSWORD_RESET_THROTTLED")).toBe("Restablecimiento limitado por intentos"); expect(auditActionLabel("DTE_INVALIDATION_REJECTED")).toBe("Invalidación rechazada"); expect(auditActionLabel("QUICK_CDE_CREATED")).toBe("CDE rápido creado"); diff --git a/test/client/loginMfa.test.ts b/test/client/loginMfa.test.ts new file mode 100644 index 00000000..e40c475a --- /dev/null +++ b/test/client/loginMfa.test.ts @@ -0,0 +1,48 @@ +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +describe("login step-up verification", () => { + it("renders actionable Spanish copy and submits the one-time code without the password", async () => { + const loginMfa = await import("../../src/client/loginMfa").catch(() => null); + expect(loginMfa, "the login MFA UI module must exist").not.toBeNull(); + if (!loginMfa) return; + + const html = renderToStaticMarkup(createElement(loginMfa.LoginMfaStep, { + code: "123456", + busy: false, + onCodeChange: vi.fn() + })); + expect(html).toContain("Código de verificación"); + expect(html).toContain("Ingrese el código de 6 dígitos que enviamos a su correo"); + expect(html).toContain("Vence en 10 minutos"); + expect(html).toContain("Verificar código"); + expect(html).toContain('inputMode="numeric"'); + + const request = vi.fn(async () => ({ + user: { id: "user_operator", email: "operator@example.org", name: "Operator", role: "OPERATOR" as const }, + token: "session-token", + expiresAt: "2026-07-05T12:00:00.000Z" + })); + const result = await loginMfa.submitLoginMfa( + { + mfaRequired: true, + challengeId: "challenge-id", + continuationToken: "continuation-token", + expiresAt: "2026-07-04T12:10:00.000Z" + }, + " 123456 ", + request + ); + + expect(request).toHaveBeenCalledWith("/api/auth/login/mfa", { + method: "POST", + body: { + challengeId: "challenge-id", + continuationToken: "continuation-token", + code: "123456" + } + }); + expect(result).toMatchObject({ token: "session-token", user: { id: "user_operator" } }); + }); +}); diff --git a/test/worker/support/inMemoryD1.ts b/test/worker/support/inMemoryD1.ts index 277afefc..f5bbcab2 100644 --- a/test/worker/support/inMemoryD1.ts +++ b/test/worker/support/inMemoryD1.ts @@ -131,6 +131,22 @@ export interface SecurityRateLimitClaimRow { expires_at: string; } +export interface LoginStepUpChallengeRow { + id: string; + user_id: string; + continuation_token_hash: string; + code_hash: string; + expected_email: string; + expected_auth_generation: number; + expected_password_hash: string; + expected_password_salt: string; + expires_at: string; + failed_attempts: number; + consumed_at: string | null; + invalidated_at: string | null; + created_at: string; +} + export function withWompiIssuanceDefaults( event: Record | undefined ): Record | undefined { @@ -180,6 +196,7 @@ export class InMemoryD1 { readonly audits: Array> = []; readonly loginRateLimits = new Map(); readonly securityRateLimitClaims: SecurityRateLimitClaimRow[] = []; + readonly loginStepUpChallenges: LoginStepUpChallengeRow[] = []; readonly documents: DteDocumentRecord[] = []; readonly preparedSql: string[] = []; readonly sequencePrefixes: string[] = []; @@ -431,6 +448,115 @@ export class Statement { } async first(): Promise { + if ( + this.sql.includes("INSERT INTO login_step_up_challenges") && + this.sql.includes("RETURNING id") + ) { + const [ + id, + continuationTokenHash, + codeHash, + expiresAt, + userId, + expectedEmail, + expectedAuthGeneration, + expectedPasswordHash, + expectedPasswordSalt + ] = this.args; + const user = this.db.users.find( + (row) => + row.id === userId && + !row.disabled_at && + row.email === expectedEmail && + Number(row.auth_generation ?? 0) === Number(expectedAuthGeneration) && + row.password_hash === expectedPasswordHash && + row.password_salt === expectedPasswordSalt + ); + if (!user || this.db.loginStepUpChallenges.some((row) => row.continuation_token_hash === continuationTokenHash)) { + return null; + } + this.db.loginStepUpChallenges.push({ + id: String(id), + user_id: String(userId), + continuation_token_hash: String(continuationTokenHash), + code_hash: String(codeHash), + expected_email: String(expectedEmail), + expected_auth_generation: Number(expectedAuthGeneration), + expected_password_hash: String(expectedPasswordHash), + expected_password_salt: String(expectedPasswordSalt), + expires_at: String(expiresAt), + failed_attempts: 0, + consumed_at: null, + invalidated_at: null, + created_at: new Date().toISOString() + }); + return { id } as T; + } + if ( + this.sql.includes("UPDATE login_step_up_challenges") && + this.sql.includes("SET consumed_at = ?") && + this.sql.includes("RETURNING id, user_id") + ) { + const [consumedAt, challengeId, continuationTokenHash, codeHash, now, maxWrongAttempts] = this.args; + const challenge = this.db.loginStepUpChallenges.find( + (row) => + row.id === challengeId && + row.continuation_token_hash === continuationTokenHash && + row.code_hash === codeHash && + row.consumed_at === null && + row.invalidated_at === null && + row.expires_at > String(now) && + row.failed_attempts < Number(maxWrongAttempts) + ); + const user = challenge && this.db.users.find( + (row) => + row.id === challenge.user_id && + !row.disabled_at && + row.email === challenge.expected_email && + Number(row.auth_generation ?? 0) === challenge.expected_auth_generation && + row.password_hash === challenge.expected_password_hash && + row.password_salt === challenge.expected_password_salt + ); + if (!challenge || !user) return null; + challenge.consumed_at = String(consumedAt); + return { + id: challenge.id, + user_id: challenge.user_id, + expected_email: challenge.expected_email, + expected_auth_generation: challenge.expected_auth_generation, + expected_password_hash: challenge.expected_password_hash, + expected_password_salt: challenge.expected_password_salt + } as T; + } + if ( + this.sql.includes("UPDATE login_step_up_challenges") && + this.sql.includes("SET failed_attempts = failed_attempts + 1") && + this.sql.includes("RETURNING id") + ) { + const [challengeId, continuationTokenHash, submittedCodeHash, now, maxWrongAttempts] = this.args; + const challenge = this.db.loginStepUpChallenges.find( + (row) => + row.id === challengeId && + row.continuation_token_hash === continuationTokenHash && + row.code_hash !== submittedCodeHash && + row.consumed_at === null && + row.invalidated_at === null && + row.expires_at > String(now) && + row.failed_attempts < Number(maxWrongAttempts) + ); + const user = challenge && this.db.users.find( + (row) => + row.id === challenge.user_id && + !row.disabled_at && + row.email === challenge.expected_email && + Number(row.auth_generation ?? 0) === challenge.expected_auth_generation && + row.password_hash === challenge.expected_password_hash && + row.password_salt === challenge.expected_password_salt + ); + if (!challenge || !user) return null; + challenge.failed_attempts += 1; + return { id: challenge.id } as T; + } if ( this.sql.includes("MAX(generation)") && this.sql.includes("FROM stripe_retention_generations") @@ -1672,6 +1798,22 @@ export class Statement { ).length } as T; } + if ( + this.sql.includes("SELECT COUNT(*) AS count") && + this.sql.includes("action = 'LOGIN_FAILED'") && + this.sql.includes("entity_type = 'user'") + ) { + const [entityId, sinceIso] = this.args.map(String); + return { + count: this.db.audits.filter( + (audit) => + audit.action === "LOGIN_FAILED" && + audit.entity_type === "user" && + audit.entity_id === entityId && + String(audit.created_at) >= sinceIso + ).length + } as T; + } if ( this.sql.includes("SELECT COUNT(*) AS count") && this.sql.includes("episode_member.key = 'stalledRequeueEpochAt'") @@ -2874,6 +3016,32 @@ export class Statement { } } } + if (this.sql.includes("DELETE FROM login_step_up_challenges")) { + const [now] = this.args.map(String); + for (let index = this.db.loginStepUpChallenges.length - 1; index >= 0; index -= 1) { + if (this.db.loginStepUpChallenges[index].expires_at <= now) { + this.db.loginStepUpChallenges.splice(index, 1); + changes += 1; + } + } + } + if ( + this.sql.includes("UPDATE login_step_up_challenges") && + this.sql.includes("SET invalidated_at = ?") + ) { + const [invalidatedAt, challengeId, continuationTokenHash] = this.args.map(String); + const challenge = this.db.loginStepUpChallenges.find( + (row) => + row.id === challengeId && + row.continuation_token_hash === continuationTokenHash && + row.consumed_at === null && + row.invalidated_at === null + ); + if (challenge) { + challenge.invalidated_at = invalidatedAt; + changes = 1; + } + } if (this.sql.includes("INSERT INTO users")) { const [id, email, name, role, passwordHash, passwordSalt] = this.args.map(String); this.db.users.push({ diff --git a/test/worker/workerFetch.auth-infra.test.ts b/test/worker/workerFetch.auth-infra.test.ts index 7bf856c6..9bc57599 100644 --- a/test/worker/workerFetch.auth-infra.test.ts +++ b/test/worker/workerFetch.auth-infra.test.ts @@ -36,6 +36,143 @@ describe("public deployment identity", () => { }); }); +describe("login step-up migration", () => { + it("stores only bounded challenge hashes and exposes an expiry cleanup index", () => { + const database = migratedDatabase(); + try { + const table = database + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'login_step_up_challenges'") + .get() as { sql: string } | undefined; + expect(table, "migration 0045 must create the challenge table").toBeDefined(); + if (!table) return; + + const indexes = database + .prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'login_step_up_challenges'") + .all() + .map((row) => String((row as { name: string }).name)); + expect(indexes).toContain("idx_login_step_up_challenges_expires"); + + const validHash = "a".repeat(64); + database.prepare( + `INSERT INTO login_step_up_challenges ( + id, user_id, continuation_token_hash, code_hash, + expected_email, expected_auth_generation, + expected_password_hash, expected_password_salt, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "challenge_valid", + "user_operator", + validHash, + "b".repeat(64), + "operator@example.org", + 0, + "hash", + "salt", + "2026-07-04T12:10:00.000Z" + ); + expect( + database.prepare( + "SELECT continuation_token_hash, code_hash, failed_attempts, consumed_at, invalidated_at FROM login_step_up_challenges" + ).get() + ).toEqual({ + continuation_token_hash: validHash, + code_hash: "b".repeat(64), + failed_attempts: 0, + consumed_at: null, + invalidated_at: null + }); + expect(() => database.prepare( + `INSERT INTO login_step_up_challenges ( + id, user_id, continuation_token_hash, code_hash, + expected_email, expected_auth_generation, + expected_password_hash, expected_password_salt, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "challenge_plaintext", + "user_operator", + "continuation-token", + "123456", + "operator@example.org", + 0, + "hash", + "salt", + "2026-07-04T12:10:00.000Z" + )).toThrow(/CHECK constraint failed/); + } finally { + database.close(); + } + }); + + it("atomically consumes a valid SQLite challenge once and exhausts five wrong attempts", async () => { + const database = migratedDatabase(); + try { + const repo = new Repository(sqliteD1(database)); + const snapshot = { + userId: "user_operator", + expectedEmail: "operator@example.org", + expectedAuthGeneration: 0, + expectedPasswordHash: "hash", + expectedPasswordSalt: "salt" + }; + const firstId = await repo.createLoginStepUpChallenge({ + ...snapshot, + continuationTokenHash: "a".repeat(64), + codeHash: "b".repeat(64), + expiresAt: "2026-07-04T12:10:00.000Z" + }); + expect(firstId).toMatch(/^login_mfa_/); + + const consumed = await repo.consumeLoginStepUpChallenge({ + challengeId: firstId!, + continuationTokenHash: "a".repeat(64), + codeHash: "b".repeat(64), + now: "2026-07-04T12:00:00.000Z", + maxWrongAttempts: 5 + }); + expect(consumed).toMatchObject(snapshot); + await expect(repo.consumeLoginStepUpChallenge({ + challengeId: firstId!, + continuationTokenHash: "a".repeat(64), + codeHash: "b".repeat(64), + now: "2026-07-04T12:00:00.000Z", + maxWrongAttempts: 5 + })).resolves.toBeNull(); + + const exhaustedId = await repo.createLoginStepUpChallenge({ + ...snapshot, + continuationTokenHash: "c".repeat(64), + codeHash: "d".repeat(64), + expiresAt: "2026-07-04T12:10:00.000Z" + }); + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(repo.incrementLoginStepUpFailure({ + challengeId: exhaustedId!, + continuationTokenHash: "c".repeat(64), + submittedCodeHash: "e".repeat(64), + now: "2026-07-04T12:00:00.000Z", + maxWrongAttempts: 5 + })).resolves.toBe(true); + } + await expect(repo.incrementLoginStepUpFailure({ + challengeId: exhaustedId!, + continuationTokenHash: "c".repeat(64), + submittedCodeHash: "e".repeat(64), + now: "2026-07-04T12:00:00.000Z", + maxWrongAttempts: 5 + })).resolves.toBe(false); + await expect(repo.consumeLoginStepUpChallenge({ + challengeId: exhaustedId!, + continuationTokenHash: "c".repeat(64), + codeHash: "d".repeat(64), + now: "2026-07-04T12:00:00.000Z", + maxWrongAttempts: 5 + })).resolves.toBeNull(); + } finally { + database.close(); + } + }); +}); + describe("request body limits", () => { it("rejects an oversized login body before authentication or throttling", async () => { const db = new InMemoryD1(); @@ -436,6 +573,71 @@ describe("auth rate limiting", () => { }); } + function loginRequestFrom(email: string, password: string, ip: string) { + return new Request("https://example.org/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json", "CF-Connecting-IP": ip }, + body: JSON.stringify({ email, password }) + }); + } + + function loginMfaRequest(input: { challengeId: string; continuationToken: string; code: string }) { + return new Request("https://example.org/api/auth/login/mfa", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input) + }); + } + + function seedDistributedFailures(db: InMemoryD1, email: string, count = 5): void { + for (let index = 0; index < count; index += 1) { + seedAudit( + db, + "LOGIN_FAILED", + email, + `2026-07-04T11:${50 + index}:00.000Z`, + `203.0.113.${index + 10}` + ); + } + } + + async function seededStepUp(input: { + db: InMemoryD1; + email?: string; + password?: string; + send?: (message: unknown) => Promise<{ messageId: string }>; + }) { + const email = input.email ?? "operator@example.org"; + const password = input.password ?? "Valid#Pass2026"; + const hashed = await hashPassword(password, "fixed-salt", { enforcePolicy: false }); + input.db.users.push({ + id: "user_operator", + email, + name: "Operator", + role: "OPERATOR", + password_hash: hashed.hash, + password_salt: hashed.salt, + auth_generation: 0, + disabled_at: "" + }); + seedDistributedFailures(input.db, email); + const sentMessages: unknown[] = []; + const response = await worker.fetch( + loginRequestFrom(email, password, "198.51.100.90"), + env(input.db, { + MOCK_EXTERNAL_SERVICES: "false", + EMAIL_FROM: "security@example.org", + EMAIL: { + send: async (message: unknown) => { + sentMessages.push(message); + return input.send ? input.send(message) : { messageId: "login-step-up-code" }; + } + } as SendEmail + }) + ); + return { response, sentMessages }; + } + describe("aggregate login attempts", () => { it("blocks the sixty-first login attempt from one IP across distinct account names", async () => { const db = new InMemoryD1(); @@ -713,6 +915,21 @@ describe("auth rate limiting", () => { claimed_at: "2026-07-04T11:00:00.000Z", expires_at: "2026-07-04T11:15:00.000Z" }); + db.loginStepUpChallenges.push({ + id: "login_mfa_expired", + user_id: "user_expired", + continuation_token_hash: "a".repeat(64), + code_hash: "b".repeat(64), + expected_email: "expired@example.org", + expected_auth_generation: 0, + expected_password_hash: "hash", + expected_password_salt: "salt", + expires_at: "2026-07-04T11:15:00.000Z", + failed_attempts: 0, + consumed_at: null, + invalidated_at: null, + created_at: "2026-07-04T11:00:00.000Z" + }); const otherIp = await worker.fetch( new Request("https://example.org/api/auth/login", { @@ -734,6 +951,7 @@ describe("auth rate limiting", () => { expect(db.loginRateLimits.has("expired-hash")).toBe(false); expect(db.loginRateLimits.size).toBe(1); expect(db.securityRateLimitClaims).toHaveLength(0); + expect(db.loginStepUpChallenges).toHaveLength(0); }); it("blocks the sixty-first login attempt in the shared unknown IP bucket", async () => { @@ -837,7 +1055,7 @@ describe("auth rate limiting", () => { expect(db.audits).toContainEqual(expect.objectContaining({ action: "LOGIN", entity_id: "user_ok" })); }); - it("does not let attacker failures from one IP lock out a victim on another IP", async () => { + it("requires a non-locking email step-up after distributed account failures", async () => { const db = new InMemoryD1(); const hashed = await hashPassword("Valid#Pass2026", "fixed-salt", { enforcePolicy: false }); db.users.push({ @@ -849,25 +1067,221 @@ describe("auth rate limiting", () => { password_salt: hashed.salt, disabled_at: "" }); - // An attacker seeds the failure threshold for the victim's email from their own IP. - for (let i = 0; i < 5; i += 1) { - seedAudit(db, "LOGIN_FAILED", "victim@example.org", `2026-07-04T11:5${i}:00.000Z`, "203.0.113.7"); + db.auditCreatedAt = "2026-07-04T11:59:00.000Z"; + for (let index = 0; index < 5; index += 1) { + const failed = await worker.fetch( + loginRequestFrom( + "victim@example.org", + "Wrong#Pass2026", + `203.0.113.${index + 10}` + ), + env(db) + ); + expect(failed.status).toBe(401); } + const sentMessages: unknown[] = []; - // The victim, arriving from a different IP with the correct password, must not be - // throttled by the attacker's failures. + // Correct credentials from a fresh IP are not locked out, but they also must not + // mint a session until the emailed one-time code is completed. const response = await worker.fetch( - new Request("https://example.org/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json", "CF-Connecting-IP": "198.51.100.4" }, - body: JSON.stringify({ email: "victim@example.org", password: "Valid#Pass2026" }) - }), + loginRequestFrom("victim@example.org", "Valid#Pass2026", "198.51.100.4"), + env(db, { + MOCK_EXTERNAL_SERVICES: "false", + EMAIL_FROM: "security@example.org", + EMAIL: { + send: async (message: unknown) => { + sentMessages.push(message); + return { messageId: "login-step-up-code" }; + } + } as SendEmail + }) + ); + + expect(response.status).toBe(202); + const challenge = await response.json() as Record; + expect(challenge).toMatchObject({ + mfaRequired: true, + challengeId: expect.any(String), + continuationToken: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expiresAt: "2026-07-04T12:10:00.000Z" + }); + expect(challenge).not.toHaveProperty("token"); + expect(challenge).not.toHaveProperty("user"); + expect(db.sessions).toHaveLength(0); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0]).toMatchObject({ to: "victim@example.org" }); + expect(String((sentMessages[0] as { text?: string }).text)).toMatch(/\b\d{6}\b/); + expect(db.audits).not.toContainEqual(expect.objectContaining({ action: "LOGIN", entity_id: "user_victim" })); + }); + + it("completes the emailed challenge once and rejects a replay generically", async () => { + const db = new InMemoryD1(); + const { response, sentMessages } = await seededStepUp({ db }); + expect(response.status).toBe(202); + const challenge = await response.json() as { + challengeId: string; + continuationToken: string; + }; + const code = String((sentMessages[0] as { text?: string }).text).match(/\b(\d{6})\b/)?.[1]; + expect(code).toMatch(/^\d{6}$/); + + const completed = await worker.fetch(loginMfaRequest({ ...challenge, code: code! }), env(db)); + expect(completed.status).toBe(200); + await expect(completed.json()).resolves.toMatchObject({ + user: { id: "user_operator", email: "operator@example.org", role: "OPERATOR" }, + token: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expiresAt: "2026-07-05T12:00:00.000Z" + }); + expect(db.sessions).toHaveLength(1); + expect(db.audits).toContainEqual(expect.objectContaining({ action: "LOGIN", entity_id: "user_operator" })); + + const replay = await worker.fetch(loginMfaRequest({ ...challenge, code: code! }), env(db)); + expect(replay.status).toBe(400); + await expect(replay.json()).resolves.toEqual({ + error: "invalid_login_mfa_challenge", + message: "El código no es válido o ya expiró. Inicie sesión nuevamente." + }); + expect(db.sessions).toHaveLength(1); + }); + + it("bounds wrong codes at five attempts and then rejects the correct code generically", async () => { + const db = new InMemoryD1(); + const { response, sentMessages } = await seededStepUp({ db }); + const challenge = await response.json() as { challengeId: string; continuationToken: string }; + const code = String((sentMessages[0] as { text?: string }).text).match(/\b(\d{6})\b/)?.[1] ?? ""; + const wrongCode = code === "000000" ? "000001" : "000000"; + + for (let attempt = 0; attempt < 5; attempt += 1) { + const wrong = await worker.fetch(loginMfaRequest({ ...challenge, code: wrongCode }), env(db)); + expect(wrong.status).toBe(400); + await expect(wrong.json()).resolves.toMatchObject({ error: "invalid_login_mfa_challenge" }); + } + const exhausted = await worker.fetch(loginMfaRequest({ ...challenge, code }), env(db)); + expect(exhausted.status).toBe(400); + await expect(exhausted.json()).resolves.toMatchObject({ error: "invalid_login_mfa_challenge" }); + expect(db.sessions).toHaveLength(0); + }); + + it("allows only one concurrent completion to create a session", async () => { + const db = new InMemoryD1(); + const { response, sentMessages } = await seededStepUp({ db }); + const challenge = await response.json() as { challengeId: string; continuationToken: string }; + const code = String((sentMessages[0] as { text?: string }).text).match(/\b(\d{6})\b/)?.[1] ?? ""; + + const completions = await Promise.all([ + worker.fetch(loginMfaRequest({ ...challenge, code }), env(db)), + worker.fetch(loginMfaRequest({ ...challenge, code }), env(db)) + ]); + + expect(completions.map((result) => result.status).sort()).toEqual([200, 400]); + expect(db.sessions).toHaveLength(1); + }); + + it("rejects expired and credential-stale challenges without creating a session", async () => { + const expiredDb = new InMemoryD1(); + const expiredIssued = await seededStepUp({ db: expiredDb }); + const expiredChallenge = await expiredIssued.response.json() as { challengeId: string; continuationToken: string }; + const expiredCode = String((expiredIssued.sentMessages[0] as { text?: string }).text).match(/\b(\d{6})\b/)?.[1] ?? ""; + vi.setSystemTime(new Date("2026-07-04T12:10:00.001Z")); + const expired = await worker.fetch(loginMfaRequest({ ...expiredChallenge, code: expiredCode }), env(expiredDb)); + expect(expired.status).toBe(400); + expect(expiredDb.sessions).toHaveLength(0); + + vi.setSystemTime(new Date("2026-07-04T12:00:00.000Z")); + const changedDb = new InMemoryD1(); + const changedIssued = await seededStepUp({ db: changedDb }); + const changedChallenge = await changedIssued.response.json() as { challengeId: string; continuationToken: string }; + const changedCode = String((changedIssued.sentMessages[0] as { text?: string }).text).match(/\b(\d{6})\b/)?.[1] ?? ""; + changedDb.users[0].auth_generation = 1; + const changed = await worker.fetch(loginMfaRequest({ ...changedChallenge, code: changedCode }), env(changedDb)); + expect(changed.status).toBe(400); + expect(changedDb.sessions).toHaveLength(0); + }); + + it("invalidates the challenge and returns a generic 503 when email delivery fails", async () => { + const db = new InMemoryD1(); + const { response } = await seededStepUp({ + db, + send: async () => { + throw new Error("provider secret detail"); + } + }); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: "login_mfa_unavailable", + message: "No se pudo enviar el código de verificación. Intente de nuevo en unos minutos." + }); + expect(db.sessions).toHaveLength(0); + expect(db.loginStepUpChallenges).toHaveLength(1); + expect(db.loginStepUpChallenges[0].invalidated_at).not.toBeNull(); + }); + + it("keeps wrong credentials generic and audited after the account threshold", async () => { + const db = new InMemoryD1(); + const hashed = await hashPassword("Valid#Pass2026", "fixed-salt", { enforcePolicy: false }); + db.users.push({ + id: "user_wrong", + email: "wrong@example.org", + name: "Wrong Test", + role: "VIEWER", + password_hash: hashed.hash, + password_salt: hashed.salt, + disabled_at: "" + }); + seedDistributedFailures(db, "wrong@example.org"); + + const response = await worker.fetch( + loginRequestFrom("wrong@example.org", "Wrong#Pass2026", "198.51.100.91"), env(db) ); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ user: { email: "victim@example.org", role: "ADMIN" } }); - expect(db.audits).toContainEqual(expect.objectContaining({ action: "LOGIN", entity_id: "user_victim" })); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toEqual({ error: "auth_error", message: "Credenciales inválidas" }); + expect(db.audits.filter((audit) => audit.action === "LOGIN_FAILED")).toHaveLength(6); + expect(db.sessions).toHaveLength(0); + expect(db.loginStepUpChallenges).toHaveLength(0); + }); + + it("never sends a code or issues a challenge for disabled and unknown accounts", async () => { + const db = new InMemoryD1(); + const hashed = await hashPassword("Valid#Pass2026", "fixed-salt", { enforcePolicy: false }); + db.users.push({ + id: "user_disabled", + email: "disabled@example.org", + name: "Disabled", + role: "VIEWER", + password_hash: hashed.hash, + password_salt: hashed.salt, + disabled_at: "2026-07-01T00:00:00.000Z" + }); + seedDistributedFailures(db, "disabled@example.org"); + seedDistributedFailures(db, "unknown@example.org"); + const sentMessages: unknown[] = []; + const runtime = env(db, { + MOCK_EXTERNAL_SERVICES: "false", + EMAIL_FROM: "security@example.org", + EMAIL: { + send: async (message: unknown) => { + sentMessages.push(message); + return { messageId: "must-not-send" }; + } + } as SendEmail + }); + + const disabled = await worker.fetch( + loginRequestFrom("disabled@example.org", "Valid#Pass2026", "198.51.100.92"), + runtime + ); + const unknown = await worker.fetch( + loginRequestFrom("unknown@example.org", "Valid#Pass2026", "198.51.100.93"), + runtime + ); + + expect(disabled.status).toBe(401); + expect(unknown.status).toBe(401); + expect(sentMessages).toHaveLength(0); + expect(db.loginStepUpChallenges).toHaveLength(0); }); it("still throttles repeated failures from the same IP", async () => { From 02abd61a98a8c83582be64b97d0cdec0f342a773 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:42:31 -0600 Subject: [PATCH 06/22] fix(worker): preserve provider-safe readiness failures --- src/worker/services/wompiApi.ts | 40 ++++++++++++++--- test/worker/wompiApi.test.ts | 15 +++++-- .../workerFetch.donation-intents.test.ts | 43 +++++++++++++++++-- .../workerFetch.fiscal-correction.test.ts | 5 +++ 4 files changed, 90 insertions(+), 13 deletions(-) diff --git a/src/worker/services/wompiApi.ts b/src/worker/services/wompiApi.ts index c8543779..d4e546c7 100644 --- a/src/worker/services/wompiApi.ts +++ b/src/worker/services/wompiApi.ts @@ -67,7 +67,7 @@ export class WompiApiService { async createPaymentLink(intent: DonationIntentRecord): Promise { // Mock mode: deterministic fake link, no network. Mirrors MhClient's // isMockMode short-circuit so local dev and CI never reach Wompi. - if (isMockMode(this.env)) { + if (this.isMockMode()) { return { idEnlace: mockLinkId(intent.id), urlEnlace: `https://mock.wompi.sv/enlace/${intent.id}`, @@ -78,7 +78,7 @@ export class WompiApiService { // A real link can accept an irreversible entrega before the asynchronous CDE // pipeline runs. Validate the issuer now so configuration errors fail before // Wompi receives a link request rather than after the donor has completed it. - await assertFiscalCollectionReady(this.env); + const configuracion = await this.linkPreflight(); const start = nowIso(); const body = { @@ -97,7 +97,7 @@ export class WompiApiService { // esMontoEditable/esCantidadEditable false pins the amount: the donor cannot // change the monto or quantity on Wompi's hosted sheet, so the paid amount // always matches the intent (and the CDE we later emit). - configuracion: await this.linkConfiguracion() + configuracion }; const response = await this.authorizedFetch(ENLACE_PAGO_URL, "POST", body); @@ -169,6 +169,24 @@ export class WompiApiService { return PRODUCT_NAME; } + private isMockMode(): boolean { + try { + return isMockMode(this.env); + } catch { + throw wompiConfigurationError(); + } + } + + private async linkPreflight(): Promise>> { + try { + await assertFiscalCollectionReady(this.env); + this.wompiCredentials(); + return await this.linkConfiguracion(); + } catch { + throw wompiConfigurationError(); + } + } + // Cards-only forma de pago. The permitir/permite prefixes are intentionally // inconsistent — they mirror the Wompi EnlaceFormaPago schema exactly. private linkFormaPago(): { @@ -282,11 +300,12 @@ export class WompiApiService { // Fetches a fresh client-credentials token and caches it with a safety margin // shaved off its lifetime (expiresAt = now + (expires_in - margin)s). private async requestToken(): Promise { + const credentials = this.wompiCredentials(); const form = new URLSearchParams(); form.set("grant_type", "client_credentials"); form.set("audience", "wompi_api"); - form.set("client_id", requireSecret(this.env, "WOMPI_CLIENT_ID")); - form.set("client_secret", requireSecret(this.env, "WOMPI_CLIENT_SECRET")); + form.set("client_id", credentials.clientId); + form.set("client_secret", credentials.clientSecret); const response = await fetch(TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, @@ -305,6 +324,13 @@ export class WompiApiService { await this.repo.setSetting(TOKEN_CACHE_KEY, JSON.stringify({ token: data.access_token, expiresAt } satisfies CachedToken)); return data.access_token; } + + private wompiCredentials(): { clientId: string; clientSecret: string } { + return { + clientId: requireSecret(this.env, "WOMPI_CLIENT_ID"), + clientSecret: requireSecret(this.env, "WOMPI_CLIENT_SECRET") + }; + } } // Wompi's monto is a decimal amount in USD; we store integer cents. Round-trip @@ -374,3 +400,7 @@ function isApprovedWompiUrl(url: URL, host: string): boolean { function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } + +function wompiConfigurationError(): WompiApiError { + return new WompiApiError("No se pudo preparar la configuración de Wompi"); +} diff --git a/test/worker/wompiApi.test.ts b/test/worker/wompiApi.test.ts index a935dafd..5ec9fe92 100644 --- a/test/worker/wompiApi.test.ts +++ b/test/worker/wompiApi.test.ts @@ -238,13 +238,16 @@ describe("Wompi API service", () => { it.each([ ["missing", undefined], ["invalid", "not-json"] - ])("rejects %s issuer configuration before contacting Wompi", async (_label, config) => { + ])("returns a safe typed error for %s issuer configuration before contacting Wompi", async (_label, config) => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const env = realEnv(); env.EMISOR_CONFIG_JSON = config; - await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(/EMISOR_CONFIG_JSON/); + const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(WompiApiError); + expect((error as WompiApiError).message).toBe("No se pudo preparar la configuración de Wompi"); + expect((error as WompiApiError).message).not.toContain("EMISOR_CONFIG_JSON"); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -288,7 +291,9 @@ describe("Wompi API service", () => { const env = realEnv(); makeInvalid(env); - await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(/MH endpoint/i); + const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(WompiApiError); + expect((error as WompiApiError).message).toBe("No se pudo preparar la configuración de Wompi"); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -303,7 +308,9 @@ describe("Wompi API service", () => { env.MH_RECEPCION_URL_PROD = "https://api.dtes.mh.gob.sv/fesv/recepciondte"; delete env.MH_USER_PROD; - await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(/MH_USER_PROD/); + const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(WompiApiError); + expect((error as WompiApiError).message).toBe("No se pudo preparar la configuración de Wompi"); expect(fetchMock).not.toHaveBeenCalled(); }); diff --git a/test/worker/workerFetch.donation-intents.test.ts b/test/worker/workerFetch.donation-intents.test.ts index b8350665..341dbd20 100644 --- a/test/worker/workerFetch.donation-intents.test.ts +++ b/test/worker/workerFetch.donation-intents.test.ts @@ -4,7 +4,7 @@ import { INTENT_EXPIRY_SWEEP_LIMIT } from "../../src/worker/storage/repository"; import { utf8Bytes } from "../../src/worker/utils/encoding"; import type { Env } from "../../src/worker/types"; import { env, InMemoryD1 } from "./support/inMemoryD1"; -import { emisorConfig } from "./support/dteFixtures"; +import { emisorConfig, generatedCertificateXml } from "./support/dteFixtures"; import { installWorkerFetchGlobals } from "./support/workerFetchGlobals"; import { sha256Hex } from "./support/workerFetchHelpers"; @@ -510,9 +510,15 @@ describe("donation intents", () => { } }); - it("returns 502 and leaves the intent PENDING when Wompi link creation fails", async () => { + it("returns 502 and leaves the intent PENDING when a fiscally-ready Wompi link request fails", async () => { const db = new InMemoryD1(); - const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("nope", { status: 500 })); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(new Response(JSON.stringify({ access_token: "wompi-token", expires_in: 3600, token_type: "Bearer" }), { + status: 200, + headers: { "Content-Type": "application/json" } + })) + .mockResolvedValueOnce(new Response("nope", { status: 500 })); try { const response = await worker.fetch( intentRequest(validIntentBody()), @@ -521,7 +527,14 @@ describe("donation intents", () => { APP_ORIGIN: "https://donar.example.org", EMISOR_CONFIG_JSON: JSON.stringify(emisorConfig()), WOMPI_CLIENT_ID: "id", - WOMPI_CLIENT_SECRET: "secret" + WOMPI_CLIENT_SECRET: "secret", + MH_CERT_XML: await generatedCertificateXml("cert-password"), + MH_CERT_PASSWORD: "cert-password", + MH_USER_TEST: "test-mh-user", + MH_PASSWORD_TEST: "test-mh-password", + MH_AUTH_URL_TEST: "https://apitest.dtes.mh.gob.sv/seguridad/auth", + MH_RECEPCION_URL_TEST: "https://apitest.dtes.mh.gob.sv/fesv/recepciondte", + MH_ANULACION_URL_TEST: "https://apitest.dtes.mh.gob.sv/fesv/anulardte" }) ); @@ -534,6 +547,28 @@ describe("donation intents", () => { } }); + it("returns the donor-safe 502 and leaves the intent PENDING when fiscal readiness is invalid", async () => { + const db = new InMemoryD1(); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + try { + const response = await worker.fetch( + intentRequest(validIntentBody()), + env(db, { + MOCK_EXTERNAL_SERVICES: "false", + APP_ORIGIN: "https://donar.example.org" + }) + ); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ error: "wompi_link_failed" }); + expect(db.donationIntents).toHaveLength(1); + expect(db.donationIntents[0].status).toBe("PENDING"); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + it("returns the status and paid flag for a known intent id", async () => { const db = new InMemoryD1(); db.donationIntents.push({ id: "di_known", status: "LINK_CREATED", donor_name: "Secreto", donor_document: "10000001-9", paid_at: null }); diff --git a/test/worker/workerFetch.fiscal-correction.test.ts b/test/worker/workerFetch.fiscal-correction.test.ts index 70573801..0f0f3df0 100644 --- a/test/worker/workerFetch.fiscal-correction.test.ts +++ b/test/worker/workerFetch.fiscal-correction.test.ts @@ -3701,6 +3701,11 @@ describe("guarded fiscal correction API", () => { }); const runtime = correctionRuntime(db); runtime.APP_ENV = "production"; + runtime.MOCK_EXTERNAL_SERVICES = "false"; + runtime.EMAIL_FROM = "comprobantes@example.org"; + runtime.EMAIL = { + send: async () => ({ messageId: "production-correction-email" }) + } as SendEmail; runtime.MH_CERT_XML = await generatedCertificateXml("cert-password"); runtime.MH_CERT_PASSWORD = "cert-password"; const sequenceBefore = db.nextSequence; From 7bb6a376ec5daf461e797ea5d5c4438f20efe93e Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:05:16 -0600 Subject: [PATCH 07/22] fix: bound login MFA reissuance attempts --- e2e/loginMfa.spec.ts | 96 ++++++ migrations/0045_login_step_up_mfa.sql | 2 +- src/worker/index.ts | 31 +- src/worker/services/auth.ts | 2 +- src/worker/storage/repository/identity.ts | 22 +- test/worker/support/inMemoryD1.ts | 36 ++- test/worker/workerFetch.auth-infra.test.ts | 351 +++++++++++++++++++-- 7 files changed, 501 insertions(+), 39 deletions(-) create mode 100644 e2e/loginMfa.spec.ts diff --git a/e2e/loginMfa.spec.ts b/e2e/loginMfa.spec.ts new file mode 100644 index 00000000..f2061e81 --- /dev/null +++ b/e2e/loginMfa.spec.ts @@ -0,0 +1,96 @@ +import { expect, test, type Route } from "@playwright/test"; + +async function fulfillJson(route: Route, body: unknown, status = 200): Promise { + await route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(body) + }); +} + +test("mounts the login step-up flow, clears the password, and establishes the verified session", async ({ page }) => { + const loginBodies: Array> = []; + const mfaBodies: Array> = []; + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.pathname === "/api/branding") { + await fulfillJson(route, { + displayName: "Iglesia Ejemplo", + accentColor: "#0f766e", + supportEmail: "soporte@example.org", + logoVersion: null, + donorLogoVersion: null + }); + return; + } + if (url.pathname === "/api/auth/bootstrap-status") { + await fulfillJson(route, { bootstrapAvailable: false }); + return; + } + if (url.pathname === "/api/auth/login" && request.method() === "POST") { + loginBodies.push(request.postDataJSON() as Record); + await fulfillJson(route, { + mfaRequired: true, + challengeId: `login_mfa_challenge_${loginBodies.length}`, + continuationToken: `continuation-token-${loginBodies.length}`, + expiresAt: "2026-08-23T18:10:00.000Z" + }, 202); + return; + } + if (url.pathname === "/api/auth/login/mfa" && request.method() === "POST") { + mfaBodies.push(request.postDataJSON() as Record); + await fulfillJson(route, { + user: { + id: "user_operator", + email: "operator@example.org", + name: "Operador", + role: "OPERATOR" + }, + token: "verified-session-token", + expiresAt: "2026-08-24T18:00:00.000Z" + }); + return; + } + await fulfillJson(route, { error: "not_part_of_login_mfa_fixture" }, 503); + }); + + await page.goto("/admin"); + const email = page.getByLabel("Correo"); + const password = page.getByLabel("Contraseña"); + await email.fill("operator@example.org"); + await password.fill("Valid#Pass2026"); + await page.getByRole("button", { name: "Continuar" }).click(); + + await expect(page.getByText("Ingrese el código de 6 dígitos que enviamos a su correo.")).toBeVisible(); + await expect(page.getByLabel("Código de verificación")).toBeVisible(); + await expect(page.getByLabel("Contraseña")).toHaveCount(0); + + await page.getByRole("button", { name: "Volver a iniciar sesión" }).click(); + await expect(page.getByLabel("Contraseña")).toHaveValue(""); + await page.getByLabel("Contraseña").fill("Valid#Pass2026"); + await page.getByRole("button", { name: "Continuar" }).click(); + await page.getByLabel("Código de verificación").fill("123456"); + await page.getByRole("button", { name: "Verificar código" }).click(); + + expect(loginBodies).toEqual([ + { email: "operator@example.org", password: "Valid#Pass2026" }, + { email: "operator@example.org", password: "Valid#Pass2026" } + ]); + expect(mfaBodies).toEqual([{ + challengeId: "login_mfa_challenge_2", + continuationToken: "continuation-token-2", + code: "123456" + }]); + expect(mfaBodies[0]).not.toHaveProperty("password"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("diezmos_token"))) + .toBe("verified-session-token"); + await expect.poll(() => page.evaluate(() => localStorage.getItem("diezmos_user"))) + .toBe(JSON.stringify({ + id: "user_operator", + email: "operator@example.org", + name: "Operador", + role: "OPERATOR" + })); +}); diff --git a/migrations/0045_login_step_up_mfa.sql b/migrations/0045_login_step_up_mfa.sql index cefeb4c4..7b17bf8f 100644 --- a/migrations/0045_login_step_up_mfa.sql +++ b/migrations/0045_login_step_up_mfa.sql @@ -31,4 +31,4 @@ CREATE INDEX idx_login_step_up_challenges_expires ON login_step_up_challenges(expires_at); CREATE INDEX idx_login_step_up_challenges_user_expires - ON login_step_up_challenges(user_id, expires_at); + ON login_step_up_challenges(user_id, expected_auth_generation, expires_at); diff --git a/src/worker/index.ts b/src/worker/index.ts index 1d82e917..53935188 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -180,6 +180,7 @@ const WOMPI_RECONCILIATION_RECHECK_MS = 10 * 60 * 1000; const AUTH_THROTTLE_WINDOW_MINUTES = 15; const LOGIN_FAILED_LIMIT = 5; const LOGIN_IP_ATTEMPT_LIMIT = 60; +const LOGIN_MFA_ISSUANCE_LIMIT = 5; const PASSWORD_RESET_PAIR_LIMIT = 3; const PASSWORD_RESET_ACCOUNT_LIMIT = 3; const BOOTSTRAP_ATTEMPT_LIMIT = 10; @@ -273,6 +274,16 @@ async function rateLimitKey(value: string | null): Promise { return sha256Hex(utf8Bytes(value?.trim() || "unknown")); } +function loginMfaUnavailableResponse(): Response { + return jsonResponse( + { + error: "login_mfa_unavailable", + message: "No se pudo enviar el código de verificación. Intente de nuevo en unos minutos." + }, + { status: 503 } + ); +} + function intentThrottleExpiresIso(): string { return new Date(Date.now() + INTENT_THROTTLE_WINDOW_MINUTES * 60_000).toISOString(); } @@ -2125,6 +2136,18 @@ async function handleLogin(ctx: ApiRouteContext): Promise { try { const credentials = await ctx.auth.verifyLoginCredentials(body.email, body.password); if (accountFailures >= LOGIN_FAILED_LIMIT) { + const issuanceAccepted = await ctx.repo.claimLoginAttempt( + await rateLimitKey( + `login-step-up-issuance-v1:${credentials.userId}:${credentials.expectedAuthGeneration}` + ), + claimNow, + authThrottleSinceIso(), + authThrottleExpiresIso(), + LOGIN_MFA_ISSUANCE_LIMIT + ); + if (!issuanceAccepted) { + return loginMfaUnavailableResponse(); + } const issued = await ctx.auth.issueLoginStepUpChallenge(credentials); try { const branding = await loadEmailBranding(ctx.repo, ctx.env); @@ -2145,13 +2168,7 @@ async function handleLogin(ctx: ApiRouteContext): Promise { entityId: issued.user.id, summary: "No se pudo enviar el código de verificación" }); - return jsonResponse( - { - error: "login_mfa_unavailable", - message: "No se pudo enviar el código de verificación. Intente de nuevo en unos minutos." - }, - { status: 503 } - ); + return loginMfaUnavailableResponse(); } await ctx.repo.createAudit({ actorType: "USER", diff --git a/src/worker/services/auth.ts b/src/worker/services/auth.ts index 617f31a9..7178edb6 100644 --- a/src/worker/services/auth.ts +++ b/src/worker/services/auth.ts @@ -13,7 +13,7 @@ export interface AuthUser { role: Role; } -interface VerifiedLoginCredentials { +export interface VerifiedLoginCredentials { user: AuthUser; userId: string; expectedPasswordHash: string; diff --git a/src/worker/storage/repository/identity.ts b/src/worker/storage/repository/identity.ts index b7e46918..4849268b 100644 --- a/src/worker/storage/repository/identity.ts +++ b/src/worker/storage/repository/identity.ts @@ -409,7 +409,15 @@ export async function consumeLoginStepUpChallenge( AND consumed_at IS NULL AND invalidated_at IS NULL AND expires_at > ? - AND failed_attempts < ? + AND ( + SELECT COALESCE(SUM(cohort.failed_attempts), 0) + FROM login_step_up_challenges AS cohort + WHERE cohort.user_id = login_step_up_challenges.user_id + AND cohort.expected_auth_generation = login_step_up_challenges.expected_auth_generation + AND cohort.consumed_at IS NULL + AND cohort.invalidated_at IS NULL + AND cohort.expires_at > ? + ) < ? AND EXISTS ( SELECT 1 FROM users WHERE users.id = login_step_up_challenges.user_id @@ -428,6 +436,7 @@ export async function consumeLoginStepUpChallenge( input.continuationTokenHash, input.codeHash, input.now, + input.now, input.maxWrongAttempts ) .first>(); @@ -462,7 +471,15 @@ export async function incrementLoginStepUpFailure( AND consumed_at IS NULL AND invalidated_at IS NULL AND expires_at > ? - AND failed_attempts < ? + AND ( + SELECT COALESCE(SUM(cohort.failed_attempts), 0) + FROM login_step_up_challenges AS cohort + WHERE cohort.user_id = login_step_up_challenges.user_id + AND cohort.expected_auth_generation = login_step_up_challenges.expected_auth_generation + AND cohort.consumed_at IS NULL + AND cohort.invalidated_at IS NULL + AND cohort.expires_at > ? + ) < ? AND EXISTS ( SELECT 1 FROM users WHERE users.id = login_step_up_challenges.user_id @@ -479,6 +496,7 @@ export async function incrementLoginStepUpFailure( input.continuationTokenHash, input.submittedCodeHash, input.now, + input.now, input.maxWrongAttempts ) .first<{ id: string }>(); diff --git a/test/worker/support/inMemoryD1.ts b/test/worker/support/inMemoryD1.ts index f5bbcab2..aca941b4 100644 --- a/test/worker/support/inMemoryD1.ts +++ b/test/worker/support/inMemoryD1.ts @@ -497,7 +497,7 @@ export class Statement { this.sql.includes("SET consumed_at = ?") && this.sql.includes("RETURNING id, user_id") ) { - const [consumedAt, challengeId, continuationTokenHash, codeHash, now, maxWrongAttempts] = this.args; + const [consumedAt, challengeId, continuationTokenHash, codeHash, now, aggregateNow, maxWrongAttempts] = this.args; const challenge = this.db.loginStepUpChallenges.find( (row) => row.id === challengeId && @@ -505,9 +505,19 @@ export class Statement { row.code_hash === codeHash && row.consumed_at === null && row.invalidated_at === null && - row.expires_at > String(now) && - row.failed_attempts < Number(maxWrongAttempts) + row.expires_at > String(now) ); + const aggregateAttempts = challenge + ? this.db.loginStepUpChallenges + .filter((row) => + row.user_id === challenge.user_id && + row.expected_auth_generation === challenge.expected_auth_generation && + row.consumed_at === null && + row.invalidated_at === null && + row.expires_at > String(aggregateNow) + ) + .reduce((sum, row) => sum + row.failed_attempts, 0) + : Number(maxWrongAttempts); const user = challenge && this.db.users.find( (row) => row.id === challenge.user_id && @@ -517,7 +527,7 @@ export class Statement { row.password_hash === challenge.expected_password_hash && row.password_salt === challenge.expected_password_salt ); - if (!challenge || !user) return null; + if (!challenge || !user || aggregateAttempts >= Number(maxWrongAttempts)) return null; challenge.consumed_at = String(consumedAt); return { id: challenge.id, @@ -533,7 +543,7 @@ export class Statement { this.sql.includes("SET failed_attempts = failed_attempts + 1") && this.sql.includes("RETURNING id") ) { - const [challengeId, continuationTokenHash, submittedCodeHash, now, maxWrongAttempts] = this.args; + const [challengeId, continuationTokenHash, submittedCodeHash, now, aggregateNow, maxWrongAttempts] = this.args; const challenge = this.db.loginStepUpChallenges.find( (row) => row.id === challengeId && @@ -541,9 +551,19 @@ export class Statement { row.code_hash !== submittedCodeHash && row.consumed_at === null && row.invalidated_at === null && - row.expires_at > String(now) && - row.failed_attempts < Number(maxWrongAttempts) + row.expires_at > String(now) ); + const aggregateAttempts = challenge + ? this.db.loginStepUpChallenges + .filter((row) => + row.user_id === challenge.user_id && + row.expected_auth_generation === challenge.expected_auth_generation && + row.consumed_at === null && + row.invalidated_at === null && + row.expires_at > String(aggregateNow) + ) + .reduce((sum, row) => sum + row.failed_attempts, 0) + : Number(maxWrongAttempts); const user = challenge && this.db.users.find( (row) => row.id === challenge.user_id && @@ -553,7 +573,7 @@ export class Statement { row.password_hash === challenge.expected_password_hash && row.password_salt === challenge.expected_password_salt ); - if (!challenge || !user) return null; + if (!challenge || !user || aggregateAttempts >= Number(maxWrongAttempts)) return null; challenge.failed_attempts += 1; return { id: challenge.id } as T; } diff --git a/test/worker/workerFetch.auth-infra.test.ts b/test/worker/workerFetch.auth-infra.test.ts index 9bc57599..984ba440 100644 --- a/test/worker/workerFetch.auth-infra.test.ts +++ b/test/worker/workerFetch.auth-infra.test.ts @@ -171,6 +171,70 @@ describe("login step-up migration", () => { database.close(); } }); + + it("atomically caps cumulative SQLite guesses across active reissued challenges", async () => { + const database = migratedDatabase(); + try { + const repo = new Repository(sqliteD1(database)); + const snapshot = { + userId: "user_operator", + expectedEmail: "operator@example.org", + expectedAuthGeneration: 0, + expectedPasswordHash: "hash", + expectedPasswordSalt: "salt" + }; + const firstId = await repo.createLoginStepUpChallenge({ + ...snapshot, + continuationTokenHash: "1".repeat(64), + codeHash: "2".repeat(64), + expiresAt: "2026-07-04T12:10:00.000Z" + }); + const secondId = await repo.createLoginStepUpChallenge({ + ...snapshot, + continuationTokenHash: "3".repeat(64), + codeHash: "4".repeat(64), + expiresAt: "2026-07-04T12:10:00.000Z" + }); + + const attempts = await Promise.all( + Array.from({ length: 6 }, (_, index) => repo.incrementLoginStepUpFailure({ + challengeId: index % 2 === 0 ? firstId! : secondId!, + continuationTokenHash: (index % 2 === 0 ? "1" : "3").repeat(64), + submittedCodeHash: "5".repeat(64), + now: "2026-07-04T12:00:00.000Z", + maxWrongAttempts: 5 + })) + ); + + expect(attempts.filter(Boolean)).toHaveLength(5); + expect(database.prepare( + "SELECT SUM(failed_attempts) AS attempts FROM login_step_up_challenges WHERE consumed_at IS NULL AND invalidated_at IS NULL" + ).get()).toEqual({ attempts: 5 }); + await expect(repo.consumeLoginStepUpChallenge({ + challengeId: secondId!, + continuationTokenHash: "3".repeat(64), + codeHash: "4".repeat(64), + now: "2026-07-04T12:00:00.000Z", + maxWrongAttempts: 5 + })).resolves.toBeNull(); + + const afterExpiryId = await repo.createLoginStepUpChallenge({ + ...snapshot, + continuationTokenHash: "6".repeat(64), + codeHash: "7".repeat(64), + expiresAt: "2026-07-04T12:20:00.000Z" + }); + await expect(repo.consumeLoginStepUpChallenge({ + challengeId: afterExpiryId!, + continuationTokenHash: "6".repeat(64), + codeHash: "7".repeat(64), + now: "2026-07-04T12:10:00.001Z", + maxWrongAttempts: 5 + })).resolves.toMatchObject(snapshot); + } finally { + database.close(); + } + }); }); describe("request body limits", () => { @@ -601,16 +665,13 @@ describe("auth rate limiting", () => { } } - async function seededStepUp(input: { - db: InMemoryD1; - email?: string; - password?: string; - send?: (message: unknown) => Promise<{ messageId: string }>; - }) { - const email = input.email ?? "operator@example.org"; - const password = input.password ?? "Valid#Pass2026"; + async function seedStepUpAccount( + db: InMemoryD1, + email = "operator@example.org", + password = "Valid#Pass2026" + ): Promise { const hashed = await hashPassword(password, "fixed-salt", { enforcePolicy: false }); - input.db.users.push({ + db.users.push({ id: "user_operator", email, name: "Operator", @@ -620,20 +681,47 @@ describe("auth rate limiting", () => { auth_generation: 0, disabled_at: "" }); - seedDistributedFailures(input.db, email); + seedDistributedFailures(db, email); + } + + function stepUpEmailRuntime( + db: InMemoryD1, + sentMessages: unknown[], + send?: (message: unknown) => Promise<{ messageId: string }> + ) { + return env(db, { + MOCK_EXTERNAL_SERVICES: "false", + EMAIL_FROM: "security@example.org", + EMAIL: { + send: async (message: unknown) => { + sentMessages.push(message); + return send ? send(message) : { messageId: "login-step-up-code" }; + } + } as SendEmail + }); + } + + function codeFromMessage(message: unknown): string { + return String((message as { text?: string }).text).match(/\b(\d{6})\b/)?.[1] ?? ""; + } + + function differentCode(code: string): string { + return code === "000000" ? "000001" : "000000"; + } + + async function seededStepUp(input: { + db: InMemoryD1; + email?: string; + password?: string; + send?: (message: unknown) => Promise<{ messageId: string }>; + }) { + const email = input.email ?? "operator@example.org"; + const password = input.password ?? "Valid#Pass2026"; + await seedStepUpAccount(input.db, email, password); const sentMessages: unknown[] = []; const response = await worker.fetch( loginRequestFrom(email, password, "198.51.100.90"), - env(input.db, { - MOCK_EXTERNAL_SERVICES: "false", - EMAIL_FROM: "security@example.org", - EMAIL: { - send: async (message: unknown) => { - sentMessages.push(message); - return input.send ? input.send(message) : { messageId: "login-step-up-code" }; - } - } as SendEmail - }) + stepUpEmailRuntime(input.db, sentMessages, input.send) ); return { response, sentMessages }; } @@ -1114,6 +1202,177 @@ describe("auth rate limiting", () => { expect(db.audits).not.toContainEqual(expect.objectContaining({ action: "LOGIN", entity_id: "user_victim" })); }); + it("atomically caps concurrent challenge issuance across rotating IPs without creating a session", async () => { + const db = new InMemoryD1(); + await seedStepUpAccount(db); + const currentStoredPassword = await hashForStorage("Valid#Pass2026", { enforcePolicy: false }); + db.users[0].password_hash = currentStoredPassword.hash; + db.users[0].password_salt = currentStoredPassword.salt; + const sentMessages: unknown[] = []; + const runtime = stepUpEmailRuntime(db, sentMessages); + + const responses = await Promise.all( + Array.from({ length: 6 }, (_, index) => worker.fetch( + loginRequestFrom("operator@example.org", "Valid#Pass2026", `198.51.100.${100 + index}`), + runtime + )) + ); + + expect(responses.map((response) => response.status).sort()).toEqual([202, 202, 202, 202, 202, 503]); + const limited = responses.find((response) => response.status === 503); + await expect(limited?.json()).resolves.toEqual({ + error: "login_mfa_unavailable", + message: "No se pudo enviar el código de verificación. Intente de nuevo en unos minutos." + }); + expect(db.loginStepUpChallenges).toHaveLength(5); + expect(sentMessages).toHaveLength(5); + expect(db.sessions).toHaveLength(0); + expect(db.audits.filter((audit) => audit.action === "LOGIN_FAILED")).toHaveLength(5); + expect([...db.loginRateLimits.values()].filter((row) => row.attempt_count === 5)).toHaveLength(1); + + const auditJson = JSON.stringify(db.audits); + for (const response of responses.filter((candidate) => candidate.status === 202)) { + const challenge = await response.json() as { continuationToken: string }; + expect(auditJson).not.toContain(challenge.continuationToken); + } + for (const message of sentMessages) { + expect(auditJson).not.toContain(codeFromMessage(message)); + } + + db.users[0].auth_generation = 1; + const afterGenerationChange = await worker.fetch( + loginRequestFrom("operator@example.org", "Valid#Pass2026", "198.51.100.106"), + runtime + ); + expect(afterGenerationChange.status).toBe(202); + expect(sentMessages).toHaveLength(6); + expect(db.loginStepUpChallenges).toHaveLength(6); + expect(db.sessions).toHaveLength(0); + }); + + it("shares one five-guess budget across concurrent submissions to multiple active challenges", async () => { + const db = new InMemoryD1(); + await seedStepUpAccount(db); + const sentMessages: unknown[] = []; + const runtime = stepUpEmailRuntime(db, sentMessages); + const issuedResponses = [ + await worker.fetch(loginRequestFrom("operator@example.org", "Valid#Pass2026", "198.51.100.110"), runtime), + await worker.fetch(loginRequestFrom("operator@example.org", "Valid#Pass2026", "198.51.100.111"), runtime) + ]; + const challenges = await Promise.all(issuedResponses.map((response) => response.json())) as Array<{ + challengeId: string; + continuationToken: string; + }>; + const codes = sentMessages.map(codeFromMessage); + + const wrongResponses = await Promise.all( + Array.from({ length: 6 }, (_, index) => { + const challengeIndex = index % 2; + return worker.fetch(loginMfaRequest({ + ...challenges[challengeIndex], + code: differentCode(codes[challengeIndex]) + }), env(db)); + }) + ); + + expect(wrongResponses.every((response) => response.status === 400)).toBe(true); + expect(db.loginStepUpChallenges.reduce((sum, challenge) => sum + challenge.failed_attempts, 0)).toBe(5); + const correctAfterExhaustion = await worker.fetch( + loginMfaRequest({ ...challenges[1], code: codes[1] }), + env(db) + ); + expect(correctAfterExhaustion.status).toBe(400); + expect(db.sessions).toHaveLength(0); + }); + + it("does not regain code guesses by exhausting one challenge and reissuing another", async () => { + const db = new InMemoryD1(); + await seedStepUpAccount(db); + const sentMessages: unknown[] = []; + const runtime = stepUpEmailRuntime(db, sentMessages); + const firstResponse = await worker.fetch( + loginRequestFrom("operator@example.org", "Valid#Pass2026", "198.51.100.120"), + runtime + ); + const first = await firstResponse.json() as { challengeId: string; continuationToken: string }; + const firstCode = codeFromMessage(sentMessages[0]); + for (let attempt = 0; attempt < 5; attempt += 1) { + const wrong = await worker.fetch( + loginMfaRequest({ ...first, code: differentCode(firstCode) }), + env(db) + ); + expect(wrong.status).toBe(400); + } + + const secondResponse = await worker.fetch( + loginRequestFrom("operator@example.org", "Valid#Pass2026", "198.51.100.121"), + runtime + ); + expect(secondResponse.status).toBe(202); + const second = await secondResponse.json() as { challengeId: string; continuationToken: string }; + const secondCode = codeFromMessage(sentMessages[1]); + const bypass = await worker.fetch(loginMfaRequest({ ...second, code: secondCode }), env(db)); + + expect(bypass.status).toBe(400); + expect(db.sessions).toHaveLength(0); + }); + + it("resets the aggregate guess budget only after expiry or a new auth generation", async () => { + const expiredDb = new InMemoryD1(); + const expiredIssued = await seededStepUp({ db: expiredDb }); + const expiredChallenge = await expiredIssued.response.json() as { challengeId: string; continuationToken: string }; + const expiredCode = codeFromMessage(expiredIssued.sentMessages[0]); + for (let attempt = 0; attempt < 5; attempt += 1) { + await worker.fetch(loginMfaRequest({ ...expiredChallenge, code: differentCode(expiredCode) }), env(expiredDb)); + } + vi.setSystemTime(new Date("2026-07-04T12:10:00.001Z")); + for (let index = 0; index < 5; index += 1) { + seedAudit( + expiredDb, + "LOGIN_FAILED", + "operator@example.org", + `2026-07-04T12:0${5 + index}:00.000Z`, + `203.0.113.${30 + index}` + ); + } + const freshMessages: unknown[] = []; + const freshRuntime = stepUpEmailRuntime(expiredDb, freshMessages); + const freshResponse = await worker.fetch( + loginRequestFrom("operator@example.org", "Valid#Pass2026", "198.51.100.130"), + freshRuntime + ); + expect(freshResponse.status).toBe(202); + const freshChallenge = await freshResponse.json() as { challengeId: string; continuationToken: string }; + const afterExpiry = await worker.fetch( + loginMfaRequest({ ...freshChallenge, code: codeFromMessage(freshMessages[0]) }), + env(expiredDb) + ); + expect(afterExpiry.status).toBe(200); + + vi.setSystemTime(new Date("2026-07-04T12:00:00.000Z")); + const generationDb = new InMemoryD1(); + const generationIssued = await seededStepUp({ db: generationDb }); + const generationChallenge = await generationIssued.response.json() as { challengeId: string; continuationToken: string }; + const generationCode = codeFromMessage(generationIssued.sentMessages[0]); + for (let attempt = 0; attempt < 5; attempt += 1) { + await worker.fetch(loginMfaRequest({ ...generationChallenge, code: differentCode(generationCode) }), env(generationDb)); + } + generationDb.users[0].auth_generation = 1; + const nextGenerationMessages: unknown[] = []; + const nextGenerationRuntime = stepUpEmailRuntime(generationDb, nextGenerationMessages); + const nextGenerationResponse = await worker.fetch( + loginRequestFrom("operator@example.org", "Valid#Pass2026", "198.51.100.131"), + nextGenerationRuntime + ); + expect(nextGenerationResponse.status).toBe(202); + const nextGenerationChallenge = await nextGenerationResponse.json() as { challengeId: string; continuationToken: string }; + const afterGenerationChange = await worker.fetch( + loginMfaRequest({ ...nextGenerationChallenge, code: codeFromMessage(nextGenerationMessages[0]) }), + env(generationDb) + ); + expect(afterGenerationChange.status).toBe(200); + }); + it("completes the emailed challenge once and rejects a replay generically", async () => { const db = new InMemoryD1(); const { response, sentMessages } = await seededStepUp({ db }); @@ -1198,6 +1457,28 @@ describe("auth rate limiting", () => { expect(changedDb.sessions).toHaveLength(0); }); + it.each([ + ["email", (user: Record) => { user.email = "changed@example.org"; }], + ["password hash", (user: Record) => { user.password_hash = "changed-hash"; }], + ["password salt", (user: Record) => { user.password_salt = "changed-salt"; }], + ["disabled state", (user: Record) => { user.disabled_at = "2026-07-04T12:00:00.000Z"; }] + ] as const)("rejects a post-issuance challenge after the user %s changes", async (_field, mutateUser) => { + const db = new InMemoryD1(); + const issued = await seededStepUp({ db }); + const challenge = await issued.response.json() as { challengeId: string; continuationToken: string }; + const code = codeFromMessage(issued.sentMessages[0]); + mutateUser(db.users[0]); + + const response = await worker.fetch(loginMfaRequest({ ...challenge, code }), env(db)); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toEqual({ + error: "invalid_login_mfa_challenge", + message: "El código no es válido o ya expiró. Inicie sesión nuevamente." + }); + expect(db.sessions).toHaveLength(0); + }); + it("invalidates the challenge and returns a generic 503 when email delivery fails", async () => { const db = new InMemoryD1(); const { response } = await seededStepUp({ @@ -1217,6 +1498,36 @@ describe("auth rate limiting", () => { expect(db.loginStepUpChallenges[0].invalidated_at).not.toBeNull(); }); + it("counts failed deliveries toward the aggregate issuance cap without reopening a flood path", async () => { + const db = new InMemoryD1(); + await seedStepUpAccount(db); + const sentMessages: unknown[] = []; + const runtime = stepUpEmailRuntime(db, sentMessages, async () => { + throw new Error("provider detail must stay private"); + }); + + const responses = []; + for (let index = 0; index < 6; index += 1) { + responses.push(await worker.fetch( + loginRequestFrom("operator@example.org", "Valid#Pass2026", `198.51.100.${140 + index}`), + runtime + )); + } + + expect(responses.every((response) => response.status === 503)).toBe(true); + for (const response of responses) { + await expect(response.json()).resolves.toEqual({ + error: "login_mfa_unavailable", + message: "No se pudo enviar el código de verificación. Intente de nuevo en unos minutos." + }); + } + expect(sentMessages).toHaveLength(5); + expect(db.loginStepUpChallenges).toHaveLength(5); + expect(db.loginStepUpChallenges.every((challenge) => challenge.invalidated_at !== null)).toBe(true); + expect(db.sessions).toHaveLength(0); + expect(JSON.stringify(db.audits)).not.toContain("provider detail must stay private"); + }); + it("keeps wrong credentials generic and audited after the account threshold", async () => { const db = new InMemoryD1(); const hashed = await hashPassword("Valid#Pass2026", "fixed-salt", { enforcePolicy: false }); From 69325d9ad84b3aec42c85f73e8227e827a5678d9 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:09:10 -0600 Subject: [PATCH 08/22] fix(worker): narrow Wompi readiness error handling --- src/worker/services/wompiApi.ts | 13 ++-- test/worker/wompiApi.test.ts | 65 +++++++++++++++---- .../workerFetch.donation-intents.test.ts | 12 ++++ 3 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/worker/services/wompiApi.ts b/src/worker/services/wompiApi.ts index d4e546c7..40fcbac3 100644 --- a/src/worker/services/wompiApi.ts +++ b/src/worker/services/wompiApi.ts @@ -151,7 +151,7 @@ export class WompiApiService { // The PUT replaces the whole object, so formaPago and configuracion must be // resent or Wompi would re-enable every payment method / re-email the donor. formaPago: this.linkFormaPago(), - configuracion: await this.linkConfiguracion() + configuracion: await this.linkConfiguracion(requireSecret(this.env, "APP_ORIGIN")) }; const response = await this.authorizedFetch(`${ENLACE_PAGO_URL}/${intent.wompi_id_enlace}`, "PUT", body); @@ -178,10 +178,16 @@ export class WompiApiService { } private async linkPreflight(): Promise>> { + const origin = await this.assertLinkConfiguration(); + return this.linkConfiguracion(origin); + } + + private async assertLinkConfiguration(): Promise { try { await assertFiscalCollectionReady(this.env); + const origin = requireSecret(this.env, "APP_ORIGIN"); this.wompiCredentials(); - return await this.linkConfiguracion(); + return origin; } catch { throw wompiConfigurationError(); } @@ -207,7 +213,7 @@ export class WompiApiService { }; } - private async linkConfiguracion(): Promise<{ + private async linkConfiguracion(origin: string): Promise<{ urlRedirect: string; urlWebhook: string; esMontoEditable: false; @@ -217,7 +223,6 @@ export class WompiApiService { notificarTransaccionCliente: boolean; duracionInterfazIntentoMinutos: number; }> { - const origin = requireSecret(this.env, "APP_ORIGIN"); // These values live in D1 and are intentionally read for every link. An owner // can change notification targets without rotating secrets or redeploying. const notifications = await loadWompiNotificationSettings(this.repo); diff --git a/test/worker/wompiApi.test.ts b/test/worker/wompiApi.test.ts index 5ec9fe92..918c85ad 100644 --- a/test/worker/wompiApi.test.ts +++ b/test/worker/wompiApi.test.ts @@ -27,10 +27,27 @@ const CARDS_ONLY_FORMA_PAGO = { permitePagoNequi: false }; +const WOMPI_CONFIGURATION_ERROR = "No se pudo preparar la configuración de Wompi"; +const INTERNAL_CONFIGURATION_TEXT = [ + "EMISOR_CONFIG_JSON", + "MH_CERT_XML", + "MH_CERT_PASSWORD", + "MH_USER_TEST", + "MH_PASSWORD_TEST", + "MH_AUTH_URL_TEST", + "MH_RECEPCION_URL_TEST", + "MH_ANULACION_URL_TEST", + "El certificado del Ministerio de Hacienda", + "La contraseña de la llave privada", + "not-a-pkcs8-key", + "99999999999999" +]; + // Minimal in-memory D1 covering exactly the two app_settings statements // Repository.getSetting/setSetting issue, so the token cache has real storage. class FakeD1 { readonly settings = new Map(); + getSettingFault: Error | null = null; prepare(sql: string) { return new FakeStatement(this, sql); } @@ -45,6 +62,9 @@ class FakeStatement { } async first(): Promise { if (this.sql.includes("SELECT value FROM app_settings WHERE key = ?")) { + if (this.db.getSettingFault) { + throw this.db.getSettingFault; + } const value = this.db.settings.get(String(this.args[0])); return value === undefined ? null : ({ value } as T); } @@ -246,8 +266,7 @@ describe("Wompi API service", () => { const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); expect(error).toBeInstanceOf(WompiApiError); - expect((error as WompiApiError).message).toBe("No se pudo preparar la configuración de Wompi"); - expect((error as WompiApiError).message).not.toContain("EMISOR_CONFIG_JSON"); + expectSafeConfigurationError(error); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -256,28 +275,32 @@ describe("Wompi API service", () => { ["mismatched", (env: Env) => { env.MH_CERT_XML = signingCertificateXml.replace("10000000000001", "99999999999999"); }], ["inactive", (env: Env) => { env.MH_CERT_XML = signingCertificateXml.replace("true", "false"); }], ["unimportable", (env: Env) => { env.MH_CERT_XML = signingCertificateXml.replace(/[\s\S]*?<\/encodied>/, "not-a-pkcs8-key"); }] - ])("fails closed before Wompi when MH signing material is %s", async (_label, makeInvalid) => { + ])("returns a safe typed error before Wompi when MH signing material is %s", async (_label, makeInvalid) => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const env = realEnv(); makeInvalid(env); - await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(); + const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); + expectSafeConfigurationError(error); expect(fetchMock).not.toHaveBeenCalled(); }); it.each([ - ["credentials", (env: Env) => { delete env.MH_USER_TEST; }], + ["certificate password", (env: Env) => { delete env.MH_CERT_PASSWORD; }], + ["credential user", (env: Env) => { delete env.MH_USER_TEST; }], + ["credential password", (env: Env) => { delete env.MH_PASSWORD_TEST; }], ["authentication endpoint", (env: Env) => { delete env.MH_AUTH_URL_TEST; }], ["reception endpoint", (env: Env) => { delete env.MH_RECEPCION_URL_TEST; }], ["invalidation endpoint", (env: Env) => { delete env.MH_ANULACION_URL_TEST; }] - ])("fails closed before Wompi when the MH TEST %s is missing", async (_label, makeInvalid) => { + ])("returns a safe typed error before Wompi when the MH TEST %s is missing", async (_label, makeInvalid) => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); const env = realEnv(); makeInvalid(env); - await expect(new WompiApiService(env).createPaymentLink(intent())).rejects.toThrow(); + const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); + expectSafeConfigurationError(error); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -292,8 +315,7 @@ describe("Wompi API service", () => { makeInvalid(env); const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(WompiApiError); - expect((error as WompiApiError).message).toBe("No se pudo preparar la configuración de Wompi"); + expectSafeConfigurationError(error); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -309,8 +331,20 @@ describe("Wompi API service", () => { delete env.MH_USER_PROD; const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); - expect(error).toBeInstanceOf(WompiApiError); - expect((error as WompiApiError).message).toBe("No se pudo preparar la configuración de Wompi"); + expectSafeConfigurationError(error); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not reclassify a notification D1 fault as Wompi configuration", async () => { + const db = new FakeD1(); + const fault = new Error("synthetic D1 notification read fault"); + db.getSettingFault = fault; + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const error = await new WompiApiService(realEnv(db)).createPaymentLink(intent()).catch((caught: unknown) => caught); + expect(error).toBe(fault); + expect(error).not.toBeInstanceOf(WompiApiError); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -795,6 +829,15 @@ function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); } +function expectSafeConfigurationError(error: unknown): void { + expect(error).toBeInstanceOf(WompiApiError); + const message = (error as WompiApiError).message; + expect(message).toBe(WOMPI_CONFIGURATION_ERROR); + for (const forbidden of INTERNAL_CONFIGURATION_TEXT) { + expect(message).not.toContain(forbidden); + } +} + async function generatedCertificateXml(password: string): Promise { const pair = (await crypto.subtle.generateKey( { diff --git a/test/worker/workerFetch.donation-intents.test.ts b/test/worker/workerFetch.donation-intents.test.ts index 341dbd20..85441713 100644 --- a/test/worker/workerFetch.donation-intents.test.ts +++ b/test/worker/workerFetch.donation-intents.test.ts @@ -542,6 +542,18 @@ describe("donation intents", () => { await expect(response.json()).resolves.toMatchObject({ error: "wompi_link_failed" }); expect(db.donationIntents).toHaveLength(1); expect(db.donationIntents[0].status).toBe("PENDING"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + const [tokenUrl, tokenInit] = fetchSpy.mock.calls[0]; + expect(tokenUrl).toBe("https://id.wompi.sv/connect/token"); + expect(tokenInit?.method).toBe("POST"); + const [linkUrl, linkInit] = fetchSpy.mock.calls[1]; + expect(linkUrl).toBe("https://api.wompi.sv/EnlacePago"); + expect(linkInit?.method).toBe("POST"); + expect(linkInit?.headers).toMatchObject({ authorization: "Bearer wompi-token" }); + expect(JSON.parse(String(linkInit?.body))).toMatchObject({ + identificadorEnlaceComercio: expect.stringMatching(/^di_/), + monto: 25.5 + }); } finally { fetchSpy.mockRestore(); } From db50a483a6f79ee13d64b911a1a30a7f46e2f48c Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:50:01 -0600 Subject: [PATCH 09/22] fix(worker): bound provider creation budgets --- README.es.md | 9 +- README.md | 9 +- docs/stripe-us-giving.md | 2 +- migrations/0046_provider_creation_budgets.sql | 45 ++ scripts/check-migration-immutability.mjs | 6 +- src/worker/index.ts | 147 ++++--- src/worker/services/donations.ts | 86 +++- src/worker/storage/repository.ts | 12 + .../storage/repository/donationIntents.ts | 6 +- src/worker/storage/repository/rateLimits.ts | 103 +++++ .../storage/repository/stripeDonations.ts | 7 +- src/worker/types.ts | 6 +- test/scripts/migrationImmutability.test.ts | 23 +- .../productionProvisioningDocs.test.ts | 2 +- test/scripts/stripeProvisioningDocs.test.ts | 2 +- test/worker/donationIntents.test.ts | 8 +- test/worker/fixtures.ts | 1 + test/worker/stripeAcknowledgment.test.ts | 2 +- .../stripeAnnualStatementMigration.test.ts | 2 +- test/worker/stripeRepository.test.ts | 2 +- test/worker/stripeRoutes.test.ts | 159 ++++++- test/worker/support/inMemoryD1.ts | 139 +++++- test/worker/wompiApi.test.ts | 1 + test/worker/workerFetch.auth-infra.test.ts | 407 +++++++++++++++++- .../workerFetch.donation-intents.test.ts | 194 +++++++-- 25 files changed, 1255 insertions(+), 125 deletions(-) create mode 100644 migrations/0046_provider_creation_budgets.sql diff --git a/README.es.md b/README.es.md index 241af58d..9c1f70ea 100644 --- a/README.es.md +++ b/README.es.md @@ -208,7 +208,7 @@ DiezmosSV/ │ ├── client/ # Panel React + Vite, /donar, fuentes, recursos │ └── shared/ # Catálogos · DUI · NIT · ventanas legales · política de contraseñas │ # correcciones fiscales · entrega · montos · correo -├── migrations/ # Esquema D1 (incremental, solo se agrega, 0001…0044) +├── migrations/ # Esquema D1 (incremental, solo se agrega, 0001…0046) ├── DTE/svfe-json-schemas/ # Esquemas JSON de MH para validación ├── docs/ # Despliegue/UAT · manual del operador · restauración de retención │ # cutover/conciliación de claims fiscales · recuperación previa al CDE @@ -1075,7 +1075,7 @@ El modelo de seguridad es el modelo del claim fiscal aplicado a una ruta de repa ## 📚 Modelo de datos
-Tablas de D1 (migrations/0001_init.sql, extendidas hasta la 0044) +Tablas de D1 (migrations/0001_init.sql, extendidas hasta la 0046)
@@ -1103,8 +1103,9 @@ El modelo de seguridad es el modelo del claim fiscal aplicado a una ruta de repa | `stripe_invoice_settlement_retention_generations` | Libro interno y monotónico de pertenencia para instantáneas de convergencia de facturas mensuales. No forma parte del payload archivado y se reconstruye automáticamente al restaurar. | | `contingency_batches` · `contingency_batch_lines` | Envíos históricos de lotes de contingencia a MH y sus resultados por CDE (solo lectura). | | `app_settings` | Configuración en tiempo de ejecución (ambiente de emisión, plantillas de correo, marca, correo de alertas). | -| `users` · `sessions` · `password_reset_tokens` | Autenticación, RBAC y restablecimiento de contraseña autogestionado. | -| `login_rate_limits` · `security_rate_limit_claims` | Limitación de tasa respaldada en D1 para el inicio de sesión, el restablecimiento de contraseña y los intentos públicos de donación, con la procedencia del claim registrada en las filas que admite. | +| `users` · `sessions` · `password_reset_tokens` · `login_step_up_challenges` | Autenticación, RBAC, restablecimiento de contraseña autogestionado y desafíos breves de verificación escalonada de cuenta, almacenados solo como hashes. | +| `login_rate_limits` · `security_rate_limit_claims` | Limitación de tasa respaldada en D1 para el inicio de sesión, el restablecimiento de contraseña y las capacidades de datos del donante. | +| `provider_creation_claims` | Presupuestos atómicos de 15 minutos por cliente, proveedor y global para enlaces Wompi nuevos y estado Checkout de Stripe. Los clientes IPv6 comparten un `/64` normalizado; las filas padre conservan la procedencia del claim después de barrer el libro temporal. | Las claves foráneas están habilitadas (`PRAGMA foreign_keys = ON`). El acceso es SQL crudo a través de `src/worker/storage/repository.ts` — sin ORM. diff --git a/README.md b/README.md index 5fcb7495..1560895e 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ DiezmosSV/ │ ├── client/ # React + Vite admin panel, /donar, fonts, assets │ └── shared/ # Catalogs · DUI · NIT · legal windows · password policy │ # fiscal corrections · checkout · money · email -├── migrations/ # D1 schema (incremental, append-only 0001…0044) +├── migrations/ # D1 schema (incremental, append-only 0001…0046) ├── DTE/svfe-json-schemas/ # MH-bundled JSON schemas for validation ├── docs/ # Deploy/UAT · operator runbook · retention-restore │ # fiscal-claim cutover/reconciliation · pre-CDE recovery @@ -1037,7 +1037,7 @@ The safety model is the fiscal-claim model applied to a repair path: ## 🗄 Data model
-D1 tables (migrations/0001_init.sql, extended through 0044) +D1 tables (migrations/0001_init.sql, extended through 0046)
@@ -1065,8 +1065,9 @@ The safety model is the fiscal-claim model applied to a repair path: | `stripe_invoice_settlement_retention_generations` | Internal monotonic membership ledger for monthly-invoice convergence snapshots. It is not an archive payload and is rebuilt automatically on restore. | | `contingency_batches` · `contingency_batch_lines` | Historical MH contingency batch submissions and per-CDE results (read-only). | | `app_settings` | Runtime settings (emission environment, email templates, branding, alert email). | -| `users` · `sessions` · `password_reset_tokens` | Authentication, RBAC, and self-service password reset. | -| `login_rate_limits` · `security_rate_limit_claims` | D1-backed rate limiting for login, password reset, and public donation intents, with claim provenance recorded on the rows they admit. | +| `users` · `sessions` · `password_reset_tokens` · `login_step_up_challenges` | Authentication, RBAC, self-service password reset, and short-lived hashed account step-up verification challenges. | +| `login_rate_limits` · `security_rate_limit_claims` | D1-backed rate limiting for login, password reset, and donor-data capabilities. | +| `provider_creation_claims` | Atomic 15-minute client, provider, and global budgets for new Wompi links and Stripe Checkout state. IPv6 clients share a normalized `/64`; parent rows retain claim provenance after the expiring ledger is swept. | Foreign keys are enabled (`PRAGMA foreign_keys = ON`). Access is raw SQL via `src/worker/storage/repository.ts` — no ORM. diff --git a/docs/stripe-us-giving.md b/docs/stripe-us-giving.md index 4ef48407..5331768a 100644 --- a/docs/stripe-us-giving.md +++ b/docs/stripe-us-giving.md @@ -170,4 +170,4 @@ Pendiente del propietario del despliegue: Para detener nuevas entregas sin perder la reconciliación, mantenga la revisión actual compatible con Stripe y configure `DONATION_INTAKE_DISABLED=true`. Ese interruptor bloquea la creación de Checkout nueva, pero debe conservar `/webhooks/stripe`, la consulta durable `/api/donations/stripe/session/`, `/api/donations/stripe/portal`, los acuses de recibo pendientes y la conciliación de constancias anuales. -Si el incidente exige desplegar código anterior, use solamente una revisión conocida compatible con Stripe que retenga esas rutas y tareas; nunca despliegue un SHA anterior a la integración Stripe mientras existan sesiones, facturas o suscripciones abiertas. Conserve las migraciones aditivas `0032` (tablas base), `0033` (tipo de entrega), `0034` (constancias anuales), `0035` (cronología monotónica del proveedor), `0036` (retención consistente), `0037` (seguridad de entregas), `0038` (cercas finales de integridad), `0039` (evidencia de contacto del donante), `0040` (evidencia no sensible del método realmente usado), `0041` (evidencia inmutable del correo anual), `0042` (reclamo de la constancia anual para entregas anteriores a 0041), `0043` (cerca que exige evidencia congelada antes del despacho o estado SENT) y `0044` (capacidad de acceso y límites atómicos del Portal de Stripe), junto con todas sus filas. No elimine ni revierta esas migraciones: una reversión de código no revierte D1, webhooks, facturas, suscripciones, constancias ni evidencia de cronología. Mantenga la clave activa y el secreto de webhook operativo hasta conciliar sesiones abiertas y entregas mensuales. Desactivar una configuración o clave sin esa conciliación puede impedir renovaciones o administración de la persona donante. +Si el incidente exige desplegar código anterior, use solamente una revisión conocida compatible con Stripe que retenga esas rutas y tareas; nunca despliegue un SHA anterior a la integración Stripe mientras existan sesiones, facturas o suscripciones abiertas. Conserve las migraciones aditivas `0032` (tablas base), `0033` (tipo de entrega), `0034` (constancias anuales), `0035` (cronología monotónica del proveedor), `0036` (retención consistente), `0037` (seguridad de entregas), `0038` (cercas finales de integridad), `0039` (evidencia de contacto del donante), `0040` (evidencia no sensible del método realmente usado), `0041` (evidencia inmutable del correo anual), `0042` (reclamo de la constancia anual para entregas anteriores a 0041), `0043` (cerca que exige evidencia congelada antes del despacho o estado SENT), `0044` (capacidad de acceso y límites atómicos del Portal de Stripe) y `0046` (presupuestos atómicos de creación de proveedor), junto con todas sus filas. No elimine ni revierta esas migraciones: una reversión de código no revierte D1, webhooks, facturas, suscripciones, constancias ni evidencia de cronología. Mantenga la clave activa y el secreto de webhook operativo hasta conciliar sesiones abiertas y entregas mensuales. Desactivar una configuración o clave sin esa conciliación puede impedir renovaciones o administración de la persona donante. diff --git a/migrations/0046_provider_creation_budgets.sql b/migrations/0046_provider_creation_budgets.sql new file mode 100644 index 00000000..2d7021a0 --- /dev/null +++ b/migrations/0046_provider_creation_budgets.sql @@ -0,0 +1,45 @@ +-- Repository-owned rolling admission for public provider-object creation. +-- Claims deliberately have no parent foreign key: the 15-minute ledger may be +-- swept while the durable intent/session keeps the claim id as provenance. +CREATE TABLE provider_creation_claims ( + id TEXT PRIMARY KEY, + provider TEXT NOT NULL CHECK (provider IN ('WOMPI', 'STRIPE')), + client_key_hash TEXT NOT NULL, + stripe_request_id TEXT, + claimed_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + CHECK ( + (provider = 'WOMPI' AND stripe_request_id IS NULL) + OR (provider = 'STRIPE' AND stripe_request_id IS NOT NULL) + ) +); + +CREATE INDEX idx_provider_creation_claims_client_claimed + ON provider_creation_claims(client_key_hash, claimed_at); + +CREATE INDEX idx_provider_creation_claims_provider_claimed + ON provider_creation_claims(provider, claimed_at); + +CREATE INDEX idx_provider_creation_claims_global_claimed + ON provider_creation_claims(claimed_at); + +CREATE INDEX idx_provider_creation_claims_expires + ON provider_creation_claims(expires_at); + +CREATE UNIQUE INDEX idx_provider_creation_claims_stripe_request + ON provider_creation_claims(provider, stripe_request_id) + WHERE provider = 'STRIPE' AND stripe_request_id IS NOT NULL; + +ALTER TABLE donation_intents + ADD COLUMN provider_creation_claim_id TEXT; + +ALTER TABLE stripe_checkout_sessions + ADD COLUMN provider_creation_claim_id TEXT; + +CREATE INDEX idx_donation_intents_provider_creation_claim + ON donation_intents(provider_creation_claim_id) + WHERE provider_creation_claim_id IS NOT NULL; + +CREATE INDEX idx_stripe_checkout_provider_creation_claim + ON stripe_checkout_sessions(provider_creation_claim_id) + WHERE provider_creation_claim_id IS NOT NULL; diff --git a/scripts/check-migration-immutability.mjs b/scripts/check-migration-immutability.mjs index 69bd0454..82b518b9 100644 --- a/scripts/check-migration-immutability.mjs +++ b/scripts/check-migration-immutability.mjs @@ -47,7 +47,9 @@ export const IMMUTABLE_MIGRATION_SHA256 = Object.freeze({ "0041_stripe_annual_email_evidence.sql": "471f4c90154e18b1115aae8a3dbc3a3419b994bf901ce25b23d8f0cc37b68378", "0042_stripe_annual_email_evidence_reclaim.sql": "44277c88b821f7eb2dd3dbeb783d839d6507358ed02d4894a7715ffbfe3fe506", "0043_stripe_annual_email_evidence_dispatch_guard.sql": "dd00177c7dece29d887cfd7913b7b2ca0eecd7e63862c6f09d459a0b2a054714", - "0044_stripe_portal_capability.sql": "2a4be49afc5da8201438999cec857978910631ec002084467a50951b5373d1ca" + "0044_stripe_portal_capability.sql": "2a4be49afc5da8201438999cec857978910631ec002084467a50951b5373d1ca", + "0045_login_step_up_mfa.sql": "9fd64e15528cb80f72d99389cec87378c01cada6adb70497ece9e4cb567853ca", + "0046_provider_creation_budgets.sql": "dd014b8bf754da9bca91cddf96d77dcb48718a4644cd3c89d01ddaecc8bca35d" }); export function assertImmutableMigrations( @@ -96,7 +98,7 @@ export function assertImmutableMigrations( if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { assertImmutableMigrations(); - process.stdout.write("Historical migrations 0001-0044 are immutable.\n"); + process.stdout.write("Historical migrations 0001-0046 are immutable.\n"); } catch (error) { process.stderr.write( `${error instanceof Error ? error.message : String(error)}\n` diff --git a/src/worker/index.ts b/src/worker/index.ts index 53935188..f458d6dc 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -29,6 +29,10 @@ import { IntentValidationError, INTENT_THROTTLE_LIMIT, INTENT_THROTTLE_WINDOW_MINUTES, + PROVIDER_CREATION_CLIENT_LIMIT, + PROVIDER_CREATION_GLOBAL_LIMIT, + PROVIDER_CREATION_PROVIDER_LIMIT, + providerCreationRateIdentity, isDraftIntentBody, validateDatosInput, validateDraftIntentInput, @@ -288,6 +292,16 @@ function intentThrottleExpiresIso(): string { return new Date(Date.now() + INTENT_THROTTLE_WINDOW_MINUTES * 60_000).toISOString(); } +function providerCreationLimitedResponse(): Response { + return jsonResponse( + { + error: "too_many_attempts", + message: "Demasiados intentos. Espere 15 minutos e intente de nuevo." + }, + { status: 429, headers: { "Cache-Control": "no-store" } } + ); +} + async function listAuditForUser( repo: Repository, user: AuthUser, @@ -1324,23 +1338,27 @@ async function handleCreateDonationIntent(ctx: ApiRouteContext): Promise, clientIp, rateLimitClaimId) - : await createDonationIntent(ctx.env, ctx.repo, input as ReturnType, clientIp, rateLimitClaimId); + ? await createDraftDonationIntent(ctx.env, ctx.repo, input as ReturnType, clientIp, providerClaim.id) + : await createDonationIntent(ctx.env, ctx.repo, input as ReturnType, clientIp, providerClaim.id); return jsonResponse(created, { status: 201 }); } catch (error) { + // The repository deletes only an unattached claim. Once the PENDING parent + // exists, readiness/provider failures retain their durable admission proof. + await ctx.repo.releaseUnusedProviderCreationClaim(providerClaim.id); if (error instanceof IntentLinkError) { // Intent stays PENDING and expires harmlessly on the cron sweep. return jsonResponse({ error: "wompi_link_failed", message: "No se pudo generar el enlace de pago. Intente de nuevo en unos minutos." }, { status: 502 }); @@ -1393,54 +1411,69 @@ async function handleCreateStripeCheckout(ctx: ApiRouteContext): Promise>; + let reservation: Awaited>; + try { + request = await buildStripeCheckoutCreationRequest( + ctx, + checkoutId, + input, + stripeConfiguration + ); + reservation = await ctx.repo.reserveStripeCheckout({ + id: checkoutId, + requestId: input.requestId, + requestFingerprint: request.fingerprint, + frequency: input.frequency, + giftType: input.giftType, + amountCents: input.amountCents, + livemode: stripeConfiguration.livemode, + providerCreationClaimId: providerClaim.id, + now: claimNow + }); + } catch (error) { + await ctx.repo.releaseUnusedProviderCreationClaim(providerClaim.id); + throw error; + } + if (reservation.kind !== "CREATED") { + await ctx.repo.releaseUnusedProviderCreationClaim(providerClaim.id); + const plan = await prepareExistingStripeCheckoutCreation( + ctx, + reservation.record, + input, + stripeConfiguration + ); + if (plan instanceof Response) return plan; + ({ checkout, params } = plan); + } else { + checkout = reservation.record; + params = request.params; + } } } @@ -1655,6 +1688,16 @@ async function existingStripeCheckoutResponse( } } +function stripeCheckoutCreationInProgressResponse(): Response { + return jsonResponse( + { + error: "stripe_checkout_in_progress", + message: "Su entrega se está preparando. Inténtelo de nuevo en un momento." + }, + { status: 409, headers: { "Cache-Control": "no-store" } } + ); +} + async function claimStripeProviderRecoveryRead( ctx: ApiRouteContext, kind: "OPEN_REPLAY" | "STATUS_RECOVERY", diff --git a/src/worker/services/donations.ts b/src/worker/services/donations.ts index ecf6b5b5..02aa5d7c 100644 --- a/src/worker/services/donations.ts +++ b/src/worker/services/donations.ts @@ -46,9 +46,18 @@ function isGiftType(value: unknown): value is DonationGiftType { return typeof value === "string" && (GIFT_TYPES as readonly string[]).includes(value); } -// Per-IP throttle: at most 5 intent creations per rolling 15 minutes. -export const INTENT_THROTTLE_WINDOW_MINUTES = 15; -export const INTENT_THROTTLE_LIMIT = 5; +// Public provider-object creation is bounded in one rolling D1 ledger. The +// client ceiling spans both providers; provider and global ceilings bound +// distributed callers before they can create durable or third-party state. +export const PROVIDER_CREATION_WINDOW_MINUTES = 15; +export const PROVIDER_CREATION_CLIENT_LIMIT = 5; +export const PROVIDER_CREATION_PROVIDER_LIMIT = 60; +export const PROVIDER_CREATION_GLOBAL_LIMIT = 100; + +// The datos endpoint keeps its existing D1-only per-IP throttle. These aliases +// also preserve the established 15-minute donor-facing retry guidance. +export const INTENT_THROTTLE_WINDOW_MINUTES = PROVIDER_CREATION_WINDOW_MINUTES; +export const INTENT_THROTTLE_LIMIT = PROVIDER_CREATION_CLIENT_LIMIT; // A validation failure carries a distinct machine code plus a Spanish usted-form // message; the route serializes it to a 400 body. @@ -292,11 +301,72 @@ export function intentThrottleSinceIso(): string { return new Date(Date.now() - INTENT_THROTTLE_WINDOW_MINUTES * 60_000).toISOString(); } +// Provider budgets never hash raw header text directly. Canonical IPv4 remains +// host-specific; valid IPv6 is collapsed to a lower-case, zero-padded /64. +// Ambiguous forwarding syntax and malformed values share one unknown bucket. +export function providerCreationRateIdentity(value: string | null): string { + if (!value) return "unknown"; + const ipv4 = parseCanonicalIpv4(value); + if (ipv4) return ipv4.join("."); + const ipv6 = parseIpv6Groups(value); + if (!ipv6) return "unknown"; + return `${ipv6.slice(0, 4).map((group) => group.toString(16).padStart(4, "0")).join(":")}::/64`; +} + +function parseCanonicalIpv4(value: string): number[] | null { + const parts = value.split("."); + if (parts.length !== 4) return null; + const octets: number[] = []; + for (const part of parts) { + if (!/^(?:0|[1-9][0-9]{0,2})$/u.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + octets.push(octet); + } + return octets; +} + +function parseIpv6Groups(value: string): number[] | null { + if (/[\[\],%\s]/u.test(value)) return null; + let candidate = value.toLowerCase(); + if (!candidate.includes(":")) return null; + + if (candidate.includes(".")) { + const lastColon = candidate.lastIndexOf(":"); + if (lastColon < 0) return null; + const ipv4 = parseCanonicalIpv4(candidate.slice(lastColon + 1)); + if (!ipv4) return null; + candidate = `${candidate.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`; + } + + const compression = candidate.indexOf("::"); + if (compression !== candidate.lastIndexOf("::")) return null; + const parseSide = (side: string): string[] | null => { + if (!side) return []; + const groups = side.split(":"); + return groups.every((group) => /^[0-9a-f]{1,4}$/u.test(group)) ? groups : null; + }; + + let groups: string[]; + if (compression >= 0) { + const left = parseSide(candidate.slice(0, compression)); + const right = parseSide(candidate.slice(compression + 2)); + if (!left || !right || left.length + right.length >= 8) return null; + groups = [...left, ...Array(8 - left.length - right.length).fill("0"), ...right]; + } else { + const exact = parseSide(candidate); + if (!exact || exact.length !== 8) return null; + groups = exact; + } + return groups.map((group) => Number.parseInt(group, 16)); +} + // Header may be absent behind some proxies / in direct tests; collapse that to a // single shared "unknown" bucket rather than skipping the throttle, so an omitted // header cannot be used to bypass the per-IP limit. export function clientIpFrom(request: Request): string { - return request.headers.get("cf-connecting-ip")?.trim() || "unknown"; + const value = request.headers.get("cf-connecting-ip"); + return value === null || value.trim() === "" ? "unknown" : value; } export interface CreatedIntent { @@ -367,7 +437,7 @@ export async function createDonationIntent( repo: Repository, input: ValidatedIntentInput, clientIp: string, - rateLimitClaimId: string + providerCreationClaimId: string ): Promise { const start = nowIso(); const intent = await repo.createDonationIntent({ @@ -389,7 +459,7 @@ export async function createDonationIntent( clientIp, expiresAt: addHours(start, INTENT_VALIDITY_HOURS), datosTokenHash: null, - rateLimitClaimId + providerCreationClaimId }); return mintLinkForIntent(env, repo, intent); @@ -405,7 +475,7 @@ export async function createDraftDonationIntent( repo: Repository, input: ValidatedDraftIntentInput, clientIp: string, - rateLimitClaimId: string + providerCreationClaimId: string ): Promise { const start = nowIso(); const datosToken = base64UrlFromBytes(crypto.getRandomValues(new Uint8Array(32))); @@ -428,7 +498,7 @@ export async function createDraftDonationIntent( clientIp, expiresAt: addHours(start, INTENT_VALIDITY_HOURS), datosTokenHash, - rateLimitClaimId + providerCreationClaimId }); const created = await mintLinkForIntent(env, repo, intent); diff --git a/src/worker/storage/repository.ts b/src/worker/storage/repository.ts index 877914ba..444941e5 100644 --- a/src/worker/storage/repository.ts +++ b/src/worker/storage/repository.ts @@ -40,6 +40,7 @@ import { updateUserPasswordHashIfCurrent as updateUserPasswordHashIfCurrentRepository } from "./repository/identity"; import { + claimProviderCreationBudget as claimProviderCreationBudgetRepository, claimDonationDatosRateLimit as claimDonationDatosRateLimitRepository, claimDonationIntentRateLimit as claimDonationIntentRateLimitRepository, claimStripePortalRateLimit as claimStripePortalRateLimitRepository, @@ -50,6 +51,7 @@ import { deleteExpiredLoginRateLimits as deleteExpiredLoginRateLimitsRepository, deleteExpiredSecurityRateLimitClaims as deleteExpiredSecurityRateLimitClaimsRepository, finalizeStripeProviderRecoveryRead as finalizeStripeProviderRecoveryReadRepository, + releaseUnusedProviderCreationClaim as releaseUnusedProviderCreationClaimRepository, releaseUnusedDonationIntentRateLimitClaim as releaseUnusedDonationIntentRateLimitClaimRepository } from "./repository/rateLimits"; import { @@ -1616,6 +1618,16 @@ export class Repository { ); } + async claimProviderCreationBudget( + input: Parameters[1] + ): ReturnType { + return claimProviderCreationBudgetRepository(this.db, input); + } + + async releaseUnusedProviderCreationClaim(id: string): Promise { + return releaseUnusedProviderCreationClaimRepository(this.db, id); + } + async releaseUnusedDonationIntentRateLimitClaim(id: string): Promise { return releaseUnusedDonationIntentRateLimitClaimRepository(this.db, id); } diff --git a/src/worker/storage/repository/donationIntents.ts b/src/worker/storage/repository/donationIntents.ts index cd6f23f4..61193a1c 100644 --- a/src/worker/storage/repository/donationIntents.ts +++ b/src/worker/storage/repository/donationIntents.ts @@ -38,7 +38,7 @@ export interface CreateDonationIntentInput { clientIp: string | null; expiresAt: string; datosTokenHash: string | null; - rateLimitClaimId: string; + providerCreationClaimId: string; } export interface IntentDatosInput { @@ -65,7 +65,7 @@ export async function createDonationIntent( `INSERT INTO donation_intents ( id, status, amount_cents, donor_name, donor_document_type, donor_document, donor_email, donor_phone, direccion_departamento, direccion_municipio, direccion_distrito, direccion_complemento, donor_pais, client_ip, expires_at, gift_type, - datos_token_hash, rate_limit_claim_id + datos_token_hash, provider_creation_claim_id ) VALUES (?, 'PENDING', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .bind( @@ -85,7 +85,7 @@ export async function createDonationIntent( input.expiresAt, input.giftType, input.datosTokenHash, - input.rateLimitClaimId + input.providerCreationClaimId ) .run(); const record = await host.getDonationIntent(input.id); diff --git a/src/worker/storage/repository/rateLimits.ts b/src/worker/storage/repository/rateLimits.ts index 97ce146a..f7c8d15c 100644 --- a/src/worker/storage/repository/rateLimits.ts +++ b/src/worker/storage/repository/rateLimits.ts @@ -5,6 +5,106 @@ export type StripeProviderRecoveryClaim = | { kind: "IN_PROGRESS" } | { kind: "LIMITED" }; +export type ProviderCreationClaim = + | { kind: "CLAIMED"; id: string } + | { kind: "DUPLICATE" } + | { kind: "LIMITED" }; + +export async function claimProviderCreationBudget( + db: D1Database, + input: { + provider: "WOMPI" | "STRIPE"; + clientKeyHash: string; + stripeRequestId: string | null; + now: string; + cutoff: string; + expiresAt: string; + clientLimit: number; + providerLimit: number; + globalLimit: number; + } +): Promise { + const id = newId("provider_create"); + // One statement owns all three rolling count decisions. During a rolling + // deploy, recent parent rows without a provider claim remain attributed to + // their provider/global budgets; attached rows are represented by the claim + // itself and are deliberately not double-counted. + const row = await db.prepare( + `INSERT OR IGNORE INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) + SELECT ?, ?, ?, ?, ?, ? + WHERE ( + SELECT COUNT(*) FROM provider_creation_claims + WHERE client_key_hash = ? AND claimed_at >= ? + ) < ? + AND ( + (SELECT COUNT(*) FROM provider_creation_claims + WHERE provider = ? AND claimed_at >= ?) + + CASE WHEN ? = 'WOMPI' + THEN (SELECT COUNT(*) FROM donation_intents + WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + ELSE (SELECT COUNT(*) FROM stripe_checkout_sessions + WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + END + ) < ? + AND ( + (SELECT COUNT(*) FROM provider_creation_claims WHERE claimed_at >= ?) + + (SELECT COUNT(*) FROM donation_intents + WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + + (SELECT COUNT(*) FROM stripe_checkout_sessions + WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + ) < ? + RETURNING id` + ).bind( + id, + input.provider, + input.clientKeyHash, + input.stripeRequestId, + input.now, + input.expiresAt, + input.clientKeyHash, + input.cutoff, + input.clientLimit, + input.provider, + input.cutoff, + input.provider, + input.cutoff, + input.cutoff, + input.providerLimit, + input.cutoff, + input.cutoff, + input.cutoff, + input.globalLimit + ).first<{ id: string }>(); + if (row) return { kind: "CLAIMED", id: row.id }; + if (input.provider === "STRIPE" && input.stripeRequestId) { + const duplicate = await db.prepare( + `SELECT id FROM provider_creation_claims + WHERE provider = 'STRIPE' AND stripe_request_id = ? + LIMIT 1` + ).bind(input.stripeRequestId).first<{ id: string }>(); + if (duplicate) return { kind: "DUPLICATE" }; + } + return { kind: "LIMITED" }; +} + +export async function releaseUnusedProviderCreationClaim( + db: D1Database, + id: string +): Promise { + await db.prepare( + `DELETE FROM provider_creation_claims + WHERE id = ? + AND NOT EXISTS ( + SELECT 1 FROM donation_intents WHERE provider_creation_claim_id = ? + ) + AND NOT EXISTS ( + SELECT 1 FROM stripe_checkout_sessions WHERE provider_creation_claim_id = ? + )` + ).bind(id, id, id).run(); +} + export async function claimDonationIntentRateLimit( db: D1Database, keyHash: string, @@ -362,4 +462,7 @@ export async function deleteExpiredSecurityRateLimitClaims( await db.prepare( "DELETE FROM stripe_portal_rate_limit_claims WHERE expires_at <= ?" ).bind(now).run(); + await db.prepare( + "DELETE FROM provider_creation_claims WHERE expires_at <= ?" + ).bind(now).run(); } diff --git a/src/worker/storage/repository/stripeDonations.ts b/src/worker/storage/repository/stripeDonations.ts index 5a906ed3..bab0df4f 100644 --- a/src/worker/storage/repository/stripeDonations.ts +++ b/src/worker/storage/repository/stripeDonations.ts @@ -47,6 +47,7 @@ export interface StripeCheckoutRecord { donor_phone: string | null; donor_address_json: string | null; rate_limit_claim_id: string | null; + provider_creation_claim_id: string | null; error_code: string | null; expires_at: string | null; completed_at: string | null; @@ -175,7 +176,7 @@ export async function reserveStripeCheckout( giftType: Exclude; amountCents: number; livemode: boolean; - rateLimitClaimId: string | null; + providerCreationClaimId: string; now: string; } ): Promise<{ @@ -185,7 +186,7 @@ export async function reserveStripeCheckout( await db.prepare( `INSERT OR IGNORE INTO stripe_checkout_sessions ( id, request_id, request_fingerprint, frequency, gift_type, amount_cents, currency, - livemode, status, payment_status, rate_limit_claim_id, created_at, updated_at + livemode, status, payment_status, provider_creation_claim_id, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 'usd', ?, 'CREATING', 'UNPAID', ?, ?, ?)` ).bind( input.id, @@ -195,7 +196,7 @@ export async function reserveStripeCheckout( input.giftType, input.amountCents, input.livemode ? 1 : 0, - input.rateLimitClaimId, + input.providerCreationClaimId, input.now, input.now ).run(); diff --git a/src/worker/types.ts b/src/worker/types.ts index c670c84e..adff3a58 100644 --- a/src/worker/types.ts +++ b/src/worker/types.ts @@ -313,9 +313,11 @@ export interface DonationIntentRecord { // SHA-256 of the one-time draft /datos capability (migration 0017). The raw // capability is never stored and this column is cleared by the successful CAS. datos_token_hash: string | null; - // Admission claim that reserved this create in the atomic public-rate-limit ledger. - // Legacy rows remain null so deployment-overlap activity is still counted. + // Legacy pre-0046 raw-IP admission provenance. New provider creates leave it null. rate_limit_claim_id: string | null; + // Provider-object creation claim. It is evidence only: expired ledger rows + // are deleted without a parent FK, while this id remains on the intent. + provider_creation_claim_id: string | null; // Wompi payment marker (migration 0016): stamped by the webhook the moment an // approved payment for this intent arrives — independent of status. COMPLETED still // means the CDE was accepted by MH; paid_at means the donor paid. The donor-facing diff --git a/test/scripts/migrationImmutability.test.ts b/test/scripts/migrationImmutability.test.ts index dafa8c8a..095b15f9 100644 --- a/test/scripts/migrationImmutability.test.ts +++ b/test/scripts/migrationImmutability.test.ts @@ -45,7 +45,7 @@ describe("migration immutability checker", () => { await loadChecker(); expect(Object.keys(IMMUTABLE_MIGRATION_SHA256).at(-1)).toBe( - "0044_stripe_portal_capability.sql" + "0046_provider_creation_budgets.sql" ); expect(Object.keys(IMMUTABLE_MIGRATION_SHA256)).toEqual([ "0001_init.sql", @@ -91,7 +91,9 @@ describe("migration immutability checker", () => { "0041_stripe_annual_email_evidence.sql", "0042_stripe_annual_email_evidence_reclaim.sql", "0043_stripe_annual_email_evidence_dispatch_guard.sql", - "0044_stripe_portal_capability.sql" + "0044_stripe_portal_capability.sql", + "0045_login_step_up_mfa.sql", + "0046_provider_creation_budgets.sql" ]); expect(() => assertImmutableMigrations(migrationsDirectory)).not.toThrow(); }); @@ -118,6 +120,21 @@ describe("migration immutability checker", () => { ); }); + it("fails if either newly pinned remediation migration is modified", async () => { + const { assertImmutableMigrations } = await loadChecker(); + for (const name of [ + "0045_login_step_up_mfa.sql", + "0046_provider_creation_budgets.sql" + ]) { + const copy = copiedMigrations(); + const target = join(copy, name); + writeFileSync(target, `${readFileSync(target, "utf8")}\n-- mutation\n`); + expect(() => assertImmutableMigrations(copy)).toThrow( + new RegExp(`${name.replace(".", "\\.")}.*modified`, "i") + ); + } + }); + it("fails if a historical migration is renamed or removed", async () => { const { assertImmutableMigrations } = await loadChecker(); const renamed = copiedMigrations(); @@ -162,7 +179,7 @@ describe("migration immutability checker", () => { it("accepts only the next unique additive migration prefix", async () => { const { assertImmutableMigrations } = await loadChecker(); const copy = copiedMigrations(); - writeFileSync(join(copy, "0045_future_addition.sql"), "SELECT 1;\n"); + writeFileSync(join(copy, "0047_future_addition.sql"), "SELECT 1;\n"); expect(() => assertImmutableMigrations(copy)).not.toThrow(); }); diff --git a/test/scripts/productionProvisioningDocs.test.ts b/test/scripts/productionProvisioningDocs.test.ts index e623875c..6a5f1d54 100644 --- a/test/scripts/productionProvisioningDocs.test.ts +++ b/test/scripts/productionProvisioningDocs.test.ts @@ -349,7 +349,7 @@ const allowedWranglerDocumentationCases = [ describe("remote provisioning documentation", () => { it("mirrors the current Stripe migration range and non-archived ledgers in both READMEs", () => { for (const document of [readme, readmeEs]) { - expect(document).toContain("0001…0044"); + expect(document).toContain("0001…0046"); expect(document).toContain("stripe_retention_generations"); expect(document).toContain("stripe_invoice_settlement_retention_generations"); expect(document).toContain("Stripe"); diff --git a/test/scripts/stripeProvisioningDocs.test.ts b/test/scripts/stripeProvisioningDocs.test.ts index 065c224c..4a5e55d0 100644 --- a/test/scripts/stripeProvisioningDocs.test.ts +++ b/test/scripts/stripeProvisioningDocs.test.ts @@ -241,7 +241,7 @@ describe("Stripe US giving provisioning documentation", () => { it("keeps every additive Stripe migration in the rollback preservation boundary", () => { const rollback = runbook.slice(runbook.indexOf("## Handoff del propietario y rollback")); - for (const migration of ["0032", "0033", "0034", "0035", "0036", "0037", "0038", "0039", "0040", "0041", "0042", "0043", "0044"]) { + for (const migration of ["0032", "0033", "0034", "0035", "0036", "0037", "0038", "0039", "0040", "0041", "0042", "0043", "0044", "0046"]) { expect(rollback).toContain(migration); } expect(rollback).toMatch(/no.*elimine|conserve/is); diff --git a/test/worker/donationIntents.test.ts b/test/worker/donationIntents.test.ts index 1abe701b..0cbc7cce 100644 --- a/test/worker/donationIntents.test.ts +++ b/test/worker/donationIntents.test.ts @@ -121,7 +121,7 @@ describe("donation intents repository", () => { clientIp: "203.0.113.9", expiresAt: "2026-07-05T13:00:00.000Z", datosTokenHash: null, - rateLimitClaimId: "rate_seed" + providerCreationClaimId: "provider_seed" }); expect(created.id).toBe("di_seed"); @@ -144,7 +144,7 @@ describe("donation intents repository", () => { // The capability hash and admission provenance follow gift_type. expect(insert!.args[14]).toBeNull(); expect(insert!.args[15]).toBeNull(); - expect(insert!.args[16]).toBe("rate_seed"); + expect(insert!.args[16]).toBe("provider_seed"); }); it("binds the razón social and país when the intent carries them (NIT / foreign path)", async () => { @@ -168,7 +168,7 @@ describe("donation intents repository", () => { clientIp: "203.0.113.9", expiresAt: "2026-07-05T13:00:00.000Z", datosTokenHash: "a".repeat(64), - rateLimitClaimId: "rate_foreign" + providerCreationClaimId: "provider_foreign" }); const insert = db.calls.find((call) => call.sql.includes("INSERT INTO donation_intents")); @@ -180,7 +180,7 @@ describe("donation intents repository", () => { expect(insert!.args).toContain("DIEZMO"); expect(insert!.args[14]).toBe("DIEZMO"); expect(insert!.args[15]).toBe("a".repeat(64)); - expect(insert!.args[16]).toBe("rate_foreign"); + expect(insert!.args[16]).toBe("provider_foreign"); }); it("consumes a datos capability with one guarded UPDATE RETURNING", async () => { diff --git a/test/worker/fixtures.ts b/test/worker/fixtures.ts index 4923a1c5..b39cae1c 100644 --- a/test/worker/fixtures.ts +++ b/test/worker/fixtures.ts @@ -57,6 +57,7 @@ export function makeIntent(overrides: Partial = {}): Donat client_ip: null, datos_token_hash: null, rate_limit_claim_id: null, + provider_creation_claim_id: null, paid_at: null, created_at: "2026-07-05T12:00:00.000Z", updated_at: "2026-07-05T12:00:00.000Z", diff --git a/test/worker/stripeAcknowledgment.test.ts b/test/worker/stripeAcknowledgment.test.ts index 0e469807..6862a334 100644 --- a/test/worker/stripeAcknowledgment.test.ts +++ b/test/worker/stripeAcknowledgment.test.ts @@ -1101,7 +1101,7 @@ async function seedGift(repo: Repository): Promise { giftType: "TITHE", amountCents: 5000, livemode: false, - rateLimitClaimId: null, + providerCreationClaimId: "provider_claim_seed", now: "2026-08-10T12:00:00.000Z" }); await repo.recordStripeGiftAndAcknowledgment({ diff --git a/test/worker/stripeAnnualStatementMigration.test.ts b/test/worker/stripeAnnualStatementMigration.test.ts index 0a42cabe..e0e1350e 100644 --- a/test/worker/stripeAnnualStatementMigration.test.ts +++ b/test/worker/stripeAnnualStatementMigration.test.ts @@ -172,7 +172,7 @@ describe("Stripe U.S. annual statement persistence", () => { it("upgrades 0042 legacy post-dispatch rows and still lets operators move them to REVIEW", () => { expect(migrationFiles().at(-1)).toBe( - "0044_stripe_portal_capability.sql" + "0046_provider_creation_budgets.sql" ); expect(existsSync(emailEvidenceDispatchGuardMigrationPath)).toBe(true); const database = migratedDatabaseThrough("0042"); diff --git a/test/worker/stripeRepository.test.ts b/test/worker/stripeRepository.test.ts index a588a911..c2124d7c 100644 --- a/test/worker/stripeRepository.test.ts +++ b/test/worker/stripeRepository.test.ts @@ -534,7 +534,7 @@ function checkoutInput(overrides: Partial { "SELECT COUNT(*) AS count FROM stripe_checkout_sessions" ).get()).toEqual({ count: 1 }); expect(database.prepare( - "SELECT COUNT(*) AS count FROM security_rate_limit_claims WHERE scope = 'donation_intent'" + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE provider = 'STRIPE'" ).get()).toEqual({ count: 1 }); + const reservationEvidence = database.prepare( + `SELECT rate_limit_claim_id, provider_creation_claim_id + FROM stripe_checkout_sessions WHERE request_id = ?` + ).get(requestId) as { + rate_limit_claim_id: string | null; + provider_creation_claim_id: string | null; + }; + expect(reservationEvidence.rate_limit_claim_id).toBeNull(); + expect(reservationEvidence.provider_creation_claim_id).toMatch(/^provider_create_/); expect(database.prepare("SELECT COUNT(*) AS count FROM donation_intents").get()) .toEqual({ count: 0 }); @@ -82,6 +91,24 @@ describe("Stripe public donation routes", () => { expect(giftTypeConflict.response.status).toBe(409); }); + it("releases a Stripe provider claim when reservation persistence fails", async () => { + workerEnv = { ...workerEnv, DB: withFailingStripeReservation(database) }; + + const result = await createCheckout(workerEnv, { + requestId, + amount: 50, + frequency: "once" + }); + + expect(result.response.status).toBe(500); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM stripe_checkout_sessions" + ).get()).toEqual({ count: 0 }); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE provider = 'STRIPE'" + ).get()).toEqual({ count: 0 }); + }); + it("reclaims a failed Session reservation with the same request identity", async () => { const first = await createCheckout(workerEnv, { requestId, @@ -112,7 +139,7 @@ describe("Stripe public donation routes", () => { error_code: null }); expect(database.prepare( - "SELECT COUNT(*) AS count FROM security_rate_limit_claims WHERE scope = 'donation_intent'" + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE provider = 'STRIPE'" ).get()).toEqual({ count: 1 }); }); @@ -146,7 +173,7 @@ describe("Stripe public donation routes", () => { error_code: null }); expect(database.prepare( - "SELECT COUNT(*) AS count FROM security_rate_limit_claims WHERE scope = 'donation_intent'" + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE provider = 'STRIPE'" ).get()).toEqual({ count: 1 }); }); @@ -223,6 +250,14 @@ describe("Stripe public donation routes", () => { const first = await createCheckout(proxyEnv, { requestId, amount: 50, frequency: "once" }); expect(first.response.status).toBe(502); + const retainedClaim = database.prepare( + `SELECT claims.id + FROM provider_creation_claims AS claims + JOIN stripe_checkout_sessions AS checkout + ON checkout.provider_creation_claim_id = claims.id + WHERE checkout.request_id = ?` + ).get(requestId) as { id: string } | undefined; + expect(retainedClaim?.id).toMatch(/^provider_create_/); const second = await createCheckout(proxyEnv, { requestId, amount: 50, frequency: "once" }); expect(second.response.status).toBe(502); const third = await createCheckout(proxyEnv, { requestId, amount: 50, frequency: "once" }); @@ -840,21 +875,108 @@ describe("Stripe public donation routes", () => { }, "203.0.113.10"); expect(limited.response.status).toBe(429); expect(limited.body).toMatchObject({ error: "too_many_attempts" }); + expect(limited.response.headers.get("Cache-Control")).toBe("no-store"); }); - it("releases the duplicate admission claim when concurrent requests converge on one checkout", async () => { + it("blocks distinct clients at the Stripe provider ceiling before reservation or provider work", async () => { + const now = "2026-07-04T12:00:00.000Z"; + for (let index = 0; index < 60; index += 1) { + database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES (?, 'STRIPE', ?, ?, ?, ?)` + ).run( + `stripe_provider_seed_${index}`, + `stripe-client-${index}`, + `stripe-request-${index}`, + now, + "2026-07-04T12:15:00.000Z" + ); + } + vi.useFakeTimers({ toFake: ["Date"], now: new Date(now) }); + const providerFetch = vi.fn(); + vi.stubGlobal("fetch", providerFetch); + try { + const limited = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }, "198.51.100.200"); + + expect(limited.response.status).toBe(429); + expect(limited.body).toEqual({ + error: "too_many_attempts", + message: "Demasiados intentos. Espere 15 minutos e intente de nuevo." + }); + expect(limited.response.headers.get("Cache-Control")).toBe("no-store"); + expect(database.prepare("SELECT COUNT(*) AS count FROM stripe_checkout_sessions").get()) + .toEqual({ count: 0 }); + expect(providerFetch).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("enforces one shared global ceiling across Wompi and Stripe claims", async () => { + const now = "2026-07-04T12:00:00.000Z"; + for (let index = 0; index < 100; index += 1) { + const stripe = index % 2 === 1; + database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ).run( + `global_seed_${index}`, + stripe ? "STRIPE" : "WOMPI", + `global-client-${index}`, + stripe ? `global-request-${index}` : null, + now, + "2026-07-04T12:15:00.000Z" + ); + } + vi.useFakeTimers({ toFake: ["Date"], now: new Date(now) }); + const providerFetch = vi.fn(); + vi.stubGlobal("fetch", providerFetch); + try { + const limited = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }, "198.51.100.201"); + + expect(limited.response.status).toBe(429); + expect(limited.response.headers.get("Cache-Control")).toBe("no-store"); + expect(database.prepare("SELECT COUNT(*) AS count FROM stripe_checkout_sessions").get()) + .toEqual({ count: 0 }); + expect(providerFetch).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("uses one claim and one provider call when fresh concurrent requests share a request id", async () => { + let providerCalls = 0; + vi.stubGlobal("fetch", vi.fn(async (input, init) => { + providerCalls += 1; + const request = input instanceof Request ? input : new Request(input, init); + return stripeCheckoutJson(new URLSearchParams(await request.text())); + })); const concurrent = withSynchronizedStripeReservationReads(database); concurrent.synchronizeNextPair(); - const concurrentEnv = { ...workerEnv, DB: concurrent.db }; + const concurrentEnv = { ...stripeProxyEnv(workerEnv), DB: concurrent.db }; const [first, second] = await Promise.all([ createCheckout(concurrentEnv, { requestId, amount: 50, frequency: "once" }, "203.0.113.55"), createCheckout(concurrentEnv, { requestId, amount: 50, frequency: "once" }, "203.0.113.55") ]); - expect([first.response.status, second.response.status].sort()).toEqual([200, 201]); + expect([first.response.status, second.response.status].sort()).toEqual([201, 409]); + expect([first.body.error, second.body.error]).toContain("stripe_checkout_in_progress"); + expect(providerCalls).toBe(1); expect(database.prepare( - "SELECT COUNT(*) AS count FROM security_rate_limit_claims WHERE scope = 'donation_intent'" + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE provider = 'STRIPE'" ).get()).toEqual({ count: 1 }); + expect(database.prepare("SELECT COUNT(*) AS count FROM stripe_checkout_sessions").get()) + .toEqual({ count: 1 }); }); }); @@ -960,6 +1082,29 @@ function stripeCheckoutObject(params: URLSearchParams): Record }; } +function withFailingStripeReservation( + database: ReturnType +): D1Database { + const base = sqliteD1(database); + return { + prepare(sql: string) { + const statement = base.prepare(sql); + if (sql.includes("INSERT OR IGNORE INTO stripe_checkout_sessions")) { + const mutable = statement as unknown as { + run: (...args: unknown[]) => Promise; + }; + mutable.run = async () => { + throw new Error("injected Stripe reservation persistence failure"); + }; + } + return statement; + }, + batch(statements: D1PreparedStatement[]) { + return base.batch(statements); + } + } as D1Database; +} + function withDeferredStripeAttachment(database: ReturnType): { db: D1Database; allowAttachments(): void; diff --git a/test/worker/support/inMemoryD1.ts b/test/worker/support/inMemoryD1.ts index aca941b4..91b60d22 100644 --- a/test/worker/support/inMemoryD1.ts +++ b/test/worker/support/inMemoryD1.ts @@ -131,6 +131,15 @@ export interface SecurityRateLimitClaimRow { expires_at: string; } +export interface ProviderCreationClaimRow { + id: string; + provider: "WOMPI" | "STRIPE"; + client_key_hash: string; + stripe_request_id: string | null; + claimed_at: string; + expires_at: string; +} + export interface LoginStepUpChallengeRow { id: string; user_id: string; @@ -196,6 +205,7 @@ export class InMemoryD1 { readonly audits: Array> = []; readonly loginRateLimits = new Map(); readonly securityRateLimitClaims: SecurityRateLimitClaimRow[] = []; + readonly providerCreationClaims: ProviderCreationClaimRow[] = []; readonly loginStepUpChallenges: LoginStepUpChallengeRow[] = []; readonly documents: DteDocumentRecord[] = []; readonly preparedSql: string[] = []; @@ -987,6 +997,102 @@ export class Statement { document.updated_at = String(updatedAt); return { id: document.id } as T; } + if (this.sql.includes("INSERT OR IGNORE INTO provider_creation_claims")) { + const [ + id, + provider, + clientKeyHash, + stripeRequestId, + claimedAt, + expiresAt, + countClientKeyHash, + clientCutoff, + clientLimit, + countProvider, + providerCutoff, + legacyProvider, + donationLegacyCutoff, + stripeLegacyCutoff, + providerLimit, + globalCutoff, + globalDonationCutoff, + globalStripeCutoff, + globalLimit + ] = this.args; + const normalizedProvider = String(provider) as "WOMPI" | "STRIPE"; + const normalizedRequestId = stripeRequestId == null ? null : String(stripeRequestId); + if ( + normalizedProvider === "STRIPE" && + normalizedRequestId !== null && + this.db.providerCreationClaims.some( + (claim) => claim.provider === "STRIPE" && claim.stripe_request_id === normalizedRequestId + ) + ) { + return null; + } + const clientCount = this.db.providerCreationClaims.filter( + (claim) => + claim.client_key_hash === String(countClientKeyHash) && + claim.claimed_at >= String(clientCutoff) + ).length; + const providerClaimCount = this.db.providerCreationClaims.filter( + (claim) => + claim.provider === String(countProvider) && + claim.claimed_at >= String(providerCutoff) + ).length; + const providerLegacyCount = String(legacyProvider) === "WOMPI" + ? this.db.donationIntents.filter( + (intent) => + (intent.provider_creation_claim_id ?? null) === null && + String(intent.created_at) >= String(donationLegacyCutoff) + ).length + : this.db.stripeCheckoutSessions.filter( + (checkout) => + (checkout.provider_creation_claim_id ?? null) === null && + String(checkout.created_at) >= String(stripeLegacyCutoff) + ).length; + const globalClaimCount = this.db.providerCreationClaims.filter( + (claim) => claim.claimed_at >= String(globalCutoff) + ).length; + const globalLegacyCount = this.db.donationIntents.filter( + (intent) => + (intent.provider_creation_claim_id ?? null) === null && + String(intent.created_at) >= String(globalDonationCutoff) + ).length + this.db.stripeCheckoutSessions.filter( + (checkout) => + (checkout.provider_creation_claim_id ?? null) === null && + String(checkout.created_at) >= String(globalStripeCutoff) + ).length; + if ( + clientCount >= Number(clientLimit) || + providerClaimCount + providerLegacyCount >= Number(providerLimit) || + globalClaimCount + globalLegacyCount >= Number(globalLimit) + ) { + return null; + } + const claim: ProviderCreationClaimRow = { + id: String(id), + provider: normalizedProvider, + client_key_hash: String(clientKeyHash), + stripe_request_id: normalizedRequestId, + claimed_at: String(claimedAt), + expires_at: String(expiresAt) + }; + this.db.providerCreationClaims.push(claim); + return { id: claim.id } as T; + } + if ( + this.sql.includes("SELECT id FROM provider_creation_claims") && + this.sql.includes("stripe_request_id = ?") + ) { + const [stripeRequestId] = this.args; + const claim = this.db.providerCreationClaims.find( + (candidate) => + candidate.provider === "STRIPE" && + candidate.stripe_request_id === String(stripeRequestId) + ); + return (claim ? { id: claim.id } : null) as T | null; + } if (this.sql.includes("INSERT INTO security_rate_limit_claims")) { const scope = this.sql.includes("'donation_intent'") ? "donation_intent" @@ -3024,6 +3130,32 @@ export class Statement { } } } + if ( + this.sql.includes("DELETE FROM provider_creation_claims") && + this.sql.includes("NOT EXISTS") + ) { + const [id] = this.args.map(String); + const attached = this.db.donationIntents.some( + (intent) => intent.provider_creation_claim_id === id + ) || this.db.stripeCheckoutSessions.some( + (checkout) => checkout.provider_creation_claim_id === id + ); + if (!attached) { + const index = this.db.providerCreationClaims.findIndex((claim) => claim.id === id); + if (index >= 0) { + this.db.providerCreationClaims.splice(index, 1); + changes += 1; + } + } + } else if (this.sql.includes("DELETE FROM provider_creation_claims")) { + const [now] = this.args.map(String); + for (let index = this.db.providerCreationClaims.length - 1; index >= 0; index -= 1) { + if (this.db.providerCreationClaims[index].expires_at <= now) { + this.db.providerCreationClaims.splice(index, 1); + changes += 1; + } + } + } if (this.sql.includes("INSERT OR IGNORE INTO document_sequences")) { this.db.sequencePrefixes.push(String(this.args[1])); } @@ -3691,7 +3823,7 @@ export class Statement { expiresAt, giftType, datosTokenHash, - rateLimitClaimId + providerCreationClaimId ] = this.args; this.db.donationIntents.push({ id: String(id), @@ -3716,7 +3848,10 @@ export class Statement { document_id: null, client_ip: clientIp == null ? null : String(clientIp), datos_token_hash: datosTokenHash == null ? null : String(datosTokenHash), - rate_limit_claim_id: rateLimitClaimId == null ? null : String(rateLimitClaimId), + rate_limit_claim_id: null, + provider_creation_claim_id: providerCreationClaimId == null + ? null + : String(providerCreationClaimId), // paid_at (migration 0016): stamped only by the webhook's markIntentPaid, // never on create — a fresh intent has not been paid. paid_at: null, diff --git a/test/worker/wompiApi.test.ts b/test/worker/wompiApi.test.ts index 918c85ad..75a55196 100644 --- a/test/worker/wompiApi.test.ts +++ b/test/worker/wompiApi.test.ts @@ -765,6 +765,7 @@ function intent(overrides: Partial = {}): DonationIntentRe client_ip: null, datos_token_hash: null, rate_limit_claim_id: null, + provider_creation_claim_id: null, paid_at: null, created_at: "2026-07-05T12:00:00.000Z", updated_at: "2026-07-05T12:00:00.000Z", diff --git a/test/worker/workerFetch.auth-infra.test.ts b/test/worker/workerFetch.auth-infra.test.ts index 984ba440..204b5a23 100644 --- a/test/worker/workerFetch.auth-infra.test.ts +++ b/test/worker/workerFetch.auth-infra.test.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import worker from "../../src/worker/index"; @@ -5,7 +7,7 @@ import { AuthService, hashForStorage, hashPassword } from "../../src/worker/serv import { Repository } from "../../src/worker/storage/repository"; import { utf8Bytes } from "../../src/worker/utils/encoding"; import { env, InMemoryD1 } from "./support/inMemoryD1"; -import { migratedDatabase } from "./support/migratedDatabase"; +import { migratedDatabase, migratedDatabaseThrough } from "./support/migratedDatabase"; import { sqliteD1 } from "./support/sqliteD1"; import { makeDocument as testDocument } from "./fixtures"; import { installWorkerFetchGlobals } from "./support/workerFetchGlobals"; @@ -237,6 +239,351 @@ describe("login step-up migration", () => { }); }); +describe("provider creation budget migration", () => { + const migrationPath = resolve( + import.meta.dirname, + "../../migrations/0046_provider_creation_budgets.sql" + ); + + it("installs the checked provider ledger, count indexes, and parent evidence columns", () => { + const database = migratedDatabase(); + try { + const table = database.prepare( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'provider_creation_claims'" + ).get() as { sql: string } | undefined; + expect(table, "migration 0046 must create the provider claim ledger").toBeDefined(); + if (!table) return; + + const indexes = database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'provider_creation_claims'" + ).all().map((row) => String((row as { name: string }).name)); + expect(indexes).toEqual(expect.arrayContaining([ + "idx_provider_creation_claims_client_claimed", + "idx_provider_creation_claims_provider_claimed", + "idx_provider_creation_claims_global_claimed", + "idx_provider_creation_claims_expires", + "idx_provider_creation_claims_stripe_request" + ])); + + expect(database.prepare("PRAGMA table_info(donation_intents)").all()) + .toEqual(expect.arrayContaining([expect.objectContaining({ name: "provider_creation_claim_id" })])); + expect(database.prepare("PRAGMA table_info(stripe_checkout_sessions)").all()) + .toEqual(expect.arrayContaining([expect.objectContaining({ name: "provider_creation_claim_id" })])); + expect(database.prepare("PRAGMA foreign_key_list(donation_intents)").all()) + .not.toEqual(expect.arrayContaining([expect.objectContaining({ from: "provider_creation_claim_id" })])); + expect(database.prepare("PRAGMA foreign_key_list(stripe_checkout_sessions)").all()) + .not.toEqual(expect.arrayContaining([expect.objectContaining({ from: "provider_creation_claim_id" })])); + + expect(() => database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES ('bad_provider', 'PAYPAL', 'client', NULL, '2026-07-04T12:00:00.000Z', '2026-07-04T12:15:00.000Z')` + ).run()).toThrow(/CHECK constraint failed/); + + expect(() => database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES ('bad_wompi_request', 'WOMPI', 'client', 'request-one', '2026-07-04T12:00:00.000Z', '2026-07-04T12:15:00.000Z')` + ).run()).toThrow(/CHECK constraint failed/); + + expect(() => database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES ('bad_stripe_request', 'STRIPE', 'client', NULL, '2026-07-04T12:00:00.000Z', '2026-07-04T12:15:00.000Z')` + ).run()).toThrow(/CHECK constraint failed/); + + database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES (?, 'STRIPE', ?, ?, ?, ?)` + ).run( + "stripe_claim_one", + "client-one", + "request-one", + "2026-07-04T12:00:00.000Z", + "2026-07-04T12:15:00.000Z" + ); + expect(() => database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES (?, 'STRIPE', ?, ?, ?, ?)` + ).run( + "stripe_claim_two", + "client-two", + "request-one", + "2026-07-04T12:00:00.000Z", + "2026-07-04T12:15:00.000Z" + )).toThrow(/UNIQUE constraint failed/); + } finally { + database.close(); + } + }); + + it("upgrades an exact 0045 database through additive 0046", () => { + expect(existsSync(migrationPath), "migration 0046 exists").toBe(true); + if (!existsSync(migrationPath)) return; + const database = migratedDatabaseThrough("0045"); + try { + expect(database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_creation_claims'" + ).get()).toBeUndefined(); + + database.exec(readFileSync(migrationPath, "utf8")); + + expect(database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'provider_creation_claims'" + ).get()).toEqual({ name: "provider_creation_claims" }); + expect(database.prepare("PRAGMA table_info(donation_intents)").all()) + .toEqual(expect.arrayContaining([expect.objectContaining({ name: "provider_creation_claim_id" })])); + expect(database.prepare("PRAGMA table_info(stripe_checkout_sessions)").all()) + .toEqual(expect.arrayContaining([expect.objectContaining({ name: "provider_creation_claim_id" })])); + } finally { + database.close(); + } + }); +}); + +describe("provider creation budget repository", () => { + const now = "2026-07-04T12:00:00.000Z"; + const cutoff = "2026-07-04T11:45:00.000Z"; + const expiresAt = "2026-07-04T12:15:00.000Z"; + + it("atomically caps concurrent SQLite claims at injected client, provider, and global ceilings", async () => { + const clientDatabase = migratedDatabase(); + const providerDatabase = migratedDatabase(); + const globalDatabase = migratedDatabase(); + try { + const clientRepo = new Repository(sqliteD1(clientDatabase)); + const clientClaims = await Promise.all(Array.from({ length: 20 }, () => + claimProviderCreationBudgetForTest(clientRepo, { + provider: "WOMPI", + clientKeyHash: "same-client", + stripeRequestId: null, + now, + cutoff, + expiresAt, + clientLimit: 2, + providerLimit: 20, + globalLimit: 20 + }) + )); + expect(clientClaims.filter((claim) => claim.kind === "CLAIMED")).toHaveLength(2); + + const providerRepo = new Repository(sqliteD1(providerDatabase)); + const providerClaims = await Promise.all(Array.from({ length: 20 }, (_, index) => + claimProviderCreationBudgetForTest(providerRepo, { + provider: "STRIPE", + clientKeyHash: `provider-client-${index}`, + stripeRequestId: `provider-request-${index}`, + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 3, + globalLimit: 20 + }) + )); + expect(providerClaims.filter((claim) => claim.kind === "CLAIMED")).toHaveLength(3); + + const globalRepo = new Repository(sqliteD1(globalDatabase)); + const globalClaims = await Promise.all(Array.from({ length: 20 }, (_, index) => + claimProviderCreationBudgetForTest(globalRepo, { + provider: index % 2 === 0 ? "WOMPI" : "STRIPE", + clientKeyHash: `global-client-${index}`, + stripeRequestId: index % 2 === 0 ? null : `global-request-${index}`, + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 4 + }) + )); + expect(globalClaims.filter((claim) => claim.kind === "CLAIMED")).toHaveLength(4); + } finally { + clientDatabase.close(); + providerDatabase.close(); + globalDatabase.close(); + } + }); + + it("matches the atomic low-ceiling behavior in the in-memory D1 emulator", async () => { + const db = new InMemoryD1(); + const repo = new Repository(db as unknown as D1Database); + const claims = await Promise.all(Array.from({ length: 20 }, (_, index) => + claimProviderCreationBudgetForTest(repo, { + provider: index % 2 === 0 ? "WOMPI" : "STRIPE", + clientKeyHash: `memory-client-${index}`, + stripeRequestId: index % 2 === 0 ? null : `memory-request-${index}`, + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 4 + }) + )); + + expect(claims.filter((claim) => claim.kind === "CLAIMED")).toHaveLength(4); + expect(providerClaimsFrom(db)).toHaveLength(4); + }); + + it("releases only unused claims and preserves attached Wompi and Stripe evidence", async () => { + const database = migratedDatabase(); + try { + const repo = new Repository(sqliteD1(database)); + const unused = await claimProviderCreationBudgetForTest(repo, { + provider: "WOMPI", + clientKeyHash: "unused-client", + stripeRequestId: null, + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 20 + }); + expect(unused.kind).toBe("CLAIMED"); + if (unused.kind !== "CLAIMED") return; + await releaseProviderCreationClaimForTest(repo, unused.id); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE id = ?" + ).get(unused.id)).toEqual({ count: 0 }); + + const wompi = await claimProviderCreationBudgetForTest(repo, { + provider: "WOMPI", + clientKeyHash: "wompi-attached-client", + stripeRequestId: null, + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 20 + }); + const stripe = await claimProviderCreationBudgetForTest(repo, { + provider: "STRIPE", + clientKeyHash: "stripe-attached-client", + stripeRequestId: "stripe-attached-request", + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 20 + }); + expect(wompi.kind).toBe("CLAIMED"); + expect(stripe.kind).toBe("CLAIMED"); + if (wompi.kind !== "CLAIMED" || stripe.kind !== "CLAIMED") return; + + database.prepare( + `INSERT INTO donation_intents ( + id, status, amount_cents, donor_document_type, client_ip, expires_at, + provider_creation_claim_id, created_at, updated_at + ) VALUES (?, 'PENDING', 1000, '13', '203.0.113.1', ?, ?, ?, ?)` + ).run("attached_wompi", expiresAt, wompi.id, now, now); + database.prepare( + `INSERT INTO stripe_checkout_sessions ( + id, request_id, request_fingerprint, frequency, gift_type, amount_cents, + currency, livemode, status, payment_status, provider_creation_claim_id, + created_at, updated_at + ) VALUES (?, ?, 'v2:test', 'ONCE', 'TITHE', 1000, + 'usd', 0, 'CREATING', 'UNPAID', ?, ?, ?)` + ).run("attached_stripe", "attached-stripe-request", stripe.id, now, now); + + await releaseProviderCreationClaimForTest(repo, wompi.id); + await releaseProviderCreationClaimForTest(repo, stripe.id); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE id IN (?, ?)" + ).get(wompi.id, stripe.id)).toEqual({ count: 2 }); + } finally { + database.close(); + } + }); + + it("counts unattributed legacy parents globally without double-counting attached parents", async () => { + const legacyDatabase = migratedDatabase(); + const attachedDatabase = migratedDatabase(); + try { + legacyDatabase.prepare( + `INSERT INTO donation_intents ( + id, status, amount_cents, donor_document_type, client_ip, expires_at, + created_at, updated_at + ) VALUES ('legacy_wompi', 'PENDING', 1000, '13', '198.51.100.1', ?, ?, ?)` + ).run(expiresAt, now, now); + legacyDatabase.prepare( + `INSERT INTO stripe_checkout_sessions ( + id, request_id, request_fingerprint, frequency, gift_type, amount_cents, + currency, livemode, status, payment_status, created_at, updated_at + ) VALUES ('legacy_stripe', 'legacy-stripe-request', 'v2:legacy', 'ONCE', 'TITHE', + 1000, 'usd', 0, 'CREATING', 'UNPAID', ?, ?)` + ).run(now, now); + const legacyRepo = new Repository(sqliteD1(legacyDatabase)); + const legacyStripeProviderClaim = await claimProviderCreationBudgetForTest(legacyRepo, { + provider: "STRIPE", + clientKeyHash: "legacy-stripe-provider-client", + stripeRequestId: "fresh-stripe-request", + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 1, + globalLimit: 20 + }); + expect(legacyStripeProviderClaim).toEqual({ kind: "LIMITED" }); + + const legacyClaim = await claimProviderCreationBudgetForTest(legacyRepo, { + provider: "WOMPI", + clientKeyHash: "legacy-global-client", + stripeRequestId: null, + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 2 + }); + expect(legacyClaim).toEqual({ kind: "LIMITED" }); + + const attachedRepo = new Repository(sqliteD1(attachedDatabase)); + const first = await claimProviderCreationBudgetForTest(attachedRepo, { + provider: "WOMPI", + clientKeyHash: "attached-global-one", + stripeRequestId: null, + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 2 + }); + expect(first.kind).toBe("CLAIMED"); + if (first.kind !== "CLAIMED") return; + attachedDatabase.prepare( + `INSERT INTO donation_intents ( + id, status, amount_cents, donor_document_type, client_ip, expires_at, + provider_creation_claim_id, created_at, updated_at + ) VALUES ('attached_global_wompi', 'PENDING', 1000, '13', '198.51.100.2', ?, ?, ?, ?)` + ).run(expiresAt, first.id, now, now); + const second = await claimProviderCreationBudgetForTest(attachedRepo, { + provider: "STRIPE", + clientKeyHash: "attached-global-two", + stripeRequestId: "attached-global-request", + now, + cutoff, + expiresAt, + clientLimit: 20, + providerLimit: 20, + globalLimit: 2 + }); + expect(second.kind).toBe("CLAIMED"); + } finally { + legacyDatabase.close(); + attachedDatabase.close(); + } + }); +}); + describe("request body limits", () => { it("rejects an oversized login body before authentication or throttling", async () => { const db = new InMemoryD1(); @@ -1003,6 +1350,15 @@ describe("auth rate limiting", () => { claimed_at: "2026-07-04T11:00:00.000Z", expires_at: "2026-07-04T11:15:00.000Z" }); + const providerClaims = providerClaimsFrom(db); + providerClaims.push({ + id: "expired-provider-claim", + provider: "WOMPI", + client_key_hash: "expired-provider-hash", + stripe_request_id: null, + claimed_at: "2026-07-04T11:00:00.000Z", + expires_at: "2026-07-04T11:15:00.000Z" + }); db.loginStepUpChallenges.push({ id: "login_mfa_expired", user_id: "user_expired", @@ -1039,6 +1395,7 @@ describe("auth rate limiting", () => { expect(db.loginRateLimits.has("expired-hash")).toBe(false); expect(db.loginRateLimits.size).toBe(1); expect(db.securityRateLimitClaims).toHaveLength(0); + expect(providerClaims).toHaveLength(0); expect(db.loginStepUpChallenges).toHaveLength(0); }); @@ -2172,3 +2529,51 @@ function bootstrapRequest(options: { token?: string; password?: string } = {}, c }) }); } + +type ProviderBudgetTestInput = { + provider: "WOMPI" | "STRIPE"; + clientKeyHash: string; + stripeRequestId: string | null; + now: string; + cutoff: string; + expiresAt: string; + clientLimit: number; + providerLimit: number; + globalLimit: number; +}; + +type ProviderBudgetTestResult = + | { kind: "CLAIMED"; id: string } + | { kind: "DUPLICATE" } + | { kind: "LIMITED" }; + +async function claimProviderCreationBudgetForTest( + repo: Repository, + input: ProviderBudgetTestInput +): Promise { + const method = (repo as unknown as { + claimProviderCreationBudget?: (value: ProviderBudgetTestInput) => Promise; + }).claimProviderCreationBudget; + expect(method, "repository exposes the provider creation claim boundary").toBeTypeOf("function"); + if (!method) return { kind: "LIMITED" }; + return method.call(repo, input); +} + +async function releaseProviderCreationClaimForTest( + repo: Repository, + id: string +): Promise { + const method = (repo as unknown as { + releaseUnusedProviderCreationClaim?: (claimId: string) => Promise; + }).releaseUnusedProviderCreationClaim; + expect(method, "repository exposes safe provider claim release").toBeTypeOf("function"); + if (!method) return; + await method.call(repo, id); +} + +function providerClaimsFrom(db: InMemoryD1): Array> { + const claims = (db as unknown as { providerCreationClaims?: Array> }) + .providerCreationClaims; + expect(claims, "the in-memory D1 mirrors provider creation claims").toBeInstanceOf(Array); + return claims ?? []; +} diff --git a/test/worker/workerFetch.donation-intents.test.ts b/test/worker/workerFetch.donation-intents.test.ts index 85441713..aa5c6320 100644 --- a/test/worker/workerFetch.donation-intents.test.ts +++ b/test/worker/workerFetch.donation-intents.test.ts @@ -5,6 +5,8 @@ import { utf8Bytes } from "../../src/worker/utils/encoding"; import type { Env } from "../../src/worker/types"; import { env, InMemoryD1 } from "./support/inMemoryD1"; import { emisorConfig, generatedCertificateXml } from "./support/dteFixtures"; +import { migratedDatabase } from "./support/migratedDatabase"; +import { sqliteD1 } from "./support/sqliteD1"; import { installWorkerFetchGlobals } from "./support/workerFetchGlobals"; import { sha256Hex } from "./support/workerFetchHelpers"; @@ -130,7 +132,8 @@ describe("donation intents", () => { expect(intent.donor_email).toBeNull(); expect(intent.client_ip).toBe("203.0.113.7"); expect(intent.wompi_url_enlace).toBe(payload.urlEnlace); - expect(intent.rate_limit_claim_id).toBe(db.securityRateLimitClaims[0].id); + expect(intent.rate_limit_claim_id).toBeNull(); + expect(intent.provider_creation_claim_id).toBe(providerCreationClaims(db)[0].id); // Audit records the intent creation with amount + document type, never the number. const audit = db.audits.find((row) => row.action === "DONATION_INTENT_CREATED"); @@ -141,6 +144,26 @@ describe("donation intents", () => { expect(metadata).not.toContain("04182769"); }); + it("releases a Wompi provider claim when parent persistence fails", async () => { + const db = new InMemoryD1(); + const prepare = db.prepare.bind(db); + db.prepare = (sql: string) => { + const statement = prepare(sql); + if (sql.includes("INSERT INTO donation_intents")) { + statement.run = async () => { + throw new Error("injected donation persistence failure"); + }; + } + return statement; + }; + + const response = await worker.fetch(intentRequest(validIntentBody()), env(db)); + + expect(response.status).toBe(500); + expect(db.donationIntents).toHaveLength(0); + expect(providerCreationClaims(db)).toHaveLength(0); + }); + it("atomically admits at most five overlapping intent creations from one IP", async () => { const db = new InMemoryD1(); @@ -151,32 +174,133 @@ describe("donation intents", () => { expect(responses.filter((response) => response.status === 201)).toHaveLength(5); expect(responses.filter((response) => response.status === 429)).toHaveLength(15); expect(db.donationIntents).toHaveLength(5); - expect(db.securityRateLimitClaims.filter((claim) => claim.scope === "donation_intent")).toHaveLength(5); - const [claim] = db.securityRateLimitClaims; - expect(claim.key_hash).toMatch(/^[a-f0-9]{64}$/); - expect(claim.key_hash).not.toContain("203.0.113.7"); + const claims = providerCreationClaims(db); + expect(claims).toHaveLength(5); + const [claim] = claims; + expect(claim.client_key_hash).toMatch(/^[a-f0-9]{64}$/); + expect(claim.client_key_hash).not.toContain("203.0.113.7"); + }); + + it("groups compressed and long IPv6 variants by /64 while preserving the raw source IP", async () => { + const db = new InMemoryD1(); + const samePrefix = [ + "2001:0DB8:1234:5678::1", + "2001:db8:1234:5678:0:0:0:2", + "2001:0db8:1234:5678:abcd::3", + "2001:db8:1234:5678:ffff:0:0:4", + "2001:db8:1234:5678:ffff:ffff:ffff:ffff" + ]; + const statuses: number[] = []; + for (const ip of samePrefix) { + statuses.push((await worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": ip }), + env(db) + )).status); + } + statuses.push((await worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": "2001:db8:1234:5678::99" }), + env(db) + )).status); + statuses.push((await worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": "2001:db8:1234:5679::1" }), + env(db) + )).status); + + expect(statuses).toEqual([201, 201, 201, 201, 201, 429, 201]); + expect(db.donationIntents[0].client_ip).toBe("2001:0DB8:1234:5678::1"); + expect(new Set(providerCreationClaims(db).map((claim) => claim.client_key_hash)).size).toBe(2); }); - it("counts pre-ledger intents while atomically admitting overlapping creations", async () => { + it("collapses malformed, bracketed, port, zone, proxy-list, and missing IPs into one bucket", async () => { + const db = new InMemoryD1(); + const malformed = [ + "", + "[2001:db8::1]", + "203.0.113.7:443", + "fe80::1%eth0", + "203.0.113.7, 198.51.100.8", + "not-an-ip" + ]; + const statuses: number[] = []; + for (const ip of malformed) { + statuses.push((await worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": ip }), + env(db) + )).status); + } + + expect(statuses).toEqual([201, 201, 201, 201, 201, 429]); + expect(db.donationIntents[1].client_ip).toBe("[2001:db8::1]"); + expect(new Set(providerCreationClaims(db).map((claim) => claim.client_key_hash)).size).toBe(1); + }); + + it("keeps the IPv6 /64 and unknown-bucket behavior on real SQLite", async () => { + const ipv6Database = migratedDatabase(); + const malformedDatabase = migratedDatabase(); + try { + const ipv6Env = { ...env(new InMemoryD1()), DB: sqliteD1(ipv6Database) }; + const ipv6Addresses = [ + "2001:0DB8:AAAA:BBBB::1", + "2001:db8:aaaa:bbbb:0:0:0:2", + "2001:db8:aaaa:bbbb:1111::3", + "2001:db8:aaaa:bbbb:2222::4", + "2001:db8:aaaa:bbbb:ffff:ffff:ffff:ffff", + "2001:db8:aaaa:bbbb::99", + "2001:db8:aaaa:bbbc::1" + ]; + const ipv6Statuses: number[] = []; + for (const ip of ipv6Addresses) { + ipv6Statuses.push((await worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": ip }), + ipv6Env + )).status); + } + expect(ipv6Statuses).toEqual([201, 201, 201, 201, 201, 429, 201]); + expect(ipv6Database.prepare( + "SELECT client_ip FROM donation_intents ORDER BY created_at, rowid LIMIT 1" + ).get()).toEqual({ client_ip: "2001:0DB8:AAAA:BBBB::1" }); + + const malformedEnv = { ...env(new InMemoryD1()), DB: sqliteD1(malformedDatabase) }; + const malformedStatuses: number[] = []; + for (const ip of ["", "[::1]", "192.0.2.1:80", "fe80::1%lo0", "192.0.2.1,198.51.100.1", "bad"]) { + malformedStatuses.push((await worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": ip }), + malformedEnv + )).status); + } + expect(malformedStatuses).toEqual([201, 201, 201, 201, 201, 429]); + } finally { + ipv6Database.close(); + malformedDatabase.close(); + } + }); + + it("counts unattributed rolling-deploy Wompi rows in the provider ceiling", async () => { vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:00:00.000Z") }); try { const db = new InMemoryD1(); - for (let index = 0; index < 2; index += 1) { + for (let index = 0; index < 59; index += 1) { db.donationIntents.push({ id: `legacy_intent_${index}`, - client_ip: "203.0.113.7", - created_at: `2026-07-04T11:5${index}:00.000Z` + client_ip: `198.51.100.${index + 1}`, + provider_creation_claim_id: null, + created_at: "2026-07-04T11:50:00.000Z" }); } const responses = await Promise.all( - Array.from({ length: 20 }, () => worker.fetch(intentRequest(validIntentBody()), env(db))) + Array.from({ length: 20 }, (_, index) => worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": `203.0.113.${index + 1}` }), + env(db) + )) ); - expect(responses.filter((response) => response.status === 201)).toHaveLength(3); - expect(responses.filter((response) => response.status === 429)).toHaveLength(17); - expect(db.donationIntents).toHaveLength(5); - expect(db.securityRateLimitClaims.filter((claim) => claim.scope === "donation_intent")).toHaveLength(3); + expect(responses.filter((response) => response.status === 201)).toHaveLength(1); + expect(responses.filter((response) => response.status === 429)).toHaveLength(19); + expect(responses.find((response) => response.status === 429)?.headers.get("Cache-Control")) + .toBe("no-store"); + expect(db.donationIntents).toHaveLength(60); + expect(providerCreationClaims(db)).toHaveLength(1); } finally { vi.useRealTimers(); } @@ -485,14 +609,15 @@ describe("donation intents", () => { vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:00:00.000Z") }); try { const db = new InMemoryD1(); - // Five intents already created by this IP inside the window. + const clientKeyHash = await sha256Hex(utf8Bytes("203.0.113.7")); for (let i = 0; i < 5; i += 1) { - db.donationIntents.push({ - id: `di_seed_${i}`, - status: "LINK_CREATED", - client_ip: "203.0.113.7", - expires_at: "2026-07-04T13:00:00.000Z", - created_at: `2026-07-04T11:5${i}:00.000Z` + providerCreationClaims(db).push({ + id: `provider_seed_${i}`, + provider: "WOMPI", + client_key_hash: clientKeyHash, + stripe_request_id: null, + expires_at: "2026-07-04T12:15:00.000Z", + claimed_at: `2026-07-04T11:5${i}:00.000Z` }); } @@ -504,7 +629,7 @@ describe("donation intents", () => { message: "Demasiados intentos. Espere 15 minutos e intente de nuevo." }); // No new intent was created. - expect(db.donationIntents).toHaveLength(5); + expect(db.donationIntents).toHaveLength(0); } finally { vi.useRealTimers(); } @@ -542,6 +667,9 @@ describe("donation intents", () => { await expect(response.json()).resolves.toMatchObject({ error: "wompi_link_failed" }); expect(db.donationIntents).toHaveLength(1); expect(db.donationIntents[0].status).toBe("PENDING"); + expect(providerCreationClaims(db)).toHaveLength(1); + expect(db.donationIntents[0].provider_creation_claim_id) + .toBe(providerCreationClaims(db)[0].id); expect(fetchSpy).toHaveBeenCalledTimes(2); const [tokenUrl, tokenInit] = fetchSpy.mock.calls[0]; expect(tokenUrl).toBe("https://id.wompi.sv/connect/token"); @@ -575,6 +703,9 @@ describe("donation intents", () => { await expect(response.json()).resolves.toMatchObject({ error: "wompi_link_failed" }); expect(db.donationIntents).toHaveLength(1); expect(db.donationIntents[0].status).toBe("PENDING"); + expect(providerCreationClaims(db)).toHaveLength(1); + expect(db.donationIntents[0].provider_creation_claim_id) + .toBe(providerCreationClaims(db)[0].id); expect(fetchSpy).not.toHaveBeenCalled(); } finally { fetchSpy.mockRestore(); @@ -1068,14 +1199,22 @@ describe("donation intents", () => { it("applies the same per-IP throttle to draft creates", async () => { const db = new InMemoryD1(); + const clientKeyHash = await sha256Hex(utf8Bytes("203.0.113.7")); for (let i = 0; i < 5; i += 1) { - db.donationIntents.push({ id: `di_seed_${i}`, client_ip: "203.0.113.7", created_at: "2026-07-04T12:00:00.000Z" }); + providerCreationClaims(db).push({ + id: `provider_seed_${i}`, + provider: "WOMPI", + client_key_hash: clientKeyHash, + stripe_request_id: null, + claimed_at: "2026-07-04T12:00:00.000Z", + expires_at: "2026-07-04T12:15:00.000Z" + }); } vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:05:00.000Z") }); try { const response = await worker.fetch(draftRequest({ amount: "25.00", giftType: "DIEZMO" }), env(db)); expect(response.status).toBe(429); - expect(db.donationIntents).toHaveLength(5); + expect(db.donationIntents).toHaveLength(0); } finally { vi.useRealTimers(); } @@ -1431,3 +1570,10 @@ describe("donation intents", () => { }); }); }); + +function providerCreationClaims(db: InMemoryD1): Array> { + const claims = (db as unknown as { providerCreationClaims?: Array> }) + .providerCreationClaims; + expect(claims, "the in-memory D1 mirrors provider creation claims").toBeInstanceOf(Array); + return claims ?? []; +} From dea96e1967d4e5760792a628ce9bc786b7b0f6d8 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:30:01 -0600 Subject: [PATCH 10/22] fix(worker): gate Stripe retry generations --- README.es.md | 4 +- README.md | 4 +- docs/stripe-us-giving.md | 2 +- .../0047_provider_creation_legacy_index.sql | 4 + scripts/check-migration-immutability.mjs | 5 +- src/worker/index.ts | 41 +- src/worker/storage/repository/rateLimits.ts | 27 +- .../storage/repository/stripeDonations.ts | 37 +- test/scripts/migrationImmutability.test.ts | 12 +- .../productionProvisioningDocs.test.ts | 2 +- test/scripts/stripeProvisioningDocs.test.ts | 2 +- .../stripeAnnualStatementMigration.test.ts | 2 +- test/worker/stripeRepository.test.ts | 73 +++- test/worker/stripeRoutes.test.ts | 389 ++++++++++++++++++ test/worker/support/inMemoryD1.ts | 45 +- test/worker/workerFetch.auth-infra.test.ts | 99 ++++- 16 files changed, 709 insertions(+), 39 deletions(-) create mode 100644 migrations/0047_provider_creation_legacy_index.sql diff --git a/README.es.md b/README.es.md index 9c1f70ea..3cb6103b 100644 --- a/README.es.md +++ b/README.es.md @@ -208,7 +208,7 @@ DiezmosSV/ │ ├── client/ # Panel React + Vite, /donar, fuentes, recursos │ └── shared/ # Catálogos · DUI · NIT · ventanas legales · política de contraseñas │ # correcciones fiscales · entrega · montos · correo -├── migrations/ # Esquema D1 (incremental, solo se agrega, 0001…0046) +├── migrations/ # Esquema D1 (incremental, solo se agrega, 0001…0047) ├── DTE/svfe-json-schemas/ # Esquemas JSON de MH para validación ├── docs/ # Despliegue/UAT · manual del operador · restauración de retención │ # cutover/conciliación de claims fiscales · recuperación previa al CDE @@ -1075,7 +1075,7 @@ El modelo de seguridad es el modelo del claim fiscal aplicado a una ruta de repa ## 📚 Modelo de datos
-Tablas de D1 (migrations/0001_init.sql, extendidas hasta la 0046) +Tablas de D1 (migrations/0001_init.sql, extendidas hasta la 0047)
diff --git a/README.md b/README.md index 1560895e..2e7fdde5 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ DiezmosSV/ │ ├── client/ # React + Vite admin panel, /donar, fonts, assets │ └── shared/ # Catalogs · DUI · NIT · legal windows · password policy │ # fiscal corrections · checkout · money · email -├── migrations/ # D1 schema (incremental, append-only 0001…0046) +├── migrations/ # D1 schema (incremental, append-only 0001…0047) ├── DTE/svfe-json-schemas/ # MH-bundled JSON schemas for validation ├── docs/ # Deploy/UAT · operator runbook · retention-restore │ # fiscal-claim cutover/reconciliation · pre-CDE recovery @@ -1037,7 +1037,7 @@ The safety model is the fiscal-claim model applied to a repair path: ## 🗄 Data model
-D1 tables (migrations/0001_init.sql, extended through 0046) +D1 tables (migrations/0001_init.sql, extended through 0047)
diff --git a/docs/stripe-us-giving.md b/docs/stripe-us-giving.md index 5331768a..51f1151d 100644 --- a/docs/stripe-us-giving.md +++ b/docs/stripe-us-giving.md @@ -170,4 +170,4 @@ Pendiente del propietario del despliegue: Para detener nuevas entregas sin perder la reconciliación, mantenga la revisión actual compatible con Stripe y configure `DONATION_INTAKE_DISABLED=true`. Ese interruptor bloquea la creación de Checkout nueva, pero debe conservar `/webhooks/stripe`, la consulta durable `/api/donations/stripe/session/`, `/api/donations/stripe/portal`, los acuses de recibo pendientes y la conciliación de constancias anuales. -Si el incidente exige desplegar código anterior, use solamente una revisión conocida compatible con Stripe que retenga esas rutas y tareas; nunca despliegue un SHA anterior a la integración Stripe mientras existan sesiones, facturas o suscripciones abiertas. Conserve las migraciones aditivas `0032` (tablas base), `0033` (tipo de entrega), `0034` (constancias anuales), `0035` (cronología monotónica del proveedor), `0036` (retención consistente), `0037` (seguridad de entregas), `0038` (cercas finales de integridad), `0039` (evidencia de contacto del donante), `0040` (evidencia no sensible del método realmente usado), `0041` (evidencia inmutable del correo anual), `0042` (reclamo de la constancia anual para entregas anteriores a 0041), `0043` (cerca que exige evidencia congelada antes del despacho o estado SENT), `0044` (capacidad de acceso y límites atómicos del Portal de Stripe) y `0046` (presupuestos atómicos de creación de proveedor), junto con todas sus filas. No elimine ni revierta esas migraciones: una reversión de código no revierte D1, webhooks, facturas, suscripciones, constancias ni evidencia de cronología. Mantenga la clave activa y el secreto de webhook operativo hasta conciliar sesiones abiertas y entregas mensuales. Desactivar una configuración o clave sin esa conciliación puede impedir renovaciones o administración de la persona donante. +Si el incidente exige desplegar código anterior, use solamente una revisión conocida compatible con Stripe que retenga esas rutas y tareas; nunca despliegue un SHA anterior a la integración Stripe mientras existan sesiones, facturas o suscripciones abiertas. Conserve las migraciones aditivas `0032` (tablas base), `0033` (tipo de entrega), `0034` (constancias anuales), `0035` (cronología monotónica del proveedor), `0036` (retención consistente), `0037` (seguridad de entregas), `0038` (cercas finales de integridad), `0039` (evidencia de contacto del donante), `0040` (evidencia no sensible del método realmente usado), `0041` (evidencia inmutable del correo anual), `0042` (reclamo de la constancia anual para entregas anteriores a 0041), `0043` (cerca que exige evidencia congelada antes del despacho o estado SENT), `0044` (capacidad de acceso y límites atómicos del Portal de Stripe), `0046` (presupuestos atómicos de creación de proveedor) y `0047` (índice acotado de compatibilidad para sesiones Stripe heredadas), junto con todas sus filas. No elimine ni revierta esas migraciones: una reversión de código no revierte D1, webhooks, facturas, suscripciones, constancias ni evidencia de cronología. Mantenga la clave activa y el secreto de webhook operativo hasta conciliar sesiones abiertas y entregas mensuales. Desactivar una configuración o clave sin esa conciliación puede impedir renovaciones o administración de la persona donante. diff --git a/migrations/0047_provider_creation_legacy_index.sql b/migrations/0047_provider_creation_legacy_index.sql new file mode 100644 index 00000000..0342581b --- /dev/null +++ b/migrations/0047_provider_creation_legacy_index.sql @@ -0,0 +1,4 @@ +-- Bound rolling-deploy Stripe legacy counts to the recent unattributed slice. +CREATE INDEX idx_stripe_checkout_legacy_created + ON stripe_checkout_sessions(created_at) + WHERE provider_creation_claim_id IS NULL; diff --git a/scripts/check-migration-immutability.mjs b/scripts/check-migration-immutability.mjs index 82b518b9..fd9c4cf7 100644 --- a/scripts/check-migration-immutability.mjs +++ b/scripts/check-migration-immutability.mjs @@ -49,7 +49,8 @@ export const IMMUTABLE_MIGRATION_SHA256 = Object.freeze({ "0043_stripe_annual_email_evidence_dispatch_guard.sql": "dd00177c7dece29d887cfd7913b7b2ca0eecd7e63862c6f09d459a0b2a054714", "0044_stripe_portal_capability.sql": "2a4be49afc5da8201438999cec857978910631ec002084467a50951b5373d1ca", "0045_login_step_up_mfa.sql": "9fd64e15528cb80f72d99389cec87378c01cada6adb70497ece9e4cb567853ca", - "0046_provider_creation_budgets.sql": "dd014b8bf754da9bca91cddf96d77dcb48718a4644cd3c89d01ddaecc8bca35d" + "0046_provider_creation_budgets.sql": "dd014b8bf754da9bca91cddf96d77dcb48718a4644cd3c89d01ddaecc8bca35d", + "0047_provider_creation_legacy_index.sql": "52c0072c5d0c85ee488c5351cc0d33fcc01da3370b08d3a583440e7c8663aeeb" }); export function assertImmutableMigrations( @@ -98,7 +99,7 @@ export function assertImmutableMigrations( if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { try { assertImmutableMigrations(); - process.stdout.write("Historical migrations 0001-0046 are immutable.\n"); + process.stdout.write("Historical migrations 0001-0047 are immutable.\n"); } catch (error) { process.stderr.write( `${error instanceof Error ? error.message : String(error)}\n` diff --git a/src/worker/index.ts b/src/worker/index.ts index f458d6dc..79aaaeeb 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1547,6 +1547,9 @@ async function prepareExistingStripeCheckoutCreation( return existingStripeCheckoutResponse(ctx, existing, configuration); } + const definiteFailureRetry = existing.status === "FAILED" + && existing.creation_outcome_class === "DEFINITE_FAILURE"; + const request = await buildStripeCheckoutCreationRequest(ctx, existing.id, { requestId: existing.request_id, amountCents: existing.amount_cents, @@ -1560,12 +1563,48 @@ async function prepareExistingStripeCheckoutCreation( return stripeCheckoutIndeterminateResponse(); } + let definiteFailureAdmission: { + claim: Awaited>; + id: string; + } | null = null; + if (definiteFailureRetry) { + const clientIp = clientIpFrom(ctx.request); + const claimNow = nowIso(); + const claim = await ctx.repo.claimProviderCreationBudget({ + provider: "STRIPE", + clientKeyHash: await rateLimitKey(providerCreationRateIdentity(clientIp)), + stripeRequestId: existing.request_id, + now: claimNow, + cutoff: intentThrottleSinceIso(), + expiresAt: intentThrottleExpiresIso(), + clientLimit: PROVIDER_CREATION_CLIENT_LIMIT, + providerLimit: PROVIDER_CREATION_PROVIDER_LIMIT, + globalLimit: PROVIDER_CREATION_GLOBAL_LIMIT + }); + if (claim.kind === "LIMITED") return providerCreationLimitedResponse(); + if ( + claim.kind === "DUPLICATE" + && claim.id !== existing.provider_creation_claim_id + ) { + return stripeCheckoutCreationInProgressResponse(); + } + definiteFailureAdmission = { claim, id: claim.id }; + } + const reclaimed = await ctx.repo.reclaimStripeCheckoutCreation({ id: existing.id, requestFingerprint: request.fingerprint, - now: nowIso() + now: nowIso(), + definiteFailureAdmission: definiteFailureAdmission ? { + admittedProviderCreationClaimId: definiteFailureAdmission.id, + expectedProviderCreationClaimId: existing.provider_creation_claim_id, + expectedIdempotencyGeneration: existing.idempotency_generation + } : undefined }); if (!reclaimed) { + if (definiteFailureAdmission?.claim.kind === "CLAIMED") { + await ctx.repo.releaseUnusedProviderCreationClaim(definiteFailureAdmission.id); + } const current = await ctx.repo.getStripeCheckoutById(existing.id); return current ? existingStripeCheckoutResponse(ctx, current, configuration) diff --git a/src/worker/storage/repository/rateLimits.ts b/src/worker/storage/repository/rateLimits.ts index f7c8d15c..ddf5edf9 100644 --- a/src/worker/storage/repository/rateLimits.ts +++ b/src/worker/storage/repository/rateLimits.ts @@ -7,7 +7,7 @@ export type StripeProviderRecoveryClaim = export type ProviderCreationClaim = | { kind: "CLAIMED"; id: string } - | { kind: "DUPLICATE" } + | { kind: "DUPLICATE"; id: string } | { kind: "LIMITED" }; export async function claimProviderCreationBudget( @@ -30,17 +30,19 @@ export async function claimProviderCreationBudget( // their provider/global budgets; attached rows are represented by the claim // itself and are deliberately not double-counted. const row = await db.prepare( - `INSERT OR IGNORE INTO provider_creation_claims ( + `INSERT INTO provider_creation_claims ( id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at ) SELECT ?, ?, ?, ?, ?, ? WHERE ( SELECT COUNT(*) FROM provider_creation_claims WHERE client_key_hash = ? AND claimed_at >= ? + AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?) ) < ? AND ( (SELECT COUNT(*) FROM provider_creation_claims - WHERE provider = ? AND claimed_at >= ?) + WHERE provider = ? AND claimed_at >= ? + AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?)) + CASE WHEN ? = 'WOMPI' THEN (SELECT COUNT(*) FROM donation_intents WHERE provider_creation_claim_id IS NULL AND created_at >= ?) @@ -49,12 +51,21 @@ export async function claimProviderCreationBudget( END ) < ? AND ( - (SELECT COUNT(*) FROM provider_creation_claims WHERE claimed_at >= ?) + (SELECT COUNT(*) FROM provider_creation_claims + WHERE claimed_at >= ? + AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?)) + (SELECT COUNT(*) FROM donation_intents WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + (SELECT COUNT(*) FROM stripe_checkout_sessions WHERE provider_creation_claim_id IS NULL AND created_at >= ?) ) < ? + ON CONFLICT(provider, stripe_request_id) + WHERE provider = 'STRIPE' AND stripe_request_id IS NOT NULL + DO UPDATE SET + client_key_hash = excluded.client_key_hash, + claimed_at = excluded.claimed_at, + expires_at = excluded.expires_at + WHERE provider_creation_claims.expires_at <= excluded.claimed_at RETURNING id` ).bind( id, @@ -65,14 +76,17 @@ export async function claimProviderCreationBudget( input.expiresAt, input.clientKeyHash, input.cutoff, + input.stripeRequestId, input.clientLimit, input.provider, input.cutoff, + input.stripeRequestId, input.provider, input.cutoff, input.cutoff, input.providerLimit, input.cutoff, + input.stripeRequestId, input.cutoff, input.cutoff, input.globalLimit @@ -82,9 +96,10 @@ export async function claimProviderCreationBudget( const duplicate = await db.prepare( `SELECT id FROM provider_creation_claims WHERE provider = 'STRIPE' AND stripe_request_id = ? + AND claimed_at >= ? AND expires_at > ? LIMIT 1` - ).bind(input.stripeRequestId).first<{ id: string }>(); - if (duplicate) return { kind: "DUPLICATE" }; + ).bind(input.stripeRequestId, input.cutoff, input.now).first<{ id: string }>(); + if (duplicate) return { kind: "DUPLICATE", id: duplicate.id }; } return { kind: "LIMITED" }; } diff --git a/src/worker/storage/repository/stripeDonations.ts b/src/worker/storage/repository/stripeDonations.ts index bab0df4f..7c95ec25 100644 --- a/src/worker/storage/repository/stripeDonations.ts +++ b/src/worker/storage/repository/stripeDonations.ts @@ -343,8 +343,40 @@ export async function failStripeCheckoutCreation( export async function reclaimStripeCheckoutCreation( db: D1Database, - input: { id: string; requestFingerprint: string; now: string } + input: { + id: string; + requestFingerprint: string; + now: string; + definiteFailureAdmission?: { + admittedProviderCreationClaimId: string; + expectedProviderCreationClaimId: string | null; + expectedIdempotencyGeneration: number; + }; + } ): Promise { + if (input.definiteFailureAdmission) { + const admission = input.definiteFailureAdmission; + return db.prepare( + `UPDATE stripe_checkout_sessions + SET request_fingerprint = ?, status = 'CREATING', + creation_attempt_count = creation_attempt_count + 1, + idempotency_generation = idempotency_generation + 1, + creation_outcome_class = NULL, error_code = NULL, + provider_creation_claim_id = ?, updated_at = ? + WHERE id = ? AND provider_creation_claim_id IS ? + AND idempotency_generation = ? + AND status = 'FAILED' AND creation_outcome_class = 'DEFINITE_FAILURE' + AND stripe_session_id IS NULL AND creation_attempt_count < 3 + RETURNING *` + ).bind( + input.requestFingerprint, + admission.admittedProviderCreationClaimId, + input.now, + input.id, + admission.expectedProviderCreationClaimId, + admission.expectedIdempotencyGeneration + ).first(); + } return db.prepare( `UPDATE stripe_checkout_sessions SET request_fingerprint = CASE @@ -359,7 +391,8 @@ export async function reclaimStripeCheckoutCreation( error_code = NULL, updated_at = ? WHERE id = ? AND stripe_session_id IS NULL AND creation_attempt_count < 3 - AND (creation_outcome_class = 'DEFINITE_FAILURE' OR request_fingerprint = ?) + AND creation_outcome_class IS NOT 'DEFINITE_FAILURE' + AND request_fingerprint = ? AND ( status = 'FAILED' OR (status = 'CREATING' AND updated_at < ?) diff --git a/test/scripts/migrationImmutability.test.ts b/test/scripts/migrationImmutability.test.ts index 095b15f9..4061af64 100644 --- a/test/scripts/migrationImmutability.test.ts +++ b/test/scripts/migrationImmutability.test.ts @@ -45,7 +45,7 @@ describe("migration immutability checker", () => { await loadChecker(); expect(Object.keys(IMMUTABLE_MIGRATION_SHA256).at(-1)).toBe( - "0046_provider_creation_budgets.sql" + "0047_provider_creation_legacy_index.sql" ); expect(Object.keys(IMMUTABLE_MIGRATION_SHA256)).toEqual([ "0001_init.sql", @@ -93,7 +93,8 @@ describe("migration immutability checker", () => { "0043_stripe_annual_email_evidence_dispatch_guard.sql", "0044_stripe_portal_capability.sql", "0045_login_step_up_mfa.sql", - "0046_provider_creation_budgets.sql" + "0046_provider_creation_budgets.sql", + "0047_provider_creation_legacy_index.sql" ]); expect(() => assertImmutableMigrations(migrationsDirectory)).not.toThrow(); }); @@ -120,11 +121,12 @@ describe("migration immutability checker", () => { ); }); - it("fails if either newly pinned remediation migration is modified", async () => { + it("fails if any newly pinned remediation migration is modified", async () => { const { assertImmutableMigrations } = await loadChecker(); for (const name of [ "0045_login_step_up_mfa.sql", - "0046_provider_creation_budgets.sql" + "0046_provider_creation_budgets.sql", + "0047_provider_creation_legacy_index.sql" ]) { const copy = copiedMigrations(); const target = join(copy, name); @@ -179,7 +181,7 @@ describe("migration immutability checker", () => { it("accepts only the next unique additive migration prefix", async () => { const { assertImmutableMigrations } = await loadChecker(); const copy = copiedMigrations(); - writeFileSync(join(copy, "0047_future_addition.sql"), "SELECT 1;\n"); + writeFileSync(join(copy, "0048_future_addition.sql"), "SELECT 1;\n"); expect(() => assertImmutableMigrations(copy)).not.toThrow(); }); diff --git a/test/scripts/productionProvisioningDocs.test.ts b/test/scripts/productionProvisioningDocs.test.ts index 6a5f1d54..20feffdb 100644 --- a/test/scripts/productionProvisioningDocs.test.ts +++ b/test/scripts/productionProvisioningDocs.test.ts @@ -349,7 +349,7 @@ const allowedWranglerDocumentationCases = [ describe("remote provisioning documentation", () => { it("mirrors the current Stripe migration range and non-archived ledgers in both READMEs", () => { for (const document of [readme, readmeEs]) { - expect(document).toContain("0001…0046"); + expect(document).toContain("0001…0047"); expect(document).toContain("stripe_retention_generations"); expect(document).toContain("stripe_invoice_settlement_retention_generations"); expect(document).toContain("Stripe"); diff --git a/test/scripts/stripeProvisioningDocs.test.ts b/test/scripts/stripeProvisioningDocs.test.ts index 4a5e55d0..7dc3d11e 100644 --- a/test/scripts/stripeProvisioningDocs.test.ts +++ b/test/scripts/stripeProvisioningDocs.test.ts @@ -241,7 +241,7 @@ describe("Stripe US giving provisioning documentation", () => { it("keeps every additive Stripe migration in the rollback preservation boundary", () => { const rollback = runbook.slice(runbook.indexOf("## Handoff del propietario y rollback")); - for (const migration of ["0032", "0033", "0034", "0035", "0036", "0037", "0038", "0039", "0040", "0041", "0042", "0043", "0044", "0046"]) { + for (const migration of ["0032", "0033", "0034", "0035", "0036", "0037", "0038", "0039", "0040", "0041", "0042", "0043", "0044", "0046", "0047"]) { expect(rollback).toContain(migration); } expect(rollback).toMatch(/no.*elimine|conserve/is); diff --git a/test/worker/stripeAnnualStatementMigration.test.ts b/test/worker/stripeAnnualStatementMigration.test.ts index e0e1350e..8d3133f3 100644 --- a/test/worker/stripeAnnualStatementMigration.test.ts +++ b/test/worker/stripeAnnualStatementMigration.test.ts @@ -172,7 +172,7 @@ describe("Stripe U.S. annual statement persistence", () => { it("upgrades 0042 legacy post-dispatch rows and still lets operators move them to REVIEW", () => { expect(migrationFiles().at(-1)).toBe( - "0046_provider_creation_budgets.sql" + "0047_provider_creation_legacy_index.sql" ); expect(existsSync(emailEvidenceDispatchGuardMigrationPath)).toBe(true); const database = migratedDatabaseThrough("0042"); diff --git a/test/worker/stripeRepository.test.ts b/test/worker/stripeRepository.test.ts index c2124d7c..97e37038 100644 --- a/test/worker/stripeRepository.test.ts +++ b/test/worker/stripeRepository.test.ts @@ -194,12 +194,81 @@ describe("Stripe donation repository", () => { expect(await reclaimStripeCheckoutCreation(db, { id: "stripe_checkout_one", requestFingerprint: "v2:corrected", - now: "2026-08-10T12:00:05.000Z" + now: "2026-08-10T12:00:05.000Z", + definiteFailureAdmission: { + admittedProviderCreationClaimId: "provider_claim_two", + expectedProviderCreationClaimId: "provider_claim_one", + expectedIdempotencyGeneration: 1 + } })).toMatchObject({ status: "CREATING", creation_attempt_count: 3, request_fingerprint: "v2:corrected", - idempotency_generation: 2 + idempotency_generation: 2, + provider_creation_claim_id: "provider_claim_two" + }); + }); + + it("binds definite retry admission to the old claim and generation snapshot", async () => { + await reserveStripeCheckout(db, checkoutInput()); + await failStripeCheckoutCreation(db, { + outcomeClass: "DEFINITE_FAILURE", + id: "stripe_checkout_one", + errorCode: "definite_fixture", + now: "2026-08-10T12:00:01.000Z" + }); + + expect(await reclaimStripeCheckoutCreation(db, { + id: "stripe_checkout_one", + requestFingerprint: "v2:corrected", + now: "2026-08-10T12:00:02.000Z" + })).toBeNull(); + expect(await reclaimStripeCheckoutCreation(db, { + id: "stripe_checkout_one", + requestFingerprint: "v2:corrected", + now: "2026-08-10T12:00:02.000Z", + definiteFailureAdmission: { + admittedProviderCreationClaimId: "provider_claim_two", + expectedProviderCreationClaimId: "wrong_old_claim", + expectedIdempotencyGeneration: 1 + } + })).toBeNull(); + expect(await reclaimStripeCheckoutCreation(db, { + id: "stripe_checkout_one", + requestFingerprint: "v2:corrected", + now: "2026-08-10T12:00:02.000Z", + definiteFailureAdmission: { + admittedProviderCreationClaimId: "provider_claim_two", + expectedProviderCreationClaimId: "provider_claim_one", + expectedIdempotencyGeneration: 2 + } + })).toBeNull(); + expect(database.prepare( + `SELECT status, creation_outcome_class, idempotency_generation, + provider_creation_claim_id + FROM stripe_checkout_sessions WHERE id = 'stripe_checkout_one'` + ).get()).toEqual({ + status: "FAILED", + creation_outcome_class: "DEFINITE_FAILURE", + idempotency_generation: 1, + provider_creation_claim_id: "provider_claim_one" + }); + + expect(await reclaimStripeCheckoutCreation(db, { + id: "stripe_checkout_one", + requestFingerprint: "v2:corrected", + now: "2026-08-10T12:00:03.000Z", + definiteFailureAdmission: { + admittedProviderCreationClaimId: "provider_claim_two", + expectedProviderCreationClaimId: "provider_claim_one", + expectedIdempotencyGeneration: 1 + } + })).toMatchObject({ + status: "CREATING", + creation_attempt_count: 2, + creation_outcome_class: null, + idempotency_generation: 2, + provider_creation_claim_id: "provider_claim_two" }); }); diff --git a/test/worker/stripeRoutes.test.ts b/test/worker/stripeRoutes.test.ts index 1f199f44..4a1c2bba 100644 --- a/test/worker/stripeRoutes.test.ts +++ b/test/worker/stripeRoutes.test.ts @@ -30,6 +30,7 @@ describe("Stripe public donation routes", () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); database.close(); }); @@ -486,6 +487,298 @@ describe("Stripe public donation routes", () => { creation_outcome_class: null, idempotency_generation: 2 }); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE stripe_request_id = ?" + ).get(requestId)).toEqual({ count: 1 }); + }); + + it("reuses an active attached claim for a definite retry without charging twice", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:00:00.000Z") }); + seedDefiniteFailureCheckout(database, { + claimId: "active_retry_claim", + claimedAt: "2026-07-04T11:55:00.000Z", + expiresAt: "2026-07-04T12:10:00.000Z" + }); + const providerFetch = stubSuccessfulStripeCreation(); + + const retry = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }); + + expect(retry.response.status).toBe(201); + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(database.prepare( + `SELECT id, claimed_at, expires_at FROM provider_creation_claims + WHERE stripe_request_id = ?` + ).get(requestId)).toEqual({ + id: "active_retry_claim", + claimed_at: "2026-07-04T11:55:00.000Z", + expires_at: "2026-07-04T12:10:00.000Z" + }); + expect(database.prepare( + `SELECT status, idempotency_generation, provider_creation_claim_id + FROM stripe_checkout_sessions WHERE request_id = ?` + ).get(requestId)).toEqual({ + status: "OPEN", + idempotency_generation: 2, + provider_creation_claim_id: "active_retry_claim" + }); + }); + + it("admits a definite retry with a new claim after the expired claim was swept", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { claimId: "swept_retry_claim" }); + const providerFetch = stubSuccessfulStripeCreation(); + + const retry = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }); + + expect(retry.response.status).toBe(201); + expect(providerFetch).toHaveBeenCalledTimes(1); + const claim = database.prepare( + `SELECT id, claimed_at, expires_at FROM provider_creation_claims + WHERE stripe_request_id = ?` + ).get(requestId) as { id: string; claimed_at: string; expires_at: string } | undefined; + expect(claim).toEqual({ + id: expect.stringMatching(/^provider_create_/), + claimed_at: "2026-07-04T12:16:00.000Z", + expires_at: "2026-07-04T12:31:00.000Z" + }); + expect(claim?.id).not.toBe("swept_retry_claim"); + expect(database.prepare( + "SELECT provider_creation_claim_id FROM stripe_checkout_sessions WHERE request_id = ?" + ).get(requestId)).toEqual({ provider_creation_claim_id: claim?.id }); + }); + + it("atomically refreshes an unswept expired claim before a definite retry", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { + claimId: "expired_retry_claim", + claimedAt: "2026-07-04T12:00:00.000Z", + expiresAt: "2026-07-04T12:15:00.000Z" + }); + const providerFetch = stubSuccessfulStripeCreation(); + + const retry = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }); + + expect(retry.response.status).toBe(201); + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(database.prepare( + `SELECT id, claimed_at, expires_at FROM provider_creation_claims + WHERE stripe_request_id = ?` + ).get(requestId)).toEqual({ + id: "expired_retry_claim", + claimed_at: "2026-07-04T12:16:00.000Z", + expires_at: "2026-07-04T12:31:00.000Z" + }); + expect(database.prepare( + `SELECT status, idempotency_generation, provider_creation_claim_id + FROM stripe_checkout_sessions WHERE request_id = ?` + ).get(requestId)).toEqual({ + status: "OPEN", + idempotency_generation: 2, + provider_creation_claim_id: "expired_retry_claim" + }); + }); + + it.each([ + ["provider", 60], + ["global", 100] + ] as const)("blocks a definite retry at the exhausted %s ceiling before Stripe", async (dimension, count) => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { claimId: `${dimension}_old_retry_claim` }); + seedProviderCreationCapacity(database, dimension, count, "2026-07-04T12:16:00.000Z"); + const providerFetch = stubSuccessfulStripeCreation(); + + const retry = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }, "198.51.100.250"); + + expect(retry.response.status).toBe(429); + expect(retry.body).toEqual({ + error: "too_many_attempts", + message: "Demasiados intentos. Espere 15 minutos e intente de nuevo." + }); + expect(retry.response.headers.get("Cache-Control")).toBe("no-store"); + expect(providerFetch).not.toHaveBeenCalled(); + expect(database.prepare( + `SELECT status, idempotency_generation, provider_creation_claim_id + FROM stripe_checkout_sessions WHERE request_id = ?` + ).get(requestId)).toEqual({ + status: "FAILED", + idempotency_generation: 1, + provider_creation_claim_id: `${dimension}_old_retry_claim` + }); + }); + + it("admits and attaches only one claim across concurrent definite retries", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { claimId: "concurrent_old_retry_claim" }); + const providerFetch = stubSuccessfulStripeCreation(); + const synchronized = withSynchronizedStripeReservationReads(database); + synchronized.synchronizeNextPair(); + const concurrentEnv = { ...stripeProxyEnv(workerEnv), DB: synchronized.db }; + + const results = await Promise.all([ + createCheckout(concurrentEnv, { requestId, amount: 50, frequency: "once" }), + createCheckout(concurrentEnv, { requestId, amount: 50, frequency: "once" }) + ]); + + expect(results.map((result) => result.response.status).sort()).toEqual([201, 409]); + expect(providerFetch).toHaveBeenCalledTimes(1); + const claims = database.prepare( + "SELECT id FROM provider_creation_claims WHERE stripe_request_id = ?" + ).all(requestId) as Array<{ id: string }>; + expect(claims).toHaveLength(1); + expect(database.prepare( + `SELECT status, idempotency_generation, provider_creation_claim_id + FROM stripe_checkout_sessions WHERE request_id = ?` + ).get(requestId)).toEqual({ + status: "OPEN", + idempotency_generation: 2, + provider_creation_claim_id: claims[0]?.id + }); + }); + + it("releases an unattached fresh claim when the definite retry CAS loses", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { claimId: "lost_cas_old_retry_claim" }); + const providerFetch = stubSuccessfulStripeCreation(); + const losing = withLosingDefiniteRetryCas(database); + + const retry = await createCheckout({ + ...stripeProxyEnv(workerEnv), + DB: losing + }, { + requestId, + amount: 50, + frequency: "once" + }); + + expect(retry.response.status).toBe(409); + expect(retry.body).toMatchObject({ error: "stripe_checkout_unavailable" }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE stripe_request_id = ?" + ).get(requestId)).toEqual({ count: 0 }); + expect(database.prepare( + `SELECT status, creation_outcome_class, idempotency_generation, + provider_creation_claim_id + FROM stripe_checkout_sessions WHERE request_id = ?` + ).get(requestId)).toEqual({ + status: "FAILED", + creation_outcome_class: "DEFINITE_FAILURE", + idempotency_generation: 1, + provider_creation_claim_id: "lost_cas_old_retry_claim" + }); + }); + + it("retains an attached refreshed claim when the definite retry provider call fails", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { + claimId: "failed_refreshed_retry_claim", + claimedAt: "2026-07-04T12:00:00.000Z", + expiresAt: "2026-07-04T12:15:00.000Z" + }); + const providerFetch = vi.fn(async () => stripeJson({ + error: { + type: "invalid_request_error", + code: "parameter_invalid_integer", + message: "definite retry fixture", + param: "line_items[0][price_data][unit_amount]" + } + }, 400, { "stripe-should-retry": "false" })); + vi.stubGlobal("fetch", providerFetch); + + const retry = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }); + + expect(retry.response.status).toBe(502); + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(database.prepare( + `SELECT id, claimed_at, expires_at FROM provider_creation_claims + WHERE stripe_request_id = ?` + ).get(requestId)).toEqual({ + id: "failed_refreshed_retry_claim", + claimed_at: "2026-07-04T12:16:00.000Z", + expires_at: "2026-07-04T12:31:00.000Z" + }); + expect(database.prepare( + `SELECT status, creation_outcome_class, idempotency_generation, + provider_creation_claim_id + FROM stripe_checkout_sessions WHERE request_id = ?` + ).get(requestId)).toEqual({ + status: "FAILED", + creation_outcome_class: "DEFINITE_FAILURE", + idempotency_generation: 2, + provider_creation_claim_id: "failed_refreshed_retry_claim" + }); + }); + + it("keeps an active unattached Stripe claim in progress before expiry", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:00:00.000Z") }); + seedStripeProviderClaim(database, { + id: "active_unattached_claim", + claimedAt: "2026-07-04T11:55:00.000Z", + expiresAt: "2026-07-04T12:10:00.000Z" + }); + const providerFetch = stubSuccessfulStripeCreation(); + + const retry = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }); + + expect(retry.response.status).toBe(409); + expect(retry.body).toMatchObject({ error: "stripe_checkout_in_progress" }); + expect(providerFetch).not.toHaveBeenCalled(); + expect(database.prepare("SELECT COUNT(*) AS count FROM stripe_checkout_sessions").get()) + .toEqual({ count: 0 }); + }); + + it("refreshes an expired unattached Stripe claim instead of waiting for cron", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedStripeProviderClaim(database, { + id: "expired_unattached_claim", + claimedAt: "2026-07-04T12:00:00.000Z", + expiresAt: "2026-07-04T12:15:00.000Z" + }); + const providerFetch = stubSuccessfulStripeCreation(); + + const retry = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }); + + expect(retry.response.status).toBe(201); + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(database.prepare( + `SELECT id, claimed_at, expires_at FROM provider_creation_claims + WHERE stripe_request_id = ?` + ).get(requestId)).toEqual({ + id: "expired_unattached_claim", + claimed_at: "2026-07-04T12:16:00.000Z", + expires_at: "2026-07-04T12:31:00.000Z" + }); + expect(database.prepare( + "SELECT provider_creation_claim_id FROM stripe_checkout_sessions WHERE request_id = ?" + ).get(requestId)).toEqual({ provider_creation_claim_id: "expired_unattached_claim" }); }); it("recovers a returned Session after deferred D1 attachment", async () => { @@ -1082,6 +1375,102 @@ function stripeCheckoutObject(params: URLSearchParams): Record }; } +function seedStripeProviderClaim( + database: ReturnType, + input: { id: string; claimedAt: string; expiresAt: string; stripeRequestId?: string } +): void { + database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES (?, 'STRIPE', 'seed-client', ?, ?, ?)` + ).run(input.id, input.stripeRequestId ?? requestId, input.claimedAt, input.expiresAt); +} + +function seedDefiniteFailureCheckout( + database: ReturnType, + input: { claimId: string; claimedAt?: string; expiresAt?: string } +): void { + if (input.claimedAt && input.expiresAt) { + seedStripeProviderClaim(database, { + id: input.claimId, + claimedAt: input.claimedAt, + expiresAt: input.expiresAt + }); + } + database.prepare( + `INSERT INTO stripe_checkout_sessions ( + id, request_id, request_fingerprint, frequency, gift_type, amount_cents, + currency, livemode, status, creation_attempt_count, creation_outcome_class, + idempotency_generation, payment_status, provider_creation_claim_id, error_code, + created_at, updated_at + ) VALUES ('stripe_checkout_retry_fixture', ?, 'v2:stale', 'ONCE', 'TITHE', 5000, + 'usd', 0, 'FAILED', 1, 'DEFINITE_FAILURE', 1, 'UNPAID', ?, + 'stripe_checkout_create_failed', '2026-07-04T12:00:00.000Z', + '2026-07-04T12:00:00.000Z')` + ).run(requestId, input.claimId); +} + +function seedProviderCreationCapacity( + database: ReturnType, + dimension: "provider" | "global", + count: number, + claimedAt: string +): void { + const insert = database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES (?, ?, ?, ?, ?, '2026-07-04T12:31:00.000Z')` + ); + for (let index = 0; index < count; index += 1) { + const provider = dimension === "provider" || index % 2 === 0 ? "STRIPE" : "WOMPI"; + insert.run( + `${dimension}_capacity_${index}`, + provider, + `${dimension}-client-${index}`, + provider === "STRIPE" ? `${dimension}-request-${index}` : null, + claimedAt + ); + } +} + +function stubSuccessfulStripeCreation(): ReturnType> { + const providerFetch = vi.fn(async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + const url = new URL(request.url); + if (request.method !== "POST" || url.pathname !== "/v1/checkout/sessions") { + throw new Error(`Unexpected Stripe request: ${request.method} ${url.pathname}`); + } + return stripeCheckoutJson(new URLSearchParams(await request.text())); + }); + vi.stubGlobal("fetch", providerFetch); + return providerFetch; +} + +function withLosingDefiniteRetryCas( + database: ReturnType +): D1Database { + const base = sqliteD1(database); + return { + prepare(sql: string) { + const statement = base.prepare(sql); + if ( + sql.includes("UPDATE stripe_checkout_sessions") + && sql.includes("provider_creation_claim_id = ?") + && sql.includes("creation_outcome_class = 'DEFINITE_FAILURE'") + ) { + const mutable = statement as unknown as { + first: () => Promise; + }; + mutable.first = async () => null as T | null; + } + return statement; + }, + batch(statements: D1PreparedStatement[]) { + return base.batch(statements); + } + } as D1Database; +} + function withFailingStripeReservation( database: ReturnType ): D1Database { diff --git a/test/worker/support/inMemoryD1.ts b/test/worker/support/inMemoryD1.ts index 91b60d22..81aad0d6 100644 --- a/test/worker/support/inMemoryD1.ts +++ b/test/worker/support/inMemoryD1.ts @@ -997,7 +997,7 @@ export class Statement { document.updated_at = String(updatedAt); return { id: document.id } as T; } - if (this.sql.includes("INSERT OR IGNORE INTO provider_creation_claims")) { + if (this.sql.includes("INSERT INTO provider_creation_claims")) { const [ id, provider, @@ -1007,14 +1007,17 @@ export class Statement { expiresAt, countClientKeyHash, clientCutoff, + excludedClientRequestId, clientLimit, countProvider, providerCutoff, + excludedProviderRequestId, legacyProvider, donationLegacyCutoff, stripeLegacyCutoff, providerLimit, globalCutoff, + excludedGlobalRequestId, globalDonationCutoff, globalStripeCutoff, globalLimit @@ -1022,23 +1025,30 @@ export class Statement { const normalizedProvider = String(provider) as "WOMPI" | "STRIPE"; const normalizedRequestId = stripeRequestId == null ? null : String(stripeRequestId); if ( - normalizedProvider === "STRIPE" && - normalizedRequestId !== null && - this.db.providerCreationClaims.some( - (claim) => claim.provider === "STRIPE" && claim.stripe_request_id === normalizedRequestId - ) + (normalizedProvider === "WOMPI" && normalizedRequestId !== null) || + (normalizedProvider === "STRIPE" && normalizedRequestId === null) ) { - return null; + throw new Error("CHECK constraint failed: provider_creation_claims"); } + const matchingRequest = normalizedRequestId === null + ? null + : this.db.providerCreationClaims.find( + (claim) => claim.provider === "STRIPE" && claim.stripe_request_id === normalizedRequestId + ) ?? null; + const includedClaim = (claim: ProviderCreationClaimRow, excludedRequestId: unknown): boolean => + claim.provider !== "STRIPE" + || claim.stripe_request_id !== (excludedRequestId == null ? null : String(excludedRequestId)); const clientCount = this.db.providerCreationClaims.filter( (claim) => claim.client_key_hash === String(countClientKeyHash) && - claim.claimed_at >= String(clientCutoff) + claim.claimed_at >= String(clientCutoff) && + includedClaim(claim, excludedClientRequestId) ).length; const providerClaimCount = this.db.providerCreationClaims.filter( (claim) => claim.provider === String(countProvider) && - claim.claimed_at >= String(providerCutoff) + claim.claimed_at >= String(providerCutoff) && + includedClaim(claim, excludedProviderRequestId) ).length; const providerLegacyCount = String(legacyProvider) === "WOMPI" ? this.db.donationIntents.filter( @@ -1052,7 +1062,9 @@ export class Statement { String(checkout.created_at) >= String(stripeLegacyCutoff) ).length; const globalClaimCount = this.db.providerCreationClaims.filter( - (claim) => claim.claimed_at >= String(globalCutoff) + (claim) => + claim.claimed_at >= String(globalCutoff) && + includedClaim(claim, excludedGlobalRequestId) ).length; const globalLegacyCount = this.db.donationIntents.filter( (intent) => @@ -1070,6 +1082,13 @@ export class Statement { ) { return null; } + if (matchingRequest) { + if (matchingRequest.expires_at > String(claimedAt)) return null; + matchingRequest.client_key_hash = String(clientKeyHash); + matchingRequest.claimed_at = String(claimedAt); + matchingRequest.expires_at = String(expiresAt); + return { id: matchingRequest.id } as T; + } const claim: ProviderCreationClaimRow = { id: String(id), provider: normalizedProvider, @@ -1085,11 +1104,13 @@ export class Statement { this.sql.includes("SELECT id FROM provider_creation_claims") && this.sql.includes("stripe_request_id = ?") ) { - const [stripeRequestId] = this.args; + const [stripeRequestId, cutoff, now] = this.args; const claim = this.db.providerCreationClaims.find( (candidate) => candidate.provider === "STRIPE" && - candidate.stripe_request_id === String(stripeRequestId) + candidate.stripe_request_id === String(stripeRequestId) && + candidate.claimed_at >= String(cutoff) && + candidate.expires_at > String(now) ); return (claim ? { id: claim.id } : null) as T | null; } diff --git a/test/worker/workerFetch.auth-infra.test.ts b/test/worker/workerFetch.auth-infra.test.ts index 204b5a23..d6c32aeb 100644 --- a/test/worker/workerFetch.auth-infra.test.ts +++ b/test/worker/workerFetch.auth-infra.test.ts @@ -244,6 +244,10 @@ describe("provider creation budget migration", () => { import.meta.dirname, "../../migrations/0046_provider_creation_budgets.sql" ); + const legacyIndexMigrationPath = resolve( + import.meta.dirname, + "../../migrations/0047_provider_creation_legacy_index.sql" + ); it("installs the checked provider ledger, count indexes, and parent evidence columns", () => { const database = migratedDatabase(); @@ -341,6 +345,99 @@ describe("provider creation budget migration", () => { database.close(); } }); + + it("upgrades an exact 0046 database through additive 0047", () => { + expect(existsSync(legacyIndexMigrationPath), "migration 0047 exists").toBe(true); + if (!existsSync(legacyIndexMigrationPath)) return; + const database = migratedDatabaseThrough("0046"); + try { + expect(database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_stripe_checkout_legacy_created'" + ).get()).toBeUndefined(); + + database.exec(readFileSync(legacyIndexMigrationPath, "utf8")); + + expect(database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'idx_stripe_checkout_legacy_created'" + ).get()).toEqual({ name: "idx_stripe_checkout_legacy_created" }); + expect(database.prepare("PRAGMA index_info(idx_stripe_checkout_legacy_created)").all()) + .toEqual([expect.objectContaining({ name: "created_at" })]); + } finally { + database.close(); + } + }); + + it("searches indexed recent Stripe legacy history while preserving rolling admission", async () => { + const database = migratedDatabase(); + const historyNow = "2026-07-04T12:00:00.000Z"; + const historyCutoff = "2026-07-04T11:45:00.000Z"; + const historyExpiresAt = "2026-07-04T12:15:00.000Z"; + try { + const insert = database.prepare( + `INSERT INTO stripe_checkout_sessions ( + id, request_id, request_fingerprint, frequency, gift_type, amount_cents, + currency, livemode, status, payment_status, created_at, updated_at + ) VALUES (?, ?, 'v2:legacy', 'ONCE', 'TITHE', 1000, + 'usd', 0, 'FAILED', 'UNPAID', ?, ?)` + ); + database.exec("BEGIN IMMEDIATE"); + for (let index = 0; index < 2_000; index += 1) { + insert.run( + `historic_stripe_${index}`, + `historic-stripe-request-${index}`, + "2025-01-01T00:00:00.000Z", + "2025-01-01T00:00:00.000Z" + ); + } + insert.run( + "recent_legacy_stripe", + "recent-legacy-stripe-request", + "2026-07-04T11:59:00.000Z", + "2026-07-04T11:59:00.000Z" + ); + database.exec("COMMIT"); + database.exec("ANALYZE"); + + const plan = database.prepare( + `EXPLAIN QUERY PLAN + SELECT COUNT(*) FROM stripe_checkout_sessions + WHERE provider_creation_claim_id IS NULL AND created_at >= ?` + ).all(historyCutoff) as Array<{ detail: string }>; + const detail = plan.map((step) => step.detail).join("\n"); + expect(detail).toMatch( + /SEARCH stripe_checkout_sessions USING INDEX idx_stripe_checkout_legacy_created \(created_at>\?\)/i + ); + expect(detail).not.toMatch(/SCAN stripe_checkout_sessions/i); + + const repo = new Repository(sqliteD1(database)); + const first = await claimProviderCreationBudgetForTest(repo, { + provider: "STRIPE", + clientKeyHash: "large-history-client-one", + stripeRequestId: "large-history-request-one", + now: historyNow, + cutoff: historyCutoff, + expiresAt: historyExpiresAt, + clientLimit: 20, + providerLimit: 2, + globalLimit: 20 + }); + const second = await claimProviderCreationBudgetForTest(repo, { + provider: "STRIPE", + clientKeyHash: "large-history-client-two", + stripeRequestId: "large-history-request-two", + now: historyNow, + cutoff: historyCutoff, + expiresAt: historyExpiresAt, + clientLimit: 20, + providerLimit: 2, + globalLimit: 20 + }); + expect(first.kind).toBe("CLAIMED"); + expect(second).toEqual({ kind: "LIMITED" }); + } finally { + database.close(); + } + }); }); describe("provider creation budget repository", () => { @@ -2544,7 +2641,7 @@ type ProviderBudgetTestInput = { type ProviderBudgetTestResult = | { kind: "CLAIMED"; id: string } - | { kind: "DUPLICATE" } + | { kind: "DUPLICATE"; id: string } | { kind: "LIMITED" }; async function claimProviderCreationBudgetForTest( From 0aa0eb8b6b1fe0663de8f16699369f2134d90d61 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:52:10 -0600 Subject: [PATCH 11/22] fix(worker): reject conflicting Wompi replays --- src/worker/index.ts | 44 ++- src/worker/storage/repository.ts | 5 +- .../storage/repository/wompiIssuance.ts | 202 ++++++++++- test/worker/support/inMemoryD1.ts | 4 + .../workerFetch.advanced-cde-webhook.test.ts | 333 ++++++++++++++++++ 5 files changed, 568 insertions(+), 20 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 79aaaeeb..a945fcc9 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -932,6 +932,9 @@ async function handleWompiWebhook(request: Request, env: Env): Promise insertedAction: "WOMPI_RECEIVED", duplicateAction: "WOMPI_DUPLICATE" }); + if (ingested.conflict) { + return jsonResponse({ error: "wompi_event_conflict" }, { status: 409 }); + } return jsonResponse({ ok: true, wompiEventId: ingested.wompiEventId, @@ -1053,21 +1056,49 @@ async function ingestTrustedWompiPayload( inserted: boolean; queued: boolean; environmentAllowed: boolean; + conflict: boolean; }> { // The signed webhook or authenticated payment-link response remains the event's // fiscal environment, but the deployment capability decides whether this Worker // may issue it. Incompatible events are retained as evidence and quarantined. - const environment = ambienteFromWompi(payload); + const incomingEnvironment = ambienteFromWompi(payload); + const insertion = await repo.insertWompiEvent( + payload, + rawBody, + headers, + incomingEnvironment + ); + if (insertion.kind === "conflict") { + await repo.createAudit({ + action: "WOMPI_EVENT_CONFLICT", + entityType: "wompi_event", + entityId: insertion.record.id, + summary: "Evento Wompi rechazado por conflicto con el registro canónico", + metadata: { + reason: insertion.reason, + fields: insertion.fields + } + }); + return { + wompiEventId: insertion.record.id, + inserted: false, + queued: false, + environmentAllowed: false, + conflict: true + }; + } + const { record, canonicalPayload } = insertion; + const inserted = insertion.kind === "inserted"; + const environment = record.environment; const policy = deploymentEnvironmentPolicy(env); const environmentAllowed = policy.allowedAmbiente === environment; - const { record, inserted } = await repo.insertWompiEvent(payload, rawBody, headers, environment); const action = inserted ? source.insertedAction : source.duplicateAction; if (action) { await repo.createAudit({ action, entityType: "wompi_event", entityId: record.id, - summary: `${payload.IdTransaccion} ${payload.ResultadoTransaccion}`, + summary: `${canonicalPayload.IdTransaccion} ${canonicalPayload.ResultadoTransaccion}`, metadata: source.auditMetadata }); } @@ -1090,10 +1121,10 @@ async function ingestTrustedWompiPayload( // Runs on replays too (markIntentPaid is idempotent). Wrapped defensively — a // bad/unknown intent id must never break webhook processing. if (environmentAllowed) { - await markIntentPaidFromWebhook(env, repo, payload); + await markIntentPaidFromWebhook(env, repo, canonicalPayload); } let queued = false; - if (environmentAllowed && isApprovedDonation(payload)) { + if (environmentAllowed && isApprovedDonation(canonicalPayload)) { // Claim on duplicates too. If a previous delivery inserted the event but failed // before queueing it, the CAS repairs that gap; an already-queued event returns null. const attemptId = await repo.claimInitialWompiIssuanceAttempt(record.id); @@ -1106,7 +1137,8 @@ async function ingestTrustedWompiPayload( wompiEventId: record.id, inserted, queued, - environmentAllowed + environmentAllowed, + conflict: false }; } diff --git a/src/worker/storage/repository.ts b/src/worker/storage/repository.ts index 444941e5..5da67e76 100644 --- a/src/worker/storage/repository.ts +++ b/src/worker/storage/repository.ts @@ -202,7 +202,8 @@ import { markWompiIssuanceProcessing as markWompiIssuanceProcessingRepository, recordWompiIssuanceFailure as recordWompiIssuanceFailureRepository, releaseWompiEventIssuance as releaseWompiEventIssuanceRepository, - reserveWompiDocumentIdentifiers as reserveWompiDocumentIdentifiersRepository + reserveWompiDocumentIdentifiers as reserveWompiDocumentIdentifiersRepository, + type WompiEventInsertResult } from "./repository/wompiIssuance"; import { claimDocumentInvalidation as claimDocumentInvalidationRepository, @@ -596,7 +597,7 @@ export class Repository { return finalizeStripeAnnualStatementDeliveryRepository(this.db, input); } - async insertWompiEvent(payload: WompiWebhook, rawBody: string, headers: Record, environment: Ambiente): Promise<{ record: WompiEventRecord; inserted: boolean }> { + async insertWompiEvent(payload: WompiWebhook, rawBody: string, headers: Record, environment: Ambiente): Promise { return insertWompiEventRepository(this.db, this, payload, rawBody, headers, environment); } diff --git a/src/worker/storage/repository/wompiIssuance.ts b/src/worker/storage/repository/wompiIssuance.ts index c1bb99d5..0aa20c36 100644 --- a/src/worker/storage/repository/wompiIssuance.ts +++ b/src/worker/storage/repository/wompiIssuance.ts @@ -6,7 +6,13 @@ import type { WompiIssuanceRetrySnapshot, WompiWebhook } from "../../types"; -import { amountCents, donorName, isApprovedDonation } from "../../domain/wompi"; +import { + ambienteFromWompi, + amountCents, + donorName, + isApprovedDonation, + normalizeWompiWebhook +} from "../../domain/wompi"; import { normalizeAuditIp, serializeAuditContext, @@ -26,6 +32,28 @@ type WompiHost = Pick< "getWompiEventById" | "getWompiEventByTransaction" | "getWompiEventByPaymentLinkId" >; +export type WompiEventConflictField = + | "identity" + | "environment" + | "result" + | "amount" + | "payment_link" + | "commerce_intent" + | "normalized_body"; + +export type WompiEventInsertResult = + | { + kind: "inserted" | "equivalent_replay"; + record: WompiEventRecord; + canonicalPayload: WompiWebhook; + } + | { + kind: "conflict"; + record: WompiEventRecord; + reason: "identity_lookup_conflict" | "canonical_mismatch"; + fields: WompiEventConflictField[]; + }; + const WOMPI_ISSUANCE_CLAIM_STALE_MS = 15 * 60 * 1000; const ISSUANCE_RETRIES_EXHAUSTED_CODE = "ISSUANCE_RETRIES_EXHAUSTED"; const ISSUANCE_RETRIES_EXHAUSTED_MESSAGE = @@ -46,13 +74,8 @@ export async function insertWompiEvent( rawBody: string, headers: Record, environment: Ambiente -): Promise<{ record: WompiEventRecord; inserted: boolean }> { +): Promise { const paymentLinkId = dynamicApprovedPaymentLinkId(payload); - const existing = await host.getWompiEventByTransaction(payload.IdTransaccion) - ?? (paymentLinkId === null ? null : await host.getWompiEventByPaymentLinkId(paymentLinkId)); - if (existing) { - return { record: existing, inserted: false }; - } const id = newId("wompi"); const result = await db .prepare( @@ -75,14 +98,37 @@ export async function insertWompiEvent( ) .run(); const inserted = Number(result.meta?.changes ?? 0) === 1; - const record = inserted - ? await host.getWompiEventById(id) - : await host.getWompiEventByTransaction(payload.IdTransaccion) - ?? (paymentLinkId === null ? null : await host.getWompiEventByPaymentLinkId(paymentLinkId)); + if (inserted) { + const record = await host.getWompiEventById(id); + if (!record) { + throw new Error("No se pudo leer el evento Wompi creado"); + } + const canonicalPayload = storedCanonicalPayload(record); + if (!canonicalPayload) { + throw new Error("No se pudo reconstruir el evento Wompi creado"); + } + return { kind: "inserted", record, canonicalPayload }; + } + + // A uniqueness race can happen after any pre-read, so an ignored insert is + // always resolved by fresh reads of both provider identifiers. + const transactionRecord = await host.getWompiEventByTransaction(payload.IdTransaccion); + const paymentLinkRecord = paymentLinkId === null + ? null + : await host.getWompiEventByPaymentLinkId(paymentLinkId); + if (transactionRecord && paymentLinkRecord && transactionRecord.id !== paymentLinkRecord.id) { + return { + kind: "conflict", + record: transactionRecord, + reason: "identity_lookup_conflict", + fields: ["identity"] + }; + } + const record = transactionRecord ?? paymentLinkRecord; if (!record) { throw new Error("No se pudo leer el evento Wompi creado o deduplicado"); } - return { record, inserted }; + return compareWompiReplay(record, payload, environment); } export async function getWompiEventById( @@ -119,6 +165,138 @@ function dynamicApprovedPaymentLinkId(payload: WompiWebhook): number | null { : null; } +function compareWompiReplay( + record: WompiEventRecord, + incoming: WompiWebhook, + incomingEnvironment: Ambiente +): WompiEventInsertResult { + const stored = storedCanonicalPayload(record); + if (!stored) { + return canonicalConflict(record, ["normalized_body"]); + } + + const fields: WompiEventConflictField[] = []; + addConflict( + fields, + "environment", + record.environment !== ambienteFromWompi(stored) + || record.environment !== incomingEnvironment + ); + addConflict( + fields, + "result", + record.result !== stored.ResultadoTransaccion + || record.result !== incoming.ResultadoTransaccion + ); + addConflict( + fields, + "amount", + record.amount_cents !== amountCents(stored) + || record.amount_cents !== amountCents(incoming) + ); + + const storedPaymentLink = paymentLinkIdentifier(stored); + const incomingPaymentLink = paymentLinkIdentifier(incoming); + addConflict( + fields, + "payment_link", + record.payment_link_id !== dynamicApprovedPaymentLinkId(stored) + || storedPaymentLink !== incomingPaymentLink + ); + + const storedIntent = commerceIntentIdentifier(stored); + const incomingIntent = commerceIntentIdentifier(incoming); + addConflict(fields, "commerce_intent", storedIntent !== incomingIntent); + + const sameTransaction = record.transaction_id === incoming.IdTransaccion; + const canonicalStored = canonicalNormalizedPayload(stored, !sameTransaction); + const canonicalIncoming = canonicalNormalizedPayload(incoming, !sameTransaction); + const alternateTransactionAllowed = sameTransaction || ( + record.transaction_id === stored.IdTransaccion + && dynamicApprovedPaymentLinkId(stored) !== null + && dynamicApprovedPaymentLinkId(stored) === dynamicApprovedPaymentLinkId(incoming) + && storedIntent === incomingIntent + ); + addConflict( + fields, + "normalized_body", + canonicalStored !== canonicalIncoming || !alternateTransactionAllowed + ); + + return fields.length === 0 + ? { kind: "equivalent_replay", record, canonicalPayload: stored } + : canonicalConflict(record, fields); +} + +function canonicalConflict( + record: WompiEventRecord, + fields: WompiEventConflictField[] +): Extract { + return { + kind: "conflict", + record, + reason: "canonical_mismatch", + fields + }; +} + +function addConflict( + fields: WompiEventConflictField[], + field: WompiEventConflictField, + conflicting: boolean +): void { + if (conflicting && !fields.includes(field)) { + fields.push(field); + } +} + +function storedCanonicalPayload(record: WompiEventRecord): WompiWebhook | null { + try { + return normalizeWompiWebhook(JSON.parse(record.raw_body)); + } catch { + return null; + } +} + +function paymentLinkIdentifier(payload: WompiWebhook): number | null { + const linkId = payload.EnlacePago?.Id; + return Number.isInteger(linkId) && Number(linkId) > 0 ? Number(linkId) : null; +} + +function commerceIntentIdentifier(payload: WompiWebhook): string | null { + return payload.EnlacePago?.IdentificadorEnlaceComercio?.trim() || null; +} + +function canonicalNormalizedPayload( + payload: WompiWebhook, + excludeTransactionId: boolean +): string { + if (!excludeTransactionId) { + return stableJson(payload); + } + const { IdTransaccion: _excludedTransactionId, ...withoutTransactionId } = payload; + return stableJson(withoutTransactionId); +} + +function stableJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortJson); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, member]) => member !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, member]) => [key, sortJson(member)]) + ); + } + return value; +} + export async function claimWompiEventIssuance( db: D1Database, id: string, diff --git a/test/worker/support/inMemoryD1.ts b/test/worker/support/inMemoryD1.ts index 81aad0d6..0c31a6c1 100644 --- a/test/worker/support/inMemoryD1.ts +++ b/test/worker/support/inMemoryD1.ts @@ -247,6 +247,7 @@ export class InMemoryD1 { beforeDocumentSignedUpdate: (() => void | Promise) | null = null; beforeWompiIssuanceClaim: (() => void | Promise) | null = null; beforeWompiIssuanceRetryClaim: (() => void | Promise) | null = null; + beforeWompiEventInsert: (() => void | Promise) | null = null; beforePostAcceptFinalizationClaim: (() => void | Promise) | null = null; beforePostAcceptEmailDispatchMark: (() => void | Promise) | null = null; beforeAuditCount: ((action: string, entityId: string) => Promise) | null = null; @@ -3790,6 +3791,9 @@ export class Statement { } } if (this.sql.includes("INSERT") && this.sql.includes("INTO wompi_events")) { + const beforeInsert = this.db.beforeWompiEventInsert; + this.db.beforeWompiEventInsert = null; + await beforeInsert?.(); const [ id, transactionId, diff --git a/test/worker/workerFetch.advanced-cde-webhook.test.ts b/test/worker/workerFetch.advanced-cde-webhook.test.ts index 5f173f59..be871253 100644 --- a/test/worker/workerFetch.advanced-cde-webhook.test.ts +++ b/test/worker/workerFetch.advanced-cde-webhook.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import worker from "../../src/worker/index"; import { EnvironmentNotAllowedError } from "../../src/worker/services/environmentPolicy"; import { IssuancePipeline } from "../../src/worker/services/pipeline"; +import type { WompiWebhook } from "../../src/worker/types"; import { TEST_RESEND_REQUEST_ID } from "./support/documentDeliveryFixtures"; import { advancedCdeDraft, @@ -14,6 +15,127 @@ import { signWompiBody } from "./support/workerFetchHelpers"; installWorkerFetchGlobals(); +const WOMPI_WEBHOOK_SECRET = "wompi-secret"; + +function collisionWebhook(overrides: Partial = {}): WompiWebhook { + const base: WompiWebhook = { + IdCuenta: "acct_1", + FechaTransaccion: "2026-06-27T10:00:00-06:00", + Monto: "25.00", + IdTransaccion: "collision-transaction", + ResultadoTransaccion: "ExitosaAprobada", + CodigoAutorizacion: "000001", + IdIntentoPago: null, + Cantidad: 1, + EsProductiva: false, + EnlacePago: { + Id: 555, + IdentificadorEnlaceComercio: "di_collision" + }, + Cliente: { + Nombre: "Example", + Apellidos: "Person", + Direccion: "canonical-address", + EMail: "donor@example.org", + Celular: "70000005" + } + }; + return { + ...base, + ...overrides, + EnlacePago: { ...base.EnlacePago, ...overrides.EnlacePago }, + Cliente: { ...base.Cliente, ...overrides.Cliente } + }; +} + +function seedCanonicalWompiEvent( + db: InMemoryD1, + payload: WompiWebhook, + id = "wompi_collision_canonical" +): Record { + const firstName = payload.Cliente?.Nombre?.trim() ?? ""; + const lastName = payload.Cliente?.Apellidos?.trim() ?? ""; + const row: Record = { + id, + transaction_id: payload.IdTransaccion, + payment_link_id: + payload.ResultadoTransaccion === "ExitosaAprobada" + && (payload.EnlacePago?.IdentificadorEnlaceComercio?.trim() ?? "").startsWith("di_") + ? payload.EnlacePago?.Id ?? null + : null, + environment: payload.EsProductiva ? "01" : "00", + result: payload.ResultadoTransaccion, + amount_cents: Math.round(Number(payload.Monto) * 100), + donor_email: payload.Cliente?.EMail ?? null, + donor_name: `${firstName} ${lastName}`.trim() || "Donante", + raw_body: JSON.stringify(payload), + headers_json: "{}", + received_at: "2026-06-26T01:46:47.015Z", + processed_at: null, + created_document_id: null, + issuance_claim_id: null, + issuance_claimed_at: null, + issuance_status: null, + control_prefix: null, + control_sequence: null, + reserved_numero_control: null, + reserved_codigo_generacion: null, + issuance_attempt_count: 0, + issuance_attempt_id: null, + issuance_error_code: null, + issuance_error_message: null, + issuance_last_attempt_at: null, + stalled_requeue_epoch_at: null, + issuance_failed_at: null, + issuance_dead_lettered_at: null + }; + db.wompiEvents.push(row); + return row; +} + +function seedCollisionIntent(db: InMemoryD1): void { + db.donationIntents.push({ + id: "di_collision", + status: "LINK_CREATED", + amount_cents: 2500, + donor_document: "10000001-9", + wompi_id_enlace: 555, + donor_phone: null, + direccion_complemento: null, + paid_at: null + }); +} + +async function postSignedWompi( + db: InMemoryD1, + payload: WompiWebhook, + send: ReturnType +): Promise { + return postRawSignedWompi(db, JSON.stringify(payload), send); +} + +async function postRawSignedWompi( + db: InMemoryD1, + rawBody: string, + send: ReturnType +): Promise { + return worker.fetch( + new Request("https://example.org/webhooks/wompi", { + method: "POST", + headers: { + "Content-Type": "application/json", + wompi_hash: await signWompiBody(rawBody, WOMPI_WEBHOOK_SECRET) + }, + body: rawBody + }), + env(db, { + APP_ENV: "staging", + WOMPI_API_SECRET: WOMPI_WEBHOOK_SECRET, + ISSUANCE_QUEUE: { send } as unknown as Queue + }) + ); +} + describe("advanced CDE generation", () => { it.each(["/api/test/dte/advanced-template", "/api/test/dte/advanced"])( "restricts caller-controlled CDE generation to owners at %s", @@ -489,6 +611,168 @@ describe("advanced CDE generation", () => { }); describe("Wompi webhook integration", () => { + it.each([ + { + name: "environment", + stored: collisionWebhook({ EsProductiva: true }), + incoming: collisionWebhook(), + fields: ["environment", "normalized_body"] + }, + { + name: "result", + stored: collisionWebhook({ ResultadoTransaccion: "Denegada" }), + incoming: collisionWebhook(), + fields: ["result", "normalized_body"] + }, + { + name: "amount", + stored: collisionWebhook({ Monto: "20.00" }), + incoming: collisionWebhook(), + fields: ["amount", "normalized_body"] + }, + { + name: "payment link", + stored: collisionWebhook({ EnlacePago: { Id: 556 } }), + incoming: collisionWebhook(), + fields: ["payment_link", "normalized_body"] + }, + { + name: "commerce intent", + stored: collisionWebhook({ + EnlacePago: { IdentificadorEnlaceComercio: "di_other_intent" } + }), + incoming: collisionWebhook(), + fields: ["commerce_intent", "normalized_body"] + }, + { + name: "normalized body", + stored: collisionWebhook(), + incoming: collisionWebhook({ + Cliente: { Direccion: "incoming-private-collision-value" } + }), + fields: ["normalized_body"] + } + ])("rejects a same-transaction $name collision without trusting incoming values", async ({ + stored, + incoming, + fields + }) => { + const db = new InMemoryD1(); + seedCollisionIntent(db); + seedCanonicalWompiEvent(db, stored); + const canonicalBefore = structuredClone(db.wompiEvents); + const send = vi.fn(); + + const response = await postSignedWompi(db, incoming, send); + const responseBody = await response.json(); + + expect(response.status).toBe(409); + expect(responseBody).toEqual({ error: "wompi_event_conflict" }); + expect(send).not.toHaveBeenCalled(); + expect(db.donationIntents[0].paid_at).toBeNull(); + expect(db.wompiEvents).toEqual(canonicalBefore); + const audit = db.audits.find((row) => row.action === "WOMPI_EVENT_CONFLICT"); + expect(audit).toMatchObject({ + entity_type: "wompi_event", + entity_id: "wompi_collision_canonical", + summary: "Evento Wompi rechazado por conflicto con el registro canónico" + }); + expect(JSON.parse(String(audit!.metadata_json))).toEqual({ + reason: "canonical_mismatch", + fields + }); + const boundedOutput = JSON.stringify({ responseBody, audit }); + expect(boundedOutput).not.toContain(incoming.IdTransaccion); + expect(boundedOutput).not.toContain("incoming-private-collision-value"); + }); + + it("rejects when transaction and payment-link lookups identify different canonical rows", async () => { + const db = new InMemoryD1(); + seedCollisionIntent(db); + seedCanonicalWompiEvent( + db, + collisionWebhook({ EnlacePago: { Id: 556 } }), + "wompi_by_transaction" + ); + seedCanonicalWompiEvent( + db, + collisionWebhook({ IdTransaccion: "other-transaction" }), + "wompi_by_payment_link" + ); + const canonicalBefore = structuredClone(db.wompiEvents); + const send = vi.fn(); + + const response = await postSignedWompi(db, collisionWebhook(), send); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ error: "wompi_event_conflict" }); + expect(send).not.toHaveBeenCalled(); + expect(db.donationIntents[0].paid_at).toBeNull(); + expect(db.wompiEvents).toEqual(canonicalBefore); + const audit = db.audits.find((row) => row.action === "WOMPI_EVENT_CONFLICT"); + expect(audit).toMatchObject({ + entity_type: "wompi_event", + entity_id: "wompi_by_transaction", + summary: "Evento Wompi rechazado por conflicto con el registro canónico" + }); + expect(JSON.parse(String(audit!.metadata_json))).toEqual({ + reason: "identity_lookup_conflict", + fields: ["identity"] + }); + }); + + it("re-reads and compares a conflicting event inserted during the uniqueness race", async () => { + const db = new InMemoryD1(); + seedCollisionIntent(db); + const stored = collisionWebhook({ + Cliente: { Direccion: "canonical-race-address" } + }); + db.beforeWompiEventInsert = () => { + seedCanonicalWompiEvent(db, stored, "wompi_concurrent_winner"); + }; + const send = vi.fn(); + + const response = await postSignedWompi(db, collisionWebhook(), send); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ error: "wompi_event_conflict" }); + expect(send).not.toHaveBeenCalled(); + expect(db.donationIntents[0].paid_at).toBeNull(); + expect(db.wompiEvents).toHaveLength(1); + expect(db.wompiEvents[0].raw_body).toBe(JSON.stringify(stored)); + expect(db.audits.find((row) => row.action === "WOMPI_EVENT_CONFLICT")).toBeDefined(); + }); + + it("rejects an alternate transaction identifier when any other normalized body value changes", async () => { + const db = new InMemoryD1(); + seedCollisionIntent(db); + const stored = collisionWebhook({ IdTransaccion: "payment-link-display-id" }); + seedCanonicalWompiEvent(db, stored); + const canonicalBefore = structuredClone(db.wompiEvents); + const send = vi.fn(); + + const response = await postSignedWompi( + db, + collisionWebhook({ + IdTransaccion: "delayed-webhook-uuid", + Cliente: { Direccion: "alternate-transaction-private-collision" } + }), + send + ); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ error: "wompi_event_conflict" }); + expect(send).not.toHaveBeenCalled(); + expect(db.donationIntents[0].paid_at).toBeNull(); + expect(db.wompiEvents).toEqual(canonicalBefore); + const audit = db.audits.find((row) => row.action === "WOMPI_EVENT_CONFLICT"); + expect(JSON.parse(String(audit!.metadata_json))).toEqual({ + reason: "canonical_mismatch", + fields: ["normalized_body"] + }); + expect(JSON.stringify(audit)).not.toContain("alternate-transaction-private-collision"); + }); + it("accepts a signed official Wompi webhook and queues approved payments", async () => { const db = new InMemoryD1(); const queued: unknown[] = []; @@ -546,6 +830,53 @@ describe("Wompi webhook integration", () => { }]); }); + it("accepts an exact replay whose JSON aliases and member order normalize identically", async () => { + const db = new InMemoryD1(); + seedCollisionIntent(db); + const send = vi.fn(); + const canonicalPayload = collisionWebhook(); + const canonicalRawBody = JSON.stringify(canonicalPayload); + + const first = await postSignedWompi(db, canonicalPayload, send); + const replayRawBody = JSON.stringify({ + cliente: { + celular: "70000005", + email: "donor@example.org", + direccion: "canonical-address", + apellidos: "Person", + nombre: "Example" + }, + enlacePago: { + identificadorEnlaceComercio: "di_collision", + id: 555 + }, + esProductiva: false, + cantidad: 1, + idIntentoPago: null, + codigoAutorizacion: "000001", + resultadoTransaccion: "ExitosaAprobada", + idTransaccion: "collision-transaction", + monto: "25.00", + fechaTransaccion: "2026-06-27T10:00:00-06:00", + idCuenta: "acct_1" + }); + const replay = await postRawSignedWompi(db, replayRawBody, send); + + expect(first.status).toBe(202); + expect(replay.status).toBe(200); + await expect(replay.json()).resolves.toMatchObject({ + ok: true, + inserted: false, + queued: false + }); + expect(send).toHaveBeenCalledTimes(1); + expect(db.donationIntents[0].paid_at).not.toBeNull(); + expect(db.wompiEvents).toHaveLength(1); + expect(db.wompiEvents[0].raw_body).toBe(canonicalRawBody); + expect(db.audits.find((row) => row.action === "WOMPI_DUPLICATE")?.summary) + .toBe("collision-transaction ExitosaAprobada"); + }); + it("deduplicates one approved payment link even when Wompi uses a different transaction id later", async () => { const db = new InMemoryD1(); db.donationIntents.push({ @@ -608,6 +939,8 @@ describe("Wompi webhook integration", () => { payment_link_id: 555 }); expect(queued).toHaveLength(1); + const duplicate = db.audits.find((row) => row.action === "WOMPI_DUPLICATE"); + expect(duplicate?.summary).toBe("display-id-from-payment-link-api ExitosaAprobada"); }); it("stores but quarantines a signed webhook whose ambiente is incompatible with the deployment", async () => { From 26305fa112d501a39fc55b22a9f12c47593a57c4 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:06:01 -0600 Subject: [PATCH 12/22] fix(worker): compare Wompi replay bodies losslessly --- src/worker/index.ts | 2 +- .../storage/repository/wompiIssuance.ts | 172 +++++++++++++++--- .../workerFetch.advanced-cde-webhook.test.ts | 125 ++++++++++++- 3 files changed, 272 insertions(+), 27 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index a945fcc9..4db64ed8 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1089,7 +1089,7 @@ async function ingestTrustedWompiPayload( } const { record, canonicalPayload } = insertion; const inserted = insertion.kind === "inserted"; - const environment = record.environment; + const environment = ambienteFromWompi(canonicalPayload); const policy = deploymentEnvironmentPolicy(env); const environmentAllowed = policy.allowedAmbiente === environment; const action = inserted ? source.insertedAction : source.duplicateAction; diff --git a/src/worker/storage/repository/wompiIssuance.ts b/src/worker/storage/repository/wompiIssuance.ts index 0aa20c36..fe4e0729 100644 --- a/src/worker/storage/repository/wompiIssuance.ts +++ b/src/worker/storage/repository/wompiIssuance.ts @@ -32,6 +32,68 @@ type WompiHost = Pick< "getWompiEventById" | "getWompiEventByTransaction" | "getWompiEventByPaymentLinkId" >; +interface WompiRawAliasSchema { + groups: ReadonlyArray; + nested?: Readonly>; +} + +const WOMPI_APP_RAW_ALIASES: WompiRawAliasSchema = { + groups: [ + ["Nombre", "nombre"], + ["Url", "URL", "url"], + ["Id", "id"] + ] +}; + +const WOMPI_LINK_RAW_ALIASES: WompiRawAliasSchema = { + groups: [ + ["Id", "id"], + ["IdentificadorEnlaceComercio", "identificadorEnlaceComercio"], + ["NombreProducto", "nombreProducto"], + ["DescripcionProducto", "descripcionProducto"] + ] +}; + +const WOMPI_CLIENT_RAW_ALIASES: WompiRawAliasSchema = { + groups: [ + ["DocumentoIdentidad", "documentoIdentidad"], + ["Nombre", "nombre"], + ["Apellidos", "apellidos"], + ["Direccion", "direccion"], + ["EMail", "Email", "email", "eMail", "Correo", "correo"], + ["Celular", "celular", "Telefono", "telefono"], + ["NombreRegion", "nombreRegion"], + ["NombrePais", "nombrePais"], + ["CodigoPais", "codigoPais"], + ["CodigoRegion", "codigoRegion"] + ] +}; + +const WOMPI_WEBHOOK_RAW_ALIASES: WompiRawAliasSchema = { + groups: [ + ["IdCuenta", "idCuenta"], + ["FechaTransaccion", "fechaTransaccion"], + ["Monto", "monto"], + ["IdTransaccion", "idTransaccion"], + ["ResultadoTransaccion", "resultadoTransaccion"], + ["CodigoAutorizacion", "codigoAutorizacion"], + ["IdIntentoPago", "idIntentoPago"], + ["Cantidad", "cantidad"], + ["EsProductiva", "esProductiva"], + ["Aplicativo", "aplicativo"], + ["EnlacePago", "enlacePago"], + ["Cliente", "cliente"], + ["Tarjeta", "tarjeta"], + ["EsInternacional", "esInternacional"], + ["IdExterno", "idExterno"] + ], + nested: { + Aplicativo: WOMPI_APP_RAW_ALIASES, + EnlacePago: WOMPI_LINK_RAW_ALIASES, + Cliente: WOMPI_CLIENT_RAW_ALIASES + } +}; + export type WompiEventConflictField = | "identity" | "environment" @@ -128,7 +190,7 @@ export async function insertWompiEvent( if (!record) { throw new Error("No se pudo leer el evento Wompi creado o deduplicado"); } - return compareWompiReplay(record, payload, environment); + return compareWompiReplay(record, payload, rawBody, environment); } export async function getWompiEventById( @@ -168,6 +230,7 @@ function dynamicApprovedPaymentLinkId(payload: WompiWebhook): number | null { function compareWompiReplay( record: WompiEventRecord, incoming: WompiWebhook, + incomingRawBody: string, incomingEnvironment: Ambiente ): WompiEventInsertResult { const stored = storedCanonicalPayload(record); @@ -209,8 +272,8 @@ function compareWompiReplay( addConflict(fields, "commerce_intent", storedIntent !== incomingIntent); const sameTransaction = record.transaction_id === incoming.IdTransaccion; - const canonicalStored = canonicalNormalizedPayload(stored, !sameTransaction); - const canonicalIncoming = canonicalNormalizedPayload(incoming, !sameTransaction); + const canonicalStored = canonicalWompiRawBody(record.raw_body, !sameTransaction); + const canonicalIncoming = canonicalWompiRawBody(incomingRawBody, !sameTransaction); const alternateTransactionAllowed = sameTransaction || ( record.transaction_id === stored.IdTransaccion && dynamicApprovedPaymentLinkId(stored) !== null @@ -220,7 +283,10 @@ function compareWompiReplay( addConflict( fields, "normalized_body", - canonicalStored !== canonicalIncoming || !alternateTransactionAllowed + canonicalStored === null + || canonicalIncoming === null + || canonicalStored !== canonicalIncoming + || !alternateTransactionAllowed ); return fields.length === 0 @@ -267,36 +333,98 @@ function commerceIntentIdentifier(payload: WompiWebhook): string | null { return payload.EnlacePago?.IdentificadorEnlaceComercio?.trim() || null; } -function canonicalNormalizedPayload( - payload: WompiWebhook, +function canonicalWompiRawBody( + rawBody: string, excludeTransactionId: boolean -): string { - if (!excludeTransactionId) { - return stableJson(payload); +): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(rawBody); + } catch { + return null; + } + if (!isJsonObject(parsed)) { + return null; } - const { IdTransaccion: _excludedTransactionId, ...withoutTransactionId } = payload; - return stableJson(withoutTransactionId); + return JSON.stringify( + canonicalizeAliasedObject( + parsed, + WOMPI_WEBHOOK_RAW_ALIASES, + excludeTransactionId ? "IdTransaccion" : null + ) + ); } -function stableJson(value: unknown): string { - return JSON.stringify(sortJson(value)); +function canonicalizeAliasedObject( + record: Record, + schema: WompiRawAliasSchema, + omittedCanonicalKey: string | null = null +): Record { + const consumed = new Set(); + const entries: Array<[string, unknown]> = []; + + for (const [canonicalKey, ...aliases] of schema.groups) { + const group = [canonicalKey, ...aliases]; + const present = group.filter((key) => + Object.prototype.hasOwnProperty.call(record, key) + ); + if (present.length === 1) { + const sourceKey = present[0]; + consumed.add(sourceKey); + if (canonicalKey !== omittedCanonicalKey) { + entries.push([ + canonicalKey, + canonicalizeRawMember(record[sourceKey], schema.nested?.[canonicalKey]) + ]); + } + continue; + } + for (const sourceKey of present) { + consumed.add(sourceKey); + // Coexisting documented aliases are distinct evidence. For alternate + // transactions, omit only the canonical member and preserve every alias. + if (sourceKey !== omittedCanonicalKey) { + entries.push([ + sourceKey, + canonicalizeRawMember(record[sourceKey], schema.nested?.[canonicalKey]) + ]); + } + } + } + + for (const [key, member] of Object.entries(record)) { + if (!consumed.has(key) && key !== omittedCanonicalKey) { + entries.push([key, canonicalizeRawMember(member)]); + } + } + + entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + return Object.fromEntries(entries); } -function sortJson(value: unknown): unknown { +function canonicalizeRawMember( + value: unknown, + nestedSchema?: WompiRawAliasSchema +): unknown { if (Array.isArray(value)) { - return value.map(sortJson); + return value.map((member) => canonicalizeRawMember(member)); } - if (value !== null && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record) - .filter(([, member]) => member !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, member]) => [key, sortJson(member)]) - ); + if (isJsonObject(value)) { + if (nestedSchema) { + return canonicalizeAliasedObject(value, nestedSchema); + } + const entries = Object.entries(value) + .map(([key, member]) => [key, canonicalizeRawMember(member)] as [string, unknown]) + .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + return Object.fromEntries(entries); } return value; } +function isJsonObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + export async function claimWompiEventIssuance( db: D1Database, id: string, diff --git a/test/worker/workerFetch.advanced-cde-webhook.test.ts b/test/worker/workerFetch.advanced-cde-webhook.test.ts index be871253..83c5c4c7 100644 --- a/test/worker/workerFetch.advanced-cde-webhook.test.ts +++ b/test/worker/workerFetch.advanced-cde-webhook.test.ts @@ -51,7 +51,8 @@ function collisionWebhook(overrides: Partial = {}): WompiWebhook { function seedCanonicalWompiEvent( db: InMemoryD1, payload: WompiWebhook, - id = "wompi_collision_canonical" + id = "wompi_collision_canonical", + rawBody = JSON.stringify(payload) ): Record { const firstName = payload.Cliente?.Nombre?.trim() ?? ""; const lastName = payload.Cliente?.Apellidos?.trim() ?? ""; @@ -68,7 +69,7 @@ function seedCanonicalWompiEvent( amount_cents: Math.round(Number(payload.Monto) * 100), donor_email: payload.Cliente?.EMail ?? null, donor_name: `${firstName} ${lastName}`.trim() || "Donante", - raw_body: JSON.stringify(payload), + raw_body: rawBody, headers_json: "{}", received_at: "2026-06-26T01:46:47.015Z", processed_at: null, @@ -611,6 +612,114 @@ describe("advanced CDE generation", () => { }); describe("Wompi webhook integration", () => { + const losslessBodyEdges: Array<{ + name: string; + marker: string; + mutateStored?: (raw: Record) => void; + mutateIncoming: (raw: Record) => void; + }> = [ + { + name: "an unknown nested extra value", + marker: "unknown-edge-private-marker", + mutateIncoming: (raw) => { + raw.ProviderEvidence = { + nested: { decision: "unknown-edge-private-marker" }, + steps: [1, { approved: true }] + }; + } + }, + { + name: "explicit null instead of a missing member", + marker: "IdExterno", + mutateIncoming: (raw) => { + raw.IdExterno = null; + } + }, + { + name: "a duplicate documented alias", + marker: "999.00", + mutateIncoming: (raw) => { + raw.monto = "999.00"; + } + }, + { + name: "a numeric string instead of a number", + marker: "Cantidad", + mutateIncoming: (raw) => { + raw.Cantidad = "1"; + } + }, + { + name: "a different nested array order", + marker: "array-order-private-marker", + mutateStored: (raw) => { + raw.ProviderEvidence = { + history: [1, 2, { note: "array-order-private-marker" }] + }; + }, + mutateIncoming: (raw) => { + raw.ProviderEvidence = { + history: [2, 1, { note: "array-order-private-marker" }] + }; + } + } + ]; + + it.each( + (["same-ID", "alternate-ID"] as const).flatMap((replayKind) => + losslessBodyEdges.map((edge) => ({ replayKind, ...edge })) + ) + )("rejects a $replayKind replay with $name", async ({ + replayKind, + marker, + mutateStored, + mutateIncoming + }) => { + const db = new InMemoryD1(); + seedCollisionIntent(db); + const alternate = replayKind === "alternate-ID"; + const storedPayload = collisionWebhook({ + IdTransaccion: alternate ? "lossless-stored-transaction" : "collision-transaction" + }); + const storedRaw = structuredClone(storedPayload) as unknown as Record; + const incomingRaw = structuredClone(storedPayload) as unknown as Record; + mutateStored?.(storedRaw); + mutateIncoming(incomingRaw); + if (alternate) { + incomingRaw.IdTransaccion = "lossless-alternate-transaction"; + } + seedCanonicalWompiEvent( + db, + storedPayload, + "wompi_lossless_canonical", + JSON.stringify(storedRaw) + ); + const canonicalBefore = structuredClone(db.wompiEvents); + const send = vi.fn(); + + const response = await postRawSignedWompi(db, JSON.stringify(incomingRaw), send); + const responseBody = await response.json(); + + expect(response.status).toBe(409); + expect(responseBody).toEqual({ error: "wompi_event_conflict" }); + expect(send).not.toHaveBeenCalled(); + expect(db.donationIntents[0].paid_at).toBeNull(); + expect(db.wompiEvents).toEqual(canonicalBefore); + const audit = db.audits.find((row) => row.action === "WOMPI_EVENT_CONFLICT"); + expect(audit).toMatchObject({ + entity_type: "wompi_event", + entity_id: "wompi_lossless_canonical", + summary: "Evento Wompi rechazado por conflicto con el registro canónico" + }); + expect(JSON.parse(String(audit!.metadata_json))).toEqual({ + reason: "canonical_mismatch", + fields: ["normalized_body"] + }); + const boundedOutput = JSON.stringify({ responseBody, audit }); + expect(boundedOutput).not.toContain(marker); + expect(boundedOutput).not.toContain(String(incomingRaw.IdTransaccion)); + }); + it.each([ { name: "environment", @@ -835,10 +944,18 @@ describe("Wompi webhook integration", () => { seedCollisionIntent(db); const send = vi.fn(); const canonicalPayload = collisionWebhook(); - const canonicalRawBody = JSON.stringify(canonicalPayload); + const canonicalRawBody = JSON.stringify({ + ...canonicalPayload, + ProviderEvidence: { + nested: { first: 1, second: 2 } + } + }); - const first = await postSignedWompi(db, canonicalPayload, send); + const first = await postRawSignedWompi(db, canonicalRawBody, send); const replayRawBody = JSON.stringify({ + ProviderEvidence: { + nested: { second: 2, first: 1 } + }, cliente: { celular: "70000005", email: "donor@example.org", From ef9f9218319944287ff1673dfbe618206975f2d8 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:26:01 -0600 Subject: [PATCH 13/22] fix(worker): sanitize MH provider responses --- src/worker/services/mhClient.ts | 93 +++++++++++-- test/worker/mhClient.test.ts | 190 +++++++++++++++++++++++++- test/worker/pipeline.issuance.test.ts | 139 ++++++++++++++++++- 3 files changed, 408 insertions(+), 14 deletions(-) diff --git a/src/worker/services/mhClient.ts b/src/worker/services/mhClient.ts index e4c02911..aba75771 100644 --- a/src/worker/services/mhClient.ts +++ b/src/worker/services/mhClient.ts @@ -5,6 +5,14 @@ import { generationCode } from "../utils/ids"; import { assertDeploymentAllowsAmbiente } from "./environmentPolicy"; const MH_REQUEST_TIMEOUT_MS = 60 * 1000; +const MH_REDACTION = "[REDACTED]"; +const PUBLIC_INDETERMINATE_ESTADOS = new Set([ + "ACEPTADO", + "NO PROCESADO", + "PROCESADO", + "RECIBIDO", + "RECHAZADO" +]); export class MhClient { constructor(private readonly env: Env) {} @@ -27,7 +35,7 @@ export class MhClient { documento: input.signedJws }) }); - return parseMhResponse(response); + return parseMhResponse(response, this.providerRedactions(input.ambiente, token)); } async transmitInvalidacion(input: { ambiente: Ambiente; version: number; signedJws: string }): Promise { @@ -46,7 +54,7 @@ export class MhClient { documento: input.signedJws }) }); - return parseMhResponse(response); + return parseMhResponse(response, this.providerRedactions(input.ambiente, token)); } // Los métodos de contingencia (evento y lotes) se eliminaron: el Anexo de @@ -76,9 +84,7 @@ export class MhClient { } if (!token) { - const code = data.body?.codigoMsg ? ` ${data.body.codigoMsg}` : ""; - const message = data.body?.descripcionMsg ? `: ${data.body.descripcionMsg}` : ""; - throw new Error(`La autenticación con el Ministerio de Hacienda no devolvió body.token${code}${message}`); + throw new Error("La autenticación con el Ministerio de Hacienda no devolvió body.token"); } const expiresAt = new Date(Date.now() + 23 * 60 * 60 * 1000).toISOString(); await this.env.DB.prepare( @@ -112,9 +118,18 @@ export class MhClient { body: form }); if (!response.ok) { - throw new Error(`Falló la autenticación con el Ministerio de Hacienda: ${response.status} ${await response.text()}`); + throw new Error(`Falló la autenticación con el Ministerio de Hacienda (HTTP ${response.status})`); + } + const text = await response.text(); + if (!text) return {}; + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as MhAuthResponse + : {}; + } catch { + return {}; } - return (await response.json()) as MhAuthResponse; } private jsonHeaders(token: string): HeadersInit { @@ -137,6 +152,12 @@ export class MhClient { password: requireSecret(this.env, "MH_PASSWORD_TEST") }; } + + private providerRedactions(ambiente: Ambiente, authorization: string): string[] { + const user = ambiente === "01" ? this.env.MH_USER_PROD : this.env.MH_USER_TEST; + const password = ambiente === "01" ? this.env.MH_PASSWORD_PROD : this.env.MH_PASSWORD_TEST; + return providerRedactions(user, password, authorization); + } } async function fetchMh(url: string, init: RequestInit): Promise { @@ -169,8 +190,8 @@ function isInvalidCredentials(data: MhAuthResponse): boolean { return data.body?.codigoMsg === "106"; } -async function parseMhResponse(response: Response): Promise { - const raw = await safeJson(response); +async function parseMhResponse(response: Response, redactions: string[]): Promise { + const raw = sanitizeProviderValue(await safeJson(response), redactions); const body = raw as Record; const estado = String(body.estado ?? body.status ?? "RECIBIDO"); const rawSeal = typeof body.selloRecibido === "string" ? body.selloRecibido.trim() : ""; @@ -188,7 +209,7 @@ async function parseMhResponse(response: Response): Promise { // and contradictory bodies leave the already-dispatched fiscal outcome unknown. if (!rejected) { throw new MhUnavailableError( - `Ministerio de Hacienda devolvió un resultado no definitivo: ${estado} (HTTP ${response.status})` + `Ministerio de Hacienda devolvió un resultado no definitivo: ${publicIndeterminateEstado(normalizedEstado)} (HTTP ${response.status})` ); } } @@ -197,7 +218,9 @@ async function parseMhResponse(response: Response): Promise { // malformed, contradictory, substring-like (for example NO PROCESADO), // intermediate, and undocumented 2xx bodies leave the external outcome unknown. // A positive verdict without its required seal is equally non-definitive. - throw new MhUnavailableError(`Ministerio de Hacienda devolvió un resultado no definitivo: ${estado}`); + throw new MhUnavailableError( + `Ministerio de Hacienda devolvió un resultado no definitivo: ${publicIndeterminateEstado(normalizedEstado)}` + ); } return { accepted: response.ok && accepted, @@ -208,6 +231,54 @@ async function parseMhResponse(response: Response): Promise { }; } +function providerRedactions( + user: string | undefined, + password: string | undefined, + authorization: string +): string[] { + const values = new Set(); + for (const credential of [user, password]) { + if (!credential) continue; + values.add(credential); + values.add(encodeURIComponent(credential)); + values.add(new URLSearchParams({ value: credential }).toString().slice("value=".length)); + } + if (authorization) values.add(authorization); + return [...values].filter(Boolean).sort((left, right) => right.length - left.length); +} + +function sanitizeProviderValue(value: unknown, redactions: string[]): unknown { + if (typeof value === "string") { + return sanitizeProviderText(value, redactions); + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeProviderValue(entry, redactions)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + sanitizeProviderText(key, redactions), + sanitizeProviderValue(entry, redactions) + ]) + ); + } + return value; +} + +function sanitizeProviderText(value: string, redactions: string[]): string { + let sanitized = value; + for (const secret of redactions) { + sanitized = sanitized.split(secret).join(MH_REDACTION); + } + return sanitized; +} + +function publicIndeterminateEstado(normalizedEstado: string): string { + return PUBLIC_INDETERMINATE_ESTADOS.has(normalizedEstado) + ? normalizedEstado + : "ESTADO_NO_RECONOCIDO"; +} + async function safeJson(response: Response): Promise { const text = await response.text(); if (!text) { diff --git a/test/worker/mhClient.test.ts b/test/worker/mhClient.test.ts index 64562ccf..3678537a 100644 --- a/test/worker/mhClient.test.ts +++ b/test/worker/mhClient.test.ts @@ -3,6 +3,24 @@ import { MhClient, MhPreDispatchError, MhUnavailableError } from "../../src/work import { EnvironmentNotAllowedError } from "../../src/worker/services/environmentPolicy"; import type { Env } from "../../src/worker/types"; +const MH_SECRET_USER = "mh user+canary@example.test"; +const MH_SECRET_USER_PERCENT = "mh%20user%2Bcanary%40example.test"; +const MH_SECRET_USER_FORM = "mh+user%2Bcanary%40example.test"; +const MH_SECRET_PASSWORD = "PW canary+&=/%?"; +const MH_SECRET_PASSWORD_PERCENT = "PW%20canary%2B%26%3D%2F%25%3F"; +const MH_SECRET_PASSWORD_FORM = "PW+canary%2B%26%3D%2F%25%3F"; +const MH_SECRET_TOKEN = `Bearer token:${MH_SECRET_PASSWORD}:mh-token-canary`; + +const MH_SECRET_VARIANTS = [ + MH_SECRET_USER, + MH_SECRET_USER_PERCENT, + MH_SECRET_USER_FORM, + MH_SECRET_PASSWORD, + MH_SECRET_PASSWORD_PERCENT, + MH_SECRET_PASSWORD_FORM, + MH_SECRET_TOKEN +]; + describe("MH client", () => { afterEach(() => { vi.restoreAllMocks(); @@ -93,6 +111,142 @@ describe("MH client", () => { })).rejects.toBeInstanceOf(MhUnavailableError); }); + it("keeps a plain-text authentication rejection and echoed form credentials out of the pre-dispatch error", async () => { + const environment = testEnv(); + environment.MH_USER_TEST = MH_SECRET_USER; + environment.MH_PASSWORD_TEST = MH_SECRET_PASSWORD; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response( + `credential echo ${MH_SECRET_USER} ${MH_SECRET_USER_PERCENT} ${MH_SECRET_USER_FORM} ${MH_SECRET_PASSWORD} ${MH_SECRET_PASSWORD_PERCENT} ${MH_SECRET_PASSWORD_FORM}`, + { status: 401 } + ))); + + const error = await transmitTestDte(new MhClient(environment)).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MhPreDispatchError); + expect((error as Error).message).toBe("Falló la autenticación con el Ministerio de Hacienda (HTTP 401)"); + expect((error as MhPreDispatchError).cause).toBeInstanceOf(Error); + expect(((error as MhPreDispatchError).cause as Error).message).toBe( + "Falló la autenticación con el Ministerio de Hacienda (HTTP 401)" + ); + expectNoMhSecrets(serializeError(error)); + }); + + it("discards provider codes and descriptions when a successful authentication response has no token", async () => { + const environment = testEnv(); + environment.MH_USER_TEST = MH_SECRET_USER; + environment.MH_PASSWORD_TEST = MH_SECRET_PASSWORD; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({ + status: "ERROR", + body: { + codigoMsg: `AUTH-${MH_SECRET_USER}-${MH_SECRET_USER_FORM}`, + descripcionMsg: `Credential ${MH_SECRET_PASSWORD} ${MH_SECRET_PASSWORD_PERCENT} ${MH_SECRET_PASSWORD_FORM}` + } + }))); + + const error = await transmitTestDte(new MhClient(environment)).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MhPreDispatchError); + expect((error as Error).message).toBe( + "La autenticación con el Ministerio de Hacienda no devolvió body.token" + ); + expectNoMhSecrets(serializeError(error)); + }); + + it("uses the same bounded token-missing error for a non-object authentication body", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(null))); + + const error = await transmitTestDte(new MhClient(testEnv())).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MhPreDispatchError); + expect((error as Error).message).toBe( + "La autenticación con el Ministerio de Hacienda no devolvió body.token" + ); + }); + + it("sanitizes credentials and authorization recursively before returning a terminal rejection", async () => { + const environment = testEnv(); + environment.MH_USER_TEST = MH_SECRET_USER; + environment.MH_PASSWORD_TEST = MH_SECRET_PASSWORD; + vi.stubGlobal("fetch", vi + .fn() + .mockResolvedValueOnce(jsonResponse({ + status: "OK", + body: { token: MH_SECRET_TOKEN }, + tokenType: "Bearer" + })) + .mockResolvedValueOnce(jsonResponse({ + estado: "RECHAZADO", + selloRecibido: null, + observaciones: [ + `user=${MH_SECRET_USER}; encoded=${MH_SECRET_USER_PERCENT}`, + `pwd=${MH_SECRET_PASSWORD}; form=${MH_SECRET_PASSWORD_FORM}`, + `authorization=${MH_SECRET_TOKEN}` + ], + descripcionMsg: `nested ${MH_SECRET_PASSWORD_PERCENT}`, + estadoDetalle: `provider state echoed ${MH_SECRET_USER_FORM}`, + selloEcho: `provider seal echoed ${MH_SECRET_TOKEN}`, + text: `provider text echoed ${MH_SECRET_PASSWORD}`, + nested: [{ arrayValue: `prefix-${MH_SECRET_TOKEN}-suffix` }], + [`provider-${MH_SECRET_PASSWORD}-key`]: "nested object key" + }, { status: 400 }))); + + const result = await transmitTestDte(new MhClient(environment)); + + expect(result).toMatchObject({ + accepted: false, + estado: "RECHAZADO", + selloRecibido: null + }); + expect(result.observaciones).toHaveLength(3); + expect(result.observaciones[2]).toBe("authorization=[REDACTED]"); + expectNoMhSecrets(JSON.stringify(result)); + }); + + it("bounds an arbitrary indeterminate estado and sanitizes a plain-text reception response", async () => { + const environment = testEnv(); + environment.MH_USER_TEST = MH_SECRET_USER; + environment.MH_PASSWORD_TEST = MH_SECRET_PASSWORD; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ + status: "OK", + body: { token: MH_SECRET_TOKEN }, + tokenType: "Bearer" + })) + .mockResolvedValueOnce(jsonResponse({ + estado: `PENDIENTE ${MH_SECRET_USER} ${MH_SECRET_PASSWORD_FORM} ${MH_SECRET_TOKEN}`, + observaciones: [`still pending ${MH_SECRET_PASSWORD}`] + })); + vi.stubGlobal("fetch", fetchMock); + + const indeterminate = await transmitTestDte(new MhClient(environment)).catch((caught: unknown) => caught); + + expect(indeterminate).toBeInstanceOf(MhUnavailableError); + expect((indeterminate as Error).message).toBe( + "Ministerio de Hacienda devolvió un resultado no definitivo: ESTADO_NO_RECONOCIDO" + ); + expectNoMhSecrets(serializeError(indeterminate)); + + fetchMock + .mockResolvedValueOnce(jsonResponse({ + status: "OK", + body: { token: MH_SECRET_TOKEN }, + tokenType: "Bearer" + })) + .mockResolvedValueOnce(new Response( + `plain response ${MH_SECRET_USER_FORM} ${MH_SECRET_PASSWORD_PERCENT} ${MH_SECRET_TOKEN}`, + { status: 422 } + )); + + const plainText = await transmitTestDte(new MhClient(environment)).catch((caught: unknown) => caught); + + expect(plainText).toBeInstanceOf(MhUnavailableError); + expect((plainText as Error).message).toBe( + "Ministerio de Hacienda devolvió un resultado no definitivo: RECIBIDO (HTTP 422)" + ); + expectNoMhSecrets(serializeError(plainText)); + }); + it("rejects an incompatible ambiente before mock mode, token lookup, or fetch", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); @@ -136,6 +290,38 @@ function testEnv(): Env { }; } -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: init.status ?? 200, + headers: { "Content-Type": "application/json" } + }); +} + +async function transmitTestDte(client: MhClient) { + return client.transmitDte({ + ambiente: "00", + version: 2, + tipoDte: "15", + codigoGeneracion: "11111111-2222-4333-8444-555555555555", + signedJws: "signed-test-document" + }); +} + +function serializeError(error: unknown): string { + if (!(error instanceof Error)) return JSON.stringify(error); + const cause = "cause" in error ? (error as Error & { cause?: unknown }).cause : undefined; + return JSON.stringify({ + name: error.name, + message: error.message, + stack: error.stack, + cause: cause instanceof Error + ? { name: cause.name, message: cause.message, stack: cause.stack } + : cause + }); +} + +function expectNoMhSecrets(evidence: string): void { + for (const secret of MH_SECRET_VARIANTS) { + expect(evidence).not.toContain(secret); + } } diff --git a/test/worker/pipeline.issuance.test.ts b/test/worker/pipeline.issuance.test.ts index 3d30edaf..9ac896e8 100644 --- a/test/worker/pipeline.issuance.test.ts +++ b/test/worker/pipeline.issuance.test.ts @@ -24,6 +24,24 @@ type SentEmail = { headers?: Record; }; +const PIPELINE_MH_SECRET_USER = "mh user+canary@example.test"; +const PIPELINE_MH_SECRET_USER_PERCENT = "mh%20user%2Bcanary%40example.test"; +const PIPELINE_MH_SECRET_USER_FORM = "mh+user%2Bcanary%40example.test"; +const PIPELINE_MH_SECRET_PASSWORD = "PW canary+&=/%?"; +const PIPELINE_MH_SECRET_PASSWORD_PERCENT = "PW%20canary%2B%26%3D%2F%25%3F"; +const PIPELINE_MH_SECRET_PASSWORD_FORM = "PW+canary%2B%26%3D%2F%25%3F"; +const PIPELINE_MH_SECRET_TOKEN = `Bearer token:${PIPELINE_MH_SECRET_PASSWORD}:mh-token-canary`; + +const PIPELINE_MH_SECRET_VARIANTS = [ + PIPELINE_MH_SECRET_USER, + PIPELINE_MH_SECRET_USER_PERCENT, + PIPELINE_MH_SECRET_USER_FORM, + PIPELINE_MH_SECRET_PASSWORD, + PIPELINE_MH_SECRET_PASSWORD_PERCENT, + PIPELINE_MH_SECRET_PASSWORD_FORM, + PIPELINE_MH_SECRET_TOKEN +]; + const INTENT_ADDRESS = { departamento: "05", municipio: "24", @@ -373,11 +391,124 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { expect(db.audits).not.toContainEqual(expect.objectContaining({ action: "DONATION_INTENT_COMPLETED" })); expect(db.audits).not.toContainEqual(expect.objectContaining({ action: "DTE_ACCEPTED" })); }); + + it("keeps echoed MH credentials out of returned, document, audit, and log rejection evidence", async () => { + const db = new InMemoryD1(); + seedIntent(db); + const eventId = seedEvent(db, unitWebhook()); + const sent: SentEmail[] = []; + const runtime = await pipelineRuntime(db, sent); + runtime.MH_USER_TEST = PIPELINE_MH_SECRET_USER; + runtime.MH_PASSWORD_TEST = PIPELINE_MH_SECRET_PASSWORD; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/seguridad/auth")) { + return jsonResponse({ + status: "OK", + body: { token: PIPELINE_MH_SECRET_TOKEN }, + tokenType: "Bearer" + }); + } + if (url.includes("recepciondte")) { + return jsonResponse({ + estado: "RECHAZADO", + selloRecibido: null, + observaciones: [ + `user=${PIPELINE_MH_SECRET_USER}; encoded=${PIPELINE_MH_SECRET_USER_PERCENT}`, + `pwd=${PIPELINE_MH_SECRET_PASSWORD}; form=${PIPELINE_MH_SECRET_PASSWORD_FORM}`, + `authorization=${PIPELINE_MH_SECRET_TOKEN}` + ], + descripcionMsg: `description ${PIPELINE_MH_SECRET_PASSWORD_PERCENT}`, + estadoDetalle: `state ${PIPELINE_MH_SECRET_USER_FORM}`, + selloEcho: `seal ${PIPELINE_MH_SECRET_TOKEN}`, + text: `text ${PIPELINE_MH_SECRET_PASSWORD}`, + nested: [{ evidence: `prefix-${PIPELINE_MH_SECRET_TOKEN}-suffix` }], + [`provider-${PIPELINE_MH_SECRET_PASSWORD}-key`]: "nested key evidence" + }, { status: 400 }); + } + throw new Error(`Fetch inesperado en prueba unitaria del pipeline: ${url}`); + })); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const record = await new IssuancePipeline(runtime).processWompiEvent(eventId); + const capturedLogs = errorLog.mock.calls; + errorLog.mockRestore(); + + expect(record).toMatchObject({ + status: "REJECTED", + mh_estado: "RECHAZADO", + sello_recibido: null + }); + expect(db.audits).toContainEqual(expect.objectContaining({ + action: "DTE_REJECTED", + summary: "DTE-15-M001P004-000000000000001 RECHAZADO" + })); + expect(JSON.parse(String(record!.mh_observaciones_json))[2]).toBe("authorization=[REDACTED]"); + expectNoPipelineMhSecrets(JSON.stringify({ + returned: record, + documents: db.documents, + rejectionAudits: db.audits.filter((audit) => audit.action === "DTE_REJECTED"), + logs: capturedLogs + })); + }); + + it("retains the fiscal claim and bounds durable evidence for an indeterminate MH estado", async () => { + const db = new InMemoryD1(); + seedIntent(db); + const eventId = seedEvent(db, unitWebhook()); + const sent: SentEmail[] = []; + const runtime = await pipelineRuntime(db, sent); + runtime.MH_USER_TEST = PIPELINE_MH_SECRET_USER; + runtime.MH_PASSWORD_TEST = PIPELINE_MH_SECRET_PASSWORD; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/seguridad/auth")) { + return jsonResponse({ + status: "OK", + body: { token: PIPELINE_MH_SECRET_TOKEN }, + tokenType: "Bearer" + }); + } + if (url.includes("recepciondte")) { + return jsonResponse({ + estado: `PENDIENTE ${PIPELINE_MH_SECRET_USER} ${PIPELINE_MH_SECRET_TOKEN}`, + selloRecibido: null, + observaciones: [`pending ${PIPELINE_MH_SECRET_PASSWORD_FORM}`], + nested: { evidence: `nested ${PIPELINE_MH_SECRET_PASSWORD_PERCENT}` } + }); + } + throw new Error(`Fetch inesperado en prueba unitaria del pipeline: ${url}`); + })); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const error = await new IssuancePipeline(runtime) + .processWompiEvent(eventId) + .catch((caught: unknown) => caught); + const capturedLogs = errorLog.mock.calls; + errorLog.mockRestore(); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe( + "Ministerio de Hacienda devolvió un resultado no definitivo: ESTADO_NO_RECONOCIDO" + ); + expect(db.documents[0]).toMatchObject({ + status: "SIGNED", + fiscal_operation_claim_id: expect.stringMatching(/^fiscal_/), + transmission_deferred_at: null + }); + expect(db.audits).not.toContainEqual(expect.objectContaining({ action: "DTE_REJECTED" })); + expectNoPipelineMhSecrets(JSON.stringify({ + error: { name: (error as Error).name, message: (error as Error).message, stack: (error as Error).stack }, + documents: db.documents, + audits: db.audits, + logs: capturedLogs + })); + }); }); describe("IssuancePipeline deferred transmission", () => { const AUTH_OUTAGE_REASON = - "Falló la autenticación con el Ministerio de Hacienda: 503 MH no disponible"; + "Falló la autenticación con el Ministerio de Hacienda (HTTP 503)"; it("defers to SIGNED + transmission_deferred_at and sends the transitorio receipt", async () => { const db = new InMemoryD1(); @@ -508,6 +639,12 @@ describe("IssuancePipeline deferred transmission", () => { }); }); +function expectNoPipelineMhSecrets(evidence: string): void { + for (const secret of PIPELINE_MH_SECRET_VARIANTS) { + expect(evidence).not.toContain(secret); + } +} + describe("IssuancePipeline receipt email claim behavior", () => { function acceptedAdvancedDocument(): DteDocumentRecord { return testDocument({ wompi_event_id: null, post_accept_finalized_at: null }); From bf27961e81a19dc6896e6701116ea5bdb13cec1e Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:39:41 -0600 Subject: [PATCH 14/22] fix(worker): redact cached MH bearer variants --- src/worker/services/mhClient.ts | 21 +++-- test/worker/mhClient.test.ts | 108 +++++++++++++++++++++++++- test/worker/pipeline.issuance.test.ts | 74 ++++++++++++++---- 3 files changed, 177 insertions(+), 26 deletions(-) diff --git a/src/worker/services/mhClient.ts b/src/worker/services/mhClient.ts index aba75771..f35012c9 100644 --- a/src/worker/services/mhClient.ts +++ b/src/worker/services/mhClient.ts @@ -238,15 +238,26 @@ function providerRedactions( ): string[] { const values = new Set(); for (const credential of [user, password]) { - if (!credential) continue; - values.add(credential); - values.add(encodeURIComponent(credential)); - values.add(new URLSearchParams({ value: credential }).toString().slice("value=".length)); + addProviderRedactionVariants(values, credential); + } + addProviderRedactionVariants(values, authorization); + const bearer = authorization.match(/^(Bearer)[ \t]+(.+)$/i); + const bearerCredential = bearer?.[2]?.trim(); + if (bearer && bearerCredential) { + addProviderRedactionVariants(values, bearerCredential); + values.add(`${bearer[1]}%20${bearerCredential}`); + values.add(`${bearer[1]}+${bearerCredential}`); } - if (authorization) values.add(authorization); return [...values].filter(Boolean).sort((left, right) => right.length - left.length); } +function addProviderRedactionVariants(values: Set, value: string | undefined): void { + if (!value) return; + values.add(value); + values.add(encodeURIComponent(value)); + values.add(new URLSearchParams({ value }).toString().slice("value=".length)); +} + function sanitizeProviderValue(value: unknown, redactions: string[]): unknown { if (typeof value === "string") { return sanitizeProviderText(value, redactions); diff --git a/test/worker/mhClient.test.ts b/test/worker/mhClient.test.ts index 3678537a..f5303c2c 100644 --- a/test/worker/mhClient.test.ts +++ b/test/worker/mhClient.test.ts @@ -9,7 +9,14 @@ const MH_SECRET_USER_FORM = "mh+user%2Bcanary%40example.test"; const MH_SECRET_PASSWORD = "PW canary+&=/%?"; const MH_SECRET_PASSWORD_PERCENT = "PW%20canary%2B%26%3D%2F%25%3F"; const MH_SECRET_PASSWORD_FORM = "PW+canary%2B%26%3D%2F%25%3F"; -const MH_SECRET_TOKEN = `Bearer token:${MH_SECRET_PASSWORD}:mh-token-canary`; +const MH_BEARER_CREDENTIAL = "cache token+credential/%? canary"; +const MH_BEARER_CREDENTIAL_PERCENT = "cache%20token%2Bcredential%2F%25%3F%20canary"; +const MH_BEARER_CREDENTIAL_FORM = "cache+token%2Bcredential%2F%25%3F+canary"; +const MH_SECRET_TOKEN = `bEaReR ${MH_BEARER_CREDENTIAL}`; +const MH_SECRET_TOKEN_PERCENT = "bEaReR%20cache%20token%2Bcredential%2F%25%3F%20canary"; +const MH_SECRET_TOKEN_FORM = "bEaReR+cache+token%2Bcredential%2F%25%3F+canary"; +const MH_SECRET_TOKEN_PERCENT_SEPARATOR = `bEaReR%20${MH_BEARER_CREDENTIAL}`; +const MH_SECRET_TOKEN_FORM_SEPARATOR = `bEaReR+${MH_BEARER_CREDENTIAL}`; const MH_SECRET_VARIANTS = [ MH_SECRET_USER, @@ -18,7 +25,14 @@ const MH_SECRET_VARIANTS = [ MH_SECRET_PASSWORD, MH_SECRET_PASSWORD_PERCENT, MH_SECRET_PASSWORD_FORM, - MH_SECRET_TOKEN + MH_BEARER_CREDENTIAL, + MH_BEARER_CREDENTIAL_PERCENT, + MH_BEARER_CREDENTIAL_FORM, + MH_SECRET_TOKEN, + MH_SECRET_TOKEN_PERCENT, + MH_SECRET_TOKEN_FORM, + MH_SECRET_TOKEN_PERCENT_SEPARATOR, + MH_SECRET_TOKEN_FORM_SEPARATOR ]; describe("MH client", () => { @@ -202,6 +216,57 @@ describe("MH client", () => { expectNoMhSecrets(JSON.stringify(result)); }); + it.each([ + { + ambiente: "00" as const, + appEnv: "staging" as const, + receptionUrl: "https://apitest.dtes.mh.gob.sv/fesv/recepciondte" + }, + { + ambiente: "01" as const, + appEnv: "production" as const, + receptionUrl: "https://api.dtes.mh.gob.sv/fesv/recepciondte" + } + ])("sanitizes cached Bearer credential and authorization variants in lane $ambiente", async ({ + ambiente, + appEnv, + receptionUrl + }) => { + const { environment, cacheStatement } = cachedTokenEnv(ambiente, appEnv); + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + estado: "PROCESADO", + selloRecibido: `SEAL-${MH_BEARER_CREDENTIAL}`, + observaciones: [ + `credential=${MH_BEARER_CREDENTIAL}`, + `credential-percent=${MH_BEARER_CREDENTIAL_PERCENT}`, + `credential-form=${MH_BEARER_CREDENTIAL_FORM}`, + `authorization=${MH_SECRET_TOKEN}`, + `authorization-percent=${MH_SECRET_TOKEN_PERCENT}`, + `authorization-form=${MH_SECRET_TOKEN_FORM}`, + `separator-percent=${MH_SECRET_TOKEN_PERCENT_SEPARATOR}`, + `separator-form=${MH_SECRET_TOKEN_FORM_SEPARATOR}` + ], + text: `plain ${MH_BEARER_CREDENTIAL_FORM}`, + nested: [{ value: `prefix-${MH_SECRET_TOKEN_PERCENT}-suffix` }], + [`provider-${MH_BEARER_CREDENTIAL_PERCENT}-key`]: MH_SECRET_TOKEN_FORM + })); + vi.stubGlobal("fetch", fetchMock); + + const result = await transmitTestDte(new MhClient(environment), ambiente); + + expect(result).toMatchObject({ + accepted: true, + estado: "PROCESADO", + selloRecibido: "SEAL-[REDACTED]" + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe(receptionUrl); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: MH_SECRET_TOKEN }); + expect(cacheStatement.first).toHaveBeenCalledTimes(1); + expect(cacheStatement.run).not.toHaveBeenCalled(); + expectNoMhSecrets(JSON.stringify(result)); + }); + it("bounds an arbitrary indeterminate estado and sanitizes a plain-text reception response", async () => { const environment = testEnv(); environment.MH_USER_TEST = MH_SECRET_USER; @@ -290,6 +355,41 @@ function testEnv(): Env { }; } +function cachedTokenEnv( + ambiente: "00" | "01", + appEnv: "staging" | "production" +): { + environment: Env; + cacheStatement: { + bind: ReturnType; + first: ReturnType; + run: ReturnType; + }; +} { + const cacheStatement = { + bind: vi.fn().mockReturnThis(), + first: vi.fn().mockResolvedValue({ + token: MH_SECRET_TOKEN, + token_type: "Bearer", + expires_at: "2099-01-01T00:00:00.000Z" + }), + run: vi.fn().mockResolvedValue({}) + }; + const environment = testEnv(); + environment.DB = { prepare: vi.fn().mockReturnValue(cacheStatement) } as unknown as D1Database; + environment.APP_ENV = appEnv; + if (ambiente === "00") { + environment.MH_USER_TEST = MH_SECRET_USER; + environment.MH_PASSWORD_TEST = MH_SECRET_PASSWORD; + } else { + environment.MH_USER_PROD = MH_SECRET_USER; + environment.MH_PASSWORD_PROD = MH_SECRET_PASSWORD; + environment.MH_AUTH_URL_PROD = "https://api.dtes.mh.gob.sv/seguridad/auth"; + environment.MH_RECEPCION_URL_PROD = "https://api.dtes.mh.gob.sv/fesv/recepciondte"; + } + return { environment, cacheStatement }; +} + function jsonResponse(body: unknown, init: ResponseInit = {}): Response { return new Response(JSON.stringify(body), { status: init.status ?? 200, @@ -297,9 +397,9 @@ function jsonResponse(body: unknown, init: ResponseInit = {}): Response { }); } -async function transmitTestDte(client: MhClient) { +async function transmitTestDte(client: MhClient, ambiente: "00" | "01" = "00") { return client.transmitDte({ - ambiente: "00", + ambiente, version: 2, tipoDte: "15", codigoGeneracion: "11111111-2222-4333-8444-555555555555", diff --git a/test/worker/pipeline.issuance.test.ts b/test/worker/pipeline.issuance.test.ts index 9ac896e8..53f83bcf 100644 --- a/test/worker/pipeline.issuance.test.ts +++ b/test/worker/pipeline.issuance.test.ts @@ -30,7 +30,14 @@ const PIPELINE_MH_SECRET_USER_FORM = "mh+user%2Bcanary%40example.test"; const PIPELINE_MH_SECRET_PASSWORD = "PW canary+&=/%?"; const PIPELINE_MH_SECRET_PASSWORD_PERCENT = "PW%20canary%2B%26%3D%2F%25%3F"; const PIPELINE_MH_SECRET_PASSWORD_FORM = "PW+canary%2B%26%3D%2F%25%3F"; -const PIPELINE_MH_SECRET_TOKEN = `Bearer token:${PIPELINE_MH_SECRET_PASSWORD}:mh-token-canary`; +const PIPELINE_MH_BEARER_CREDENTIAL = "cache token+credential/%? canary"; +const PIPELINE_MH_BEARER_CREDENTIAL_PERCENT = "cache%20token%2Bcredential%2F%25%3F%20canary"; +const PIPELINE_MH_BEARER_CREDENTIAL_FORM = "cache+token%2Bcredential%2F%25%3F+canary"; +const PIPELINE_MH_SECRET_TOKEN = `bEaReR ${PIPELINE_MH_BEARER_CREDENTIAL}`; +const PIPELINE_MH_SECRET_TOKEN_PERCENT = "bEaReR%20cache%20token%2Bcredential%2F%25%3F%20canary"; +const PIPELINE_MH_SECRET_TOKEN_FORM = "bEaReR+cache+token%2Bcredential%2F%25%3F+canary"; +const PIPELINE_MH_SECRET_TOKEN_PERCENT_SEPARATOR = `bEaReR%20${PIPELINE_MH_BEARER_CREDENTIAL}`; +const PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR = `bEaReR+${PIPELINE_MH_BEARER_CREDENTIAL}`; const PIPELINE_MH_SECRET_VARIANTS = [ PIPELINE_MH_SECRET_USER, @@ -39,7 +46,14 @@ const PIPELINE_MH_SECRET_VARIANTS = [ PIPELINE_MH_SECRET_PASSWORD, PIPELINE_MH_SECRET_PASSWORD_PERCENT, PIPELINE_MH_SECRET_PASSWORD_FORM, - PIPELINE_MH_SECRET_TOKEN + PIPELINE_MH_BEARER_CREDENTIAL, + PIPELINE_MH_BEARER_CREDENTIAL_PERCENT, + PIPELINE_MH_BEARER_CREDENTIAL_FORM, + PIPELINE_MH_SECRET_TOKEN, + PIPELINE_MH_SECRET_TOKEN_PERCENT, + PIPELINE_MH_SECRET_TOKEN_FORM, + PIPELINE_MH_SECRET_TOKEN_PERCENT_SEPARATOR, + PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR ]; const INTENT_ADDRESS = { @@ -172,6 +186,25 @@ function mhRecepcionCalls(fetchMock: ReturnType): number { return fetchMock.mock.calls.filter((call) => String(call[0]).includes("recepciondte")).length; } +function installCachedMhToken(db: InMemoryD1, token: string) { + const originalPrepare = db.prepare.bind(db); + const cacheStatement = { + bind: vi.fn().mockReturnThis(), + first: vi.fn().mockResolvedValue({ + token, + token_type: "Bearer", + expires_at: "2099-01-01T00:00:00.000Z" + }), + run: vi.fn().mockResolvedValue({}) + }; + vi.spyOn(db, "prepare").mockImplementation((sql) => + sql.includes("FROM mh_tokens") + ? cacheStatement as unknown as ReturnType + : originalPrepare(sql) + ); + return cacheStatement; +} + describe("IssuancePipeline.processWompiEvent acceptance", () => { it("issues an intent-correlated CDE to ACCEPTED with sello, receipt evidence, and completed intent", async () => { const db = new InMemoryD1(); @@ -392,7 +425,7 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { expect(db.audits).not.toContainEqual(expect.objectContaining({ action: "DTE_ACCEPTED" })); }); - it("keeps echoed MH credentials out of returned, document, audit, and log rejection evidence", async () => { + it("keeps cached-token echoes out of returned, document, audit, and log rejection evidence", async () => { const db = new InMemoryD1(); seedIntent(db); const eventId = seedEvent(db, unitWebhook()); @@ -400,15 +433,9 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { const runtime = await pipelineRuntime(db, sent); runtime.MH_USER_TEST = PIPELINE_MH_SECRET_USER; runtime.MH_PASSWORD_TEST = PIPELINE_MH_SECRET_PASSWORD; - vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const cacheStatement = installCachedMhToken(db, PIPELINE_MH_SECRET_TOKEN); + const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { const url = String(input); - if (url.includes("/seguridad/auth")) { - return jsonResponse({ - status: "OK", - body: { token: PIPELINE_MH_SECRET_TOKEN }, - tokenType: "Bearer" - }); - } if (url.includes("recepciondte")) { return jsonResponse({ estado: "RECHAZADO", @@ -416,18 +443,26 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { observaciones: [ `user=${PIPELINE_MH_SECRET_USER}; encoded=${PIPELINE_MH_SECRET_USER_PERCENT}`, `pwd=${PIPELINE_MH_SECRET_PASSWORD}; form=${PIPELINE_MH_SECRET_PASSWORD_FORM}`, - `authorization=${PIPELINE_MH_SECRET_TOKEN}` + `authorization=${PIPELINE_MH_SECRET_TOKEN}`, + `credential=${PIPELINE_MH_BEARER_CREDENTIAL}`, + `credential-percent=${PIPELINE_MH_BEARER_CREDENTIAL_PERCENT}`, + `credential-form=${PIPELINE_MH_BEARER_CREDENTIAL_FORM}`, + `authorization-percent=${PIPELINE_MH_SECRET_TOKEN_PERCENT}`, + `authorization-form=${PIPELINE_MH_SECRET_TOKEN_FORM}`, + `separator-percent=${PIPELINE_MH_SECRET_TOKEN_PERCENT_SEPARATOR}`, + `separator-form=${PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR}` ], descripcionMsg: `description ${PIPELINE_MH_SECRET_PASSWORD_PERCENT}`, estadoDetalle: `state ${PIPELINE_MH_SECRET_USER_FORM}`, - selloEcho: `seal ${PIPELINE_MH_SECRET_TOKEN}`, - text: `text ${PIPELINE_MH_SECRET_PASSWORD}`, - nested: [{ evidence: `prefix-${PIPELINE_MH_SECRET_TOKEN}-suffix` }], - [`provider-${PIPELINE_MH_SECRET_PASSWORD}-key`]: "nested key evidence" + selloEcho: `seal ${PIPELINE_MH_BEARER_CREDENTIAL}`, + text: `text ${PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR}`, + nested: [{ evidence: `prefix-${PIPELINE_MH_SECRET_TOKEN_PERCENT}-suffix` }], + [`provider-${PIPELINE_MH_BEARER_CREDENTIAL_PERCENT}-key`]: PIPELINE_MH_SECRET_TOKEN_FORM }, { status: 400 }); } throw new Error(`Fetch inesperado en prueba unitaria del pipeline: ${url}`); - })); + }); + vi.stubGlobal("fetch", fetchMock); const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); const record = await new IssuancePipeline(runtime).processWompiEvent(eventId); @@ -444,6 +479,11 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { summary: "DTE-15-M001P004-000000000000001 RECHAZADO" })); expect(JSON.parse(String(record!.mh_observaciones_json))[2]).toBe("authorization=[REDACTED]"); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("https://apitest.dtes.mh.gob.sv/fesv/recepciondte"); + expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: PIPELINE_MH_SECRET_TOKEN }); + expect(cacheStatement.first).toHaveBeenCalledTimes(1); + expect(cacheStatement.run).not.toHaveBeenCalled(); expectNoPipelineMhSecrets(JSON.stringify({ returned: record, documents: db.documents, From 5b59682ff0a49fc60c4ed8bbb0066b6885dadb79 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:57:24 -0600 Subject: [PATCH 15/22] fix(worker): normalize cached MH authorization --- src/worker/services/mhClient.ts | 6 ++-- test/worker/mhClient.test.ts | 51 +++++++++++++++++++++++++-- test/worker/pipeline.issuance.test.ts | 21 ++++++++--- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/src/worker/services/mhClient.ts b/src/worker/services/mhClient.ts index f35012c9..42447aa4 100644 --- a/src/worker/services/mhClient.ts +++ b/src/worker/services/mhClient.ts @@ -241,8 +241,10 @@ function providerRedactions( addProviderRedactionVariants(values, credential); } addProviderRedactionVariants(values, authorization); - const bearer = authorization.match(/^(Bearer)[ \t]+(.+)$/i); - const bearerCredential = bearer?.[2]?.trim(); + const transmittedAuthorization = authorization.replace(/^[ \t]+|[ \t]+$/g, ""); + addProviderRedactionVariants(values, transmittedAuthorization); + const bearer = transmittedAuthorization.match(/^(Bearer)[ \t]+(.+)$/i); + const bearerCredential = bearer?.[2]?.replace(/^[ \t]+|[ \t]+$/g, ""); if (bearer && bearerCredential) { addProviderRedactionVariants(values, bearerCredential); values.add(`${bearer[1]}%20${bearerCredential}`); diff --git a/test/worker/mhClient.test.ts b/test/worker/mhClient.test.ts index f5303c2c..4b798f64 100644 --- a/test/worker/mhClient.test.ts +++ b/test/worker/mhClient.test.ts @@ -17,6 +17,9 @@ const MH_SECRET_TOKEN_PERCENT = "bEaReR%20cache%20token%2Bcredential%2F%25%3F%20 const MH_SECRET_TOKEN_FORM = "bEaReR+cache+token%2Bcredential%2F%25%3F+canary"; const MH_SECRET_TOKEN_PERCENT_SEPARATOR = `bEaReR%20${MH_BEARER_CREDENTIAL}`; const MH_SECRET_TOKEN_FORM_SEPARATOR = `bEaReR+${MH_BEARER_CREDENTIAL}`; +const MH_OWS_SECRET_TOKEN = ` \t${MH_SECRET_TOKEN}\t `; +const MH_OWS_SECRET_TOKEN_PERCENT = "%20%09bEaReR%20cache%20token%2Bcredential%2F%25%3F%20canary%09%20"; +const MH_OWS_SECRET_TOKEN_FORM = "+%09bEaReR+cache+token%2Bcredential%2F%25%3F+canary%09+"; const MH_SECRET_VARIANTS = [ MH_SECRET_USER, @@ -32,7 +35,10 @@ const MH_SECRET_VARIANTS = [ MH_SECRET_TOKEN_PERCENT, MH_SECRET_TOKEN_FORM, MH_SECRET_TOKEN_PERCENT_SEPARATOR, - MH_SECRET_TOKEN_FORM_SEPARATOR + MH_SECRET_TOKEN_FORM_SEPARATOR, + MH_OWS_SECRET_TOKEN, + MH_OWS_SECRET_TOKEN_PERCENT, + MH_OWS_SECRET_TOKEN_FORM ]; describe("MH client", () => { @@ -267,6 +273,44 @@ describe("MH client", () => { expectNoMhSecrets(JSON.stringify(result)); }); + it("sanitizes cached authorization after Fetch removes outer HTTP whitespace", async () => { + const { environment, cacheStatement } = cachedTokenEnv("00", "staging", MH_OWS_SECRET_TOKEN); + let sentAuthorization: string | null = null; + const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + sentAuthorization = new Request(input, init).headers.get("Authorization"); + return jsonResponse({ + estado: "RECHAZADO", + selloRecibido: null, + observaciones: [ + `credential=${MH_BEARER_CREDENTIAL}`, + `credential-percent=${MH_BEARER_CREDENTIAL_PERCENT}`, + `credential-form=${MH_BEARER_CREDENTIAL_FORM}`, + `authorization=${MH_SECRET_TOKEN}`, + `authorization-percent=${MH_SECRET_TOKEN_PERCENT}`, + `authorization-form=${MH_SECRET_TOKEN_FORM}`, + `stored-authorization=${MH_OWS_SECRET_TOKEN}`, + `stored-percent=${MH_OWS_SECRET_TOKEN_PERCENT}`, + `stored-form=${MH_OWS_SECRET_TOKEN_FORM}` + ], + nested: [{ [`provider-${MH_BEARER_CREDENTIAL_PERCENT}-key`]: `prefix-${MH_SECRET_TOKEN_FORM}-suffix` }] + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await transmitTestDte(new MhClient(environment)); + + expect(result).toMatchObject({ + accepted: false, + estado: "RECHAZADO", + selloRecibido: null + }); + expect(sentAuthorization).toBe(MH_SECRET_TOKEN); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(cacheStatement.first).toHaveBeenCalledTimes(1); + expect(cacheStatement.run).not.toHaveBeenCalled(); + expectNoMhSecrets(JSON.stringify(result)); + }); + it("bounds an arbitrary indeterminate estado and sanitizes a plain-text reception response", async () => { const environment = testEnv(); environment.MH_USER_TEST = MH_SECRET_USER; @@ -357,7 +401,8 @@ function testEnv(): Env { function cachedTokenEnv( ambiente: "00" | "01", - appEnv: "staging" | "production" + appEnv: "staging" | "production", + token = MH_SECRET_TOKEN ): { environment: Env; cacheStatement: { @@ -369,7 +414,7 @@ function cachedTokenEnv( const cacheStatement = { bind: vi.fn().mockReturnThis(), first: vi.fn().mockResolvedValue({ - token: MH_SECRET_TOKEN, + token, token_type: "Bearer", expires_at: "2099-01-01T00:00:00.000Z" }), diff --git a/test/worker/pipeline.issuance.test.ts b/test/worker/pipeline.issuance.test.ts index 53f83bcf..5baaa63d 100644 --- a/test/worker/pipeline.issuance.test.ts +++ b/test/worker/pipeline.issuance.test.ts @@ -38,6 +38,9 @@ const PIPELINE_MH_SECRET_TOKEN_PERCENT = "bEaReR%20cache%20token%2Bcredential%2F const PIPELINE_MH_SECRET_TOKEN_FORM = "bEaReR+cache+token%2Bcredential%2F%25%3F+canary"; const PIPELINE_MH_SECRET_TOKEN_PERCENT_SEPARATOR = `bEaReR%20${PIPELINE_MH_BEARER_CREDENTIAL}`; const PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR = `bEaReR+${PIPELINE_MH_BEARER_CREDENTIAL}`; +const PIPELINE_MH_OWS_SECRET_TOKEN = ` \t${PIPELINE_MH_SECRET_TOKEN}\t `; +const PIPELINE_MH_OWS_SECRET_TOKEN_PERCENT = "%20%09bEaReR%20cache%20token%2Bcredential%2F%25%3F%20canary%09%20"; +const PIPELINE_MH_OWS_SECRET_TOKEN_FORM = "+%09bEaReR+cache+token%2Bcredential%2F%25%3F+canary%09+"; const PIPELINE_MH_SECRET_VARIANTS = [ PIPELINE_MH_SECRET_USER, @@ -53,7 +56,10 @@ const PIPELINE_MH_SECRET_VARIANTS = [ PIPELINE_MH_SECRET_TOKEN_PERCENT, PIPELINE_MH_SECRET_TOKEN_FORM, PIPELINE_MH_SECRET_TOKEN_PERCENT_SEPARATOR, - PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR + PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR, + PIPELINE_MH_OWS_SECRET_TOKEN, + PIPELINE_MH_OWS_SECRET_TOKEN_PERCENT, + PIPELINE_MH_OWS_SECRET_TOKEN_FORM ]; const INTENT_ADDRESS = { @@ -433,10 +439,12 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { const runtime = await pipelineRuntime(db, sent); runtime.MH_USER_TEST = PIPELINE_MH_SECRET_USER; runtime.MH_PASSWORD_TEST = PIPELINE_MH_SECRET_PASSWORD; - const cacheStatement = installCachedMhToken(db, PIPELINE_MH_SECRET_TOKEN); - const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => { + const cacheStatement = installCachedMhToken(db, PIPELINE_MH_OWS_SECRET_TOKEN); + let sentAuthorization: string | null = null; + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if (url.includes("recepciondte")) { + sentAuthorization = new Request(input, init).headers.get("Authorization"); return jsonResponse({ estado: "RECHAZADO", selloRecibido: null, @@ -450,7 +458,10 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { `authorization-percent=${PIPELINE_MH_SECRET_TOKEN_PERCENT}`, `authorization-form=${PIPELINE_MH_SECRET_TOKEN_FORM}`, `separator-percent=${PIPELINE_MH_SECRET_TOKEN_PERCENT_SEPARATOR}`, - `separator-form=${PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR}` + `separator-form=${PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR}`, + `stored-authorization=${PIPELINE_MH_OWS_SECRET_TOKEN}`, + `stored-percent=${PIPELINE_MH_OWS_SECRET_TOKEN_PERCENT}`, + `stored-form=${PIPELINE_MH_OWS_SECRET_TOKEN_FORM}` ], descripcionMsg: `description ${PIPELINE_MH_SECRET_PASSWORD_PERCENT}`, estadoDetalle: `state ${PIPELINE_MH_SECRET_USER_FORM}`, @@ -481,7 +492,7 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { expect(JSON.parse(String(record!.mh_observaciones_json))[2]).toBe("authorization=[REDACTED]"); expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls[0][0]).toBe("https://apitest.dtes.mh.gob.sv/fesv/recepciondte"); - expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: PIPELINE_MH_SECRET_TOKEN }); + expect(sentAuthorization).toBe(PIPELINE_MH_SECRET_TOKEN); expect(cacheStatement.first).toHaveBeenCalledTimes(1); expect(cacheStatement.run).not.toHaveBeenCalled(); expectNoPipelineMhSecrets(JSON.stringify({ From d1c6952c9f9d7167c31a53146f1729e8391fadb2 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:26:16 -0600 Subject: [PATCH 16/22] fix(worker): anchor retention verification in D1 --- docs/retention-restore.md | 77 +++-- src/worker/services/backups.ts | 285 +++++++++++++----- src/worker/services/retention.ts | 184 +++++++++-- src/worker/storage/repository.ts | 10 +- src/worker/storage/repository/audit.ts | 24 ++ test/worker/retention.test.ts | 110 ++++++- test/worker/support/inMemoryD1.ts | 28 ++ .../workerFetch.retention-admin.test.ts | 245 ++++++++++++++- 8 files changed, 823 insertions(+), 140 deletions(-) diff --git a/docs/retention-restore.md b/docs/retention-restore.md index acb50d5c..f0e443af 100644 --- a/docs/retention-restore.md +++ b/docs/retention-restore.md @@ -61,16 +61,17 @@ infer a failed issuance from an absent field or invent a reservation/error during restore. Every run writes to a fresh immutable `` prefix. `manifest.json` is -published **last** with a conditional create and is the authoritative completion -marker. If two runs overlap, only one can publish the month manifest; the losing -run cannot overwrite the winning files because their object keys differ. A later -re-run skips and audits `RETENTION_EXPORT_SKIPPED`. Version 1 manifests without -run-scoped keys remain readable for legacy restores. A version 2 manifest looks like: +published with a conditional create after all table objects. If two runs overlap, +only one can publish the month manifest; the losing run cannot overwrite the +winning files because their object keys differ. The winner then appends a live D1 +`RETENTION_EXPORT_COMPLETED` audit from the same in-memory manifest. A later re-run +skips and audits `RETENTION_EXPORT_SKIPPED`; it never creates or repairs completion +evidence from R2. A version 2 manifest looks like: ```json { "version": 2, - "runId": "example-run-id", + "runId": "example-run-id", "month": "2026-06", "generatedAt": "2026-07-01T09:00:03.512Z", "tables": { @@ -88,9 +89,30 @@ run-scoped keys remain readable for legacy restores. A version 2 manifest looks } ``` -The real manifest contains one keyed entry for every table listed above. Never -construct a version 2 table path from the month or from an untrusted run ID; use -the exact `tables..key` recorded by the canonical manifest. +The real manifest is an exact version 2 schema: those five root fields and only +those fields, plus exactly the 18 table entries listed above. Every entry contains +only `key`, a non-negative safe-integer `rowCount`, and a lowercase 64-hex +`sha256`. Its key must be exactly +`retention///runs//.ndjson`; partial, empty, extra, +wrong-month, wrong-run, or malformed manifests are invalid. Consumers rebuild the +table map in the canonical order shown above. Never construct a table path from an +untrusted run ID; use the exact key only after the whole manifest passes this +schema. + +New completion audits contain `month`, `runId`, `generatedAt`, `totalRows`, the +same exact `tables` map, and `manifestSha256`. The digest is SHA-256 over compact +UTF-8 JSON with root fields ordered `version`, `runId`, `month`, `generatedAt`, +`tables`; tables in the 18-table order above; and entry fields ordered `key`, +`rowCount`, `sha256`. Historical completion audits shaped exactly as +`{month,totalRows,tables}` remain acceptable only when the total and the entire +strict table map match. A present but malformed or mismatched new or historical +audit fails closed. + +The publish/audit order deliberately leaves a fail-closed crash gap: a Worker that +publishes the immutable manifest and crashes before appending the D1 audit leaves +R2 objects that listing/download can parse, but verification rejects them as +unanchored. Do not synthesize an anchor from those objects; investigate the failed +export and preserve the evidence. ## 1. List what's in the archive @@ -102,7 +124,16 @@ To list all objects for a given month without downloading each one, use the R2 API/dashboard (`wrangler r2 object` operates on a single key at a time) or `aws s3 ls` against R2's S3-compatible endpoint if configured. -## 2. Verify manifest hashes match the archived bodies +## 2. Verify the D1 anchor, then the archived bodies + +Use the authenticated admin verification action in the target environment first. +It parses the exact manifest and looks up the latest live D1 +`RETENTION_EXPORT_COMPLETED` audit for `entity_type=retention_export` and the same +month. The exact map (and, for new evidence, run ID, timestamp, total, and canonical +manifest digest) must match before the Worker reads or hashes any table body. No +anchor, a malformed latest anchor, or any mismatch creates +`RETENTION_VERIFY_FAILED`, sends an operational alert, and never creates +`RETENTION_VERIFIED`. Download each `.ndjson` at the exact `key` referenced in the manifest and confirm its SHA-256 matches the recorded hash before trusting it for a restore: @@ -118,6 +149,11 @@ Repeat for every table listed in the manifest. If any hash mismatches, the object was corrupted or tampered with after being written — do not use it for a restore; escalate before proceeding. +Repository tests prove this fail-closed contract with controlled D1/R2 fakes; +they are not evidence that a particular live R2 bucket or D1 database currently +contains a valid anchored month. Record the target, time, actor, and resulting +live `RETENTION_VERIFIED` audit when performing an operational verification. + ## 3. Re-import NDJSON into D1 Each line in a `.ndjson` file is a full row as it existed in D1 at export @@ -238,8 +274,10 @@ Stripe snapshots are intended for an empty loss-recovery database. If restoring into a database with existing Stripe rows, compare rows by primary/unique key and stop for manual review on any difference; never overwrite immutable annual snapshot/lineage evidence or turn REVIEW/SENT delivery evidence backward. -Archives created before these Stripe snapshot files existed remain valid legacy -archives, but they cannot reconstruct Stripe gifts and no missing row may be +Historical artifacts created before these Stripe snapshot files existed are not +exact v2 manifests and the current verifier will not label them archived. If an +incident requires separate forensic recovery from one, treat it as an incomplete +historical input: it cannot reconstruct Stripe gifts, and no missing row may be manufactured from an audit entry. Do not concatenate every repeated Wompi snapshot. Restore historical @@ -306,9 +344,10 @@ rehearsal they belong inside the existing `BEGIN IMMEDIATE` transaction. An upsert or trigger-recreation failure must roll back the whole restore; never leave the allocation trigger absent. -Archives created before `fiscal_corrections_latest.ndjson` are valid legacy -archives. Restore their historical `fiscal_corrections.ndjson` rows as they -exist and do not invent a later outcome. When any newer verified archive +Historical artifacts created before `fiscal_corrections_latest.ndjson` are not +exact v2 manifests and require separate forensic review. If independently +accepted for recovery, restore their historical `fiscal_corrections.ndjson` rows +as they exist and do not invent a later outcome. When any newer verified archive contains the authoritative snapshot, overlay that newest snapshot last. The snapshot repeats only the already protected correction row in R2; it does not copy receptor JSON into audit metadata, and it remains behind the same audited @@ -328,10 +367,10 @@ Wompi reservation maximum is the greatest non-null fiscal-correction reservation maximum is the greatest non-null `fiscal_corrections.reserved_control_sequence` for the same environment/`reserved_control_prefix`. If one source has no row, omit that -term (or treat it as `1`). Archives created before -`document_sequences.ndjson` existed are valid legacy archives: derive the -counter from the document, Wompi, and fiscal-correction reservation maxima -instead of assuming `1`. +term (or treat it as `1`). Historical artifacts created before +`document_sequences.ndjson` existed are not exact v2 manifests. If separate +forensic review accepts one for recovery, derive the counter from the document, +Wompi, and fiscal-correction reservation maxima instead of assuming `1`. Never move an existing counter backward. When restoring into a database that already has a counter, compare its current `next_value` with the formula above diff --git a/src/worker/services/backups.ts b/src/worker/services/backups.ts index 8932bb0a..58e5b191 100644 --- a/src/worker/services/backups.ts +++ b/src/worker/services/backups.ts @@ -1,17 +1,17 @@ -import { Repository, RETENTION_SNAPSHOT_TABLES, RETENTION_WINDOWED_TABLES } from "../storage/repository"; +import { Repository } from "../storage/repository"; import type { AuthUser } from "./auth"; import type { Env } from "../types"; -import { sha256Hex } from "../utils/encoding"; +import { sha256Hex, utf8Bytes } from "../utils/encoding"; import { newId } from "../utils/ids"; import { sendOperationalAlert } from "./alerts"; import { - DOCUMENT_SEQUENCES_SNAPSHOT, - FISCAL_CORRECTION_LATEST_SNAPSHOT, + RETENTION_CANONICAL_TABLES, RETENTION_KEY_ROOT, + canonicalRetentionManifestJson, elSalvadorMonth, + parseRetentionManifest, previousElSalvadorMonth, retentionManifestKey, - retentionTableKey, type RetentionManifest } from "./retention"; @@ -51,14 +51,22 @@ interface BackupVerifyFile { export interface BackupVerifyResult { ok: boolean; files: BackupVerifyFile[]; + reason?: BackupVerifyFailureReason; } -const RETENTION_DOWNLOAD_TABLES = new Set([ - ...RETENTION_WINDOWED_TABLES, - ...RETENTION_SNAPSHOT_TABLES, - FISCAL_CORRECTION_LATEST_SNAPSHOT, - DOCUMENT_SEQUENCES_SNAPSHOT -]); +type BackupVerifyFailureReason = + | "manifest_invalid" + | "anchor_missing" + | "anchor_invalid" + | "anchor_mismatch" + | "object_mismatch"; + +type ManifestReadResult = + | { status: "absent" } + | { status: "invalid" } + | { status: "valid"; manifest: RetentionManifest }; + +const RETENTION_DOWNLOAD_TABLES = new Set(RETENTION_CANONICAL_TABLES); export async function isManifestedBackupTable(env: Env, month: string, table: string): Promise { return (await manifestedBackupTableKey(env, month, table)) !== null; @@ -72,22 +80,17 @@ export async function manifestedBackupTableKey( if (!RETENTION_DOWNLOAD_TABLES.has(table)) { return null; } - const manifest = await getManifest(env, month); - if ( - manifest === null - || typeof manifest.tables !== "object" - || manifest.tables === null - || !Object.hasOwn(manifest.tables, table) - ) { + const manifestResult = await readManifest(env, month); + if (manifestResult.status !== "valid") { return null; } - return tableObjectKey(month, table, manifest.tables[table]); + return manifestResult.manifest.tables[table].key; } // Ground truth is the set of manifests in R2, never the audit log. A month is -// "archivado" only when its manifest.json exists and parses; the current (still -// open) El Salvador month is always "en_curso"; every other expected month with -// no manifest is "faltante". +// "archivado" only when its manifest.json passes the exact v2 schema; the current +// (still open) El Salvador month is always "en_curso"; every other expected month +// without a valid manifest is "faltante". export async function listBackupMonths(env: Env, repo: Repository, now: Date): Promise { const manifests = await listArchivedManifests(env); const earliestDocIso = await repo.earliestDteDocumentCreatedAt(); @@ -140,16 +143,30 @@ export async function listBackupMonths(env: Env, repo: Repository, now: Date): P // RETENTION_VERIFY_FAILED and fires an operational alert, so silent tampering or // bit-rot does not wait for someone to reopen the panel. export async function verifyBackupMonth(env: Env, repo: Repository, month: string, actor: AuthUser): Promise { - const manifest = await getManifest(env, month); - if (!manifest) { + const manifestResult = await readManifest(env, month); + if (manifestResult.status === "absent") { return null; } const incidentId = newId("retention_verify"); + if (manifestResult.status === "invalid") { + return failBackupVerification(env, repo, month, actor, incidentId, "manifest_invalid", []); + } + const manifest = manifestResult.manifest; + + const anchor = await repo.getLatestRetentionExportCompletionAudit(month); + if (!anchor) { + return failBackupVerification(env, repo, month, actor, incidentId, "anchor_missing", []); + } + const manifestSha256 = await sha256Hex(utf8Bytes(canonicalRetentionManifestJson(manifest))); + const anchorStatus = retentionAnchorStatus(anchor.metadataJson, manifest, manifestSha256); + if (anchorStatus !== "valid") { + return failBackupVerification(env, repo, month, actor, incidentId, anchorStatus, []); + } const files: BackupVerifyFile[] = []; - for (const [table, entry] of Object.entries(manifest.tables)) { - const key = tableObjectKey(month, table, entry); - const object = key ? await env.ARCHIVE.get(key) : null; + for (const table of RETENTION_CANONICAL_TABLES) { + const entry = manifest.tables[table]; + const object = await env.ARCHIVE.get(entry.key); if (!object) { files.push({ table, ok: false, expected: entry.sha256, actual: "" }); continue; @@ -171,27 +188,161 @@ export async function verifyBackupMonth(env: Env, repo: Repository, month: strin }); } else { const mismatches = files.filter((file) => !file.ok).map((file) => file.table); - await repo.createAudit({ - actorType: "USER", - actorId: actor.id, - action: "RETENTION_VERIFY_FAILED", - entityType: "retention_export", - entityId: month, - summary: `Respaldo de ${month} con discrepancias: ${mismatches.join(", ")}`, - metadata: { month, mismatches, files, incidentId } - }); - await sendOperationalAlert(env, repo, { - kind: "RETENTION_VERIFY_FAILED", - title: `Respaldo de ${month} corrupto o alterado`, - detail: `La verificación del respaldo de ${month} falló. Archivos con discrepancia: ${mismatches.join(", ")}.`, - entityType: "retention_export", - entityId: month, - incidentId - }); + return failBackupVerification( + env, + repo, + month, + actor, + incidentId, + "object_mismatch", + mismatches, + files + ); } return { ok, files }; } +async function failBackupVerification( + env: Env, + repo: Repository, + month: string, + actor: AuthUser, + incidentId: string, + reason: BackupVerifyFailureReason, + tables: string[], + files: BackupVerifyFile[] = [] +): Promise { + const canonicalTables = RETENTION_CANONICAL_TABLES.filter((table) => tables.includes(table)); + await repo.createAudit({ + actorType: "USER", + actorId: actor.id, + action: "RETENTION_VERIFY_FAILED", + entityType: "retention_export", + entityId: month, + summary: `Verificación de respaldo ${month} fallida (${reason})`, + metadata: { month, reason, tables: canonicalTables, incidentId } + }); + await sendOperationalAlert(env, repo, { + kind: "RETENTION_VERIFY_FAILED", + title: `Verificación de respaldo ${month} fallida`, + detail: canonicalTables.length > 0 + ? `La verificación falló (${reason}) en: ${canonicalTables.join(", ")}.` + : `La verificación falló (${reason}) antes de validar archivos.`, + entityType: "retention_export", + entityId: month, + incidentId + }); + return { ok: false, reason, files }; +} + +function retentionAnchorStatus( + metadataJson: unknown, + manifest: RetentionManifest, + manifestSha256: string +): "valid" | "anchor_invalid" | "anchor_mismatch" { + if (typeof metadataJson !== "string") return "anchor_invalid"; + let metadata: unknown; + try { + metadata = JSON.parse(metadataJson); + } catch { + return "anchor_invalid"; + } + if (!isRecord(metadata)) return "anchor_invalid"; + + const legacyFields = ["month", "totalRows", "tables"] as const; + const newFields = ["month", "runId", "generatedAt", "totalRows", "tables", "manifestSha256"] as const; + const isLegacy = hasExactFields(metadata, legacyFields); + const isNew = hasExactFields(metadata, newFields); + if (!isLegacy && !isNew) return "anchor_invalid"; + if ( + typeof metadata.month !== "string" + || !Number.isSafeInteger(metadata.totalRows) + || Number(metadata.totalRows) < 0 + ) { + return "anchor_invalid"; + } + + const tableStatus = anchorTableStatus(metadata.tables, manifest); + if (tableStatus !== "valid") return tableStatus; + const expectedTotalRows = RETENTION_CANONICAL_TABLES.reduce( + (sum, table) => sum + manifest.tables[table].rowCount, + 0 + ); + if (metadata.month !== manifest.month || metadata.totalRows !== expectedTotalRows) { + return "anchor_mismatch"; + } + if (isLegacy) return "valid"; + + if ( + typeof metadata.runId !== "string" + || !/^[A-Za-z0-9_-]{1,100}$/.test(metadata.runId) + || typeof metadata.generatedAt !== "string" + || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(metadata.generatedAt) + || Number.isNaN(new Date(metadata.generatedAt).getTime()) + || new Date(metadata.generatedAt).toISOString() !== metadata.generatedAt + || typeof metadata.manifestSha256 !== "string" + || !/^[0-9a-f]{64}$/.test(metadata.manifestSha256) + ) { + return "anchor_invalid"; + } + return metadata.runId === manifest.runId + && metadata.generatedAt === manifest.generatedAt + && metadata.manifestSha256 === manifestSha256 + ? "valid" + : "anchor_mismatch"; +} + +function anchorTableStatus( + value: unknown, + manifest: RetentionManifest +): "valid" | "anchor_invalid" | "anchor_mismatch" { + if (!isRecord(value)) return "anchor_invalid"; + const tableNames = Object.keys(value); + if ( + tableNames.length !== RETENTION_CANONICAL_TABLES.length + || RETENTION_CANONICAL_TABLES.some((table) => !Object.hasOwn(value, table)) + ) { + return "anchor_invalid"; + } + for (const table of RETENTION_CANONICAL_TABLES) { + const entry = value[table]; + if (!hasExactFields(entry, ["key", "rowCount", "sha256"] as const)) { + return "anchor_invalid"; + } + if ( + typeof entry.key !== "string" + || !Number.isSafeInteger(entry.rowCount) + || Number(entry.rowCount) < 0 + || typeof entry.sha256 !== "string" + || !/^[0-9a-f]{64}$/.test(entry.sha256) + ) { + return "anchor_invalid"; + } + const expected = manifest.tables[table]; + if ( + entry.key !== expected.key + || entry.rowCount !== expected.rowCount + || entry.sha256 !== expected.sha256 + ) { + return "anchor_mismatch"; + } + } + return "valid"; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactFields( + value: unknown, + fields: Fields +): value is Record { + return isRecord(value) + && Object.keys(value).length === fields.length + && fields.every((field) => Object.hasOwn(value, field)); +} + // Collects every R2 object of a month's archive (the manifest.json plus each table's // NDJSON snapshot) as ZIP entries, so the whole month downloads as one file. Returns // null when the month has no manifest (never archived) so the route can 404 exactly @@ -208,17 +359,18 @@ export async function collectBackupMonthObjects(env: Env, month: string): Promis let totalBytes = manifestBytes.byteLength; enforceBackupArchiveLimit(totalBytes); - let manifest: RetentionManifest; + let parsed: unknown; try { - manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as RetentionManifest; + parsed = JSON.parse(new TextDecoder().decode(manifestBytes)); } catch { return null; } + const manifest = parseRetentionManifest(parsed, month); + if (!manifest) return null; const entries: Array<{ name: string; data: Uint8Array }> = [{ name: "manifest.json", data: manifestBytes }]; - for (const [table, manifestEntry] of Object.entries(manifest.tables)) { - const key = tableObjectKey(month, table, manifestEntry); - const object = key ? await env.ARCHIVE.get(key) : null; + for (const table of RETENTION_CANONICAL_TABLES) { + const object = await env.ARCHIVE.get(manifest.tables[table].key); if (!object) { continue; } @@ -231,38 +383,25 @@ export async function collectBackupMonthObjects(env: Env, month: string): Promis return entries; } -function tableObjectKey( - month: string, - table: string, - entry: RetentionManifest["tables"][string] -): string | null { - if (!entry.key) { - return retentionTableKey(month, table); - } - const prefix = `${RETENTION_KEY_ROOT}/${month.slice(0, 4)}/${month}/runs/`; - const suffix = `/${table}.ndjson`; - if (!entry.key.startsWith(prefix) || !entry.key.endsWith(suffix)) { - return null; - } - const runId = entry.key.slice(prefix.length, -suffix.length); - return /^[A-Za-z0-9_-]{1,100}$/.test(runId) ? entry.key : null; -} - function enforceBackupArchiveLimit(totalBytes: number): void { if (totalBytes > BACKUP_MONTH_DOWNLOAD_MAX_BYTES) { throw new BackupArchiveTooLargeError(BACKUP_MONTH_DOWNLOAD_MAX_BYTES); } } -async function getManifest(env: Env, month: string): Promise { +async function readManifest(env: Env, month: string): Promise { const object = await env.ARCHIVE.get(retentionManifestKey(month)); if (!object) { - return null; + return { status: "absent" }; } try { - return JSON.parse(new TextDecoder().decode(new Uint8Array(await object.arrayBuffer()))) as RetentionManifest; + const parsed: unknown = JSON.parse( + new TextDecoder().decode(new Uint8Array(await object.arrayBuffer())) + ); + const manifest = parseRetentionManifest(parsed, month); + return manifest ? { status: "valid", manifest } : { status: "invalid" }; } catch { - return null; + return { status: "invalid" }; } } @@ -276,9 +415,9 @@ async function listArchivedManifests(env: Env): Promise; @@ -63,6 +63,21 @@ interface RetentionSuspendedTrigger { export const FISCAL_CORRECTION_LATEST_SNAPSHOT = "fiscal_corrections_latest"; export const DOCUMENT_SEQUENCES_SNAPSHOT = "document_sequences"; +const NON_STRIPE_RETENTION_SNAPSHOT_TABLES = RETENTION_SNAPSHOT_TABLES.filter( + (table) => !(STRIPE_RETENTION_SNAPSHOT_TABLES as readonly string[]).includes(table) +); + +// Ordered source of truth for both production export and every manifest consumer. +// The two special snapshots deliberately sit between the windowed, non-Stripe, +// and fenced Stripe sections so serialization and object reads are deterministic. +export const RETENTION_CANONICAL_TABLES = Object.freeze([ + ...RETENTION_WINDOWED_TABLES, + FISCAL_CORRECTION_LATEST_SNAPSHOT, + ...NON_STRIPE_RETENTION_SNAPSHOT_TABLES, + DOCUMENT_SEQUENCES_SNAPSHOT, + ...STRIPE_RETENTION_SNAPSHOT_TABLES +]); + const FISCAL_CORRECTION_RESTORE_UPDATE_COLUMNS = [ "request_id", "request_payload_sha256", @@ -227,7 +242,7 @@ export const RETENTION_FOREIGN_KEY_PROTOCOL: { // Single source of truth for the R2 archive key layout, shared with the backups // service so month listing/verification/download derive keys the same way the // export writes them. Version 2 manifests stay canonical at the month root and -// name immutable run-scoped table objects; retentionTableKey remains the v1 fallback. +// name immutable run-scoped table objects. export const RETENTION_KEY_ROOT = "retention"; function retentionMonthPrefix(month: string): string { @@ -238,8 +253,94 @@ export function retentionManifestKey(month: string): string { return `${retentionMonthPrefix(month)}/manifest.json`; } -export function retentionTableKey(month: string, table: string): string { - return `${retentionMonthPrefix(month)}/${table}.ndjson`; +const RETENTION_MANIFEST_ROOT_FIELDS = ["version", "runId", "month", "generatedAt", "tables"] as const; +const RETENTION_MANIFEST_ENTRY_FIELDS = ["key", "rowCount", "sha256"] as const; +const RETENTION_MONTH_PATTERN = /^\d{4}-(0[1-9]|1[0-2])$/; +const RETENTION_RUN_ID_PATTERN = /^[A-Za-z0-9_-]{1,100}$/; +const RETENTION_SHA256_PATTERN = /^[0-9a-f]{64}$/; +const RETENTION_ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +export function parseRetentionManifest(value: unknown, expectedMonth: string): RetentionManifest | null { + if (!hasExactFields(value, RETENTION_MANIFEST_ROOT_FIELDS)) return null; + if (value.version !== 2) return null; + if (typeof value.month !== "string" || !RETENTION_MONTH_PATTERN.test(value.month) || value.month !== expectedMonth) { + return null; + } + if (typeof value.runId !== "string" || !RETENTION_RUN_ID_PATTERN.test(value.runId)) return null; + if (typeof value.generatedAt !== "string" || !RETENTION_ISO_TIMESTAMP_PATTERN.test(value.generatedAt)) { + return null; + } + const generatedAt = new Date(value.generatedAt); + if (Number.isNaN(generatedAt.getTime()) || generatedAt.toISOString() !== value.generatedAt) { + return null; + } + if (!isRecord(value.tables)) return null; + const sourceTables = value.tables; + const tableNames = Object.keys(sourceTables); + if ( + tableNames.length !== RETENTION_CANONICAL_TABLES.length + || RETENTION_CANONICAL_TABLES.some((table) => !Object.hasOwn(sourceTables, table)) + ) { + return null; + } + + const tables: RetentionManifest["tables"] = {}; + for (const table of RETENTION_CANONICAL_TABLES) { + const entry = sourceTables[table]; + if (!hasExactFields(entry, RETENTION_MANIFEST_ENTRY_FIELDS)) return null; + const expectedKey = `${RETENTION_KEY_ROOT}/${value.month.slice(0, 4)}/${value.month}/runs/${value.runId}/${table}.ndjson`; + if (entry.key !== expectedKey) return null; + if (!Number.isSafeInteger(entry.rowCount) || Number(entry.rowCount) < 0) return null; + if (typeof entry.sha256 !== "string" || !RETENTION_SHA256_PATTERN.test(entry.sha256)) return null; + tables[table] = { + key: entry.key, + rowCount: Number(entry.rowCount), + sha256: entry.sha256 + }; + } + + return { + version: 2, + runId: value.runId, + month: value.month, + generatedAt: value.generatedAt, + tables + }; +} + +// Canonical digest payload: compact JSON; fixed root field order above; canonical +// table order; and key,rowCount,sha256 entry order. It intentionally differs from +// the human-readable, indented R2 representation. +export function canonicalRetentionManifestJson(manifest: RetentionManifest): string { + const tables: RetentionManifest["tables"] = {}; + for (const table of RETENTION_CANONICAL_TABLES) { + const entry = manifest.tables[table]; + tables[table] = { + key: entry.key, + rowCount: entry.rowCount, + sha256: entry.sha256 + }; + } + return JSON.stringify({ + version: manifest.version, + runId: manifest.runId, + month: manifest.month, + generatedAt: manifest.generatedAt, + tables + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasExactFields( + value: unknown, + fields: Fields +): value is Record { + return isRecord(value) + && Object.keys(value).length === fields.length + && fields.every((field) => Object.hasOwn(value, field)); } // Every month, snapshot all legal records into R2 so they survive D1 loss, an @@ -276,38 +377,52 @@ export async function runRetentionExport(env: Env, now: Date, options: { month?: let totalRows = 0; const { startIso, endIso } = elSalvadorMonthWindow(month); - for (const table of RETENTION_WINDOWED_TABLES) { - const entry = await exportWindowedTable(env, repo, table, prefix, startIso, endIso); - manifest.tables[table] = entry; - totalRows += entry.rowCount; - } - const fiscalCorrectionSnapshotEntry = await exportFiscalCorrectionSnapshot(env, prefix); - manifest.tables[FISCAL_CORRECTION_LATEST_SNAPSHOT] = fiscalCorrectionSnapshotEntry; - totalRows += fiscalCorrectionSnapshotEntry.rowCount; - for (const table of RETENTION_SNAPSHOT_TABLES) { - if ((STRIPE_RETENTION_SNAPSHOT_TABLES as readonly string[]).includes(table)) continue; - const entry = await exportSnapshotTable(env, repo, table, prefix); + let stripeFence: StripeRetentionFence | null = null; + for (const table of RETENTION_CANONICAL_TABLES) { + let entry: TableManifestEntry; + if ((RETENTION_WINDOWED_TABLES as readonly string[]).includes(table)) { + entry = await exportWindowedTable( + env, + repo, + table as RetentionTable, + prefix, + startIso, + endIso + ); + } else if (table === FISCAL_CORRECTION_LATEST_SNAPSHOT) { + entry = await exportFiscalCorrectionSnapshot(env, prefix); + } else if (table === DOCUMENT_SEQUENCES_SNAPSHOT) { + entry = await exportDocumentSequences(env, repo, prefix); + } else { + const isStripeTable = (STRIPE_RETENTION_SNAPSHOT_TABLES as readonly string[]).includes(table); + // The canonical order places the six Stripe streams last, so this fence + // is captured immediately before their first D1 read. + if (isStripeTable && !stripeFence) { + stripeFence = await repo.captureStripeRetentionFence(); + } + entry = await exportSnapshotTable( + env, + repo, + table as RetentionSnapshotTable, + prefix, + isStripeTable ? stripeFence ?? undefined : undefined + ); + } manifest.tables[table] = entry; totalRows += entry.rowCount; } - const sequenceEntry = await exportDocumentSequences(env, repo, prefix); - manifest.tables[DOCUMENT_SEQUENCES_SNAPSHOT] = sequenceEntry; - totalRows += sequenceEntry.rowCount; - - // Capture immediately before the six Stripe streams, then prove no - // material insert/update/delete occurred before publishing the manifest. - const stripeFence = await repo.captureStripeRetentionFence(); - for (const table of STRIPE_RETENTION_SNAPSHOT_TABLES) { - const entry = await exportSnapshotTable(env, repo, table, prefix, stripeFence); - manifest.tables[table] = entry; - totalRows += entry.rowCount; + if (!stripeFence) { + throw new Error("retention_stripe_fence_required"); } const completedStripeFence = await repo.captureStripeRetentionFence(); if (completedStripeFence.materialMutationEpoch !== stripeFence.materialMutationEpoch) { throw new Error("retention_stripe_material_epoch_changed"); } - // Manifest last: its existence is the idempotency/completion marker. + const manifestSha256 = await sha256Hex(utf8Bytes(canonicalRetentionManifestJson(manifest))); + + // Publish the immutable manifest conditionally, then anchor that winning + // in-memory manifest in D1. A crash between the two steps fails verification. const published = await env.ARCHIVE.put( manifestKey, utf8Bytes(JSON.stringify(manifest, null, 2)), @@ -329,7 +444,14 @@ export async function runRetentionExport(env: Env, now: Date, options: { month?: entityType: "retention_export", entityId: month, summary: `Exportación de retención ${month} completada: ${totalRows} filas`, - metadata: { month, totalRows, tables: manifest.tables } + metadata: { + month, + runId: manifest.runId, + generatedAt: manifest.generatedAt, + totalRows, + tables: manifest.tables, + manifestSha256 + } }); return { status: "completed", month, totalRows }; } catch (error) { diff --git a/src/worker/storage/repository.ts b/src/worker/storage/repository.ts index 5da67e76..08368be6 100644 --- a/src/worker/storage/repository.ts +++ b/src/worker/storage/repository.ts @@ -5,8 +5,10 @@ import { createAudit as createAuditRepository, createAuditIfAbsent as createAuditIfAbsentRepository, ensurePostAcceptAudit as ensurePostAcceptAuditRepository, + getLatestRetentionExportCompletionAudit as getLatestRetentionExportCompletionAuditRepository, listAudit as listAuditRepository, - listAuditPage as listAuditPageRepository + listAuditPage as listAuditPageRepository, + type RetentionExportCompletionAudit } from "./repository/audit"; import { getOpenContingency as getOpenContingencyRepository, @@ -1432,6 +1434,12 @@ export class Repository { return createAuditRepository(this.db, this.auditContext, input); } + async getLatestRetentionExportCompletionAudit( + month: string + ): Promise { + return getLatestRetentionExportCompletionAuditRepository(this.db, month); + } + async ensurePostAcceptAudit(input: { auditId: string; documentId: string; diff --git a/src/worker/storage/repository/audit.ts b/src/worker/storage/repository/audit.ts index 7f272dbc..d120c754 100644 --- a/src/worker/storage/repository/audit.ts +++ b/src/worker/storage/repository/audit.ts @@ -6,6 +6,30 @@ import { import { newId } from "../../utils/ids"; import { redactSensitiveAuditRows } from "../shared"; +export interface RetentionExportCompletionAudit { + id: string; + metadataJson: string; + createdAt: string; +} + +export async function getLatestRetentionExportCompletionAudit( + db: D1Database, + month: string +): Promise { + return db + .prepare( + `SELECT id, metadata_json AS metadataJson, created_at AS createdAt + FROM audit_logs + WHERE action = 'RETENTION_EXPORT_COMPLETED' + AND entity_type = 'retention_export' + AND entity_id = ? + ORDER BY created_at DESC, id DESC + LIMIT 1` + ) + .bind(month) + .first(); +} + export async function createAudit( db: D1Database, auditContext: AuditRequestContext | undefined, diff --git a/test/worker/retention.test.ts b/test/worker/retention.test.ts index 5bd25134..e3ce4248 100644 --- a/test/worker/retention.test.ts +++ b/test/worker/retention.test.ts @@ -7,7 +7,9 @@ import { STRIPE_RETENTION_SNAPSHOT_TABLES } from "../../src/worker/storage/repository"; import { + RETENTION_CANONICAL_TABLES, RETENTION_FOREIGN_KEY_PROTOCOL, + parseRetentionManifest, previousElSalvadorMonth, runRetentionExport } from "../../src/worker/services/retention"; @@ -814,6 +816,90 @@ function restoreArchivedRows( } } +function exactManifestFixture(month = "2026-06") { + const runId = "11111111-1111-4111-8111-111111111111"; + const tables = Object.fromEntries( + RETENTION_CANONICAL_TABLES.map((table) => [ + table, + { + key: `retention/${month.slice(0, 4)}/${month}/runs/${runId}/${table}.ndjson`, + rowCount: 0, + sha256: "a".repeat(64) + } + ]) + ); + return { + version: 2, + runId, + month, + generatedAt: "2026-07-01T09:00:03.512Z", + tables + }; +} + +describe("parseRetentionManifest", () => { + it("defines the complete 18-table legal-retention contract once", () => { + expect(RETENTION_CANONICAL_TABLES).toEqual([ + "dte_documents", + "fiscal_corrections", + "donation_intents", + "dte_events", + "email_deliveries", + "audit_logs", + "fiscal_corrections_latest", + "wompi_events", + "contingency_periods", + "contingency_batches", + "contingency_batch_lines", + "document_sequences", + "stripe_checkout_sessions", + "stripe_webhook_events", + "stripe_gifts", + "stripe_invoice_settlements", + "stripe_acknowledgment_deliveries", + "stripe_annual_statement_deliveries" + ]); + }); + + it("returns one deterministic canonical v2 manifest for the exact table set", () => { + const source = exactManifestFixture(); + source.tables = Object.fromEntries(Object.entries(source.tables).reverse()); + + const parsed = parseRetentionManifest(source, "2026-06"); + + expect(parsed).not.toBeNull(); + expect(Object.keys(parsed!.tables)).toEqual(RETENTION_CANONICAL_TABLES); + expect(parsed).not.toBe(source); + }); + + it.each([ + ["empty table set", (manifest: ReturnType) => { manifest.tables = {}; }], + ["partial table set", (manifest: ReturnType) => { delete manifest.tables.audit_logs; }], + ["extra table", (manifest: ReturnType) => { manifest.tables.debug_dump = manifest.tables.audit_logs; }], + ["missing root field", (manifest: ReturnType) => { delete (manifest as Partial).generatedAt; }], + ["extra root field", (manifest: ReturnType) => { Object.assign(manifest, { note: "unexpected" }); }], + ["malformed root value", (manifest: ReturnType) => { (manifest as { version: unknown }).version = 1; }], + ["wrong month", (manifest: ReturnType) => { manifest.month = "2026-05"; }], + ["malformed generatedAt", (manifest: ReturnType) => { manifest.generatedAt = "2026-07-01"; }], + ["impossible generatedAt", (manifest: ReturnType) => { manifest.generatedAt = "2026-99-99T99:99:99.999Z"; }], + ["unsafe runId", (manifest: ReturnType) => { manifest.runId = "../escape"; }], + ["wrong run-scoped key", (manifest: ReturnType) => { manifest.tables.audit_logs.key = manifest.tables.audit_logs.key.replace(manifest.runId, "other-run"); }], + ["missing entry field", (manifest: ReturnType) => { delete (manifest.tables.audit_logs as Partial).key; }], + ["extra entry field", (manifest: ReturnType) => { Object.assign(manifest.tables.audit_logs, { size: 12 }); }], + ["negative row count", (manifest: ReturnType) => { manifest.tables.audit_logs.rowCount = -1; }], + ["fractional row count", (manifest: ReturnType) => { manifest.tables.audit_logs.rowCount = 0.5; }], + ["unsafe row count", (manifest: ReturnType) => { manifest.tables.audit_logs.rowCount = Number.MAX_SAFE_INTEGER + 1; }], + ["string row count", (manifest: ReturnType) => { (manifest.tables.audit_logs as { rowCount: unknown }).rowCount = "0"; }], + ["uppercase digest", (manifest: ReturnType) => { manifest.tables.audit_logs.sha256 = "A".repeat(64); }], + ["short digest", (manifest: ReturnType) => { manifest.tables.audit_logs.sha256 = "a".repeat(63); }] + ])("rejects a manifest with %s", (_name, mutate) => { + const manifest = exactManifestFixture(); + mutate(manifest); + + expect(parseRetentionManifest(manifest, "2026-06")).toBeNull(); + }); +}); + describe("runRetentionExport", () => { it("exports the previous El Salvador calendar month for windowed tables into NDJSON keyed objects", async () => { const db = new InMemoryRetentionD1(); @@ -1768,7 +1854,7 @@ describe("runRetentionExport", () => { })); }); - it("audits RETENTION_EXPORT_COMPLETED with month and total rows", async () => { + it("anchors RETENTION_EXPORT_COMPLETED to the exact in-memory manifest and canonical digest", async () => { const db = new InMemoryRetentionD1(); db.dteDocuments.push(row({ id: "dte_1", created_at: "2026-06-10T00:00:00.000Z" })); db.wompiEvents.push(row({ id: "wompi_1", created_at: undefined, received_at: "2026-06-11T00:00:00.000Z" })); @@ -1781,6 +1867,18 @@ describe("runRetentionExport", () => { expect(completed).toBeTruthy(); expect(String(completed?.summary)).toContain("2026-06"); expect(String(completed?.summary)).toMatch(/\b2\b/); + const manifest = JSON.parse(new TextDecoder().decode( + archive.objects.get("retention/2026/2026-06/manifest.json")!.body + )) as ReturnType; + const metadata = JSON.parse(String(completed!.metadata_json)) as Record; + expect(metadata).toEqual({ + month: manifest.month, + runId: manifest.runId, + generatedAt: manifest.generatedAt, + totalRows: 2, + tables: manifest.tables, + manifestSha256: await sha256Hex(utf8Bytes(JSON.stringify(manifest))) + }); }); it("skips and audits RETENTION_EXPORT_SKIPPED when the manifest already exists (idempotent)", async () => { @@ -1788,7 +1886,10 @@ describe("runRetentionExport", () => { db.dteDocuments.push(row({ id: "dte_1", created_at: "2026-06-10T00:00:00.000Z" })); const archive = new FakeArchiveBucket(); // Pre-seed the manifest as if a previous run already completed. - archive.objects.set("retention/2026/2026-06/manifest.json", { key: "retention/2026/2026-06/manifest.json", body: utf8Bytes("{}") }); + archive.objects.set("retention/2026/2026-06/manifest.json", { + key: "retention/2026/2026-06/manifest.json", + body: utf8Bytes(JSON.stringify(exactManifestFixture())) + }); const env = envWithArchive(db, archive); const result = await runRetentionExport(env, new Date("2026-07-04T15:00:00.000Z")); @@ -1796,6 +1897,7 @@ describe("runRetentionExport", () => { expect(result.status).toBe("skipped"); expect(archive.putCalls).toHaveLength(0); // no re-export, no re-write of manifest expect(db.audits.find((audit) => audit.action === "RETENTION_EXPORT_SKIPPED")).toBeTruthy(); + expect(db.audits.find((audit) => audit.action === "RETENTION_EXPORT_COMPLETED")).toBeUndefined(); }); it("supports exporting an explicit month for the manual verification endpoint", async () => { @@ -2151,7 +2253,7 @@ describe("retention restore guidance", () => { expect(guidance).toContain("latest `wompi_events.ndjson` snapshot"); expect(guidance).toContain("latest `document_sequences.ndjson` snapshot"); expect(guidance.toLowerCase()).toMatch( - /archives created before\s+`document_sequences\.ndjson`/ + /historical artifacts created before\s+`document_sequences\.ndjson`/ ); expect(guidance).toContain( "MAX(snapshot `next_value`, restored document maximum + 1, restored Wompi reservation maximum + 1, restored fiscal-correction reservation maximum + 1)" @@ -2413,7 +2515,7 @@ describe("retention restore guidance", () => { "CREATE TRIGGER trg_fiscal_correction_reserve_sequence" ); expect(guidance).toContain( - "Archives created before `fiscal_corrections_latest.ndjson`" + "Historical artifacts created before `fiscal_corrections_latest.ndjson`" ); }); }); diff --git a/test/worker/support/inMemoryD1.ts b/test/worker/support/inMemoryD1.ts index 0c31a6c1..198e828d 100644 --- a/test/worker/support/inMemoryD1.ts +++ b/test/worker/support/inMemoryD1.ts @@ -26,6 +26,7 @@ export class FakeArchiveBucket { readonly contentTypes = new Map(); readonly putCalls: Array<{ key: string; bytes: Uint8Array }> = []; readonly headCalls: string[] = []; + readonly getCalls: string[] = []; readonly deleteCalls: string[] = []; async put(key: string, value: unknown, options?: { httpMetadata?: { contentType?: string } }): Promise { @@ -82,6 +83,7 @@ export class FakeArchiveBucket { } async get(key: string): Promise { + this.getCalls.push(key); const bytes = this.objects.get(key); if (!bytes) { return null; @@ -459,6 +461,32 @@ export class Statement { } async first(): Promise { + if ( + this.sql.includes("RETENTION_EXPORT_COMPLETED") + && this.sql.includes("entity_type = 'retention_export'") + && this.sql.includes("ORDER BY created_at DESC, id DESC") + ) { + const month = String(this.args[0]); + const row = this.db.audits + .filter( + (audit) => + audit.action === "RETENTION_EXPORT_COMPLETED" + && audit.entity_type === "retention_export" + && audit.entity_id === month + ) + .sort( + (left, right) => + String(right.created_at).localeCompare(String(left.created_at)) + || String(right.id).localeCompare(String(left.id)) + )[0]; + return row + ? { + id: String(row.id), + metadataJson: String(row.metadata_json), + createdAt: String(row.created_at) + } as T + : null; + } if ( this.sql.includes("INSERT INTO login_step_up_challenges") && this.sql.includes("RETURNING id") diff --git a/test/worker/workerFetch.retention-admin.test.ts b/test/worker/workerFetch.retention-admin.test.ts index 692d00ee..aecca299 100644 --- a/test/worker/workerFetch.retention-admin.test.ts +++ b/test/worker/workerFetch.retention-admin.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import worker from "../../src/worker/index"; -import { elSalvadorMonth } from "../../src/worker/services/retention"; +import { + RETENTION_CANONICAL_TABLES, + elSalvadorMonth, + type RetentionManifest +} from "../../src/worker/services/retention"; import { utf8Bytes } from "../../src/worker/utils/encoding"; import type { Env } from "../../src/worker/types"; import { env, FakeArchiveBucket, InMemoryD1 } from "./support/inMemoryD1"; @@ -112,20 +116,60 @@ describe("manual retention export endpoint", () => { }); describe("admin backups panel", () => { - function seedManifest(archive: FakeArchiveBucket, month: string, tables: Record): Promise { + function seedManifest( + archive: FakeArchiveBucket, + month: string, + tables: Record = {} + ): Promise { return (async () => { const prefix = `retention/${month.slice(0, 4)}/${month}`; - const manifestTables: Record = {}; - for (const [table, { rowCount, body }] of Object.entries(tables)) { + const runId = "11111111-1111-4111-8111-111111111111"; + const manifestTables: RetentionManifest["tables"] = {}; + for (const table of RETENTION_CANONICAL_TABLES) { + const { rowCount, body } = tables[table] ?? { rowCount: 0, body: "" }; const bytes = utf8Bytes(body); - await archive.put(`${prefix}/${table}.ndjson`, bytes); - manifestTables[table] = { rowCount, sha256: await sha256Hex(bytes) }; + const key = `${prefix}/runs/${runId}/${table}.ndjson`; + await archive.put(key, bytes); + manifestTables[table] = { key, rowCount, sha256: await sha256Hex(bytes) }; } - const manifest = { month, generatedAt: `${month}-28T09:00:00.000Z`, tables: manifestTables }; + const manifest: RetentionManifest = { + version: 2, + runId, + month, + generatedAt: `${month}-28T09:00:00.000Z`, + tables: manifestTables + }; await archive.put(`${prefix}/manifest.json`, utf8Bytes(JSON.stringify(manifest))); + return manifest; })(); } + async function seedCompletionAnchor( + db: InMemoryD1, + manifest: RetentionManifest, + kind: "new" | "legacy" = "new" + ): Promise { + const totalRows = Object.values(manifest.tables).reduce((sum, entry) => sum + entry.rowCount, 0); + const metadata = kind === "new" + ? { + month: manifest.month, + runId: manifest.runId, + generatedAt: manifest.generatedAt, + totalRows, + tables: manifest.tables, + manifestSha256: await sha256Hex(utf8Bytes(JSON.stringify(manifest))) + } + : { month: manifest.month, totalRows, tables: manifest.tables }; + db.audits.push({ + id: `audit_anchor_${kind}`, + action: "RETENTION_EXPORT_COMPLETED", + entity_type: "retention_export", + entity_id: manifest.month, + metadata_json: JSON.stringify(metadata), + created_at: "2026-07-01T09:00:04.000Z" + }); + } + it("lists archived, missing, and in-progress months newest-first with parsed manifest data", async () => { const db = new InMemoryD1(); db.sessionUser = { id: "user_admin", email: "admin@example.org", name: "Admin", role: "ADMIN" }; @@ -168,6 +212,43 @@ describe("admin backups panel", () => { await expect(response.json()).resolves.toEqual({ months: [] }); }); + it("does not list, resolve a table from, or ZIP a present-invalid manifest", async () => { + const db = new InMemoryD1(); + db.sessionUser = { id: "user_admin", email: "admin@example.org", name: "Admin", role: "ADMIN" }; + db.documents.push(testDocument({ id: "doc_invalid_manifest", created_at: "2026-04-10T12:00:00.000Z" })); + const archive = new FakeArchiveBucket(); + await seedManifest(archive, "2026-04", { dte_documents: { rowCount: 1, body: "must not escape\n" } }); + await archive.put("retention/2026/2026-04/manifest.json", utf8Bytes(JSON.stringify({ + version: 2, + runId: "11111111-1111-4111-8111-111111111111", + month: "2026-04", + generatedAt: "2026-05-01T09:00:00.000Z", + tables: {} + }))); + const workerEnv = env(db, { ARCHIVE: archive as unknown as R2Bucket }); + const headers = { Authorization: "Bearer test-token" }; + + const listResponse = await worker.fetch( + new Request("https://example.org/api/admin/backups", { headers }), + workerEnv + ); + const listPayload = (await listResponse.json()) as { months: Array<{ month: string; status: string }> }; + expect(listPayload.months.find((entry) => entry.month === "2026-04")).toMatchObject({ status: "faltante" }); + + const tableResponse = await worker.fetch( + new Request("https://example.org/api/admin/backups/2026-04/download?table=dte_documents", { headers }), + workerEnv + ); + expect(tableResponse.status).toBe(404); + + const zipResponse = await worker.fetch( + new Request("https://example.org/api/admin/backups/2026-04/download-all", { headers }), + workerEnv + ); + expect(zipResponse.status).toBe(404); + expect(db.audits.filter((row) => row.action === "RETENTION_DOWNLOADED")).toHaveLength(0); + }); + it("rejects a VIEWER with 403 and an unauthenticated caller with 401", async () => { const dbViewer = new InMemoryD1(); dbViewer.sessionUser = { id: "user_viewer", email: "viewer@example.org", name: "Viewer", role: "VIEWER" }; @@ -185,10 +266,11 @@ describe("admin backups panel", () => { const db = new InMemoryD1(); db.sessionUser = { id: "user_admin", email: "admin@example.org", name: "Admin", role: "ADMIN" }; const archive = new FakeArchiveBucket(); - await seedManifest(archive, "2026-04", { + const manifest = await seedManifest(archive, "2026-04", { dte_documents: { rowCount: 1, body: "row\n" }, audit_logs: { rowCount: 0, body: "" } }); + await seedCompletionAnchor(db, manifest); const response = await worker.fetch( new Request("https://example.org/api/admin/backups/2026-04/verify", { @@ -202,20 +284,159 @@ describe("admin backups panel", () => { const payload = (await response.json()) as { ok: boolean; files: Array<{ table: string; ok: boolean }> }; expect(payload.ok).toBe(true); expect(payload.files.every((file) => file.ok)).toBe(true); + expect(payload.files.map((file) => file.table)).toEqual(RETENTION_CANONICAL_TABLES); + expect(archive.getCalls).toEqual([ + "retention/2026/2026-04/manifest.json", + ...RETENTION_CANONICAL_TABLES.map((table) => manifest.tables[table].key) + ]); expect(db.audits).toContainEqual( expect.objectContaining({ action: "RETENTION_VERIFIED", entity_type: "retention_export", entity_id: "2026-04" }) ); }); + it("verifies a strict manifest against an exact legacy completion anchor", async () => { + const db = new InMemoryD1(); + db.sessionUser = { id: "user_admin", email: "admin@example.org", name: "Admin", role: "ADMIN" }; + const archive = new FakeArchiveBucket(); + const manifest = await seedManifest(archive, "2026-04", { + dte_documents: { rowCount: 1, body: "legacy anchored row\n" } + }); + await seedCompletionAnchor(db, manifest, "legacy"); + + const response = await worker.fetch( + new Request("https://example.org/api/admin/backups/2026-04/verify", { + method: "POST", + headers: { Authorization: "Bearer test-token" } + }), + env(db, { ARCHIVE: archive as unknown as R2Bucket }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ ok: true }); + expect(db.audits.filter((row) => row.action === "RETENTION_VERIFIED")).toHaveLength(1); + }); + + it.each([ + ["present invalid manifest", async (_db: InMemoryD1, archive: FakeArchiveBucket) => { + await seedManifest(archive, "2026-04"); + await archive.put("retention/2026/2026-04/manifest.json", utf8Bytes(JSON.stringify({ + version: 2, + runId: "11111111-1111-4111-8111-111111111111", + month: "2026-04", + generatedAt: "2026-05-01T09:00:00.000Z", + tables: {} + }))); + }, "manifest_invalid"], + ["missing D1 anchor", async (_db: InMemoryD1, archive: FakeArchiveBucket) => { + await seedManifest(archive, "2026-04"); + }, "anchor_missing"], + ["malformed latest D1 anchor", async (db: InMemoryD1, archive: FakeArchiveBucket) => { + const manifest = await seedManifest(archive, "2026-04"); + await seedCompletionAnchor(db, manifest); + db.audits.push({ + id: "audit_anchor_malformed_latest", + action: "RETENTION_EXPORT_COMPLETED", + entity_type: "retention_export", + entity_id: "2026-04", + metadata_json: "{", + created_at: "2026-07-01T09:00:05.000Z" + }); + }, "anchor_invalid"], + ["malformed new D1 anchor", async (db: InMemoryD1, archive: FakeArchiveBucket) => { + const manifest = await seedManifest(archive, "2026-04"); + await seedCompletionAnchor(db, manifest); + const anchor = db.audits.at(-1)!; + const metadata = JSON.parse(String(anchor.metadata_json)) as Record; + delete metadata.generatedAt; + anchor.metadata_json = JSON.stringify(metadata); + }, "anchor_invalid"], + ["malformed legacy D1 anchor", async (db: InMemoryD1, archive: FakeArchiveBucket) => { + const manifest = await seedManifest(archive, "2026-04"); + await seedCompletionAnchor(db, manifest, "legacy"); + const anchor = db.audits.at(-1)!; + const metadata = JSON.parse(String(anchor.metadata_json)) as { tables: RetentionManifest["tables"] }; + delete metadata.tables.audit_logs; + anchor.metadata_json = JSON.stringify(metadata); + }, "anchor_invalid"], + ["anchor run mismatch", async (db: InMemoryD1, archive: FakeArchiveBucket) => { + const manifest = await seedManifest(archive, "2026-04"); + await seedCompletionAnchor(db, manifest); + const anchor = db.audits.at(-1)!; + const metadata = JSON.parse(String(anchor.metadata_json)) as { runId: string }; + metadata.runId = "22222222-2222-4222-8222-222222222222"; + anchor.metadata_json = JSON.stringify(metadata); + }, "anchor_mismatch"], + ["anchor table mismatch", async (db: InMemoryD1, archive: FakeArchiveBucket) => { + const manifest = await seedManifest(archive, "2026-04"); + await seedCompletionAnchor(db, manifest); + const anchor = db.audits.at(-1)!; + const metadata = JSON.parse(String(anchor.metadata_json)) as { tables: RetentionManifest["tables"] }; + metadata.tables.audit_logs.rowCount = 1; + anchor.metadata_json = JSON.stringify(metadata); + }, "anchor_mismatch"], + ["anchor digest mismatch", async (db: InMemoryD1, archive: FakeArchiveBucket) => { + const manifest = await seedManifest(archive, "2026-04"); + await seedCompletionAnchor(db, manifest); + const anchor = db.audits.at(-1)!; + const metadata = JSON.parse(String(anchor.metadata_json)) as { manifestSha256: string }; + metadata.manifestSha256 = "b".repeat(64); + anchor.metadata_json = JSON.stringify(metadata); + }, "anchor_mismatch"], + ["forged manifest and matching forged body", async (db: InMemoryD1, archive: FakeArchiveBucket) => { + const manifest = await seedManifest(archive, "2026-04"); + await seedCompletionAnchor(db, manifest); + const forgedBody = utf8Bytes("forged but internally consistent\n"); + manifest.tables.audit_logs.rowCount = 1; + manifest.tables.audit_logs.sha256 = await sha256Hex(forgedBody); + await archive.put(manifest.tables.audit_logs.key, forgedBody); + await archive.put("retention/2026/2026-04/manifest.json", utf8Bytes(JSON.stringify(manifest))); + }, "anchor_mismatch"] + ])("fails closed before table-body reads for %s", async (_name, arrange, reason) => { + const db = new InMemoryD1(); + db.sessionUser = { id: "user_admin", email: "admin@example.org", name: "Admin", role: "ADMIN" }; + db.settings.push({ key: "alert_email", value: "owner@example.org" }); + const archive = new FakeArchiveBucket(); + await arrange(db, archive); + const sent: unknown[] = []; + + const response = await worker.fetch( + new Request("https://example.org/api/admin/backups/2026-04/verify", { + method: "POST", + headers: { Authorization: "Bearer test-token" } + }), + env(db, { + ARCHIVE: archive as unknown as R2Bucket, + MOCK_EXTERNAL_SERVICES: "false", + EMAIL_FROM: "alerts@example.org", + EMAIL: { + send: async (message: unknown) => { + sent.push(message); + return { messageId: "alert-anchor" }; + } + } as unknown as Env["EMAIL"] + }) + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ ok: false, reason, files: [] }); + expect(archive.getCalls).toEqual(["retention/2026/2026-04/manifest.json"]); + expect(db.audits.filter((row) => row.action === "RETENTION_VERIFY_FAILED")).toHaveLength(1); + expect(db.audits.filter((row) => row.action === "RETENTION_VERIFIED")).toHaveLength(0); + expect(sent).toHaveLength(1); + const failed = db.audits.find((row) => row.action === "RETENTION_VERIFY_FAILED")!; + expect(JSON.parse(String(failed.metadata_json))).toMatchObject({ month: "2026-04", reason }); + }); + it("reports a mismatch, audits RETENTION_VERIFY_FAILED, and sends an operational alert when an object is corrupted", async () => { const db = new InMemoryD1(); db.sessionUser = { id: "user_admin", email: "admin@example.org", name: "Admin", role: "ADMIN" }; db.settings.push({ key: "alert_email", value: "owner@example.org" }); const sent: unknown[] = []; const archive = new FakeArchiveBucket(); - await seedManifest(archive, "2026-04", { dte_documents: { rowCount: 1, body: "row\n" } }); + const manifest = await seedManifest(archive, "2026-04", { dte_documents: { rowCount: 1, body: "row\n" } }); + await seedCompletionAnchor(db, manifest); // Corrupt the stored object's bytes so its SHA-256 no longer matches the manifest. - await archive.put("retention/2026/2026-04/dte_documents.ndjson", utf8Bytes("tampered\n")); + await archive.put(manifest.tables.dte_documents.key, utf8Bytes("tampered\n")); const response = await worker.fetch( new Request("https://example.org/api/admin/backups/2026-04/verify", { @@ -333,11 +554,11 @@ describe("admin backups panel", () => { const archive = new FakeArchiveBucket(); // One object claims a size beyond the 32 MiB budget; its body is tiny so the test // itself stays cheap — the guard must trust the R2-reported size, not read first. - await seedManifest(archive, "2026-04", { + const manifest = await seedManifest(archive, "2026-04", { dte_documents: { rowCount: 2, body: "line1\nline2\n" }, audit_logs: { rowCount: 1, body: "audit\n" } }); - archive.sizeOverrides.set("retention/2026/2026-04/dte_documents.ndjson", 32 * 1024 * 1024 + 1); + archive.sizeOverrides.set(manifest.tables.dte_documents.key, 32 * 1024 * 1024 + 1); const response = await worker.fetch( new Request("https://example.org/api/admin/backups/2026-04/download-all", { From 827a0c6a3f2175757980d38aa22098c541e70f60 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:36:12 -0600 Subject: [PATCH 17/22] fix(docs): align retention digest order --- docs/retention-restore.md | 4 ++-- test/worker/retention.test.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/docs/retention-restore.md b/docs/retention-restore.md index f0e443af..0c2a638f 100644 --- a/docs/retention-restore.md +++ b/docs/retention-restore.md @@ -13,16 +13,16 @@ multi-year retention tax law requires survives independently of D1. retention///manifest.json retention///runs//dte_documents.ndjson retention///runs//fiscal_corrections.ndjson -retention///runs//fiscal_corrections_latest.ndjson retention///runs//donation_intents.ndjson retention///runs//dte_events.ndjson retention///runs//email_deliveries.ndjson retention///runs//audit_logs.ndjson +retention///runs//fiscal_corrections_latest.ndjson retention///runs//wompi_events.ndjson -retention///runs//document_sequences.ndjson retention///runs//contingency_periods.ndjson retention///runs//contingency_batches.ndjson retention///runs//contingency_batch_lines.ndjson +retention///runs//document_sequences.ndjson retention///runs//stripe_checkout_sessions.ndjson retention///runs//stripe_webhook_events.ndjson retention///runs//stripe_gifts.ndjson diff --git a/test/worker/retention.test.ts b/test/worker/retention.test.ts index e3ce4248..f1fa81eb 100644 --- a/test/worker/retention.test.ts +++ b/test/worker/retention.test.ts @@ -2213,6 +2213,26 @@ describe("previousElSalvadorMonth (UTC/El Salvador day seam)", () => { }); describe("retention restore guidance", () => { + it("keeps the documented object-layout digest order aligned with the canonical manifest", () => { + const guidance = readFileSync( + resolve(import.meta.dirname, "../../docs/retention-restore.md"), + "utf8" + ); + const layoutHeading = guidance.indexOf("## Object layout"); + const layoutFenceStart = guidance.indexOf("```", layoutHeading); + const layoutFenceEnd = guidance.indexOf("```", layoutFenceStart + 3); + const documentedTables = guidance + .slice(layoutFenceStart + 3, layoutFenceEnd) + .split("\n") + .map((line) => line.match(/\/([^/]+)\.ndjson$/)?.[1] ?? null) + .filter((table): table is string => table !== null); + + expect(layoutHeading).toBeGreaterThanOrEqual(0); + expect(layoutFenceStart).toBeGreaterThan(layoutHeading); + expect(layoutFenceEnd).toBeGreaterThan(layoutFenceStart); + expect(documentedTables).toEqual([...RETENTION_CANONICAL_TABLES]); + }); + it("keeps the Wrangler restore file free of nested transaction statements", () => { const protocol = RETENTION_FOREIGN_KEY_PROTOCOL as unknown as { wranglerFile: { From 70731d62cef5b39f5f4fce8ad353bf6b9c966075 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:50:02 -0600 Subject: [PATCH 18/22] fix(audit): enforce account audit audience before query --- src/worker/index.ts | 7 +- src/worker/services/auditProjection.ts | 13 +- ...h.audit-context-branding-analytics.test.ts | 116 +++++++++++++----- ...rkerFetch.contingency-invalidation.test.ts | 80 +++++++++++- 4 files changed, 182 insertions(+), 34 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 4db64ed8..300b79bd 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -117,7 +117,7 @@ import { MhClient, MhPreDispatchError } from "./services/mhClient"; import { IssuancePipeline } from "./services/pipeline"; import { loadPdfBrandingLogo, renderDtePdf } from "./services/pdf"; import { auditContextFrom } from "./services/requestContext"; -import { projectAuditRows } from "./services/auditProjection"; +import { hasAccountAuditAudience, projectAuditRows, projectContingencyEvents } from "./services/auditProjection"; import { BackupArchiveTooLargeError, BACKUP_MONTH_DOWNLOAD_MAX_BYTES, collectBackupMonthObjects, manifestedBackupTableKey, listBackupMonths, verifyBackupMonth } from "./services/backups"; import { zipStored } from "./utils/zip"; import { previousElSalvadorMonth, retentionManifestKey, runRetentionExport } from "./services/retention"; @@ -3151,6 +3151,9 @@ async function handleAudit(ctx: ApiRouteContext): Promise { const actor = ctx.actor!; const entityType = ctx.url.searchParams.get("entityType"); const entityId = ctx.url.searchParams.get("entityId"); + if (entityType === "user" && !hasAccountAuditAudience(actor.role)) { + return jsonResponse({ error: "account_audit_forbidden" }, { status: 403 }); + } if (entityType && entityId) { // Entity-scoped history keeps its original (uncapped-page) shape. return jsonResponse({ audit: await listAuditForUser(ctx.repo, actor, entityType, entityId), nextCursor: null }); @@ -4095,7 +4098,7 @@ async function contingencyState(repo: Repository, user: AuthUser): Promise periods.filter((period) => period.status === status).length; diff --git a/src/worker/services/auditProjection.ts b/src/worker/services/auditProjection.ts index dc6e711a..2e88f141 100644 --- a/src/worker/services/auditProjection.ts +++ b/src/worker/services/auditProjection.ts @@ -14,8 +14,12 @@ const SAFE_ACTION_SUMMARIES: Record = { USER_PASSWORD_RESET: "Contraseña de usuario restablecida" }; +export function hasAccountAuditAudience(role: Role): boolean { + return role === "ADMIN" || role === "OWNER"; +} + export function projectAuditRows(rows: Array>, role: Role): Array> { - if (role === "ADMIN" || role === "OWNER") { + if (hasAccountAuditAudience(role)) { return rows; } return rows.map((row) => ({ @@ -36,3 +40,10 @@ export function projectAuditRows(rows: Array>, role: Rol created_at: row.created_at })); } + +export function projectContingencyEvents(rows: Array>, role: Role): Array> { + if (hasAccountAuditAudience(role)) { + return rows; + } + return rows.map(({ created_by: _createdBy, ...event }) => event); +} diff --git a/test/worker/workerFetch.audit-context-branding-analytics.test.ts b/test/worker/workerFetch.audit-context-branding-analytics.test.ts index 3f784f9c..7fc7d90c 100644 --- a/test/worker/workerFetch.audit-context-branding-analytics.test.ts +++ b/test/worker/workerFetch.audit-context-branding-analytics.test.ts @@ -266,9 +266,35 @@ describe("audit actor context", () => { expect(systemRow?.actor_ip ?? null).toBeNull(); }); - it("applies the lower-role audit projection on scoped, document-detail, and contingency responses", async () => { + it.each(["VIEWER", "OPERATOR"] as const)("rejects every user-scoped audit filter before preparing audit SQL for %s", async (role) => { + const userScopeQueries = [ + "entityType=user&entityId=user_admin", + "entityType=user&entityId=arbitrary-account-id", + "entityType=user", + "entityType=user&entityId=", + "entityType=%75ser&entityId=user_admin", + "entityType=user&entityType=dte_document&entityId=user_admin" + ]; + + for (const query of userScopeQueries) { + const db = authedDb(role, new InMemoryD1()); + db.preparedSql.length = 0; + + const response = await worker.fetch( + new Request(`https://example.org/api/audit?${query}`, { + headers: { Authorization: "Bearer test-token" } + }), + env(db) + ); + + expect(response.status, query).toBe(403); + expect(db.preparedSql.some((sql) => sql.includes("FROM audit_logs")), query).toBe(false); + } + }); + + it.each(["VIEWER", "OPERATOR"] as const)("keeps non-user audit scopes available with the lower-role projection for %s", async (role) => { const db = new InMemoryD1(); - db.sessionUser = { id: "user_viewer", email: "viewer@example.org", name: "Viewer", role: "VIEWER" }; + db.sessionUser = { id: `user_${role.toLowerCase()}`, email: `${role.toLowerCase()}@example.org`, name: role, role }; db.users.push({ id: "user_admin", email: "admin@example.org", @@ -280,17 +306,6 @@ describe("audit actor context", () => { created_at: "2026-06-26T01:46:47.015Z", updated_at: "2026-06-26T01:46:47.015Z" }); - db.documents.push(testDocument({ id: "doc_projection" })); - db.contingencies.push({ - id: "cont_projection", - environment: "00", - status: "OPEN", - reason: "MH TEST no disponible", - tipo_contingencia: 2, - started_at: "2026-06-26T01:00:00.000Z", - ended_at: null, - created_at: "2026-06-26T01:00:00.000Z" - }); const sensitiveContext = JSON.stringify({ city: "San Salvador", country: "SV" }); db.audits.push( { @@ -335,37 +350,78 @@ describe("audit actor context", () => { ); const headers = { Authorization: "Bearer test-token" }; - const [scopedResponse, documentResponse, contingencyResponse] = await Promise.all([ + const [userScopedResponse, documentResponse, contingencyResponse] = await Promise.all([ worker.fetch( new Request("https://example.org/api/audit?entityType=user&entityId=user_operator", { headers }), env(db) ), - worker.fetch(new Request("https://example.org/api/documents/doc_projection", { headers }), env(db)), - worker.fetch(new Request("https://example.org/api/contingency", { headers }), env(db)) + worker.fetch( + new Request("https://example.org/api/audit?entityType=dte_document&entityId=doc_projection", { headers }), + env(db) + ), + worker.fetch( + new Request("https://example.org/api/audit?entityType=contingency_period&entityId=cont_projection", { headers }), + env(db) + ) ]); - expect(scopedResponse.status).toBe(200); + expect(userScopedResponse.status).toBe(403); expect(documentResponse.status).toBe(200); expect(contingencyResponse.status).toBe(200); - const scoped = (await scopedResponse.json()) as { audit: Array> }; const document = (await documentResponse.json()) as { audit: Array> }; - const contingency = (await contingencyResponse.json()) as { contingency: { audit: Array> } }; + const contingency = (await contingencyResponse.json()) as { audit: Array> }; - expect(scoped.audit[0]).toMatchObject({ - actor_id: null, - actor_name: null, - actor_email: null, - actor_ip: null, - actor_context: null, - entity_id: null, - summary: "Usuario actualizado", - metadata_json: "{}" - }); - for (const row of [document.audit[0], contingency.contingency.audit[0]]) { + for (const row of [document.audit[0], contingency.audit[0]]) { expect(row).toMatchObject({ actor_email: null, actor_ip: null, actor_context: null }); } }); + it.each(["ADMIN", "OWNER"] as const)("retains account-scoped audit identity and context for %s", async (role) => { + const db = authedDb(role, new InMemoryD1()); + db.users.push({ + id: "user_admin", + email: "admin@example.org", + name: "Ada Admin", + role: "ADMIN", + password_hash: "h", + password_salt: "s", + disabled_at: "", + created_at: "2026-06-26T01:46:47.015Z", + updated_at: "2026-06-26T01:46:47.015Z" + }); + db.audits.push({ + id: "audit_user_scoped_identity", + actor_type: "USER", + actor_id: "user_admin", + action: "USER_UPDATED", + entity_type: "user", + entity_id: "user_operator", + summary: "Usuario actualizado", + metadata_json: "{}", + actor_ip: "190.86.1.2", + actor_context: JSON.stringify({ city: "San Salvador", country: "SV" }), + created_at: "2026-06-26T01:46:47.015Z" + }); + + const response = await worker.fetch( + new Request("https://example.org/api/audit?entityType=user&entityId=user_operator", { + headers: { Authorization: "Bearer test-token" } + }), + env(db) + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { audit: Array> }; + expect(body.audit[0]).toMatchObject({ + actor_id: "user_admin", + actor_name: "Ada Admin", + actor_email: "admin@example.org", + actor_ip: "190.86.1.2", + entity_id: "user_operator" + }); + expect(JSON.parse(String(body.audit[0]?.actor_context))).toMatchObject({ city: "San Salvador" }); + }); + it("returns sensitive audit actor fields for ADMIN users", async () => { const db = new InMemoryD1(); db.sessionUser = { id: "user_admin_session", email: "admin-session@example.org", name: "Admin Session", role: "ADMIN" }; diff --git a/test/worker/workerFetch.contingency-invalidation.test.ts b/test/worker/workerFetch.contingency-invalidation.test.ts index 41f7b478..077a498c 100644 --- a/test/worker/workerFetch.contingency-invalidation.test.ts +++ b/test/worker/workerFetch.contingency-invalidation.test.ts @@ -5,7 +5,7 @@ import { utf8Bytes } from "../../src/worker/utils/encoding"; import { makeDocument as testDocument } from "./fixtures"; import { emisorConfig, generatedCertificateXml } from "./support/dteFixtures"; import { TEST_RESEND_REQUEST_ID } from "./support/documentDeliveryFixtures"; -import { env, InMemoryD1 } from "./support/inMemoryD1"; +import { authedDb, env, InMemoryD1 } from "./support/inMemoryD1"; import { installWorkerFetchGlobals } from "./support/workerFetchGlobals"; import { jsonResponse, sha256Hex } from "./support/workerFetchHelpers"; @@ -203,6 +203,84 @@ describe("contingency history (read-only)", () => { } }); }); + + it.each(["VIEWER", "OPERATOR"] as const)("omits a contingency event creator before a %s can reuse it for account audit", async (role) => { + const seededAccountId = "user_admin"; + const db = authedDb(role, new InMemoryD1()); + db.dteEvents.push({ + id: "event_contingency_creator", + document_id: "doc_contingency_creator", + event_type: "CONTINGENCIA", + environment: "00", + codigo_generacion: "CONTINGENCY-CODE", + status: "ACCEPTED", + sello_recibido: "CONTINGENCY-SEAL", + mh_estado: "PROCESADO", + mh_observaciones_json: "[]", + legal_deadline_at: null, + created_by: seededAccountId, + created_at: "2026-06-26T01:00:00.000Z", + accepted_at: "2026-06-26T01:01:00.000Z" + }); + + const contingencyResponse = await worker.fetch( + new Request("https://example.org/api/contingency", { + headers: { Authorization: "Bearer test-token" } + }), + env(db) + ); + + expect(contingencyResponse.status).toBe(200); + const contingency = (await contingencyResponse.json()) as { + contingency: { events: Array> }; + }; + const event = contingency.contingency.events[0]!; + const leakedAccountId = String(event.created_by ?? seededAccountId); + + db.preparedSql.length = 0; + const auditResponse = await worker.fetch( + new Request(`https://example.org/api/audit?entityType=user&entityId=${encodeURIComponent(leakedAccountId)}`, { + headers: { Authorization: "Bearer test-token" } + }), + env(db) + ); + + expect(Object.hasOwn(event, "created_by")).toBe(false); + expect(event).not.toHaveProperty("created_by"); + expect(auditResponse.status).toBe(403); + expect(db.preparedSql.some((sql) => sql.includes("FROM audit_logs"))).toBe(false); + }); + + it.each(["ADMIN", "OWNER"] as const)("retains a contingency event creator for %s", async (role) => { + const db = authedDb(role, new InMemoryD1()); + db.dteEvents.push({ + id: "event_contingency_creator", + document_id: "doc_contingency_creator", + event_type: "CONTINGENCIA", + environment: "00", + codigo_generacion: "CONTINGENCY-CODE", + status: "ACCEPTED", + sello_recibido: "CONTINGENCY-SEAL", + mh_estado: "PROCESADO", + mh_observaciones_json: "[]", + legal_deadline_at: null, + created_by: "user_admin", + created_at: "2026-06-26T01:00:00.000Z", + accepted_at: "2026-06-26T01:01:00.000Z" + }); + + const response = await worker.fetch( + new Request("https://example.org/api/contingency", { + headers: { Authorization: "Bearer test-token" } + }), + env(db) + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { contingency: { events: Array> } }; + expect(Object.hasOwn(body.contingency.events[0]!, "created_by")).toBe(true); + expect(body.contingency.events[0]).toHaveProperty("created_by", "user_admin"); + }); }); describe("document invalidation", () => { From c8f209240d444e38e783515cb395806d81d69614 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:03:42 -0600 Subject: [PATCH 19/22] fix(worker): emit HSTS on production responses --- public/_headers | 1 + src/worker/index.ts | 87 ++++++------ test/worker/workerFetch.infra.test.ts | 190 ++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 38 deletions(-) diff --git a/public/_headers b/public/_headers index 416dbb85..780ba600 100644 --- a/public/_headers +++ b/public/_headers @@ -1,4 +1,5 @@ /* Content-Security-Policy: frame-ancestors 'none' + Strict-Transport-Security: max-age=31536000 X-Frame-Options: DENY Referrer-Policy: no-referrer diff --git a/src/worker/index.ts b/src/worker/index.ts index 300b79bd..7add5856 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -199,6 +199,7 @@ const STRIPE_PORTAL_IP_LIMIT = 10; const STRIPE_PORTAL_CUSTOMER_LIMIT = 5; const STRIPE_PORTAL_AGGREGATE_LIMIT = 100; const STRIPE_PORTAL_PATH = "/api/donations/stripe/portal"; +const STRICT_TRANSPORT_SECURITY = "max-age=31536000"; // Public donation endpoints parse untrusted JSON before validation and rate-limit // admission. Cap bodies at 16 KiB (normal payloads are a few hundred bytes) so an @@ -455,47 +456,57 @@ function emergencyDonationShutdownResponse(request: Request, env: Env, url: URL) }); } +async function handleFetch(request: Request, env: Env, ctx?: ExecutionContext): Promise { + try { + const url = new URL(request.url); + const shutdownResponse = emergencyDonationShutdownResponse(request, env, url); + if (shutdownResponse) { + return shutdownResponse; + } + if (url.pathname.startsWith("/api/")) { + return await handleApi(request, env, url, ctx); + } + if (url.pathname === "/webhooks/wompi") { + return await handleWompiWebhook(request, env); + } + if (url.pathname === "/webhooks/stripe") { + return await handleStripeWebhook(request, env, ctx); + } + const documentRedirect = redirectToCanonicalDocument(env, url); + if (documentRedirect) { + return documentRedirect; + } + return documentResponseWithSecurityHeaders(await env.ASSETS.fetch(request)); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return jsonResponse({ error: "request_body_too_large", message: "La solicitud es demasiado grande." }, { status: 413 }); + } + if (error instanceof InvalidJsonBodyError) { + return jsonResponse({ error: "invalid_json_body", message: "La solicitud no contiene JSON válido." }, { status: 400 }); + } + if (error instanceof AuthError) { + return jsonResponse({ error: "auth_error", message: error.message }, { status: error.status }); + } + if (error instanceof EnvironmentNotAllowedError) { + return jsonResponse({ error: error.code, message: error.message }, { status: 409 }); + } + if (error instanceof PaymentCollectionDisabledError) { + return jsonResponse({ error: error.code, message: error.message }, { status: 503 }); + } + logWorkerError(env, "unhandled_worker_request_error", error); + return jsonResponse({ error: "internal_error", message: "Ocurrió un error interno." }, { status: 500 }); + } +} + export default { async fetch(request: Request, env: Env, ctx?: ExecutionContext): Promise { - try { - const url = new URL(request.url); - const shutdownResponse = emergencyDonationShutdownResponse(request, env, url); - if (shutdownResponse) { - return shutdownResponse; - } - if (url.pathname.startsWith("/api/")) { - return await handleApi(request, env, url, ctx); - } - if (url.pathname === "/webhooks/wompi") { - return await handleWompiWebhook(request, env); - } - if (url.pathname === "/webhooks/stripe") { - return await handleStripeWebhook(request, env, ctx); - } - const documentRedirect = redirectToCanonicalDocument(env, url); - if (documentRedirect) { - return documentRedirect; - } - return documentResponseWithSecurityHeaders(await env.ASSETS.fetch(request)); - } catch (error) { - if (error instanceof RequestBodyTooLargeError) { - return jsonResponse({ error: "request_body_too_large", message: "La solicitud es demasiado grande." }, { status: 413 }); - } - if (error instanceof InvalidJsonBodyError) { - return jsonResponse({ error: "invalid_json_body", message: "La solicitud no contiene JSON válido." }, { status: 400 }); - } - if (error instanceof AuthError) { - return jsonResponse({ error: "auth_error", message: error.message }, { status: error.status }); - } - if (error instanceof EnvironmentNotAllowedError) { - return jsonResponse({ error: error.code, message: error.message }, { status: 409 }); - } - if (error instanceof PaymentCollectionDisabledError) { - return jsonResponse({ error: error.code, message: error.message }, { status: 503 }); - } - logWorkerError(env, "unhandled_worker_request_error", error); - return jsonResponse({ error: "internal_error", message: "Ocurrió un error interno." }, { status: 500 }); + const response = await handleFetch(request, env, ctx); + if (env.APP_ENV !== "production") { + return response; } + const wrappedResponse = new Response(response.body, response); + wrappedResponse.headers.set("Strict-Transport-Security", STRICT_TRANSPORT_SECURITY); + return wrappedResponse; }, async queue(batch: MessageBatch, env: Env): Promise { diff --git a/test/worker/workerFetch.infra.test.ts b/test/worker/workerFetch.infra.test.ts index ee0e4a52..bb135b8f 100644 --- a/test/worker/workerFetch.infra.test.ts +++ b/test/worker/workerFetch.infra.test.ts @@ -1,12 +1,202 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, expect, it, vi } from "vitest"; import worker from "../../src/worker/index"; import { IssuancePipeline } from "../../src/worker/services/pipeline"; import type { Env, IssuanceMessage } from "../../src/worker/types"; +import { utf8Bytes } from "../../src/worker/utils/encoding"; import { env, InMemoryD1 } from "./support/inMemoryD1"; import { installWorkerFetchGlobals } from "./support/workerFetchGlobals"; +import { sha256Hex } from "./support/workerFetchHelpers"; installWorkerFetchGlobals(); +const CONSERVATIVE_HSTS = "max-age=31536000"; + +function expectConservativeHsts(response: Response): void { + const value = response.headers.get("Strict-Transport-Security"); + expect(value).toBe(CONSERVATIVE_HSTS); + expect(value).not.toMatch(/\b(?:includeSubDomains|preload)\b/i); +} + +describe("production HSTS policy", () => { + it("adds the conservative policy to production health JSON only", async () => { + const productionResponse = await worker.fetch( + new Request("https://example.org/api/health"), + env(new InMemoryD1(), { APP_ENV: "production" }) + ); + const stagingResponse = await worker.fetch( + new Request("https://example.org/api/health"), + env(new InMemoryD1(), { APP_ENV: "staging" }) + ); + + expect(productionResponse.status).toBe(200); + expectConservativeHsts(productionResponse); + await expect(productionResponse.json()).resolves.toMatchObject({ ok: true, appEnv: "production" }); + expect(stagingResponse.status).toBe(200); + expect(stagingResponse.headers.has("Strict-Transport-Security")).toBe(false); + await expect(stagingResponse.json()).resolves.toMatchObject({ ok: true, appEnv: "staging" }); + }); + + it("wraps streamed production HTML without consuming it or changing response metadata", async () => { + const html = "DiezmosSV"; + let pullCount = 0; + const body = new ReadableStream({ + pull(controller) { + pullCount += 1; + controller.enqueue(new TextEncoder().encode(html)); + controller.close(); + } + }, { highWaterMark: 0 }); + const assetResponse = new Response(body, { + status: 202, + statusText: "Asset response", + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "public, max-age=60", + ETag: '"asset-v2"', + "Set-Cookie": "asset_cookie=1; Path=/; Secure", + "Content-Security-Policy": "default-src 'self'; script-src 'self'", + "Strict-Transport-Security": "max-age=60; includeSubDomains; preload" + } + }); + + const response = await worker.fetch( + new Request("https://example.org/admin"), + env(new InMemoryD1(), { + APP_ENV: "production", + ASSETS: { fetch: () => Promise.resolve(assetResponse) } as unknown as Fetcher + }) + ); + + expect(response.status).toBe(202); + expect(response.statusText).toBe("Asset response"); + expect(response.bodyUsed).toBe(false); + expect(pullCount).toBe(0); + expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=60"); + expect(response.headers.get("ETag")).toBe('"asset-v2"'); + expect(response.headers.get("Set-Cookie")).toBe("asset_cookie=1; Path=/; Secure"); + expect(response.headers.get("Content-Security-Policy")).toContain("default-src 'self'"); + expect(response.headers.get("Content-Security-Policy")).toContain("frame-ancestors 'none'"); + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); + expect(response.headers.get("Referrer-Policy")).toBe("no-referrer"); + expectConservativeHsts(response); + await expect(response.text()).resolves.toBe(html); + expect(pullCount).toBe(1); + }); + + it("preserves a production document redirect status and Location", async () => { + const response = await worker.fetch( + new Request("https://example.org/documents?stale=1"), + env(new InMemoryD1(), { + APP_ENV: "production", + APP_ORIGIN: "https://donations.example.invalid" + }) + ); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("https://donations.example.invalid/"); + expectConservativeHsts(response); + }); + + it("adds the policy to an invalid production Wompi webhook response", async () => { + const response = await worker.fetch( + new Request("https://example.org/webhooks/wompi", { method: "POST", body: "{}" }), + env(new InMemoryD1(), { + APP_ENV: "production", + WOMPI_API_SECRET: "test-wompi-secret" + }) + ); + + expect(response.status).toBe(401); + expectConservativeHsts(response); + await expect(response.json()).resolves.toEqual({ error: "invalid_wompi_hash" }); + }); + + it("adds the policy to caught production API and asset failures", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const apiResponse = await worker.fetch( + new Request("https://example.org/api/auth/bootstrap-status"), + env({ + prepare: () => { + throw new Error("api failure"); + } + } as unknown as InMemoryD1, { APP_ENV: "production" }) + ); + const assetResponse = await worker.fetch( + new Request("https://example.org/admin"), + env(new InMemoryD1(), { + APP_ENV: "production", + ASSETS: { + fetch: () => Promise.reject(new Error("asset failure")) + } as unknown as Fetcher + }) + ); + + for (const response of [apiResponse, assetResponse]) { + expect(response.status).toBe(500); + expectConservativeHsts(response); + await expect(response.json()).resolves.toEqual({ + error: "internal_error", + message: "Ocurrió un error interno." + }); + } + expect(errorSpy).toHaveBeenCalledTimes(2); + }); + + it("preserves the null body on an authenticated production logout", async () => { + const db = new InMemoryD1(); + const rawToken = "production-logout-token"; + db.users.push({ + id: "user_admin", + email: "admin@example.org", + name: "Admin", + role: "ADMIN", + password_hash: "hash", + password_salt: "salt", + disabled_at: null + }); + db.sessions.push({ + id: "session_logout_hsts", + user_id: "user_admin", + token_hash: await sha256Hex(utf8Bytes(rawToken)), + expires_at: "2099-01-01T00:00:00.000Z", + created_at: "2026-08-23T12:00:00.000Z", + revoked_at: null + }); + + const response = await worker.fetch( + new Request("https://example.org/api/auth/logout", { + method: "POST", + headers: { Authorization: `Bearer ${rawToken}` } + }), + env(db, { APP_ENV: "production" }) + ); + + expect(response.status).toBe(204); + expect(response.body).toBeNull(); + expectConservativeHsts(response); + await expect(response.text()).resolves.toBe(""); + }); + + it("declares the exact conservative policy in the global static-asset block", () => { + const lines = readFileSync(resolve(import.meta.dirname, "../../public/_headers"), "utf8").split(/\r?\n/); + const blockStart = lines.findIndex((line) => line.trim() === "/*"); + expect(blockStart).toBeGreaterThanOrEqual(0); + + const globalHeaderLines: string[] = []; + for (const line of lines.slice(blockStart + 1)) { + if (line.trim() && !/^\s/.test(line)) break; + if (line.trim()) globalHeaderLines.push(line.trim()); + } + const hstsLines = globalHeaderLines.filter((line) => /^Strict-Transport-Security:/i.test(line)); + + expect(hstsLines).toEqual([`Strict-Transport-Security: ${CONSERVATIVE_HSTS}`]); + expect(hstsLines[0]).not.toMatch(/\b(?:includeSubDomains|preload)\b/i); + }); +}); + describe("Worker fetch error handling", () => { it("converts async API auth errors into JSON responses", async () => { const response = await worker.fetch(new Request("https://example.org/api/documents"), { From 1230716783a1cf81684d676c217b52c8e04e3fa5 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Mon, 24 Aug 2026 01:10:24 -0600 Subject: [PATCH 20/22] test(worker): prove repeated cookie preservation --- test/worker/workerFetch.infra.test.ts | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/test/worker/workerFetch.infra.test.ts b/test/worker/workerFetch.infra.test.ts index bb135b8f..7c3d9c19 100644 --- a/test/worker/workerFetch.infra.test.ts +++ b/test/worker/workerFetch.infra.test.ts @@ -48,17 +48,19 @@ describe("production HSTS policy", () => { controller.close(); } }, { highWaterMark: 0 }); + const assetHeaders = new Headers({ + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "public, max-age=60", + ETag: '"asset-v2"', + "Content-Security-Policy": "default-src 'self'; script-src 'self'", + "Strict-Transport-Security": "max-age=60; includeSubDomains; preload" + }); + assetHeaders.append("Set-Cookie", "asset_cookie=1; Path=/; Secure"); + assetHeaders.append("Set-Cookie", "session_cookie=2; Path=/admin; HttpOnly; Secure"); const assetResponse = new Response(body, { status: 202, statusText: "Asset response", - headers: { - "Content-Type": "text/html; charset=utf-8", - "Cache-Control": "public, max-age=60", - ETag: '"asset-v2"', - "Set-Cookie": "asset_cookie=1; Path=/; Secure", - "Content-Security-Policy": "default-src 'self'; script-src 'self'", - "Strict-Transport-Security": "max-age=60; includeSubDomains; preload" - } + headers: assetHeaders }); const response = await worker.fetch( @@ -76,7 +78,10 @@ describe("production HSTS policy", () => { expect(response.headers.get("Content-Type")).toBe("text/html; charset=utf-8"); expect(response.headers.get("Cache-Control")).toBe("public, max-age=60"); expect(response.headers.get("ETag")).toBe('"asset-v2"'); - expect(response.headers.get("Set-Cookie")).toBe("asset_cookie=1; Path=/; Secure"); + expect(response.headers.getSetCookie()).toEqual([ + "asset_cookie=1; Path=/; Secure", + "session_cookie=2; Path=/admin; HttpOnly; Secure" + ]); expect(response.headers.get("Content-Security-Policy")).toContain("default-src 'self'"); expect(response.headers.get("Content-Security-Policy")).toContain("frame-ancestors 'none'"); expect(response.headers.get("X-Frame-Options")).toBe("DENY"); From 8e1a3274e9c44537e289156f524d58401bd38ec2 Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:00:33 -0600 Subject: [PATCH 21/22] fix(worker): harden MH and Stripe cleanup boundaries --- README.es.md | 2 +- README.md | 2 +- src/worker/config.ts | 16 +++ src/worker/index.ts | 22 ++- src/worker/services/environmentPolicy.ts | 11 +- src/worker/services/mhClient.ts | 17 ++- src/worker/services/observability.ts | 2 + test/worker/config.test.ts | 35 ++++- test/worker/mhClient.test.ts | 170 ++++++++++++++++++++++- test/worker/pipeline.issuance.test.ts | 71 +++++++++- test/worker/stripeRoutes.test.ts | 137 +++++++++++++++--- test/worker/wompiApi.test.ts | 23 +++ 12 files changed, 477 insertions(+), 31 deletions(-) diff --git a/README.es.md b/README.es.md index 3cb6103b..8204c9f0 100644 --- a/README.es.md +++ b/README.es.md @@ -607,7 +607,7 @@ configuración privada seleccionada y se duplican por ambiente de Wrangler: | `ARCHIVE` (binding) | Binding del bucket de R2 para la exportación mensual de retención legal y los objetos del logo de marca blanca. La configuración de ejemplo versionada nombra `diezmossv-local-archive-example`, `diezmossv-staging-archive-example` y `diezmossv-production-archive-example`; los nombres reales de bucket pertenecen únicamente a la configuración privada seleccionada. | | `EMAIL_ARBITRARY_RECIPIENTS` | Marcador opcional `"true"` que se define después de confirmar que Cloudflare Email Sending puede alcanzar direcciones externas de donantes. El ejemplo versionado ya lo define para `staging`; local y producción lo dejan sin definir. | | `DONATION_INTAKE_DISABLED` | Interruptor de emergencia para nueva recepción pública. Cuando vale exactamente `"true"`, las mutaciones de intentos Wompi y `POST /api/donations/stripe/checkout` responden `503 donation_intake_disabled`; `/`, `/donar` y `/donar/gracias` sirven un documento vacío y cerrado. La página de resultado de Stripe, lecturas de estado, webhook, recibos y Billing Portal siguen disponibles para no dejar varado a un donante existente o mensual. El webhook de Wompi, la tubería de emisión y el panel de administración también siguen funcionando. El ejemplo versionado lo fija en `"true"` para `production`; sin definir o con cualquier otro valor, la recepción queda abierta. | -| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | Endpoints de MH disponibles solo para el carril de credenciales del despliegue. `MH_AUTH_URL_TEST_FALLBACK` es el respaldo acotado de autenticación central para cuentas TEST tras el código 106 de MH; no es una capacidad de transmisión en PROD. | +| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | Endpoints de MH disponibles solo para el carril de credenciales del despliegue. `MH_AUTH_URL_TEST_FALLBACK` puede estar ausente/vacío, ser la URL oficial exacta de autenticación TEST (sin efecto), o la URL exacta de autenticación central `https://api.dtes.mh.gob.sv/seguridad/auth` para cuentas TEST tras el código 106 de MH. Cualquier otro valor se rechaza antes de la recepción por Wompi y antes de enviar credenciales al respaldo; no es una capacidad de transmisión en PROD. | | `MH_USER_AGENT` | Encabezado User-Agent enviado a MH. | | `EMISOR_CONFIG_JSON` | La configuración del emisor de demostración/local vive en el archivo de entorno privado seleccionado; el valor remoto real se define como secreto de Cloudflare. | | `STRIPE_RESTRICTED_KEY` | Clave de servidor `rk_test_…` (staging) o `rk_live_…` (producción), con privilegios mínimos para Checkout Sessions y Billing Portal. Se rechazan las claves amplias `sk_…`. | diff --git a/README.md b/README.md index 2e7fdde5..08f1e480 100644 --- a/README.md +++ b/README.md @@ -588,7 +588,7 @@ selected private config and are duplicated per Wrangler environment: | `ARCHIVE` (binding) | R2 bucket binding for the monthly legal-retention export and the white-label logo objects. The committed example config names `diezmossv-local-archive-example`, `diezmossv-staging-archive-example`, and `diezmossv-production-archive-example`; real bucket names belong only in the selected private config. | | `EMAIL_ARBITRARY_RECIPIENTS` | Optional `"true"` marker set after Cloudflare Email Sending is confirmed able to reach external donor addresses. The committed example already sets it for `staging`; local and production leave it unset. | | `DONATION_INTAKE_DISABLED` | Emergency kill switch for new public intake. When exactly `"true"`, Wompi intent mutations and `POST /api/donations/stripe/checkout` return `503 donation_intake_disabled`; `/`, `/donar`, and `/donar/gracias` serve an empty locked-down document. Stripe's result page, status reads, webhook, acknowledgments, and Billing Portal remain available so an existing or monthly donor is not stranded. The Wompi webhook, issuance pipeline, and admin panel also keep working. The committed example sets it to `"true"` for `production`; unset or any other value leaves intake open. | -| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | MH endpoints available only for the deployment's credential lane. `MH_AUTH_URL_TEST_FALLBACK` is the narrow central-auth fallback for TEST accounts after MH code 106; it is not a PROD transmission capability. | +| `MH_AUTH_URL_*` · `MH_RECEPCION_URL_*` · `MH_ANULACION_URL_*` | MH endpoints available only for the deployment's credential lane. `MH_AUTH_URL_TEST_FALLBACK` may be absent/empty, the exact official TEST auth URL (a no-op), or the exact central auth URL `https://api.dtes.mh.gob.sv/seguridad/auth` for TEST accounts after MH code 106. Every other value is rejected before Wompi collection and before fallback credentials are sent; it is not a PROD transmission capability. | | `MH_USER_AGENT` | User-Agent header sent to MH. | | `EMISOR_CONFIG_JSON` | Demo/local issuer config lives in the selected private env file; set the real remote value as a Cloudflare secret. | | `STRIPE_RESTRICTED_KEY` | Server-only `rk_test_…` (staging) or `rk_live_…` (production) key with least privilege for Checkout Sessions and Billing Portal. Broad `sk_…` keys are rejected. | diff --git a/src/worker/config.ts b/src/worker/config.ts index b2043cb4..a7989d7a 100644 --- a/src/worker/config.ts +++ b/src/worker/config.ts @@ -196,6 +196,22 @@ const MH_ENDPOINTS = { } } as const; +export function resolveMhTestAuthFallbackEndpoint(env: Env): string | null { + const value = env.MH_AUTH_URL_TEST_FALLBACK; + if (value === undefined || value === "") { + return null; + } + if (value === MH_ENDPOINTS["00"].auth) { + return null; + } + if (value === MH_ENDPOINTS["01"].auth) { + return value; + } + throw new Error( + `MH_AUTH_URL_TEST_FALLBACK debe ser ${MH_ENDPOINTS["00"].auth}, ${MH_ENDPOINTS["01"].auth}, o estar vacío` + ); +} + function isExpectedMhEndpoint(value: string, expected: string): boolean { if (value !== value.trim()) return false; try { diff --git a/src/worker/index.ts b/src/worker/index.ts index 7add5856..5be0eb58 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -1646,7 +1646,7 @@ async function prepareExistingStripeCheckoutCreation( }); if (!reclaimed) { if (definiteFailureAdmission?.claim.kind === "CLAIMED") { - await ctx.repo.releaseUnusedProviderCreationClaim(definiteFailureAdmission.id); + await releaseLostStripeRetryClaim(ctx, definiteFailureAdmission.id); } const current = await ctx.repo.getStripeCheckoutById(existing.id); return current @@ -1656,6 +1656,26 @@ async function prepareExistingStripeCheckoutCreation( return { checkout: reclaimed, params: request.params }; } +async function releaseLostStripeRetryClaim( + ctx: ApiRouteContext, + claimId: string +): Promise { + try { + await ctx.repo.releaseUnusedProviderCreationClaim(claimId); + return; + } catch (error) { + logWorkerError(ctx.env, "stripe_checkout_claim_cleanup_retry", error); + } + + try { + await ctx.repo.releaseUnusedProviderCreationClaim(claimId); + } catch (error) { + // The claim is unattached and expires after the fixed admission window. A + // later cleanup sweep removes it; never retry in a loop or reach Stripe. + logWorkerError(ctx.env, "stripe_checkout_claim_cleanup_deferred", error); + } +} + async function buildStripeCheckoutCreationRequest( ctx: ApiRouteContext, checkoutId: string, diff --git a/src/worker/services/environmentPolicy.ts b/src/worker/services/environmentPolicy.ts index e63654d6..8207d1eb 100644 --- a/src/worker/services/environmentPolicy.ts +++ b/src/worker/services/environmentPolicy.ts @@ -1,5 +1,11 @@ import type { Ambiente, Env } from "../types"; -import { getEmisorConfig, getMhCertificateXml, mhEndpoint, requireSecret } from "../config"; +import { + getEmisorConfig, + getMhCertificateXml, + mhEndpoint, + requireSecret, + resolveMhTestAuthFallbackEndpoint +} from "../config"; import { assertMhSigningMaterialReady } from "../domain/signer"; type DeploymentAppEnvironment = "local" | "staging" | "production" | "unknown"; @@ -71,6 +77,9 @@ export async function assertFiscalCollectionReady(env: Env): Promise { requireSecret(env, `MH_USER_${credentialLane}` as keyof Env); requireSecret(env, `MH_PASSWORD_${credentialLane}` as keyof Env); mhEndpoint(env, "auth", ambiente); + if (ambiente === "00") { + resolveMhTestAuthFallbackEndpoint(env); + } mhEndpoint(env, "recepcion", ambiente); mhEndpoint(env, "anulacion", ambiente); const certificate = await assertMhSigningMaterialReady( diff --git a/src/worker/services/mhClient.ts b/src/worker/services/mhClient.ts index 42447aa4..7c26e702 100644 --- a/src/worker/services/mhClient.ts +++ b/src/worker/services/mhClient.ts @@ -1,4 +1,4 @@ -import { isMockMode, mhEndpoint, requireSecret } from "../config"; +import { isMockMode, mhEndpoint, requireSecret, resolveMhTestAuthFallbackEndpoint } from "../config"; import type { Ambiente, Env, MhResponse } from "../types"; import { nowIso } from "../utils/dates"; import { generationCode } from "../utils/ids"; @@ -6,6 +6,7 @@ import { assertDeploymentAllowsAmbiente } from "./environmentPolicy"; const MH_REQUEST_TIMEOUT_MS = 60 * 1000; const MH_REDACTION = "[REDACTED]"; +const MH_TOKEN_TYPE = "Bearer"; const PUBLIC_INDETERMINATE_ESTADOS = new Set([ "ACEPTADO", "NO PROCESADO", @@ -76,9 +77,9 @@ export class MhClient { // Some Ministerio de Hacienda test accounts are provisioned through the central auth service while still transmitting to TEST endpoints. if (!token && ambiente === "00" && isInvalidCredentials(data)) { - const centralAuthUrl = this.env.MH_AUTH_URL_TEST_FALLBACK?.trim(); - if (centralAuthUrl && centralAuthUrl !== primaryAuthUrl) { - data = await this.authenticate(centralAuthUrl, credentials); + const fallbackAuthUrl = resolveMhTestAuthFallbackEndpoint(this.env); + if (fallbackAuthUrl) { + data = await this.authenticate(fallbackAuthUrl, credentials); token = data.body?.token; } } @@ -92,7 +93,7 @@ export class MhClient { VALUES (?, ?, ?, ?, ?) ON CONFLICT(environment) DO UPDATE SET token = excluded.token, token_type = excluded.token_type, expires_at = excluded.expires_at, updated_at = excluded.updated_at` ) - .bind(ambiente, token, data.tokenType ?? "Bearer", expiresAt, nowIso()) + .bind(ambiente, token, MH_TOKEN_TYPE, expiresAt, nowIso()) .run(); return token; } @@ -264,6 +265,12 @@ function sanitizeProviderValue(value: unknown, redactions: string[]): unknown { if (typeof value === "string") { return sanitizeProviderText(value, redactions); } + if ( + (typeof value === "number" && Number.isFinite(value)) + || typeof value === "boolean" + ) { + return redactions.includes(String(value)) ? MH_REDACTION : value; + } if (Array.isArray(value)) { return value.map((entry) => sanitizeProviderValue(entry, redactions)); } diff --git a/src/worker/services/observability.ts b/src/worker/services/observability.ts index 3565c526..2f132865 100644 --- a/src/worker/services/observability.ts +++ b/src/worker/services/observability.ts @@ -94,6 +94,8 @@ const ERROR_EVENTS = new Set([ "stripe_acknowledgment_reconciliation_audit_failed", "stripe_annual_statement_audit_failed", "stripe_acknowledgment_sweep_failed", + "stripe_checkout_claim_cleanup_deferred", + "stripe_checkout_claim_cleanup_retry", "stripe_checkout_create_failed", "stripe_checkout_finalize_deferred", "stripe_portal_create_failed", diff --git a/test/worker/config.test.ts b/test/worker/config.test.ts index c8a42434..c9ec0555 100644 --- a/test/worker/config.test.ts +++ b/test/worker/config.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { getEmisorConfig, getMhCertificateXml, isMockMode, mhEndpoint } from "../../src/worker/config"; +import { + getEmisorConfig, + getMhCertificateXml, + isMockMode, + mhEndpoint, + resolveMhTestAuthFallbackEndpoint +} from "../../src/worker/config"; import type { Env } from "../../src/worker/types"; import { emisorConfig } from "./fixtures"; @@ -81,6 +87,33 @@ describe("MH endpoints", () => { ] as const)("rejects an %s %s endpoint outside the requested lane", (_label, name, ambiente, key, endpoint) => { expect(() => mhEndpoint(env({ [key]: endpoint }), name, ambiente)).toThrow(/MH endpoint/i); }); + + it.each([ + ["an omitted value", undefined, null], + ["an empty value", "", null], + ["the official TEST endpoint", "https://apitest.dtes.mh.gob.sv/seguridad/auth", null], + [ + "the official central endpoint", + "https://api.dtes.mh.gob.sv/seguridad/auth", + "https://api.dtes.mh.gob.sv/seguridad/auth" + ] + ] as const)("resolves %s as a safe TEST authentication fallback", (_label, value, expected) => { + expect(resolveMhTestAuthFallbackEndpoint(env({ MH_AUTH_URL_TEST_FALLBACK: value }))).toBe(expected); + }); + + it.each([ + "https://credentials.example/collect", + "http://api.dtes.mh.gob.sv/seguridad/auth", + "https://user:password@api.dtes.mh.gob.sv/seguridad/auth", + "https://api.dtes.mh.gob.sv:444/seguridad/auth", + "https://api.dtes.mh.gob.sv/seguridad/auth?next=evil", + "https://api.dtes.mh.gob.sv/seguridad/auth#fragment", + " https://api.dtes.mh.gob.sv/seguridad/auth", + "https://api.dtes.mh.gob.sv/seguridad/auth " + ])("rejects a non-official TEST authentication fallback before credentials can be sent: %s", (value) => { + expect(() => resolveMhTestAuthFallbackEndpoint(env({ MH_AUTH_URL_TEST_FALLBACK: value }))) + .toThrow(/MH_AUTH_URL_TEST_FALLBACK/); + }); }); function env(values: Partial): Env { diff --git a/test/worker/mhClient.test.ts b/test/worker/mhClient.test.ts index 4b798f64..58fb0e1f 100644 --- a/test/worker/mhClient.test.ts +++ b/test/worker/mhClient.test.ts @@ -20,6 +20,8 @@ const MH_SECRET_TOKEN_FORM_SEPARATOR = `bEaReR+${MH_BEARER_CREDENTIAL}`; const MH_OWS_SECRET_TOKEN = ` \t${MH_SECRET_TOKEN}\t `; const MH_OWS_SECRET_TOKEN_PERCENT = "%20%09bEaReR%20cache%20token%2Bcredential%2F%25%3F%20canary%09%20"; const MH_OWS_SECRET_TOKEN_FORM = "+%09bEaReR+cache+token%2Bcredential%2F%25%3F+canary%09+"; +const MH_NUMERIC_USER = "73194620581734"; +const MH_NUMERIC_PASSWORD = "86420975318642"; const MH_SECRET_VARIANTS = [ MH_SECRET_USER, @@ -38,7 +40,9 @@ const MH_SECRET_VARIANTS = [ MH_SECRET_TOKEN_FORM_SEPARATOR, MH_OWS_SECRET_TOKEN, MH_OWS_SECRET_TOKEN_PERCENT, - MH_OWS_SECRET_TOKEN_FORM + MH_OWS_SECRET_TOKEN_FORM, + MH_NUMERIC_USER, + MH_NUMERIC_PASSWORD ]; describe("MH client", () => { @@ -75,6 +79,49 @@ describe("MH client", () => { expect(fetchMock.mock.calls[2][1]?.headers).toMatchObject({ Authorization: "Bearer test-token" }); }); + it("rejects a hostile TEST fallback after code 106 without sending credentials to it", async () => { + const environment = testEnv(); + environment.MH_USER_TEST = MH_SECRET_USER; + environment.MH_PASSWORD_TEST = MH_SECRET_PASSWORD; + environment.MH_AUTH_URL_TEST_FALLBACK = "https://credentials.example/collect"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ + status: "ERROR", + body: { codigoMsg: "106", descripcionMsg: "CREDENCIALES INVÁLIDAS" } + })) + .mockResolvedValueOnce(jsonResponse({ + status: "OK", + body: { token: "Bearer stolen-credential-proof" }, + tokenType: "Bearer" + })) + .mockResolvedValueOnce(jsonResponse({ estado: "PROCESADO", selloRecibido: "UNREACHABLE" })); + vi.stubGlobal("fetch", fetchMock); + + const error = await transmitTestDte(new MhClient(environment)).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MhPreDispatchError); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("https://apitest.dtes.mh.gob.sv/seguridad/auth"); + expectNoMhSecrets(serializeError(error)); + }); + + it("treats the official TEST endpoint as a no-op fallback after code 106", async () => { + const environment = testEnv(); + environment.MH_AUTH_URL_TEST_FALLBACK = "https://apitest.dtes.mh.gob.sv/seguridad/auth"; + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ + status: "ERROR", + body: { codigoMsg: "106", descripcionMsg: "CREDENCIALES INVÁLIDAS" } + })); + vi.stubGlobal("fetch", fetchMock); + + const error = await transmitTestDte(new MhClient(environment)).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(MhPreDispatchError); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe("https://apitest.dtes.mh.gob.sv/seguridad/auth"); + }); + it("bounds both MH authentication and transmission below the DTE lease", async () => { const timeoutSignals: AbortSignal[] = []; const timeoutDurations: number[] = []; @@ -183,6 +230,52 @@ describe("MH client", () => { ); }); + it("stores the supported Bearer token type instead of provider-controlled authentication text", async () => { + const lookupStatement = { + bind: vi.fn().mockReturnThis(), + first: vi.fn().mockResolvedValue(null), + run: vi.fn().mockResolvedValue({}) + }; + const writeStatement = { + bind: vi.fn().mockReturnThis(), + first: vi.fn().mockResolvedValue(null), + run: vi.fn().mockResolvedValue({}) + }; + const environment = testEnv(); + environment.MH_USER_TEST = MH_SECRET_USER; + environment.MH_PASSWORD_TEST = MH_SECRET_PASSWORD; + environment.DB = { + prepare: vi.fn((sql: string) => sql.includes("SELECT token, token_type") + ? lookupStatement + : writeStatement) + } as unknown as D1Database; + vi.stubGlobal("fetch", vi + .fn() + .mockResolvedValueOnce(jsonResponse({ + status: "OK", + body: { token: "Bearer safe-auth-token" }, + tokenType: `provider ${MH_SECRET_USER} ${MH_SECRET_PASSWORD}` + })) + .mockResolvedValueOnce(jsonResponse({ + estado: "PROCESADO", + selloRecibido: "SAFE-SEAL", + observaciones: [] + }))); + + const result = await transmitTestDte(new MhClient(environment)); + + expect(result.accepted).toBe(true); + expect(writeStatement.bind).toHaveBeenCalledWith( + "00", + "Bearer safe-auth-token", + "Bearer", + expect.any(String), + expect.any(String) + ); + const [ambiente, _token, tokenType, expiresAt, updatedAt] = writeStatement.bind.mock.calls[0]; + expectNoMhSecrets(JSON.stringify({ ambiente, tokenType, expiresAt, updatedAt })); + }); + it("sanitizes credentials and authorization recursively before returning a terminal rejection", async () => { const environment = testEnv(); environment.MH_USER_TEST = MH_SECRET_USER; @@ -222,6 +315,81 @@ describe("MH client", () => { expectNoMhSecrets(JSON.stringify(result)); }); + it("redacts exact all-numeric credential echoes before observations stringify provider scalars", async () => { + const environment = testEnv(); + environment.MH_USER_TEST = MH_NUMERIC_USER; + environment.MH_PASSWORD_TEST = MH_NUMERIC_PASSWORD; + vi.stubGlobal("fetch", vi + .fn() + .mockResolvedValueOnce(jsonResponse({ + status: "OK", + body: { token: "Bearer numeric-sanitizer-token" }, + tokenType: "Bearer" + })) + .mockResolvedValueOnce(jsonResponse({ + estado: "RECHAZADO", + selloRecibido: null, + observaciones: [ + Number(MH_NUMERIC_USER), + Number(MH_NUMERIC_PASSWORD), + 42, + false + ], + nested: { + userEcho: Number(MH_NUMERIC_USER), + passwordEcho: Number(MH_NUMERIC_PASSWORD), + unrelatedNumber: 7, + unrelatedBoolean: false + } + }, { status: 400 }))); + + const result = await transmitTestDte(new MhClient(environment)); + const raw = result.raw as { + nested: Record; + }; + + expect(result).toMatchObject({ + accepted: false, + estado: "RECHAZADO", + selloRecibido: null, + observaciones: ["[REDACTED]", "[REDACTED]", "42", "false"] + }); + expect(raw.nested).toEqual({ + userEcho: "[REDACTED]", + passwordEcho: "[REDACTED]", + unrelatedNumber: 7, + unrelatedBoolean: false + }); + expectNoMhSecrets(JSON.stringify(result)); + }); + + it("redacts an exact boolean credential echo while preserving an unrelated boolean", async () => { + const environment = testEnv(); + environment.MH_PASSWORD_TEST = "false"; + vi.stubGlobal("fetch", vi + .fn() + .mockResolvedValueOnce(jsonResponse({ + status: "OK", + body: { token: "Bearer boolean-sanitizer-token" }, + tokenType: "Bearer" + })) + .mockResolvedValueOnce(jsonResponse({ + estado: "RECHAZADO", + selloRecibido: null, + observaciones: [false, true], + nested: { passwordEcho: false, unrelatedBoolean: true } + }, { status: 400 }))); + + const result = await transmitTestDte(new MhClient(environment)); + const raw = result.raw as { nested: Record }; + + expect(result.observaciones).toEqual(["[REDACTED]", "true"]); + expect(raw.nested).toEqual({ + passwordEcho: "[REDACTED]", + unrelatedBoolean: true + }); + }); + it.each([ { ambiente: "00" as const, diff --git a/test/worker/pipeline.issuance.test.ts b/test/worker/pipeline.issuance.test.ts index 5baaa63d..ccf48a31 100644 --- a/test/worker/pipeline.issuance.test.ts +++ b/test/worker/pipeline.issuance.test.ts @@ -41,6 +41,8 @@ const PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR = `bEaReR+${PIPELINE_MH_BEARER_CRE const PIPELINE_MH_OWS_SECRET_TOKEN = ` \t${PIPELINE_MH_SECRET_TOKEN}\t `; const PIPELINE_MH_OWS_SECRET_TOKEN_PERCENT = "%20%09bEaReR%20cache%20token%2Bcredential%2F%25%3F%20canary%09%20"; const PIPELINE_MH_OWS_SECRET_TOKEN_FORM = "+%09bEaReR+cache+token%2Bcredential%2F%25%3F+canary%09+"; +const PIPELINE_MH_NUMERIC_USER = "59281476035192"; +const PIPELINE_MH_NUMERIC_PASSWORD = "84720693145827"; const PIPELINE_MH_SECRET_VARIANTS = [ PIPELINE_MH_SECRET_USER, @@ -59,7 +61,9 @@ const PIPELINE_MH_SECRET_VARIANTS = [ PIPELINE_MH_SECRET_TOKEN_FORM_SEPARATOR, PIPELINE_MH_OWS_SECRET_TOKEN, PIPELINE_MH_OWS_SECRET_TOKEN_PERCENT, - PIPELINE_MH_OWS_SECRET_TOKEN_FORM + PIPELINE_MH_OWS_SECRET_TOKEN_FORM, + PIPELINE_MH_NUMERIC_USER, + PIPELINE_MH_NUMERIC_PASSWORD ]; const INTENT_ADDRESS = { @@ -503,6 +507,71 @@ describe("IssuancePipeline.processWompiEvent rejection", () => { })); }); + it("keeps all-numeric MH credential echoes out of durable rejection evidence", async () => { + const db = new InMemoryD1(); + seedIntent(db); + const eventId = seedEvent(db, unitWebhook()); + const runtime = await pipelineRuntime(db, []); + runtime.MH_USER_TEST = PIPELINE_MH_NUMERIC_USER; + runtime.MH_PASSWORD_TEST = PIPELINE_MH_NUMERIC_PASSWORD; + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/seguridad/auth")) { + return jsonResponse({ + status: "OK", + body: { token: "Bearer pipeline-numeric-sanitizer-token" }, + tokenType: "Bearer" + }); + } + if (url.includes("recepciondte")) { + return jsonResponse({ + estado: "RECHAZADO", + selloRecibido: null, + observaciones: [ + Number(PIPELINE_MH_NUMERIC_USER), + Number(PIPELINE_MH_NUMERIC_PASSWORD), + 17, + false + ], + nested: { + userEcho: Number(PIPELINE_MH_NUMERIC_USER), + passwordEcho: Number(PIPELINE_MH_NUMERIC_PASSWORD), + unrelatedNumber: 9, + unrelatedBoolean: false + } + }, { status: 400 }); + } + throw new Error(`Fetch inesperado en prueba unitaria del pipeline: ${url}`); + })); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const record = await new IssuancePipeline(runtime).processWompiEvent(eventId); + const capturedLogs = errorLog.mock.calls; + errorLog.mockRestore(); + + expect(record).toMatchObject({ + status: "REJECTED", + mh_estado: "RECHAZADO", + sello_recibido: null + }); + expect(JSON.parse(String(record!.mh_observaciones_json))).toEqual([ + "[REDACTED]", + "[REDACTED]", + "17", + "false" + ]); + expect(db.audits).toContainEqual(expect.objectContaining({ + action: "DTE_REJECTED", + summary: "DTE-15-M001P004-000000000000001 RECHAZADO" + })); + expectNoPipelineMhSecrets(JSON.stringify({ + returned: record, + documents: db.documents, + rejectionAudits: db.audits.filter((audit) => audit.action === "DTE_REJECTED"), + logs: capturedLogs + })); + }); + it("retains the fiscal claim and bounds durable evidence for an indeterminate MH estado", async () => { const db = new InMemoryD1(); seedIntent(db); diff --git a/test/worker/stripeRoutes.test.ts b/test/worker/stripeRoutes.test.ts index 4a1c2bba..26178037 100644 --- a/test/worker/stripeRoutes.test.ts +++ b/test/worker/stripeRoutes.test.ts @@ -659,7 +659,7 @@ describe("Stripe public donation routes", () => { const retry = await createCheckout({ ...stripeProxyEnv(workerEnv), - DB: losing + DB: losing.db }, { requestId, amount: 50, @@ -669,6 +669,7 @@ describe("Stripe public donation routes", () => { expect(retry.response.status).toBe(409); expect(retry.body).toMatchObject({ error: "stripe_checkout_unavailable" }); expect(providerFetch).not.toHaveBeenCalled(); + expect(losing.releaseAttempts()).toBe(1); expect(database.prepare( "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE stripe_request_id = ?" ).get(requestId)).toEqual({ count: 0 }); @@ -684,6 +685,84 @@ describe("Stripe public donation routes", () => { }); }); + it("retries lost-CAS claim cleanup once after the first release throws", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { claimId: "lost_cas_retry_cleanup_old_claim" }); + const providerFetch = stubSuccessfulStripeCreation(); + const losing = withLosingDefiniteRetryCas(database, 1); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const retry = await createCheckout({ + ...stripeProxyEnv(workerEnv), + DB: losing.db + }, { + requestId, + amount: 50, + frequency: "once" + }); + const cleanupEvents = errorLog.mock.calls + .map(([entry]) => entry) + .filter((entry) => (entry as { event?: string }).event?.startsWith("stripe_checkout_claim_cleanup_")); + errorLog.mockRestore(); + + expect(retry.response.status).toBe(409); + expect(retry.body).toMatchObject({ error: "stripe_checkout_unavailable" }); + expect(losing.releaseAttempts()).toBe(2); + expect(providerFetch).not.toHaveBeenCalled(); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE stripe_request_id = ?" + ).get(requestId)).toEqual({ count: 0 }); + expect(cleanupEvents).toEqual([{ + event: "stripe_checkout_claim_cleanup_retry", + app_env: "local", + error_name: "error", + error_code: "unknown" + }]); + }); + + it("bounds lost-CAS claim cleanup at two attempts and relies on expiry when both throw", async () => { + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); + seedDefiniteFailureCheckout(database, { claimId: "lost_cas_deferred_cleanup_old_claim" }); + const providerFetch = stubSuccessfulStripeCreation(); + const losing = withLosingDefiniteRetryCas(database, 2); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + + const retry = await createCheckout({ + ...stripeProxyEnv(workerEnv), + DB: losing.db + }, { + requestId, + amount: 50, + frequency: "once" + }); + const cleanupEvents = errorLog.mock.calls + .map(([entry]) => entry) + .filter((entry) => (entry as { event?: string }).event?.startsWith("stripe_checkout_claim_cleanup_")); + errorLog.mockRestore(); + + expect(retry.response.status).toBe(409); + expect(retry.body).toMatchObject({ error: "stripe_checkout_unavailable" }); + expect(losing.releaseAttempts()).toBe(2); + expect(providerFetch).not.toHaveBeenCalled(); + expect(database.prepare( + "SELECT COUNT(*) AS count FROM provider_creation_claims WHERE stripe_request_id = ?" + ).get(requestId)).toEqual({ count: 1 }); + expect(cleanupEvents).toEqual([ + { + event: "stripe_checkout_claim_cleanup_retry", + app_env: "local", + error_name: "error", + error_code: "unknown" + }, + { + event: "stripe_checkout_claim_cleanup_deferred", + app_env: "local", + error_name: "error", + error_code: "unknown" + } + ]); + }); + it("retains an attached refreshed claim when the definite retry provider call fails", async () => { vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); seedDefiniteFailureCheckout(database, { @@ -1447,28 +1526,48 @@ function stubSuccessfulStripeCreation(): ReturnType> } function withLosingDefiniteRetryCas( - database: ReturnType -): D1Database { + database: ReturnType, + releaseFailures = 0 +): { db: D1Database; releaseAttempts(): number } { const base = sqliteD1(database); + let releaseAttempts = 0; return { - prepare(sql: string) { - const statement = base.prepare(sql); - if ( - sql.includes("UPDATE stripe_checkout_sessions") - && sql.includes("provider_creation_claim_id = ?") - && sql.includes("creation_outcome_class = 'DEFINITE_FAILURE'") - ) { - const mutable = statement as unknown as { - first: () => Promise; - }; - mutable.first = async () => null as T | null; + db: { + prepare(sql: string) { + const statement = base.prepare(sql); + if ( + sql.includes("UPDATE stripe_checkout_sessions") + && sql.includes("provider_creation_claim_id = ?") + && sql.includes("creation_outcome_class = 'DEFINITE_FAILURE'") + ) { + const mutable = statement as unknown as { + first: () => Promise; + }; + mutable.first = async () => null as T | null; + } + if (sql.includes("DELETE FROM provider_creation_claims")) { + const mutable = statement as unknown as { + run: (...args: unknown[]) => Promise; + }; + const run = mutable.run.bind(mutable); + mutable.run = async (...args: unknown[]) => { + releaseAttempts += 1; + if (releaseAttempts <= releaseFailures) { + throw new Error("injected provider claim release failure"); + } + return run(...args); + }; + } + return statement; + }, + batch(statements: D1PreparedStatement[]) { + return base.batch(statements); } - return statement; - }, - batch(statements: D1PreparedStatement[]) { - return base.batch(statements); + } as D1Database, + releaseAttempts() { + return releaseAttempts; } - } as D1Database; + }; } function withFailingStripeReservation( diff --git a/test/worker/wompiApi.test.ts b/test/worker/wompiApi.test.ts index 75a55196..f19a15f7 100644 --- a/test/worker/wompiApi.test.ts +++ b/test/worker/wompiApi.test.ts @@ -319,6 +319,29 @@ describe("Wompi API service", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("fails fiscal readiness on a hostile optional MH TEST fallback before the first Wompi fetch", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ + access_token: "wompi-access-token", + expires_in: 3600, + token_type: "Bearer" + })) + .mockResolvedValueOnce(jsonResponse({ + idEnlace: 1, + urlEnlace: "https://s.wompi.sv/1", + urlEnlaceLargo: "https://pagos.wompi.sv/L?id=1" + })); + vi.stubGlobal("fetch", fetchMock); + const env = realEnv(); + env.MH_AUTH_URL_TEST_FALLBACK = "https://credentials.example/collect"; + + const error = await new WompiApiService(env).createPaymentLink(intent()).catch((caught: unknown) => caught); + + expectSafeConfigurationError(error); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it("uses the production MH credential lane before contacting Wompi in production", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); From 5a2d82e16c57e54a101391ec739670f651753ddc Mon Sep 17 00:00:00 2001 From: jomplox <21096700+jomplox@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:45:14 -0600 Subject: [PATCH 22/22] fix: address PR 181 review findings --- src/worker/index.ts | 109 ++++++++++++++-- src/worker/services/donations.ts | 13 +- src/worker/services/observability.ts | 12 +- src/worker/services/wompiApi.ts | 24 ++-- src/worker/storage/repository/rateLimits.ts | 73 +++++++++-- .../storage/repository/wompiIssuance.ts | 12 +- test/worker/stripeRoutes.test.ts | 108 +++++++++++++--- test/worker/support/inMemoryD1.ts | 78 ++++++++++++ test/worker/wompiApi.test.ts | 5 +- .../workerFetch.advanced-cde-webhook.test.ts | 116 +++++++++++++----- test/worker/workerFetch.auth-infra.test.ts | 56 ++++++++- .../workerFetch.donation-intents.test.ts | 47 ++++++- test/worker/workerFetch.infra.test.ts | 5 + 13 files changed, 558 insertions(+), 100 deletions(-) diff --git a/src/worker/index.ts b/src/worker/index.ts index 5be0eb58..0ac46111 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -279,6 +279,17 @@ async function rateLimitKey(value: string | null): Promise { return sha256Hex(utf8Bytes(value?.trim() || "unknown")); } +async function providerCreationClientKeys(clientIp: string | null): Promise<{ + clientKeyHash: string; + legacyClientKeyHash: string; +}> { + const [clientKeyHash, legacyClientKeyHash] = await Promise.all([ + rateLimitKey(providerCreationRateIdentity(clientIp)), + rateLimitKey(clientIp) + ]); + return { clientKeyHash, legacyClientKeyHash }; +} + function loginMfaUnavailableResponse(): Response { return jsonResponse( { @@ -293,7 +304,7 @@ function intentThrottleExpiresIso(): string { return new Date(Date.now() + INTENT_THROTTLE_WINDOW_MINUTES * 60_000).toISOString(); } -function providerCreationLimitedResponse(): Response { +function providerCreationClientLimitedResponse(): Response { return jsonResponse( { error: "too_many_attempts", @@ -303,6 +314,75 @@ function providerCreationLimitedResponse(): Response { ); } +async function providerCreationLimitedResponse( + ctx: ApiRouteContext, + provider: "WOMPI" | "STRIPE", + claim: Extract< + Awaited>, + { kind: "LIMITED" } + >, + now: string +): Promise { + if (claim.scope === "CLIENT") { + return providerCreationClientLimitedResponse(); + } + const capacityClaim = claim; + const limit = capacityClaim.scope === "GLOBAL" + ? PROVIDER_CREATION_GLOBAL_LIMIT + : PROVIDER_CREATION_PROVIDER_LIMIT; + const windowMs = INTENT_THROTTLE_WINDOW_MINUTES * 60_000; + const bucketStart = new Date( + Math.floor(Date.parse(now) / windowMs) * windowMs + ).toISOString(); + const entityId = capacityClaim.scope === "GLOBAL" + ? `global:${bucketStart}` + : `${provider.toLowerCase()}:${bucketStart}`; + const evidenceTask = (async () => { + try { + await ctx.repo.createAuditIfAbsent({ + action: "PROVIDER_CREATION_CAPACITY_EXHAUSTED", + entityType: "provider_creation_capacity", + entityId, + summary: "Capacidad temporal de creación de entregas agotada", + metadata: { + scope: capacityClaim.scope, + provider, + windowMinutes: INTENT_THROTTLE_WINDOW_MINUTES, + limit + } + }); + } catch (error) { + logWorkerError(ctx.env, "provider_creation_capacity_audit_failed", error); + } + try { + await sendOperationalAlert(ctx.env, ctx.repo, { + kind: "PROVIDER_CREATION_CAPACITY_EXHAUSTED", + title: "Capacidad temporal de entregas agotada", + detail: capacityClaim.scope === "GLOBAL" + ? `El límite global de ${limit} creaciones en ${INTENT_THROTTLE_WINDOW_MINUTES} minutos fue alcanzado.` + : `El límite de ${provider} de ${limit} creaciones en ${INTENT_THROTTLE_WINDOW_MINUTES} minutos fue alcanzado.`, + entityType: "provider_creation_capacity", + entityId, + incidentId: entityId + }); + } catch (error) { + logWorkerError(ctx.env, "provider_creation_capacity_alert_failed", error); + } + })(); + if (ctx.executionContext) { + ctx.executionContext.waitUntil(evidenceTask); + } else { + await evidenceTask; + } + return jsonResponse( + { + error: "donation_service_busy", + message: "No pudimos preparar su entrega en este momento. Intente de nuevo en unos minutos." + }, + { status: 503, headers: { "Cache-Control": "no-store" } } + ); +} + async function listAuditForUser( repo: Repository, user: AuthUser, @@ -501,7 +581,7 @@ async function handleFetch(request: Request, env: Env, ctx?: ExecutionContext): export default { async fetch(request: Request, env: Env, ctx?: ExecutionContext): Promise { const response = await handleFetch(request, env, ctx); - if (env.APP_ENV !== "production") { + if (deploymentEnvironmentPolicy(env).appEnv !== "production") { return response; } const wrappedResponse = new Response(response.body, response); @@ -1381,9 +1461,10 @@ async function handleCreateDonationIntent(ctx: ApiRouteContext): Promise, clientIp, providerClaim.id) @@ -1404,6 +1490,7 @@ async function handleCreateDonationIntent(ctx: ApiRouteContext): Promise { return value !== null && typeof value === "object" && !Array.isArray(value); } -function wompiConfigurationError(): WompiApiError { - return new WompiApiError("No se pudo preparar la configuración de Wompi"); +function wompiConfigurationError(cause: unknown): WompiApiError { + return new WompiApiError( + "No se pudo preparar la configuración de Wompi", + "wompi_configuration_error", + { cause } + ); } diff --git a/src/worker/storage/repository/rateLimits.ts b/src/worker/storage/repository/rateLimits.ts index ddf5edf9..6a8d13a1 100644 --- a/src/worker/storage/repository/rateLimits.ts +++ b/src/worker/storage/repository/rateLimits.ts @@ -8,13 +8,14 @@ export type StripeProviderRecoveryClaim = export type ProviderCreationClaim = | { kind: "CLAIMED"; id: string } | { kind: "DUPLICATE"; id: string } - | { kind: "LIMITED" }; + | { kind: "LIMITED"; scope: "CLIENT" | "PROVIDER" | "GLOBAL" }; export async function claimProviderCreationBudget( db: D1Database, input: { provider: "WOMPI" | "STRIPE"; clientKeyHash: string; + legacyClientKeyHash: string; stripeRequestId: string | null; now: string; cutoff: string; @@ -26,18 +27,21 @@ export async function claimProviderCreationBudget( ): Promise { const id = newId("provider_create"); // One statement owns all three rolling count decisions. During a rolling - // deploy, recent parent rows without a provider claim remain attributed to - // their provider/global budgets; attached rows are represented by the claim - // itself and are deliberately not double-counted. + // deploy, the old per-client ledger remains counted by its raw-IP hash, while + // recent parent rows without a provider claim remain attributed to their + // provider/global budgets. Attached rows are represented by the claim itself + // and are deliberately not double-counted. const row = await db.prepare( `INSERT INTO provider_creation_claims ( id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at ) SELECT ?, ?, ?, ?, ?, ? WHERE ( - SELECT COUNT(*) FROM provider_creation_claims - WHERE client_key_hash = ? AND claimed_at >= ? - AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?) + (SELECT COUNT(*) FROM provider_creation_claims + WHERE client_key_hash = ? AND claimed_at >= ? + AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?)) + + (SELECT COUNT(*) FROM security_rate_limit_claims + WHERE scope = 'donation_intent' AND key_hash = ? AND claimed_at >= ?) ) < ? AND ( (SELECT COUNT(*) FROM provider_creation_claims @@ -77,6 +81,8 @@ export async function claimProviderCreationBudget( input.clientKeyHash, input.cutoff, input.stripeRequestId, + input.legacyClientKeyHash, + input.cutoff, input.clientLimit, input.provider, input.cutoff, @@ -101,7 +107,58 @@ export async function claimProviderCreationBudget( ).bind(input.stripeRequestId, input.cutoff, input.now).first<{ id: string }>(); if (duplicate) return { kind: "DUPLICATE", id: duplicate.id }; } - return { kind: "LIMITED" }; + // Admission remains atomic above. This read only classifies the already + // rejected request so callers can distinguish a client throttle from a + // provider/site capacity incident. A concurrent cleanup can make every count + // fall below its ceiling; that uncertain case is treated as global capacity, + // never as personal abuse by the donor. + const counts = await db.prepare( + `SELECT + ((SELECT COUNT(*) FROM provider_creation_claims + WHERE client_key_hash = ? AND claimed_at >= ? + AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?)) + + (SELECT COUNT(*) FROM security_rate_limit_claims + WHERE scope = 'donation_intent' AND key_hash = ? AND claimed_at >= ?)) AS client_count, + ((SELECT COUNT(*) FROM provider_creation_claims + WHERE provider = ? AND claimed_at >= ? + AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?)) + + CASE WHEN ? = 'WOMPI' + THEN (SELECT COUNT(*) FROM donation_intents + WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + ELSE (SELECT COUNT(*) FROM stripe_checkout_sessions + WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + END) AS provider_count, + ((SELECT COUNT(*) FROM provider_creation_claims + WHERE claimed_at >= ? + AND (provider <> 'STRIPE' OR stripe_request_id IS NOT ?)) + + (SELECT COUNT(*) FROM donation_intents + WHERE provider_creation_claim_id IS NULL AND created_at >= ?) + + (SELECT COUNT(*) FROM stripe_checkout_sessions + WHERE provider_creation_claim_id IS NULL AND created_at >= ?)) AS global_count` + ).bind( + input.clientKeyHash, + input.cutoff, + input.stripeRequestId, + input.legacyClientKeyHash, + input.cutoff, + input.provider, + input.cutoff, + input.stripeRequestId, + input.provider, + input.cutoff, + input.cutoff, + input.cutoff, + input.stripeRequestId, + input.cutoff, + input.cutoff + ).first<{ client_count: number; provider_count: number; global_count: number }>(); + if (Number(counts?.client_count ?? 0) >= input.clientLimit) { + return { kind: "LIMITED", scope: "CLIENT" }; + } + if (Number(counts?.provider_count ?? 0) >= input.providerLimit) { + return { kind: "LIMITED", scope: "PROVIDER" }; + } + return { kind: "LIMITED", scope: "GLOBAL" }; } export async function releaseUnusedProviderCreationClaim( diff --git a/src/worker/storage/repository/wompiIssuance.ts b/src/worker/storage/repository/wompiIssuance.ts index fe4e0729..5e01dc32 100644 --- a/src/worker/storage/repository/wompiIssuance.ts +++ b/src/worker/storage/repository/wompiIssuance.ts @@ -76,7 +76,8 @@ const WOMPI_WEBHOOK_RAW_ALIASES: WompiRawAliasSchema = { ["Monto", "monto"], ["IdTransaccion", "idTransaccion"], ["ResultadoTransaccion", "resultadoTransaccion"], - ["CodigoAutorizacion", "codigoAutorizacion"], + // CodigoAutorizacion is provider-enriched and may legitimately be absent on + // the first delivery. The canonical stored payload remains authoritative. ["IdIntentoPago", "idIntentoPago"], ["Cantidad", "cantidad"], ["EsProductiva", "esProductiva"], @@ -360,7 +361,6 @@ function canonicalizeAliasedObject( schema: WompiRawAliasSchema, omittedCanonicalKey: string | null = null ): Record { - const consumed = new Set(); const entries: Array<[string, unknown]> = []; for (const [canonicalKey, ...aliases] of schema.groups) { @@ -370,7 +370,6 @@ function canonicalizeAliasedObject( ); if (present.length === 1) { const sourceKey = present[0]; - consumed.add(sourceKey); if (canonicalKey !== omittedCanonicalKey) { entries.push([ canonicalKey, @@ -380,7 +379,6 @@ function canonicalizeAliasedObject( continue; } for (const sourceKey of present) { - consumed.add(sourceKey); // Coexisting documented aliases are distinct evidence. For alternate // transactions, omit only the canonical member and preserve every alias. if (sourceKey !== omittedCanonicalKey) { @@ -392,12 +390,6 @@ function canonicalizeAliasedObject( } } - for (const [key, member] of Object.entries(record)) { - if (!consumed.has(key) && key !== omittedCanonicalKey) { - entries.push([key, canonicalizeRawMember(member)]); - } - } - entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); return Object.fromEntries(entries); } diff --git a/test/worker/stripeRoutes.test.ts b/test/worker/stripeRoutes.test.ts index 26178037..d079b880 100644 --- a/test/worker/stripeRoutes.test.ts +++ b/test/worker/stripeRoutes.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import worker from "../../src/worker/index"; +import { EmailService } from "../../src/worker/services/email"; import type { Env } from "../../src/worker/types"; import { env, InMemoryD1 } from "./support/inMemoryD1"; import { migratedDatabase } from "./support/migratedDatabase"; @@ -591,8 +592,8 @@ describe("Stripe public donation routes", () => { }); it.each([ - ["provider", 60], - ["global", 100] + ["provider", 600], + ["global", 1000] ] as const)("blocks a definite retry at the exhausted %s ceiling before Stripe", async (dimension, count) => { vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:16:00.000Z") }); seedDefiniteFailureCheckout(database, { claimId: `${dimension}_old_retry_claim` }); @@ -605,10 +606,10 @@ describe("Stripe public donation routes", () => { frequency: "once" }, "198.51.100.250"); - expect(retry.response.status).toBe(429); + expect(retry.response.status).toBe(503); expect(retry.body).toEqual({ - error: "too_many_attempts", - message: "Demasiados intentos. Espere 15 minutos e intente de nuevo." + error: "donation_service_busy", + message: "No pudimos preparar su entrega en este momento. Intente de nuevo en unos minutos." }); expect(retry.response.headers.get("Cache-Control")).toBe("no-store"); expect(providerFetch).not.toHaveBeenCalled(); @@ -1252,7 +1253,7 @@ describe("Stripe public donation routes", () => { it("blocks distinct clients at the Stripe provider ceiling before reservation or provider work", async () => { const now = "2026-07-04T12:00:00.000Z"; - for (let index = 0; index < 60; index += 1) { + for (let index = 0; index < 600; index += 1) { database.prepare( `INSERT INTO provider_creation_claims ( id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at @@ -1266,6 +1267,14 @@ describe("Stripe public donation routes", () => { ); } vi.useFakeTimers({ toFake: ["Date"], now: new Date(now) }); + database.prepare( + "INSERT INTO app_settings (key, value) VALUES ('alert_email', 'owner@example.org')" + ).run(); + const alertSend = vi.spyOn(EmailService.prototype, "sendOperationalAlert") + .mockImplementation(async (_input, beforeProviderDispatch) => { + await beforeProviderDispatch?.(); + return { messageId: "capacity-alert" }; + }); const providerFetch = vi.fn(); vi.stubGlobal("fetch", providerFetch); try { @@ -1275,21 +1284,33 @@ describe("Stripe public donation routes", () => { frequency: "once" }, "198.51.100.200"); - expect(limited.response.status).toBe(429); + expect(limited.response.status).toBe(503); expect(limited.body).toEqual({ - error: "too_many_attempts", - message: "Demasiados intentos. Espere 15 minutos e intente de nuevo." + error: "donation_service_busy", + message: "No pudimos preparar su entrega en este momento. Intente de nuevo en unos minutos." }); expect(limited.response.headers.get("Cache-Control")).toBe("no-store"); expect(database.prepare("SELECT COUNT(*) AS count FROM stripe_checkout_sessions").get()) .toEqual({ count: 0 }); expect(providerFetch).not.toHaveBeenCalled(); + expect(alertSend).toHaveBeenCalledTimes(1); + expect(database.prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'PROVIDER_CREATION_CAPACITY_EXHAUSTED' + AND entity_type = 'provider_creation_capacity'` + ).get()).toEqual({ count: 1 }); + expect(database.prepare( + `SELECT COUNT(*) AS count FROM audit_logs + WHERE action = 'ALERT_SENT:PROVIDER_CREATION_CAPACITY_EXHAUSTED' + AND entity_type = 'provider_creation_capacity'` + ).get()).toEqual({ count: 1 }); } finally { + alertSend.mockRestore(); vi.useRealTimers(); } }); - it("enforces one shared global ceiling across Wompi and Stripe claims", async () => { + it("admits the 101st site-wide creation attempt when provider capacity remains", async () => { const now = "2026-07-04T12:00:00.000Z"; for (let index = 0; index < 100; index += 1) { const stripe = index % 2 === 1; @@ -1307,6 +1328,50 @@ describe("Stripe public donation routes", () => { ); } vi.useFakeTimers({ toFake: ["Date"], now: new Date(now) }); + const providerFetch = stubSuccessfulStripeCreation(); + try { + const created = await createCheckout(stripeProxyEnv(workerEnv), { + requestId, + amount: 50, + frequency: "once" + }, "198.51.100.201"); + + expect(created.response.status).toBe(201); + expect(database.prepare("SELECT COUNT(*) AS count FROM stripe_checkout_sessions").get()) + .toEqual({ count: 1 }); + expect(providerFetch).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it("reports, audits, and alerts at the shared emergency capacity ceiling", async () => { + const now = "2026-07-04T12:00:00.000Z"; + const insert = database.prepare( + `INSERT INTO provider_creation_claims ( + id, provider, client_key_hash, stripe_request_id, claimed_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?)` + ); + for (let index = 0; index < 1000; index += 1) { + const stripe = index % 2 === 1; + insert.run( + `emergency_global_seed_${index}`, + stripe ? "STRIPE" : "WOMPI", + `emergency-global-client-${index}`, + stripe ? `emergency-global-request-${index}` : null, + now, + "2026-07-04T12:15:00.000Z" + ); + } + database.prepare( + "INSERT INTO app_settings (key, value) VALUES ('alert_email', 'owner@example.org')" + ).run(); + vi.useFakeTimers({ toFake: ["Date"], now: new Date(now) }); + const alertSend = vi.spyOn(EmailService.prototype, "sendOperationalAlert") + .mockImplementation(async (_input, beforeProviderDispatch) => { + await beforeProviderDispatch?.(); + return { messageId: "capacity-alert" }; + }); const providerFetch = vi.fn(); vi.stubGlobal("fetch", providerFetch); try { @@ -1314,14 +1379,29 @@ describe("Stripe public donation routes", () => { requestId, amount: 50, frequency: "once" - }, "198.51.100.201"); + }, "198.51.100.202"); - expect(limited.response.status).toBe(429); + expect(limited.response.status).toBe(503); + expect(limited.body).toEqual({ + error: "donation_service_busy", + message: "No pudimos preparar su entrega en este momento. Intente de nuevo en unos minutos." + }); expect(limited.response.headers.get("Cache-Control")).toBe("no-store"); - expect(database.prepare("SELECT COUNT(*) AS count FROM stripe_checkout_sessions").get()) - .toEqual({ count: 0 }); expect(providerFetch).not.toHaveBeenCalled(); + expect(alertSend).toHaveBeenCalledTimes(1); + expect(database.prepare( + `SELECT metadata_json FROM audit_logs + WHERE action = 'PROVIDER_CREATION_CAPACITY_EXHAUSTED'` + ).get()).toEqual({ + metadata_json: JSON.stringify({ + scope: "GLOBAL", + provider: "STRIPE", + windowMinutes: 15, + limit: 1000 + }) + }); } finally { + alertSend.mockRestore(); vi.useRealTimers(); } }); diff --git a/test/worker/support/inMemoryD1.ts b/test/worker/support/inMemoryD1.ts index 198e828d..de5596cb 100644 --- a/test/worker/support/inMemoryD1.ts +++ b/test/worker/support/inMemoryD1.ts @@ -1037,6 +1037,8 @@ export class Statement { countClientKeyHash, clientCutoff, excludedClientRequestId, + legacyClientKeyHash, + legacyClientCutoff, clientLimit, countProvider, providerCutoff, @@ -1072,6 +1074,11 @@ export class Statement { claim.client_key_hash === String(countClientKeyHash) && claim.claimed_at >= String(clientCutoff) && includedClaim(claim, excludedClientRequestId) + ).length + this.db.securityRateLimitClaims.filter( + (claim) => + claim.scope === "donation_intent" && + claim.key_hash === String(legacyClientKeyHash) && + claim.claimed_at >= String(legacyClientCutoff) ).length; const providerClaimCount = this.db.providerCreationClaims.filter( (claim) => @@ -1129,6 +1136,77 @@ export class Statement { this.db.providerCreationClaims.push(claim); return { id: claim.id } as T; } + if ( + this.sql.includes("AS client_count") && + this.sql.includes("AS provider_count") && + this.sql.includes("AS global_count") + ) { + const [ + clientKeyHash, + clientCutoff, + excludedClientRequestId, + legacyClientKeyHash, + legacyClientCutoff, + provider, + providerCutoff, + excludedProviderRequestId, + legacyProvider, + donationLegacyCutoff, + stripeLegacyCutoff, + globalCutoff, + excludedGlobalRequestId, + globalDonationCutoff, + globalStripeCutoff + ] = this.args; + const includedClaim = (claim: ProviderCreationClaimRow, excludedRequestId: unknown): boolean => + claim.provider !== "STRIPE" + || claim.stripe_request_id !== (excludedRequestId == null ? null : String(excludedRequestId)); + const clientCount = this.db.providerCreationClaims.filter( + (claim) => + claim.client_key_hash === String(clientKeyHash) && + claim.claimed_at >= String(clientCutoff) && + includedClaim(claim, excludedClientRequestId) + ).length + this.db.securityRateLimitClaims.filter( + (claim) => + claim.scope === "donation_intent" && + claim.key_hash === String(legacyClientKeyHash) && + claim.claimed_at >= String(legacyClientCutoff) + ).length; + const providerCount = this.db.providerCreationClaims.filter( + (claim) => + claim.provider === String(provider) && + claim.claimed_at >= String(providerCutoff) && + includedClaim(claim, excludedProviderRequestId) + ).length + (String(legacyProvider) === "WOMPI" + ? this.db.donationIntents.filter( + (intent) => + (intent.provider_creation_claim_id ?? null) === null && + String(intent.created_at) >= String(donationLegacyCutoff) + ).length + : this.db.stripeCheckoutSessions.filter( + (checkout) => + (checkout.provider_creation_claim_id ?? null) === null && + String(checkout.created_at) >= String(stripeLegacyCutoff) + ).length); + const globalCount = this.db.providerCreationClaims.filter( + (claim) => + claim.claimed_at >= String(globalCutoff) && + includedClaim(claim, excludedGlobalRequestId) + ).length + this.db.donationIntents.filter( + (intent) => + (intent.provider_creation_claim_id ?? null) === null && + String(intent.created_at) >= String(globalDonationCutoff) + ).length + this.db.stripeCheckoutSessions.filter( + (checkout) => + (checkout.provider_creation_claim_id ?? null) === null && + String(checkout.created_at) >= String(globalStripeCutoff) + ).length; + return { + client_count: clientCount, + provider_count: providerCount, + global_count: globalCount + } as T; + } if ( this.sql.includes("SELECT id FROM provider_creation_claims") && this.sql.includes("stripe_request_id = ?") diff --git a/test/worker/wompiApi.test.ts b/test/worker/wompiApi.test.ts index f19a15f7..367b2a1a 100644 --- a/test/worker/wompiApi.test.ts +++ b/test/worker/wompiApi.test.ts @@ -855,7 +855,10 @@ function jsonResponse(body: unknown): Response { function expectSafeConfigurationError(error: unknown): void { expect(error).toBeInstanceOf(WompiApiError); - const message = (error as WompiApiError).message; + const wompiError = error as WompiApiError; + expect(wompiError.code).toBe("wompi_configuration_error"); + expect(wompiError.cause).toBeInstanceOf(Error); + const message = wompiError.message; expect(message).toBe(WOMPI_CONFIGURATION_ERROR); for (const forbidden of INTERNAL_CONFIGURATION_TEXT) { expect(message).not.toContain(forbidden); diff --git a/test/worker/workerFetch.advanced-cde-webhook.test.ts b/test/worker/workerFetch.advanced-cde-webhook.test.ts index 83c5c4c7..40b1a866 100644 --- a/test/worker/workerFetch.advanced-cde-webhook.test.ts +++ b/test/worker/workerFetch.advanced-cde-webhook.test.ts @@ -612,22 +612,11 @@ describe("advanced CDE generation", () => { }); describe("Wompi webhook integration", () => { - const losslessBodyEdges: Array<{ + const strictBodyEdges: Array<{ name: string; marker: string; - mutateStored?: (raw: Record) => void; mutateIncoming: (raw: Record) => void; }> = [ - { - name: "an unknown nested extra value", - marker: "unknown-edge-private-marker", - mutateIncoming: (raw) => { - raw.ProviderEvidence = { - nested: { decision: "unknown-edge-private-marker" }, - steps: [1, { approved: true }] - }; - } - }, { name: "explicit null instead of a missing member", marker: "IdExterno", @@ -648,33 +637,14 @@ describe("Wompi webhook integration", () => { mutateIncoming: (raw) => { raw.Cantidad = "1"; } - }, - { - name: "a different nested array order", - marker: "array-order-private-marker", - mutateStored: (raw) => { - raw.ProviderEvidence = { - history: [1, 2, { note: "array-order-private-marker" }] - }; - }, - mutateIncoming: (raw) => { - raw.ProviderEvidence = { - history: [2, 1, { note: "array-order-private-marker" }] - }; - } } ]; it.each( (["same-ID", "alternate-ID"] as const).flatMap((replayKind) => - losslessBodyEdges.map((edge) => ({ replayKind, ...edge })) + strictBodyEdges.map((edge) => ({ replayKind, ...edge })) ) - )("rejects a $replayKind replay with $name", async ({ - replayKind, - marker, - mutateStored, - mutateIncoming - }) => { + )("rejects a $replayKind replay with $name", async ({ replayKind, marker, mutateIncoming }) => { const db = new InMemoryD1(); seedCollisionIntent(db); const alternate = replayKind === "alternate-ID"; @@ -683,7 +653,6 @@ describe("Wompi webhook integration", () => { }); const storedRaw = structuredClone(storedPayload) as unknown as Record; const incomingRaw = structuredClone(storedPayload) as unknown as Record; - mutateStored?.(storedRaw); mutateIncoming(incomingRaw); if (alternate) { incomingRaw.IdTransaccion = "lossless-alternate-transaction"; @@ -720,6 +689,85 @@ describe("Wompi webhook integration", () => { expect(boundedOutput).not.toContain(String(incomingRaw.IdTransaccion)); }); + it.each( + (["same-ID", "alternate-ID"] as const).flatMap((replayKind) => [ + { + replayKind, + name: "an added unknown provider field", + mutateStored: (_raw: Record): void => {}, + mutateIncoming: (raw: Record) => { + raw.ProviderEvidence = { + nested: { decision: "provider-added-after-first-delivery" }, + steps: [1, { approved: true }] + }; + } + }, + { + replayKind, + name: "changed unknown provider metadata", + mutateStored: (raw: Record) => { + raw.ProviderEvidence = { history: [1, 2] }; + }, + mutateIncoming: (raw: Record) => { + raw.ProviderEvidence = { history: [2, 1] }; + } + }, + { + replayKind, + name: "a later authorization code", + mutateStored: (raw: Record) => { + raw.CodigoAutorizacion = null; + }, + mutateIncoming: (raw: Record) => { + raw.CodigoAutorizacion = "provider-filled-later"; + } + } + ]) + )("accepts a $replayKind replay with $name and repairs downstream processing", async ({ + replayKind, + mutateStored, + mutateIncoming + }) => { + const db = new InMemoryD1(); + seedCollisionIntent(db); + const alternate = replayKind === "alternate-ID"; + const storedPayload = collisionWebhook({ + IdTransaccion: alternate ? "benign-stored-transaction" : "collision-transaction" + }); + const storedRaw = structuredClone(storedPayload) as unknown as Record; + const incomingRaw = structuredClone(storedPayload) as unknown as Record; + mutateStored(storedRaw); + mutateIncoming(incomingRaw); + if (alternate) { + incomingRaw.IdTransaccion = "benign-alternate-transaction"; + } + const canonicalRawBody = JSON.stringify(storedRaw); + seedCanonicalWompiEvent( + db, + storedPayload, + "wompi_benign_canonical", + canonicalRawBody + ); + const send = vi.fn(); + + const response = await postRawSignedWompi(db, JSON.stringify(incomingRaw), send); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + ok: true, + wompiEventId: "wompi_benign_canonical", + inserted: false, + queued: true + }); + expect(send).toHaveBeenCalledTimes(1); + expect(db.donationIntents[0].paid_at).not.toBeNull(); + expect(db.wompiEvents).toHaveLength(1); + expect(db.wompiEvents[0].raw_body).toBe(canonicalRawBody); + expect(db.audits.find((row) => row.action === "WOMPI_EVENT_CONFLICT")).toBeUndefined(); + expect(db.audits.find((row) => row.action === "WOMPI_DUPLICATE")?.entity_id) + .toBe("wompi_benign_canonical"); + }); + it.each([ { name: "environment", diff --git a/test/worker/workerFetch.auth-infra.test.ts b/test/worker/workerFetch.auth-infra.test.ts index d6c32aeb..99f05dc3 100644 --- a/test/worker/workerFetch.auth-infra.test.ts +++ b/test/worker/workerFetch.auth-infra.test.ts @@ -433,7 +433,7 @@ describe("provider creation budget migration", () => { globalLimit: 20 }); expect(first.kind).toBe("CLAIMED"); - expect(second).toEqual({ kind: "LIMITED" }); + expect(second).toEqual({ kind: "LIMITED", scope: "PROVIDER" }); } finally { database.close(); } @@ -465,6 +465,8 @@ describe("provider creation budget repository", () => { }) )); expect(clientClaims.filter((claim) => claim.kind === "CLAIMED")).toHaveLength(2); + expect(clientClaims.filter((claim) => claim.kind === "LIMITED")) + .toEqual(expect.arrayContaining([{ kind: "LIMITED", scope: "CLIENT" }])); const providerRepo = new Repository(sqliteD1(providerDatabase)); const providerClaims = await Promise.all(Array.from({ length: 20 }, (_, index) => @@ -481,6 +483,8 @@ describe("provider creation budget repository", () => { }) )); expect(providerClaims.filter((claim) => claim.kind === "CLAIMED")).toHaveLength(3); + expect(providerClaims.filter((claim) => claim.kind === "LIMITED")) + .toEqual(expect.arrayContaining([{ kind: "LIMITED", scope: "PROVIDER" }])); const globalRepo = new Repository(sqliteD1(globalDatabase)); const globalClaims = await Promise.all(Array.from({ length: 20 }, (_, index) => @@ -497,6 +501,8 @@ describe("provider creation budget repository", () => { }) )); expect(globalClaims.filter((claim) => claim.kind === "CLAIMED")).toHaveLength(4); + expect(globalClaims.filter((claim) => claim.kind === "LIMITED")) + .toEqual(expect.arrayContaining([{ kind: "LIMITED", scope: "GLOBAL" }])); } finally { clientDatabase.close(); providerDatabase.close(); @@ -525,6 +531,40 @@ describe("provider creation budget repository", () => { expect(providerClaimsFrom(db)).toHaveLength(4); }); + it("counts legacy per-client claims during the rolling deployment", async () => { + const database = migratedDatabase(); + try { + const insert = database.prepare( + `INSERT INTO security_rate_limit_claims ( + id, scope, key_hash, claimed_at, expires_at + ) VALUES (?, 'donation_intent', ?, ?, ?)` + ); + for (let index = 0; index < 5; index += 1) { + insert.run(`legacy_client_${index}`, "legacy-raw-client-hash", now, expiresAt); + } + const repo = new Repository(sqliteD1(database)); + + const claim = await claimProviderCreationBudgetForTest(repo, { + provider: "WOMPI", + clientKeyHash: "normalized-client-hash", + legacyClientKeyHash: "legacy-raw-client-hash", + stripeRequestId: null, + now, + cutoff, + expiresAt, + clientLimit: 5, + providerLimit: 20, + globalLimit: 20 + }); + + expect(claim).toEqual({ kind: "LIMITED", scope: "CLIENT" }); + expect(database.prepare("SELECT COUNT(*) AS count FROM provider_creation_claims").get()) + .toEqual({ count: 0 }); + } finally { + database.close(); + } + }); + it("releases only unused claims and preserves attached Wompi and Stripe evidence", async () => { const database = migratedDatabase(); try { @@ -627,7 +667,7 @@ describe("provider creation budget repository", () => { providerLimit: 1, globalLimit: 20 }); - expect(legacyStripeProviderClaim).toEqual({ kind: "LIMITED" }); + expect(legacyStripeProviderClaim).toEqual({ kind: "LIMITED", scope: "PROVIDER" }); const legacyClaim = await claimProviderCreationBudgetForTest(legacyRepo, { provider: "WOMPI", @@ -640,7 +680,7 @@ describe("provider creation budget repository", () => { providerLimit: 20, globalLimit: 2 }); - expect(legacyClaim).toEqual({ kind: "LIMITED" }); + expect(legacyClaim).toEqual({ kind: "LIMITED", scope: "GLOBAL" }); const attachedRepo = new Repository(sqliteD1(attachedDatabase)); const first = await claimProviderCreationBudgetForTest(attachedRepo, { @@ -2630,6 +2670,7 @@ function bootstrapRequest(options: { token?: string; password?: string } = {}, c type ProviderBudgetTestInput = { provider: "WOMPI" | "STRIPE"; clientKeyHash: string; + legacyClientKeyHash?: string; stripeRequestId: string | null; now: string; cutoff: string; @@ -2642,7 +2683,7 @@ type ProviderBudgetTestInput = { type ProviderBudgetTestResult = | { kind: "CLAIMED"; id: string } | { kind: "DUPLICATE"; id: string } - | { kind: "LIMITED" }; + | { kind: "LIMITED"; scope: "CLIENT" | "PROVIDER" | "GLOBAL" }; async function claimProviderCreationBudgetForTest( repo: Repository, @@ -2652,8 +2693,11 @@ async function claimProviderCreationBudgetForTest( claimProviderCreationBudget?: (value: ProviderBudgetTestInput) => Promise; }).claimProviderCreationBudget; expect(method, "repository exposes the provider creation claim boundary").toBeTypeOf("function"); - if (!method) return { kind: "LIMITED" }; - return method.call(repo, input); + if (!method) return { kind: "LIMITED", scope: "GLOBAL" }; + return method.call(repo, { + ...input, + legacyClientKeyHash: input.legacyClientKeyHash ?? input.clientKeyHash + }); } async function releaseProviderCreationClaimForTest( diff --git a/test/worker/workerFetch.donation-intents.test.ts b/test/worker/workerFetch.donation-intents.test.ts index aa5c6320..65ebf641 100644 --- a/test/worker/workerFetch.donation-intents.test.ts +++ b/test/worker/workerFetch.donation-intents.test.ts @@ -279,7 +279,7 @@ describe("donation intents", () => { vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:00:00.000Z") }); try { const db = new InMemoryD1(); - for (let index = 0; index < 59; index += 1) { + for (let index = 0; index < 599; index += 1) { db.donationIntents.push({ id: `legacy_intent_${index}`, client_ip: `198.51.100.${index + 1}`, @@ -296,10 +296,10 @@ describe("donation intents", () => { ); expect(responses.filter((response) => response.status === 201)).toHaveLength(1); - expect(responses.filter((response) => response.status === 429)).toHaveLength(19); - expect(responses.find((response) => response.status === 429)?.headers.get("Cache-Control")) + expect(responses.filter((response) => response.status === 503)).toHaveLength(19); + expect(responses.find((response) => response.status === 503)?.headers.get("Cache-Control")) .toBe("no-store"); - expect(db.donationIntents).toHaveLength(60); + expect(db.donationIntents).toHaveLength(600); expect(providerCreationClaims(db)).toHaveLength(1); } finally { vi.useRealTimers(); @@ -635,6 +635,36 @@ describe("donation intents", () => { } }); + it("keeps the legacy per-client budget during an IPv6 rolling deployment", async () => { + const clientIp = "2001:db8:abcd:1::1234"; + const legacyClientKeyHash = await sha256Hex(utf8Bytes(clientIp)); + vi.useFakeTimers({ toFake: ["Date"], now: new Date("2026-07-04T12:00:00.000Z") }); + try { + const db = new InMemoryD1(); + for (let index = 0; index < 5; index += 1) { + db.securityRateLimitClaims.push({ + id: `legacy_provider_client_${index}`, + scope: "donation_intent", + key_hash: legacyClientKeyHash, + claimed_at: `2026-07-04T11:5${index}:00.000Z`, + expires_at: "2026-07-04T12:15:00.000Z" + }); + } + + const response = await worker.fetch( + intentRequest(validIntentBody(), { "cf-connecting-ip": clientIp }), + env(db) + ); + + expect(response.status).toBe(429); + await expect(response.json()).resolves.toMatchObject({ error: "too_many_attempts" }); + expect(db.donationIntents).toHaveLength(0); + expect(providerCreationClaims(db)).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + it("returns 502 and leaves the intent PENDING when a fiscally-ready Wompi link request fails", async () => { const db = new InMemoryD1(); const fetchSpy = vi @@ -690,6 +720,7 @@ describe("donation intents", () => { it("returns the donor-safe 502 and leaves the intent PENDING when fiscal readiness is invalid", async () => { const db = new InMemoryD1(); const fetchSpy = vi.spyOn(globalThis, "fetch"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); try { const response = await worker.fetch( intentRequest(validIntentBody()), @@ -707,8 +738,16 @@ describe("donation intents", () => { expect(db.donationIntents[0].provider_creation_claim_id) .toBe(providerCreationClaims(db)[0].id); expect(fetchSpy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith({ + event: "wompi_link_create_failed", + app_env: "local", + error_name: "wompiapierror", + error_code: "wompi_configuration_error" + }); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain("APP_ORIGIN"); } finally { fetchSpy.mockRestore(); + errorSpy.mockRestore(); } }); diff --git a/test/worker/workerFetch.infra.test.ts b/test/worker/workerFetch.infra.test.ts index 7c3d9c19..a2d8f89b 100644 --- a/test/worker/workerFetch.infra.test.ts +++ b/test/worker/workerFetch.infra.test.ts @@ -29,10 +29,15 @@ describe("production HSTS policy", () => { new Request("https://example.org/api/health"), env(new InMemoryD1(), { APP_ENV: "staging" }) ); + const normalizedProductionResponse = await worker.fetch( + new Request("https://example.org/api/health"), + env(new InMemoryD1(), { APP_ENV: " Production " }) + ); expect(productionResponse.status).toBe(200); expectConservativeHsts(productionResponse); await expect(productionResponse.json()).resolves.toMatchObject({ ok: true, appEnv: "production" }); + expectConservativeHsts(normalizedProductionResponse); expect(stagingResponse.status).toBe(200); expect(stagingResponse.headers.has("Strict-Transport-Security")).toBe(false); await expect(stagingResponse.json()).resolves.toMatchObject({ ok: true, appEnv: "staging" });