Commit Graph

9 Commits

Author SHA1 Message Date
Grant 5ec9a6e8c0 Migrate reconcile + tipping onto PaymentProvider trait; add worker tests
Two compat-path holdovers migrated:

- src/reconcile.rs: was state.btcpay_client().get_invoice() with
  manual JSON parsing of BTCPay-specific status strings ("Settled",
  "Complete", "Expired", "Invalid"). Now state.payment_provider()
  .get_invoice_status() returning the typed ProviderInvoiceStatus
  enum. The string normalization moves into BtcpayProvider's impl
  where it belongs.

- src/tipping.rs: was state.btcpay_client().pay_lightning_invoice()
  returning raw JSON, then manual paymentHash extraction. Now
  provider.pay_lightning_invoice() returning a typed PaymentReceipt
  { payment_hash, raw }. The audit message now records the active
  provider's kind() rather than hardcoding "BTCPay LN node".

Combined with v0.1.0:43's purchase migration, the daemon's
non-test code now contains zero calls to state.btcpay_client() or
.btcpay_webhook_secret(). Those compat accessors stay on AppState
for v0.2 (no need to break things gratuitously) but they're dead
code in the production path. Zaprite's drop-in only needs to
implement the trait.

Worker integration tests (tests/worker.rs):

- worker_marks_failure_and_schedules_retry_on_500: spins up a tiny
  axum receiver that 500s, calls webhooks::tick(), verifies attempt
  count and next-attempt scheduling.
- worker_dead_letters_after_max_attempts: seeds a row at attempt
  count 9, ticks once, verifies attempt_count → 10 and
  next_attempt_at → NULL. Confirms the row also satisfies the admin
  DLQ predicate (the contract :43's webhook_deliveries.rs depends
  on).
- worker_marks_success_on_2xx: pins the happy path.

webhooks::tick is now `pub` so integration tests can drive it
synchronously.

Test count: 26 (9 unit + 4 migration + 10 API + 3 worker).
2026-05-08 10:40:11 -05:00
Grant f9ef1a854c Webhook DLQ — list failed deliveries and manually retry
Closes the silent-loss hole in outbound webhook delivery. The worker
in src/webhooks.rs retries failed deliveries with exponential backoff
up to 10 attempts, then sets next_attempt_at = NULL and walks away.
Pre-this-commit, those "dead-lettered" rows sat in webhook_deliveries
forever with no surface for the operator to discover, inspect, or
recover from them — a subscriber that was down for >6h during a
license-issuance burst would silently lose those events forever.

What's new:

- repo::DeliveryStatusFilter — enum with parse() so query strings
  map cleanly to SQL predicates.
- repo::list_deliveries — endpoint_id + status + limit, newest first.
- repo::requeue_delivery — resets attempt_count=0, clears delivered_at
  and last_error, sets next_attempt_at=now. The worker picks it up on
  the next 5s tick.

- src/api/webhook_deliveries.rs — admin module with two handlers:
  - GET /v1/admin/webhook-deliveries?endpoint_id=…&status=…&limit=…
  - POST /v1/admin/webhook-deliveries/:id/retry  (audit-logged as
    webhook_delivery.retry; 404 on missing id)
- Routes registered in src/api/mod.rs alongside the existing
  webhook_endpoints CRUD.

- tests/api.rs gains webhook_dlq_lists_failed_and_retry_requeues:
  seeds three deliveries directly via SQL (one each: delivered,
  pending, dead-lettered), exercises the list filter, runs the retry,
  asserts the row migrates from failed→pending, audit row is written,
  404 on bad id, 400 on bad status filter.

Worker code is unchanged. The DLQ is operator-actionable infrastructure
on top of the existing retry semantics.

Test count: 23 (9 unit + 4 migration + 10 API), up from 22.
2026-05-08 09:38:58 -05:00
Grant e2b296ce29 Migrate purchase::start onto PaymentProvider trait + paid-purchase test
Drops the legacy compat path. `purchase::start` now calls
`state.payment_provider().await?.create_invoice(CreateInvoiceParams {
...})` instead of `state.btcpay_client().await?.create_invoice(...)`.
Provider-specific concerns (BTCPay's checkout-URL rewriting from the
internal Docker hostname to the public domain, metadata enrichment
with `orderId` / `source`) move inside the BtcpayProvider impl where
they belong; the same code path now serves any future provider
(Zaprite, etc.) without fork/copy.

