diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aed0d59..5b8ae490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security +- Aborted the shared SQLite transaction when a Checkout-attempt savepoint + rollback cannot confirm state, preserving the causal write error and marking + a connection that also cannot roll back as unsafe to reuse. - Made contextual-orchestrator briefing requests fail closed unless an authenticated endpoint is configured. Deterministic generated text is restricted to explicit `SCOPEWEAVE_DEV=1`, message/provider responses are bounded and validated, and non-loopback HTTP transport is rejected. +- Persisted a tenant/price-scoped Stripe Checkout attempt identity and opaque + idempotency key before live Session creation, reusing unresolved identity only + inside a 23-hour safety window; network/abort, Stripe 5xx, malformed or + untrusted successful responses, and local success-persistence failures remain + pending for same-key retry or reconciliation, while known Stripe 4xx outcomes + other than concurrent 409 conflicts close the attempt before a later deliberate + Checkout receives fresh authority. - Bounded hosted Stripe Checkout provider calls to one 15-second, no-retry attempt with a 1 MiB response ceiling before JSON parsing until durable idempotency exists; validated returned destinations as exact HTTPS @@ -120,4 +130,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.0.1] - 2026-06-25 ### 성능 개선 (Performance) -- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하는 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. +- 드래그 앤 드롭 동작 중 `dragover` 이벤트에서 발생하던 O(N) 작업 리스트 검색 성능 병목 문제를, O(1) 해시맵(Map) 기반의 캐싱 조회 로직으로 개선하여 큰 크기의 WBS 리스트에서의 버벅임 현상을 해결했습니다. diff --git a/docs/billing-production.md b/docs/billing-production.md index ff6dff15..951dbf01 100644 --- a/docs/billing-production.md +++ b/docs/billing-production.md @@ -52,13 +52,12 @@ request host cannot replace that origin. ## Provider trust boundary -> Active PR state: this section describes the stacked provider-boundary work in -> PR #507. It is not protected-`develop` shipped truth until its parent PR #505 -> and this PR are independently approved and integrated. +> Active PR state: the provider-boundary behavior originates in stacked PR #507; +> the durable retry behavior below is active PR #511. Neither is protected- +> `develop` shipped truth until the stack is independently approved and integrated. The live hosted-Checkout adapter performs one direct server-side HTTPS request to -Stripe until a later lifecycle slice introduces durable checkout-attempt and -idempotency state: +Stripe for each ScopeWeave attempt: - endpoint: exact constant `https://api.stripe.com/v1/checkout/sessions`; - method/body: one POST with `application/x-www-form-urlencoded` fields; @@ -66,12 +65,12 @@ idempotency state: constant Stripe API authority; - total request budget: 15,000 ms using an abort signal; - redirect policy: provider HTTP redirects are rejected; -- automatic application retries: none; +- automatic in-request retry loop: none; - successful response budget: at most 1 MiB before UTF-8 decoding and JSON parsing; invalid, negative, or oversized `Content-Length` declarations are rejected, and a streamed body that crosses the ceiling is cancelled; -- network, abort, or non-2xx provider failures: stable HTTP 502 - `billing_provider_unavailable` with `Cache-Control: no-store`; +- provider/network failures: stable HTTP 502 `billing_provider_unavailable` with + `Cache-Control: no-store`; - successful non-JSON, bodyless, unreadable, malformed JSON, or oversized responses: stable HTTP 502 `billing_provider_invalid_response` with `Cache-Control: no-store`; @@ -83,9 +82,9 @@ idempotency state: This exact-host check deliberately rejects suffix-confusion names such as `checkout.stripe.com.evil.example`. Stripe's current Checkout Session API reference shows a standard hosted `checkout.stripe.com` URL containing an opaque -`#fidk...` fragment. ScopeWeave therefore preserves provider-issued fragments -verbatim after the authority checks instead of treating a fragment as an origin -or hostname decision. +client fragment. ScopeWeave preserves provider-issued fragments verbatim after +the authority checks because fragments do not participate in HTTPS authority +selection. Stripe Checkout custom domains are not silently trusted. Supporting one requires a separate operator-owned allowlist or canonical-domain configuration contract @@ -98,24 +97,74 @@ customer receives a retry/diagnostic next action rather than downstream internal The provider boundary intentionally uses the documented Stripe HTTPS API instead of dynamically importing an undeclared runtime SDK. A clean deployment therefore does not depend on a hidden `stripe` package merely to create the hosted Session. -Package provenance remains part of the normal application supply-chain gate, but -there is no Stripe SDK package gate for this direct adapter. -## Current lifecycle boundary +## Durable Checkout attempt and idempotency boundary + +> Active PR state: this section describes PR #511 only. It is not yet release or +> protected-`develop` truth. + +Before the live POST, ScopeWeave persists a `billing_checkout_attempts` row with +an opaque local attempt ID, tenant/price scope, and an opaque Stripe idempotency +key. A partial unique index permits at most one unresolved attempt (`pending` or +`reconciliation_required`) for the same organization and price. The generated +key is sent as the Stripe `Idempotency-Key` header; no secret key, bearer token, +or webhook secret is stored in this ledger. + +An unresolved attempt is reused only while its age is non-negative and strictly +less than 23 hours. The 23-hour local ceiling is intentionally shorter than +Stripe's documented 24-hour safe-retry horizon / at-least-24-hour key-retention +boundary. At or beyond that local ceiling, or after a local clock rollback, the +old unresolved attempt becomes `reconciliation_required`; checkout then fails +closed until authoritative provider/webhook reconciliation resolves that held +identity. ScopeWeave does not mint a fresh key merely because local time is stale +or contradictory. + +Provider outcomes are intentionally asymmetric: + +- **network/abort/no HTTP response** — provider outcome is unknown; keep the + attempt `pending` so the next checkout reuses the exact key; +- **Stripe 5xx** — keep the attempt `pending`; Stripe explicitly documents 500 + mutations as indeterminate and warns that retrying with a fresh key can repeat + side effects; +- **Stripe 4xx other than concurrent 409** — close as `provider_failed`; a + corrected deliberate retry can use fresh provider authority. A 409 caused by + a concurrent request remains unresolved because Stripe says endpoint execution + did not begin and the same idempotency key may be retried; +- **validated 2xx Checkout Session** — validate provider session ID and hosted + destination, persist `provider_succeeded` plus the provider session ID, then + return the hosted URL; +- **2xx with malformed, over-budget, unreadable, or untrusted content** — the + provider may already have committed the mutation, so keep the attempt `pending` + and return only the stable sanitized error contract; a later retry must reuse + the same idempotency key rather than create a speculative second Session; +- **provider success followed by local persistence failure** — return + `billing_checkout_state_unavailable` and leave the attempt pending. A later + checkout can replay the same key instead of creating a second provider object. + +Repository construction and request handling perform no DDL. The schema is +installed during database bootstrap after the organization table exists. That +matches the repository's current migration style, but billing release approval +remains blocked until this schema is reconciled with the formal migration-ledger, +restore, and rollback work elsewhere in the repository. + +`docs/doctoring/stripe-checkout-attempt-idempotency.md` records the evidence, +TDD chronology, data model, threat/rollback reasoning, and APA 7 references. -The trusted-configuration and provider-trust slices do **not** declare the Stripe -subscription lifecycle production complete. Before production billing can be -release-approved, ScopeWeave still needs the remaining #488 controls, including: +## Current lifecycle boundary -a durable checkout-attempt UUID and stable idempotency key; raw-body webhook -signature verification and streaming size limits; durable event deduplication; -out-of-order reconciliation; normalized customer/subscription/payment/entitlement -state; transactional reversible entitlement changes; migration and restore -evidence; privacy/incident runbooks; and provider smoke plus release acceptance. +The trusted-configuration, provider-trust, and durable-attempt slices do **not** +declare the Stripe subscription lifecycle production complete. Before production +billing can be release-approved, ScopeWeave still needs the remaining #488 +controls, including raw-body webhook signature verification and streaming size +limits; durable event deduplication; out-of-order reconciliation; normalized +customer/subscription/payment/entitlement state; transactional reversible +entitlement changes; migration and restore evidence; retention/privacy/incident +runbooks; provider smoke plus release acceptance; and operator-visible alerting, +inspection, and audited resolution for `reconciliation_required` attempts. -No automatic provider retry should be enabled before durable idempotency exists. No custom Checkout domain should be accepted before an operator-owned trust -configuration exists. +configuration exists. No unresolved attempt record should be deleted merely to +force a retry with a fresh provider key. ## Operator verification @@ -131,23 +180,34 @@ Before a billing-enabled rollout: 4. Capture the canary's outbound request destination and verify exactly one POST goes to `api.stripe.com/v1/checkout/sessions`, redirects are not followed, and the request aborts within the configured 15-second total budget. -5. Exercise network failure, non-2xx response, non-JSON success, malformed JSON, - bodyless success, invalid/oversized declared response length, streamed - response overflow, stream-read failure, and provider timeout handling. - Confirm response bodies above 1 MiB are not buffered/parsed and callers - receive only the stable no-store 502 contract without provider body, network, - stream, or credential detail. -6. Reject null, malformed, plaintext, credential-bearing, non-standard-port, +5. Confirm the outbound request contains an opaque `Idempotency-Key`, then force a + transport timeout and retry the same organization/price inside the local + safety window. The second request must reuse the same key and local attempt ID. +6. Exercise HTTP 400 and HTTP 503 responses separately. A 400 must close the + attempt so a later deliberate checkout receives a fresh key; a 503 must leave + the attempt pending so a later retry cannot silently duplicate provider side + effects. +7. Exercise non-JSON success, malformed JSON, bodyless success, + invalid/oversized declared response length, streamed response overflow, + stream-read failure, and provider timeout handling. Confirm response bodies + above 1 MiB are not buffered/parsed, callers receive only the stable no-store + error contract without provider body/network/credential detail, and every + malformed 2xx case remains pending for same-key retry/reconciliation. +8. Reject null, malformed, plaintext, credential-bearing, non-standard-port, and hostname-confusion Checkout destinations; accept and preserve the exact standard `https://checkout.stripe.com/...#...` hosted destination, including its provider-issued fragment. -7. Keep the rollout blocked until the remaining #488 lifecycle controls are - implemented and their exact-head security, coverage, review, rollback, and - recovery gates pass together. - -Rollback for the trusted-configuration/provider-boundary stack is data-neutral: -revert the validation and provider-boundary source, tests, documentation, and -CHANGELOG entries together. No database migration or persisted billing state is -introduced by these slices. If billing must be disabled while investigating a -provider outage, remove the complete live provider tuple and restart; never -substitute a production mock. +9. Simulate a successful Stripe response followed by a local state-write failure. + The customer must receive `billing_checkout_state_unavailable`, and a later + retry must preserve the original idempotency identity rather than minting a + duplicate Checkout Session. +10. Keep the rollout blocked until the remaining #488 lifecycle controls and the + formal migration/restore path are implemented and their exact-head security, + coverage, review, rollback, and recovery gates pass together. + +Rollback is no longer data-neutral once PR #511 exists. Disable the complete live +Stripe configuration and restart before reverting request-path code. Preserve +`pending`, `reconciliation_required`, and `provider_succeeded` attempt rows for +reconciliation. Do not drop or truncate the ledger during a provider incident; +any eventual schema removal must be a reviewed reversible migration with +export/restore evidence. diff --git a/docs/doctoring/stripe-checkout-attempt-idempotency.md b/docs/doctoring/stripe-checkout-attempt-idempotency.md new file mode 100644 index 00000000..74654891 --- /dev/null +++ b/docs/doctoring/stripe-checkout-attempt-idempotency.md @@ -0,0 +1,157 @@ +# Stripe Checkout attempt idempotency — active PR #511 + +## Status and decision + +This document describes **active stacked PR #511**, based on PR #507 at +`f1ca84bab7603cb0882c1a5b4d822c5714daacdb`. It is not protected-`develop` +shipped truth and it is not a production-readiness claim. The slice exists to +make an uncertain Checkout Session creation retryable without silently creating +a second provider object. + +The decision is to persist a ScopeWeave-owned Checkout attempt before the live +Stripe POST and bind exactly one opaque idempotency key to that attempt. The live +transport sends the key as `Idempotency-Key`; an unresolved attempt is reused +only for the same organization and price and only inside a 23-hour local safety +window. Terminal provider outcomes close the local attempt. No authentication +secret or bearer token is stored in the ledger. + +The 23-hour window is intentionally shorter than Stripe's documented 24-hour +retry horizon / at-least-24-hour key retention boundary. It is a conservative +local ceiling, not a claim that Stripe purges every key at exactly 24 hours. + +## Evidence-to-control traceability + +| Primary evidence | ScopeWeave control | Acceptance evidence | +| --- | --- | --- | +| Stripe recommends sufficiently unique keys such as UUID v4 and permits keys up to 255 characters. | Generate opaque UUID-backed `attempt_id` and `idempotency_key`; never derive the provider key from a secret. | `tests/unit/billing-checkout-attempt.test.mjs` | +| Stripe records POST results by idempotency key and compares parameters on reuse. | Persist one organization/price attempt identity and reuse the same key only while that exact attempt is unresolved. | repository reuse/terminal-state tests plus transport header tests | +| A network failure can leave the client unable to know whether Stripe executed the mutation. | Network/abort failures leave the attempt `pending`; the next caller reuses the same key. | `tests/unit/billing-provider-boundary.test.mjs` | +| Stripe documents server errors, especially HTTP 500, as indeterminate and warns that a fresh key can duplicate side effects. | All Stripe 5xx responses keep the attempt `pending`; no fresh key is issued merely because a server-error response arrived. | regression commit `35571be0c0e81359dff09238f5815ed13dcf0440` followed by the production fix | +| A successful HTTP response can still be unusable locally after the provider has performed the mutation. | Malformed, unreadable, over-budget, or untrusted 2xx responses remain unresolved and reuse the same idempotency key instead of closing the attempt. | `tests/unit/billing-checkout-review-regressions.test.mjs` and provider-boundary regressions | +| A received 4xx normally identifies a correctable request failure, but Stripe does not begin endpoint execution for a concurrent idempotency conflict. | Known 4xx responses other than 409 close the current local attempt as `provider_failed`; a concurrent 409 remains pending so the same key can be retried. | provider-boundary 4xx/409 regression | +| Checkout Sessions expose `client_reference_id` for reconciliation with internal systems. | Send organization identity as `client_reference_id` and metadata while retaining a separate opaque local attempt ID. | transport form assertions | + +## Data model + +`billing_checkout_attempts` is the only new persisted object in this slice. Its +owned names are descriptive multi-word `snake_case` identifiers. + +- `attempt_id`: opaque local primary key. +- `organization_id`: tenant boundary; foreign key to the existing organization + row and cascade-deleted with it. +- `price_id`: server-owned Stripe price identity used for the request. +- `idempotency_key`: unique opaque Stripe POST identity. +- `attempt_state`: `pending`, `provider_succeeded`, `provider_failed`, or + `reconciliation_required`. +- `provider_session_id`: populated only after a validated successful provider + response. +- `created_at_ms` / `updated_at_ms`: bounded local lifecycle timestamps. + +A partial unique index on `(organization_id, price_id)` while the state is +`pending` or `reconciliation_required` prevents two unresolved retry identities +for the same tenant/price. The repository uses a savepoint around each synchronous +state mutation; if savepoint rollback cannot confirm the state, it aborts the +shared transaction and preserves the causal error, and a connection that also +cannot roll back must be discarded. Clock rollback is fail-safe: a pending +attempt whose calculated age is negative is moved to `reconciliation_required` +rather than silently replayed, and terminal writes clamp `updated_at_ms` to at +least `created_at_ms` so a provider outcome can still be recorded without +violating the timestamp constraint. + +The table is installed only during database bootstrap after the referenced +organization table exists. Repository construction and request handling do not +perform DDL. This is compatible with the repository's current bootstrap pattern, +but it is **not** a substitute for the formal migration-ledger/recovery work that +must converge before billing release approval. + +## Failure semantics + +1. **No provider response / transport abort** — customer receives stable no-store + `billing_provider_unavailable`; local attempt remains pending. +2. **Stripe 5xx** — customer receives the same sanitized 502; local attempt + remains pending because provider side effects are indeterminate. +3. **Stripe known 4xx other than concurrent 409** — customer receives sanitized + 502; the local attempt becomes `provider_failed` so a later corrected Checkout + can use a fresh key. A concurrent 409 remains pending because Stripe allows + retrying the same idempotency key when endpoint execution did not begin. +4. **Successful HTTP response with malformed/unbounded/untrusted content** — the + provider may already have committed the mutation, so the local attempt remains + pending; no provider body, network address, or credential is reflected to the + caller, and a later retry reuses the same idempotency key. +5. **Validated provider success** — persist `provider_session_id` and + `provider_succeeded` before returning the hosted URL. +6. **Provider success but local success-state commit fails** — fail closed with + `billing_checkout_state_unavailable`; the attempt remains pending. A later + request can replay the same provider key and recover the cached Session rather + than create a new one. +7. **Known provider failure but local failure-state commit fails** — fail closed + with `billing_checkout_state_unavailable`; do not pretend the local ledger is + authoritative. +8. **Stale or clock-ambiguous unresolved attempt** — move it to + `reconciliation_required` and fail closed. No fresh key is issued until an + authoritative reconciliation path resolves that held identity. + +## TDD chronology + +The first child commit, `02f1728f0f271b258e7b0260c5806d51e6a68e2a`, added the +durable-attempt contract while `server/billing_checkout_attempt.mjs` did not yet +exist. Subsequent implementation commits added the repository, bootstrap wiring, +provider binding, coverage registration, and real failure-boundary tests. + +During primary-source reconciliation, Stripe's server-error guidance exposed a +semantic defect in the first implementation: every received non-2xx response was +being treated as a known terminal failure. Regression commit +`35571be0c0e81359dff09238f5815ed13dcf0440` changed the test contract first so a +503 must keep the attempt pending while a 400 closes it. Production commit +`fee01e7dd3055f1aedc0ef12e094536d7af05d13` then made all 5xx responses +indeterminate. + +A later current-head review exposed two additional causal defects and one +defensive configuration diagnostic: malformed 2xx outcomes were being closed as +known failures, and terminal ledger writes failed the timestamp CHECK after wall- +clock rollback. Regression file `tests/unit/billing-checkout-review-regressions.test.mjs` +was registered in the real unit/coverage gates before the production fix. The +exact merge checkout for head `e8abdf9bddb609aa5504a5c680e104772408d5d3` +failed all four targeted assertions, including both SQLite CHECK violations and +the missing-price diagnostic mismatch. The production fix must obtain its own +exact-head GREEN evidence before integration; predecessor success is not reused. + +## Security, privacy, and operability boundaries + +The ledger stores operational identifiers, not Stripe credentials. Tenant scope +is explicit in every lookup and the unresolved uniqueness constraint. Error +payloads remain no-store and sanitized. The new local attempt ID is suitable for +audit and support correlation, but customer-facing workflows should not treat it +as an authorization credential. + +This slice still does **not** provide raw-body webhook verification, durable event +deduplication, out-of-order subscription reconciliation, normalized +customer/subscription/payment/entitlement state, retention cleanup policy, +operator-visible attempt inspection/alerting/audited resolution, formal schema +migrations, restore proof, or release acceptance. In particular, webhook or +another authoritative provider reconciliation path is required to resolve Stripe +5xx and malformed-2xx cases that may have produced provider-side objects, and +`reconciliation_required` remains intentionally blocking until that follow-up +slice exists. + +## Rollback + +Do not drop the table as an emergency rollback step. First disable the complete +live Stripe configuration and restart so no new live attempts are created. Revert +the live-route/idempotency code only after preserving any `pending`, +`reconciliation_required`, or `provider_succeeded` rows needed for incident +reconciliation. Schema removal, if ever required, belongs in a reviewed reversible +migration with export/restore proof; deleting the ledger during an unresolved +provider incident would destroy the evidence needed to avoid duplicate Checkout +Sessions. + +## References + +Stripe, Inc. (n.d.). *Advanced error handling*. Stripe Documentation. Retrieved +August 16, 2026, from https://docs.stripe.com/error-low-level + +Stripe, Inc. (n.d.). *Create a Checkout Session*. Stripe API Reference. Retrieved +August 16, 2026, from https://docs.stripe.com/api/checkout/sessions/create + +Stripe, Inc. (n.d.). *Idempotent requests*. Stripe API Reference. Retrieved +August 16, 2026, from https://docs.stripe.com/api/idempotent_requests diff --git a/package.json b/package.json index 1db0fc3e..86f0fbc0 100644 --- a/package.json +++ b/package.json @@ -12,10 +12,10 @@ "check:python-docstrings": "node scripts/ci/static_coverage_evidence.mjs docstrings", "coverage": "npm run test:coverage", "server": "node server/server.mjs", - "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs", - "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/toast-accessibility.test.mjs", - "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", - "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && npm run test:api", + "test:api": "node tests/api/auth-secret.test.mjs && node --env-file=tests/api/smoke.env tests/api/smoke.mjs && node tests/api/ratelimit.test.mjs && node tests/api/attachment-status.test.mjs && node tests/api/session-revocation.test.mjs && node tests/api/orchestrator-attribution.test.mjs && node tests/api/billing-checkout.test.mjs && node tests/api/billing-live-checkout.test.mjs", + "test:unit": "node tests/unit/opencode-config.test.mjs && node tests/unit/changelog-release-notes.test.mjs && node tests/unit/analytics.test.mjs && node tests/unit/cpm.test.mjs && node tests/unit/baseline-compare.test.mjs && node tests/unit/workload.test.mjs && node tests/unit/cost-evm.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/dep-types.test.mjs && node tests/unit/weekly-report.test.mjs && node tests/unit/clearfolio.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/sprint-stats.test.mjs && node tests/unit/burndown.test.mjs && node tests/unit/pm-analysis.test.mjs && node tests/unit/cloud-sync-security.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/coverage-script-contract.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && node tests/unit/toast-accessibility.test.mjs", + "test:coverage": "c8 --all --include=app.js --include=cloud-sync.js --include=scripts/ci/static_coverage_evidence.mjs --include=server/attachment_status.mjs --include=server/app.mjs --include=server/auth.mjs --include=server/billing.mjs --include=server/billing_checkout_attempt.mjs --include=server/billing_configuration.mjs --include=server/clearfolio.mjs --include=server/orchestrator.mjs --reporter=json --reporter=json-summary npm run test:coverage:cases", + "test:coverage:cases": "node tests/unit/coverage-script-contract.test.mjs && node tests/unit/attachment-status.test.mjs && node tests/unit/clearfolio-status-signal.test.mjs && node tests/unit/clearfolio-adapter-mock-hmac.test.mjs && node tests/unit/orchestrator.test.mjs && node tests/unit/orchestrator-coverage.test.mjs && node tests/unit/orchestrator-attribution.test.mjs && node tests/unit/msproject.test.mjs && node tests/unit/auth-password.test.mjs && node tests/unit/editor-unsaved.test.mjs && node tests/unit/static-coverage-evidence.test.mjs && node tests/unit/billing-configuration.test.mjs && node tests/unit/billing-checkout-attempt.test.mjs && node tests/unit/billing-checkout-attempt-authority.test.mjs && node tests/unit/billing-checkout.test.mjs && node tests/unit/billing-provider-boundary.test.mjs && node tests/unit/billing-checkout-review-regressions.test.mjs && npm run test:api", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:cloud": "playwright install chromium && playwright test tests/e2e/cloud.spec.js tests/e2e/toast-accessibility.spec.js", diff --git a/server/billing.mjs b/server/billing.mjs index 9df39904..837db923 100644 --- a/server/billing.mjs +++ b/server/billing.mjs @@ -8,6 +8,7 @@ const billingConfiguration = validateBillingStartupConfiguration(); const STRIPE_CHECKOUT_ENDPOINT = 'https://api.stripe.com/v1/checkout/sessions'; const STRIPE_REQUEST_TIMEOUT_MS = 15_000; const STRIPE_RESPONSE_MAX_BYTES = 1024 * 1024; +const STRIPE_PROVIDER_ID_MAX_LENGTH = 255; export const PLANS = { free: { name: 'Free', limits: { projects: 2, members: 3 }, priceKrw: 0 }, @@ -51,26 +52,70 @@ function billingUnavailableResponse() { ); } -function providerFailure(code, action) { - return new HTTPException(502, { +function checkoutStateFailure() { + return new HTTPException(503, { + res: jsonErrorResponse( + 503, + 'billing_checkout_state_unavailable', + 'Retry checkout after durable billing state is healthy; do not bypass the checkout-attempt ledger.', + ), + }); +} + +function checkoutReconciliationRequiredFailure() { + return new HTTPException(503, { + res: jsonErrorResponse( + 503, + 'billing_checkout_reconciliation_required', + 'Reconcile the existing Checkout attempt with authoritative Stripe or webhook state before starting or retrying Checkout.', + ), + }); +} + +function providerFailure(code, action, { outcomeKnown = false } = {}) { + const error = new HTTPException(502, { res: jsonErrorResponse(502, code, action), }); + Object.defineProperty(error, 'providerOutcomeKnown', { + value: outcomeKnown, + enumerable: false, + }); + return error; } -function providerUnavailableFailure() { +function providerUnavailableFailure(outcomeKnown = false) { return providerFailure( 'billing_provider_unavailable', 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + { outcomeKnown }, ); } function providerInvalidResponseFailure() { + // This error is raised only after Stripe has returned a successful HTTP status + // or an SDK-style call has returned a session-like value. The provider may + // already have committed the mutation, so the outcome is not known merely + // because the response representation is unusable. Preserve the durable + // idempotency identity for an authoritative replay/reconciliation path. return providerFailure( 'billing_provider_invalid_response', 'Retry checkout. If the problem persists, verify the Stripe Checkout provider configuration and service health.', + { outcomeKnown: false }, ); } +function providerOutcomeKnownForStatus(status) { + const statusCode = Number(status); + return Number.isInteger(statusCode) + && statusCode >= 400 + && statusCode < 500 + && statusCode !== 409; +} + +function providerOutcomeKnownForError(error) { + return providerOutcomeKnownForStatus(error?.statusCode ?? error?.status); +} + function stripeCheckoutForm(payload) { return new URLSearchParams([ ['mode', payload.mode], @@ -148,7 +193,7 @@ async function cancelUnreadProviderBody(response) { } } -async function createStripeSessionWithFetch(secretKey, payload) { +async function createStripeSessionWithFetch(secretKey, payload, idempotencyKey) { let response; try { response = await fetch(STRIPE_CHECKOUT_ENDPOINT, { @@ -158,16 +203,27 @@ async function createStripeSessionWithFetch(secretKey, payload) { headers: { authorization: `Bearer ${secretKey}`, 'content-type': 'application/x-www-form-urlencoded', + 'idempotency-key': idempotencyKey, }, body: stripeCheckoutForm(payload).toString(), }); } catch { - throw providerUnavailableFailure(); + // No HTTP response means the provider outcome is uncertain. Keep the durable + // pending attempt so the next caller reuses this exact idempotency key. + throw providerUnavailableFailure(false); } if (!response.ok) { + // Stripe explicitly treats 5xx mutations, especially 500, as indeterminate: + // the original request can have produced side effects even though the client + // received an error. Preserve the pending identity for every server error so + // no later caller silently creates a second Checkout Session with a fresh key. + // A concurrent idempotent request returns 409 before endpoint execution and + // remains retryable with the same key. Other received 4xx responses are known + // request failures and can safely receive fresh authority after correction. + const outcomeKnown = providerOutcomeKnownForStatus(response.status); await cancelUnreadProviderBody(response); - throw providerUnavailableFailure(); + throw providerUnavailableFailure(outcomeKnown); } const mediaType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase(); @@ -206,6 +262,45 @@ function validateHostedCheckoutUrl(rawUrl) { return rawUrl; } +function validateProviderSessionId(value) { + if (typeof value !== 'string' || value.length === 0 || value.length > STRIPE_PROVIDER_ID_MAX_LENGTH) { + throw providerInvalidResponseFailure(); + } + return value; +} + +function requireAttemptRepository(repository) { + if (!repository + || typeof repository.startAttempt !== 'function' + || typeof repository.markProviderSucceeded !== 'function' + || typeof repository.markProviderFailed !== 'function') { + throw checkoutStateFailure(); + } + return repository; +} + +async function resolveAttemptRepository(repository) { + if (repository !== undefined) return requireAttemptRepository(repository); + try { + // The app already owns the database singleton. Dynamic resolution keeps the + // billing domain directly testable without opening a database at import time, + // while the real live route still uses the bootstrap-installed durable port. + const { billingCheckoutAttempts } = await import('./db.mjs'); + return requireAttemptRepository(billingCheckoutAttempts); + } catch { + throw checkoutStateFailure(); + } +} + +function markKnownProviderFailure(repository, attemptId, error) { + if (error?.providerOutcomeKnown !== true) return; + try { + repository.markProviderFailed({ attemptId }); + } catch { + throw checkoutStateFailure(); + } +} + /** * Create one hosted checkout session from trusted server-owned configuration. * @@ -213,24 +308,32 @@ function validateHostedCheckoutUrl(rawUrl) { * URLs always derive from the canonical operator-configured public origin. The * successful mock exists only in explicit development mode; an unconfigured * production capability returns HTTP 503 instead of pretending checkout worked. - * Live provider calls use one direct HTTPS attempt with a 15-second total budget - * and a 1 MiB response-body ceiling until durable checkout-attempt idempotency - * state exists. The hosted destination must use Stripe's standard HTTPS authority; - * provider-issued client fragments are preserved verbatim. + * Live provider calls use one direct HTTPS attempt with a 15-second total budget, + * a 1 MiB response-body ceiling, and a durable per-attempt idempotency key. + * Network/abort, Stripe 5xx, and malformed/untrusted 2xx response outcomes keep + * the attempt pending so a later call reuses the same key; known 4xx responses + * other than concurrent 409 conflicts close the attempt so a deliberate later + * Checkout gets fresh provider authority. + * The hosted destination must use Stripe's standard HTTPS authority; provider- + * issued client fragments are preserved verbatim. * * @param {object} options - Checkout inputs and optional deterministic test seams. * @param {string|number} options.orgId - Organization that owns the checkout. * @param {{mode: 'disabled'|'mock'|'live', publicOrigin: string|null}} [options.configuration] * Validated billing capability; defaults to startup configuration. + * @param {{startAttempt: Function, markProviderSucceeded: Function, markProviderFailed: Function}} [options.attemptRepository] + * Durable live-mode Checkout-attempt persistence port. Production resolves the + * bootstrap-installed database port when omitted; tests should inject a seam. * @param {(secretKey: string) => Promise} [options.stripeClientFactory] * Optional Stripe-compatible test seam. Production uses the direct HTTPS transport. - * @returns {Promise<{url: string, live: boolean, mock?: boolean}>} Checkout target. - * @throws {HTTPException} HTTP 503 when production billing is not configured; + * @returns {Promise<{url: string, live: boolean, mock?: boolean, checkoutAttemptId?: string}>} Checkout target. + * @throws {HTTPException} HTTP 503 when production billing/state is unavailable; * HTTP 502 when the provider call fails or returns an untrusted destination. */ export async function createCheckout({ orgId, configuration = billingConfiguration, + attemptRepository, stripeClientFactory, }) { const { mode, publicOrigin } = configuration; @@ -241,6 +344,19 @@ export async function createCheckout({ if (mode === 'live') { const secretKey = String(process.env.STRIPE_SECRET_KEY || '').trim(); const priceId = String(process.env.STRIPE_PRICE_ID || '').trim(); + const repository = await resolveAttemptRepository(attemptRepository); + if (typeof priceId !== 'string' || priceId.trim().length === 0) { + throw new HTTPException(503, { res: billingUnavailableResponse() }); + } + let attempt; + try { + attempt = repository.startAttempt({ organizationId: orgId, priceId }); + } catch (error) { + if (error?.code === 'billing_checkout_reconciliation_required') { + throw checkoutReconciliationRequiredFailure(); + } + throw checkoutStateFailure(); + } const payload = { mode: 'subscription', line_items: [{ price: priceId, quantity: 1 }], @@ -251,21 +367,47 @@ export async function createCheckout({ }; let session; - if (stripeClientFactory) { + try { + if (stripeClientFactory) { + try { + const stripe = await stripeClientFactory(secretKey); + session = await stripe.checkout.sessions.create(payload, { + idempotencyKey: attempt.idempotencyKey, + }); + } catch (error) { + // The injected seam models an SDK/network boundary. Without a concrete + // provider response, its outcome is uncertain; Stripe SDK status codes + // preserve the same known-4xx/409 semantics as the direct transport. + throw providerUnavailableFailure(providerOutcomeKnownForError(error)); + } + } else { + session = await createStripeSessionWithFetch( + secretKey, + payload, + attempt.idempotencyKey, + ); + } + + const providerSessionId = validateProviderSessionId(session?.id); + const hostedUrl = validateHostedCheckoutUrl(session?.url); try { - const stripe = await stripeClientFactory(secretKey); - session = await stripe.checkout.sessions.create(payload); + repository.markProviderSucceeded({ + attemptId: attempt.attemptId, + providerSessionId, + }); } catch { - throw providerUnavailableFailure(); + throw checkoutStateFailure(); } - } else { - session = await createStripeSessionWithFetch(secretKey, payload); - } - return { - url: validateHostedCheckoutUrl(session?.url), - live: true, - }; + return { + url: hostedUrl, + live: true, + checkoutAttemptId: attempt.attemptId, + }; + } catch (error) { + markKnownProviderFailure(repository, attempt.attemptId, error); + throw error; + } } return { url: `${publicOrigin}/?billing=mock&org=${encodeURIComponent(String(orgId))}`, live: false, mock: true }; diff --git a/server/billing_checkout_attempt.mjs b/server/billing_checkout_attempt.mjs new file mode 100644 index 00000000..139c9f68 --- /dev/null +++ b/server/billing_checkout_attempt.mjs @@ -0,0 +1,277 @@ +import { randomUUID as systemRandomUUID } from 'node:crypto'; + +/** + * Maximum age for automatically replaying an unresolved Stripe idempotency key. + * + * Stripe documents a 24-hour safe-retry horizon for POST idempotency. ScopeWeave + * stops automatic replay one hour earlier. Crossing this local ceiling never + * authorizes a fresh key: the attempt moves to `reconciliation_required` so a + * potentially side-effectful provider outcome cannot be duplicated by guesswork. + */ +export const BILLING_CHECKOUT_REUSE_WINDOW_MS = 23 * 60 * 60 * 1000; + +const MAX_PRICE_ID_LENGTH = 255; +const MAX_PROVIDER_SESSION_ID_LENGTH = 255; +const MAX_IDENTIFIER_LENGTH = 255; +const SAVEPOINT_NAME = 'billing_checkout_attempt_write'; + +/** + * Signals that a stale or temporally ambiguous provider attempt must be resolved + * from authoritative provider/webhook state before another Checkout can begin. + */ +export class BillingCheckoutReconciliationRequiredError extends Error { + /** @param {string} attemptId - Opaque local attempt requiring reconciliation. */ + constructor(attemptId) { + super('checkout attempt requires authoritative reconciliation before retry'); + this.name = 'BillingCheckoutReconciliationRequiredError'; + this.code = 'billing_checkout_reconciliation_required'; + Object.defineProperty(this, 'attemptId', { + value: attemptId, + enumerable: false, + }); + } +} + +function positiveOrganizationId(value) { + if (typeof value !== 'number' && typeof value !== 'string') { + throw new TypeError('organizationId must be a positive integer'); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new TypeError('organizationId must be a positive integer'); + } + return parsed; +} + +function boundedRequiredString(value, name, maximumLength) { + if (typeof value !== 'string') throw new TypeError(`${name} must be a non-empty string`); + const normalized = value.trim(); + if (normalized.length === 0 || normalized.length > maximumLength) { + throw new TypeError(`${name} must be a non-empty string no longer than ${maximumLength} characters`); + } + return normalized; +} + +function safeNow(now) { + const value = Number(now()); + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError('checkout attempt clock must return a non-negative safe integer'); + } + return value; +} + +function opaqueIdentifier(randomUUID, name) { + return boundedRequiredString(randomUUID(), name, MAX_IDENTIFIER_LENGTH); +} + +function withSavepoint(database, operation) { + database.exec(`SAVEPOINT ${SAVEPOINT_NAME}`); + try { + const result = operation(); + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + return result; + } catch (error) { + let rollbackSucceeded = false; + try { + database.exec(`ROLLBACK TO SAVEPOINT ${SAVEPOINT_NAME}`); + rollbackSucceeded = true; + } catch { + // Abort the shared transaction when savepoint rollback cannot confirm state. + // If this also fails, the caller must discard the database connection. + try { database.exec('ROLLBACK'); } catch { /* connection is no longer trustworthy */ } + } + if (rollbackSucceeded) { + try { + database.exec(`RELEASE SAVEPOINT ${SAVEPOINT_NAME}`); + } catch { + // Cleanup after a confirmed rollback must not replace the causal operation error. + } + } + throw error; + } +} + +/** + * Install the durable Checkout-attempt schema during process/database bootstrap. + * + * The schema is intentionally separate from request handling. One row represents + * one provider-attempt identity; organization and price facts are referenced or + * recorded once, while provider outcome is a state of that same attempt. The + * partial unique index guarantees at most one unresolved or reconciliation-held + * identity for an organization/price pair. + * + * @param {import('node:sqlite').DatabaseSync} database - Open SQLite database. + * @returns {void} + */ +export function installBillingCheckoutAttemptSchema(database) { + database.exec(` + CREATE TABLE IF NOT EXISTS billing_checkout_attempts ( + attempt_id TEXT PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + price_id TEXT NOT NULL CHECK(length(price_id) BETWEEN 1 AND ${MAX_PRICE_ID_LENGTH}), + idempotency_key TEXT NOT NULL UNIQUE CHECK(length(idempotency_key) BETWEEN 1 AND ${MAX_IDENTIFIER_LENGTH}), + attempt_state TEXT NOT NULL CHECK(attempt_state IN ('pending','provider_succeeded','provider_failed','reconciliation_required')), + provider_session_id TEXT CHECK(provider_session_id IS NULL OR length(provider_session_id) BETWEEN 1 AND ${MAX_PROVIDER_SESSION_ID_LENGTH}), + created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), + updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms), + CHECK( + (attempt_state = 'provider_succeeded' AND provider_session_id IS NOT NULL) + OR (attempt_state <> 'provider_succeeded' AND provider_session_id IS NULL) + ) + ); + CREATE UNIQUE INDEX IF NOT EXISTS billing_checkout_unresolved_attempts + ON billing_checkout_attempts(organization_id, price_id) + WHERE attempt_state IN ('pending','reconciliation_required'); + `); +} + +/** + * Create the SQLite persistence port for Checkout attempt identities. + * + * This constructor never creates database objects. Call + * {@link installBillingCheckoutAttemptSchema} exactly from bootstrap/migration + * code before serving requests. SQL statements are prepared lazily so merely + * constructing the port cannot accidentally turn missing bootstrap into schema + * creation or another hidden startup side effect. + * + * @param {import('node:sqlite').DatabaseSync} database - Bootstrapped database. + * @param {object} [dependencies] - Deterministic seams for tests. + * @param {() => string} [dependencies.randomUUID] - Cryptographic UUID source. + * @param {() => number} [dependencies.now] - Persisted wall-clock milliseconds. + * @returns {{ + * startAttempt(input: {organizationId: string|number, priceId: string}): {attemptId: string, idempotencyKey: string, state: 'pending', reused: boolean}, + * markProviderSucceeded(input: {attemptId: string, providerSessionId: string}): void, + * markProviderFailed(input: {attemptId: string}): void + * }} Checkout-attempt persistence port. + */ +export function createSqliteBillingCheckoutAttemptRepository( + database, + { randomUUID = systemRandomUUID, now = Date.now } = {}, +) { + if (!database || typeof database.prepare !== 'function' || typeof database.exec !== 'function') { + throw new TypeError('database must provide SQLite prepare/exec operations'); + } + if (typeof randomUUID !== 'function') throw new TypeError('randomUUID must be a function'); + if (typeof now !== 'function') throw new TypeError('now must be a function'); + + let preparedStatements; + const statements = () => { + if (preparedStatements) return preparedStatements; + preparedStatements = { + selectUnresolved: database.prepare(` + SELECT attempt_id, idempotency_key, attempt_state, created_at_ms + FROM billing_checkout_attempts + WHERE organization_id = ? + AND price_id = ? + AND attempt_state IN ('pending','reconciliation_required') + LIMIT 1 + `), + requireReconciliation: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'reconciliation_required', updated_at_ms = ? + WHERE attempt_id = ? AND attempt_state = 'pending' + `), + insertAttempt: database.prepare(` + INSERT INTO billing_checkout_attempts( + attempt_id, organization_id, price_id, idempotency_key, + attempt_state, provider_session_id, created_at_ms, updated_at_ms + ) VALUES(?,?,?,?, 'pending', NULL, ?, ?) + `), + succeedAttempt: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_succeeded', provider_session_id = ?, + updated_at_ms = MAX(?, created_at_ms) + WHERE attempt_id = ? AND attempt_state = 'pending' + `), + failAttempt: database.prepare(` + UPDATE billing_checkout_attempts + SET attempt_state = 'provider_failed', updated_at_ms = MAX(?, created_at_ms) + WHERE attempt_id = ? AND attempt_state = 'pending' + `), + }; + return preparedStatements; + }; + + return { + /** + * Reuse a still-pending same-tenant/same-price identity only inside the safe + * replay window. Stale, clock-ambiguous, or already-held attempts fail closed + * for authoritative reconciliation and never mint a speculative fresh key. + */ + startAttempt({ organizationId, priceId }) { + const organization = positiveOrganizationId(organizationId); + const price = boundedRequiredString(priceId, 'priceId', MAX_PRICE_ID_LENGTH); + const nowMs = safeNow(now); + const sql = statements(); + + const result = withSavepoint(database, () => { + const unresolved = sql.selectUnresolved.get(organization, price); + if (unresolved) { + if (unresolved.attempt_state === 'reconciliation_required') { + return { reconciliationRequiredAttemptId: unresolved.attempt_id }; + } + + const createdAtMs = Number(unresolved.created_at_ms); + const ageMs = nowMs - createdAtMs; + if (ageMs >= 0 && ageMs < BILLING_CHECKOUT_REUSE_WINDOW_MS) { + return { + attemptId: unresolved.attempt_id, + idempotencyKey: unresolved.idempotency_key, + state: 'pending', + reused: true, + }; + } + + sql.requireReconciliation.run( + Math.max(nowMs, createdAtMs), + unresolved.attempt_id, + ); + return { reconciliationRequiredAttemptId: unresolved.attempt_id }; + } + + const attemptId = opaqueIdentifier(randomUUID, 'attemptId'); + const idempotencyKey = opaqueIdentifier(randomUUID, 'idempotencyKey'); + sql.insertAttempt.run(attemptId, organization, price, idempotencyKey, nowMs, nowMs); + return { attemptId, idempotencyKey, state: 'pending', reused: false }; + }); + + if (result.reconciliationRequiredAttemptId) { + throw new BillingCheckoutReconciliationRequiredError( + result.reconciliationRequiredAttemptId, + ); + } + return result; + }, + + /** Mark one unresolved attempt successful and bind its provider session ID. */ + markProviderSucceeded({ attemptId, providerSessionId }) { + const id = boundedRequiredString(attemptId, 'attemptId', MAX_IDENTIFIER_LENGTH); + const sessionId = boundedRequiredString( + providerSessionId, + 'providerSessionId', + MAX_PROVIDER_SESSION_ID_LENGTH, + ); + const nowMs = safeNow(now); + const result = withSavepoint( + database, + () => statements().succeedAttempt.run(sessionId, nowMs, id), + ); + if (Number(result.changes) !== 1) { + throw new Error('expected one pending checkout attempt for provider success'); + } + }, + + /** Mark one unresolved attempt as a known provider failure. */ + markProviderFailed({ attemptId }) { + const id = boundedRequiredString(attemptId, 'attemptId', MAX_IDENTIFIER_LENGTH); + const nowMs = safeNow(now); + const result = withSavepoint( + database, + () => statements().failAttempt.run(nowMs, id), + ); + if (Number(result.changes) !== 1) { + throw new Error('expected one pending checkout attempt for provider failure'); + } + }, + }; +} diff --git a/server/db.mjs b/server/db.mjs index 122b70d6..7a27f461 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -4,6 +4,10 @@ import { DatabaseSync } from 'node:sqlite'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from './billing_checkout_attempt.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.SCOPEWEAVE_DB || join(__dirname, '..', 'data.db'); @@ -177,5 +181,9 @@ try { db.exec('ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAU try { db.exec('ALTER TABLE projects ADD COLUMN archived INTEGER NOT NULL DEFAULT 0'); } catch { /* already there */ } try { db.exec("ALTER TABLE projects ADD COLUMN methodology TEXT NOT NULL DEFAULT 'waterfall'"); } catch { /* already there */ } +// Billing attempt state is installed at bootstrap only, after referenced orgs exist. +installBillingCheckoutAttemptSchema(db); +export const billingCheckoutAttempts = createSqliteBillingCheckoutAttemptRepository(db); + // node:sqlite returns lastInsertRowid as number|bigint; normalize to Number. -export const rowid = (r) => Number(r.lastInsertRowid); +export const rowid = (r) => Number(r.lastInsertRowid); \ No newline at end of file diff --git a/tests/api/billing-live-checkout.test.mjs b/tests/api/billing-live-checkout.test.mjs new file mode 100644 index 00000000..e8058e78 --- /dev/null +++ b/tests/api/billing-live-checkout.test.mjs @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +process.env.SCOPEWEAVE_DB = ':memory:'; +delete process.env.SCOPEWEAVE_DEV; +process.env.SCOPEWEAVE_PUBLIC_ORIGIN = 'https://planner.example.com'; +process.env.SCOPEWEAVE_JWT_SECRET = '0123456789abcdef0123456789abcdef'; +process.env.STRIPE_SECRET_KEY = 'sk_test_live_route'; +process.env.STRIPE_PRICE_ID = 'price_live_route'; +process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_live_route'; + +const originalFetch = globalThis.fetch; +const providerCalls = []; +let providerAttempt = 0; + +globalThis.fetch = async (url, options) => { + providerAttempt += 1; + providerCalls.push({ url, options }); + if (providerAttempt === 1) { + throw new Error('simulated connection loss after request dispatch'); + } + return new Response(JSON.stringify({ + id: 'cs_test_live_route_recovered', + url: 'https://checkout.stripe.com/c/pay/cs_test_live_route_recovered', + }), { + status: 200, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); +}; + +const { app } = await import('../../server/app.mjs'); +const { db } = await import('../../server/db.mjs'); + +const jsonHeaders = { 'content-type': 'application/json' }; + +async function createOwner() { + const signup = await app.request('https://edge.example/api/auth/signup', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + email: 'billing-live-route@example.test', + password: 'password123', + name: 'Billing Live Route', + }), + }); + assert.equal(signup.status, 200); + const { token } = await signup.json(); + assert.ok(token); + + const me = await app.request('https://edge.example/api/me', { + headers: { authorization: `Bearer ${token}` }, + }); + assert.equal(me.status, 200); + const profile = await me.json(); + assert.equal(profile.orgs.length, 1); + return { token, orgId: profile.orgs[0].id }; +} + +test('uncertain live Checkout retries reuse one persisted provider identity end to end', async () => { + try { + const { token, orgId } = await createOwner(); + const requestOptions = { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }; + + const first = await app.request( + `https://untrusted-proxy.example/api/orgs/${orgId}/checkout`, + requestOptions, + ); + assert.equal(first.status, 502); + assert.deepEqual(await first.json(), { + error: 'billing_provider_unavailable', + action: 'Retry checkout. If the problem persists, verify Stripe connectivity and service health before retrying.', + }); + + const pendingRows = db.prepare(` + SELECT attempt_id, organization_id, price_id, idempotency_key, attempt_state, + provider_session_id + FROM billing_checkout_attempts + WHERE organization_id = ? + `).all(orgId); + assert.equal(pendingRows.length, 1); + assert.equal(pendingRows[0].attempt_state, 'pending'); + assert.equal(pendingRows[0].provider_session_id, null); + + const second = await app.request( + `https://different-proxy.example/api/orgs/${orgId}/checkout`, + requestOptions, + ); + assert.equal(second.status, 200); + const recovered = await second.json(); + assert.equal(recovered.live, true); + assert.equal( + recovered.url, + 'https://checkout.stripe.com/c/pay/cs_test_live_route_recovered', + ); + assert.equal(recovered.checkoutAttemptId, pendingRows[0].attempt_id); + + assert.equal(providerCalls.length, 2); + assert.equal(providerCalls[0].url, 'https://api.stripe.com/v1/checkout/sessions'); + assert.equal(providerCalls[1].url, providerCalls[0].url); + assert.equal( + providerCalls[1].options.headers['idempotency-key'], + providerCalls[0].options.headers['idempotency-key'], + 'the retry must reuse the first uncertain attempt idempotency key', + ); + assert.equal( + providerCalls[0].options.headers['idempotency-key'], + pendingRows[0].idempotency_key, + ); + + for (const call of providerCalls) { + const form = new URLSearchParams(call.options.body); + assert.equal(form.get('success_url'), 'https://planner.example.com/?billing=success'); + assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); + assert.equal(form.get('line_items[0][price]'), 'price_live_route'); + assert.equal(form.get('client_reference_id'), String(orgId)); + assert.equal(form.get('metadata[orgId]'), String(orgId)); + } + + const settledRows = db.prepare(` + SELECT attempt_id, attempt_state, provider_session_id + FROM billing_checkout_attempts + WHERE organization_id = ? + `).all(orgId); + assert.deepEqual(settledRows.map((row) => ({ ...row })), [{ + attempt_id: pendingRows[0].attempt_id, + attempt_state: 'provider_succeeded', + provider_session_id: 'cs_test_live_route_recovered', + }]); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/tests/unit/billing-checkout-attempt-authority.test.mjs b/tests/unit/billing-checkout-attempt-authority.test.mjs new file mode 100644 index 00000000..bc7800f4 --- /dev/null +++ b/tests/unit/billing-checkout-attempt-authority.test.mjs @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +function authorityDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(1); + installBillingCheckoutAttemptSchema(database); + return database; +} + +test('checkout-attempt authority rejects non-number/string values before tenant lookup', () => { + const database = authorityDatabase(); + let uuidCounter = 0; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: () => `00000000-0000-4000-8000-${String(++uuidCounter).padStart(12, '0')}`, + now: () => 1_000, + }); + + for (const organizationId of [true, new Number(1), [1]]) { + assert.throws( + () => repository.startAttempt({ organizationId, priceId: 'price_pro' }), + TypeError, + 'tenant authority must not be synthesized through JavaScript numeric coercion', + ); + } + + assert.equal( + database.prepare('SELECT COUNT(*) AS count FROM billing_checkout_attempts').get().count, + 0, + 'malformed local authority cannot create provider retry authority', + ); + database.close(); +}); diff --git a/tests/unit/billing-checkout-attempt.test.mjs b/tests/unit/billing-checkout-attempt.test.mjs new file mode 100644 index 00000000..453ab571 --- /dev/null +++ b/tests/unit/billing-checkout-attempt.test.mjs @@ -0,0 +1,381 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { + BILLING_CHECKOUT_REUSE_WINDOW_MS, + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +function createDatabase() { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(7); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(8); + return database; +} + +function deterministicIds() { + const values = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + '33333333-3333-4333-8333-333333333333', + '44444444-4444-4444-8444-444444444444', + '55555555-5555-4555-8555-555555555555', + '66666666-6666-4666-8666-666666666666', + '77777777-7777-4777-8777-777777777777', + '88888888-8888-4888-8888-888888888888', + ]; + return () => { + const value = values.shift(); + assert.ok(value, 'test UUID source must not be exhausted'); + return value; + }; +} + +function expectReconciliationRequired(run) { + assert.throws(run, (error) => { + assert.equal(error.code, 'billing_checkout_reconciliation_required'); + assert.match(error.message, /reconcil/i); + return true; + }); +} + +test('checkout-attempt bootstrap owns only compliant normalized objects', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + installBillingCheckoutAttemptSchema(database); + + const table = database.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name = 'billing_checkout_attempts'", + ).get(); + assert.equal(table.name, 'billing_checkout_attempts'); + assert.match(table.sql, /CHECK\s*\(attempt_state IN \('pending','provider_succeeded','provider_failed','reconciliation_required'\)\)/); + + const index = database.prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'index' AND name = 'billing_checkout_unresolved_attempts'", + ).get(); + assert.equal(index.name, 'billing_checkout_unresolved_attempts'); + assert.match(index.sql, /WHERE attempt_state IN \('pending','reconciliation_required'\)/); + + const columns = database.prepare("PRAGMA table_info('billing_checkout_attempts')").all().map((row) => row.name); + assert.deepEqual(columns, [ + 'attempt_id', + 'organization_id', + 'price_id', + 'idempotency_key', + 'attempt_state', + 'provider_session_id', + 'created_at_ms', + 'updated_at_ms', + ]); + assert.equal(columns.some((name) => /secret|token/i.test(name)), false); + + const foreignKeys = database.prepare("PRAGMA foreign_key_list('billing_checkout_attempts')").all(); + assert.equal(foreignKeys.length, 1); + assert.equal(foreignKeys[0].table, 'orgs'); + assert.equal(foreignKeys[0].from, 'organization_id'); + assert.equal(foreignKeys[0].on_delete, 'CASCADE'); +}); + +test('repository never performs request-time schema installation', () => { + const database = createDatabase(); + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => 1_000, + }); + + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), + /billing_checkout_attempts/, + ); + const table = database.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'billing_checkout_attempts'", + ).get(); + assert.equal(table, undefined); +}); + +test('pending uncertain attempts reuse one durable Stripe idempotency key inside the safe window', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 1_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const first = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.deepEqual(first, { + attemptId: '11111111-1111-4111-8111-111111111111', + idempotencyKey: '22222222-2222-4222-8222-222222222222', + state: 'pending', + reused: false, + }); + + nowMs += BILLING_CHECKOUT_REUSE_WINDOW_MS - 1; + const retry = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.deepEqual(retry, { ...first, reused: true }); + + const otherTenant = repository.startAttempt({ organizationId: 8, priceId: 'price_pro' }); + assert.notEqual(otherTenant.attemptId, first.attemptId); + assert.notEqual(otherTenant.idempotencyKey, first.idempotencyKey); + + const persisted = database.prepare( + 'SELECT organization_id, price_id, idempotency_key, attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?', + ).get(first.attemptId); + assert.deepEqual({ ...persisted }, { + organization_id: 7, + price_id: 'price_pro', + idempotency_key: first.idempotencyKey, + attempt_state: 'pending', + }); +}); + +test('terminal provider outcomes close the retry identity and a later checkout gets fresh authority', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 2_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const successAttempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + repository.markProviderSucceeded({ + attemptId: successAttempt.attemptId, + providerSessionId: 'cs_test_success_123', + }); + const successRow = database.prepare( + 'SELECT attempt_state, provider_session_id FROM billing_checkout_attempts WHERE attempt_id = ?', + ).get(successAttempt.attemptId); + assert.deepEqual({ ...successRow }, { + attempt_state: 'provider_succeeded', + provider_session_id: 'cs_test_success_123', + }); + + nowMs += 1; + const afterSuccess = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.notEqual(afterSuccess.idempotencyKey, successAttempt.idempotencyKey); + + repository.markProviderFailed({ attemptId: afterSuccess.attemptId }); + nowMs += 1; + const afterFailure = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + assert.notEqual(afterFailure.idempotencyKey, afterSuccess.idempotencyKey); +}); + +test('stale uncertain attempts fail closed for reconciliation instead of minting a duplicate key', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 3_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const oldAttempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + nowMs += BILLING_CHECKOUT_REUSE_WINDOW_MS; + expectReconciliationRequired( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), + ); + + const rows = database.prepare(` + SELECT attempt_id, idempotency_key, attempt_state + FROM billing_checkout_attempts + WHERE organization_id = ? AND price_id = ? + `).all(7, 'price_pro'); + assert.deepEqual(rows.map((row) => ({ ...row })), [{ + attempt_id: oldAttempt.attemptId, + idempotency_key: oldAttempt.idempotencyKey, + attempt_state: 'reconciliation_required', + }]); + + nowMs += 1; + expectReconciliationRequired( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_attempts WHERE organization_id = ?').get(7).n, + 1, + 'retries cannot create a second attempt until authoritative reconciliation resolves the first', + ); +}); + +test('clock rollback requires reconciliation instead of guessing the provider retention age', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let nowMs = 5_000_000; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => nowMs, + }); + + const first = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + nowMs -= 1_000; + expectReconciliationRequired( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }), + ); + + const row = database.prepare( + 'SELECT attempt_id, idempotency_key, attempt_state FROM billing_checkout_attempts WHERE attempt_id = ?', + ).get(first.attemptId); + assert.deepEqual({ ...row }, { + attempt_id: first.attemptId, + idempotency_key: first.idempotencyKey, + attempt_state: 'reconciliation_required', + }); +}); + +test('repository rejects malformed identifiers and impossible terminal transitions', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => 4_000_000, + }); + + for (const organizationId of [0, -1, 1.5, 'not-an-id']) { + assert.throws( + () => repository.startAttempt({ organizationId, priceId: 'price_pro' }), + /organizationId/, + ); + } + for (const priceId of [null, ' ', 'x'.repeat(256)]) { + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId }), + /priceId/, + ); + } + + const attempt = repository.startAttempt({ organizationId: 7, priceId: 'price_pro' }); + for (const providerSessionId of [null, '', 'x'.repeat(256)]) { + assert.throws( + () => repository.markProviderSucceeded({ attemptId: attempt.attemptId, providerSessionId }), + /providerSessionId/, + ); + } + repository.markProviderFailed({ attemptId: attempt.attemptId }); + assert.throws( + () => repository.markProviderSucceeded({ attemptId: attempt.attemptId, providerSessionId: 'cs_too_late' }), + /pending checkout attempt/, + ); + assert.throws( + () => repository.markProviderFailed({ attemptId: 'not-an-attempt' }), + /pending checkout attempt/, + ); + assert.throws( + () => repository.markProviderFailed({ attemptId: '' }), + /attemptId/, + ); +}); + +test('dependency seams fail closed and default UUID/clock dependencies are usable', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + + assert.throws( + () => createSqliteBillingCheckoutAttemptRepository(null), + /database/, + ); + assert.throws( + () => createSqliteBillingCheckoutAttemptRepository(database, { randomUUID: 'not-a-function' }), + /randomUUID/, + ); + assert.throws( + () => createSqliteBillingCheckoutAttemptRepository(database, { now: 'not-a-function' }), + /now/, + ); + + const repository = createSqliteBillingCheckoutAttemptRepository(database); + const attempt = repository.startAttempt({ organizationId: 7, priceId: 'price_default' }); + assert.match(attempt.attemptId, /^[0-9a-f-]{36}$/i); + assert.match(attempt.idempotencyKey, /^[0-9a-f-]{36}$/i); + repository.markProviderFailed({ attemptId: attempt.attemptId }); +}); + +test('invalid clock and identifier sources roll back without leaving a pending row', () => { + let database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + let repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => -1, + }); + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_bad_clock' }), + /clock/, + ); + + database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: () => '', + now: () => 6_000_000, + }); + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_bad_uuid' }), + /attemptId/, + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_attempts').get().n, + 0, + ); + + database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + repository = createSqliteBillingCheckoutAttemptRepository(database, { + randomUUID: deterministicIds(), + now: () => 7_000_000, + }); + assert.throws( + () => repository.startAttempt({ organizationId: 999, priceId: 'price_missing_org' }), + /FOREIGN KEY|constraint/i, + ); + assert.equal( + database.prepare('SELECT COUNT(*) AS n FROM billing_checkout_attempts').get().n, + 0, + ); +}); + +test('rollback cleanup aborts the shared transaction and preserves the causal write failure', () => { + const database = createDatabase(); + installBillingCheckoutAttemptSchema(database); + database.exec(` + CREATE TRIGGER billing_checkout_test_attempt_failure + BEFORE INSERT ON billing_checkout_attempts + BEGIN + SELECT RAISE(ABORT, 'causal checkout attempt write failure'); + END; + `); + + const executed = []; + const guardedDatabase = { + prepare: database.prepare.bind(database), + exec(sql) { + executed.push(sql); + if (sql === 'ROLLBACK TO SAVEPOINT billing_checkout_attempt_write') { + throw new Error('simulated rollback cleanup failure'); + } + return database.exec(sql); + }, + }; + const repository = createSqliteBillingCheckoutAttemptRepository(guardedDatabase, { + randomUUID: deterministicIds(), + now: () => 8_000_000, + }); + + assert.throws( + () => repository.startAttempt({ organizationId: 7, priceId: 'price_rollback_failure' }), + /causal checkout attempt write failure/, + ); + assert.equal( + executed.filter((sql) => sql === 'RELEASE SAVEPOINT billing_checkout_attempt_write').length, + 0, + 'failed rollback must not release an unconfirmed savepoint', + ); + assert.equal( + executed.filter((sql) => sql === 'ROLLBACK').length, + 1, + 'failed savepoint rollback must abort the shared transaction', + ); +}); diff --git a/tests/unit/billing-checkout-review-regressions.test.mjs b/tests/unit/billing-checkout-review-regressions.test.mjs new file mode 100644 index 00000000..a028ae83 --- /dev/null +++ b/tests/unit/billing-checkout-review-regressions.test.mjs @@ -0,0 +1,214 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { DatabaseSync } from 'node:sqlite'; + +import { createCheckout } from '../../server/billing.mjs'; +import { + createSqliteBillingCheckoutAttemptRepository, + installBillingCheckoutAttemptSchema, +} from '../../server/billing_checkout_attempt.mjs'; + +const liveConfiguration = { + mode: 'live', + publicOrigin: 'https://planner.example.com', +}; + +function createAttemptRepository() { + const events = []; + return { + events, + startAttempt(input) { + events.push({ type: 'start', input }); + return { + attemptId: 'attempt-review-regression', + idempotencyKey: 'idem-review-regression', + state: 'pending', + reused: false, + }; + }, + markProviderSucceeded(input) { + events.push({ type: 'success', input }); + }, + markProviderFailed(input) { + events.push({ type: 'failure', input }); + }, + }; +} + +async function withStripeEnv(run) { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; + process.env.STRIPE_SECRET_KEY = 'sk_test_review_regression'; + process.env.STRIPE_PRICE_ID = 'price_review_regression'; + try { + await run(); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +} + +async function expectProviderInvalidResponse(run) { + let rejected; + await assert.rejects(run, (error) => { + rejected = error; + assert.equal(error.status, 502); + return true; + }); + const response = rejected.getResponse(); + const payload = await response.json(); + assert.equal(payload.error, 'billing_provider_invalid_response'); +} + +test('malformed successful Stripe responses keep the durable retry identity unresolved', async () => { + await withStripeEnv(async () => { + const cases = [ + { + name: 'non-JSON 2xx response', + response: () => new Response('unexpected', { + status: 200, + headers: { 'content-type': 'text/html' }, + }), + }, + { + name: 'untrusted hosted URL in a 2xx response', + response: () => new Response(JSON.stringify({ + id: 'cs_test_review_regression', + url: 'https://checkout.stripe.com.evil.example/c/pay/session', + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + }, + ]; + + for (const scenario of cases) { + const repository = createAttemptRepository(); + globalThis.fetch = async () => scenario.response(); + + await expectProviderInvalidResponse(() => createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository: repository, + })); + + assert.deepEqual( + repository.events.map((event) => event.type), + ['start'], + `${scenario.name} is an uncertain provider outcome and must retain the same idempotency key`, + ); + } + }); +}); + +function createCheckoutAttemptFixture(startTimeMs) { + const database = new DatabaseSync(':memory:'); + database.exec('PRAGMA foreign_keys = ON'); + database.exec('CREATE TABLE orgs (id INTEGER PRIMARY KEY)'); + database.prepare('INSERT INTO orgs(id) VALUES(?)').run(7); + installBillingCheckoutAttemptSchema(database); + + let nowMs = startTimeMs; + const identifiers = [ + '11111111-1111-4111-8111-111111111111', + '22222222-2222-4222-8222-222222222222', + ]; + const repository = createSqliteBillingCheckoutAttemptRepository(database, { + now: () => nowMs, + randomUUID: () => identifiers.shift(), + }); + const attempt = repository.startAttempt({ + organizationId: 7, + priceId: 'price_review_regression', + }); + return { + database, + repository, + attempt, + rollbackClock() { + nowMs = startTimeMs - 1_000; + }, + }; +} + +test('terminal success remains durable when the wall clock moves behind attempt creation', () => { + const fixture = createCheckoutAttemptFixture(5_000_000); + fixture.rollbackClock(); + + fixture.repository.markProviderSucceeded({ + attemptId: fixture.attempt.attemptId, + providerSessionId: 'cs_test_clock_rollback', + }); + + const row = fixture.database.prepare(` + SELECT attempt_state, provider_session_id, created_at_ms, updated_at_ms + FROM billing_checkout_attempts + WHERE attempt_id = ? + `).get(fixture.attempt.attemptId); + assert.deepEqual({ ...row }, { + attempt_state: 'provider_succeeded', + provider_session_id: 'cs_test_clock_rollback', + created_at_ms: 5_000_000, + updated_at_ms: 5_000_000, + }); +}); + +test('terminal failure remains durable when the wall clock moves behind attempt creation', () => { + const fixture = createCheckoutAttemptFixture(6_000_000); + fixture.rollbackClock(); + + fixture.repository.markProviderFailed({ attemptId: fixture.attempt.attemptId }); + + const row = fixture.database.prepare(` + SELECT attempt_state, created_at_ms, updated_at_ms + FROM billing_checkout_attempts + WHERE attempt_id = ? + `).get(fixture.attempt.attemptId); + assert.deepEqual({ ...row }, { + attempt_state: 'provider_failed', + created_at_ms: 6_000_000, + updated_at_ms: 6_000_000, + }); +}); + +test('live Checkout reports missing price configuration before touching durable state', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_SECRET_KEY = 'sk_test_review_regression'; + delete process.env.STRIPE_PRICE_ID; + let startAttemptCalled = false; + try { + let rejected; + await assert.rejects( + () => createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository: { + startAttempt() { + startAttemptCalled = true; + throw new Error('must not reach durable state without price configuration'); + }, + markProviderSucceeded() {}, + markProviderFailed() {}, + }, + }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + const payload = await rejected.getResponse().json(); + assert.equal(payload.error, 'billing_not_configured'); + assert.equal(startAttemptCalled, false); + } finally { + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); diff --git a/tests/unit/billing-checkout.test.mjs b/tests/unit/billing-checkout.test.mjs index 56be040e..db8a3447 100644 --- a/tests/unit/billing-checkout.test.mjs +++ b/tests/unit/billing-checkout.test.mjs @@ -17,6 +17,36 @@ const providerFailurePayloads = Object.freeze({ }, }); +function createAttemptRepository(overrides = {}) { + const events = []; + return { + events, + startAttempt(input) { + events.push({ type: 'start', input }); + if (overrides.startError) throw overrides.startError; + return { + attemptId: overrides.attemptId || 'attempt-test-001', + idempotencyKey: overrides.idempotencyKey || 'idem-test-001', + state: 'pending', + reused: false, + }; + }, + markProviderSucceeded(input) { + events.push({ type: 'success', input }); + if (overrides.successError) throw overrides.successError; + }, + markProviderFailed(input) { + events.push({ type: 'failure', input }); + if (overrides.failureError) throw overrides.failureError; + }, + }; +} + +async function responsePayloadFrom(error) { + assert.equal(typeof error.getResponse, 'function'); + return error.getResponse().json(); +} + async function withDefaultStripeTransport(responseFactory, assertion) { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; @@ -56,13 +86,14 @@ async function assertProviderFailure(runCheckout, expectedCode = 'billing_provid assert.deepEqual(payload, providerFailurePayloads[expectedCode]); } -async function expectSafeProviderFailure( - responseFactory, - expectedCode = 'billing_provider_unavailable', -) { +async function expectSafeProviderFailure(responseFactory, expectedCode = 'billing_provider_unavailable') { await withDefaultStripeTransport(responseFactory, async () => { await assertProviderFailure( - () => createCheckout({ orgId: 91, configuration: liveConfiguration }), + () => createCheckout({ + orgId: 91, + configuration: liveConfiguration, + attemptRepository: createAttemptRepository(), + }), expectedCode, ); }); @@ -73,7 +104,8 @@ function fixedSessionFactory(session) { checkout: { sessions: { async create() { - return session; + if (!session || typeof session !== 'object') return session; + return { id: 'cs_test_fixture', ...session }; }, }, }, @@ -115,21 +147,25 @@ test('development mock uses only the operator-owned public origin', async () => assert.doesNotMatch(checkout.url, /attacker\.example/); }); -test('live checkout builds redirects from canonical configuration and preserves server identity', async () => { +test('live checkout binds SDK-style calls to the durable idempotency identity', async () => { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; process.env.STRIPE_SECRET_KEY = 'sk_test_trusted'; process.env.STRIPE_PRICE_ID = 'price_trusted'; const calls = []; + const attemptRepository = createAttemptRepository(); const fakeStripeClientFactory = async (secretKey) => { assert.equal(secretKey, 'sk_test_trusted'); return { checkout: { sessions: { - async create(payload) { - calls.push(payload); - return { url: 'https://checkout.stripe.com/c/pay/cs_test_123' }; + async create(payload, requestOptions) { + calls.push({ payload, requestOptions }); + return { + id: 'cs_test_123', + url: 'https://checkout.stripe.com/c/pay/cs_test_123', + }; }, }, }, @@ -141,21 +177,73 @@ test('live checkout builds redirects from canonical configuration and preserves orgId: 73, origin: 'https://attacker.example', configuration: liveConfiguration, + attemptRepository, stripeClientFactory: fakeStripeClientFactory, }); assert.deepEqual(checkout, { url: 'https://checkout.stripe.com/c/pay/cs_test_123', live: true, + checkoutAttemptId: 'attempt-test-001', }); assert.deepEqual(calls, [{ - mode: 'subscription', - line_items: [{ price: 'price_trusted', quantity: 1 }], - success_url: 'https://planner.example.com/?billing=success', - cancel_url: 'https://planner.example.com/?billing=cancel', - client_reference_id: '73', - metadata: { orgId: '73' }, + payload: { + mode: 'subscription', + line_items: [{ price: 'price_trusted', quantity: 1 }], + success_url: 'https://planner.example.com/?billing=success', + cancel_url: 'https://planner.example.com/?billing=cancel', + client_reference_id: '73', + metadata: { orgId: '73' }, + }, + requestOptions: { idempotencyKey: 'idem-test-001' }, }]); + assert.deepEqual(attemptRepository.events, [ + { type: 'start', input: { organizationId: 73, priceId: 'price_trusted' } }, + { + type: 'success', + input: { attemptId: 'attempt-test-001', providerSessionId: 'cs_test_123' }, + }, + ]); + } finally { + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); + +test('SDK-reported 409 conflicts preserve the durable retry identity', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_SECRET_KEY = 'sk_test_sdk_conflict'; + process.env.STRIPE_PRICE_ID = 'price_sdk_conflict'; + const attemptRepository = createAttemptRepository(); + const stripeClientFactory = async () => ({ + checkout: { + sessions: { + async create() { + const error = new Error('concurrent idempotency conflict'); + error.statusCode = 409; + throw error; + }, + }, + }, + }); + + try { + await assert.rejects( + createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository, + stripeClientFactory, + }), + (error) => { + assert.equal(error.status, 502); + return true; + }, + ); + assert.deepEqual(attemptRepository.events.map((event) => event.type), ['start']); } finally { if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; else process.env.STRIPE_SECRET_KEY = previousSecret; @@ -164,26 +252,41 @@ test('live checkout builds redirects from canonical configuration and preserves } }); -test('default live provider transport uses Stripe HTTPS without an undeclared runtime SDK', async () => { +test('default live provider transport sends the persisted Stripe Idempotency-Key', async () => { + const previousSecret = process.env.STRIPE_SECRET_KEY; + const previousPrice = process.env.STRIPE_PRICE_ID; + const previousFetch = globalThis.fetch; + process.env.STRIPE_SECRET_KEY = 'sk_test_default_transport'; + process.env.STRIPE_PRICE_ID = 'price_default_transport'; + const calls = []; - await withDefaultStripeTransport(async (url, options) => { + const attemptRepository = createAttemptRepository({ + attemptId: 'attempt-default-transport', + idempotencyKey: 'idem-default-transport', + }); + globalThis.fetch = async (url, options) => { calls.push({ url, options }); return new Response(JSON.stringify({ + id: 'cs_test_default_transport', url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', }), { status: 200, headers: { 'content-type': 'application/json; charset=utf-8' }, }); - }, async () => { + }; + + try { const checkout = await createCheckout({ orgId: 91, origin: 'https://attacker.example', configuration: liveConfiguration, + attemptRepository, }); assert.deepEqual(checkout, { url: 'https://checkout.stripe.com/c/pay/cs_test_default_transport', live: true, + checkoutAttemptId: 'attempt-default-transport', }); assert.equal(calls.length, 1); assert.equal(calls[0].url, 'https://api.stripe.com/v1/checkout/sessions'); @@ -192,6 +295,7 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru assert.ok(calls[0].options.signal instanceof AbortSignal); assert.equal(calls[0].options.headers.authorization, 'Bearer sk_test_default_transport'); assert.equal(calls[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + assert.equal(calls[0].options.headers['idempotency-key'], 'idem-default-transport'); const form = new URLSearchParams(calls[0].options.body); assert.equal(form.get('mode'), 'subscription'); @@ -201,7 +305,20 @@ test('default live provider transport uses Stripe HTTPS without an undeclared ru assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); assert.equal(form.get('client_reference_id'), '91'); assert.equal(form.get('metadata[orgId]'), '91'); - }); + assert.deepEqual(attemptRepository.events.at(-1), { + type: 'success', + input: { + attemptId: 'attempt-default-transport', + providerSessionId: 'cs_test_default_transport', + }, + }); + } finally { + globalThis.fetch = previousFetch; + if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; + else process.env.STRIPE_SECRET_KEY = previousSecret; + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } }); test('default live provider transport rejects non-2xx Stripe responses with a safe retryable error', async () => { @@ -218,11 +335,13 @@ test('live checkout trims configuration values before the provider boundary', as const previousPrice = process.env.STRIPE_PRICE_ID; process.env.STRIPE_SECRET_KEY = ' sk_test_trimmed '; process.env.STRIPE_PRICE_ID = ' price_trimmed '; + const attemptRepository = createAttemptRepository({ attemptId: 'attempt-trimmed' }); try { const checkout = await createCheckout({ orgId: 94, configuration: liveConfiguration, + attemptRepository, stripeClientFactory: async (secretKey) => { assert.equal(secretKey, 'sk_test_trimmed'); return { @@ -230,7 +349,10 @@ test('live checkout trims configuration values before the provider boundary', as sessions: { async create(payload) { assert.equal(payload.line_items[0].price, 'price_trimmed'); - return { url: 'https://checkout.stripe.com/c/pay/cs_test_trimmed' }; + return { + id: 'cs_test_trimmed', + url: 'https://checkout.stripe.com/c/pay/cs_test_trimmed', + }; }, }, }, @@ -240,6 +362,7 @@ test('live checkout trims configuration values before the provider boundary', as assert.deepEqual(checkout, { url: 'https://checkout.stripe.com/c/pay/cs_test_trimmed', live: true, + checkoutAttemptId: 'attempt-trimmed', }); } finally { if (previousSecret === undefined) delete process.env.STRIPE_SECRET_KEY; @@ -249,6 +372,58 @@ test('live checkout trims configuration values before the provider boundary', as } }); +test('live checkout fails closed when the durable attempt port cannot start or commit success', async () => { + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_PRICE_ID = 'price_state_failure'; + + try { + for (const attemptRepository of [ + {}, + createAttemptRepository({ startError: new Error('database unavailable') }), + ]) { + let rejected; + await assert.rejects( + createCheckout({ orgId: 73, configuration: liveConfiguration, attemptRepository }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + assert.equal((await responsePayloadFrom(rejected)).error, 'billing_checkout_state_unavailable'); + } + + const attemptRepository = createAttemptRepository({ successError: new Error('commit failed') }); + const stripeClientFactory = async () => ({ + checkout: { + sessions: { + async create() { + return { id: 'cs_test_state_failure', url: 'https://checkout.stripe.com/c/pay/cs_test_state_failure' }; + }, + }, + }, + }); + let rejected; + await assert.rejects( + createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository, + stripeClientFactory, + }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + assert.equal((await responsePayloadFrom(rejected)).error, 'billing_checkout_state_unavailable'); + } finally { + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); + test('default live provider transport rejects network failures without leaking provider detail', async () => { await expectSafeProviderFailure(async () => { throw new Error('getaddrinfo ENOTFOUND api.stripe.com internal-network-detail'); @@ -282,6 +457,7 @@ test('live checkout rejects absent and blank provider redirect shapes', async () () => createCheckout({ orgId: 92, configuration: liveConfiguration, + attemptRepository: createAttemptRepository(), stripeClientFactory: fixedSessionFactory(session), }), 'billing_provider_invalid_response', @@ -303,6 +479,7 @@ test('live checkout rejects unsafe or malformed provider redirect URLs', async ( () => createCheckout({ orgId: 92, configuration: liveConfiguration, + attemptRepository: createAttemptRepository(), stripeClientFactory: fixedSessionFactory({ url }), }), 'billing_provider_invalid_response', @@ -314,8 +491,37 @@ test('live checkout maps unexpected injected provider failures to the same safe await assertProviderFailure(() => createCheckout({ orgId: 93, configuration: liveConfiguration, + attemptRepository: createAttemptRepository(), stripeClientFactory: async () => { throw new Error('provider credential detail must not escape'); }, })); }); + +test('stale uncertain Checkout state tells the customer not to mint a speculative retry', async () => { + const previousPrice = process.env.STRIPE_PRICE_ID; + process.env.STRIPE_PRICE_ID = 'price_reconciliation_required'; + const reconciliationError = new Error('provider outcome must be reconciled'); + reconciliationError.code = 'billing_checkout_reconciliation_required'; + const attemptRepository = createAttemptRepository({ startError: reconciliationError }); + + try { + let rejected; + await assert.rejects( + createCheckout({ orgId: 73, configuration: liveConfiguration, attemptRepository }), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + const payload = await responsePayloadFrom(rejected); + assert.equal(payload.error, 'billing_checkout_reconciliation_required'); + assert.match(payload.action, /reconcil/i); + assert.match(payload.action, /do not start|do not retry|before/i); + assert.deepEqual(attemptRepository.events.map((event) => event.type), ['start']); + } finally { + if (previousPrice === undefined) delete process.env.STRIPE_PRICE_ID; + else process.env.STRIPE_PRICE_ID = previousPrice; + } +}); diff --git a/tests/unit/billing-provider-boundary.test.mjs b/tests/unit/billing-provider-boundary.test.mjs index 0d739ca6..d76259de 100644 --- a/tests/unit/billing-provider-boundary.test.mjs +++ b/tests/unit/billing-provider-boundary.test.mjs @@ -7,6 +7,38 @@ const liveConfiguration = { mode: 'live', publicOrigin: 'https://planner.example const hostedCheckoutUrl = 'https://checkout.stripe.com/c/pay/cs_test_boundary#fidkdWxOYHwnPyd1blpx'; const providerResponseLimitBytes = 1024 * 1024; +function createAttemptRepository(overrides = {}) { + const events = []; + return { + events, + startAttempt(input) { + events.push({ type: 'start', input }); + return { + attemptId: overrides.attemptId || 'attempt-provider-boundary', + idempotencyKey: overrides.idempotencyKey || 'idem-provider-boundary', + state: 'pending', + reused: false, + }; + }, + markProviderSucceeded(input) { + events.push({ type: 'success', input }); + }, + markProviderFailed(input) { + events.push({ type: 'failure', input }); + if (overrides.failureError) throw overrides.failureError; + }, + }; +} + +function liveCheckout(attemptRepository, extra = {}) { + return createCheckout({ + orgId: 73, + configuration: liveConfiguration, + attemptRepository, + ...extra, + }); +} + async function withStripeEnv(run) { const previousSecret = process.env.STRIPE_SECRET_KEY; const previousPrice = process.env.STRIPE_PRICE_ID; @@ -42,12 +74,22 @@ async function expectProviderError(run, expectedCode) { return JSON.stringify(payload); } +function expectUnresolved(attemptRepository, message) { + assert.deepEqual( + attemptRepository.events.map((event) => event.type), + ['start'], + message, + ); +} + test('live Checkout uses one bounded direct Stripe HTTPS request and preserves the hosted URL', async () => { await withStripeEnv(async () => { + process.env.STRIPE_PRICE_ID = ' price_provider_boundary '; const observed = []; + const attemptRepository = createAttemptRepository(); globalThis.fetch = async (url, options) => { observed.push({ url, options }); - const payload = JSON.stringify({ url: hostedCheckoutUrl }); + const payload = JSON.stringify({ id: 'cs_test_boundary', url: hostedCheckoutUrl }); return new Response(payload, { status: 200, headers: { @@ -57,12 +99,10 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t }); }; - const result = await createCheckout({ - orgId: 73, - configuration: liveConfiguration, - }); + const result = await liveCheckout(attemptRepository); assert.equal(result.url, hostedCheckoutUrl, 'Stripe-hosted client fragment is preserved verbatim'); + assert.equal(result.checkoutAttemptId, 'attempt-provider-boundary'); assert.equal(observed.length, 1, 'checkout transport performs exactly one provider attempt'); assert.equal(observed[0].url, 'https://api.stripe.com/v1/checkout/sessions'); assert.equal(observed[0].options.method, 'POST'); @@ -70,6 +110,7 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t assert.ok(observed[0].options.signal instanceof AbortSignal); assert.equal(observed[0].options.headers.authorization, 'Bearer sk_test_provider_boundary'); assert.equal(observed[0].options.headers['content-type'], 'application/x-www-form-urlencoded'); + assert.equal(observed[0].options.headers['idempotency-key'], 'idem-provider-boundary'); const form = new URLSearchParams(observed[0].options.body); assert.equal(form.get('mode'), 'subscription'); @@ -79,10 +120,21 @@ test('live Checkout uses one bounded direct Stripe HTTPS request and preserves t assert.equal(form.get('cancel_url'), 'https://planner.example.com/?billing=cancel'); assert.equal(form.get('client_reference_id'), '73'); assert.equal(form.get('metadata[orgId]'), '73'); + assert.deepEqual(attemptRepository.events[0], { + type: 'start', + input: { organizationId: 73, priceId: 'price_provider_boundary' }, + }); + assert.deepEqual(attemptRepository.events.at(-1), { + type: 'success', + input: { + attemptId: 'attempt-provider-boundary', + providerSessionId: 'cs_test_boundary', + }, + }); }); }); -test('live Checkout rejects malformed or untrusted provider authorities', async () => { +test('live Checkout rejects malformed provider identities or untrusted browser authorities without closing retry identity', async () => { const invalidUrls = [ null, '', @@ -95,78 +147,129 @@ test('live Checkout rejects malformed or untrusted provider authorities', async await withStripeEnv(async () => { for (const url of invalidUrls) { - globalThis.fetch = async () => new Response(JSON.stringify({ url }), { + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(JSON.stringify({ id: 'cs_test_invalid_url', url }), { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + expectUnresolved(attemptRepository, 'untrusted 2xx destination remains unresolved'); + } + + for (const id of [null, '', 'x'.repeat(256)]) { + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(JSON.stringify({ id, url: hostedCheckoutUrl }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_invalid_response', + ); + expectUnresolved(attemptRepository, 'malformed 2xx provider identity remains unresolved'); } }); }); -test('provider transport failures become a stable sanitized buyer-facing error', async () => { +test('uncertain transport failures stay pending and remain sanitized', async () => { await withStripeEnv(async () => { + const attemptRepository = createAttemptRepository(); globalThis.fetch = async () => { throw new Error('dial tcp 10.7.0.12:443 with sk_live_should_not_escape'); }; const payload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_unavailable', ); assert.doesNotMatch(payload, /10\.7\.0\.12|sk_live_should_not_escape/); + expectUnresolved(attemptRepository, 'transport failure remains unresolved'); }); }); -test('provider HTTP and malformed-success responses fail with stable categories', async () => { +test('Stripe server and malformed-success outcomes remain indeterminate while known 4xx closes retry identity', async () => { await withStripeEnv(async () => { - globalThis.fetch = async () => new Response('provider secret body', { + let attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response('provider incident body', { status: 503, headers: { 'content-type': 'text/plain' }, }); - const unavailablePayload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + const serverErrorPayload = await expectProviderError( + () => liveCheckout(attemptRepository), 'billing_provider_unavailable', ); - assert.doesNotMatch(unavailablePayload, /provider secret body/); + assert.doesNotMatch(serverErrorPayload, /provider incident body/); + expectUnresolved(attemptRepository, '5xx is indeterminate and must preserve the same retry identity'); + attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response('invalid request body detail', { + status: 400, + headers: { 'content-type': 'application/json' }, + }); + const clientErrorPayload = await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(clientErrorPayload, /invalid request body detail/); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + + attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response('concurrent request', { + status: 409, + headers: { 'content-type': 'application/json' }, + }); + const conflictPayload = await expectProviderError( + () => liveCheckout(attemptRepository), + 'billing_provider_unavailable', + ); + assert.doesNotMatch(conflictPayload, /concurrent request/); + expectUnresolved(attemptRepository, '409 concurrent idempotency conflict remains retryable with the same key'); + + attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('not json', { status: 200, headers: { 'content-type': 'text/html' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + expectUnresolved(attemptRepository, 'non-JSON 2xx remains unresolved'); + attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response('{malformed', { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + expectUnresolved(attemptRepository, 'malformed JSON 2xx remains unresolved'); + attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(null, { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + expectUnresolved(attemptRepository, 'bodyless 2xx remains unresolved'); }); }); -test('rejected Stripe responses cancel unread bodies before returning sanitized failures', async () => { +test('rejected Stripe responses cancel unread bodies while preserving retry-state semantics', async () => { await withStripeEnv(async () => { for (const scenario of [ - { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable' }, - { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response' }, + { status: 503, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, + { status: 400, body: null, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: true }, + { status: 409, contentType: 'application/json', expectedCode: 'billing_provider_unavailable', closesAttempt: false }, + { status: 200, contentType: 'text/html', expectedCode: 'billing_provider_invalid_response', closesAttempt: false }, ]) { let cancelled = false; const unreadBody = new ReadableStream({ @@ -177,21 +280,34 @@ test('rejected Stripe responses cancel unread bodies before returning sanitized cancelled = true; }, }); - globalThis.fetch = async () => new Response(unreadBody, { - status: scenario.status, - headers: { 'content-type': scenario.contentType }, - }); + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response( + scenario.body === null ? null : unreadBody, + { + status: scenario.status, + headers: { 'content-type': scenario.contentType }, + }, + ); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), scenario.expectedCode, ); - assert.equal(cancelled, true, `${scenario.expectedCode} cancels its unread response body`); + assert.equal( + cancelled, + scenario.body === null ? false : true, + `${scenario.expectedCode} cancels its unread response body when one exists`, + ); + if (scenario.closesAttempt) { + assert.equal(attemptRepository.events.at(-1).type, 'failure'); + } else { + expectUnresolved(attemptRepository, 'indeterminate provider response keeps the durable retry identity'); + } } }); }); -test('response-body cleanup failure never replaces the stable provider failure', async () => { +test('response-body cleanup failure never replaces provider error or attempt outcome semantics', async () => { await withStripeEnv(async () => { let cancelCalls = 0; const unreadBody = new ReadableStream({ @@ -203,24 +319,27 @@ test('response-body cleanup failure never replaces the stable provider failure', throw new Error('cleanup secret must not escape'); }, }); + const attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(unreadBody, { - status: 503, + status: 400, headers: { 'content-type': 'application/json' }, }); const payload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_unavailable', ); assert.equal(cancelCalls, 1); assert.doesNotMatch(payload, /cleanup secret/); + assert.equal(attemptRepository.events.at(-1).type, 'failure'); }); }); test('provider response declarations and streamed bytes are bounded before JSON parsing', async () => { await withStripeEnv(async () => { for (const declaredLength of ['not-a-number', '-1', String(providerResponseLimitBytes + 1)]) { - globalThis.fetch = async () => new Response(JSON.stringify({ url: hostedCheckoutUrl }), { + const attemptRepository = createAttemptRepository(); + globalThis.fetch = async () => new Response(JSON.stringify({ id: 'cs_test_bounded', url: hostedCheckoutUrl }), { status: 200, headers: { 'content-type': 'application/json', @@ -228,9 +347,10 @@ test('provider response declarations and streamed bytes are bounded before JSON }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); + expectUnresolved(attemptRepository, 'invalid successful response declaration remains unresolved'); } let cancelled = false; @@ -248,34 +368,60 @@ test('provider response declarations and streamed bytes are bounded before JSON cancelled = true; }, }); + const attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(oversizedBody, { status: 200, headers: { 'content-type': 'application/json' }, }); await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); assert.equal(cancelled, true, 'oversized streamed provider bodies are cancelled at the byte boundary'); + expectUnresolved(attemptRepository, 'oversized successful response remains unresolved'); }); }); -test('provider stream read failures remain sanitized invalid responses', async () => { +test('provider stream read failures remain sanitized invalid responses without closing retry identity', async () => { await withStripeEnv(async () => { const failingBody = new ReadableStream({ pull(controller) { controller.error(new Error('provider stream secret 10.9.0.7')); }, }); + const attemptRepository = createAttemptRepository(); globalThis.fetch = async () => new Response(failingBody, { status: 200, headers: { 'content-type': 'application/json' }, }); const payload = await expectProviderError( - () => createCheckout({ orgId: 73, configuration: liveConfiguration }), + () => liveCheckout(attemptRepository), 'billing_provider_invalid_response', ); assert.doesNotMatch(payload, /provider stream secret|10\.9\.0\.7/); + expectUnresolved(attemptRepository, 'unreadable successful response remains unresolved'); + }); +}); + +test('a known provider failure that cannot be durably closed fails as state unavailable', async () => { + await withStripeEnv(async () => { + const attemptRepository = createAttemptRepository({ failureError: new Error('disk full') }); + globalThis.fetch = async () => new Response('known failure', { + status: 400, + headers: { 'content-type': 'application/json' }, + }); + + let rejected; + await assert.rejects( + () => liveCheckout(attemptRepository), + (error) => { + rejected = error; + assert.equal(error.status, 503); + return true; + }, + ); + const payload = await rejected.getResponse().json(); + assert.equal(payload.error, 'billing_checkout_state_unavailable'); }); }); diff --git a/tests/unit/coverage-script-contract.test.mjs b/tests/unit/coverage-script-contract.test.mjs index c5b58fa1..82b9c8a3 100644 --- a/tests/unit/coverage-script-contract.test.mjs +++ b/tests/unit/coverage-script-contract.test.mjs @@ -34,11 +34,21 @@ assert.match( /--include=server\/clearfolio\.mjs/, 'the abortable Clearfolio adapter is instrumented', ); +assert.match( + scripts['test:coverage'], + /--include=server\/billing_checkout_attempt\.mjs/, + 'the durable Checkout-attempt repository is instrumented', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/clearfolio-status-signal\.test\.mjs/, 'the Clearfolio signal and HTTP failure regression executes under c8', ); +assert.match( + scripts['test:coverage:cases'], + /tests\/unit\/billing-checkout-attempt\.test\.mjs/, + 'the durable Checkout-attempt regression executes under c8', +); assert.match( scripts['test:coverage:cases'], /tests\/unit\/billing-provider-boundary\.test\.mjs/, @@ -50,4 +60,4 @@ assert.doesNotMatch( 'coverage cases never recursively invoke a coverage wrapper', ); -console.log('✓ coverage script contract tests passed'); +console.log('✓ coverage script contract tests passed'); \ No newline at end of file