mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 08:37:30 +00:00
* core, badge-service: name Apple claims by reference
Rename appleTransactionId to appleTransactionRef, matching googlePurchaseRef
and StoreTransactionRef. No behaviour change: the next change makes it
return a subscription's original transaction id, which the old name would
misstate.
Run: 001-badge-subscriptions, slice 1
* core, badge-service: store subscription charges and carry the subscription state
Add the service migration 20261012_badge_subscriptions: the subscription
charges table, the ledger's charge_id, and the payment's subscription state,
check time and Play token, replacing three subscription columns nothing
wrote. The ledger now stores and reads back charge credits, and the badge
credential response gains an optional subscription state that older clients
ignore and that reads an unknown state without failing. Nothing writes a
charge or a state yet, so no behaviour changes.
Run: 001-badge-subscriptions, slice 2
* core, badge-service: plan subscription charges and claim Apple subscriptions by original id
Add chargeEntries, the one planner for a store charge's ledger rows: a
lapse to the billing period start, the grant dated to that start, and a
lapse to now, so coverage follows the store's periods and every row
verifies by re-running it at its createdAt. A test runs its rows through
the client's own balanceChecked for first charges, monthly and annual
renewals, billing-retry recovery and grace.
An Apple auto-renewable transaction is now claimed by its original
transaction id, which every renewal keeps, so renewals resolve to one
purchase. One function names the ref for both the claim and the real
verifier, so they cannot disagree.
Run: 001-badge-subscriptions, slice 3a
* badge-service: credit a store subscription purchase
The catalog now tells subscription products from one-time ones and maps a
store plan to its tier and months. A subscription receipt is checked with
the store once: with a grantable charge, the service plans the charge's
rows from the store's period start, signs the first month, and writes the
payment, purchase, charge, ledger rows, issuance, state and Play token in
one transaction, answering with the subscription state. Without one, it
answers payment_pending (300 s while the period has not started or the
store is pending, a day otherwise), receipt_invalid for an ended Play
subscription, or test_purchase, and writes nothing. Replays carry the
state. The store verifier interface gains the subscription check, which no
real store implements yet; the fake store does.
Run: 001-badge-subscriptions, slice 3b
* badge-service: renew a subscription once its paid coverage ends
While a subscription purchase's coverage runs, issueBadge is unchanged
and makes no store call. Once it has ended, the service asks the store at
most once an hour per subscription, stamping every attempt, and answers
from the stored state in between; a failed check is retried after the
whole interval. A grantable charge is planned from the tip at the store's
period start, signed, and written with its issuance and state in one
transaction that re-reads the tip, answering internal if it moved. Any
other charge and the state are recorded without a ledger row. Every answer
for a subscription purchase carries its state.
Run: 001-badge-subscriptions, slice 4
* core: keep a subscription badge until the service says it ended
A purchase is a subscription exactly when its ledger replica holds a
charge credit, so the kind is derived with no column or migration. A
subscription is still asked about at zero months, at the shared weekly
moment, and keeps its badge past paidThrough until an answer says the
subscription ended, so a renewed month is presented at the old
credential's expiry with no gap, and a charge the store recovers later
still re-activates it. While it waits, the client records no failure and
raises no support-ended alert; "issued no credential" now fires only while
the ledger still owes a month. Prepaid purchases are unchanged.
Run: 001-badge-subscriptions, slice 5
* badge-service: check Apple subscriptions with the App Store Server API
With an in-app purchase key configured in [apple], the service reads a
subscription's status by its original transaction id from Get All
Subscription Statuses, authenticated by an ES256 token reused until near
expiry. Each signed transaction and renewal info is verified against the
pinned Apple root through the same chain check as one-time purchases, now a
separate function, and a body that fails it is a verifier failure, never a
refusal. Statuses map to renewing, cancelled, billing retry and ended;
family-shared transactions are refused; 401 and 403 are this service's
failure, other errors and timeouts unreachable. A verifier failure thrown
by a store check is answered internal, and a test subscription is refused
in one place for first purchases and renewals. Without the key, Apple
one-time purchases verify as before and subscriptions are not configured.
Run: 001-badge-subscriptions, slice 6
* badge-service: check and acknowledge Google Play subscriptions
The Play subscription check reads purchases.subscriptionsv2 by token
through the existing service-account client, with the same HTTP classes as
product reads, and maps Play's state, base plan, period, order id and
offer phase; the period start is its end minus the plan's length, and a
pending subscription stays pending. A subscription is acknowledged with
purchases.subscriptions.acknowledge before it is credited, skipped when
Play reports it acknowledged, so it never reaches the one-time endpoint.
Each store's subscriptions answer provider_not_configured when that store
is not configured. A charge on a plan the catalog does not sell is pending
rather than a malformed period.
Run: 001-badge-subscriptions, slice 7
* review: round 1 fixes
R1-1: test an App Store subscription renewal through issueBadge, including
a charge recorded before its period starts and granted later under the
same charge id.
R1-2: make the ledger's charge_id index unique, so the schema itself grants
each store charge at most once.
R1-3: pin that a charge on a plan the catalog does not sell, with an empty
period, is pending at purchase and recorded at renewal, never internal.
Run: 001-badge-subscriptions, review round 1
* core: update query plans
getBadgePurchase derives the purchase kind from the ledger replica, and a
subscription test makes a held store receipt due.
Run: 001-badge-subscriptions, final verification
* fixes
* core: move SubscriptionState to Badges.Types (structural)
BadgeState, which lives in Badges.Types, is about to carry a subscription's
state, and Badges.Service imports Badges.Types. Service re-exports it, as it
does BadgeServiceErrorCode. No behaviour change.
Run: 003-badge-subscriptions-ui, slice 1
* core: keep a store subscription's state on the client
The screens and alerts need to know whether a subscription renews, was
cancelled, waits for a charge, or ended, and the client dropped every state
the service sent but ended. It is now stored on the purchase: from the
answer to the first credit, and from a renewal answer only when the
asserted tip's coverage had ended, since while coverage runs the service
repeats what it read at the last renewal (a year old for an annual plan).
BadgeState gains `subscription` (the plan's months, from the latest charge
credit, and the state), and fills renewsAt/willRenew from it. A changed
state is announced with CEvtBadgeChanged. The column goes into the
unreleased M20260925 migration.
Run: 003-badge-subscriptions-ui, slice 2
* core: record the device store's view of a subscription
The service reads the store only once a subscription's paid coverage has
ended, so it never sees a cancellation while the period runs. The apps read
the device's store (StoreKit's renewal state, Play's isAutoRenewing) and
report it with /_badge subscription <state> <payment>, which records it on
the credited subscription purchase the payment resolves to and announces the
change to its owner.
A renewing report does not replace billingRetry or ended: Play lists a
subscription in its grace period with auto-renew on, and the service's next
answer clears billing retry once the charge lands.
Run: 003-badge-subscriptions-ui, slice 3
* core: claim a store-listed transaction only on the device that bought it
A store account's purchases are listed on every device signed into it:
Play's purchase query lists active subscriptions for their whole life, and
Apple's Transaction.updates delivers auto-renewables made on other devices.
A hand-over with no record and an unknown echoed invoice made a new record
under the presenting profile, so another device could claim a buyer's
subscription for its own active profile, or repeat a refused request every
30 days after cleanup.
/_badge purchase now takes a `listed` mark, which the apps' sweeps and
purchase listeners send: it joins only a record this core already has for
the transaction or its echoed invoice, and otherwise records and sends
nothing (CRStoreReceiptNotRecorded). The purchase call and Restore hand over
unmarked, as before.
Run: 003-badge-subscriptions-ui, slice 4
* core: let a held App Store subscription adopt its later transactions
A held Apple subscription record is keyed by its original transaction id,
so every renewal resolves to it. It kept the payment's back-off when a new
transaction arrived, and adopted any payment handed over, including a
revoked renewal, which the service refuses as receipt_invalid and so would
refuse the whole subscription.
A held Apple record now adopts a JWS only for another transaction with a
later purchaseDate and no revocationDate, read from the unverified payload,
and is then due at once with its retry delay and credit error cleared. A
re-delivered or re-signed copy changes nothing. A failed attempt writes its
back-off only if the record still holds the payment it sent.
Run: 003-badge-subscriptions-ui, slice 5
* core: refuse a badge code or invoice while a store receipt is pending
A code redeemed while a store receipt is held put the code's badge on the
profile, and the held receipt then waited behind it; for a subscription the
store kept charging every period meanwhile. Code redemption and invoice
creation now answer "store purchase open" while the profile holds an App
Store subscription receipt, or another receipt whose last failure can clear
on its own. A receipt that failed for good does not block, except an Apple
subscription, which Apple keeps charging; an uncredited Play subscription is
refunded by Play.
Run: 003-badge-subscriptions-ui, slice 6
* core: alert a subscriber that a payment failed, a renewal nears, or it was cancelled or ended
A subscription's alerts were the prepaid ones: a retired subscription said
"support ended", and nothing told the user a payment failed, an annual
renewal was near, or a cancelled subscription still runs. The derived alert
for a subscription is now, in order: subscription_ended once retired,
payment_issue while the store retries, issue_failed as before,
subscription_cancelled before paidThrough, and renewal_approaching from
three days before an annual plan renews, with a wake at that moment. Monthly
plans get no reminder, as there is no opt-out yet.
The unreleased migration's down step clears an acked subscription_cancelled,
which an older core cannot read.
Run: 003-badge-subscriptions-ui, slice 7
* badge-service: let the dev mock vouch for store subscriptions
With [dev] accept_unverified_store_receipts on, every subscription was
answered provider_not_configured, so the apps' subscription screens could
not be tried against a dev service. The mock now credits an App Store
subscription for the plan its product names, and a Play subscription for
the base plan a test token test.<basePlanId>.<uuid> names, or else its
product's monthly plan, from a minute ago; it remembers the plan in memory
and grants each renewal a new charge, answering a subscription it does not
know (after a restart) as ended.
The subscription check is given the product the presented receipt names,
which only the mock reads; it reads the service's badge clock at check time.
Run: 003-badge-subscriptions-ui, slice 8
* ios: hand App Store subscriptions to the core and report their renewal state
A subscription transaction was finished uncredited, so a buyer would pay and
get nothing. Every badge product now goes through a core invoice and is
handed over, and finished only once the core answers it credited or refused.
Transactions the store lists (the launch sweep, Transaction.updates) are
handed over as listed, so another device's purchase is not claimed here;
Restore purchases syncs with the App Store and claims them for this profile.
A revoked renewal is finished without hand-over, as the service would refuse
it and so the whole subscription.
The app reports StoreKit's renewal state to the core at each sweep, after the
manage-subscriptions sheet and after a restore. Monthly and annual are offered
only to an Apple ID with no earlier subscription in the group, which Apple
would fold a new one into.
Run: 003-badge-subscriptions-ui, slice 9
* ios: sell badge subscriptions and show their state on Your badge
Monthly and annual subscriptions are offered on How long and Check your
order, with the App Store's subscription disclosure, Terms of use and
Privacy policy links, and Restore purchases. Your badge shows a
subscription's state - Renews {date} · {price}, Ends {date}, Payment
problem, Renewing or Ended - with why another badge cannot be bought or
redeemed while it waits for its charge, Manage subscription and Restore
purchases. The chat list shows one banner for the current badge alert of
any kind: renewal soon, payment problem, cancelled, ended, as well as the
expired and renewal-failed ones it had.
Run: 003-badge-subscriptions-ui, slice 10
* android: hand Play subscriptions to the core and report their renewal state
The Kotlin port of the iOS store change. A Play subscription was
acknowledged uncredited; every badge product now goes through a core
invoice and is handed over. The sweep queries subscriptions as well as
one-time products, and the sweep and purchases listener hand over as listed,
so another device on the Google account does not claim them; Restore claims
them for this profile. A subscription is bought on its base plan's own
offer, and the app never acknowledges one itself: the service does when it
credits it, so Play still refunds one the service never credits.
The app reports Play's isAutoRenewing to the core as renewing or cancelled.
Run: 003-badge-subscriptions-ui, slice 11
* android, desktop: sell badge subscriptions and show their state on Your badge
The Kotlin port of the iOS screens. How long and Check your order offer
monthly and annual with Google Play's subscription disclosure, the privacy
policy link and Restore purchases. Your badge shows the subscription's state
with the same texts as iOS, the store named Google Play with a store lane and
"the store" without one, and Manage subscription and Restore purchases only
with a store lane on the local host. The chat list shows one banner for the
current badge alert of any kind. Desktop shows the state, footers and banners
without store rows or a price.
Run: 003-badge-subscriptions-ui, slice 12
* docs: describe the one badge alert banner in the apps' chat-list specs
Both chat-list specs described two hand-written badge banners (expired,
renewal failed); the chat list now has one banner for the current badge
alert of any kind. Also moves the line links this run shifted.
Run: 003-badge-subscriptions-ui, slice 13
* review: round 1 fixes
ios: stop selling a subscription once this device has seen one, so a
second profile is not charged for a subscription Apple folds into the
first (R1-12). Continue pins the chosen period on both apps (R1-13).
Only a device with a subscription asks its store for products and status
when reporting, and the purchases are marked checked before the report
(R1-14, R1-5: Kotlin now loads the products, so the price shows after a
restart). Restore says "Nothing to restore" after a cancelled purchase
(R1-7). Android keeps sweeping one-time purchases when the subscriptions
query fails (R1-6). The freshness comment in requestBadgeIssue states the
service's one-hour repeat of its last check (R1-1).
Run: 003-badge-subscriptions-ui, review round 1
* badge-service: answer a long-expired Play subscription as ended, and hash Play order ids
Play answers HTTP 410 for a subscription that expired too long ago to be
queried; it was read as unreachable, so the service never answered ended,
the badge was never retired and the profile stayed blocked from buying or
redeeming (R1-2). Play order ids appear on the buyer's receipt email, so
charges now store a hash of the order id, as purchase tokens already are
(R1-10).
Run: 004-badge-subscriptions-review, review round 1
* review: run 003 round 2 and run 004 round 1 app fixes
ios: buy a subscription only while it is still for sale, after re-reading
the Apple ID's transactions, and disable Pay once it is not, so a Check
your order screen opened before another profile or device subscribed
cannot buy one Apple folds into the earlier subscription (run 003 R2-1,
run 004 R1-4). android: skip the subscriptions query only on a device
whose Play does not support subscriptions, so Restore no longer says
"Nothing to restore" when the query failed (run 003 R2-2).
Run: 003-badge-subscriptions-ui round 2; 004-badge-subscriptions-review round 1
* review: round 3 fixes
ios: a subscription refused because this Apple ID subscribed before has
its own error, and Check your order says why Pay is disabled (R3-2).
android: skip the subscriptions query only when Play does not support
subscriptions, so a disconnected client fails the sweep or Restore
rather than reading as no subscriptions (R3-1).
Run: 003-badge-subscriptions-ui, review round 3
* core: update query plans
Regenerated by the full suite for the purchase, subscription status and adoption queries. Run: 003-badge-subscriptions-ui, final step
* badge-service: say what hashing the Play order id protects
The hash is unkeyed, so a holder of the buyer's receipt can still find the row (R2-1). Run: 004-badge-subscriptions-review, review round 2
* badge-service: name the rule for when a subscription is due a store check
The issue path's purchase read becomes getIssuePurchase and its "ask the
store now" test becomes subscriptionCheckDue, so the request router can
read the same rows by the same rule. No behaviour change.
Run: 005-subscriptions-load-timing, slice 1
* badge-service: answer every request that may ask a store on the store lane
App Store subscription purchases and subscription renewal checks called
their store from the main lane, so one slow or repeated store call held up
code redemptions and every other renewal. The router now reads the
purchase the way the issue path does and sends to the store lane every
request that may ask a store: Play purchases, App Store auto-renewable
purchases, and issue requests for a subscription due its store check,
judged a minute ahead so the handler never finds one due on the main lane.
Run: 005-subscriptions-load-timing, slice 2
* core: send no badge request before the wake a request that issued nothing set
While a subscription waited for its store charge, or a prepaid renewal
kept failing, every launch, foreground, profile switch and badge-state
read sent the issue request again, so the badge service saw the user's
app-open times for weeks. A due request now waits for the wake the last
pass stored, as long as that wake is no more than a stall interval away,
so it is asked again a day later as the renewal design says.
Run: 005-subscriptions-load-timing, slice 3
* fixup! core: send no badge request before the wake a request that issued nothing set
* fixup! badge-service: answer every request that may ask a store on the store lane
* badge-service: ask the App Store about a subscription purchase at most once an hour per claim
Anyone holding a real but uncreditable App Store subscription JWS, such as
their own lapsed subscription, could present it again and again, each time
spending an App Store Server API call from the quota every subscriber's
renewal check needs. The purchase path now remembers, in memory, which
subscription claims it asked about within the hour and answers a repeat
payment_pending with the time left. Keys are original transaction ids from
JWS that verified offline, entries expire after the hour, and a full table
asks rather than refuses, so memory stays bounded and no buyer waits on
another's claim.
Run: 005-subscriptions-load-timing, slice 4
* fixup! badge-service: ask the App Store about a subscription purchase at most once an hour per claim
* review: round 1 fixes
badge-service: a Play charge whose clipped period start leaves its month
already spent is answered as period over, not internal (R1-A1).
docs: the per-store answer classes list Play's 410 and the App Store
subscription check (R1-A2).
tests: a held receipt with no attempt yet blocks an invoice (R1-B2); a
renewing report keeps an ended status (R1-B3); a test comment names
[google] (run 005 C2-R2-1).
ios: a subscription refused because this Apple ID subscribed before
shows its sentence, not the error's name (R1-C2); ios, android, desktop:
the Check your order footnotes share one helper (R1-C3).
Run: 006-subscriptions-branch-review, review round 1
* fixup! review: round 1 fixes
* core: wait a day after a badge request that issued nothing, whatever the service or a snooze says
The hold on the stored wake let two moments the client does not schedule end it early. A renewal whose store check failed was answered with an hour's retryAfter, so the next activation after each hour sent it again, sampling when the app is opened. A snooze set before a request ended its wait at the snooze's end, timing the next request by the user's tap. Now a request that issued nothing waits the stall or a ledger moment: retryAfter only marks the failure transient, and a snooze is not a wake while a request waits; the request's own wake raises the alert again.
Run: 005-subscriptions-load-timing, slice 5
* fixup! review: round 1 fixes
* fixup! core: wait a day after a badge request that issued nothing, whatever the service or a snooze says
* badge-service: ask Play about a subscription purchase at most once an hour per token
The purchase path's hourly bound covered only App Store claims, so a re-presented Play subscription token, uncredited or credited to another key, spent a subscriptionsv2.get call each time, from the quota bucket Play renewal checks use. The bound now lives in purchaseSubscription, which both stores' purchase paths call, with one map per store: Play's keyed by the token's hash, so made-up Play tokens cannot fill the App Store's. Distinct made-up tokens are still one call each.
Run: 005-subscriptions-load-timing, slice 6
* badge-service: read Play's paused subscription as waiting to renew
An unmapped SUBSCRIPTION_STATE_PAUSED was answered internal, so a paused subscriber's renewal failed for the whole pause. Play reports a pause with auto-renewal on and charges again when it ends, so it now maps as an active subscription does: renewing, or cancelled with auto-renewal off. Pause is being turned off on the base plan; this is defence in depth. A pause grants nothing: Play names no new order, and the last one is already granted.
Run: 005-subscriptions-load-timing, slice 9
* core: retire a subscription badge once no store could still charge it, whatever the service answers
A subscription badge retired only on an ended answer, so a service that never sent one (a final error such as unknown_purchase_key, a rollback, a service that never answers) left the badge shown, one request a day forever, and the profile unable to redeem, buy or have a held receipt credited. Now it also retires once paidThrough is older than the longest store recovery window: Apple retries a failed renewal for up to 60 days, the longest with Play's pause off, plus a week. paidThrough counts every month the ledger holds, so banked months are never retired by it.
Run: 005-subscriptions-load-timing, slice 8
* ios, android: no Restore purchases on Your badge, where a restored purchase could not be credited
Your badge shows only while a badge is shown, so a purchase Restore found there with no record on this device was held under a profile whose held receipts are not processed while it shows a badge: not credited, nothing said, and on Android acknowledged, ending Play's three-day refund. Restore stays on Check your order, the one screen where a restored purchase can be processed.
Run: 005-subscriptions-load-timing, slice 7
* review: round 4 fixes
R4-1: a subscription badge is asked about first and retired only after the service answers, judged on the ledger after that answer. Retiring before asking used the client's paidThrough, which moves only when the client asks, so a subscriber who had not opened the app for longer than the recovery window while the store kept charging lost the badge at the next launch and was never asked again. A request that is not answered retires nothing. Tests: testSubscriptionRetiredAfterRecoveryWindow changed, testIdleSubscriptionKeptPastWindow and testSubscriptionNotRetiredWithoutAnswer added.
R4-2: one snooze comment where two contradicted each other.
R4-3: the recovery window's comment names Play's clipped period start.
R4-4: the retirement tests use subscriptionRecoveryWindow.
* badge-service: name the lane each request waits on
The router answered "may this request ask a store" as a Bool; renewal checks are to get a lane of their own, which a Bool cannot say. requestLane now names the lane: main, purchase or renewal. Both store lanes still share one queue, so no request changes lane.
Run: 007-subscriptions-retirement-lane, slice 1
* badge-service: check renewals on a lane no purchase waits on
Renewal checks shared the store lane with every purchase, so made-up Play tokens, which anyone can send, and the weekly burst of genuine purchases delayed them. Renewal checks now wait on a lane of their own, which only a request signed by the purchase key it names, and due its hourly store check, can reach.
Run: 007-subscriptions-retirement-lane, slice 2
* badge-service: name the web limiter's fixed-window step
takeToken counted a request against a minute's limit inline. The step becomes takeFrom, a pure function, so the purchase path's Play read allowance can use the same rule. No behaviour change.
Run: 007-subscriptions-retirement-lane, slice 3a
* badge-service: read Play's subscriptions at most 300 times a minute for purchases
A Play subscription purchase is asked about without any offline check, so any token in the accepted grammar spends a read from the Subscriptions quota renewal checks share. The purchase path now takes one of a minute's 300 reads for each Play subscription it asks about, a tenth of Play's default quota; with none left, the claim is answered rate_limited with the seconds left in the minute and is not held, as Play was not asked. A claim already held takes no read, and App Store claims, verified offline first, take none.
Run: 007-subscriptions-retirement-lane, slice 3
* core: count the subscription retirement window from the first answer that renewed nothing
The window was counted from the client's paidThrough, which moves only when the client asks, so after months away the first answer that granted nothing for any reason, a transient error, Play's clipped last days or a billing retry, retired a subscriber whose store may still have been charging. The purchase now stores its unrenewed run: the first answered request after which the ledger covered nothing, and the latest one. The badge retires once the window has passed since the run started, still only after an answer; a renewal ends the run, and an answer that comes a window or more after the latest one starts a new run, since an absence or an outage that long may hide a renewal. The window stays 67 days, written as Apple's 60-day billing retry plus the week contacts give an expired credential.
Run: 007-subscriptions-retirement-lane, slice 4
* review: round 1 fixes
R1-A1: the docs and the PlayReads comment said the purchase path reads Play's subscriptions at most 300 times a minute; it asks about at most 300 subscriptions, and a credited one is read once more to acknowledge it, so made-up tokens can spend at most a tenth of the quota renewal checks share.
R1-A2: a comment on RequestLane repeated the one above the queues.
R1-B1: a test that a held pass, which sends no request, leaves the unrenewed run unchanged.
R1-B2: the outage test's last wait for the run is 30 s instead of 5 s.
Run: 007-subscriptions-retirement-lane, review round 1
* core: add the badge subscription columns in their own migration
M20261016_badge_store_receipts shipped to master in #7587, so it must
stay as released. Restore it in both dialects and move the
subscription_status, unrenewed_since and last_unrenewed_at columns,
with the down step that clears subscription_cancelled acknowledgements
master cannot read, into M20261016_badge_subscriptions.
Run: 008-subs-migration-split, slice 1
* tests: drop the FilePath import a merge duplicated
The branch and master each added import System.FilePath ((</>)) to
Direct.hs on 2026-10-14 at different positions, and an earlier merge
kept both, so GHC warned that one was redundant. Keep master's.
Run: 008-subs-migration-split, slice 2
* fixup! core: add the badge subscription columns in their own migration
* ios, android, desktop: choose a badge in the app
* ios, android, desktop: use the dev badge page for testing (don't merge)
* update postgres schema
* update ios lib
* ios, android: hide Restore purchases on the order screen
* docs: summarize the badge subscriptions PR for reviewers, with diagrams
A summary of PR #7716 as built: buying, renewing and retiring on each
store, the retirement window, lanes and store limits, RPC, API and
database changes, alerts, tests, open items, and a review map. Seven
SVG diagrams, an HTML page with them inline, and a link from the plan.
* ios, android, desktop: restore purchases on the Support SimpleX screen
* ios, android, desktop: show the badge privacy policy in the app
* docs: keep only the badge subscription diagrams, with a README
The reviewer summary leaves the repo; the folder keeps the seven
diagrams with a README that captions each, and the plan links the
README.
* ios, android, desktop: smaller Restore purchases label
* ios: no alert when a badge purchase is cancelled
* docs: replace the badge subscriptions diagrams README with a short summary
A plain-English summary for anyone on the team, with the seven
diagrams and a self-contained HTML page; the plan links it again.
Diagrams follow the branch: Restore purchases is on Support SimpleX,
"Renewing" shows past paidThrough, and only a present retryAfter is
raised to an hour.
* ui
* ui wip
* core: retire a subscription badge as soon as its store says it ended
A subscription badge retired only on an ended answer to a request that was due, and only once the ledger's paidThrough had passed, so an ended answer while the ledger still covered, or the iOS app's report of StoreKit's expired or revoked state, left the badge shown, Support SimpleX unreachable and every purchase refused until a later request. Now an answered ended state is stored even while coverage runs (the service writes it only from a store check, after which it grants nothing), and every pass retires a subscription whose state, from this pass's answer or else as stored, is ended, whatever the ledger covers. The paidThrough guard stays at the prepaid call site, its only remaining user.
Run: 010-subscription-lifecycle, slice S1
* docs: record that a badge subscription cannot be restored or bought again
Restoring a subscription on a new device or under another profile fails for two independent reasons, the client's restore reading only unfinished transactions on iOS and the service answering any key but the one it credited receipt_used, and a lapsed iOS subscriber cannot subscribe again on the same install either. Both gaps files record it in their own format, and both restorePurchases() say why reading the store's entitlements alone would not help.
Run: 010-subscription-lifecycle, slice S2c
* fixup! core: retire a subscription badge as soon as its store says it ended
* transfer plan
* shorter plan
* update plan
* badge-service: let a dev deployment ask its mock store while coverage runs
[dev] check_store_during_coverage = on makes every issueBadge for a
subscription ask the dev mock, during paid coverage too, and bank the
month of a charge it can grant, issued once coverage reaches it. So a
renewal can be watched by hand without waiting a month, and a mock that
forgot the plan after a restart answers ended at once.
Off by default, and refused unless accept_unverified_store_receipts is
on, which is itself refused beside a real store: a real store has not
started its next charge while coverage runs, and asking spends the
quota every subscriber shares.
Run: 011-subscription-tools, slice S3a
* core: send a badge request at once from a build for the dev badge service
/_badge request <userId> sends the profile's badge request now, while
its badge is shown, and runs the badge pass on the answer, so a renewal
or an ended subscription can be watched by hand against the dev
service.
It is allowed only when ChatConfig's badgeDevRequests is set, which no
shipped build does and nothing sets at runtime: the dev-service branch
sets it beside its own badge service overrides. A request off the shared
expiry would tell requests apart, so every other build refuses it, and
it is refused from a paired remote controller.
Run: 011-subscription-tools, slice S3b
* fixup! badge-service: let a dev deployment ask its mock store while coverage runs
* fixup! badge-service: let a dev deployment ask its mock store while coverage runs
* fixup! core: send a badge request at once from a build for the dev badge service
* core: split the badge request's lock from its body
The receipt worker's attempt already holds the owner's badge lock, which is not reentrant, so it cannot call requestBadgeIssue. The body becomes sendBadgeIssue, and both answer a service error as Left (code, retryAfter) instead of Nothing, so that caller can classify it as it does today. No behaviour change.
Run: 011-subscription-resume, slice S2-0
* core: retire on a device's ended report only once the paid period is over
StoreKit can report a subscription expired during Apple's billing retry, and Apple may then recover the charge, so retiring at once on that report left a paying user without the badge. The service's answered ended still retires at once, also while the ledger covers. A stored ended, which is how the device's report arrives, retires once paidThrough has passed.
Run: 011-subscription-resume, slice S1c
* core: resume a retired Apple subscription when its store charges it again
A same-install resubscription keeps Apple's original transaction id, so its hand-over found the old credited record and was answered from it with the old credential: the subscriber paid and got nothing. A credited record is now held again when a later charge of its subscription is handed over, the charge is not revoked, its paid period runs and it is dated less than a day ahead of this device's clock, and the record's purchase is its owner's newest, a subscription and not shown, with no other receipt held. The receipt worker then sends the purchase's issueBadge, and the service grants the charge as a renewal. A covered answer shows the purchase again with the newest credential and clears an ended state. A charge the service answers without crediting is deferred an hour and refused a day after its purchase date, so it holds the profile for about a day. A refused resume is remembered on the record (every credit, re-hold and resume clears credit_error), so the app's sweep does not hold the same charge again after its day.
Run: 011-subscription-resume, slice S2a
* fixup! core: resume a retired Apple subscription when its store charges it again
Review round 1 fixes:
- R1-1: a resumable charge waiting behind another held receipt of the owner is answered held and left unfinished, rather than answered from the record and finished uncredited.
- R1-3: a resume shows the purchase again only with a credential that has not expired.
- R1-2, R1-8: tests of the resumable-charge rule and the hold's edge, of a held charge whose paid period ends before it is sent, and that a resumed profile shows the newest credential. The resume test is split in two, one environment each.
- R2-V1: a test comment that the reordered steps made false.
Run: 011-subscription-resume, review rounds 1 and 2
* core, ios, android, desktop: let a lapsed iOS subscriber subscribe again to the badge the profile retired
The iOS app refused any subscription to an Apple ID that ever subscribed, so a lapsed subscriber could never subscribe again in the app. Core now tells the apps each subscription's store reference (BadgeSubscription.transactionRef, optional in JSON), and the iOS gate opens a subscription card only when every earlier subscription of the Apple ID is the active profile's newest purchase, retired, of the card's badge: the one charge core resumes. Kotlin gets the model field only, as Play gives a resubscription a new token and needs no gate.
Run: 011-subscription-resume, slice S2b
* docs: bound a badge move by the previous credential's expiry
* badge-service: credit test purchases and check the store during coverage in a dev deployment
* core: let a dev build ask the badge service on an interval
* badge-service: log each request's command and each store check's outcome
* fix compilation
* badge-service: name the purchase each request came from in the log
* badge-service: name the purchase each request came from in the log
* core, ios, android: explain a failed badge renewal and stop retrying a subscription the store forgot; tests (#7788)
* core, badge-service, ios, android: change badge tier in the store, and restore a subscription on another device (#7795)
* core: tell the apps when a badge request banks a month without issuing it
A pass announced the badge state only when the balance start moved, so a charge granted onto running coverage, or a debit the service wrote, changed months left and paid-through without an event, and the apps kept showing the old renewal date until the screen was reopened. The pass now announces whenever the ledger's last entry is not the one it started from, which a moved balance start implies.
Run: 015-badge-fixes, slice 1
* core: test that every Play charge of a month-end subscription funds its month
Play reports only a period's end, and the service dates the charge a plan length before it, so month-end clipping could leave a charge funding less than its month. Walk a monthly and an annual Play plan anchored on 31 January through their renewals at the device's request moments, through the deployed Play verifier: each charge funds exactly its months, paid-through stays at most the grace behind Play's period end (three days at most in this walk), and no charge is left without a grant.
Run: 015-badge-fixes, slice 2
* core: test that the newest issued badge credential is taken by its ledger row
After a tier change the newer issuance can end before the older one, so ordering issuances by period end would answer the superseded credential to the worker and to the resume and tier-change paths. Pin the row order: the test fails if the query goes back to period_end order.
Run: 015-badge-fixes, slice 3
* review: round 1 fixes
The App Store tier-change hold, reviewed for the first time:
- R1-B2: a tier change's credit now shows its purchase again (shown_badge_id) in the same transaction, as a resume's does, so a retire that ran while the change was with the service cannot leave a paid purchase unshown and never renewed.
- R1-B5: a tier change of a shown purchase is held whatever other receipt is held, as the worker serves it while the badge is shown; only a resume waits for other held receipts.
- R1-B3: test a tier change the store does not vouch for: refused in silence, the badge kept, and answered from the record after.
- R1-B8: the changeTier comment names when the record settles.
Run: 015-badge-fixes, review round 1
* review: round 2 fixes
- R2-1: a granted tier change clears an ended state the device reported before it. A renewing report never replaces ended, and a charge of the new tier granted proves the subscription has not ended, so without this the worker retired the new tier's badge at its paid-through while the subscription renewed.
- R1-B3: test a tier change whose credential does not verify: the failure stays on the receipt record, not the purchase, and Supporter stays shown.
- R2-3: the refused tier change's test buys Supporter through an invoice, so it shows the refusal announces nothing.
- R2-2: the changeTier comment says what follows each way the record settles.
Run: 015-badge-fixes, review round 2
* review: round 3 fixes
- R3-1: a tier change whose answer says the subscription ended is not credited, as a resume is not, so the credit's clearing of a stored ended never drops the one this answer carries.
- R3-2: the changeTier comment in two lines.
Run: 015-badge-fixes, review round 3
* core: apply an App Store tier change at once only when it raises the tier
Apple keeps charging the old plan to the end of the period, so applying a downgrade at once took away a badge the subscriber had already paid for. A charge of another tier is now held and sent at once only from supporter to legend; a lower tier, or one that is not ranked (investor, unknown), is answered from the record and the renewal check grants it, as badges-rpc.md says. A record already held for such a change settles silently.
Run: 016-tier-change-direction
* core: test that an App Store change to a lower tier waits for the renewal
A Supporter charge handed over while Legend is shown is answered from the record and sends nothing, and the renewal check grants Supporter, shown once the Legend credential expires; a record an older build held for a lower tier settles with no request. Both fail if every change of tier is sent at once again.
Run: 016-tier-change-direction
* badge-service: let a restore ask the store without waiting for the purchase's own check
* docs: describe badge tier changes and restore as the service handles them
The RPC document splits a store tier change by store and direction: the
app never starts one, an App Store raise seen within a day of its charge
is sent as upgradeBadgeSubscription, every other change waits for the
renewal check, and a Play plan change is a new token, a new purchase.
transferBadge and its limits are checked against the handlers, and the
upgrades that are not built are marked so.
The overview admits what Restore discloses: the service links the two
purchase keys, a store receipt moves its subscription to whoever presents
it within the move limit, and a new badge or tier is shown as soon as it
is issued. The Android gap names what remains of Restore.
* docs: tell the badge subscriptions story with tier changes and restore
The summary now covers the whole lifecycle: buying, renewing, changing
tier, moving to another device or profile, and ending. The badge retires
as soon as the store says the subscription ended, a lapsed iOS subscriber
can resume the retired badge, an App Store raise is granted at once and
a lower tier at the renewal, and a Play plan change is a new purchase.
The known limits and the before-sale list follow the code.
The seven diagrams are updated, with stale file and line references
dropped, and two are added: a tier change and a restore. The page now
points at the diagram files instead of carrying copies of them.
* fixup! docs: describe badge tier changes and restore as the service handles them
* fixup! docs: tell the badge subscriptions story with tier changes and restore
* fixup! docs: describe badge tier changes and restore as the service handles them
* fixup! docs: tell the badge subscriptions story with tier changes and restore
* core: make the badge subscription tests fail on the defects they name (#7798)
* fix compilation
* fix api
* ios, android, desktop: open the badge page on the site again
* ios: choose a badge on the site, not in the app
* tests: hold the move with no charge to grant, not with the purchase's store check
* unfocus test
* update postgres schema
* badge-service: break a tie between issuances by their period, so both databases agree
* badge subscription review fixes (#7799)
* update badge subscriptions docs (#7802)
* core: show a changed badge tier to contacts as soon as it is granted
* update plans
* core: show a raised badge tier to contacts at once, hold back a lower one
* update sqlite schema
* badge subscriptions review fixes (#7903)
* update ios lib
* update query plans
* ios: one property wrapper on the chat view's chat (#7910)
* android: a shorter note under Restore purchases
* Revert "ios: one property wrapper on the chat view's chat (#7910)"
This reverts commit d5a24bb342.
* ci: larger RAM disk so tests do not run out of space
* update sqlite schema
* ios: preview references for the badge screens
---------
Co-authored-by: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com>
3740 lines
189 KiB
Haskell
3740 lines
189 KiB
Haskell
{-# LANGUAGE CPP #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE TupleSections #-}
|
|
|
|
module Bots.BadgeService.WebTests (badgeWebTests) where
|
|
|
|
import BadgeService.Catalog (catalogCurrency, defaultCatalog)
|
|
import BadgeService.Config (BTCPayConfig (..), ListenerConfig (..), PollConfig (..), ServiceConfig (..), SpeedPolicy (..), StripeConfig (..))
|
|
import BadgeService.Orders (codeLifetime, settleOrder)
|
|
import BadgeService.Poller
|
|
import BadgeService.Providers
|
|
import BadgeService.Providers.BTCPay (btcpayProvider, listPageSize, maxListPages)
|
|
import BadgeService.Providers.Stripe (stripeProvider)
|
|
import BadgeService.Store (CreditedPurchase (..), FundingCredit (..), KeyPurchase (..), KeyRedemption (..), ManagedGroup (..), NewCodePurchase (..), NewStorePurchase (..), PaymentRebind (..), PurchaseSubscription (..), RevokeResult (..), appendLedgerPlan, createCodePurchase, createStorePurchase, getCodePurchaseForKey, getLedgerEntries, getLedgerTip, getManagedGroup, getMovedFromExpiry, getPurchaseByKey, getPurchaseSubscription, getStorePaymentCredit, insertBadgeCode, insertManagedGroup, insertSubscriptionCharge, markOwnerBootstrapped, rebindStorePayment, revokeCode)
|
|
import BadgeService.StoreReceipts (StoreCharge (..))
|
|
import BadgeService.Store.Invoices
|
|
import BadgeService.Waiters (awaitStatus, newWaiters, publish, waitingCount)
|
|
import BadgeService.Web.Server
|
|
import Bots.BadgeService.BotTests (codeCounts, newPurchaseKeys)
|
|
import Bots.BadgeService.CatalogTests (WebOffer (..), WebPrice (..), parseCatalogSource)
|
|
import Bots.BadgeService.FakeBTCPay
|
|
import Bots.BadgeService.FakeStripe (FakeStripe (..), fakeIntentStatus, setIntentState, stripeEvent, stripeSigHeader, withFakeStripe)
|
|
import Control.Concurrent (forkIO, threadDelay)
|
|
import Control.Concurrent.Async (async, wait)
|
|
import qualified Control.Concurrent.Async as Async
|
|
import Control.Concurrent.MVar (MVar, newEmptyMVar, newMVar, putMVar, takeMVar, withMVar)
|
|
import Control.Concurrent.STM (atomically, modifyTVar', readTVarIO)
|
|
import qualified Control.Exception as E
|
|
import Control.Monad (forM_, join, replicateM, replicateM_, void, when)
|
|
import Data.Aeson ((.=))
|
|
import qualified Data.Aeson as J
|
|
import qualified Data.Aeson.Key as K
|
|
import qualified Data.Aeson.KeyMap as KM
|
|
import Data.ByteString (ByteString)
|
|
import qualified Data.ByteString as BS
|
|
import qualified Data.ByteString.Base64.URL as B64U
|
|
import qualified Data.ByteString.Char8 as BC
|
|
import qualified Data.ByteString.Lazy.Char8 as LB
|
|
import Data.Char (toLower)
|
|
import Data.Either (isLeft)
|
|
import Data.Functor ((<&>))
|
|
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
|
|
import Data.Int (Int64)
|
|
import Data.List (isInfixOf, sort, sortOn)
|
|
import qualified Data.Map.Strict as Map
|
|
import Data.Maybe (catMaybes, isJust, isNothing, fromMaybe, mapMaybe)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Text.Encoding (encodeUtf8)
|
|
import qualified Data.Text.IO as T
|
|
import Data.Time.Calendar (fromGregorian)
|
|
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, picosecondsToDiffTime, secondsToDiffTime)
|
|
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
|
|
import Data.Word (Word32, Word8)
|
|
import qualified GHC.IO.Device as IODevice
|
|
import qualified GHC.IO.FD as FD
|
|
import GHC.IO.Handle.FD (handleToFd)
|
|
import Network.HTTP.Client (Manager, Request (..), RequestBody (..), Response, defaultManagerSettings, httpLbs, newManager, parseRequest, responseBody, responseHeaders, responseStatus, responseTimeoutMicro)
|
|
import Network.HTTP.Types (Header, HeaderName, hCacheControl, hContentType)
|
|
import Network.HTTP.Types.Status (statusCode)
|
|
import qualified Network.Wai.Handler.Warp as Warp
|
|
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey (..), BadgeType (..))
|
|
import Simplex.Chat.Badges.Ledger (addMonths, debitEntry, emptyEntry, grantEntry, issueEntry)
|
|
import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..), StatementCreditType (..), StatementDebitType (..), StatementEntry (..), SubscriptionState (..))
|
|
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..), BadgeItemStatus (..), BadgeOfferId (..), BadgePriceId (..), OfferDiscount (..))
|
|
import Simplex.Chat.PaymentService.Types (CryptoCurrency (..), CurrencyAmount (..), InvoiceId (..), InvoiceStatus (..), PaymentProvider (..), PaymentStatus (..), ServicePaymentDestination (..), ServicePaymentMethod (..))
|
|
import Simplex.Messaging.Agent.Store.Common (DBStore (..), withConnection, withTransaction)
|
|
import qualified Simplex.Messaging.Agent.Store.DB as DB
|
|
import Simplex.Messaging.Agent.Store.Interface
|
|
import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration)
|
|
import qualified Simplex.Messaging.Crypto as C
|
|
import Simplex.Messaging.Crypto.BBS (BBSSignature (..))
|
|
import Simplex.Messaging.Encoding.String (textDecode, textEncode)
|
|
import Simplex.Messaging.Util (safeDecodeUtf8, tshow)
|
|
import System.Directory (createDirectoryIfMissing, createFileLink, doesFileExist, listDirectory)
|
|
import System.FilePath ((</>))
|
|
import System.IO (IOMode (..), hFlush, stderr, withFile)
|
|
import System.IO.Unsafe (unsafePerformIO)
|
|
import System.Timeout (timeout)
|
|
import Test.Hspec
|
|
import Text.Read (readMaybe)
|
|
import UnliftIO.Temporary (withTempDirectory)
|
|
|
|
#if defined(dbPostgres)
|
|
import BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations)
|
|
import ChatClient (testDBConnstr)
|
|
import Database.PostgreSQL.Simple (Only (..))
|
|
import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations
|
|
#else
|
|
import BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations)
|
|
import Data.String (fromString)
|
|
import Database.SQLite.Simple (Only (..))
|
|
import qualified Database.SQLite.Simple as SQL
|
|
import Simplex.Messaging.Agent.Store.DB (TrackQueries (..))
|
|
import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations
|
|
#endif
|
|
|
|
#if defined(dbPostgres)
|
|
withServiceStore :: (DBStore -> IO a) -> IO a
|
|
withServiceStore action = do
|
|
n <- atomicModifyIORef' serviceSchemaCounter (\i -> (i + 1, i))
|
|
Right st <- createDBStore (serviceDBOpts n) badgeServiceSchemaMigrations (MigrationConfig MCError Nothing)
|
|
action st `E.finally` closeDBStore st
|
|
where
|
|
serviceDBOpts :: Int -> DBOpts
|
|
serviceDBOpts n =
|
|
DBOpts
|
|
{ connstr = BC.pack testDBConnstr,
|
|
schema = "sx_badge_service_web_test_" <> BC.pack (show n),
|
|
poolSize = 4,
|
|
createSchema = True
|
|
}
|
|
|
|
serviceSchemaCounter :: IORef Int
|
|
serviceSchemaCounter = unsafePerformIO $ newIORef 0
|
|
{-# NOINLINE serviceSchemaCounter #-}
|
|
#else
|
|
withServiceStore :: (DBStore -> IO a) -> IO a
|
|
withServiceStore action = do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-web" $ \dir -> do
|
|
Right st <- createDBStore (DBOpts (dir </> "badge_service_test.db") [] "" False True TQOff) badgeServiceSchemaMigrations (MigrationConfig MCError Nothing)
|
|
action st `E.finally` closeDBStore st
|
|
#endif
|
|
|
|
columnsOf :: DBStore -> Text -> IO [Text]
|
|
columnsOf st table = withConnection st $ \db ->
|
|
#if defined(dbPostgres)
|
|
map fromOnly
|
|
<$> DB.query
|
|
db
|
|
"SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = ?"
|
|
(Only table)
|
|
#else
|
|
map fromOnly
|
|
<$> DB.query_ db (fromString ("SELECT name FROM pragma_table_info('" ++ T.unpack table ++ "')"))
|
|
#endif
|
|
|
|
badgeWebTests :: Spec
|
|
badgeWebTests = do
|
|
describe "badge service schema" $ do
|
|
it "carries the service-only columns, and none of the subscription columns it never wrote" testServiceColumns
|
|
it "refuses a duplicate provider_ref" testProviderRefUnique
|
|
it "M20260918 adds the multi-use columns and the group table" testGroupOpsColumns
|
|
it "M20260918 defaults a fresh code to one use with none spent" testGroupOpsRedeemCounts
|
|
it "migrates all the way down and up again" testSchemaDownUpCycle
|
|
it "rolls back only the group migration and re-applies it, keeping a code redeemed twice spent" testGroupOpsDownUp
|
|
it "rolls back only the subscription migration, restoring the columns it dropped, and re-applies it" testSubscriptionsDownUp
|
|
describe "badge service store" $ do
|
|
it "writes the invoice, its code and their link atomically" testCreationIsAtomic
|
|
it "newInvoiceId is 128 CSPRNG bits, base64url, and two calls differ" testNewInvoiceIdRandom
|
|
it "codeHashExists is true for a hash already written" testCodeHashExists
|
|
it "expireOverdue moves only open, past-expiry rows, and names them" testExpireOverdueMovesOnlyQualifying
|
|
it "expireOverdue moves only the overdue invoices it is given" testExpireOverdueMovesOnlyTheNamed
|
|
it "expireOverdue never sweeps an invoice that has been paid into" testExpireOverdueSparesAFundedInvoice
|
|
it "expireOverdue spares an invoice funded by dust, or by the verdict alone" testExpireOverdueSparesAZeroAmount
|
|
it "readCatalogRows drops every disabled row" testReadCatalogRowsDropsDisabled
|
|
it "a revoked code cannot be redeemed, and a redeemed code cannot be revoked" testRevokeAndRedeemExcludeEachOther
|
|
it "a multi-use code can be revoked while it has uses left, and not once they are gone" testRevokeMultiUseCode
|
|
it "every timestamp round-trips to the second" testTimestampRoundTrip
|
|
it "stores a charge credit with its charge, and reads it back at the tip and in the statement" testChargeCreditRoundTrip
|
|
it "stores transfers and an upgrade with the purchase each names, and reads them back at the tip and in the statement" testTransferEntriesRoundTrip
|
|
it "refuses a ledger row naming a purchase key with no purchase, writing nothing" testUnknownPurchaseKeyRefused
|
|
it "moves a store payment to a new key, ending the old purchase's share with a transferOut naming it" testPaymentRebind
|
|
it "moves nothing when the old purchase's ledger or the payment's binding moved since they were read" testPaymentRebindLostRace
|
|
it "reads the latest credential expiry of the purchase a payment last moved from" testMovedFromExpiry
|
|
describe "managed group" $ do
|
|
it "round-trips and bootstraps once" testManagedGroupRoundTripsAndBootstrapsOnce
|
|
it "refuses a second row and burns the flag per group" testManagedGroupIsSingleRow
|
|
describe "multi-use" $ do
|
|
it "finds a key's own purchase with its newest credential, and none for another key" testKeyPurchaseLookup
|
|
it "gives concurrent claims exactly the code's uses" testMultiUseConcurrentClaimsUpToLimit
|
|
it "refuses a second claim by the same key without using up a use" testSameKeyClaimsOnce
|
|
describe "badge service catalog seed" $ do
|
|
it "writes the compiled-in catalog into an empty database" testSeedWritesTheCatalog
|
|
it "leaves exactly one row per id when the service starts twice" testSeedIsIdempotent
|
|
it "leaves a price and an offer an operator withdrew withdrawn" testSeedNeverResurrectsAWithdrawnRow
|
|
it "seeds exactly what web/src/catalog.ts compiles into the page" testSeedMatchesWebCatalog
|
|
it "prices a btc checkout, and answers card provider_unavailable" testSeededCatalogSellsSomething
|
|
describe "BadgeService.Providers stub" $ do
|
|
it "constructs a Provider record and records every call, in order" testStubProviderRecordsCalls
|
|
it "makes pCreateInvoice fail on demand, so provider_unavailable is exercisable" testStubProviderCreateFailsOnDemand
|
|
it "records nothing when the caller never reaches the provider" testStubProviderNotCalledWhenSkipped
|
|
describe "badge service web" $ do
|
|
it "serves the shell at / and a hashed asset under /assets" testServesTheBuild
|
|
it "fills the shell's publishable key from the ini at boot" testShellCarriesPublishableKey
|
|
it "caches by where the file is, not by how the request spelled it" testCachingFollowsTheResolvedPath
|
|
it "serves the built web app, exactly as it is shipped" testServesBuiltWebApp
|
|
it "serves no static routes when serve_webapp is off, and still answers the API" testServeWebappOff
|
|
it "exports the injected webapp to a folder for a proxy to serve" testExportWebapp
|
|
it "refuses every traversal spelling, by canonicalisation" testTraversalRefused
|
|
it "answers the read fields, with cryptoCurrency lowercase" testInvoiceView
|
|
it "names the confirmations settlement needs, from the speed policy" testViewNamesTheConfirmationsSettlementNeeds
|
|
it "omits the confirmations where no provider is configured" testViewOmitsConfirmationsWithoutBTCPay
|
|
it "carries the provider's paid-in-full verdict, which the amounts cannot give" testViewCarriesTheProvidersPaidVerdict
|
|
it "never withdraws a paid-in-full verdict on a later read" testPaidVerdictIsNotWithdrawnByALaterRead
|
|
it "answers an unknown id with 404 not_found and nothing else" testUnknownInvoiceIsOpaque
|
|
it "releases a held wait when a payment lands without settling" testHeldWaitWakesOnAPaymentThatDoesNotSettle
|
|
it "leaves a held wait alone when a pass writes nothing" testHeldWaitIsNotWokenByAPassThatWroteNothing
|
|
it "is not woken by the same figures arriving again" testARepeatedSignalDoesNotWakeAHold
|
|
it "carries Cache-Control: no-store on every API response" testApiIsNeverCached
|
|
it "answers a known path reached with the wrong method with 405" testWrongMethodIs405
|
|
it "answers a path this task does not implement with 404" testUnroutedPathIs404
|
|
it "answers ?wait= at once when it is terminal, stale or unparseable" testWaitAnswersAtOnce
|
|
it "answers at once when it holds a payment the page has not seen" testHoldAnswersAPaymentThePageHasNotSeen
|
|
it "answers a verdict that arrived with no figure to go with it" testHoldAnswersAVerdictWithNoFigure
|
|
it "holds ?wait=open, and is woken by publish rather than polled" testHoldIsWokenNotPolled
|
|
it "refuses over sixty reads a minute from one IP" testReadRateLimit
|
|
it "takes the client from X-Forwarded-For only when it is trusted" testForwardedForOnlyWhenTrusted
|
|
it "uses a forwarded client only when it parses as an IP address" testForwardedForMustBeAnAddress
|
|
it "holds the bucket map under its cap through a flood of distinct clients" testBucketsStayBounded
|
|
it "answers a hold from the row it read, not from the status it woke on" testHoldReportsTheRowNotTheWake
|
|
it "answers a hold that timed out with a change nothing published" testHoldTimeoutReportsAnUnpublishedChange
|
|
it "answers a handler exception with 500 internal, and keeps serving" testHandlerExceptionIsContained
|
|
describe "badge service settlement" $ do
|
|
it "settles an open invoice: paid, a settled payment row, and the code" testSettlesAnOpenInvoice
|
|
it "settles an expired invoice, because late settlement is routine" testLateSettlementIsLegal
|
|
it "records what arrived without moving an unsettled invoice" testFundedRecordsWithoutMoving
|
|
it "expires an open invoice whose window closed, recording what arrived" testClosedExpiresAnOpenInvoice
|
|
it "writes no payment for a window that closed on nothing" testClosedWithNothingWritesNoPayment
|
|
it "rewrites the same values when a closed window is redelivered" testClosedReplayIsIdempotent
|
|
it "changes nothing for any signal against a paid invoice" testPaidRefusesEverySignal
|
|
it "takes the larger of the stored and the reported amount" testAmountIsMonotonic
|
|
it "measures the code deadline from the first settlement" testDeadlineIsFromTheFirstSettlement
|
|
it "leaves an invoice another transaction moved first alone" testStatusGuardRefusesAStaleObservation
|
|
it "publishes what it found when it loses the status guard" testLosingTheStatusGuardStillWakesTheHold
|
|
it "never downgrades a settled payment row" testSettledPaymentIsNotDowngraded
|
|
it "fills in a crypto amount that only arrived later" testCryptoAmountFillsInFromNull
|
|
it "answers Left for an invoice this service does not hold" testUnknownInvoiceSettlesNothing
|
|
it "publishes after the commit, so a woken reader finds the row" testPublishIsAfterCommit
|
|
it "wakes a request the listener is holding, from settleOrder itself" testSettlementWakesAHeldRequest
|
|
it "reports settledAt from the payment row, not from the invoice" testSettledAtIsThePaymentRow
|
|
it "refuses a settlement instant the provider cannot have meant" testAbsurdSettledInstantIsRefused
|
|
it "refuses a settlement instant in the future, as a millisecond clock gives" testFutureSettledInstantIsRefused
|
|
describe "badge service poller" $ do
|
|
it "settles a payment with no webhook delivered anywhere" testSettlesWithNoWebhookAtAll
|
|
it "reads only the invoices it is waiting on, and lists on the stray cadence" testPassReadsWhatItAwaits
|
|
it "asks the provider nothing when it is waiting on nothing" testIdlePassAsksNothing
|
|
it "lists instead of reading once too many invoices are open" testManyOpenInvoicesList
|
|
it "sweeps nothing when no provider can account for the rows" testNoProviderAccountsForNothing
|
|
it "holds the sweep for one row a configured provider does not own" testMixedProvidersHoldTheSweep
|
|
it "sends one list for a bulk pass, and none for the next one that reads" testBulkListIsTheOnlyList
|
|
it "hands every signal the pass returned to settleOrder, unless the row answers it" testEverySignalSettles
|
|
it "passes over a listed invoice this service does not hold" testForeignRefIsPassedOver
|
|
it "changes nothing when the provider fails, and settles on the next pass" testProviderFailureLosesNothing
|
|
it "reads the cadence fresh from the waiters each pass" testCadenceFollowsTheWaiters
|
|
it "cuts an idle sleep short when a browser arrives during it" testWaiterCutsTheIdleSleepShort
|
|
it "serves a hint batch without postponing a pass that falls due" testHintsDoNotPostponeThePass
|
|
it "floors the cadence, so a zero in the ini is not a busy loop" testCadenceHasAFloor
|
|
it "wakes a request the listener is holding, from a poller pass" testPollerWakesAHeldRequest
|
|
it "expires an open invoice past the ten-minute grace, and reads before it does" testSweepExpiresPastTheGrace
|
|
it "writes status alone, so a later SigClosed still records what arrived" testSweepWritesStatusAlone
|
|
it "wakes a request held on an invoice it expires" testSweepWakesAHeldRequest
|
|
it "cancels at a provider that keeps its invoice payable, then expires it" testSweepCancelsBeforeExpiring
|
|
it "keeps an invoice open while its provider refuses the cancel" testSweepKeepsOpenWhenCancelFails
|
|
it "settles an invoice past the settle window whose cancel is refused" testSweepSettlesWhatItCannotCancel
|
|
it "expires an invoice whose cancel answer was lost, from the read that follows" testSweepExpiresWhatAReadShowsCancelled
|
|
it "expires an invoice past the settle window once its cancel has failed for an hour, and reports it" testSweepGivesUpPastTheWindow
|
|
it "cancels overdue invoices oldest first, keeping the failures of those left for later" testSweepKeepsFailuresBeyondTheCap
|
|
it "expires an old invoice whose provider is gone, without a call" testSweepExpiresWithoutItsProvider
|
|
it "warns once per order while a cancel keeps failing" testSweepWarnsOncePerOrder
|
|
it "cancels at most a pass-sized batch, leaving the rest for the next pass" testSweepCapsCancelsPerPass
|
|
it "reports a skipped invoice once, and again only after the interval" testSkipWarningsAreRateLimited
|
|
it "warns once for a provider that stays down, not once a pass" testOutageWarnsOnceNotEveryPass
|
|
it "holds the skip log under its cap when every reason is fresh" testSkipReasonsStayBounded
|
|
it "warns once for all the invoices it did not sell, not once per invoice" testStrangerSkipsShareOneWarning
|
|
it "raises a skip naming an invoice this service sold" testSkipNamingOurInvoiceIsRaised
|
|
it "holds the sweep back until a pass has accounted for every invoice" testSweepWaitsForAPassThatSawEverything
|
|
it "settles the rest of the pass around an invoice that throws" testOneBadInvoiceDoesNotStopThePass
|
|
it "pages past a hundred open invoices, a request per hundred" testListPaginates
|
|
it "stops paging at its ceiling rather than looping on a server that ignores take" testPagingStopsAtTheCeiling
|
|
describe "badge service webhook" $ do
|
|
it "verifies a real BTCPay signature over the bytes as received, and queues" testWebhookVerifiesARealSignature
|
|
it "hands the adapter the raw body, byte for byte" testWebhookPassesTheRawBytes
|
|
it "queues a read and answers 200 empty, reaching no provider" testWebhookQueuesARead
|
|
it "settles by the poller's own path, so there is one settlement lane" testWebhookSettlesByThePollerPath
|
|
it "answers at once even when a read at the provider takes a second" testWebhookDoesNotWaitOnTheProvider
|
|
it "answers 400 empty, with no detail, for a signature that does not verify" testWebhookRefusesASignature
|
|
it "answers 200 for an unhandled type, an unknown ref and the other lane's ref" testWebhookIgnoresWhatItCannotActOn
|
|
it "refuses a body over the 64 KB cap before parsing or verifying it" testWebhookRefusesAnOversizedBody
|
|
it "queues a read of an order once, however often it is hinted before being served" testReadHintQueuedOnce
|
|
it "drops a hint rather than blocking when the queue is full" testWebhookDropsAHintWhenFull
|
|
it "answers 200 rather than any 5xx when the adapter or the store throws" testWebhookNeverAnswers5xx
|
|
describe "badge service checkout" $ do
|
|
it "answers the checkout fields and writes the three rows" testCreateInvoice
|
|
it "carries no code and no code hash, over the raw bytes" testCreateCarriesNoCode
|
|
it "tells the provider xmr rather than the chain another request used" testXmrReachesTheProviderAsXmr
|
|
it "derives badgeType, months and amount from the catalog, never from the body" testCreateDerivesFromCatalog
|
|
it "refuses every catalog_changed condition before the provider" testCatalogRefusalCostsNothing
|
|
it "refuses a code_hash already sold before the provider" testCodeConflictCostsNothing
|
|
it "maps a code_hash unique violation that raced the check to 409, not 500" testRacingCodeHashIsConflict
|
|
it "answers 500 and names the abandoned invoice when the store is lost" testStoreLostAfterTheProviderCreated
|
|
it "refuses a malformed body, an unknown method and a malformed codeHash" testBadRequestCostsNothing
|
|
it "refuses a body over the size cap before reading it all" testOversizedBodyCostsNothing
|
|
it "answers method card with provider_unavailable when no Stripe provider is configured" testCardUnconfigured503
|
|
it "answers a provider that failed to create with provider_unavailable, writing nothing" testProviderFailureWritesNothing
|
|
it "refuses the sixth create in a minute, without reaching the provider" testCreateRateLimit
|
|
describe "badge service card" $ do
|
|
it "creates a Stripe PaymentIntent, carrying its clientSecret and a stripe invoice row" testCardCreatesSession
|
|
it "cancels an overdue card order's intent at Stripe before expiring it" testCardSweepCancelsTheIntent
|
|
it "derives the shown expiry from stripe.session_minutes, not the btcpay window" testCardExpiryFollowsSessionMinutes
|
|
it "settles a card invoice from a signed payment_intent.succeeded and a pass" testCardWebhookSettles
|
|
describe "badge service cancel" $ do
|
|
it "expires the invoice here and invalidates it at the provider" testCancelClosesTheInvoiceAtBothEnds
|
|
it "wakes a hold another tab is sitting on" testCancelWakesAHeldRequest
|
|
it "refuses to cancel an invoice that is no longer open" testCancelIsRefusedOnceItIsNotOpen
|
|
it "refuses to cancel an invoice awaiting confirmation" testCancelIsRefusedOnceItIsFunded
|
|
it "leaves the invoice open when the provider refuses" testCancelLeavesTheInvoiceOpenWhenTheProviderFails
|
|
it "expires an invoice a payment landed on while the provider was being told" testCancelExpiresAFundedInvoice
|
|
it "answers an unknown id with the same 404 the read does" testCancelIsOpaqueForAnUnknownInvoice
|
|
it "answers GET with 405 and Allow: POST" testCancelRefusesOtherMethods
|
|
describe "badge service scenarios" $ do
|
|
it "buys a code: create, pay at BTCPay, one pass, paid" scenarioPaidPurchase
|
|
it "wakes a wait held from before the settlement, within a second of it" scenarioHeldWaitWakes
|
|
it "reports a partial payment and leaves the invoice open" scenarioPartPaymentIsReported
|
|
it "reports what an expiry received, and sells no code for it" scenarioExpiryReportsWhatArrived
|
|
it "settles an expired invoice late, because confirmation after expiry is routine" scenarioLateSettlement
|
|
it "changes nothing when the same settlement is seen a second time" scenarioReplayChangesNothing
|
|
it "closes an invalid invoice as expired, on the same screen" scenarioInvalidClosesAsExpired
|
|
it "refuses a second invoice for one code hash, creating none at BTCPay" scenarioCodeConflictCreatesNothing
|
|
it "settles with no webhook delivered, by reading the invoice it is waiting on" scenarioNoWebhookAnywhere
|
|
|
|
testServiceColumns :: IO ()
|
|
testServiceColumns = withServiceStore $ \st -> do
|
|
columnsOf st "sx_badge_service_badge_code_invoices"
|
|
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["badge_code_id", "provider_ref"])
|
|
columnsOf st "sx_badge_service_payments" >>= (`shouldSatisfy` elem "crypto_paid")
|
|
columnsOf st "sx_badge_service_badge_codes"
|
|
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["expires_at", "revoked_at"])
|
|
assertSubscriptionColumns st
|
|
|
|
assertSubscriptionColumns :: HasCallStack => DBStore -> IO ()
|
|
assertSubscriptionColumns st = do
|
|
columnsOf st "sx_badge_service_subscription_charges"
|
|
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["charge_id", "payment_id", "provider_charge_ref", "plan_id", "period_start", "period_end", "amount", "currency", "created_at"])
|
|
columnsOf st "sx_badge_service_badge_ledger" >>= (`shouldSatisfy` \cs -> all (`elem` cs) ["charge_id", "from_purchase_id", "to_purchase_id"])
|
|
payments <- columnsOf st "sx_badge_service_payments"
|
|
payments `shouldSatisfy` \cs -> all (`elem` cs) ["subscription_state", "subscription_checked_at", "provider_token", "rebound_at"]
|
|
payments `shouldSatisfy` \cs -> not (any (`elem` cs) ["subscription_renews_at", "grace_until", "cancelled"])
|
|
|
|
testGroupOpsColumns :: IO ()
|
|
testGroupOpsColumns = withServiceStore assertGroupOpsColumns
|
|
|
|
assertGroupOpsColumns :: HasCallStack => DBStore -> IO ()
|
|
assertGroupOpsColumns st = do
|
|
columnsOf st "sx_badge_service_badge_codes"
|
|
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["redeem_limit", "redeem_count", "group_item_id", "group_item_sent_at"])
|
|
columnsOf st "sx_badge_service_group"
|
|
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["group_id", "group_link", "owner_bootstrapped", "created_at"])
|
|
|
|
runMigrations :: DBStore -> MigrationsToRun -> IO ()
|
|
#if defined(dbPostgres)
|
|
runMigrations st = Migrations.run st Nothing
|
|
#else
|
|
runMigrations st = Migrations.run st Nothing True
|
|
#endif
|
|
|
|
testSchemaDownUpCycle :: IO ()
|
|
testSchemaDownUpCycle = withServiceStore $ \st -> do
|
|
let downMigrations = mapMaybe toDownMigration badgeServiceSchemaMigrations
|
|
length downMigrations `shouldBe` length badgeServiceSchemaMigrations
|
|
runMigrations st $ MTRDown downMigrations
|
|
columnsOf st "sx_badge_service_badge_codes" `shouldReturn` []
|
|
columnsOf st "sx_badge_service_group" `shouldReturn` []
|
|
columnsOf st "sx_badge_service_subscription_charges" `shouldReturn` []
|
|
runMigrations st $ MTRUp badgeServiceSchemaMigrations
|
|
assertGroupOpsColumns st
|
|
assertSubscriptionColumns st
|
|
|
|
testSubscriptionsDownUp :: IO ()
|
|
testSubscriptionsDownUp = withServiceStore $ \st -> do
|
|
let subscriptions = [m | m@Migration {name = "20261012_badge_subscriptions"} <- badgeServiceSchemaMigrations]
|
|
length subscriptions `shouldBe` 1
|
|
runMigrations st $ MTRDown (mapMaybe toDownMigration subscriptions)
|
|
columnsOf st "sx_badge_service_subscription_charges" `shouldReturn` []
|
|
columnsOf st "sx_badge_service_badge_ledger" >>= (`shouldNotSatisfy` \cs -> any (`elem` cs) ["charge_id", "from_purchase_id", "to_purchase_id"])
|
|
payments <- columnsOf st "sx_badge_service_payments"
|
|
payments `shouldSatisfy` \cs -> all (`elem` cs) ["subscription_renews_at", "grace_until", "cancelled"]
|
|
payments `shouldSatisfy` \cs -> not (any (`elem` cs) ["subscription_state", "subscription_checked_at", "provider_token", "rebound_at"])
|
|
runMigrations st $ MTRUp subscriptions
|
|
assertSubscriptionColumns st
|
|
|
|
testGroupOpsDownUp :: IO ()
|
|
testGroupOpsDownUp = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
let redeemedHash = digestFixture 44
|
|
unredeemedHash = digestFixture 45
|
|
groupOps = [m | m@Migration {name = "20260918_badge_group_ops"} <- badgeServiceSchemaMigrations]
|
|
redeemed <- insertCode st redeemedHash CPSPaid 2 now
|
|
void $ insertCode st unredeemedHash CPSPaid 1 now
|
|
-- Two purchases of one code would break a down migration that restored the unique index.
|
|
replicateM_ 2 $ newPurchaseKeys >>= claimUse st redeemed now >>= (`shouldSatisfy` isJust)
|
|
runMigrations st $ MTRDown (mapMaybe toDownMigration groupOps)
|
|
columnsOf st "sx_badge_service_badge_codes" >>= (`shouldNotSatisfy` elem "redeem_count")
|
|
runMigrations st $ MTRUp groupOps
|
|
codeCounts st redeemedHash `shouldReturn` Just (1, 1)
|
|
codeCounts st unredeemedHash `shouldReturn` Just (1, 0)
|
|
|
|
testGroupOpsRedeemCounts :: IO ()
|
|
testGroupOpsRedeemCounts = withServiceStore $ \st -> do
|
|
let codeHash = digestFixture 43
|
|
withConnection st $ \db ->
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) VALUES (?,?,?,?,?)"
|
|
(DB.Binary codeHash, "supporter" :: Text, 1 :: Int, "free" :: Text, someCreated)
|
|
codeCounts st codeHash `shouldReturn` Just (1, 0)
|
|
|
|
testProviderRefUnique :: IO ()
|
|
testProviderRefUnique = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
seedInvoice st "inv1"
|
|
seedInvoice st "inv2"
|
|
insertBadgeCodeInvoice st "inv1" "price1" "same-provider-ref"
|
|
insertBadgeCodeInvoice st "inv2" "price1" "same-provider-ref"
|
|
`shouldThrow` anyException
|
|
|
|
seedInvoice :: DBStore -> Text -> IO ()
|
|
seedInvoice st invoiceId = withConnection st $ \db ->
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_invoices (invoice_id, provider, price, amount, currency, expires_at, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
|
|
(invoiceId, "btcpay" :: Text, 500 :: Int, 500 :: Int, "usd" :: Text, "2030-01-01T00:00:00Z" :: Text, "open" :: Text, "2026-08-31T00:00:00Z" :: Text, "2026-08-31T00:00:00Z" :: Text)
|
|
|
|
seedBadgePrice :: DBStore -> Text -> IO ()
|
|
seedBadgePrice st priceId = withConnection st $ \db ->
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_prices (price_id, badge_type, month_price, currency, status, created_at) VALUES (?,?,?,?,?,?)"
|
|
(priceId, "supporter" :: Text, 500 :: Int, "usd" :: Text, "active" :: Text, "2026-08-31T00:00:00Z" :: Text)
|
|
|
|
insertBadgeCodeInvoice :: DBStore -> Text -> Text -> Text -> IO ()
|
|
insertBadgeCodeInvoice st invoiceId priceId providerRef = withConnection st $ \db -> do
|
|
let codeHash = DB.Binary (digestFixture (fromIntegral (sum (map fromEnum (T.unpack invoiceId)))))
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) VALUES (?,?,?,?,?)"
|
|
(codeHash, "supporter" :: Text, 1 :: Int, "unpaid" :: Text, "2026-08-31T00:00:00Z" :: Text)
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_code_invoices (invoice_id, badge_code_id, price_id, created_at, provider_ref) SELECT ?, badge_code_id, ?, ?, ? FROM sx_badge_service_badge_codes WHERE code_hash = ?"
|
|
(invoiceId, priceId, "2026-08-31T00:00:00Z" :: Text, providerRef, codeHash)
|
|
|
|
someExpiry :: UTCTime
|
|
someExpiry = UTCTime (fromGregorian 2030 1 1) 0
|
|
|
|
someCreated :: UTCTime
|
|
someCreated = UTCTime (fromGregorian 2026 8 31) 0
|
|
|
|
-- | These 32 bytes are all above 0x7f, so the value is not valid UTF-8 and catches the
|
|
-- postgresql-simple bug where a plain ByteString is written as escaped text.
|
|
digestFixture :: Word8 -> ByteString
|
|
digestFixture n = BS.pack [0x80 + ((n * 7 + i) `mod` 0x80) | i <- [0 .. 31]]
|
|
|
|
sampleInvoice :: NewInvoice
|
|
sampleInvoice =
|
|
NewInvoice
|
|
{ niInvoiceId = InvoiceId "inv1",
|
|
niProviderRef = "provider-ref-1",
|
|
niCodeHash = digestFixture 1,
|
|
niPriceId = BadgePriceId "price1",
|
|
niOfferId = Nothing,
|
|
niBadgeType = BTSupporter,
|
|
niMonths = 1,
|
|
niPrice = CurrencyAmount 500,
|
|
niAmount = CurrencyAmount 500,
|
|
niCurrency = "usd",
|
|
niProvider = PPCrypto,
|
|
niDestination = SPDCrypto CCBtc "bc1qexampleaddress" "0.00050000",
|
|
niExpiresAt = someExpiry,
|
|
niCreatedAt = someCreated
|
|
}
|
|
|
|
codeInvoiceRow :: DBStore -> InvoiceId -> IO (Maybe Text)
|
|
codeInvoiceRow st (InvoiceId invId) = withConnection st $ \db -> do
|
|
rows <- DB.query db "SELECT invoice_id FROM sx_badge_service_badge_code_invoices WHERE invoice_id = ?" (Only invId) :: IO [Only Text]
|
|
pure $ case rows of
|
|
[] -> Nothing
|
|
(Only r : _) -> Just r
|
|
|
|
markPaid :: DBStore -> InvoiceId -> IO ()
|
|
markPaid st (InvoiceId invId) = withConnection st $ \db ->
|
|
DB.execute db "UPDATE sx_badge_service_invoices SET status = 'paid' WHERE invoice_id = ?" (Only invId)
|
|
|
|
insertPrice :: DBStore -> Text -> Text -> Int -> Text -> IO ()
|
|
insertPrice st priceId badgeType monthPrice status = withConnection st $ \db ->
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_prices (price_id, badge_type, month_price, currency, status, created_at) VALUES (?,?,?,?,?,?)"
|
|
(priceId, badgeType, monthPrice, "usd" :: Text, status, "2026-08-31T00:00:00Z" :: Text)
|
|
|
|
insertOffer :: DBStore -> Text -> Maybe Text -> Int -> Int -> Text -> IO ()
|
|
insertOffer st offerId priceId months discount status = withConnection st $ \db ->
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_offers (offer_id, price_id, months, discount, status, created_at) VALUES (?,?,?,?,?,?)"
|
|
(offerId, priceId, months, discount, status, "2026-08-31T00:00:00Z" :: Text)
|
|
|
|
testCreationIsAtomic :: IO ()
|
|
testCreationIsAtomic = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
let duplicateHash = digestFixture 9
|
|
planted = sampleInvoice {niInvoiceId = InvoiceId "other", niProviderRef = "p-other", niCodeHash = duplicateHash}
|
|
ni = sampleInvoice {niCodeHash = duplicateHash}
|
|
planted' <- createInvoiceRows st planted
|
|
planted' `shouldBe` Right ()
|
|
r <- createInvoiceRows st ni
|
|
r `shouldSatisfy` isLeft
|
|
r `shouldBe` Left CECodeConflict
|
|
getInvoice st (niInvoiceId ni) `shouldReturn` Nothing
|
|
codeInvoiceRow st (niInvoiceId ni) `shouldReturn` Nothing
|
|
|
|
testNewInvoiceIdRandom :: IO ()
|
|
testNewInvoiceIdRandom = do
|
|
InvoiceId a <- newInvoiceId
|
|
InvoiceId b <- newInvoiceId
|
|
a `shouldNotBe` b
|
|
T.length a `shouldBe` 22
|
|
T.any (== '=') a `shouldBe` False
|
|
|
|
testCodeHashExists :: IO ()
|
|
testCodeHashExists = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
createInvoiceRows st sampleInvoice `shouldReturn` Right ()
|
|
codeHashExists st (niCodeHash sampleInvoice) `shouldReturn` True
|
|
codeHashExists st "no-such-hash" `shouldReturn` False
|
|
|
|
testExpireOverdueMovesOnlyQualifying :: IO ()
|
|
testExpireOverdueMovesOnlyQualifying = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
now <- getCurrentTime
|
|
let pastExpiry = addUTCTime (-3600) now
|
|
futureExpiry = addUTCTime 3600 now
|
|
overdue = sampleInvoice {niExpiresAt = pastExpiry}
|
|
notYet = sampleInvoice {niInvoiceId = InvoiceId "inv-notyet", niProviderRef = "p-notyet", niCodeHash = digestFixture 13, niExpiresAt = futureExpiry}
|
|
alreadyPaid = sampleInvoice {niInvoiceId = InvoiceId "inv-paid", niProviderRef = "p-paid", niCodeHash = digestFixture 14, niExpiresAt = pastExpiry}
|
|
createInvoiceRows st overdue `shouldReturn` Right ()
|
|
createInvoiceRows st notYet `shouldReturn` Right ()
|
|
createInvoiceRows st alreadyPaid `shouldReturn` Right ()
|
|
markPaid st (niInvoiceId alreadyPaid)
|
|
map (\OverdueInvoice {oiInvoiceId, oiProviderRef} -> (oiInvoiceId, oiProviderRef)) <$> overdueInvoices st now `shouldReturn` [(niInvoiceId overdue, niProviderRef overdue)]
|
|
moved <- expireAllOverdue st now
|
|
moved `shouldBe` [niInvoiceId overdue]
|
|
Just overdueRow <- getInvoice st (niInvoiceId overdue)
|
|
irStatus overdueRow `shouldBe` ISExpired
|
|
Just notYetRow <- getInvoice st (niInvoiceId notYet)
|
|
irStatus notYetRow `shouldBe` ISOpen
|
|
Just paidRow <- getInvoice st (niInvoiceId alreadyPaid)
|
|
irStatus paidRow `shouldBe` ISPaid
|
|
|
|
testExpireOverdueMovesOnlyTheNamed :: IO ()
|
|
testExpireOverdueMovesOnlyTheNamed = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
now <- getCurrentTime
|
|
let pastExpiry = addUTCTime (-3600) now
|
|
named = sampleInvoice {niExpiresAt = pastExpiry}
|
|
unnamed = sampleInvoice {niInvoiceId = InvoiceId "inv-unnamed", niProviderRef = "p-unnamed", niCodeHash = digestFixture 13, niExpiresAt = pastExpiry}
|
|
createInvoiceRows st named `shouldReturn` Right ()
|
|
createInvoiceRows st unnamed `shouldReturn` Right ()
|
|
expireOverdue st now [niInvoiceId named] `shouldReturn` [niInvoiceId named]
|
|
invoiceStatus st (niInvoiceId named) `shouldReturn` ISExpired
|
|
invoiceStatus st (niInvoiceId unnamed) `shouldReturn` ISOpen
|
|
expireOverdue st now [niInvoiceId named] `shouldReturn` []
|
|
|
|
expireAllOverdue :: DBStore -> UTCTime -> IO [InvoiceId]
|
|
expireAllOverdue st now = overdueInvoices st now >>= expireOverdue st now . map oiInvoiceId
|
|
|
|
testRevokeAndRedeemExcludeEachOther :: IO ()
|
|
testRevokeAndRedeemExcludeEachOther = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
keys <- newPurchaseKeys
|
|
let newCode codeHash = insertCode st codeHash CPSPaid 1 now
|
|
redeem badgeCodeId = claimUse st badgeCodeId now keys
|
|
revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now
|
|
revokedFirst <- newCode "revoked-first"
|
|
revoke "revoked-first" `shouldReturn` Revoked revokedFirst
|
|
redeem revokedFirst `shouldReturn` Nothing
|
|
codeCounts st "revoked-first" `shouldReturn` Just (1, 0)
|
|
redeemedFirst <- newCode "redeemed-first"
|
|
isJust <$> redeem redeemedFirst `shouldReturn` True
|
|
revoke "redeemed-first" `shouldReturn` AlreadyRedeemed
|
|
redeem redeemedFirst `shouldReturn` Nothing
|
|
|
|
testRevokeMultiUseCode :: IO ()
|
|
testRevokeMultiUseCode = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
let newCode codeHash = insertCode st codeHash CPSPaid 2 now
|
|
-- A purchase key redeems once, so every redemption here brings its own.
|
|
redeem badgeCodeId = newPurchaseKeys >>= claimUse st badgeCodeId now
|
|
revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now
|
|
partlyUsed <- newCode "partly-used"
|
|
isJust <$> redeem partlyUsed `shouldReturn` True
|
|
revoke "partly-used" `shouldReturn` Revoked partlyUsed
|
|
redeem partlyUsed `shouldReturn` Nothing
|
|
usedUp <- newCode "used-up"
|
|
isJust <$> redeem usedUp `shouldReturn` True
|
|
isJust <$> redeem usedUp `shouldReturn` True
|
|
revoke "used-up" `shouldReturn` AlreadyRedeemed
|
|
|
|
testReadCatalogRowsDropsDisabled :: IO ()
|
|
testReadCatalogRowsDropsDisabled = withServiceStore $ \st -> do
|
|
insertPrice st "price-active" "supporter" 500 "active"
|
|
insertPrice st "price-disabled" "supporter" 500 "disabled"
|
|
insertOffer st "offer-active" (Just "price-active") 3 10 "active"
|
|
insertOffer st "offer-disabled" (Just "price-active") 3 10 "disabled"
|
|
(prices, offers) <- readCatalogRows st
|
|
map (\BadgePrice {priceId} -> priceId) prices `shouldBe` [BadgePriceId "price-active"]
|
|
map (\BadgeOffer {offerId} -> offerId) offers `shouldBe` [BadgeOfferId "offer-active"]
|
|
|
|
seedDefaultCatalog :: DBStore -> UTCTime -> IO (Int, Int)
|
|
seedDefaultCatalog st at = uncurry (seedCatalog st) (defaultCatalog at)
|
|
|
|
priceFacts :: BadgePrice -> (Text, Text, Word32, Text, BadgeItemStatus)
|
|
priceFacts BadgePrice {priceId = BadgePriceId pId, badgeType = bType, monthPrice = CurrencyAmount mPrice, currency = cur, status = pStatus} =
|
|
(pId, textEncode bType, mPrice, cur, pStatus)
|
|
|
|
offerFacts :: BadgeOffer -> (Text, Maybe Text, Word8, OfferDiscount, BadgeItemStatus)
|
|
offerFacts BadgeOffer {offerId = BadgeOfferId oId, priceId = oPriceId, months = oMonths, discount = oDiscount, status = oStatus} =
|
|
(oId, (\(BadgePriceId p) -> p) <$> oPriceId, oMonths, oDiscount, oStatus)
|
|
|
|
allPriceRows :: DBStore -> IO [(Text, Text, Word32, Text, Text, UTCTime)]
|
|
allPriceRows st = withConnection st $ \db ->
|
|
DB.query_ db "SELECT price_id, badge_type, month_price, currency, status, created_at FROM sx_badge_service_badge_prices ORDER BY price_id"
|
|
|
|
allOfferRows :: DBStore -> IO [(Text, Maybe Text, Word32, Maybe Word32, Maybe Word32, Text, UTCTime)]
|
|
allOfferRows st = withConnection st $ \db ->
|
|
DB.query_ db "SELECT offer_id, price_id, months, free_months, discount, status, created_at FROM sx_badge_service_badge_offers ORDER BY offer_id"
|
|
|
|
testSeedWritesTheCatalog :: IO ()
|
|
testSeedWritesTheCatalog = withServiceStore $ \st -> do
|
|
readCatalogRows st `shouldReturn'` (0, 0)
|
|
seedDefaultCatalog st someCreated `shouldReturn` (2, 4)
|
|
(prices, offers) <- readCatalogRows st
|
|
sortOn (\(i, _, _, _, _) -> i) (map priceFacts prices)
|
|
`shouldBe` [ ("price_legend", "legend", 7000, "usd", BISActive),
|
|
("price_supporter", "supporter", 700, "usd", BISActive)
|
|
]
|
|
sortOn (\(i, _, _, _, _) -> i) (map offerFacts offers)
|
|
`shouldBe` [ ("offer_12m", Just "price_legend", 12, ODDiscount 50, BISActive),
|
|
("offer_12m_s", Just "price_supporter", 12, ODDiscount 50, BISActive),
|
|
("offer_3m", Just "price_legend", 3, ODFreeMonths 1, BISActive),
|
|
("offer_3m_s", Just "price_supporter", 3, ODFreeMonths 1, BISActive)
|
|
]
|
|
where
|
|
shouldReturn' action expected = do
|
|
(ps, os) <- action
|
|
(length ps, length os) `shouldBe` expected
|
|
|
|
testSeedIsIdempotent :: IO ()
|
|
testSeedIsIdempotent = withServiceStore $ \st -> do
|
|
seedDefaultCatalog st someCreated `shouldReturn` (2, 4)
|
|
let restartedAt = addUTCTime 86400 someCreated
|
|
seedDefaultCatalog st restartedAt `shouldReturn` (0, 0)
|
|
priceRows <- allPriceRows st
|
|
offerRows <- allOfferRows st
|
|
map (\(i, _, _, _, _, _) -> i) priceRows `shouldBe` ["price_legend", "price_supporter"]
|
|
map (\(i, _, _, _, _, _, _) -> i) offerRows `shouldBe` ["offer_12m", "offer_12m_s", "offer_3m", "offer_3m_s"]
|
|
map (\(_, _, _, _, _, at) -> at) priceRows `shouldBe` replicate 2 someCreated
|
|
map (\(_, _, _, _, _, _, at) -> at) offerRows `shouldBe` replicate 4 someCreated
|
|
|
|
testSeedNeverResurrectsAWithdrawnRow :: IO ()
|
|
testSeedNeverResurrectsAWithdrawnRow = withServiceStore $ \st -> do
|
|
insertPrice st "price_supporter" "supporter" 900 "deprecated"
|
|
insertPrice st "price_legend" "legend" 9000 "disabled"
|
|
insertOffer st "offer_12m" (Just "price_legend") 12 25 "disabled"
|
|
seedDefaultCatalog st someCreated `shouldReturn` (0, 3)
|
|
priceRows <- allPriceRows st
|
|
map (\(i, _, mp, _, s, _) -> (i, mp, s)) priceRows
|
|
`shouldBe` [("price_legend", 9000, "disabled"), ("price_supporter", 900, "deprecated")]
|
|
offerRows <- allOfferRows st
|
|
map (\(i, _, m, _, d, s, _) -> (i, m, d, s)) offerRows
|
|
`shouldBe` [ ("offer_12m", 12, Just 25, "disabled"),
|
|
("offer_12m_s", 12, Just 50, "active"),
|
|
("offer_3m", 3, Nothing, "active"),
|
|
("offer_3m_s", 3, Nothing, "active")
|
|
]
|
|
(prices, offers) <- readCatalogRows st
|
|
sortOn (\(i, _, _, _, _) -> i) (map priceFacts prices)
|
|
`shouldBe` [("price_supporter", "supporter", 900, "usd", BISDeprecated)]
|
|
map (\(i, _, _, _, _) -> i) (sortOn (\(i, _, _, _, _) -> i) (map offerFacts offers))
|
|
`shouldBe` ["offer_12m_s", "offer_3m", "offer_3m_s"]
|
|
|
|
testSeedMatchesWebCatalog :: IO ()
|
|
testSeedMatchesWebCatalog = withServiceStore $ \st -> do
|
|
src <- T.readFile "apps/simplex-badge-service/web/src/catalog.ts"
|
|
(webPrices, webOffers) <- case parseCatalogSource src of
|
|
Nothing -> failWith "could not parse CATALOG out of web/src/catalog.ts -- its shape has changed"
|
|
Just parsed -> pure parsed
|
|
webPrices `shouldNotBe` []
|
|
webOffers `shouldNotBe` []
|
|
seedDefaultCatalog st someCreated `shouldReturn` (length webPrices, length webOffers)
|
|
(prices, offers) <- readCatalogRows st
|
|
sortOn (\(i, _, _, _) -> i) (map (\(i, t, mp, cur, _) -> (i, t, mp, cur)) (map priceFacts prices))
|
|
`shouldBe` sortOn (\(i, _, _, _) -> i) (map (\WebPrice {wpPriceId, wpBadgeType, wpMonthPrice, wpCurrency} -> (wpPriceId, wpBadgeType, wpMonthPrice, wpCurrency)) webPrices)
|
|
sortOn (\(i, _, _, _) -> i) (map (\(i, p, m, d, _) -> (i, p, m, d)) (map offerFacts offers))
|
|
`shouldBe` sortOn (\(i, _, _, _) -> i) (map (\WebOffer {woOfferId, woPriceId, woMonths, woDiscount} -> (woOfferId, Just woPriceId, woMonths, woDiscount)) webOffers)
|
|
|
|
testSeededCatalogSellsSomething :: IO ()
|
|
testSeededCatalogSellsSomething = bounded "seeded catalog sells" $ withSeededCheckout $ \client -> do
|
|
card <- postCreateAs client 1 (createBody "price_legend" (Just "offer_12m") "card" (codeHashText sampleCode))
|
|
statusOf card `shouldBe` 503
|
|
responseBody card `shouldBe` errorBody "provider_unavailable"
|
|
priced <- postCreateAs client 2 (createBody "price_legend" (Just "offer_12m") "btc" (codeHashText sampleCode))
|
|
statusOf priced `shouldBe` 200
|
|
o <- jsonObject priced
|
|
fieldOf o "badgeType" `shouldBe` Just (J.String "legend")
|
|
fieldOf o "months" `shouldBe` Just (J.Number 12)
|
|
fieldOf o "amount" `shouldBe` Just (J.Number 42000)
|
|
fieldOf o "currency" `shouldBe` Just (J.String catalogCurrency)
|
|
|
|
withSeededCheckout :: (WebClient -> IO a) -> IO a
|
|
withSeededCheckout action =
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-seeded" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
_ <- seedDefaultCatalog st someCreated
|
|
ref <- newIORef (newStubState (Right sampleProviderInvoice))
|
|
let cfg = (testServiceConfig staticDir True) {btcpay = Just testBTCPayConfig}
|
|
withListener [stubProvider ref] True holdMicros st cfg (const action)
|
|
|
|
testTimestampRoundTrip :: IO ()
|
|
testTimestampRoundTrip = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
let subSecond = UTCTime (fromGregorian 2026 8 31) (picosecondsToDiffTime 12500000000000)
|
|
truncated = UTCTime (fromGregorian 2026 8 31) (picosecondsToDiffTime 12000000000000)
|
|
ni = sampleInvoice {niExpiresAt = subSecond, niCreatedAt = subSecond}
|
|
createInvoiceRows st ni `shouldReturn` Right ()
|
|
Just row <- getInvoice st (niInvoiceId ni)
|
|
irExpiresAt row `shouldBe` truncated
|
|
irCreatedAt row `shouldBe` truncated
|
|
|
|
testChargeCreditRoundTrip :: IO ()
|
|
testChargeCreditRoundTrip = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
(purchaseKey, masterKey) <- newPurchaseKeys
|
|
Just purchaseId <- withTransaction st $ \db ->
|
|
createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now
|
|
withTransaction st $ \db ->
|
|
insertSubscriptionCharge db "pay1" "ch1" StoreCharge {chargeRef = "2000000812345672", planId = "SUBSCR_BADGE_SUPPORTER_MONTH_01", periodStart = now, periodEnd = addMonths 1 now, chargePaid = Nothing, revoked = False, offer = False} now
|
|
let charge = grantEntry now "e1" BTSupporter 1 (SCCharge "ch1") (emptyEntry now BTSupporter)
|
|
withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [charge] Nothing
|
|
withTransaction st (\db -> getLedgerEntries db purchaseId 0) >>= (`shouldBe` Just [J.toJSON charge]) . fmap (map J.toJSON)
|
|
withTransaction st (`getLedgerTip` purchaseId) >>= (`shouldBe` Just (J.toJSON charge)) . fmap J.toJSON
|
|
|
|
testTransferEntriesRoundTrip :: IO ()
|
|
testTransferEntriesRoundTrip = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
(purchaseKey, masterKey) <- newPurchaseKeys
|
|
(otherKey, otherMasterKey) <- newPurchaseKeys
|
|
Just purchaseId <- withTransaction st $ \db ->
|
|
createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now
|
|
Just _ <- withTransaction st $ \db ->
|
|
createStorePurchase db NewStorePurchase {paymentId = "pay2", provider = PPApple, providerRef = "2000000812345673", paid = Nothing, purchaseKey = otherKey, masterKey = otherMasterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now
|
|
let transferIn = grantEntry now "e1" BTSupporter 2 (SCTransferIn otherKey) (emptyEntry now BTSupporter)
|
|
upgrade = debitEntry now "e2" (SDUpgrade purchaseKey) transferIn
|
|
transferOut = debitEntry now "e3" (SDTransferOut otherKey) upgrade
|
|
rows = [transferIn, upgrade, transferOut]
|
|
withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing rows Nothing
|
|
withTransaction st (\db -> getLedgerEntries db purchaseId 0) >>= (`shouldBe` Just (map J.toJSON rows)) . fmap (map J.toJSON)
|
|
withTransaction st (`getLedgerTip` purchaseId) >>= (`shouldBe` Just (J.toJSON transferOut)) . fmap J.toJSON
|
|
|
|
testUnknownPurchaseKeyRefused :: IO ()
|
|
testUnknownPurchaseKeyRefused = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
(purchaseKey, masterKey) <- newPurchaseKeys
|
|
(unknownKey, _) <- newPurchaseKeys
|
|
Just purchaseId <- withTransaction st $ \db ->
|
|
createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Nothing, providerToken = Nothing} now
|
|
let granted = grantEntry now "e1" BTSupporter 2 SCCode (emptyEntry now BTSupporter)
|
|
withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [granted] Nothing
|
|
let upgraded = debitEntry now "e2" (SDUpgrade purchaseKey) granted
|
|
refused <- E.try $ withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [upgraded, debitEntry now "e3" (SDTransferOut unknownKey) upgraded] Nothing
|
|
(refused :: Either E.SomeException ()) `shouldSatisfy` isLeft
|
|
withTransaction st (\db -> getLedgerEntries db purchaseId 0) >>= (`shouldBe` Just [J.toJSON granted]) . fmap (map J.toJSON)
|
|
|
|
testPaymentRebind :: IO ()
|
|
testPaymentRebind = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
(k2, mk2) <- newPurchaseKeys
|
|
(p1, k1Rows@[_, issued1]) <- chargedStorePurchase st now
|
|
reboundAt st "pay1" `shouldReturn` [Nothing]
|
|
let transferOut = debitEntry now "e3" (SDTransferOut k2) issued1
|
|
charge2 = grantEntry now "e4" BTSupporter 1 (SCCharge "ch1") (emptyEntry now BTSupporter)
|
|
Just issued2 <- pure $ issueEntry now "e5" charge2
|
|
Just p2 <- withTransaction st $ \db -> do
|
|
p2_ <- rebindStorePayment db PaymentRebind {paymentId = "pay1", fromPurchaseId = p1, fromTipId = "e2", fromRows = [transferOut], purchaseKey = k2, masterKey = mk2, badgeType = BTSupporter} now
|
|
forM_ p2_ $ \p2 -> appendLedgerPlan db p2 Nothing [charge2] (Just (charge2, issued2, seededCredential mk2 (balanceStartTs issued2)))
|
|
pure p2_
|
|
withTransaction st (`getLedgerTip` p1) >>= (`shouldBe` Just (J.toJSON transferOut)) . fmap J.toJSON
|
|
withTransaction st (\db -> getLedgerEntries db p1 0) >>= (`shouldBe` Just (map J.toJSON (k1Rows <> [transferOut]))) . fmap (map J.toJSON)
|
|
withTransaction st (\db -> getLedgerEntries db p2 0) >>= (`shouldBe` Just (map J.toJSON [charge2, issued2])) . fmap (map J.toJSON)
|
|
subscriptionPayment st p1 `shouldReturn` Nothing
|
|
subscriptionPayment st p2 `shouldReturn` Just "pay1"
|
|
creditedPurchaseId st `shouldReturn` Just p2
|
|
reboundAt st "pay1" `shouldReturn` [Just now]
|
|
|
|
testPaymentRebindLostRace :: IO ()
|
|
testPaymentRebindLostRace = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
(k2, mk2) <- newPurchaseKeys
|
|
(k3, mk3) <- newPurchaseKeys
|
|
(p1, k1Rows@[charge1, issued1]) <- chargedStorePurchase st now
|
|
let rebindTo (k, mk) fromTipId tip = PaymentRebind {paymentId = "pay1", fromPurchaseId = p1, fromTipId, fromRows = [debitEntry now "x" (SDTransferOut k) tip], purchaseKey = k, masterKey = mk, badgeType = BTSupporter}
|
|
k1Entries = withTransaction st (\db -> getLedgerEntries db p1 0) <&> fmap (map J.toJSON)
|
|
withTransaction st (\db -> rebindStorePayment db (rebindTo (k3, mk3) "e1" charge1) now) `shouldReturn` Nothing
|
|
k1Entries `shouldReturn` Just (map J.toJSON k1Rows)
|
|
creditedPurchaseId st `shouldReturn` Just p1
|
|
reboundAt st "pay1" `shouldReturn` [Nothing]
|
|
let transferOut = debitEntry now "x" (SDTransferOut k2) issued1
|
|
Just _ <- withTransaction st $ \db -> rebindStorePayment db (rebindTo (k2, mk2) "e2" issued1) now
|
|
withTransaction st (\db -> rebindStorePayment db (rebindTo (k3, mk3) "x" transferOut) (addUTCTime 60 now)) `shouldReturn` Nothing
|
|
k1Entries `shouldReturn` Just (map J.toJSON (k1Rows <> [transferOut]))
|
|
reboundAt st "pay1" `shouldReturn` [Just now]
|
|
isNothing <$> withTransaction st (`getPurchaseByKey` k3) `shouldReturn` True
|
|
|
|
testMovedFromExpiry :: IO ()
|
|
testMovedFromExpiry = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
[(k0, mk0), (k1, mk1), (k2, mk2)] <- replicateM 3 newPurchaseKeys
|
|
let day n = addUTCTime (n * 86400) now
|
|
granted uuid = grantEntry now uuid BTSupporter 1 SCCode (emptyEntry now BTSupporter)
|
|
movedFromExpiry = withTransaction st (`getMovedFromExpiry` "pay1")
|
|
move fromPurchaseId fromTipId (k, mk) tipId =
|
|
withTransaction st $ \db -> do
|
|
Just purchaseId <- rebindStorePayment db PaymentRebind {paymentId = "pay1", fromPurchaseId, fromTipId, fromRows = [debitEntry now (fromTipId <> "-out") (SDTransferOut k) (granted fromTipId)], purchaseKey = k, masterKey = mk, badgeType = BTSupporter} now
|
|
appendLedgerPlan db purchaseId Nothing [granted tipId] Nothing
|
|
pure purchaseId
|
|
Just p0 <- withTransaction st $ \db ->
|
|
createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey = k0, masterKey = mk0, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now
|
|
withTransaction st $ \db -> appendLedgerPlan db p0 Nothing [granted "g0"] Nothing
|
|
seedIssuance st p0 "i0a" mk0 now (day 10)
|
|
seedIssuance st p0 "i0b" mk0 now (day 20)
|
|
movedFromExpiry `shouldReturn` Nothing
|
|
p1 <- move p0 "g0" (k1, mk1) "g1"
|
|
seedIssuance st p1 "i1b" mk1 now (day 40)
|
|
seedIssuance st p1 "i1a" mk1 now (day 30)
|
|
movedFromExpiry `shouldReturn` Just (day 20)
|
|
p2 <- move p1 "g1" (k2, mk2) "g2"
|
|
seedIssuance st p2 "i2a" mk2 now (day 50)
|
|
seedIssuance st p2 "i2b" mk2 now (day 60)
|
|
movedFromExpiry `shouldReturn` Just (day 40)
|
|
|
|
-- A purchase holding payment pay1 (provider ref 2000000812345671), funded by charge ch1, with its month issued as e2.
|
|
chargedStorePurchase :: DBStore -> UTCTime -> IO (Int64, [StatementEntry])
|
|
chargedStorePurchase st now = do
|
|
(purchaseKey, masterKey) <- newPurchaseKeys
|
|
Just purchaseId <- withTransaction st $ \db ->
|
|
createStorePurchase db NewStorePurchase {paymentId = "pay1", provider = PPApple, providerRef = "2000000812345671", paid = Nothing, purchaseKey, masterKey, badgeType = BTSupporter, subscriptionState = Just SSRenewing, providerToken = Nothing} now
|
|
withTransaction st $ \db ->
|
|
insertSubscriptionCharge db "pay1" "ch1" StoreCharge {chargeRef = "2000000812345672", planId = "SUBSCR_BADGE_SUPPORTER_MONTH_01", periodStart = now, periodEnd = addMonths 1 now, chargePaid = Nothing, revoked = False, offer = False} now
|
|
let charge = grantEntry now "e1" BTSupporter 1 (SCCharge "ch1") (emptyEntry now BTSupporter)
|
|
Just issued <- pure $ issueEntry now "e2" charge
|
|
withTransaction st $ \db -> appendLedgerPlan db purchaseId Nothing [charge] (Just (charge, issued, seededCredential masterKey (balanceStartTs issued)))
|
|
pure (purchaseId, [charge, issued])
|
|
|
|
reboundAt :: DBStore -> Text -> IO [Maybe UTCTime]
|
|
reboundAt st paymentId =
|
|
withConnection st $ \db -> map fromOnly <$> DB.query db "SELECT rebound_at FROM sx_badge_service_payments WHERE payment_id = ?" (Only paymentId)
|
|
|
|
subscriptionPayment :: DBStore -> Int64 -> IO (Maybe Text)
|
|
subscriptionPayment st purchaseId =
|
|
fmap (\PurchaseSubscription {paymentId} -> paymentId) <$> withTransaction st (`getPurchaseSubscription` purchaseId)
|
|
|
|
creditedPurchaseId :: DBStore -> IO (Maybe Int64)
|
|
creditedPurchaseId st =
|
|
withTransaction st (\db -> getStorePaymentCredit db PPApple "2000000812345671") <&> \case
|
|
Credited CreditedPurchase {badgePurchaseId} -> Just badgePurchaseId
|
|
_ -> Nothing
|
|
|
|
testManagedGroupRoundTripsAndBootstrapsOnce :: IO ()
|
|
testManagedGroupRoundTripsAndBootstrapsOnce = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
beforeInsert <- withTransaction st getManagedGroup
|
|
beforeInsert `shouldBe` Nothing
|
|
withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now
|
|
afterInsert <- withTransaction st getManagedGroup
|
|
afterInsert `shouldBe` Just ManagedGroup {mgGroupId = 42, mgGroupLink = "https://link", mgOwnerBootstrapped = False}
|
|
show afterInsert `shouldNotSatisfy` ("https://link" `isInfixOf`)
|
|
firstMark <- withTransaction st (`markOwnerBootstrapped` 42)
|
|
secondMark <- withTransaction st (`markOwnerBootstrapped` 42)
|
|
(firstMark, secondMark) `shouldBe` (True, False)
|
|
|
|
testManagedGroupIsSingleRow :: IO ()
|
|
testManagedGroupIsSingleRow = withServiceStore $ \st -> do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now
|
|
withTransaction st $ \db -> insertManagedGroup db 43 "https://other" now
|
|
withTransaction st $ \db -> insertManagedGroup db 42 "https://again" now
|
|
managedGroupRows st `shouldReturn` [(42, "https://link")]
|
|
withTransaction st (`markOwnerBootstrapped` 43) `shouldReturn` False
|
|
(fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just False
|
|
withTransaction st (`markOwnerBootstrapped` 42) `shouldReturn` True
|
|
(fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just True
|
|
|
|
managedGroupRows :: DBStore -> IO [(Int64, Text)]
|
|
managedGroupRows st =
|
|
withConnection st $ \db -> DB.query_ db "SELECT group_id, group_link FROM sx_badge_service_group ORDER BY group_id"
|
|
|
|
insertCode :: DBStore -> ByteString -> BadgeCodePaymentStatus -> Int -> UTCTime -> IO Int64
|
|
insertCode st codeHash paymentStatus redeemLimit now =
|
|
withTransaction st $ \db -> insertBadgeCode db codeHash BTSupporter 1 paymentStatus redeemLimit now
|
|
|
|
claimUse :: DBStore -> Int64 -> UTCTime -> (C.PublicKeyEd25519, BadgeMasterKey) -> IO (Maybe Int64)
|
|
claimUse st badgeCodeId now (purchaseKey, masterKey) =
|
|
withTransaction st $ \db ->
|
|
createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType = BTSupporter} now
|
|
|
|
-- The signature is dummy bytes, since only the JSON round-trip is under test; 80 is the length its decoder accepts.
|
|
seededCredential :: BadgeMasterKey -> UTCTime -> BadgeCredential
|
|
seededCredential masterKey expiry =
|
|
BadgeCredential {badgeKeyIdx = 1, masterKey, signature = BBSSignature (BS.replicate 80 7), badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = expiry, badgeExtra = ""}}
|
|
|
|
seedIssuance :: DBStore -> Int64 -> Text -> BadgeMasterKey -> UTCTime -> UTCTime -> IO ()
|
|
seedIssuance st purchaseId issuanceId masterKey at periodEnd =
|
|
withConnection st $ \db ->
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_issuances (issuance_id, badge_purchase_id, badge_type, period_start, period_end, expiry, credential, created_at) VALUES (?,?,?,?,?,?,?,?)"
|
|
(issuanceId, purchaseId, "supporter" :: Text, at, periodEnd, periodEnd, DB.Binary (LB.toStrict (J.encode (seededCredential masterKey periodEnd))), at)
|
|
|
|
testKeyPurchaseLookup :: IO ()
|
|
testKeyPurchaseLookup = withServiceStore $ \st -> do
|
|
now <- getCurrentTime
|
|
let codeHash = digestFixture 41
|
|
badgeCodeId <- insertCode st codeHash CPSFree 2 now
|
|
keys@(k1, mk1) <- newPurchaseKeys
|
|
Just purchaseId <- claimUse st badgeCodeId now keys
|
|
withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId k1) >>= \case
|
|
KeyRedeemedUnreadable -> pure ()
|
|
_ -> expectationFailure "expected a purchase with no issuance to be unreadable"
|
|
let firstPeriodEnd = someExpiry
|
|
renewedPeriodEnd = addUTCTime 86400 someExpiry
|
|
seedIssuance st purchaseId "iss-first" mk1 now firstPeriodEnd
|
|
seedIssuance st purchaseId "iss-renewed" mk1 now renewedPeriodEnd
|
|
(unusedKey, _) <- newPurchaseKeys
|
|
withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId unusedKey) >>= \case
|
|
KeyUnredeemed -> pure ()
|
|
_ -> expectationFailure "expected no purchase for a key that never redeemed"
|
|
replayed <- withTransaction st (\db -> getCodePurchaseForKey db badgeCodeId k1)
|
|
case replayed of
|
|
KeyRedeemed KeyPurchase {badgePurchaseId, credential} -> do
|
|
badgePurchaseId `shouldBe` purchaseId
|
|
credential `shouldBe` seededCredential mk1 renewedPeriodEnd
|
|
_ -> expectationFailure "expected the redeemed key's purchase"
|
|
|
|
testSameKeyClaimsOnce :: IO ()
|
|
testSameKeyClaimsOnce = withServiceStore $ \st -> do
|
|
now <- getCurrentTime
|
|
let codeHash = digestFixture 46
|
|
badgeCodeId <- insertCode st codeHash CPSFree 3 now
|
|
keys <- newPurchaseKeys
|
|
claimUse st badgeCodeId now keys >>= (`shouldSatisfy` isJust)
|
|
-- The key is unique across purchases, so the second insert fails and its transaction returns the use.
|
|
claimUse st badgeCodeId now keys `shouldThrow` anyException
|
|
codeCounts st codeHash `shouldReturn` Just (3, 1)
|
|
|
|
testMultiUseConcurrentClaimsUpToLimit :: IO ()
|
|
testMultiUseConcurrentClaimsUpToLimit = withServiceStore $ \st -> do
|
|
now <- getCurrentTime
|
|
let codeHash = digestFixture 42
|
|
badgeCodeId <- insertCode st codeHash CPSFree 3 now
|
|
contenders <- replicateM 6 newPurchaseKeys
|
|
results <- Async.mapConcurrently (claimUse st badgeCodeId now) contenders
|
|
length (catMaybes results) `shouldBe` 3
|
|
codeCounts st codeHash `shouldReturn` Just (3, 3)
|
|
|
|
data StubCall
|
|
= StubCreate ServicePaymentMethod OrderDraft
|
|
| StubRead Text
|
|
| StubCancel Text
|
|
| StubListOpen
|
|
deriving (Eq, Show)
|
|
|
|
data StubState = StubState
|
|
{ ssCalls :: [StubCall],
|
|
ssCreateResult :: Either ProviderError ProviderInvoice,
|
|
ssInvoices :: Map.Map Text PaymentSignal,
|
|
ssCancelError :: Maybe ProviderError,
|
|
-- The cancel reaches the provider, but its answer is lost.
|
|
ssCancelLost :: Bool,
|
|
ssListError :: Maybe ProviderError,
|
|
ssReadError :: Maybe ProviderError,
|
|
ssSkipped :: [(Maybe Text, Text)],
|
|
ssVerifyResult :: Either WebhookError (Maybe Text),
|
|
ssVerifyThrows :: Bool,
|
|
ssWebhooks :: [([Header], ByteString)],
|
|
ssReadDelay :: Int
|
|
}
|
|
|
|
newStubState :: Either ProviderError ProviderInvoice -> StubState
|
|
newStubState ssCreateResult =
|
|
StubState
|
|
{ ssCalls = [],
|
|
ssCreateResult,
|
|
ssInvoices = Map.empty,
|
|
ssCancelError = Nothing,
|
|
ssCancelLost = False,
|
|
ssListError = Nothing,
|
|
ssReadError = Nothing,
|
|
ssSkipped = [],
|
|
ssVerifyResult = Right Nothing,
|
|
ssVerifyThrows = False,
|
|
ssWebhooks = [],
|
|
ssReadDelay = 0
|
|
}
|
|
|
|
record :: IORef StubState -> StubCall -> IO ()
|
|
record ref call = atomicModifyIORef' ref $ \s -> (s {ssCalls = ssCalls s ++ [call]}, ())
|
|
|
|
stubProvider :: IORef StubState -> Provider
|
|
stubProvider ref =
|
|
Provider
|
|
{ pProvider = PPCrypto,
|
|
pCreateInvoice = \method draft -> do
|
|
record ref (StubCreate method draft)
|
|
ssCreateResult <$> readIORef ref,
|
|
pReadInvoice = \providerRef -> do
|
|
record ref (StubRead providerRef)
|
|
stub <- readIORef ref
|
|
threadDelay (ssReadDelay stub)
|
|
pure $ maybe (Right (Map.lookup providerRef (ssInvoices stub))) Left (ssReadError stub),
|
|
pCancelInvoice = \providerRef -> do
|
|
record ref (StubCancel providerRef)
|
|
lost <- ssCancelLost <$> readIORef ref
|
|
if lost
|
|
then do
|
|
atomicModifyIORef' ref $ \s -> (s {ssInvoices = Map.insert providerRef (SigClosed (rcv 0 Nothing)) (ssInvoices s)}, ())
|
|
pure (Left (ProviderError "the response was lost"))
|
|
else maybe (Right ()) Left . ssCancelError <$> readIORef ref,
|
|
pListOpen = do
|
|
record ref StubListOpen
|
|
stub <- readIORef ref
|
|
pure $ case ssListError stub of
|
|
Just e -> Left e
|
|
Nothing -> Right ListPass {lpMoved = Map.toList (ssInvoices stub), lpSkipped = ssSkipped stub},
|
|
pVerifyWebhook = const (verifyRecording ref)
|
|
}
|
|
|
|
-- | 'pVerifyWebhook' is pure by design, so recording its arguments needs 'unsafePerformIO',
|
|
-- and the NOINLINE effect is safe because every assertion checks an exact list of calls, so a
|
|
-- lost or repeated recording fails the test rather than passing it.
|
|
verifyRecording :: IORef StubState -> [Header] -> ByteString -> Either WebhookError (Maybe Text)
|
|
verifyRecording ref hdrs body = unsafePerformIO $ do
|
|
atomicModifyIORef' ref $ \s -> (s {ssWebhooks = ssWebhooks s ++ [(hdrs, body)]}, ())
|
|
stub <- readIORef ref
|
|
if ssVerifyThrows stub
|
|
then E.throwIO (userError "the adapter threw while verifying")
|
|
else pure (ssVerifyResult stub)
|
|
{-# NOINLINE verifyRecording #-}
|
|
|
|
stubCalls :: IORef StubState -> IO [StubCall]
|
|
stubCalls ref = ssCalls <$> readIORef ref
|
|
|
|
stubWebhooks :: IORef StubState -> IO [([Header], ByteString)]
|
|
stubWebhooks ref = ssWebhooks <$> readIORef ref
|
|
|
|
setVerifyResult :: IORef StubState -> Either WebhookError (Maybe Text) -> IO ()
|
|
setVerifyResult ref r = atomicModifyIORef' ref $ \s -> (s {ssVerifyResult = r}, ())
|
|
|
|
setReadDelay :: IORef StubState -> Int -> IO ()
|
|
setReadDelay ref micros = atomicModifyIORef' ref $ \s -> (s {ssReadDelay = micros}, ())
|
|
|
|
setVerifyThrows :: IORef StubState -> Bool -> IO ()
|
|
setVerifyThrows ref throws = atomicModifyIORef' ref $ \s -> (s {ssVerifyThrows = throws}, ())
|
|
|
|
sampleDraft :: OrderDraft
|
|
sampleDraft = OrderDraft {odAmount = CurrencyAmount 500, odCurrency = "usd"}
|
|
|
|
sampleProviderInvoice :: ProviderInvoice
|
|
sampleProviderInvoice = ProviderInvoice {piProviderRef = "pref-1", piDestination = SPDCrypto CCBtc "bc1qexampleaddress" "0.00050000"}
|
|
|
|
testStubProviderRecordsCalls :: IO ()
|
|
testStubProviderRecordsCalls = do
|
|
ref <- newIORef (newStubState (Right sampleProviderInvoice))
|
|
let provider = stubProvider ref
|
|
created <- pCreateInvoice provider (SPMCrypto CCBtc) sampleDraft
|
|
created `shouldBe` Right sampleProviderInvoice
|
|
let pref = piProviderRef sampleProviderInvoice
|
|
pReadInvoice provider pref `shouldReturn` Right Nothing
|
|
let signal = SigFunded (rcv 500 (Just "0.00050000")) PaidInPart
|
|
atomicModifyIORef' ref $ \s -> (s {ssInvoices = Map.insert pref signal (ssInvoices s)}, ())
|
|
pReadInvoice provider pref `shouldReturn` Right (Just signal)
|
|
pListOpen provider `shouldReturn` Right ListPass {lpMoved = [(pref, signal)], lpSkipped = []}
|
|
calls <- stubCalls ref
|
|
calls `shouldBe` [StubCreate (SPMCrypto CCBtc) sampleDraft, StubRead pref, StubRead pref, StubListOpen]
|
|
|
|
testStubProviderCreateFailsOnDemand :: IO ()
|
|
testStubProviderCreateFailsOnDemand = do
|
|
let err = ProviderError "connection refused"
|
|
ref <- newIORef (newStubState (Left err))
|
|
let provider = stubProvider ref
|
|
pCreateInvoice provider (SPMCrypto CCBtc) sampleDraft `shouldReturn` Left err
|
|
stubCalls ref `shouldReturn` [StubCreate (SPMCrypto CCBtc) sampleDraft]
|
|
|
|
testStubProviderNotCalledWhenSkipped :: IO ()
|
|
testStubProviderNotCalledWhenSkipped = do
|
|
ref <- newIORef (newStubState (Right sampleProviderInvoice))
|
|
stubCalls ref `shouldReturn` []
|
|
|
|
-- | This ceiling is shorter than the 30s hold, so a test that waits when it should not fails
|
|
-- here instead of hanging.
|
|
exampleCeiling :: Int
|
|
exampleCeiling = 20 * 1000000
|
|
|
|
bounded :: HasCallStack => String -> IO a -> IO a
|
|
bounded label action =
|
|
timeout exampleCeiling action >>= maybe (failWith (label <> ": did not finish within 20s")) pure
|
|
|
|
clientTimeout :: Int
|
|
clientTimeout = 60 * 1000000
|
|
|
|
failWith :: HasCallStack => String -> IO a
|
|
failWith msg = expectationFailure msg >> error msg
|
|
|
|
outsideMarker :: LB.ByteString
|
|
outsideMarker = "SECRET-OUTSIDE-STATIC-DIR"
|
|
|
|
shellHtml :: LB.ByteString
|
|
shellHtml = "<!doctype html><title>SimpleX badges</title><meta id=\"stripe-publishable-key\" name=\"stripe-publishable-key\" content=\"\">"
|
|
|
|
assetJs :: LB.ByteString
|
|
assetJs = "export const build = \"d95503da54ee228f\";"
|
|
|
|
workerJs :: LB.ByteString
|
|
workerJs = "self.addEventListener(\"install\", () => {});"
|
|
|
|
buildHash :: FilePath
|
|
buildHash = "d95503da54ee228f"
|
|
|
|
notFoundBody :: LB.ByteString
|
|
notFoundBody = "{\"error\":\"not_found\"}"
|
|
|
|
prepareStaticDir :: FilePath -> IO FilePath
|
|
prepareStaticDir root = do
|
|
let staticDir = root </> "dist"
|
|
assetDir = staticDir </> "assets" </> buildHash
|
|
createDirectoryIfMissing True assetDir
|
|
LB.writeFile (root </> "secret.txt") outsideMarker
|
|
LB.writeFile (staticDir </> "index.html") shellHtml
|
|
LB.writeFile (staticDir </> "sw.js") workerJs
|
|
LB.writeFile (assetDir </> "main.js") assetJs
|
|
createFileLink (".." </> ".." </> "secret.txt") (staticDir </> "assets" </> "escape.txt")
|
|
pure staticDir
|
|
|
|
testServiceConfig :: FilePath -> Bool -> ServiceConfig
|
|
testServiceConfig staticDir trustForwarded =
|
|
ServiceConfig
|
|
{ listener = ListenerConfig {lHost = "127.0.0.1", lPort = 0, lStaticDir = staticDir, lServeWebapp = True, lWebappExportDir = Nothing, lTrustForwardedFor = trustForwarded},
|
|
btcpay = Nothing,
|
|
stripe = Nothing,
|
|
poll = PollConfig {pWaitingSeconds = 3, pIdleSeconds = 60},
|
|
appleStore = Nothing,
|
|
playStore = Nothing,
|
|
issuer = Nothing,
|
|
group = Nothing,
|
|
devAcceptUnverifiedStoreReceipts = False,
|
|
devCheckStoreDuringCoverage = False,
|
|
devCreditTestPurchases = False
|
|
}
|
|
|
|
testServeWebappOff :: IO ()
|
|
testServeWebappOff =
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-static" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
let base = testServiceConfig staticDir False
|
|
cfg = base {listener = (listener base) {lServeWebapp = False}}
|
|
withListener [] True holdMicros st cfg $ \_env client -> do
|
|
(statusOf <$> webGet client "/") >>= (`shouldBe` 404)
|
|
(statusOf <$> webGet client "/sw.js") >>= (`shouldBe` 404)
|
|
(statusOf <$> webGet client ("/assets/" <> buildHash <> "/main.js")) >>= (`shouldBe` 404)
|
|
created <- webRequest client "POST" "/api/invoice" []
|
|
statusOf created `shouldSatisfy` (\s -> s /= 404 && s /= 405)
|
|
|
|
testExportWebapp :: IO ()
|
|
testExportWebapp = do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-export" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
let outDir = root </> "out"
|
|
lc = ListenerConfig {lHost = "127.0.0.1", lPort = 0, lStaticDir = staticDir, lServeWebapp = False, lWebappExportDir = Just outDir, lTrustForwardedFor = False}
|
|
stripeCfg = Just StripeConfig {sSecretKey = "rk_test_x", sPublishableKey = "pk_test_injected", sWebhookSecret = "whsec_x", sSessionMinutes = 60, sHost = "https://api.stripe.com"}
|
|
exportWebapp lc stripeCfg
|
|
idx <- LB.readFile (outDir </> "index.html")
|
|
LB.toStrict idx `shouldSatisfy` BC.isInfixOf "content=\"pk_test_injected\""
|
|
LB.readFile (outDir </> "sw.js") >>= (`shouldBe` workerJs)
|
|
LB.readFile (outDir </> "assets" </> buildHash </> "main.js") >>= (`shouldBe` assetJs)
|
|
|
|
type WebClient = (Manager, String)
|
|
|
|
withWebApp :: (WebEnv -> WebClient -> IO a) -> IO a
|
|
withWebApp = withWebAppWith False True holdMicros
|
|
|
|
withWebAppForwarded :: Bool -> (WebEnv -> WebClient -> IO a) -> IO a
|
|
withWebAppForwarded trustForwarded = withWebAppWith trustForwarded True holdMicros
|
|
|
|
withWebAppHolding :: Int -> (WebEnv -> WebClient -> IO a) -> IO a
|
|
withWebAppHolding hold = withWebAppWith False True hold
|
|
|
|
withWebAppExpectingThrow :: (WebEnv -> WebClient -> IO a) -> IO a
|
|
withWebAppExpectingThrow = withWebAppWith False False holdMicros
|
|
|
|
withWebAppWith :: Bool -> Bool -> Int -> (WebEnv -> WebClient -> IO a) -> IO a
|
|
withWebAppWith trustForwarded rethrow hold action =
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-static" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
withListener [] rethrow hold st (testServiceConfig staticDir trustForwarded) action
|
|
|
|
withListener :: [Provider] -> Bool -> Int -> DBStore -> ServiceConfig -> (WebEnv -> WebClient -> IO a) -> IO a
|
|
withListener providers rethrow hold st cfg action = do
|
|
let runner = if rethrow then Warp.testWithApplicationSettings else Warp.withApplicationSettings
|
|
waiters <- newWaiters
|
|
hints <- newReadHints
|
|
built <- newWebEnv st cfg waiters hints providers
|
|
let env = built {weHoldMicros = hold}
|
|
mgr <- newManager defaultManagerSettings
|
|
runner (webSettings (listener cfg)) (pure (webApp env)) $ \prt ->
|
|
action env (mgr, "http://127.0.0.1:" <> show prt)
|
|
|
|
webRequest :: WebClient -> ByteString -> String -> [Header] -> IO (Response LB.ByteString)
|
|
webRequest client verb target hdrs = webRequestBody client verb target hdrs ""
|
|
|
|
webRequestBody :: WebClient -> ByteString -> String -> [Header] -> LB.ByteString -> IO (Response LB.ByteString)
|
|
webRequestBody (mgr, base) verb target hdrs body = do
|
|
req <- parseRequest base
|
|
let (rawPath, rawQuery) = break (== '?') target
|
|
httpLbs
|
|
req
|
|
{ method = verb,
|
|
path = BC.pack rawPath,
|
|
queryString = BC.pack rawQuery,
|
|
requestHeaders = hdrs,
|
|
requestBody = RequestBodyLBS body,
|
|
responseTimeout = responseTimeoutMicro clientTimeout
|
|
}
|
|
mgr
|
|
|
|
webGet :: WebClient -> String -> IO (Response LB.ByteString)
|
|
webGet client target = webRequest client "GET" target []
|
|
|
|
statusOf :: Response LB.ByteString -> Int
|
|
statusOf = statusCode . responseStatus
|
|
|
|
headerOf :: Response LB.ByteString -> HeaderName -> Maybe ByteString
|
|
headerOf r name = lookup name (responseHeaders r)
|
|
|
|
jsonObject :: HasCallStack => Response LB.ByteString -> IO J.Object
|
|
jsonObject r = case J.decode (responseBody r) of
|
|
Just (J.Object o) -> pure o
|
|
_ -> failWith ("not a JSON object: " <> LB.unpack (responseBody r))
|
|
|
|
fieldOf :: J.Object -> Text -> Maybe J.Value
|
|
fieldOf o k = KM.lookup (K.fromText k) o
|
|
|
|
timedGet :: WebClient -> String -> IO (NominalDiffTime, Response LB.ByteString)
|
|
timedGet client target = do
|
|
started <- getCurrentTime
|
|
r <- webGet client target
|
|
elapsed <- (`diffUTCTime` started) <$> getCurrentTime
|
|
pure (elapsed, r)
|
|
|
|
seedOpenInvoice :: HasCallStack => WebEnv -> IO InvoiceId
|
|
seedOpenInvoice WebEnv {weStore} = do
|
|
seedBadgePrice weStore "price1"
|
|
createInvoiceRows weStore sampleInvoice `shouldReturn` Right ()
|
|
pure (niInvoiceId sampleInvoice)
|
|
|
|
wokenBy :: HasCallStack => WebClient -> InvoiceId -> IO () -> Text -> IO J.Object
|
|
wokenBy client iid trigger expected = do
|
|
started <- getCurrentTime
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open")
|
|
threadDelay holdParkDelay
|
|
parked <- Async.poll held
|
|
parked `shouldSatisfy` isNothing
|
|
trigger
|
|
r <- wait held
|
|
elapsed <- (`diffUTCTime` started) <$> getCurrentTime
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "status" `shouldBe` Just (J.String expected)
|
|
elapsed `shouldSatisfy` (< 1)
|
|
pure o
|
|
|
|
-- | This delay is long enough for the request to reach the hold and short enough to keep the
|
|
-- whole check within the one second a woken answer is allowed.
|
|
holdParkDelay :: Int
|
|
holdParkDelay = 100000
|
|
|
|
invoicePath :: InvoiceId -> String
|
|
invoicePath (InvoiceId iid) = "/api/invoice/" <> T.unpack iid
|
|
|
|
markPaidAndPublish :: WebEnv -> InvoiceId -> IO ()
|
|
markPaidAndPublish WebEnv {weStore, weWaiters} iid = do
|
|
markPaid weStore iid
|
|
atomically $ publish weWaiters iid ISPaid
|
|
|
|
expireRow :: DBStore -> InvoiceId -> IO ()
|
|
expireRow st (InvoiceId iid) = withConnection st $ \db ->
|
|
DB.execute db "UPDATE sx_badge_service_invoices SET status = 'expired' WHERE invoice_id = ?" (Only iid)
|
|
|
|
breakStore :: DBStore -> IO ()
|
|
breakStore st = withConnection st $ \db ->
|
|
DB.execute_ db "DROP TABLE sx_badge_service_badge_code_invoices"
|
|
|
|
readsPerMinute :: Int
|
|
readsPerMinute = lmPerMinute readLimit
|
|
|
|
testCachingFollowsTheResolvedPath :: IO ()
|
|
testCachingFollowsTheResolvedPath = bounded "cache by resolved path" $ withWebApp $ \_ client -> do
|
|
asset <- webGet client ("/assets/" <> buildHash <> "/main.js")
|
|
headerOf asset hCacheControl `shouldBe` Just "public, max-age=31536000, immutable"
|
|
shell <- webGet client "/"
|
|
headerOf shell hCacheControl `shouldBe` Just "no-cache"
|
|
escaped <- webGet client ("/assets/" <> buildHash <> "/%2e%2e/%2e%2e/index.html")
|
|
statusOf escaped `shouldBe` 200
|
|
responseBody escaped `shouldBe` shellHtml
|
|
headerOf escaped hCacheControl `shouldBe` Just "no-cache"
|
|
|
|
testShellCarriesPublishableKey :: IO ()
|
|
testShellCarriesPublishableKey = bounded "shell carries the publishable key" $
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-static" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
let cfg = (testServiceConfig staticDir True) {stripe = Just publishTestStripe}
|
|
withListener [] True holdMicros st cfg $ \_ client -> do
|
|
shell <- webGet client "/"
|
|
statusOf shell `shouldBe` 200
|
|
LB.toStrict (responseBody shell) `shouldSatisfy` BC.isInfixOf "content=\"pk_test_injected\""
|
|
|
|
publishTestStripe :: StripeConfig
|
|
publishTestStripe =
|
|
StripeConfig
|
|
{ sSecretKey = "rk_test_x",
|
|
sPublishableKey = "pk_test_injected",
|
|
sWebhookSecret = "whsec_x",
|
|
sSessionMinutes = 60,
|
|
sHost = "https://api.stripe.com"
|
|
}
|
|
|
|
testServesTheBuild :: IO ()
|
|
testServesTheBuild = bounded "serves the build" $ withWebApp $ \_ client -> do
|
|
shell <- webGet client "/"
|
|
statusOf shell `shouldBe` 200
|
|
responseBody shell `shouldBe` shellHtml
|
|
headerOf shell hContentType `shouldBe` Just "text/html; charset=utf-8"
|
|
asset <- webGet client ("/assets/" <> buildHash <> "/main.js")
|
|
statusOf asset `shouldBe` 200
|
|
responseBody asset `shouldBe` assetJs
|
|
headerOf asset hContentType `shouldBe` Just "text/javascript; charset=utf-8"
|
|
worker <- webGet client "/sw.js"
|
|
statusOf worker `shouldBe` 200
|
|
responseBody worker `shouldBe` workerJs
|
|
headerOf worker hContentType `shouldBe` Just "text/javascript; charset=utf-8"
|
|
headerOf worker hCacheControl `shouldBe` Just "no-cache"
|
|
missing <- webGet client ("/assets/" <> buildHash <> "/absent.js")
|
|
statusOf missing `shouldBe` 404
|
|
|
|
builtSiteDir :: FilePath
|
|
builtSiteDir = "apps" </> "simplex-badge-service" </> "web" </> "dist"
|
|
|
|
testServesBuiltWebApp :: IO ()
|
|
testServesBuiltWebApp = bounded "built web app" $ withServiceStore $ \st -> do
|
|
built <- doesFileExist (builtSiteDir </> "index.html")
|
|
if not built
|
|
then pendingWith ("no web build at " <> builtSiteDir <> ": run `npm run build` in apps/simplex-badge-service/web")
|
|
else withListener [] True holdMicros st (testServiceConfig builtSiteDir False) $ \_ client -> do
|
|
shell <- webGet client "/"
|
|
statusOf shell `shouldBe` 200
|
|
LB.map toLower (LB.take 9 (responseBody shell)) `shouldBe` "<!doctype"
|
|
hashes <- listDirectory (builtSiteDir </> "assets")
|
|
hashes `shouldSatisfy` not . null
|
|
assets <- concat <$> mapM (\h -> map (h </>) <$> listDirectory (builtSiteDir </> "assets" </> h)) hashes
|
|
assets `shouldSatisfy` not . null
|
|
served <- mapM (\a -> (a,) . statusOf <$> webGet client ("/assets/" <> a)) assets
|
|
served `shouldSatisfy` all ((== 200) . snd)
|
|
worker <- webGet client "/sw.js"
|
|
statusOf worker `shouldBe` 200
|
|
responseBody worker `shouldSatisfy` not . LB.null
|
|
|
|
traversalSpellings :: [String]
|
|
traversalSpellings =
|
|
[ "/assets/../secret.txt",
|
|
"/assets/%2e%2e/secret.txt",
|
|
"/assets/%2e%2e%2fsecret.txt",
|
|
"/assets/..%2fsecret.txt",
|
|
"/assets/./../secret.txt",
|
|
"/assets//../secret.txt",
|
|
"/assets/" <> buildHash <> "/../../../secret.txt",
|
|
"/assets/%2e%2e%2f%2e%2e%2fsecret.txt",
|
|
"/assets/%2fetc%2fpasswd",
|
|
"/assets/%00/../secret.txt",
|
|
"/assets/escape.txt",
|
|
"/%2e%2e%2fsecret.txt"
|
|
]
|
|
|
|
testTraversalRefused :: IO ()
|
|
testTraversalRefused = bounded "traversal" $ withWebApp $ \_ client -> do
|
|
control <- webGet client ("/assets/" <> buildHash <> "/main.js")
|
|
statusOf control `shouldBe` 200
|
|
mapM_
|
|
( \target -> do
|
|
r <- webGet client target
|
|
(target, statusOf r) `shouldBe` (target, 404)
|
|
(target, responseBody r) `shouldBe` (target, notFoundBody)
|
|
)
|
|
traversalSpellings
|
|
|
|
testInvoiceView :: IO ()
|
|
testInvoiceView = bounded "invoice view" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
r <- webGet client (invoicePath iid)
|
|
statusOf r `shouldBe` 200
|
|
headerOf r hContentType `shouldBe` Just "application/json"
|
|
o <- jsonObject r
|
|
sort (map K.toText (KM.keys o))
|
|
`shouldBe` sort ["status", "badgeType", "months", "amount", "currency", "expiresAt", "address", "cryptoAmount", "cryptoCurrency"]
|
|
fieldOf o "status" `shouldBe` Just (J.String "open")
|
|
fieldOf o "badgeType" `shouldBe` Just (J.String "supporter")
|
|
fieldOf o "months" `shouldBe` Just (J.Number 1)
|
|
fieldOf o "amount" `shouldBe` Just (J.Number 500)
|
|
fieldOf o "currency" `shouldBe` Just (J.String "usd")
|
|
fieldOf o "address" `shouldBe` Just (J.String "bc1qexampleaddress")
|
|
fieldOf o "cryptoAmount" `shouldBe` Just (J.String "0.00050000")
|
|
fieldOf o "cryptoCurrency" `shouldBe` Just (J.String "btc")
|
|
case fieldOf o "expiresAt" of
|
|
Just (J.String t) -> T.unpack t `shouldStartWith` "2030-01-01T00:00:00"
|
|
other -> expectationFailure ("expiresAt is " <> show other)
|
|
settle (weStore env) iid (SigSettled (rcv 500 (Just "0.00050000")) settleAt) detectedAt `shouldReturn` Right ISPaid
|
|
settled <- webGet client (invoicePath iid)
|
|
po <- jsonObject settled
|
|
fieldOf po "status" `shouldBe` Just (J.String "paid")
|
|
fieldOf po "settledAt" `shouldBe` Just (J.toJSON settleAt)
|
|
fieldOf po "amountPaid" `shouldBe` Just (J.Number 500)
|
|
fieldOf po "cryptoAmountPaid" `shouldBe` Just (J.String "0.00050000")
|
|
fieldOf po "clientSecret" `shouldBe` Nothing
|
|
|
|
testUnknownInvoiceIsOpaque :: IO ()
|
|
testUnknownInvoiceIsOpaque = bounded "unknown id" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
guessed <- webGet client "/api/invoice/no-such-invoice"
|
|
statusOf guessed `shouldBe` 404
|
|
responseBody guessed `shouldBe` notFoundBody
|
|
nearMiss <- webGet client (invoicePath iid <> "x")
|
|
statusOf nearMiss `shouldBe` 404
|
|
responseBody nearMiss `shouldBe` responseBody guessed
|
|
|
|
testApiIsNeverCached :: IO ()
|
|
testApiIsNeverCached = bounded "no-store" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
ok <- webGet client (invoicePath iid)
|
|
statusOf ok `shouldBe` 200
|
|
headerOf ok hCacheControl `shouldBe` Just "no-store"
|
|
missing <- webGet client "/api/invoice/no-such-invoice"
|
|
statusOf missing `shouldBe` 404
|
|
headerOf missing hCacheControl `shouldBe` Just "no-store"
|
|
refused <- exhaustReadLimit client "/api/invoice/no-such-invoice" []
|
|
statusOf refused `shouldBe` 429
|
|
headerOf refused hCacheControl `shouldBe` Just "no-store"
|
|
|
|
exhaustReadLimit :: HasCallStack => WebClient -> String -> [Header] -> IO (Response LB.ByteString)
|
|
exhaustReadLimit client target hdrs = go (readsPerMinute + 5)
|
|
where
|
|
go :: Int -> IO (Response LB.ByteString)
|
|
go 0 = failWith ("no read of " <> target <> " was refused within 65 requests")
|
|
go n = do
|
|
r <- webRequest client "GET" target hdrs
|
|
if statusOf r == 429 then pure r else go (n - 1)
|
|
|
|
testWrongMethodIs405 :: IO ()
|
|
testWrongMethodIs405 = bounded "wrong method" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
postShell <- webRequest client "POST" "/" []
|
|
statusOf postShell `shouldBe` 405
|
|
headerOf postShell "Allow" `shouldBe` Just "GET"
|
|
postWorker <- webRequest client "POST" "/sw.js" []
|
|
statusOf postWorker `shouldBe` 405
|
|
deleteInvoice <- webRequest client "DELETE" (invoicePath iid) []
|
|
statusOf deleteInvoice `shouldBe` 405
|
|
headerOf deleteInvoice hCacheControl `shouldBe` Just "no-store"
|
|
getCreate <- webRequest client "GET" "/api/invoice" []
|
|
statusOf getCreate `shouldBe` 405
|
|
headerOf getCreate "Allow" `shouldBe` Just "POST"
|
|
|
|
testUnroutedPathIs404 :: IO ()
|
|
testUnroutedPathIs404 = bounded "unrouted" $ withWebApp $ \_ client -> do
|
|
nowhere <- webGet client "/nowhere"
|
|
statusOf nowhere `shouldBe` 404
|
|
responseBody nowhere `shouldBe` notFoundBody
|
|
webhook <- webRequest client "POST" "/webhooks/btcpay" []
|
|
statusOf webhook `shouldBe` 400
|
|
responseBody webhook `shouldBe` ""
|
|
wrongMethod <- webGet client "/webhooks/btcpay"
|
|
statusOf wrongMethod `shouldBe` 405
|
|
headerOf wrongMethod "Allow" `shouldBe` Just "POST"
|
|
|
|
testWaitAnswersAtOnce :: IO ()
|
|
testWaitAnswersAtOnce = bounded "wait answers at once" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
(staleElapsed, stale) <- timedGet client (invoicePath iid <> "?wait=expired")
|
|
statusOf stale `shouldBe` 200
|
|
staleObject <- jsonObject stale
|
|
fieldOf staleObject "status" `shouldBe` Just (J.String "open")
|
|
staleElapsed `shouldSatisfy` (< 1)
|
|
(junkElapsed, junk) <- timedGet client (invoicePath iid <> "?wait=not-a-status")
|
|
junkObject <- jsonObject junk
|
|
fieldOf junkObject "status" `shouldBe` Just (J.String "open")
|
|
junkElapsed `shouldSatisfy` (< 1)
|
|
markPaid (weStore env) iid
|
|
(terminalElapsed, terminalR) <- timedGet client (invoicePath iid <> "?wait=paid")
|
|
terminalObject <- jsonObject terminalR
|
|
fieldOf terminalObject "status" `shouldBe` Just (J.String "paid")
|
|
terminalElapsed `shouldSatisfy` (< 1)
|
|
|
|
testHoldAnswersAPaymentThePageHasNotSeen :: IO ()
|
|
testHoldAnswersAPaymentThePageHasNotSeen = bounded "hold sees the payment" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
settle (weStore env) iid (SigFunded (rcv 200 (Just "0.00020000")) PaidInPart) settleAt `shouldReturn` Right ISOpen
|
|
(staleElapsed, stale) <- timedGet client (invoicePath iid <> "?wait=open&seenPaid=")
|
|
statusOf stale `shouldBe` 200
|
|
staleElapsed `shouldSatisfy` (< 1)
|
|
staleObject <- jsonObject stale
|
|
fieldOf staleObject "cryptoAmountPaid" `shouldBe` Just (J.String "0.00020000")
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open&seenPaid=0.00020000")
|
|
threadDelay 100000
|
|
Async.poll held >>= (`shouldSatisfy` isNothing)
|
|
markPaidAndPublish env iid
|
|
statusOf <$> wait held `shouldReturn` 200
|
|
|
|
testHoldAnswersAVerdictWithNoFigure :: IO ()
|
|
testHoldAnswersAVerdictWithNoFigure = bounded "hold sees the verdict" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
settle (weStore env) iid (SigFunded (rcv 0 Nothing) PaidInFull) settleAt `shouldReturn` Right ISOpen
|
|
(elapsed, r) <- timedGet client (invoicePath iid <> "?wait=open&seenPaid=&seenFull=0")
|
|
statusOf r `shouldBe` 200
|
|
elapsed `shouldSatisfy` (< 1)
|
|
o <- jsonObject r
|
|
fieldOf o "paidInFull" `shouldBe` Just (J.Bool True)
|
|
fieldOf o "cryptoAmountPaid" `shouldBe` Nothing
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open&seenPaid=&seenFull=1")
|
|
threadDelay 100000
|
|
Async.poll held >>= (`shouldSatisfy` isNothing)
|
|
markPaidAndPublish env iid
|
|
statusOf <$> wait held `shouldReturn` 200
|
|
|
|
testHoldIsWokenNotPolled :: IO ()
|
|
testHoldIsWokenNotPolled = bounded "hold is woken" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
_ <- wokenBy client iid (markPaidAndPublish env iid) "paid"
|
|
pure ()
|
|
|
|
testReadRateLimit :: IO ()
|
|
testReadRateLimit = bounded "read rate limit" $ withWebApp $ \_ client -> do
|
|
let target = "/api/invoice/no-such-invoice"
|
|
allowed <- mapM (\_ -> statusOf <$> webGet client target) [1 .. readsPerMinute]
|
|
allowed `shouldSatisfy` all (== 404)
|
|
refused <- webGet client target
|
|
statusOf refused `shouldBe` 429
|
|
responseBody refused `shouldBe` "{\"error\":\"rate_limited\"}"
|
|
case headerOf refused "Retry-After" >>= (readMaybe . BC.unpack) of
|
|
Just seconds -> seconds `shouldSatisfy` \s -> s >= (1 :: Int) && s <= 60
|
|
Nothing -> expectationFailure ("Retry-After is " <> show (headerOf refused "Retry-After"))
|
|
|
|
testForwardedForOnlyWhenTrusted :: IO ()
|
|
testForwardedForOnlyWhenTrusted = bounded "forwarded-for" $ do
|
|
let target = "/api/invoice/no-such-invoice"
|
|
forgedBy i = ("X-Forwarded-For", BC.pack ("203.0.113." <> show (i :: Int)) <> ", 10.0.0.1")
|
|
proxiedFor i = ("X-Forwarded-For", BC.pack ("10.0.0." <> show (i :: Int)))
|
|
withWebApp $ \_ client -> do
|
|
forged <- mapM (\i -> statusOf <$> webRequest client "GET" target [forgedBy i]) [1 .. readsPerMinute]
|
|
forged `shouldSatisfy` all (== 404)
|
|
refused <- webRequest client "GET" target [forgedBy (readsPerMinute + 1)]
|
|
statusOf refused `shouldBe` 429
|
|
withWebAppForwarded True $ \_ client -> do
|
|
forged <- mapM (\i -> statusOf <$> webRequest client "GET" target [forgedBy i]) [1 .. readsPerMinute]
|
|
forged `shouldSatisfy` all (== 404)
|
|
refused <- webRequest client "GET" target [forgedBy (readsPerMinute + 1)]
|
|
statusOf refused `shouldBe` 429
|
|
withWebAppForwarded True $ \_ client -> do
|
|
first' <- mapM (\_ -> statusOf <$> webRequest client "GET" target [proxiedFor 1]) [1 .. readsPerMinute]
|
|
first' `shouldSatisfy` all (== 404)
|
|
statusOf <$> webRequest client "GET" target [proxiedFor 1] `shouldReturn` 429
|
|
statusOf <$> webRequest client "GET" target [proxiedFor 2] `shouldReturn` 404
|
|
|
|
testForwardedForMustBeAnAddress :: IO ()
|
|
testForwardedForMustBeAnAddress = bounded "forwarded-for is an address" $ withWebAppForwarded True $ \_ client -> do
|
|
let target = "/api/invoice/no-such-invoice"
|
|
forged i = ("X-Forwarded-For", BC.pack ("not-an-address-" <> show (i :: Int)))
|
|
minted <- mapM (\i -> statusOf <$> webRequest client "GET" target [forged i]) [1 .. readsPerMinute]
|
|
minted `shouldSatisfy` all (== 404)
|
|
refused <- webRequest client "GET" target [forged (readsPerMinute + 1)]
|
|
statusOf refused `shouldBe` 429
|
|
mapM_
|
|
( \v -> do
|
|
r <- webRequest client "GET" target [("X-Forwarded-For", v)]
|
|
(v, statusOf r) `shouldBe` (v, 429)
|
|
)
|
|
["", "not-an-address", "010.0.0.1", "1.2.3.4.5", "256.1.1.1", "1.2.3", "::1%eth0", "1:2:3:4:5:6:7", "[2001:db8::1]", "1.2.3.4:5678", "gggg::1", "12345::1"]
|
|
mapM_
|
|
( \v -> do
|
|
r <- webRequest client "GET" target [("X-Forwarded-For", v)]
|
|
(v, statusOf r) `shouldBe` (v, 404)
|
|
)
|
|
["192.0.2.7", "2001:db8::1", "::1", "::", "::ffff:192.0.2.1", "1:2:3:4:5:6:7:8"]
|
|
|
|
testBucketsStayBounded :: IO ()
|
|
testBucketsStayBounded = bounded "bucket cap" $ withWebApp $ \env _ -> do
|
|
let clients = map (\i -> T.pack ("198.51." <> show (i `div` 256) <> "." <> show (i `mod` 256))) [1 .. maxBuckets * 2 + 100]
|
|
mapM_ (takeToken env readLimit) clients
|
|
held <- Map.size <$> readTVarIO (weBuckets env)
|
|
held `shouldSatisfy` \n -> n > 0 && n <= maxBuckets
|
|
answers <- mapM (\_ -> takeToken env readLimit "203.0.113.9") [1 .. readsPerMinute + 1]
|
|
length (filter isJust answers) `shouldBe` 1
|
|
|
|
let fill i = do
|
|
n <- Map.size <$> readTVarIO (weBuckets env)
|
|
when (n < maxBuckets) $ do
|
|
_ <- takeToken env readLimit (T.pack ("192.0." <> show (i `div` 256) <> "." <> show (i `mod` 256)))
|
|
fill (i + 1)
|
|
fill (1 :: Int)
|
|
atTheCap <- Map.size <$> readTVarIO (weBuckets env)
|
|
atTheCap `shouldBe` maxBuckets
|
|
refusedAgain <- takeToken env readLimit "203.0.113.9"
|
|
refusedAgain `shouldSatisfy` isJust
|
|
afterARefusal <- Map.size <$> readTVarIO (weBuckets env)
|
|
afterARefusal `shouldSatisfy` (<= atTheCap `div` 2 + 1)
|
|
|
|
testHoldReportsTheRowNotTheWake :: IO ()
|
|
testHoldReportsTheRowNotTheWake = bounded "hold reports the row" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open")
|
|
threadDelay 100000
|
|
markPaid (weStore env) iid
|
|
atomically $ publish (weWaiters env) iid ISExpired
|
|
r <- wait held
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "status" `shouldBe` Just (J.String "paid")
|
|
|
|
testHold :: Int
|
|
testHold = 2000000
|
|
|
|
awaitWaitingCount :: WebEnv -> Int -> IO ()
|
|
awaitWaitingCount env n = do
|
|
parked <- waitingCount (weWaiters env)
|
|
when (parked /= n) $ threadDelay 10000 >> awaitWaitingCount env n
|
|
|
|
testHoldTimeoutReportsAnUnpublishedChange :: IO ()
|
|
testHoldTimeoutReportsAnUnpublishedChange = bounded "hold timeout" $ withWebAppHolding testHold $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
started <- getCurrentTime
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open")
|
|
awaitWaitingCount env 1
|
|
threadDelay 50000
|
|
expireRow (weStore env) iid
|
|
r <- wait held
|
|
elapsed <- (`diffUTCTime` started) <$> getCurrentTime
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "status" `shouldBe` Just (J.String "expired")
|
|
elapsed `shouldSatisfy` (>= 1.95)
|
|
|
|
testHandlerExceptionIsContained :: IO ()
|
|
testHandlerExceptionIsContained = bounded "handler exception" $ withWebAppExpectingThrow $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
breakStore (weStore env)
|
|
r <- webGet client (invoicePath iid)
|
|
statusOf r `shouldBe` 500
|
|
responseBody r `shouldBe` "{\"error\":\"internal\"}"
|
|
headerOf r hCacheControl `shouldBe` Just "no-store"
|
|
shell <- webGet client "/"
|
|
statusOf shell `shouldBe` 200
|
|
responseBody shell `shouldBe` shellHtml
|
|
|
|
createsPerMinute :: Int
|
|
createsPerMinute = 5
|
|
|
|
testExpiryMinutes :: Int
|
|
testExpiryMinutes = 45
|
|
|
|
testBTCPayConfig :: BTCPayConfig
|
|
testBTCPayConfig =
|
|
BTCPayConfig
|
|
{ bHost = "https://btcpay.example",
|
|
bApiKey = "api-key",
|
|
bStoreId = "store-id",
|
|
bWebhookSecret = "webhook-secret",
|
|
bExpiryMinutes = testExpiryMinutes,
|
|
bSpeedPolicy = MediumSpeed,
|
|
bPaymentTolerance = 0.5
|
|
}
|
|
|
|
supporterPriceId, legendPriceId, disabledPriceId :: Text
|
|
supporterPriceId = "price-supporter"
|
|
legendPriceId = "price-legend"
|
|
disabledPriceId = "price-retired"
|
|
|
|
anyPriceOfferId, legendOnlyOfferId, disabledOfferId :: Text
|
|
anyPriceOfferId = "offer-3m"
|
|
legendOnlyOfferId = "offer-3m-legend"
|
|
disabledOfferId = "offer-retired"
|
|
|
|
seedCheckoutCatalog :: DBStore -> IO ()
|
|
seedCheckoutCatalog st = do
|
|
insertPrice st supporterPriceId "supporter" 500 "active"
|
|
insertPrice st legendPriceId "legend" 2000 "active"
|
|
insertPrice st disabledPriceId "supporter" 500 "disabled"
|
|
insertOffer st anyPriceOfferId Nothing 3 10 "active"
|
|
insertOffer st legendOnlyOfferId (Just legendPriceId) 3 10 "active"
|
|
insertOffer st disabledOfferId Nothing 3 10 "disabled"
|
|
|
|
codeDigest :: Text -> ByteString
|
|
codeDigest = C.sha256Hash . encodeUtf8
|
|
|
|
codeHashText :: Text -> Text
|
|
codeHashText = T.filter (/= '=') . safeDecodeUtf8 . B64U.encode . codeDigest
|
|
|
|
sampleCode :: Text
|
|
sampleCode = "YDC8AYGQTMPUYZ92TUXP"
|
|
|
|
createBody :: Text -> Maybe Text -> Text -> Text -> LB.ByteString
|
|
createBody priceId offerId method codeHash =
|
|
J.encode . J.object $
|
|
["priceId" .= priceId, "method" .= method, "codeHash" .= codeHash]
|
|
<> maybe [] (\o -> ["offerId" .= o]) offerId
|
|
|
|
postCreateAs :: WebClient -> Int -> LB.ByteString -> IO (Response LB.ByteString)
|
|
postCreateAs client i body =
|
|
webRequestBody
|
|
client
|
|
"POST"
|
|
"/api/invoice"
|
|
[(hContentType, "application/json"), ("X-Forwarded-For", BC.pack ("203.0.113." <> show i))]
|
|
body
|
|
|
|
withCheckout :: (IORef StubState -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withCheckout = withCheckoutProvider stubProvider
|
|
|
|
withCheckoutProvider :: (IORef StubState -> Provider) -> (IORef StubState -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withCheckoutProvider mkProvider action =
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-checkout" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
seedCheckoutCatalog st
|
|
ref <- newIORef (newStubState (Right sampleProviderInvoice))
|
|
let cfg = (testServiceConfig staticDir True) {btcpay = Just testBTCPayConfig}
|
|
withListener [mkProvider ref] True holdMicros st cfg (action ref)
|
|
|
|
invoiceCount :: DBStore -> IO Int
|
|
invoiceCount st = withConnection st $ \db -> do
|
|
rows <- DB.query_ db "SELECT COUNT(*) FROM sx_badge_service_invoices" :: IO [Only Int]
|
|
pure $ case rows of
|
|
(Only n : _) -> n
|
|
[] -> 0
|
|
|
|
plantCodeHash :: DBStore -> Text -> IO ()
|
|
plantCodeHash st code = withConnection st $ \db ->
|
|
DB.execute
|
|
db
|
|
"INSERT INTO sx_badge_service_badge_codes (code_hash, badge_type, months, code_payment_status, created_at) VALUES (?,?,?,?,?)"
|
|
(DB.Binary (codeDigest code), "supporter" :: Text, 1 :: Int, "unpaid" :: Text, "2026-08-31T00:00:00Z" :: Text)
|
|
|
|
stringField :: HasCallStack => J.Object -> Text -> IO Text
|
|
stringField o k = case fieldOf o k of
|
|
Just (J.String t) -> pure t
|
|
other -> failWith (T.unpack k <> " is " <> show other)
|
|
|
|
errorBody :: Text -> LB.ByteString
|
|
errorBody code = "{\"error\":\"" <> LB.pack (T.unpack code) <> "\"}"
|
|
|
|
testCreateInvoice :: IO ()
|
|
testCreateInvoice = bounded "create invoice" $ withCheckout $ \ref env client -> do
|
|
started <- getCurrentTime
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "btc" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
headerOf r hContentType `shouldBe` Just "application/json"
|
|
headerOf r hCacheControl `shouldBe` Just "no-store"
|
|
o <- jsonObject r
|
|
sort (map K.toText (KM.keys o))
|
|
`shouldBe` sort ["invoiceId", "badgeType", "months", "amount", "currency", "expiresAt", "address", "cryptoAmount", "cryptoCurrency"]
|
|
fieldOf o "badgeType" `shouldBe` Just (J.String "supporter")
|
|
fieldOf o "months" `shouldBe` Just (J.Number 1)
|
|
fieldOf o "amount" `shouldBe` Just (J.Number 500)
|
|
fieldOf o "currency" `shouldBe` Just (J.String "usd")
|
|
fieldOf o "address" `shouldBe` Just (J.String "bc1qexampleaddress")
|
|
fieldOf o "cryptoAmount" `shouldBe` Just (J.String "0.00050000")
|
|
fieldOf o "cryptoCurrency" `shouldBe` Just (J.String "btc")
|
|
invId <- InvoiceId <$> stringField o "invoiceId"
|
|
Just row <- getInvoice (weStore env) invId
|
|
irStatus row `shouldBe` ISOpen
|
|
irProvider row `shouldBe` PPCrypto
|
|
irProviderRef row `shouldBe` piProviderRef sampleProviderInvoice
|
|
irBadgeType row `shouldBe` BTSupporter
|
|
irMonths row `shouldBe` 1
|
|
irPrice row `shouldBe` CurrencyAmount 500
|
|
irAmount row `shouldBe` CurrencyAmount 500
|
|
irDestination row `shouldBe` piDestination sampleProviderInvoice
|
|
codeRow <- settledCode (weStore env) invId
|
|
bcPaymentStatus codeRow `shouldBe` CPSUnpaid
|
|
fieldOf o "expiresAt" `shouldBe` Just (J.toJSON (irExpiresAt row))
|
|
diffUTCTime (irExpiresAt row) started `shouldSatisfy` \d -> d > 44 * 60 && d <= 46 * 60
|
|
calls <- stubCalls ref
|
|
case calls of
|
|
[StubCreate method draft] -> do
|
|
method `shouldBe` SPMCrypto CCBtc
|
|
odAmount draft `shouldBe` CurrencyAmount 500
|
|
odCurrency draft `shouldBe` "usd"
|
|
other -> expectationFailure ("provider calls: " <> show other)
|
|
|
|
testXmrReachesTheProviderAsXmr :: IO ()
|
|
testXmrReachesTheProviderAsXmr = bounded "xmr is xmr" $ withCheckout $ \ref _ client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "xmr" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
stubCalls ref >>= \case
|
|
[StubCreate method _] -> method `shouldBe` SPMCrypto CCXmr
|
|
other -> expectationFailure ("provider calls: " <> show other)
|
|
|
|
testCreateCarriesNoCode :: IO ()
|
|
testCreateCarriesNoCode = bounded "no code in the response" $ withCheckout $ \_ env client -> do
|
|
let hashText = codeHashText sampleCode
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "btc" hashText)
|
|
statusOf r `shouldBe` 200
|
|
let raw = LB.unpack (responseBody r)
|
|
raw `shouldNotContain` T.unpack sampleCode
|
|
raw `shouldNotContain` T.unpack hashText
|
|
raw `shouldNotContain` T.unpack supporterPriceId
|
|
raw `shouldContain` "invoiceId"
|
|
invoiceCount (weStore env) `shouldReturn` 1
|
|
|
|
testCreateDerivesFromCatalog :: IO ()
|
|
testCreateDerivesFromCatalog = bounded "derived from the catalog" $ withCheckout $ \_ env client -> do
|
|
let tampered =
|
|
J.encode $
|
|
J.object
|
|
[ "priceId" .= legendPriceId,
|
|
"offerId" .= anyPriceOfferId,
|
|
"method" .= ("btc" :: Text),
|
|
"codeHash" .= codeHashText sampleCode,
|
|
"badgeType" .= ("supporter" :: Text),
|
|
"months" .= (1 :: Int),
|
|
"amount" .= (500 :: Int)
|
|
]
|
|
r <- postCreateAs client 1 tampered
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "badgeType" `shouldBe` Just (J.String "legend")
|
|
fieldOf o "months" `shouldBe` Just (J.Number 3)
|
|
fieldOf o "amount" `shouldBe` Just (J.Number 5400)
|
|
invId <- InvoiceId <$> stringField o "invoiceId"
|
|
Just row <- getInvoice (weStore env) invId
|
|
irBadgeType row `shouldBe` BTLegend
|
|
irMonths row `shouldBe` 3
|
|
irPrice row `shouldBe` CurrencyAmount 6000
|
|
irAmount row `shouldBe` CurrencyAmount 5400
|
|
|
|
testCatalogRefusalCostsNothing :: IO ()
|
|
testCatalogRefusalCostsNothing = bounded "catalog refusals" $ withCheckout $ \ref env client -> do
|
|
let cases :: [(String, Text, Maybe Text)]
|
|
cases =
|
|
[ ("an unknown price", "price-that-never-existed", Nothing),
|
|
("a disabled price", disabledPriceId, Nothing),
|
|
("an unknown offer", supporterPriceId, Just "offer-that-never-existed"),
|
|
("a disabled offer", supporterPriceId, Just disabledOfferId),
|
|
("an offer belonging to another price", supporterPriceId, Just legendOnlyOfferId)
|
|
]
|
|
mapM_
|
|
( \(i, (label, priceId, offerId)) -> do
|
|
r <- postCreateAs client i (createBody priceId offerId "btc" (codeHashText sampleCode))
|
|
(label, statusOf r) `shouldBe` (label, 400)
|
|
(label, responseBody r) `shouldBe` (label, errorBody "catalog_changed")
|
|
)
|
|
(zip [30 ..] cases)
|
|
stubCalls ref `shouldReturn` []
|
|
invoiceCount (weStore env) `shouldReturn` 0
|
|
|
|
testCodeConflictCostsNothing :: IO ()
|
|
testCodeConflictCostsNothing = bounded "code conflict" $ withCheckout $ \ref env client -> do
|
|
first <- postCreateAs client 1 (createBody supporterPriceId Nothing "btc" (codeHashText sampleCode))
|
|
statusOf first `shouldBe` 200
|
|
sold <- stubCalls ref
|
|
length sold `shouldBe` 1
|
|
again <- postCreateAs client 2 (createBody supporterPriceId Nothing "btc" (codeHashText sampleCode))
|
|
statusOf again `shouldBe` 409
|
|
responseBody again `shouldBe` errorBody "code_conflict"
|
|
stubCalls ref `shouldReturn` sold
|
|
invoiceCount (weStore env) `shouldReturn` 1
|
|
|
|
testRacingCodeHashIsConflict :: IO ()
|
|
testRacingCodeHashIsConflict = bounded "racing code hash" $ do
|
|
planted <- newIORef (Nothing :: Maybe DBStore)
|
|
let racing ref =
|
|
let stub = stubProvider ref
|
|
in stub
|
|
{ pCreateInvoice = \method draft -> do
|
|
readIORef planted >>= mapM_ (`plantCodeHash` sampleCode)
|
|
pCreateInvoice stub method draft
|
|
}
|
|
withCheckoutProvider racing $ \ref env client -> do
|
|
atomicModifyIORef' planted (const (Just (weStore env), ()))
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "btc" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 409
|
|
responseBody r `shouldBe` errorBody "code_conflict"
|
|
length <$> stubCalls ref `shouldReturn` 1
|
|
invoiceCount (weStore env) `shouldReturn` 0
|
|
|
|
testStoreLostAfterTheProviderCreated :: IO ()
|
|
testStoreLostAfterTheProviderCreated = bounded "orphan at the provider" $ do
|
|
broken <- newIORef (Nothing :: Maybe DBStore)
|
|
let losing ref =
|
|
let stub = stubProvider ref
|
|
in stub
|
|
{ pCreateInvoice = \method draft -> do
|
|
readIORef broken >>= mapM_ breakStore
|
|
pCreateInvoice stub method draft
|
|
}
|
|
withCheckoutProvider losing $ \ref env client -> do
|
|
atomicModifyIORef' broken (const (Just (weStore env), ()))
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "btc" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 500
|
|
responseBody r `shouldBe` errorBody "internal"
|
|
headerOf r hCacheControl `shouldBe` Just "no-store"
|
|
length <$> stubCalls ref `shouldReturn` 1
|
|
invoiceCount (weStore env) `shouldReturn` 0
|
|
|
|
testBadRequestCostsNothing :: IO ()
|
|
testBadRequestCostsNothing = bounded "bad requests" $ withCheckout $ \ref env client -> do
|
|
let good = codeHashText sampleCode
|
|
bodies :: [(String, LB.ByteString)]
|
|
bodies =
|
|
[ ("not JSON at all", "priceId=price-supporter"),
|
|
("a JSON array", "[]"),
|
|
("no fields", "{}"),
|
|
("no method", J.encode (J.object ["priceId" .= supporterPriceId, "codeHash" .= good])),
|
|
("no codeHash", J.encode (J.object ["priceId" .= supporterPriceId, "method" .= ("btc" :: Text)])),
|
|
("no priceId", J.encode (J.object ["method" .= ("btc" :: Text), "codeHash" .= good])),
|
|
("an unknown method", createBody supporterPriceId Nothing "paypal" good),
|
|
("a mistyped method", J.encode (J.object ["priceId" .= supporterPriceId, "method" .= (7 :: Int), "codeHash" .= good])),
|
|
("a mistyped offerId", J.encode (J.object ["priceId" .= supporterPriceId, "offerId" .= (7 :: Int), "method" .= ("btc" :: Text), "codeHash" .= good])),
|
|
("an empty codeHash", createBody supporterPriceId Nothing "btc" ""),
|
|
("a hex codeHash", createBody supporterPriceId Nothing "btc" (T.replicate 64 "a")),
|
|
("a truncated codeHash", createBody supporterPriceId Nothing "btc" (T.dropEnd 1 good)),
|
|
("a padded codeHash", createBody supporterPriceId Nothing "btc" (good <> "=")),
|
|
("a codeHash outside base64url", createBody supporterPriceId Nothing "btc" (T.dropEnd 1 good <> "+")),
|
|
("a non-canonical codeHash", createBody supporterPriceId Nothing "btc" (T.dropEnd 1 good <> "B"))
|
|
]
|
|
mapM_
|
|
( \(i, (label, body)) -> do
|
|
r <- postCreateAs client i body
|
|
(label, statusOf r) `shouldBe` (label, 400)
|
|
(label, responseBody r) `shouldBe` (label, errorBody "bad_request")
|
|
)
|
|
(zip [10 ..] bodies)
|
|
stubCalls ref `shouldReturn` []
|
|
invoiceCount (weStore env) `shouldReturn` 0
|
|
|
|
testOversizedBodyCostsNothing :: IO ()
|
|
testOversizedBodyCostsNothing = bounded "oversized body" $ withCheckout $ \ref env client -> do
|
|
let padded =
|
|
J.encode $
|
|
J.object
|
|
[ "priceId" .= supporterPriceId,
|
|
"method" .= ("btc" :: Text),
|
|
"codeHash" .= codeHashText sampleCode,
|
|
"padding" .= T.replicate (64 * 1024) "x"
|
|
]
|
|
LB.length padded `shouldSatisfy` (> 64 * 1024)
|
|
r <- postCreateAs client 1 padded
|
|
statusOf r `shouldBe` 400
|
|
responseBody r `shouldBe` errorBody "bad_request"
|
|
stubCalls ref `shouldReturn` []
|
|
invoiceCount (weStore env) `shouldReturn` 0
|
|
small <- postCreateAs client 2 (J.encode (J.object ["priceId" .= supporterPriceId, "method" .= ("btc" :: Text), "codeHash" .= codeHashText sampleCode, "padding" .= T.replicate 16 "x"]))
|
|
statusOf small `shouldBe` 200
|
|
|
|
testCardUnconfigured503 :: IO ()
|
|
testCardUnconfigured503 = bounded "card is unavailable" $ withCheckout $ \ref env client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "card" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 503
|
|
responseBody r `shouldBe` errorBody "provider_unavailable"
|
|
stubCalls ref `shouldReturn` []
|
|
invoiceCount (weStore env) `shouldReturn` 0
|
|
|
|
stripeWebhookPath :: String
|
|
stripeWebhookPath = "/webhooks/stripe"
|
|
|
|
postStripeWebhook :: WebClient -> [Header] -> LB.ByteString -> IO (Response LB.ByteString)
|
|
postStripeWebhook client hdrs = webRequestBody client "POST" stripeWebhookPath hdrs
|
|
|
|
signedStripeEvent :: FakeStripe -> Text -> Text -> IO ([Header], LB.ByteString)
|
|
signedStripeEvent fake eventType sid = do
|
|
t <- round . utcTimeToPOSIXSeconds <$> getCurrentTime :: IO Int
|
|
let body = stripeEvent eventType sid
|
|
pure (stripeSigHeader (sWebhookSecret (fsConfig fake)) t body, body)
|
|
|
|
withFakeStripePoller :: (FakeStripe -> PollerEnv -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withFakeStripePoller action =
|
|
withFakeStripe $ \fake ->
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-stripe-fake" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
seedCheckoutCatalog st
|
|
provider <- stripeProvider (fsConfig fake)
|
|
let cfg = (testServiceConfig staticDir True) {stripe = Just (fsConfig fake)}
|
|
withListener [provider] True holdMicros st cfg $ \env client -> do
|
|
poller <- pollerFor env
|
|
action fake poller env client
|
|
|
|
testCardCreatesSession :: IO ()
|
|
testCardCreatesSession = bounded "card creates a session" $ withFakeStripePoller $ \_ _ env client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "card" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
headerOf r hContentType `shouldBe` Just "application/json"
|
|
o <- jsonObject r
|
|
fieldOf o "clientSecret" `shouldSatisfy` isJust
|
|
fieldOf o "badgeType" `shouldBe` Just (J.String "supporter")
|
|
invId <- InvoiceId <$> stringField o "invoiceId"
|
|
Just row <- getInvoice (weStore env) invId
|
|
irProvider row `shouldBe` PPStripe
|
|
irStatus row `shouldBe` ISOpen
|
|
bcPaymentStatus <$> settledCode (weStore env) invId `shouldReturn` CPSUnpaid
|
|
|
|
testCardSweepCancelsTheIntent :: IO ()
|
|
testCardSweepCancelsTheIntent = bounded "card sweep cancels" $ withFakeStripePoller $ \fake poller env client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "card" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
invId@(InvoiceId iid) <- InvoiceId <$> stringField o "invoiceId"
|
|
Just InvoiceRow {irProviderRef = pid} <- getInvoice (weStore env) invId
|
|
past <- addUTCTime (negate (expiryGrace + 60)) <$> getCurrentTime
|
|
withConnection (weStore env) $ \db ->
|
|
DB.execute db "UPDATE sx_badge_service_invoices SET expires_at = ? WHERE invoice_id = ?" (truncateToSecond past, iid)
|
|
runOnePass poller
|
|
fakeIntentStatus fake pid `shouldReturn` Just "canceled"
|
|
invoiceStatus (weStore env) invId `shouldReturn` ISExpired
|
|
|
|
-- | This value differs from the default 60 and the btcpay window 45, so a card expiry read from
|
|
-- the wrong key would show up as one of those instead.
|
|
cardSessionMinutes :: Int
|
|
cardSessionMinutes = 90
|
|
|
|
testCardExpiryFollowsSessionMinutes :: IO ()
|
|
testCardExpiryFollowsSessionMinutes = bounded "card expiry from session_minutes" $
|
|
withFakeStripe $ \fake ->
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-stripe-expiry" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
seedCheckoutCatalog st
|
|
let stripeCfg = (fsConfig fake) {sSessionMinutes = cardSessionMinutes}
|
|
cfg = (testServiceConfig staticDir True) {stripe = Just stripeCfg, btcpay = Just testBTCPayConfig}
|
|
provider <- stripeProvider stripeCfg
|
|
withListener [provider] True holdMicros st cfg $ \env client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "card" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
invId <- InvoiceId <$> stringField o "invoiceId"
|
|
Just row <- getInvoice (weStore env) invId
|
|
fieldOf o "expiresAt" `shouldBe` Just (J.toJSON (irExpiresAt row))
|
|
diffUTCTime (irExpiresAt row) (irCreatedAt row) `shouldBe` fromIntegral (cardSessionMinutes * 60)
|
|
diffUTCTime (irExpiresAt row) (irCreatedAt row) `shouldNotBe` fromIntegral (testExpiryMinutes * 60)
|
|
|
|
testCardWebhookSettles :: IO ()
|
|
testCardWebhookSettles = bounded "card webhook settles" $ withFakeStripePoller $ \fake poller env client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "card" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
iid <- InvoiceId <$> stringField o "invoiceId"
|
|
Just row <- getInvoice (weStore env) iid
|
|
let pid = irProviderRef row
|
|
setIntentState fake pid ["status" .= ("succeeded" :: Text)]
|
|
(hdrs, body) <- signedStripeEvent fake "payment_intent.succeeded" pid
|
|
delivered <- postStripeWebhook client hdrs body
|
|
statusOf delivered `shouldBe` 200
|
|
responseBody delivered `shouldBe` ""
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
drainHints poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSPaid
|
|
ipStatus <$> paymentRow (weStore env) iid `shouldReturn` "settled"
|
|
|
|
testProviderFailureWritesNothing :: IO ()
|
|
testProviderFailureWritesNothing = bounded "provider failure" $ withCheckout $ \ref env client -> do
|
|
atomicModifyIORef' ref $ \s -> (s {ssCreateResult = Left (ProviderError "connection refused")}, ())
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "btc" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 503
|
|
responseBody r `shouldBe` errorBody "provider_unavailable"
|
|
length <$> stubCalls ref `shouldReturn` 1
|
|
invoiceCount (weStore env) `shouldReturn` 0
|
|
codeHashExists (weStore env) (codeDigest sampleCode) `shouldReturn` False
|
|
|
|
-- | provider_ref is unique, so a test that creates several invoices must vary it as a real
|
|
-- provider would.
|
|
distinctProviderRefs :: IORef StubState -> Provider
|
|
distinctProviderRefs ref =
|
|
let stub = stubProvider ref
|
|
in stub
|
|
{ pCreateInvoice = \method draft -> do
|
|
created <- pCreateInvoice stub method draft
|
|
n <- length <$> stubCalls ref
|
|
pure (fmap (\inv -> inv {piProviderRef = piProviderRef inv <> "-" <> T.pack (show n)}) created)
|
|
}
|
|
|
|
testCreateRateLimit :: IO ()
|
|
testCreateRateLimit = bounded "create rate limit" $ withCheckoutProvider distinctProviderRefs $ \ref env client -> do
|
|
let attempt n = postCreateAs client 20 (createBody supporterPriceId Nothing "btc" (codeHashText (sampleCode <> T.pack (show (n :: Int)))))
|
|
allowed <- mapM (fmap statusOf . attempt) [1 .. createsPerMinute]
|
|
allowed `shouldSatisfy` all (== 200)
|
|
refused <- attempt (createsPerMinute + 1)
|
|
statusOf refused `shouldBe` 429
|
|
responseBody refused `shouldBe` errorBody "rate_limited"
|
|
headerOf refused hCacheControl `shouldBe` Just "no-store"
|
|
case headerOf refused "Retry-After" >>= (readMaybe . BC.unpack) of
|
|
Just seconds -> seconds `shouldSatisfy` \s -> s >= (1 :: Int) && s <= 60
|
|
Nothing -> expectationFailure ("Retry-After is " <> show (headerOf refused "Retry-After"))
|
|
length <$> stubCalls ref `shouldReturn` createsPerMinute
|
|
invoiceCount (weStore env) `shouldReturn` createsPerMinute
|
|
|
|
settleAt :: UTCTime
|
|
settleAt = UTCTime (fromGregorian 2026 9 2) (secondsToDiffTime (12 * 3600 + 34 * 60 + 56))
|
|
|
|
replayAt :: UTCTime
|
|
replayAt = addUTCTime 3600 settleAt
|
|
|
|
-- | The service always learns of settlement later than it happened, so this differs from
|
|
-- settleAt, and using one value for both would let a build that wrote the wrong one still pass.
|
|
detectedAt :: UTCTime
|
|
detectedAt = addUTCTime 300 settleAt
|
|
|
|
rcv :: Word32 -> Maybe Text -> Received
|
|
rcv amount crypto = Received {rcvAmount = CurrencyAmount amount, rcvCrypto = crypto, rcvDue = Nothing}
|
|
|
|
settle :: DBStore -> InvoiceId -> PaymentSignal -> UTCTime -> IO (Either Text InvoiceStatus)
|
|
settle st iid signal at = newWaiters >>= \waiters -> settleOrder st waiters iid signal at
|
|
|
|
seedOpen :: HasCallStack => DBStore -> IO InvoiceId
|
|
seedOpen st = do
|
|
seedBadgePrice st "price1"
|
|
createInvoiceRows st sampleInvoice `shouldReturn` Right ()
|
|
pure (niInvoiceId sampleInvoice)
|
|
|
|
invoiceStatus :: HasCallStack => DBStore -> InvoiceId -> IO InvoiceStatus
|
|
invoiceStatus st iid = getInvoice st iid >>= maybe (failWith "no invoice row") (pure . irStatus)
|
|
|
|
paymentRow :: HasCallStack => DBStore -> InvoiceId -> IO InvoicePayment
|
|
paymentRow st iid =
|
|
getInvoice st iid >>= \case
|
|
Just InvoiceRow {irPayment = Just p} -> pure p
|
|
_ -> failWith "no payment row"
|
|
|
|
data CodeRow = CodeRow {bcPaymentStatus :: BadgeCodePaymentStatus, bcExpiresAt :: Maybe UTCTime, bcRevokedAt :: Maybe UTCTime}
|
|
deriving (Eq, Show)
|
|
|
|
settledCode :: HasCallStack => DBStore -> InvoiceId -> IO CodeRow
|
|
settledCode st (InvoiceId iid) = withConnection st $ \db -> do
|
|
rows <-
|
|
DB.query
|
|
db
|
|
"SELECT c.code_payment_status, c.expires_at, c.revoked_at FROM sx_badge_service_badge_codes c JOIN sx_badge_service_badge_code_invoices ci ON ci.badge_code_id = c.badge_code_id WHERE ci.invoice_id = ?"
|
|
(Only iid)
|
|
case rows of
|
|
(status, expiresAt, revokedAt) : _ -> pure CodeRow {bcPaymentStatus = fromMaybe CPSUnpaid (textDecode status), bcExpiresAt = expiresAt, bcRevokedAt = revokedAt}
|
|
[] -> failWith "no badge_codes row"
|
|
|
|
paymentIdentity :: DBStore -> InvoiceId -> IO [(Text, Text, Maybe Text, Maybe Text)]
|
|
paymentIdentity st (InvoiceId iid) = withConnection st $ \db ->
|
|
DB.query db "SELECT payment_id, provider, provider_ref, currency FROM sx_badge_service_payments WHERE invoice_id = ?" (Only iid)
|
|
|
|
touchInvoice :: DBStore -> InvoiceId -> UTCTime -> IO ()
|
|
touchInvoice st (InvoiceId iid) at = withConnection st $ \db ->
|
|
DB.execute db "UPDATE sx_badge_service_invoices SET updated_at = ? WHERE invoice_id = ?" (at, iid)
|
|
|
|
-- | On SQLite the DBStore serialises access behind one connection, so a read taken through it
|
|
-- during a transaction would block rather than see uncommitted state; testPublishIsAfterCommit
|
|
-- needs this read to open its own connection instead.
|
|
independentStatus :: DBStore -> InvoiceId -> IO (Maybe Text)
|
|
#if defined(dbPostgres)
|
|
independentStatus st (InvoiceId iid) = withConnection st $ \db -> do
|
|
rows <- DB.query db "SELECT status FROM sx_badge_service_invoices WHERE invoice_id = ?" (Only iid)
|
|
pure $ case rows of
|
|
(Only s : _) -> Just s
|
|
[] -> Nothing
|
|
#else
|
|
independentStatus DBStore {dbFilePath} (InvoiceId iid) = E.bracket (SQL.open dbFilePath) SQL.close reader `E.catch` locked
|
|
where
|
|
reader c = do
|
|
rows <- SQL.query c "SELECT status FROM sx_badge_service_invoices WHERE invoice_id = ?" (Only iid)
|
|
pure $ case rows of
|
|
(Only s : _) -> Just s
|
|
[] -> Nothing
|
|
locked :: SQL.SQLError -> IO (Maybe Text)
|
|
locked _ = pure Nothing
|
|
#endif
|
|
|
|
-- | This trigger expires the invoice from inside the settling transaction, and it is a trigger
|
|
-- rather than a concurrent thread because the SQLite store puts every access behind one
|
|
-- connection and so cannot have a concurrent writer.
|
|
expireOnNextPayment :: DBStore -> IO ()
|
|
#if defined(dbPostgres)
|
|
expireOnNextPayment st = withConnection st $ \db -> do
|
|
DB.execute_
|
|
db
|
|
"CREATE FUNCTION sx_badge_service_test_expire() RETURNS trigger AS $$ BEGIN UPDATE sx_badge_service_invoices SET status = 'expired' WHERE invoice_id = NEW.invoice_id; RETURN NEW; END; $$ LANGUAGE plpgsql"
|
|
DB.execute_
|
|
db
|
|
"CREATE TRIGGER sx_badge_service_test_expire_trg AFTER INSERT ON sx_badge_service_payments FOR EACH ROW EXECUTE FUNCTION sx_badge_service_test_expire()"
|
|
#else
|
|
expireOnNextPayment st = withConnection st $ \db ->
|
|
DB.execute_
|
|
db
|
|
"CREATE TRIGGER sx_badge_service_test_expire AFTER INSERT ON sx_badge_service_payments BEGIN UPDATE sx_badge_service_invoices SET status = 'expired' WHERE invoice_id = NEW.invoice_id; END"
|
|
#endif
|
|
|
|
testSettlesAnOpenInvoice :: IO ()
|
|
testSettlesAnOpenInvoice = bounded "settles an open invoice" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigSettled (rcv 500 (Just "0.00050000")) settleAt) detectedAt `shouldReturn` Right ISPaid
|
|
invoiceStatus st iid `shouldReturn` ISPaid
|
|
p <- paymentRow st iid
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 500)
|
|
ipCryptoPaid p `shouldBe` Just "0.00050000"
|
|
ipStatus p `shouldBe` "settled"
|
|
ipUpdatedAt p `shouldBe` settleAt
|
|
ipUpdatedAt p `shouldNotBe` detectedAt
|
|
paymentIdentity st iid `shouldReturn` [("inv1", "crypto", Just (niProviderRef sampleInvoice), Just "usd")]
|
|
code <- settledCode st iid
|
|
bcPaymentStatus code `shouldBe` CPSPaid
|
|
bcExpiresAt code `shouldBe` Just (addUTCTime codeLifetime settleAt)
|
|
bcExpiresAt code `shouldNotBe` Just (addUTCTime codeLifetime detectedAt)
|
|
bcRevokedAt code `shouldBe` Nothing
|
|
|
|
testLateSettlementIsLegal :: IO ()
|
|
testLateSettlementIsLegal = bounded "late settlement" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
expireRow st iid
|
|
invoiceStatus st iid `shouldReturn` ISExpired
|
|
settle st iid (SigSettled (rcv 500 Nothing) settleAt) detectedAt `shouldReturn` Right ISPaid
|
|
invoiceStatus st iid `shouldReturn` ISPaid
|
|
ipStatus <$> paymentRow st iid `shouldReturn` "settled"
|
|
bcPaymentStatus <$> settledCode st iid `shouldReturn` CPSPaid
|
|
|
|
testFundedRecordsWithoutMoving :: IO ()
|
|
testFundedRecordsWithoutMoving = bounded "funded records only" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigFunded (rcv 200 (Just "0.00020000")) PaidInPart) settleAt `shouldReturn` Right ISOpen
|
|
invoiceStatus st iid `shouldReturn` ISOpen
|
|
p <- paymentRow st iid
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 200)
|
|
ipStatus p `shouldBe` "pending"
|
|
code <- settledCode st iid
|
|
bcPaymentStatus code `shouldBe` CPSUnpaid
|
|
bcExpiresAt code `shouldBe` Nothing
|
|
expireRow st iid
|
|
settle st iid (SigFunded (rcv 300 (Just "0.00030000")) PaidInPart) replayAt `shouldReturn` Right ISExpired
|
|
invoiceStatus st iid `shouldReturn` ISExpired
|
|
ipAmount <$> paymentRow st iid `shouldReturn` Just (CurrencyAmount 300)
|
|
bcPaymentStatus <$> settledCode st iid `shouldReturn` CPSUnpaid
|
|
|
|
testClosedExpiresAnOpenInvoice :: IO ()
|
|
testClosedExpiresAnOpenInvoice = bounded "closed expires" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigClosed (rcv 200 (Just "0.00020000"))) settleAt `shouldReturn` Right ISExpired
|
|
invoiceStatus st iid `shouldReturn` ISExpired
|
|
p <- paymentRow st iid
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 200)
|
|
ipStatus p `shouldBe` "pending"
|
|
bcPaymentStatus <$> settledCode st iid `shouldReturn` CPSUnpaid
|
|
|
|
testClosedWithNothingWritesNoPayment :: IO ()
|
|
testClosedWithNothingWritesNoPayment = bounded "closed on nothing" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigClosed (rcv 0 Nothing)) settleAt `shouldReturn` Right ISExpired
|
|
invoiceStatus st iid `shouldReturn` ISExpired
|
|
Just row <- getInvoice st iid
|
|
irPayment row `shouldBe` Nothing
|
|
bcPaymentStatus <$> settledCode st iid `shouldReturn` CPSUnpaid
|
|
|
|
testClosedReplayIsIdempotent :: IO ()
|
|
testClosedReplayIsIdempotent = bounded "closed replay" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigClosed (rcv 200 (Just "0.00020000"))) settleAt `shouldReturn` Right ISExpired
|
|
settle st iid (SigClosed (rcv 200 (Just "0.00020000"))) replayAt `shouldReturn` Right ISExpired
|
|
invoiceStatus st iid `shouldReturn` ISExpired
|
|
p <- paymentRow st iid
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 200)
|
|
ipCryptoPaid p `shouldBe` Just "0.00020000"
|
|
ipStatus p `shouldBe` "pending"
|
|
length <$> paymentIdentity st iid `shouldReturn` 1
|
|
|
|
testPaidRefusesEverySignal :: IO ()
|
|
testPaidRefusesEverySignal = bounded "paid refuses" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigSettled (rcv 500 (Just "0.00050000")) settleAt) detectedAt `shouldReturn` Right ISPaid
|
|
settled <- paymentRow st iid
|
|
deadline <- bcExpiresAt <$> settledCode st iid
|
|
mapM_
|
|
( \signal -> do
|
|
settle st iid signal replayAt `shouldReturn` Right ISPaid
|
|
(signal, ) <$> paymentRow st iid `shouldReturn` (signal, settled)
|
|
)
|
|
[ SigSettled (rcv 900 (Just "0.00090000")) replayAt,
|
|
SigFunded (rcv 900 (Just "0.00090000")) PaidInPart,
|
|
SigClosed (rcv 900 (Just "0.00090000"))
|
|
]
|
|
invoiceStatus st iid `shouldReturn` ISPaid
|
|
bcExpiresAt <$> settledCode st iid `shouldReturn` deadline
|
|
|
|
testAmountIsMonotonic :: IO ()
|
|
testAmountIsMonotonic = bounded "monotonic amount" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigFunded (rcv 40000 (Just "0.734")) PaidInPart) settleAt `shouldReturn` Right ISOpen
|
|
settle st iid (SigFunded (rcv 10000 (Just "0.180")) PaidInPart) replayAt `shouldReturn` Right ISOpen
|
|
p <- paymentRow st iid
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 40000)
|
|
ipCryptoPaid p `shouldBe` Just "0.734"
|
|
settle st iid (SigFunded (rcv 40000 (Just "0.734")) PaidInPart) replayAt `shouldReturn` Right ISOpen
|
|
settle st iid (SigFunded (rcv 40000 (Just "0.734")) PaidInPart) replayAt `shouldReturn` Right ISOpen
|
|
ipAmount <$> paymentRow st iid `shouldReturn` Just (CurrencyAmount 40000)
|
|
settle st iid (SigFunded (rcv 50000 (Just "0.900")) PaidInPart) replayAt `shouldReturn` Right ISOpen
|
|
p' <- paymentRow st iid
|
|
ipAmount p' `shouldBe` Just (CurrencyAmount 50000)
|
|
ipCryptoPaid p' `shouldBe` Just "0.900"
|
|
|
|
testDeadlineIsFromTheFirstSettlement :: IO ()
|
|
testDeadlineIsFromTheFirstSettlement = bounded "deadline from the first settlement" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigSettled (rcv 500 Nothing) settleAt) detectedAt `shouldReturn` Right ISPaid
|
|
let firstDeadline = addUTCTime codeLifetime settleAt
|
|
bcExpiresAt <$> settledCode st iid `shouldReturn` Just firstDeadline
|
|
withTransaction st $ \db -> markCodePaid db (niCodeHash sampleInvoice) (addUTCTime codeLifetime replayAt)
|
|
bcExpiresAt <$> settledCode st iid `shouldReturn` Just firstDeadline
|
|
bcPaymentStatus <$> settledCode st iid `shouldReturn` CPSPaid
|
|
|
|
testStatusGuardRefusesAStaleObservation :: IO ()
|
|
testStatusGuardRefusesAStaleObservation = bounded "stale observation" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
markPaid st iid
|
|
stale <- withTransaction st $ \db -> updateInvoiceStatus db iid ISOpen ISExpired settleAt
|
|
stale `shouldBe` False
|
|
invoiceStatus st iid `shouldReturn` ISPaid
|
|
let second' = sampleInvoice {niInvoiceId = InvoiceId "inv-2", niProviderRef = "p-2", niCodeHash = digestFixture 15}
|
|
createInvoiceRows st second' `shouldReturn` Right ()
|
|
moved <- withTransaction st $ \db -> updateInvoiceStatus db (niInvoiceId second') ISOpen ISExpired settleAt
|
|
moved `shouldBe` True
|
|
invoiceStatus st (niInvoiceId second') `shouldReturn` ISExpired
|
|
|
|
testUnknownInvoiceSettlesNothing :: IO ()
|
|
testUnknownInvoiceSettlesNothing = bounded "unknown invoice" $ withServiceStore $ \st -> do
|
|
_ <- seedOpen st
|
|
r <- settle st (InvoiceId "no-such-invoice") (SigSettled (rcv 500 Nothing) settleAt) settleAt
|
|
r `shouldSatisfy` isLeft
|
|
paymentIdentity st (InvoiceId "no-such-invoice") `shouldReturn` []
|
|
|
|
testPublishIsAfterCommit :: IO ()
|
|
testPublishIsAfterCommit = bounded "publish after commit" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
waiters <- newWaiters
|
|
seen <- newEmptyMVar
|
|
_ <- forkIO $ do
|
|
woken <- awaitStatus waiters iid ((\s -> (s, ("", False))) <$> invoiceStatus st iid) (ISOpen, ("", False)) waitCeiling
|
|
visible <- independentStatus st iid
|
|
putMVar seen (woken, visible)
|
|
threadDelay 100000
|
|
settleOrder st waiters iid (SigSettled (rcv 500 Nothing) settleAt) settleAt `shouldReturn` Right ISPaid
|
|
takeMVar seen `shouldReturn` (ISPaid, Just "paid")
|
|
|
|
waitCeiling :: Int
|
|
waitCeiling = 10 * 1000000
|
|
|
|
testSettlementWakesAHeldRequest :: IO ()
|
|
testSettlementWakesAHeldRequest = bounded "settlement wakes a hold" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
let settled =
|
|
settleOrder (weStore env) (weWaiters env) iid (SigSettled (rcv 500 (Just "0.00050000")) settleAt) settleAt
|
|
`shouldReturn` Right ISPaid
|
|
o <- wokenBy client iid settled "paid"
|
|
fieldOf o "amountPaid" `shouldBe` Just (J.Number 500)
|
|
|
|
testFutureSettledInstantIsRefused :: IO ()
|
|
testFutureSettledInstantIsRefused = bounded "future settled instant" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
let inMilliseconds = posixSecondsToUTCTime (1000 * utcTimeToPOSIXSeconds detectedAt)
|
|
settle (weStore env) iid (SigSettled (rcv 500 (Just "0.00050000")) inMilliseconds) detectedAt
|
|
`shouldReturn` Right ISPaid
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSPaid
|
|
bcExpiresAt code `shouldBe` Just (addUTCTime codeLifetime detectedAt)
|
|
o <- jsonObject =<< webGet client (invoicePath iid)
|
|
fieldOf o "settledAt" `shouldBe` Just (J.toJSON detectedAt)
|
|
|
|
testAbsurdSettledInstantIsRefused :: IO ()
|
|
testAbsurdSettledInstantIsRefused = bounded "absurd settled instant" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
let epochZero = posixSecondsToUTCTime 0
|
|
settle (weStore env) iid (SigSettled (rcv 500 (Just "0.00050000")) epochZero) detectedAt
|
|
`shouldReturn` Right ISPaid
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSPaid
|
|
bcExpiresAt code `shouldSatisfy` maybe False (> detectedAt)
|
|
o <- jsonObject =<< webGet client (invoicePath iid)
|
|
fieldOf o "status" `shouldBe` Just (J.String "paid")
|
|
fieldOf o "settledAt" `shouldBe` Just (J.toJSON detectedAt)
|
|
|
|
testSettledAtIsThePaymentRow :: IO ()
|
|
testSettledAtIsThePaymentRow = bounded "settledAt" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
settle (weStore env) iid (SigSettled (rcv 500 Nothing) settleAt) detectedAt `shouldReturn` Right ISPaid
|
|
o <- jsonObject =<< webGet client (invoicePath iid)
|
|
fieldOf o "status" `shouldBe` Just (J.String "paid")
|
|
fieldOf o "settledAt" `shouldBe` Just (J.toJSON settleAt)
|
|
fieldOf o "settledAt" `shouldNotBe` Just (J.toJSON someCreated)
|
|
fieldOf o "settledAt" `shouldNotBe` Just (J.toJSON detectedAt)
|
|
touchInvoice (weStore env) iid replayAt
|
|
o' <- jsonObject =<< webGet client (invoicePath iid)
|
|
fieldOf o' "settledAt" `shouldBe` Just (J.toJSON settleAt)
|
|
|
|
testLosingTheStatusGuardStillWakesTheHold :: IO ()
|
|
testLosingTheStatusGuardStillWakesTheHold = bounded "losing the guard" $ withWebAppHolding raceHold $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
expireOnNextPayment (weStore env)
|
|
_ <- wokenBy client iid (settledIntoAnExpiredRow env iid) "expired"
|
|
pure ()
|
|
|
|
settledIntoAnExpiredRow :: HasCallStack => WebEnv -> InvoiceId -> IO ()
|
|
settledIntoAnExpiredRow env iid = do
|
|
settleOrder (weStore env) (weWaiters env) iid (SigSettled (rcv 500 (Just "0.00050000")) settleAt) settleAt
|
|
`shouldReturn` Right ISExpired
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSUnpaid
|
|
bcExpiresAt code `shouldBe` Nothing
|
|
ipStatus <$> paymentRow (weStore env) iid `shouldReturn` "settled"
|
|
|
|
raceHold :: Int
|
|
raceHold = 3 * 1000000
|
|
|
|
testSettledPaymentIsNotDowngraded :: IO ()
|
|
testSettledPaymentIsNotDowngraded = bounded "settled is not downgraded" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
row <- getInvoice st iid >>= maybe (failWith "no invoice row") pure
|
|
withTransaction st $ \db -> upsertPayment db row PSSettled (CurrencyAmount 500) (Just "0.00050000") Nothing False settleAt
|
|
withTransaction st $ \db -> upsertPayment db row PSPending (CurrencyAmount 200) (Just "0.00020000") Nothing False replayAt
|
|
p <- paymentRow st iid
|
|
ipStatus p `shouldBe` "settled"
|
|
ipUpdatedAt p `shouldBe` settleAt
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 500)
|
|
ipCryptoPaid p `shouldBe` Just "0.00050000"
|
|
let second' = sampleInvoice {niInvoiceId = InvoiceId "inv-pending", niProviderRef = "p-pending", niCodeHash = digestFixture 16}
|
|
createInvoiceRows st second' `shouldReturn` Right ()
|
|
row' <- getInvoice st (niInvoiceId second') >>= maybe (failWith "no invoice row") pure
|
|
withTransaction st $ \db -> upsertPayment db row' PSPending (CurrencyAmount 100) Nothing Nothing False settleAt
|
|
withTransaction st $ \db -> upsertPayment db row' PSSettled (CurrencyAmount 500) (Just "0.00050000") Nothing False replayAt
|
|
p' <- paymentRow st (niInvoiceId second')
|
|
ipStatus p' `shouldBe` "settled"
|
|
ipUpdatedAt p' `shouldBe` replayAt
|
|
|
|
testCryptoAmountFillsInFromNull :: IO ()
|
|
testCryptoAmountFillsInFromNull = bounded "crypto fills in" $ withServiceStore $ \st -> do
|
|
iid <- seedOpen st
|
|
settle st iid (SigFunded (rcv 500 Nothing) PaidInPart) settleAt `shouldReturn` Right ISOpen
|
|
ipCryptoPaid <$> paymentRow st iid `shouldReturn` Nothing
|
|
settle st iid (SigSettled (rcv 500 (Just "0.00050000")) settleAt) detectedAt `shouldReturn` Right ISPaid
|
|
ipCryptoPaid <$> paymentRow st iid `shouldReturn` Just "0.00050000"
|
|
ipAmount <$> paymentRow st iid `shouldReturn` Just (CurrencyAmount 500)
|
|
|
|
pollerFor :: WebEnv -> IO PollerEnv
|
|
pollerFor WebEnv {weStore, weConfig, weWaiters, weHints, weProviders} =
|
|
newPollerEnv weStore weWaiters weHints weProviders (poll weConfig)
|
|
|
|
withStubPoller :: Int -> (IORef StubState -> PollerEnv -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withStubPoller = withStubPollerOf stubProvider
|
|
|
|
withStubPollerOf :: (IORef StubState -> Provider) -> Int -> (IORef StubState -> PollerEnv -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withStubPollerOf mkProvider hold action =
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-poller" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
ref <- newIORef (newStubState (Right sampleProviderInvoice))
|
|
let cfg = (testServiceConfig staticDir True) {btcpay = Just testBTCPayConfig}
|
|
withListener [mkProvider ref] True hold st cfg $ \env client -> do
|
|
poller <- pollerFor env
|
|
action ref poller env client
|
|
|
|
withFakePoller :: (FakeBTCPay -> PollerEnv -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withFakePoller = withFakePollerHolding holdMicros
|
|
|
|
withFakePollerHolding :: Int -> (FakeBTCPay -> PollerEnv -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withFakePollerHolding hold action =
|
|
withFakeBTCPay $ \fake ->
|
|
withServiceStore $ \st -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-poller-fake" $ \root -> do
|
|
staticDir <- prepareStaticDir root
|
|
seedCheckoutCatalog st
|
|
provider <- btcpayProvider (fbConfig fake)
|
|
let cfg = (testServiceConfig staticDir True) {btcpay = Just (fbConfig fake)}
|
|
withListener [provider] True hold st cfg $ \env client -> do
|
|
poller <- pollerFor env
|
|
action fake poller env client
|
|
|
|
withFakeProvider :: (FakeBTCPay -> Provider -> IO a) -> IO a
|
|
withFakeProvider action = withFakeBTCPay $ \fake -> btcpayProvider (fbConfig fake) >>= action fake
|
|
|
|
setSignals :: IORef StubState -> [(Text, PaymentSignal)] -> IO ()
|
|
setSignals ref signals = atomicModifyIORef' ref $ \s -> (s {ssInvoices = Map.fromList signals}, ())
|
|
|
|
clearCalls :: IORef StubState -> IO ()
|
|
clearCalls ref = atomicModifyIORef' ref $ \s -> (s {ssCalls = []}, ())
|
|
|
|
setSkipped :: IORef StubState -> [(Maybe Text, Text)] -> IO ()
|
|
setSkipped ref reasons = atomicModifyIORef' ref $ \s -> (s {ssSkipped = reasons}, ())
|
|
|
|
failList :: IORef StubState -> Maybe ProviderError -> IO ()
|
|
failList ref e = atomicModifyIORef' ref $ \s -> (s {ssListError = e}, ())
|
|
|
|
failRead :: IORef StubState -> Maybe ProviderError -> IO ()
|
|
failRead ref e = atomicModifyIORef' ref $ \s -> (s {ssReadError = e}, ())
|
|
|
|
seedOpenRef :: HasCallStack => DBStore -> Int -> Text -> UTCTime -> IO InvoiceId
|
|
seedOpenRef = seedOpenRefOf PPCrypto
|
|
|
|
seedOpenRefOf :: HasCallStack => PaymentProvider -> DBStore -> Int -> Text -> UTCTime -> IO InvoiceId
|
|
seedOpenRefOf provider st i providerRef expiresAt = do
|
|
-- The row is created at the current time because the poller reads only invoices inside its
|
|
-- recent settle window and sweeps anything older.
|
|
createdAt <- truncateToSecond <$> getCurrentTime
|
|
let n = T.pack (show i)
|
|
iid = InvoiceId ("inv-poll-" <> n)
|
|
ni =
|
|
sampleInvoice
|
|
{ niInvoiceId = iid,
|
|
niProviderRef = providerRef,
|
|
niCodeHash = digestFixture (fromIntegral i + 20),
|
|
niExpiresAt = expiresAt,
|
|
niCreatedAt = createdAt,
|
|
niProvider = provider
|
|
}
|
|
createInvoiceRows st ni `shouldReturn` Right ()
|
|
pure iid
|
|
|
|
seedOtherProvider :: HasCallStack => DBStore -> Int -> Text -> IO InvoiceId
|
|
seedOtherProvider st i providerRef = do
|
|
createdAt <- truncateToSecond <$> getCurrentTime
|
|
let n = T.pack (show i)
|
|
iid = InvoiceId ("inv-poll-" <> n)
|
|
ni =
|
|
sampleInvoice
|
|
{ niInvoiceId = iid,
|
|
niProviderRef = providerRef,
|
|
niCodeHash = digestFixture (fromIntegral i + 40),
|
|
niProvider = PPStripe,
|
|
niCreatedAt = createdAt
|
|
}
|
|
createInvoiceRows st ni `shouldReturn` Right ()
|
|
pure iid
|
|
|
|
settledSignal :: PaymentSignal
|
|
settledSignal = SigSettled (rcv 500 (Just "0.00050000")) settleAt
|
|
|
|
testSettlesWithNoWebhookAtAll :: IO ()
|
|
testSettlesWithNoWebhookAtAll = bounded "no webhook at all" $ withFakePoller $ \fake poller env client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "xmr" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
iid <- InvoiceId <$> stringField o "invoiceId"
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSUnpaid
|
|
Just row <- getInvoice (weStore env) iid
|
|
setInvoiceState fake (irProviderRef row) ["status" .= ("Settled" :: Text), "paymentMethodPaid" .= ("0.32095000" :: Text)]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSPaid
|
|
bcExpiresAt code `shouldSatisfy` isJust
|
|
p <- paymentRow (weStore env) iid
|
|
ipStatus p `shouldBe` "settled"
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 5400)
|
|
ipCryptoPaid p `shouldBe` Just "0.32095000"
|
|
|
|
testPassReadsWhatItAwaits :: IO ()
|
|
testPassReadsWhatItAwaits = bounded "reads what it awaits" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
a <- seedOpenRef (weStore env) 1 "p-1" someExpiry
|
|
b <- seedOpenRef (weStore env) 2 "p-2" someExpiry
|
|
c <- seedOpenRef (weStore env) 3 "p-3" someExpiry
|
|
setSignals ref [("p-1", settledSignal), ("p-2", settledSignal), ("p-3", settledSignal)]
|
|
runOnePass poller
|
|
stubCalls ref `shouldReturn` [StubRead "p-1", StubRead "p-2", StubRead "p-3", StubListOpen]
|
|
mapM (invoiceStatus (weStore env)) [a, b, c] `shouldReturn` [ISPaid, ISPaid, ISPaid]
|
|
|
|
testIdlePassAsksNothing :: IO ()
|
|
testIdlePassAsksNothing = bounded "idle pass" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 "p-1" someExpiry
|
|
setSignals ref [("p-1", settledSignal)]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
clearCalls ref
|
|
runOnePass poller
|
|
stubCalls ref `shouldReturn` []
|
|
|
|
testManyOpenInvoicesList :: IO ()
|
|
testManyOpenInvoicesList = bounded "many open" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
mapM_ (\i -> seedOpenRef (weStore env) i ("p-many-" <> tshow i) someExpiry) [1 .. readsPerPass + 1]
|
|
runOnePass poller
|
|
stubCalls ref `shouldReturn` [StubListOpen]
|
|
|
|
testNoProviderAccountsForNothing :: HasCallStack => IO ()
|
|
testNoProviderAccountsForNothing = bounded "no provider" $ withStubPoller raceHold $ \_ poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
let overdue i = seedOpenRef (weStore env) i ("p-none-" <> tshow i) (addUTCTime (negate (expiryGrace + 60)) now)
|
|
few <- mapM overdue [1 .. 3]
|
|
let blind = poller {peProviders = []}
|
|
runOnePass blind
|
|
mapM (invoiceStatus (weStore env)) few `shouldReturn` replicate 3 ISOpen
|
|
many <- mapM overdue [4 .. readsPerPass + 4]
|
|
runOnePass blind
|
|
mapM (invoiceStatus (weStore env)) (few <> many) `shouldReturn` replicate (length few + length many) ISOpen
|
|
|
|
testBulkListIsTheOnlyList :: HasCallStack => IO ()
|
|
testBulkListIsTheOnlyList = bounded "bulk lists once" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
mapM_ (\i -> seedOpenRef (weStore env) i ("p-cadence-" <> tshow i) someExpiry) [1 .. readsPerPass + 1]
|
|
setSignals ref [("p-cadence-" <> tshow i, settledSignal) | i <- [1 .. readsPerPass]]
|
|
runOnePass poller
|
|
stubCalls ref `shouldReturn` [StubListOpen]
|
|
clearCalls ref
|
|
runOnePass poller
|
|
calls <- stubCalls ref
|
|
filter (== StubListOpen) calls `shouldBe` []
|
|
|
|
testMixedProvidersHoldTheSweep :: HasCallStack => IO ()
|
|
testMixedProvidersHoldTheSweep = bounded "mixed providers" $ withStubPoller raceHold $ \_ poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
mapM_ (\i -> seedOpenRef (weStore env) i ("p-mixed-" <> tshow i) someExpiry) [1 .. readsPerPass + 1]
|
|
createdAt <- truncateToSecond <$> getCurrentTime
|
|
let iid = InvoiceId "inv-poll-stripe"
|
|
stripeRow =
|
|
sampleInvoice
|
|
{ niInvoiceId = iid,
|
|
niProviderRef = "p-stripe-mixed",
|
|
niCodeHash = digestFixture 99,
|
|
niProvider = PPStripe,
|
|
niExpiresAt = addUTCTime (negate (expiryGrace + 60)) now,
|
|
niCreatedAt = createdAt
|
|
}
|
|
createInvoiceRows (weStore env) stripeRow `shouldReturn` Right ()
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
|
|
testEverySignalSettles :: IO ()
|
|
testEverySignalSettles = bounded "every signal settles" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
paid <- seedOpenRef (weStore env) 1 "p-settled" someExpiry
|
|
funded <- seedOpenRef (weStore env) 2 "p-funded" someExpiry
|
|
closed <- seedOpenRef (weStore env) 3 "p-closed" someExpiry
|
|
setSignals
|
|
ref
|
|
[ ("p-settled", settledSignal),
|
|
("p-funded", SigFunded (rcv 200 (Just "0.00020000")) PaidInPart),
|
|
("p-closed", SigClosed (rcv 100 (Just "0.00010000")))
|
|
]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) paid `shouldReturn` ISPaid
|
|
invoiceStatus (weStore env) funded `shouldReturn` ISOpen
|
|
ipAmount <$> paymentRow (weStore env) funded `shouldReturn` Just (CurrencyAmount 200)
|
|
invoiceStatus (weStore env) closed `shouldReturn` ISExpired
|
|
ipCryptoPaid <$> paymentRow (weStore env) closed `shouldReturn` Just "0.00010000"
|
|
bcPaymentStatus <$> settledCode (weStore env) paid `shouldReturn` CPSPaid
|
|
bcPaymentStatus <$> settledCode (weStore env) funded `shouldReturn` CPSUnpaid
|
|
bcPaymentStatus <$> settledCode (weStore env) closed `shouldReturn` CPSUnpaid
|
|
|
|
testForeignRefIsPassedOver :: IO ()
|
|
testForeignRefIsPassedOver = bounded "foreign ref" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
ours <- seedOpenRef (weStore env) 1 "p-1" someExpiry
|
|
otherLane <- seedOtherProvider (weStore env) 2 "p-stripe"
|
|
setSignals ref [("p-1", settledSignal), ("someone-elses-invoice", settledSignal), ("p-stripe", settledSignal)]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) ours `shouldReturn` ISPaid
|
|
invoiceStatus (weStore env) otherLane `shouldReturn` ISOpen
|
|
invoiceCount (weStore env) `shouldReturn` 2
|
|
|
|
testProviderFailureLosesNothing :: IO ()
|
|
testProviderFailureLosesNothing = bounded "provider outage" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 "p-1" someExpiry
|
|
setSignals ref [("p-1", settledSignal)]
|
|
failList ref (Just (ProviderError "connection refused"))
|
|
failRead ref (Just (ProviderError "connection refused"))
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
Just row <- getInvoice (weStore env) iid
|
|
irPayment row `shouldBe` Nothing
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSUnpaid
|
|
failList ref Nothing
|
|
failRead ref Nothing
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
stubCalls ref `shouldReturn` [StubRead "p-1", StubListOpen, StubRead "p-1"]
|
|
|
|
testCadenceFollowsTheWaiters :: IO ()
|
|
testCadenceFollowsTheWaiters = bounded "cadence" $ withStubPoller raceHold $ \_ poller env client -> do
|
|
iid <- seedOpenInvoice env
|
|
let PollConfig {pWaitingSeconds, pIdleSeconds} = poll (weConfig env)
|
|
pWaitingSeconds `shouldNotBe` pIdleSeconds
|
|
waitingCount (weWaiters env) `shouldReturn` 0
|
|
passDelayNow poller `shouldReturn` (pIdleSeconds * 1000000)
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open")
|
|
awaitWaitingCount env 1
|
|
passDelayNow poller `shouldReturn` (pWaitingSeconds * 1000000)
|
|
waitingCount (weWaiters env) `shouldReturn` 1
|
|
markPaidAndPublish env iid
|
|
_ <- wait held
|
|
passDelayNow poller `shouldReturn` (pIdleSeconds * 1000000)
|
|
|
|
passDelayNow :: PollerEnv -> IO Int
|
|
passDelayNow PollerEnv {peWaiters, pePoll} = passDelay pePoll <$> waitingCount peWaiters
|
|
|
|
testHintsDoNotPostponeThePass :: IO ()
|
|
testHintsDoNotPostponeThePass = bounded "hints do not postpone the pass" $ withStubPoller raceHold $ \ref _ env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
mapM_ (\i -> seedOpenRef (weStore env) i (burstRef i) someExpiry) burstRefs
|
|
built <- newPollerEnv (weStore env) (weWaiters env) (weHints env) (weProviders env) PollConfig {pWaitingSeconds = 1, pIdleSeconds = 1}
|
|
let poller = built {peStrayEvery = 0}
|
|
setSignals ref [(burstRef i, settledSignal) | i <- burstRefs]
|
|
runOnePass poller
|
|
clearCalls ref
|
|
setReadDelay ref slowHintRead
|
|
mapM_ (\i -> queueReadHint (weHints env) (burstRef i) `shouldReturn` True) burstRefs
|
|
Async.withAsync (runPoller poller) $ \_ -> do
|
|
calls <- awaitCalls ref (\cs -> length (filter (== StubListOpen) cs) >= 2)
|
|
length (filter isRead calls) `shouldSatisfy` (< length burstRefs)
|
|
where
|
|
isRead = \case
|
|
StubRead _ -> True
|
|
_ -> False
|
|
|
|
burstRefs :: [Int]
|
|
burstRefs = [1 .. 10]
|
|
|
|
burstRef :: Int -> Text
|
|
burstRef i = "p-burst-" <> T.pack (show i)
|
|
|
|
slowHintRead :: Int
|
|
slowHintRead = 300000
|
|
|
|
awaitCalls :: IORef StubState -> ([StubCall] -> Bool) -> IO [StubCall]
|
|
awaitCalls ref p = do
|
|
calls <- stubCalls ref
|
|
if p calls then pure calls else threadDelay 10000 >> awaitCalls ref p
|
|
|
|
testWaiterCutsTheIdleSleepShort :: IO ()
|
|
testWaiterCutsTheIdleSleepShort = bounded "waiter cuts the sleep" $ withStubPoller raceHold $ \_ _ env client -> do
|
|
iid <- seedOpenInvoice env
|
|
poller <- newPollerEnv (weStore env) (weWaiters env) (weHints env) (weProviders env) PollConfig {pWaitingSeconds = 1, pIdleSeconds = 10}
|
|
started <- getCurrentTime
|
|
slept <- async (passDue poller >>= atomically)
|
|
threadDelay 200000
|
|
Async.poll slept >>= (`shouldSatisfy` isNothing)
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open")
|
|
wait slept
|
|
elapsed <- (`diffUTCTime` started) <$> getCurrentTime
|
|
elapsed `shouldSatisfy` (< 5)
|
|
elapsed `shouldSatisfy` (>= 0.9)
|
|
markPaidAndPublish env iid
|
|
_ <- wait held
|
|
pure ()
|
|
|
|
testCadenceHasAFloor :: IO ()
|
|
testCadenceHasAFloor = do
|
|
let zeroed = PollConfig {pWaitingSeconds = 0, pIdleSeconds = 0}
|
|
negated = PollConfig {pWaitingSeconds = -5, pIdleSeconds = -60}
|
|
map (passDelay zeroed) [1, 0] `shouldBe` [1000000, 1000000]
|
|
map (passDelay negated) [1, 0] `shouldBe` [1000000, 1000000]
|
|
map (passDelay PollConfig {pWaitingSeconds = 3, pIdleSeconds = 60}) [1, 0] `shouldBe` [3000000, 60000000]
|
|
|
|
testPollerWakesAHeldRequest :: IO ()
|
|
testPollerWakesAHeldRequest = bounded "poller wakes a hold" $ withStubPoller raceHold $ \ref poller env client -> do
|
|
iid <- seedOpenInvoice env
|
|
setSignals ref [(niProviderRef sampleInvoice, settledSignal)]
|
|
_ <- wokenBy client iid (runOnePass poller) "paid"
|
|
pure ()
|
|
|
|
testSweepExpiresPastTheGrace :: IO ()
|
|
testSweepExpiresPastTheGrace = bounded "expiry sweep" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
overdue <- seedOpenRef (weStore env) 1 "p-overdue" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
inGrace <- seedOpenRef (weStore env) 2 "p-ingrace" (addUTCTime (negate (expiryGrace - 60)) now)
|
|
ahead <- seedOpenRef (weStore env) 3 "p-ahead" (addUTCTime 3600 now)
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) overdue `shouldReturn` ISExpired
|
|
invoiceStatus (weStore env) inGrace `shouldReturn` ISOpen
|
|
invoiceStatus (weStore env) ahead `shouldReturn` ISOpen
|
|
stubCalls ref `shouldReturn` [StubRead "p-overdue", StubRead "p-ingrace", StubRead "p-ahead", StubListOpen]
|
|
|
|
testSweepWritesStatusAlone :: IO ()
|
|
testSweepWritesStatusAlone = bounded "sweep writes status alone" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 "p-1" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
Just row <- getInvoice (weStore env) iid
|
|
irPayment row `shouldBe` Nothing
|
|
setSignals ref [("p-1", SigClosed (rcv 200 (Just "0.00020000")))]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
p <- paymentRow (weStore env) iid
|
|
ipAmount p `shouldBe` Just (CurrencyAmount 200)
|
|
ipCryptoPaid p `shouldBe` Just "0.00020000"
|
|
|
|
testSweepWakesAHeldRequest :: IO ()
|
|
testSweepWakesAHeldRequest = bounded "sweep wakes a hold" $ withStubPoller raceHold $ \_ poller env client -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 "p-overdue" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
_ <- wokenBy client iid (runOnePass poller) "expired"
|
|
pure ()
|
|
|
|
withPayableStubPoller :: Int -> (IORef StubState -> PollerEnv -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withPayableStubPoller = withStubPollerOf (\ref -> (stubProvider ref) {pProvider = PPStripe})
|
|
|
|
-- | Creates an order older than 72 hours, which only the expiry sweep still looks at. A higher i is newer.
|
|
seedPastWindow :: HasCallStack => DBStore -> Int -> Text -> PaymentProvider -> IO InvoiceId
|
|
seedPastWindow st i providerRef provider = do
|
|
now <- truncateToSecond <$> getCurrentTime
|
|
let createdAt = addUTCTime (negate (settleWindow + 3600) + fromIntegral i) now
|
|
iid = InvoiceId ("inv-old-" <> T.pack (show i))
|
|
ni = sampleInvoice {niInvoiceId = iid, niProviderRef = providerRef, niProvider = provider, niCodeHash = digestFixture (fromIntegral i + 60), niCreatedAt = createdAt, niExpiresAt = addUTCTime 3600 createdAt}
|
|
createInvoiceRows st ni `shouldReturn` Right ()
|
|
pure iid
|
|
|
|
-- | Runs one pass to get the list call out of the way, so the next pass only does the expiry sweep.
|
|
passListing :: IORef StubState -> PollerEnv -> IO ()
|
|
passListing ref poller = runOnePass poller >> clearCalls ref
|
|
|
|
stderrCaptureLock :: MVar ()
|
|
stderrCaptureLock = unsafePerformIO $ newMVar ()
|
|
{-# NOINLINE stderrCaptureLock #-}
|
|
|
|
capturingStderr :: IO () -> IO Text
|
|
capturingStderr action = withMVar stderrCaptureLock $ \_ -> do
|
|
createDirectoryIfMissing True "tests/tmp"
|
|
withTempDirectory "tests/tmp" "badge-stderr" $ \dir -> do
|
|
let path = dir </> "stderr.log"
|
|
withFile path WriteMode $ \h -> do
|
|
fd <- handleToFd h
|
|
hFlush stderr
|
|
saved <- IODevice.dup FD.stderr
|
|
(IODevice.dup2 fd FD.stderr >> action) `E.finally` (hFlush stderr >> IODevice.dup2 saved FD.stderr >> IODevice.close saved)
|
|
T.readFile path
|
|
|
|
loggedError :: Text -> Text -> Bool
|
|
loggedError message = any (\l -> "[ERROR " `T.isInfixOf` l && message `T.isInfixOf` l) . T.lines
|
|
|
|
failCancels :: IORef StubState -> IO ()
|
|
failCancels ref = atomicModifyIORef' ref $ \s -> (s {ssCancelError = Just (ProviderError "refused")}, ())
|
|
|
|
testSweepCancelsBeforeExpiring :: IO ()
|
|
testSweepCancelsBeforeExpiring = bounded "sweep cancels" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
overdue <- seedOpenRefOf PPStripe (weStore env) 1 "p-overdue" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
ahead <- seedOpenRefOf PPStripe (weStore env) 2 "p-ahead" (addUTCTime 3600 now)
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) overdue `shouldReturn` ISExpired
|
|
invoiceStatus (weStore env) ahead `shouldReturn` ISOpen
|
|
stubCalls ref `shouldReturn` [StubRead "p-overdue", StubRead "p-ahead", StubListOpen, StubCancel "p-overdue"]
|
|
|
|
testSweepKeepsOpenWhenCancelFails :: IO ()
|
|
testSweepKeepsOpenWhenCancelFails = bounded "sweep cancel fails" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRefOf PPStripe (weStore env) 1 "p-overdue" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
failCancels ref
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
atomicModifyIORef' ref $ \s -> (s {ssCancelError = Nothing}, ())
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
filter (== StubCancel "p-overdue") <$> stubCalls ref `shouldReturn` [StubCancel "p-overdue", StubCancel "p-overdue"]
|
|
|
|
testSweepSettlesWhatItCannotCancel :: IO ()
|
|
testSweepSettlesWhatItCannotCancel = bounded "sweep settles uncancelled" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
passListing ref poller
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedPastWindow (weStore env) 1 "p-old" PPStripe
|
|
failCancels ref
|
|
setSignals ref [("p-old", settledSignal)]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
stubCalls ref `shouldReturn` [StubCancel "p-old", StubRead "p-old"]
|
|
|
|
testSweepExpiresWhatAReadShowsCancelled :: IO ()
|
|
testSweepExpiresWhatAReadShowsCancelled = bounded "sweep reads a cancel" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRefOf PPStripe (weStore env) 1 "p-overdue" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
atomicModifyIORef' ref $ \s -> (s {ssCancelLost = True}, ())
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
stubCalls ref `shouldReturn` [StubRead "p-overdue", StubListOpen, StubCancel "p-overdue", StubRead "p-overdue"]
|
|
|
|
testSweepGivesUpPastTheWindow :: IO ()
|
|
testSweepGivesUpPastTheWindow = bounded "sweep gives up" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
passListing ref poller
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedPastWindow (weStore env) 1 "p-old" PPStripe
|
|
failCancels ref
|
|
firstLog <- capturingStderr (runOnePass poller >> runOnePass poller)
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
firstLog `shouldNotSatisfy` T.isInfixOf "expired here only"
|
|
failedAnHourAgo poller "p-old"
|
|
lastLog <- capturingStderr (runOnePass poller)
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
loggedError "invoice p-old could not be cancelled at the provider, so it was expired here only" lastLog `shouldBe` True
|
|
let attempt = [StubCancel "p-old", StubRead "p-old"]
|
|
stubCalls ref `shouldReturn` concat (replicate 3 attempt)
|
|
runOnePass poller
|
|
stubCalls ref `shouldReturn` concat (replicate 3 attempt)
|
|
|
|
testSweepExpiresWithoutItsProvider :: IO ()
|
|
testSweepExpiresWithoutItsProvider = bounded "sweep without provider" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
passListing ref poller
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedPastWindow (weStore env) 1 "p-old" PPStripe
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
failedAnHourAgo poller "p-old"
|
|
logged <- capturingStderr (runOnePass poller)
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
loggedError "no provider is configured for it" logged `shouldBe` True
|
|
stubCalls ref `shouldReturn` []
|
|
|
|
testSweepKeepsFailuresBeyondTheCap :: IO ()
|
|
testSweepKeepsFailuresBeyondTheCap = bounded "sweep keeps failures past the cap" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
passListing ref poller
|
|
seedBadgePrice (weStore env) "price1"
|
|
let count = readsPerPass + 1
|
|
newest = "p-" <> T.pack (show count)
|
|
mapM_ (\i -> seedPastWindow (weStore env) i ("p-" <> T.pack (show i)) PPStripe) [1 .. count]
|
|
failCancels ref
|
|
failedAnHourAgo poller newest
|
|
runOnePass poller
|
|
stubCalls ref >>= (`shouldNotSatisfy` elem (StubCancel newest))
|
|
Map.member newest <$> readTVarIO (peCancelFailed poller) `shouldReturn` True
|
|
|
|
-- | Pretends the first cancel failure was an hour ago, so the test doesn't have to wait.
|
|
failedAnHourAgo :: PollerEnv -> Text -> IO ()
|
|
failedAnHourAgo PollerEnv {peCancelFailed} ref = do
|
|
now <- getCurrentTime
|
|
atomically $ modifyTVar' peCancelFailed (Map.insert ref (addUTCTime (negate cancelGiveUpDelay) now))
|
|
|
|
testSweepWarnsOncePerOrder :: IO ()
|
|
testSweepWarnsOncePerOrder = bounded "sweep warns once" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRefOf PPStripe (weStore env) 1 "p-overdue" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
failCancels ref
|
|
let warnings = Map.filterWithKey (\k _ -> "p-overdue" `T.isInfixOf` k) <$> readTVarIO (peSkipped poller)
|
|
runOnePass poller
|
|
first <- warnings
|
|
Map.size first `shouldBe` 1
|
|
runOnePass poller
|
|
warnings `shouldReturn` first
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
|
|
testSweepCapsCancelsPerPass :: IO ()
|
|
testSweepCapsCancelsPerPass = bounded "sweep caps cancels" $ withPayableStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
let count = readsPerPass + 1
|
|
iids <- mapM (\i -> seedOpenRefOf PPStripe (weStore env) i ("p-" <> T.pack (show i)) (addUTCTime (negate (expiryGrace + 60)) now)) [1 .. count]
|
|
let cancels = length . filter isCancel <$> stubCalls ref
|
|
isCancel = \case
|
|
StubCancel _ -> True
|
|
_ -> False
|
|
expiredCount = length . filter (== ISExpired) <$> mapM (invoiceStatus (weStore env)) iids
|
|
runOnePass poller
|
|
cancels `shouldReturn` readsPerPass
|
|
expiredCount `shouldReturn` readsPerPass
|
|
runOnePass poller
|
|
cancels `shouldReturn` count
|
|
expiredCount `shouldReturn` count
|
|
|
|
testSkipWarningsAreRateLimited :: IO ()
|
|
testSkipWarningsAreRateLimited = bounded "skip warnings" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
let reason = "btcpay invoice SOMEONEELSESINVOICE: unknown status Frobnicated"
|
|
onwards t = addUTCTime t settleAt
|
|
dueToWarn poller settleAt reason `shouldReturn` True
|
|
dueToWarn poller (onwards 3) reason `shouldReturn` False
|
|
dueToWarn poller (onwards (skipWarnInterval - 1)) reason `shouldReturn` False
|
|
dueToWarn poller (onwards skipWarnInterval) reason `shouldReturn` True
|
|
dueToWarn poller (onwards skipWarnInterval) "btcpay invoice OTHER: unknown status Frobnicated" `shouldReturn` True
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 "p-1" someExpiry
|
|
setSkipped ref [(Just "SOMEONEELSESINVOICE", reason)]
|
|
setSignals ref [("p-1", settledSignal)]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
|
|
testSkipNamingOurInvoiceIsRaised :: IO ()
|
|
testSkipNamingOurInvoiceIsRaised = bounded "skip ownership" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
_ <- seedOpenRef (weStore env) 1 "p-ours" someExpiry
|
|
skipOwner poller PPCrypto (Just "p-ours") `shouldReturn` SkipOurs
|
|
skipOwner poller PPCrypto (Just "SOMEONEELSESINVOICE") `shouldReturn` SkipStranger
|
|
skipOwner poller PPCrypto Nothing `shouldReturn` SkipUnaccounted
|
|
_ <- seedOtherProvider (weStore env) 3 "p-stripe-skip"
|
|
skipOwner poller PPCrypto (Just "p-stripe-skip") `shouldReturn` SkipStranger
|
|
iid <- seedOpenRef (weStore env) 2 "p-2" someExpiry
|
|
setSkipped
|
|
ref
|
|
[ (Just "p-ours", "btcpay invoice p-ours: unknown status Frobnicated"),
|
|
(Just "SOMEONEELSESINVOICE", "btcpay invoice SOMEONEELSESINVOICE: unknown status Frobnicated")
|
|
]
|
|
setSignals ref [("p-2", settledSignal)]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
|
|
testSkipReasonsStayBounded :: IO ()
|
|
testSkipReasonsStayBounded = bounded "skip reasons bounded" $ withStubPoller raceHold $ \_ poller _ _ -> do
|
|
now <- getCurrentTime
|
|
forM_ [1 .. maxSkipReasons + 500] $ \i -> dueToWarn poller now ("btcpay invoice p-" <> tshow i <> ": unknown method")
|
|
runOnePass poller
|
|
held <- Map.size <$> readTVarIO (peSkipped poller)
|
|
held `shouldSatisfy` (<= maxSkipReasons)
|
|
|
|
testStrangerSkipsShareOneWarning :: IO ()
|
|
testStrangerSkipsShareOneWarning = bounded "stranger skips" $ withStubPoller raceHold $ \ref poller _ _ -> do
|
|
setSkipped ref [(Just ("stranger-" <> tshow i), "btcpay invoice stranger-" <> tshow i <> ": unknown method") | i <- [1 .. 3 :: Int]]
|
|
runOnePass poller
|
|
Map.size <$> readTVarIO (peSkipped poller) `shouldReturn` 1
|
|
|
|
-- | The failure text carries the whole request, whose window moves with the clock, so the two
|
|
-- messages differ only in startDate to prove the limiter key ignores it rather than warning
|
|
-- every pass.
|
|
testOutageWarnsOnceNotEveryPass :: IO ()
|
|
testOutageWarnsOnceNotEveryPass = bounded "outage warnings" $ withStubPoller raceHold $ \ref poller _ _ -> do
|
|
let warnKeys PollerEnv {peSkipped} = Map.size <$> readTVarIO peSkipped
|
|
failList ref (Just (ProviderError "list invoices failed: ... startDate=1000000 ..."))
|
|
runOnePass poller
|
|
warnKeys poller `shouldReturn` 1
|
|
failList ref (Just (ProviderError "list invoices failed: ... startDate=1000003 ..."))
|
|
runOnePass poller
|
|
warnKeys poller `shouldReturn` 1
|
|
|
|
testOneBadInvoiceDoesNotStopThePass :: IO ()
|
|
testOneBadInvoiceDoesNotStopThePass = bounded "one bad invoice" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
InvoiceId badId <- seedOpenRef (weStore env) 1 "p-bad" (addUTCTime 3600 now)
|
|
good <- seedOpenRef (weStore env) 2 "p-good" (addUTCTime 3600 now)
|
|
overdue <- seedOpenRef (weStore env) 3 "p-overdue" (addUTCTime (negate (expiryGrace + 60)) now)
|
|
-- Setting the provider to a value this build does not know makes reading the row throw rather
|
|
-- than answer.
|
|
withConnection (weStore env) $ \db ->
|
|
DB.execute db "UPDATE sx_badge_service_invoices SET provider = ? WHERE invoice_id = ?" ("nonsense" :: Text, badId)
|
|
|
|
setSignals ref [("p-bad", settledSignal), ("p-good", settledSignal)]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) good `shouldReturn` ISPaid
|
|
invoiceStatus (weStore env) overdue `shouldReturn` ISOpen
|
|
|
|
setSignals ref []
|
|
setSkipped ref [(Just "p-bad", "some reason the provider gave")]
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) overdue `shouldReturn` ISOpen
|
|
|
|
testSweepWaitsForAPassThatSawEverything :: IO ()
|
|
testSweepWaitsForAPassThatSawEverything = bounded "sweep guards" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
now <- getCurrentTime
|
|
seedBadgePrice (weStore env) "price1"
|
|
overdue <- seedOpenRef (weStore env) 1 "p-overdue" (addUTCTime (-3600) now)
|
|
let stillOpen :: Text -> IO ()
|
|
stillOpen why = do
|
|
runOnePass poller
|
|
(why, ) <$> invoiceStatus (weStore env) overdue `shouldReturn` (why, ISOpen)
|
|
failRead ref (Just (ProviderError "connection refused"))
|
|
stillOpen "the reads never answered"
|
|
failRead ref Nothing
|
|
mapM_ (\i -> seedOpenRef (weStore env) i ("p-bulk-" <> tshow i) someExpiry) [2 .. readsPerPass + 2]
|
|
setSkipped ref [(Just "p-overdue", "btcpay invoice p-overdue: unknown status Frobnicated")]
|
|
stillOpen "it answered and left one of ours unread"
|
|
setSkipped ref [(Nothing, "btcpay: the list stopped at 50 pages")]
|
|
stillOpen "it stopped early without saying whose invoices it missed"
|
|
setSkipped ref []
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) overdue `shouldReturn` ISExpired
|
|
|
|
testListPaginates :: IO ()
|
|
testListPaginates = bounded "paging" $ withFakeProvider $ \fake p -> do
|
|
refs <- replicateM (listPageSize + 1) (piProviderRef <$> createdAtFake p)
|
|
case reverse refs of
|
|
[] -> failWith "no invoices were created"
|
|
(onSecondPage : _) -> do
|
|
setInvoiceState fake onSecondPage ["status" .= ("Settled" :: Text), "paymentMethodPaid" .= ("0.00050000" :: Text)]
|
|
pListOpen p >>= \case
|
|
Left e -> failWith ("expected a list, got " <> show e)
|
|
Right ListPass {lpMoved} -> map fst lpMoved `shouldSatisfy` elem onSecondPage
|
|
gets <- apiRequests fake "GET" ["invoices"]
|
|
let pageOf FakeRequest {frQuery} = (join (lookup "take" frQuery), join (lookup "skip" frQuery))
|
|
size = BC.pack (show listPageSize)
|
|
map pageOf gets `shouldBe` [(Just size, Just "0"), (Just size, Just size)]
|
|
|
|
testPagingStopsAtTheCeiling :: IO ()
|
|
testPagingStopsAtTheCeiling = bounded "paging ceiling" $ withFakeProvider $ \fake p -> do
|
|
ignoreListPaging fake
|
|
replicateM_ listPageSize (void (createdAtFake p))
|
|
r <- pListOpen p
|
|
gets <- apiRequests fake "GET" ["invoices"]
|
|
length gets `shouldBe` maxListPages
|
|
case r of
|
|
Left e -> failWith ("expected a list, got " <> show e)
|
|
Right ListPass {lpSkipped} -> do
|
|
map snd lpSkipped `shouldSatisfy` any (T.isInfixOf "stopped at")
|
|
map snd lpSkipped `shouldSatisfy` any (T.isInfixOf (T.pack (show (maxListPages * listPageSize))))
|
|
map fst lpSkipped `shouldSatisfy` all isNothing
|
|
|
|
createdAtFake :: HasCallStack => Provider -> IO ProviderInvoice
|
|
createdAtFake p =
|
|
pCreateInvoice p (SPMCrypto CCBtc) sampleDraft >>= \case
|
|
Right inv -> pure inv
|
|
Left e -> failWith ("expected an invoice at the fake, got " <> show e)
|
|
|
|
webhookPath :: String
|
|
webhookPath = "/webhooks/btcpay"
|
|
|
|
hookRef :: Text
|
|
hookRef = "p-hooked"
|
|
|
|
hookedEvent :: LB.ByteString
|
|
hookedEvent = webhookEvent "InvoiceSettled" hookRef
|
|
|
|
someSig :: [Header]
|
|
someSig = webhookSigHeader "not-the-secret" hookedEvent
|
|
|
|
postWebhook :: WebClient -> [Header] -> LB.ByteString -> IO (Response LB.ByteString)
|
|
postWebhook client hdrs = webRequestBody client "POST" webhookPath hdrs
|
|
|
|
slowProviderRead :: Int
|
|
slowProviderRead = 1000000
|
|
|
|
testWebhookVerifiesARealSignature :: IO ()
|
|
testWebhookVerifiesARealSignature = bounded "real signature" $ withFakePoller $ \fake poller env client -> do
|
|
r <- postCreateAs client 1 (createBody supporterPriceId Nothing "xmr" (codeHashText sampleCode))
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
iid <- InvoiceId <$> stringField o "invoiceId"
|
|
Just row <- getInvoice (weStore env) iid
|
|
let secret = bWebhookSecret (fbConfig fake)
|
|
body = webhookEvent "InvoiceSettled" (irProviderRef row)
|
|
delivered <- postWebhook client (webhookSigHeader secret body) body
|
|
statusOf delivered `shouldBe` 200
|
|
responseBody delivered `shouldBe` ""
|
|
tampered <- postWebhook client (webhookSigHeader secret body) (body <> " ")
|
|
statusOf tampered `shouldBe` 400
|
|
responseBody tampered `shouldBe` ""
|
|
unsigned <- postWebhook client [] body
|
|
statusOf unsigned `shouldBe` 400
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
setInvoiceState fake (irProviderRef row) ["status" .= ("Settled" :: Text), "paymentMethodPaid" .= ("0.32095000" :: Text)]
|
|
drainHints poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSPaid
|
|
|
|
testWebhookPassesTheRawBytes :: IO ()
|
|
testWebhookPassesTheRawBytes = bounded "raw bytes" $ withStubPoller raceHold $ \ref _ env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
_ <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
setVerifyResult ref (Right (Just hookRef))
|
|
let hdrs = webhookSigHeader "any-secret" hookedEvent
|
|
r <- postWebhook client hdrs hookedEvent
|
|
statusOf r `shouldBe` 200
|
|
stubWebhooks ref >>= \case
|
|
[(seenHdrs, seenBody)] -> do
|
|
seenBody `shouldBe` LB.toStrict hookedEvent
|
|
lookup "BTCPay-Sig" seenHdrs `shouldBe` lookup "BTCPay-Sig" hdrs
|
|
other -> failWith ("expected one verify call, got " <> show (length other))
|
|
case J.decode hookedEvent :: Maybe J.Value of
|
|
Nothing -> failWith "the event body is not JSON"
|
|
Just parsed -> J.encode parsed `shouldSatisfy` (/= hookedEvent)
|
|
|
|
testWebhookQueuesARead :: IO ()
|
|
testWebhookQueuesARead = bounded "webhook queues" $ withStubPoller raceHold $ \ref _ env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
setVerifyResult ref (Right (Just hookRef))
|
|
r <- postWebhook client someSig hookedEvent
|
|
statusOf r `shouldBe` 200
|
|
responseBody r `shouldBe` ""
|
|
headerOf r hCacheControl `shouldBe` Just "no-store"
|
|
stubCalls ref `shouldReturn` []
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
Just row <- getInvoice (weStore env) iid
|
|
irPayment row `shouldBe` Nothing
|
|
|
|
testWebhookSettlesByThePollerPath :: IO ()
|
|
testWebhookSettlesByThePollerPath = bounded "one settlement lane" $ withStubPoller raceHold $ \ref poller env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
hinted <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
listed <- seedOpenRef (weStore env) 2 "p-listed" someExpiry
|
|
setSignals ref [(hookRef, settledSignal), ("p-listed", settledSignal)]
|
|
setVerifyResult ref (Right (Just hookRef))
|
|
r <- postWebhook client someSig hookedEvent
|
|
statusOf r `shouldBe` 200
|
|
invoiceStatus (weStore env) hinted `shouldReturn` ISOpen
|
|
drainHints poller
|
|
stubCalls ref `shouldReturn` [StubRead hookRef]
|
|
invoiceStatus (weStore env) hinted `shouldReturn` ISPaid
|
|
bcPaymentStatus <$> settledCode (weStore env) hinted `shouldReturn` CPSPaid
|
|
ipStatus <$> paymentRow (weStore env) hinted `shouldReturn` "settled"
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) listed `shouldReturn` ISPaid
|
|
bcPaymentStatus <$> settledCode (weStore env) listed `shouldReturn` CPSPaid
|
|
ipStatus <$> paymentRow (weStore env) listed `shouldReturn` "settled"
|
|
|
|
testWebhookDoesNotWaitOnTheProvider :: IO ()
|
|
testWebhookDoesNotWaitOnTheProvider = bounded "webhook does not wait" $ withStubPoller raceHold $ \ref _ env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
_ <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
setSignals ref [(hookRef, settledSignal)]
|
|
setVerifyResult ref (Right (Just hookRef))
|
|
setReadDelay ref slowProviderRead
|
|
started <- getCurrentTime
|
|
r <- postWebhook client someSig hookedEvent
|
|
elapsed <- (`diffUTCTime` started) <$> getCurrentTime
|
|
statusOf r `shouldBe` 200
|
|
elapsed `shouldSatisfy` (< 0.5)
|
|
stubCalls ref `shouldReturn` []
|
|
|
|
testWebhookRefusesASignature :: IO ()
|
|
testWebhookRefusesASignature = bounded "webhook refuses" $ withStubPoller raceHold $ \ref poller env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
setSignals ref [(hookRef, settledSignal)]
|
|
setVerifyResult ref (Left (WebhookError "BTCPay-Sig does not verify"))
|
|
r <- postWebhook client someSig hookedEvent
|
|
statusOf r `shouldBe` 400
|
|
responseBody r `shouldBe` ""
|
|
drainHints poller
|
|
stubCalls ref `shouldReturn` []
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
|
|
testWebhookIgnoresWhatItCannotActOn :: IO ()
|
|
testWebhookIgnoresWhatItCannotActOn = bounded "webhook ignores" $ withStubPoller raceHold $ \ref poller env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
ours <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
otherLane <- seedOtherProvider (weStore env) 2 "p-stripe"
|
|
setSignals ref [(hookRef, settledSignal), ("p-stripe", settledSignal)]
|
|
setVerifyResult ref (Right Nothing)
|
|
unhandled <- postWebhook client someSig (webhookEvent "InvoiceReceivedPayment" hookRef)
|
|
statusOf unhandled `shouldBe` 200
|
|
responseBody unhandled `shouldBe` ""
|
|
setVerifyResult ref (Right (Just "p-no-such-invoice"))
|
|
unknown <- postWebhook client someSig (webhookEvent "InvoiceSettled" "p-no-such-invoice")
|
|
statusOf unknown `shouldBe` 200
|
|
responseBody unknown `shouldBe` ""
|
|
setVerifyResult ref (Right (Just "p-stripe"))
|
|
crossed <- postWebhook client someSig (webhookEvent "InvoiceSettled" "p-stripe")
|
|
statusOf crossed `shouldBe` 200
|
|
responseBody crossed `shouldBe` ""
|
|
drainHints poller
|
|
stubCalls ref `shouldReturn` []
|
|
invoiceStatus (weStore env) ours `shouldReturn` ISOpen
|
|
invoiceStatus (weStore env) otherLane `shouldReturn` ISOpen
|
|
|
|
testWebhookRefusesAnOversizedBody :: IO ()
|
|
testWebhookRefusesAnOversizedBody = bounded "webhook body cap" $ withStubPoller raceHold $ \ref _ _ client -> do
|
|
setVerifyResult ref (Left (WebhookError "BTCPay-Sig does not verify"))
|
|
atCap <- postWebhook client someSig (LB.replicate (fromIntegral maxWebhookBytes) 'x')
|
|
statusOf atCap `shouldBe` 400
|
|
length <$> stubWebhooks ref `shouldReturn` 1
|
|
over <- postWebhook client someSig (LB.replicate (fromIntegral maxWebhookBytes + 1) 'x')
|
|
statusOf over `shouldBe` 413
|
|
responseBody over `shouldBe` ""
|
|
length <$> stubWebhooks ref `shouldReturn` 1
|
|
|
|
testReadHintQueuedOnce :: IO ()
|
|
testReadHintQueuedOnce = bounded "hint queued once" $ withStubPoller raceHold $ \ref poller env _ -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
_ <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
replicateM_ 5 (queueReadHint (weHints env) hookRef `shouldReturn` True)
|
|
drainHints poller
|
|
stubCalls ref `shouldReturn` [StubRead hookRef]
|
|
queueReadHint (weHints env) hookRef `shouldReturn` True
|
|
drainHints poller
|
|
stubCalls ref `shouldReturn` [StubRead hookRef, StubRead hookRef]
|
|
|
|
testWebhookDropsAHintWhenFull :: IO ()
|
|
testWebhookDropsAHintWhenFull = bounded "full hint queue" $ withStubPoller raceHold $ \ref poller env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
iid <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
setSignals ref [(hookRef, settledSignal)]
|
|
let fillers = ["p-filler-" <> T.pack (show i) | i <- [1 .. hintQueueSize]]
|
|
mapM (queueReadHint (weHints env)) fillers `shouldReturn` map (const True) fillers
|
|
queueReadHint (weHints env) "p-one-more" `shouldReturn` False
|
|
setVerifyResult ref (Right (Just hookRef))
|
|
r <- postWebhook client someSig hookedEvent
|
|
statusOf r `shouldBe` 200
|
|
responseBody r `shouldBe` ""
|
|
drainHints poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
runOnePass poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
|
|
testWebhookNeverAnswers5xx :: IO ()
|
|
testWebhookNeverAnswers5xx = bounded "webhook never 5xx" $ withStubPoller raceHold $ \ref _ env client -> do
|
|
seedBadgePrice (weStore env) "price1"
|
|
_ <- seedOpenRef (weStore env) 1 hookRef someExpiry
|
|
setVerifyThrows ref True
|
|
threw <- postWebhook client someSig hookedEvent
|
|
statusOf threw `shouldBe` 200
|
|
responseBody threw `shouldBe` ""
|
|
setVerifyThrows ref False
|
|
setVerifyResult ref (Right (Just hookRef))
|
|
breakStore (weStore env)
|
|
r <- postWebhook client someSig hookedEvent
|
|
statusOf r `shouldBe` 200
|
|
responseBody r `shouldBe` ""
|
|
stubCalls ref `shouldReturn` []
|
|
|
|
paidInFull, paidInPart :: Text
|
|
paidInFull = "0.00050000"
|
|
paidInPart = "0.00020000"
|
|
|
|
fullyPaid, partlyPaid :: J.Value
|
|
fullyPaid = J.Number 5400
|
|
partlyPaid = J.Number 2160
|
|
|
|
buyBadge :: HasCallStack => WebEnv -> WebClient -> Int -> Text -> IO (InvoiceId, Text)
|
|
buyBadge env client i code = do
|
|
r <- postCreateAs client i (createBody supporterPriceId Nothing "btc" (codeHashText code))
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
iid <- InvoiceId <$> stringField o "invoiceId"
|
|
getInvoice (weStore env) iid >>= \case
|
|
Just InvoiceRow {irProviderRef} -> pure (iid, irProviderRef)
|
|
Nothing -> failWith "the create answered 200 and wrote no invoice row"
|
|
|
|
atProvider :: FakeBTCPay -> Text -> Text -> Text -> Text -> IO ()
|
|
atProvider fake ref status additional paid =
|
|
atProviderDue fake ref status additional paid (if paid == paidInFull then "0.00000000" else dueAfterPart)
|
|
|
|
atProviderDue :: FakeBTCPay -> Text -> Text -> Text -> Text -> Text -> IO ()
|
|
atProviderDue fake ref status additional paid due =
|
|
setInvoiceState fake ref ["status" .= status, "additionalStatus" .= additional, "paymentMethodPaid" .= paid, "due" .= due]
|
|
|
|
dueAfterPart :: Text
|
|
dueAfterPart = "0.00030500"
|
|
|
|
viewInvoice :: HasCallStack => WebClient -> InvoiceId -> IO J.Object
|
|
viewInvoice client iid = do
|
|
r <- webGet client (invoicePath iid)
|
|
statusOf r `shouldBe` 200
|
|
headerOf r hCacheControl `shouldBe` Just "no-store"
|
|
jsonObject r
|
|
|
|
scenarioPaidPurchase :: IO ()
|
|
scenarioPaidPurchase = bounded "scenario: paid purchase" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
unpaid <- viewInvoice client iid
|
|
fieldOf unpaid "status" `shouldBe` Just (J.String "open")
|
|
fieldOf unpaid "amountPaid" `shouldBe` Nothing
|
|
fieldOf unpaid "cryptoAmountPaid" `shouldBe` Nothing
|
|
atProvider fake ref "Settled" "None" paidInFull
|
|
runOnePass poller
|
|
paid <- viewInvoice client iid
|
|
fieldOf paid "status" `shouldBe` Just (J.String "paid")
|
|
fieldOf paid "amountPaid" `shouldBe` Just fullyPaid
|
|
fieldOf paid "cryptoAmountPaid" `shouldBe` Just (J.String paidInFull)
|
|
fieldOf paid "settledAt" `shouldSatisfy` isJust
|
|
p <- paymentRow (weStore env) iid
|
|
ipStatus p `shouldBe` "settled"
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSPaid
|
|
bcExpiresAt code `shouldBe` Just (addUTCTime codeLifetime (ipUpdatedAt p))
|
|
|
|
scenarioHeldWaitWakes :: IO ()
|
|
scenarioHeldWaitWakes = bounded "scenario: held wait" $ withFakePollerHolding raceHold $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open")
|
|
threadDelay 100000
|
|
Async.poll held >>= \parked -> parked `shouldSatisfy` isNothing
|
|
atProvider fake ref "Settled" "None" paidInFull
|
|
settling <- getCurrentTime
|
|
runOnePass poller
|
|
r <- wait held
|
|
elapsed <- (`diffUTCTime` settling) <$> getCurrentTime
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "status" `shouldBe` Just (J.String "paid")
|
|
fieldOf o "amountPaid" `shouldBe` Just fullyPaid
|
|
elapsed `shouldSatisfy` (< 1)
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
|
|
scenarioPartPaymentIsReported :: IO ()
|
|
scenarioPartPaymentIsReported = bounded "scenario: part paid" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
atProvider fake ref "New" "PaidPartial" paidInPart
|
|
runOnePass poller
|
|
o <- viewInvoice client iid
|
|
fieldOf o "status" `shouldBe` Just (J.String "open")
|
|
fieldOf o "amountPaid" `shouldBe` Just partlyPaid
|
|
fieldOf o "cryptoAmountPaid" `shouldBe` Just (J.String paidInPart)
|
|
-- The due figure is the provider's own, which includes the network fee a partial payment adds,
|
|
-- so it is 0.00030500 rather than the 0.00030000 a subtraction would give.
|
|
fieldOf o "cryptoAmountDue" `shouldBe` Just (J.String dueAfterPart)
|
|
fieldOf o "settledAt" `shouldBe` Nothing
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
ipStatus <$> paymentRow (weStore env) iid `shouldReturn` "pending"
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSUnpaid
|
|
bcExpiresAt code `shouldBe` Nothing
|
|
|
|
scenarioExpiryReportsWhatArrived :: IO ()
|
|
scenarioExpiryReportsWhatArrived = bounded "scenario: expired part paid" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
atProvider fake ref "Expired" "PaidPartial" paidInPart
|
|
runOnePass poller
|
|
o <- viewInvoice client iid
|
|
fieldOf o "status" `shouldBe` Just (J.String "expired")
|
|
fieldOf o "amountPaid" `shouldBe` Just partlyPaid
|
|
fieldOf o "cryptoAmountPaid" `shouldBe` Just (J.String paidInPart)
|
|
fieldOf o "settledAt" `shouldBe` Nothing
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
ipStatus <$> paymentRow (weStore env) iid `shouldReturn` "pending"
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSUnpaid
|
|
bcExpiresAt code `shouldBe` Nothing
|
|
|
|
scenarioLateSettlement :: IO ()
|
|
scenarioLateSettlement = bounded "scenario: late settlement" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
atProvider fake ref "Expired" "PaidPartial" paidInPart
|
|
runOnePass poller
|
|
expired <- viewInvoice client iid
|
|
fieldOf expired "status" `shouldBe` Just (J.String "expired")
|
|
fieldOf expired "amountPaid" `shouldBe` Just partlyPaid
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSUnpaid
|
|
atProvider fake ref "Settled" "PaidLate" paidInFull
|
|
runOnePass poller
|
|
paid <- viewInvoice client iid
|
|
fieldOf paid "status" `shouldBe` Just (J.String "paid")
|
|
fieldOf paid "amountPaid" `shouldBe` Just fullyPaid
|
|
fieldOf paid "cryptoAmountPaid" `shouldBe` Just (J.String paidInFull)
|
|
fieldOf paid "settledAt" `shouldSatisfy` isJust
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISPaid
|
|
p <- paymentRow (weStore env) iid
|
|
ipStatus p `shouldBe` "settled"
|
|
code <- settledCode (weStore env) iid
|
|
bcPaymentStatus code `shouldBe` CPSPaid
|
|
bcExpiresAt code `shouldBe` Just (addUTCTime codeLifetime (ipUpdatedAt p))
|
|
|
|
scenarioReplayChangesNothing :: IO ()
|
|
scenarioReplayChangesNothing = bounded "scenario: replay" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
atProvider fake ref "Settled" "None" paidInFull
|
|
runOnePass poller
|
|
settled <- viewInvoice client iid
|
|
fieldOf settled "status" `shouldBe` Just (J.String "paid")
|
|
firstPayment <- paymentRow (weStore env) iid
|
|
firstCode <- settledCode (weStore env) iid
|
|
length <$> paymentIdentity (weStore env) iid `shouldReturn` 1
|
|
runOnePass poller
|
|
length <$> apiRequests fake "GET" ["invoices"] `shouldReturn` 1
|
|
length <$> paymentIdentity (weStore env) iid `shouldReturn` 1
|
|
paymentRow (weStore env) iid `shouldReturn` firstPayment
|
|
settledCode (weStore env) iid `shouldReturn` firstCode
|
|
viewInvoice client iid `shouldReturn` settled
|
|
|
|
scenarioInvalidClosesAsExpired :: IO ()
|
|
scenarioInvalidClosesAsExpired = bounded "scenario: invoice invalid" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
atProvider fake ref "Invalid" "None" paidInPart
|
|
let body = webhookEvent "InvoiceInvalid" ref
|
|
delivered <- postWebhook client (webhookSigHeader (bWebhookSecret (fbConfig fake)) body) body
|
|
statusOf delivered `shouldBe` 200
|
|
responseBody delivered `shouldBe` ""
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
drainHints poller
|
|
o <- viewInvoice client iid
|
|
fieldOf o "status" `shouldBe` Just (J.String "expired")
|
|
fieldOf o "amountPaid" `shouldBe` Just partlyPaid
|
|
fieldOf o "cryptoAmountPaid" `shouldBe` Just (J.String paidInPart)
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISExpired
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSUnpaid
|
|
detailReads <- apiRequests fake "GET" ["invoices", ref]
|
|
length detailReads `shouldBe` 1
|
|
|
|
scenarioCodeConflictCreatesNothing :: IO ()
|
|
scenarioCodeConflictCreatesNothing = bounded "scenario: code conflict" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
fakeInvoiceIds fake `shouldReturn` [ref]
|
|
again <- postCreateAs client 2 (createBody supporterPriceId Nothing "btc" (codeHashText sampleCode))
|
|
statusOf again `shouldBe` 409
|
|
responseBody again `shouldBe` errorBody "code_conflict"
|
|
fakeInvoiceIds fake `shouldReturn` [ref]
|
|
invoiceCount (weStore env) `shouldReturn` 1
|
|
(_, otherRef) <- buyBadge env client 3 (sampleCode <> "2")
|
|
otherRef `shouldSatisfy` (/= ref)
|
|
fakeInvoiceIds fake `shouldReturn` sort [ref, otherRef]
|
|
invoiceCount (weStore env) `shouldReturn` 2
|
|
atProvider fake ref "Settled" "None" paidInFull
|
|
runOnePass poller
|
|
paid <- viewInvoice client iid
|
|
fieldOf paid "status" `shouldBe` Just (J.String "paid")
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSPaid
|
|
|
|
scenarioNoWebhookAnywhere :: IO ()
|
|
scenarioNoWebhookAnywhere = bounded "scenario: no webhook" $ withFakePoller $ \fake poller env client -> do
|
|
(iid, ref) <- buyBadge env client 1 sampleCode
|
|
atProvider fake ref "Settled" "None" paidInFull
|
|
drainHints poller
|
|
invoiceStatus (weStore env) iid `shouldReturn` ISOpen
|
|
runOnePass poller
|
|
paid <- viewInvoice client iid
|
|
fieldOf paid "status" `shouldBe` Just (J.String "paid")
|
|
bcPaymentStatus <$> settledCode (weStore env) iid `shouldReturn` CPSPaid
|
|
byId <- apiRequests fake "GET" ["invoices", ref]
|
|
byId `shouldSatisfy` (not . null)
|
|
|
|
cancelPath :: InvoiceId -> String
|
|
cancelPath iid = invoicePath iid <> "/cancel"
|
|
|
|
webPost :: WebClient -> String -> IO (Response LB.ByteString)
|
|
webPost client target = webRequest client "POST" target []
|
|
|
|
withCancel :: (IORef StubState -> WebEnv -> WebClient -> IO a) -> IO a
|
|
withCancel action = withCheckout $ \ref env client -> do
|
|
_ <- seedOpenInvoice env
|
|
action ref env client
|
|
|
|
testCancelClosesTheInvoiceAtBothEnds :: IO ()
|
|
testCancelClosesTheInvoiceAtBothEnds = bounded "cancel" $ withCancel $ \ref _ client -> do
|
|
let iid = niInvoiceId sampleInvoice
|
|
r <- webPost client (cancelPath iid)
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "status" `shouldBe` Just (J.String "expired")
|
|
stubCalls ref `shouldReturn` [StubCancel (niProviderRef sampleInvoice)]
|
|
reread <- webGet client (invoicePath iid)
|
|
ao <- jsonObject reread
|
|
fieldOf ao "status" `shouldBe` Just (J.String "expired")
|
|
|
|
testCancelWakesAHeldRequest :: IO ()
|
|
testCancelWakesAHeldRequest = bounded "cancel wakes a hold" $ withCancel $ \_ _ client -> do
|
|
let iid = niInvoiceId sampleInvoice
|
|
_ <- wokenBy client iid (void (webPost client (cancelPath iid))) "expired"
|
|
pure ()
|
|
|
|
testCancelIsRefusedOnceItIsNotOpen :: IO ()
|
|
testCancelIsRefusedOnceItIsNotOpen = bounded "cancel not open" $ withCancel $ \ref env client -> do
|
|
let iid = niInvoiceId sampleInvoice
|
|
markPaid (weStore env) iid
|
|
r <- webPost client (cancelPath iid)
|
|
statusOf r `shouldBe` 409
|
|
responseBody r `shouldBe` errorBody "not_open"
|
|
stubCalls ref `shouldReturn` []
|
|
reread <- webGet client (invoicePath iid)
|
|
ao <- jsonObject reread
|
|
fieldOf ao "status" `shouldBe` Just (J.String "paid")
|
|
|
|
testCancelLeavesTheInvoiceOpenWhenTheProviderFails :: IO ()
|
|
testCancelLeavesTheInvoiceOpenWhenTheProviderFails = bounded "cancel provider down" $ withCancel $ \ref _ client -> do
|
|
let iid = niInvoiceId sampleInvoice
|
|
atomicModifyIORef' ref $ \s -> (s {ssCancelError = Just (ProviderError "boom")}, ())
|
|
r <- webPost client (cancelPath iid)
|
|
statusOf r `shouldBe` 503
|
|
reread <- webGet client (invoicePath iid)
|
|
ao <- jsonObject reread
|
|
fieldOf ao "status" `shouldBe` Just (J.String "open")
|
|
|
|
testCancelIsOpaqueForAnUnknownInvoice :: IO ()
|
|
testCancelIsOpaqueForAnUnknownInvoice = bounded "cancel unknown" $ withCancel $ \ref _ client -> do
|
|
r <- webPost client "/api/invoice/no-such-invoice/cancel"
|
|
statusOf r `shouldBe` 404
|
|
responseBody r `shouldBe` notFoundBody
|
|
stubCalls ref `shouldReturn` []
|
|
|
|
testCancelRefusesOtherMethods :: IO ()
|
|
testCancelRefusesOtherMethods = bounded "cancel verb" $ withCancel $ \_ _ client -> do
|
|
r <- webGet client (cancelPath (niInvoiceId sampleInvoice))
|
|
statusOf r `shouldBe` 405
|
|
headerOf r "Allow" `shouldBe` Just "POST"
|
|
|
|
testExpireOverdueSparesAFundedInvoice :: IO ()
|
|
testExpireOverdueSparesAFundedInvoice = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
now <- getCurrentTime
|
|
let pastExpiry = addUTCTime (-3600) now
|
|
funded = sampleInvoice {niExpiresAt = pastExpiry}
|
|
empty' = sampleInvoice {niInvoiceId = InvoiceId "inv-empty", niProviderRef = "p-empty", niCodeHash = digestFixture 15, niExpiresAt = pastExpiry}
|
|
createInvoiceRows st funded `shouldReturn` Right ()
|
|
createInvoiceRows st empty' `shouldReturn` Right ()
|
|
settle st (niInvoiceId funded) (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) now `shouldReturn` Right ISOpen
|
|
moved <- expireAllOverdue st now
|
|
moved `shouldBe` [niInvoiceId empty']
|
|
Just fundedRow <- getInvoice st (niInvoiceId funded)
|
|
irStatus fundedRow `shouldBe` ISOpen
|
|
settle st (niInvoiceId funded) (SigSettled (rcv 500 (Just "0.00050000")) now) now `shouldReturn` Right ISPaid
|
|
|
|
testCancelExpiresAFundedInvoice :: IO ()
|
|
testCancelExpiresAFundedInvoice = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
now <- getCurrentTime
|
|
let iid = niInvoiceId sampleInvoice
|
|
createInvoiceRows st sampleInvoice `shouldReturn` Right ()
|
|
settle st iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) now `shouldReturn` Right ISOpen
|
|
cancelOpenInvoice st iid now `shouldReturn` True
|
|
Just row <- getInvoice st iid
|
|
irStatus row `shouldBe` ISExpired
|
|
(ipCryptoPaid <$> irPayment row) `shouldBe` Just (Just "0.00050000")
|
|
|
|
testCancelIsRefusedOnceItIsFunded :: IO ()
|
|
testCancelIsRefusedOnceItIsFunded = bounded "cancel funded" $ withCancel $ \ref env client -> do
|
|
let iid = niInvoiceId sampleInvoice
|
|
settle (weStore env) iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) someCreated `shouldReturn` Right ISOpen
|
|
r <- webPost client (cancelPath iid)
|
|
statusOf r `shouldBe` 409
|
|
responseBody r `shouldBe` errorBody "funded"
|
|
stubCalls ref `shouldReturn` []
|
|
reread <- webGet client (invoicePath iid)
|
|
ao <- jsonObject reread
|
|
fieldOf ao "status" `shouldBe` Just (J.String "open")
|
|
|
|
testViewNamesTheConfirmationsSettlementNeeds :: IO ()
|
|
testViewNamesTheConfirmationsSettlementNeeds = bounded "required confirmations" $ withCancel $ \_ _ client -> do
|
|
r <- webGet client (invoicePath (niInvoiceId sampleInvoice))
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
-- MediumSpeed is BTCPay's one-confirmation policy, so settlement needs one confirmation.
|
|
fieldOf o "requiredConfirmations" `shouldBe` Just (J.Number 1)
|
|
|
|
testViewOmitsConfirmationsWithoutBTCPay :: IO ()
|
|
testViewOmitsConfirmationsWithoutBTCPay = bounded "no confirmations" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
r <- webGet client (invoicePath iid)
|
|
o <- jsonObject r
|
|
fieldOf o "requiredConfirmations" `shouldBe` Nothing
|
|
|
|
testHeldWaitWakesOnAPaymentThatDoesNotSettle :: IO ()
|
|
testHeldWaitWakesOnAPaymentThatDoesNotSettle = bounded "funded wakes a hold" $ withCheckout $ \_ env client -> do
|
|
iid <- seedOpenInvoice env
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open")
|
|
awaitWaitingCount env 1
|
|
waitingCount (weWaiters env) `shouldReturn` 1
|
|
settleOrder (weStore env) (weWaiters env) iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) someCreated
|
|
`shouldReturn` Right ISOpen
|
|
r <- wait held
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "status" `shouldBe` Just (J.String "open")
|
|
fieldOf o "cryptoAmountPaid" `shouldBe` Just (J.String "0.00050000")
|
|
|
|
testARepeatedSignalDoesNotWakeAHold :: IO ()
|
|
testARepeatedSignalDoesNotWakeAHold = bounded "repeat signal" $ withWebApp $ \env client -> do
|
|
iid <- seedOpenInvoice env
|
|
let funded = SigFunded (rcv 500 (Just "0.00050000")) PaidInPart
|
|
settleOrder (weStore env) (weWaiters env) iid funded settleAt `shouldReturn` Right ISOpen
|
|
held <- async $ webGet client (invoicePath iid <> "?wait=open&seenPaid=0.00050000&seenFull=0")
|
|
threadDelay holdParkDelay
|
|
Async.poll held >>= (`shouldSatisfy` isNothing)
|
|
|
|
settleOrder (weStore env) (weWaiters env) iid funded settleAt `shouldReturn` Right ISOpen
|
|
threadDelay holdParkDelay
|
|
stillParked <- Async.poll held
|
|
stillParked `shouldSatisfy` isNothing
|
|
|
|
settleOrder (weStore env) (weWaiters env) iid (SigSettled (rcv 500 (Just "0.00050000")) settleAt) settleAt
|
|
`shouldReturn` Right ISPaid
|
|
r <- wait held
|
|
statusOf r `shouldBe` 200
|
|
o <- jsonObject r
|
|
fieldOf o "status" `shouldBe` Just (J.String "paid")
|
|
|
|
testHeldWaitIsNotWokenByAPassThatWroteNothing :: IO ()
|
|
testHeldWaitIsNotWokenByAPassThatWroteNothing = bounded "no churn" $ withCheckout $ \_ env _ -> do
|
|
iid <- seedOpenInvoice env
|
|
markPaid (weStore env) iid
|
|
waitingBefore <- waitingCount (weWaiters env)
|
|
settleOrder (weStore env) (weWaiters env) iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) someCreated
|
|
`shouldReturn` Right ISPaid
|
|
waitingAfter <- waitingCount (weWaiters env)
|
|
waitingAfter `shouldBe` waitingBefore
|
|
|
|
testViewCarriesTheProvidersPaidVerdict :: IO ()
|
|
testViewCarriesTheProvidersPaidVerdict = bounded "paid in full" $ withCheckout $ \_ env client -> do
|
|
iid <- seedOpenInvoice env
|
|
settleOrder (weStore env) (weWaiters env) iid (SigFunded (rcv 400 (Just "0.00040000")) PaidInFull) someCreated
|
|
`shouldReturn` Right ISOpen
|
|
o <- jsonObject =<< webGet client (invoicePath iid)
|
|
fieldOf o "status" `shouldBe` Just (J.String "open")
|
|
fieldOf o "paidInFull" `shouldBe` Just (J.Bool True)
|
|
fieldOf o "amountPaid" `shouldBe` Just (J.Number 400)
|
|
|
|
testPaidVerdictIsNotWithdrawnByALaterRead :: IO ()
|
|
testPaidVerdictIsNotWithdrawnByALaterRead = bounded "verdict sticks" $ withCheckout $ \_ env client -> do
|
|
iid <- seedOpenInvoice env
|
|
settleOrder (weStore env) (weWaiters env) iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInFull) someCreated
|
|
`shouldReturn` Right ISOpen
|
|
settleOrder (weStore env) (weWaiters env) iid (SigFunded (rcv 500 (Just "0.00050000")) PaidInPart) someCreated
|
|
`shouldReturn` Right ISOpen
|
|
o <- jsonObject =<< webGet client (invoicePath iid)
|
|
fieldOf o "paidInFull" `shouldBe` Just (J.Bool True)
|
|
|
|
testExpireOverdueSparesAZeroAmount :: IO ()
|
|
testExpireOverdueSparesAZeroAmount = withServiceStore $ \st -> do
|
|
seedBadgePrice st "price1"
|
|
now <- getCurrentTime
|
|
let pastExpiry = addUTCTime (-3600) now
|
|
dust = sampleInvoice {niExpiresAt = pastExpiry}
|
|
verdict = sampleInvoice {niInvoiceId = InvoiceId "inv-verdict", niProviderRef = "p-verdict", niCodeHash = digestFixture 16, niExpiresAt = pastExpiry}
|
|
createInvoiceRows st dust `shouldReturn` Right ()
|
|
createInvoiceRows st verdict `shouldReturn` Right ()
|
|
settle st (niInvoiceId dust) (SigFunded (rcv 0 (Just "0.00000001")) PaidInPart) now `shouldReturn` Right ISOpen
|
|
settle st (niInvoiceId verdict) (SigFunded (rcv 0 Nothing) PaidInFull) now `shouldReturn` Right ISOpen
|
|
expireAllOverdue st now `shouldReturn` []
|
|
mapM_ (\iid ->
|
|
getInvoice st iid >>= \case
|
|
Just InvoiceRow {irStatus, irPayment} -> do
|
|
irStatus `shouldBe` ISOpen
|
|
maybe False paymentHolds irPayment `shouldBe` True
|
|
Nothing -> failWith "the invoice went missing") [niInvoiceId dust, niInvoiceId verdict]
|