URL rewriting is removed from the caller (no longer needs to know
which provider's URLs to rewrite or how). The
`crate::payment::btcpay::rewrite_to_public` function stays on the
provider impl; pubpath unchanged.

Adds `paid_purchase_creates_invoice_via_provider` integration test —
previously deferred per :42's release notes because the compat path
prevented MockPaymentProvider from substituting. Now the mock works
through the same call site as production. Verifies:
  - daemon delegates invoice creation to the provider
  - returned provider_invoice_id is stamped on the local invoice row
  - checkout_url is what the provider returned
  - no license issued at this stage (that's the webhook's job)

Test count: 22 (9 unit + 4 migration + 9 API).
2026-05-08 09:35:41 -05:00
Grant 34704bfa03 Add tier-cap enforcement test
Verifies the 402 PAYMENT_REQUIRED gate on /v1/admin/products fires at
the Creator-tier product cap (5), and that swapping `self_tier` to a
Licensed tier with `unlimited_products` lifts the cap without a
daemon restart. Mirrors what the admin UI's "Activate Keysat license"
flow does at runtime.

Validates two production-correctness invariants:
  - the 402 carries an `upgrade_url` so the SPA can render the
    upgrade CTA inline (rather than a generic error)
  - the failed attempt does NOT leak a row into the products table —
    the cap fires BEFORE the insert
2026-05-08 09:32:53 -05:00
Grant c11764898b v0.1.0:42 — webhook idempotency test + free-purchase test
Two new API integration tests, both targeting production-correctness
invariants worth locking down:

- free_purchase_issues_license_inline: exercises the price=0 shortcut
  (price_sats_override=0 on a "free" tier policy). Verifies the daemon
  synthesizes a settled invoice locally, issues a license inline, and
  the inlined license_key validates round-trip via /v1/validate.

- webhook_settles_invoice_and_issues_license_idempotently: the most
  important new test in this set. A pending invoice + an InvoiceSettled
  webhook → license issued, status flipped. Re-delivering the SAME
  webhook (which providers DO retry, sometimes aggressively) must NOT
  duplicate the license. A duplicated license here means duplicated
  revenue and duplicated revocation surface area — both bad. This test
  pins the invariant.

MockPaymentProvider added to tests/api.rs: a test-only PaymentProvider
impl that bypasses HMAC verification and parses test-supplied JSON
bodies into ProviderWebhookEvent variants. Lets us drive deterministic
settle/expire/invalid events without a real BTCPay roundtrip. Never
compiled into the production binary.

Paid-purchase test deferred: purchase::start still uses the legacy
state.btcpay_client() compat accessor that downcasts to the concrete
BtcpayProvider, which the mock can't satisfy. Documented inline. Slots
in trivially after the trait migration on the v0.3 backlog.

Version bump to v0.1.0:42 with release notes covering everything since
:41 was published: lib.rs library refactor, the original 5 API tests
from 81066df, the 2 new ones above, KEYSAT_INTEGRATION.md restoration.
No daemon-behaviour changes for operators; straight drop-in upgrade
from :41.

Test count: 20 (9 unit + 4 migration + 7 API), up from 13 in :41.
2026-05-08 09:24:57 -05:00
Grant 81066dfe62 Add API endpoint integration tests + library scaffolding
Closes the next-biggest test gap after migration tests. The daemon has
54+ HTTP endpoints, all previously untested at the request/response
level — same shape of blind spot that allowed the v0.1.0:39 migration
bug to ship.

What's new:

- src/lib.rs — exposes the daemon's modules as a library so integration
  tests can import them (`pub mod api;`, etc.). Module source files are
  unchanged; main.rs now imports via `use keysat::...` instead of
  declaring `mod api;` directly. No runtime behaviour change in the
  binary.

- tests/api.rs — 5 integration tests that drive real HTTP requests
  through axum::Router::oneshot against a real SQLite tempfile pool
  (same options as src/db/mod.rs::init):
    1. health_endpoint_returns_200 — framework smoke test
    2. admin_endpoint_rejects_missing_or_wrong_auth — 401 vs 403 paths
    3. admin_creates_product_with_correct_token — full happy path
       (auth → handler → DB insert → audit log → response)
    4. validate_rejects_unsigned_garbage — early parse-fail surfaces
       as `ok: false, reason: "bad_format"` (HTTP still 200)
    5. validate_accepts_well_formed_license — issues a license via
       repo, signs a matching LicensePayload with the daemon's
       actual key, encodes to wire format, validates via the
       endpoint, asserts ok=true plus populated metadata fields

Test count: 9 unit + 4 migrations + 5 API = 18 (was 13).

Cargo.toml dev-deps gain `tower = { version = "0.4", features = ["util"] }`
for ServiceExt::oneshot. The main `tower` dep is feature-minimal because
axum only needs a subset.

Out of scope (explicit follow-ups):

- Purchase happy path (needs a MockPaymentProvider implementing the
  trait; ~250 LOC of mock + ~200 LOC of test).
- Webhook handler with idempotency assertions (same MockPaymentProvider
  dependency).
- Tier-cap enforcement (mechanically simple; small follow-up PR).
- Discount-code atomic reserve race (better as a SQL-layer unit test
  than an HTTP integration test).
- Rate-limiting (interacts with shared state; needs careful isolation).
- Cookie/session auth (already covered in session_layer.rs).
2026-05-08 09:14:27 -05:00
Grant 116ed0d1f8 v0.1.0:41 — second hotfix to migration 0009; migration regression tests
The v0.1.0:40 migration was correct on clean installs but crashed at
COMMIT on any database with rows in discount_redemptions: SQLite's
deferred FK check saw the dropped parent's bookkeeping as unsatisfied
even after the rename. Fix is to rebuild discount_redemptions in the
same transaction (stash → drop → rebuild → restore) plus orphan
cleanup. Migration is idempotent; operators on :40 with a checksum
mismatch recover by deleting the version=9 row from _sqlx_migrations
and restarting.

Lands the missing migration test scaffolding too. The four tests in
licensing-service/tests/migrations.rs apply migrations against a
realistic populated database (products, policies, invoices, licenses,
machines, discount codes, redemptions, webhooks, tip attempts). The
regression test fails with the exact 787 error against the v40
migration — would have caught the bug pre-release.

KEYSAT_INTEGRATION.md is removed from this repo; it now lives in the
parent licensing/ folder.
2026-05-08 08:05:19 -05:00
Grant beedd07f07 v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions 2026-05-07 23:35:22 -05:00
Grant 6ac118ae70 v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages,
discount codes, free-license redemption, Apply-discount UX,
self-licensing, and v0.1.0 release notes.
2026-05-07 10:33:39 -05:00