{-# 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 (CodeRedemption (..), IssuedCode (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getBadgeCode, insertBadgeCode, revokeCode) import BadgeService.Store.Invoices import BadgeService.Waiters (awaitStatus, newWaiters, publish, waitingCount) import BadgeService.Web.Server import Bots.BadgeService.BotTests (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 (newEmptyMVar, putMVar, takeMVar) 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.IORef (IORef, atomicModifyIORef', newIORef, readIORef) import Data.List (sort, sortOn) import qualified Data.Map.Strict as Map import Data.Maybe (isJust, isNothing, fromMaybe) 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 GHC.IO.Handle (hDuplicate, hDuplicateTo) 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 (BadgeType (..)) import Simplex.Chat.Badges.Service (BadgeOffer (..), BadgePrice (..)) 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 (MigrationConfig (..), MigrationConfirmation (..)) import qualified Simplex.Messaging.Crypto as C 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 (..), hClose, hGetBuffering, hSetBuffering, 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 (testDBConnectInfo, testDBConnstr) import Database.PostgreSQL.Simple (Only (..)) import Simplex.Messaging.Agent.Store.Postgres.Util (createDBAndUserIfNotExists, dropDatabaseAndUser) #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 (..)) #endif #if defined(dbPostgres) withServiceStore :: (DBStore -> IO a) -> IO a withServiceStore action = E.bracket_ (dropDatabaseAndUser testDBConnectInfo >> createDBAndUserIfNotExists testDBConnectInfo) (dropDatabaseAndUser testDBConnectInfo) $ do Right st <- createDBStore serviceDBOpts badgeServiceSchemaMigrations (MigrationConfig MCError Nothing) action st `E.finally` closeDBStore st where serviceDBOpts = DBOpts { connstr = BC.pack testDBConnstr, schema = "sx_badge_service_web_test", poolSize = 4, createSchema = True } #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 five service-only columns" testServiceColumns it "refuses a duplicate provider_ref" testProviderRefUnique 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 "every timestamp round-trips to the second" testTimestampRoundTrip 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"]) 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 (purchaseKey, masterKey) <- newPurchaseKeys let newCode codeHash = withTransaction st $ \db -> do insertBadgeCode db codeHash BTSupporter 1 CPSPaid now maybe (error "the code was not written") (\IssuedCode {badgeCodeId} -> badgeCodeId) <$> getBadgeCode db codeHash redeem badgeCodeId = withTransaction st $ \db -> createCodePurchase db NewCodePurchase {badgeCodeId, purchaseKey, masterKey, badgeType = BTSupporter} now revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now unredeemed codeHash = withTransaction st (`getBadgeCode` codeHash) >>= \case Just IssuedCode {redemption = CodeUnredeemed} -> pure True _ -> pure False revokedFirst <- newCode "revoked-first" revoke "revoked-first" `shouldReturn` Revoked redeem revokedFirst `shouldReturn` Nothing unredeemed "revoked-first" `shouldReturn` True redeemedFirst <- newCode "redeemed-first" isJust <$> redeem redeemedFirst `shouldReturn` True revoke "redeemed-first" `shouldReturn` AlreadyRedeemed redeem redeemedFirst `shouldReturn` Nothing 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 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 = "