Files
simplex-chat/tests/Bots/BadgeServiceTests.hs

4211 lines
252 KiB
Haskell

{-# LANGUAGE CPP #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Bots.BadgeServiceTests where
import BadgeService.Admin (AdminCmd (..), AdminOpts (..), IssueOpts (..), adminCommandParser, runAdminCmd)
import BadgeService.Catalog (catalogTotals, defaultCatalog, offerTotal, seedCatalog)
-- imported qualified: 'codeHash' and 'normalizeCode' would otherwise be ambiguous against
-- 'BadgeService.Store.BadgeCode''s 'codeHash' field, which this module already uses as a record
-- field (the same reason 'BadgeService.Admin' imports this module qualified).
import qualified BadgeService.Codes as Codes
import BadgeService.Config
( BadgeServiceConfig (..),
BadgeServiceEnv (..),
BucketLimits (..),
-- 'CodesConfig'/'IssuerConfig' import only their constructors, not '(..)': their field
-- 'issuerKeyFile' is already a pervasive local variable name below (writeTestBadgeServiceSecrets
-- and its many callers), so importing the field selector too would shadow it (Werror).
CodesConfig (CodesConfig),
IssuerConfig (IssuerConfig),
SignerBucketFamily (..),
ThrottleConfig (..),
WebConfig (..),
checkFailureBuckets,
debitFailureBuckets,
newBadgeServiceEnv,
readBadgeServiceConfig,
sweepSignerBuckets,
)
import BadgeService.Credentials (issueSignedBadge, loadIssuerKey)
import BadgeService.Options
import BadgeService.Service
import BadgeService.Store
import BadgeService.Web.Server (resolveWebPage)
import Bots.BadgeManagerTests (allowPass, gatedClockAt, newBadgeGate, waitPasses)
import ChatClient
import ChatTests.DBUtils
import ChatTests.Profiles (testBadgeKeys)
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, wait, withAsync)
import Control.Concurrent.STM (atomically, readTVarIO, writeTVar)
import Control.Exception (SomeException, bracket, finally, try)
import Control.Monad (forM_, replicateM, unless, void)
import Control.Monad.Except (ExceptT)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Reader (runReaderT)
import Crypto.Random (getRandomBytes)
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Aeson.Types as JT
import qualified Data.ByteString.Base64 as B64
import qualified Data.ByteString.Char8 as BC
import qualified Data.ByteString.Lazy.Char8 as LBC
import qualified Data.CaseInsensitive as CI
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Data.Int (Int64)
import Data.List (find, isInfixOf, isPrefixOf, isSuffixOf, nub, sort, stripPrefix)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromJust, isJust, mapMaybe)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as TIO
import Data.Time.Calendar (fromGregorian)
import Data.Time.Calendar.WeekDate (toWeekDate)
import Data.Time.Clock (DiffTime, NominalDiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, nominalDay, secondsToDiffTime)
import Data.Word (Word8, Word32)
import GHC.IO.Handle (hDuplicate, hDuplicateTo)
-- qualified: 'defaultPrefs' collides with ChatTests.Utils' own (chat preferences, unrelated)
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types (Status (statusCode))
import Network.Socket (close)
import qualified Network.Wai.Handler.Warp as Warp
import qualified Options.Applicative as O
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey (..), BadgeRequest (..), BadgeType (..), VerifiedBadgeRequest (..), generateMasterKey, issueBadge, verifyCredential)
import Simplex.Chat.Badges.Months (addMonths)
import Simplex.Chat.Badges.Service
( -- 'BadgeBalance', 'StatementEntry' and 'StatementEntryType' import only their
-- constructors, not '(..)': their field names (entryId, changeMonths, balanceMonths,
-- createdAt, ...) duplicate 'BadgeLedgerEntry''s (Badges.Types), which the existing B1
-- ledger tests below already use as bare selectors -- importing the field selectors here
-- too would make those pre-existing, untouched uses ambiguous. 'BadgeStatement' is safe
-- to import with '(..)': 'entries'/'previousEntryId' are unique names nothing else here
-- uses.
BadgeBalance (BadgeBalance),
BadgeCatalog (..),
BadgeOffer (..),
BadgePrice (..),
BadgeServiceCommand (..),
BadgeServiceErrorCode (..),
BadgeServiceRequest (..),
BadgeServiceResponse (..),
BadgeStatement (..),
-- 'BadgeUpgrade' imports only its constructor: its 'receipt' and 'balance' fields collide
-- with 'BSPBadgeCredential''s and 'BSCIssueBadge''s, both imported with '(..)' above.
BadgeUpgrade (BadgeUpgrade),
StatementCreditType (SCCharge, SCOpening, SCPayment, SCTransferIn),
StatementDebitType (SDBadge, SDLapse, SDTransferOut, SDUpgrade),
StatementEntry (StatementEntry),
StatementEntryType (SECredit, SEDebit),
pattern VersionBadgeService,
)
import Simplex.Chat.Badges.Types
( BadgeItemStatus (..),
BadgeLedgerEntry (..),
BadgeOfferId (..),
BadgePriceId (..),
BadgePurchasePayment (BPPCode),
BadgePurchaseStatus (..),
LedgerCreditType (..),
LedgerDebitType (..),
LedgerEntryType (..),
OfferDiscount (..),
)
import Simplex.Chat.Controller (ChatCommand (APIPurchaseBadge), ChatConfig (..), ChatController (ChatController, chatStore, currentUser, processServiceRequests))
import Simplex.Chat.Library.Commands (execChatCommand')
import Simplex.Chat.Options (CoreChatOpts (..))
import Simplex.Chat.Options.DB
import Simplex.Chat.PaymentService (ServicePayment (..))
import Simplex.Chat.PaymentService.Types (CurrencyAmount (..), PaymentProvider (..))
import Simplex.Chat.Types (ChatPeerType (..), ConnectTarget (..), Profile (..), User (User, userId))
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (serviceRequestTimeout))
import Simplex.Messaging.Agent.Protocol (ConnectionMode (CMContact))
import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection, withTransaction)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import Simplex.Messaging.Agent.Store.Interface (closeDBStore, createDBStore)
import Simplex.Messaging.Agent.Store.Shared (Migration (..), MigrationConfig (..), MigrationConfirmation (..), MigrationsToRun (..), toDownMigration)
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (BBSPublicKey (..), BBSSecretKey (..), bbsKeyGen)
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
import System.Directory (Permissions, createDirectoryIfMissing, createFileLink, emptyPermissions, removeDirectoryRecursive, removeFile, setOwnerReadable, setOwnerSearchable, setOwnerWritable, setPermissions)
import System.Exit (ExitCode (..))
import System.FilePath ((</>))
import System.IO (IOMode (..), hClose, hFlush, openFile, stdout)
import Test.Hspec hiding (it)
#if defined(dbPostgres)
import BadgeService.Store.Postgres.Migrations (badgeServiceSchemaMigrations)
import Database.PostgreSQL.Simple (Only (..))
import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations
#else
import BadgeService.Store.SQLite.Migrations (badgeServiceSchemaMigrations)
import Database.SQLite.Simple (Only (..))
import qualified Simplex.Messaging.Agent.Store.SQLite.Migrations as Migrations
-- Reachable only from the two '#if !defined(dbPostgres)' tests below (the schema-linkage scan and
-- the no-plaintext-at-rest scan), so they are imported here rather than unconditionally: under
-- 'client_postgres' the code that uses them is compiled out and an unconditional import would
-- make that build warn (-Wunused-imports).
import Control.Monad (forM)
import qualified Data.ByteString as BS
import Data.Text.Encoding (encodeUtf8)
#endif
badgeServiceTests :: SpecWith TestParams
badgeServiceTests = do
it "should respond unsupported_version to a request below minSupportedBadgeVersion" testBadgeServiceUnsupportedVersion
it "should respond bad_request to a request that fails to decode" testBadgeServiceMalformedRequest
it "should respond bad_request when purchaseKey differs from the verified signer" testBadgeServiceSignerMismatch
it "should respond unknown_purchase_key to issueBadge from an unknown key" testBadgeServiceIssueBadgeUnknownKey
it "should never respond unknown_purchase_key to purchaseBadge from an unknown key" testBadgeServicePurchaseBadgeUnknownKeyIsNotUnknownPurchaseKey
it "should respond bad_request to pauseBadge from a signer with an existing purchase" testBadgeServicePauseBadgeKnownSignerBadRequest
it "should respond unknown_purchase_key to pauseBadge from an unknown key" testBadgeServicePauseBadgeUnknownKey
it "should respond bad_request to purchaseBadge funded by apple" testBadgeServicePurchaseBadgeAppleBadRequest
it "should reject purchaseBadge{code} with rate_limited before processing when the per-signer bucket is drained" testBadgeServicePurchaseCodeThrottlePreCheck
it "should turn a pure exception forced only during response encoding into internal, without escaping runHandler" testBadgeServiceCatchAllContainsPureException
it "should not grow the per-signer bucket map from checks alone, across many distinct keys" testBadgeServiceThrottlePeekDoesNotGrowMap
it "should add exactly one bucket entry per distinct key that actually fails, and let a sweep evict recovered ones" testBadgeServiceThrottleDebitBoundedAndSweepEvicts
it "should reject a [throttle] capacity of 0 at config-parse time, naming the key" testBadgeServiceConfigThrottleZeroCapacity
it "should migrate web_orders, codes and provider_events up and down" testBadgeServiceWebOrderSchemaMigration
it "should seed the catalog idempotently and preserve a deprecated price" testBadgeServiceCatalogSeeding
it "should price 3 months at 2x and 12 months at 6x the monthly price" testBadgeCatalogOfferTotal
it "should fill total for every seeded offer" testBadgeCatalogTotalsFillsSeededOffers
it "should reject an offer with freeMonths >= months instead of wrapping" testBadgeCatalogOfferTotalRejectsBadFreeMonths
it "should reject an offer with discount > 100 instead of wrapping into an overcharge" testBadgeCatalogOfferTotalRejectsBadDiscount
it "should reject a total that overflows Word32 instead of wrapping into a wrong charge" testBadgeCatalogOfferTotalRejectsOverflow
it "should encode BadgeItemStatus on the wire as active/deprecated/disabled" testBadgeItemStatusJsonWireFormat
it "should fail to start on a missing config file, naming the file" testBadgeServiceConfigMissingFile
it "should fail to start on an unparsable value, naming the key" testBadgeServiceConfigUnparsableValue
it "should fail to start on an unknown key in a known section" testBadgeServiceConfigUnknownKey
it "should fail to start on a missing [issuer] section" testBadgeServiceConfigMissingIssuerSection
it "should fail to start on a missing [codes] section" testBadgeServiceConfigMissingCodesSection
it "should fail to start on a half-configured [btcpay] section" testBadgeServiceConfigHalfConfiguredBtcPay
it "should fail to start on a half-configured [stripe] section" testBadgeServiceConfigHalfConfiguredStripe
it "should fail to start when a provider is configured without [web]" testBadgeServiceConfigProviderRequiresWeb
it "should start with just [issuer] and [codes], no provider section" testBadgeServiceConfigMinimalStarts
it "should start the service from a complete config with web and both providers" testBadgeServiceCompleteConfigStarts
it "should publish the same address to address_file across two starts, matching the client's address" testBadgeServicePublishesAddressFile
it "should omit a disabled price and its offers from getBadgeCatalog, and keep a deprecated one" testBadgeServiceGetCatalogDisabledDeprecated
it "should respond unknown_purchase_key to a signed getBadgeCatalog from an unknown key" testBadgeServiceGetCatalogUnknownSignerKey
it "should heal the ledger on a signed getBadgeCatalog, appending exactly one debit(lapse), and heal nothing on a repeat" testBadgeServiceGetCatalogHealsLedger
it "should rate_limit a third unsigned getBadgeCatalog once the catalog bucket is drained, without affecting a signed one" testBadgeServiceGetCatalogBucketThrottle
it "should serve the index with every token resolved and every asset it names fetchable under one prefix" testBadgeServiceWebIndexResolvesTokens
it "should serve every module a served module imports under that module's own prefix" testBadgeServiceWebModuleGraphUnderOnePrefix
it "should answer 404 for dev.html and for an asset under a stale build hash" testBadgeServiceWebDevHtmlAndStalePrefixAre404
it "should set the four security headers on every response, including a 404 and a rejected method" testBadgeServiceWebSecurityHeadersEverywhere
it "should serve the database catalog with computed totals from /api/catalog" testBadgeServiceWebCatalogEndpoint
it "should serve web_dir from disk with no-store, pick up an edit without a restart and refuse a path outside it" testBadgeServiceWebDirServesFromDisk
it "should answer 500 with the security headers for a web_dir file it cannot read and for an uncaught exception" testBadgeServiceWebInternalErrorsCarryHeaders
it "should fail before the service starts when a token names no served file, naming the token" testBadgeServiceWebUnresolvableTokenFails
it "should reject a non-loopback http base_url and accept https and loopback ones" testBadgeServiceConfigBaseUrlValidated
it "should reject a support_contact that is not an http(s) or mailto URL" testBadgeServiceConfigSupportContactValidated
it "should create a purchase and append ledger entries readable back in order" testBadgeStorePurchaseAndLedger
it "should disable a price out of the active catalog while both stay reachable by id" testBadgeStoreSetPriceStatusDisabled
it "should return the redeeming purchase key from getCodeByHash" testBadgeStoreGetCodeByHashRedeemer
it "should refuse to redeem a revoked code, leaving the row untouched" testBadgeStoreMarkCodeRedeemedRefusesRevoked
it "should clear both redemption columns and set unredeemed_at" testBadgeStoreUnredeemCode
it "should write an order and its invoice in one call and read them back joined" testBadgeStoreCreateOrderAndGet
it "should resolve an order by its provider ref, its replacement and its short ref" testBadgeStoreOrderRefLookups
it "should return expired invoiced and pending orders oldest first, omitting paid and unexpired ones" testBadgeStoreStuckOrders
it "should record a partial payment without settling, then settle status, amount and time together" testBadgeStoreOrderStatusAndSettlement
it "should reprocess an unprocessed provider event and refuse a processed replay" testBadgeStoreProviderEventReplay
it "should redeem a valid code into a verifiable credential, with a credit(payment) then debit(badge) ledger" testBadgeServiceRedeemCodeIssuesCredential
it "should return the same credential and write no new row when the identical request is repeated" testBadgeServiceRedeemCodeIdempotent
it "should append exactly one debit(lapse) when the identical request is repeated after months lapsed" testBadgeServiceReplayAfterLapseHealsOnce
it "should respond code_used to the same code presented by a second purchase key" testBadgeServiceCodeUsedBySecondKey
it "should answer code_invalid for unknown, mistyped and revoked codes and code_expired for an expired one, consuming none" testBadgeServiceCodeFailureOutcomes
it "should debit the per-signer bucket once per failed redemption and not at all for a success, replay, badgeExtra or tier mismatch" testBadgeServiceFailureDebitsBucketOncePerFailure
it "should rate_limit a fresh signer once the global failure budget is drained, and let the same code succeed after it refills" testBadgeServiceGlobalFailureBudgetRefills
it "should issue the second period with debit(lapse) before debit(badge), then serve the cached credential" testBadgeServiceIssueBadgeSecondPeriod
it "should serve the cached credential when issueBadge repeats inside the last funded month" testBadgeServiceIssueBadgeCachedInLastFundedMonth
it "should return no credential and a zero-balance statement when the balance is exhausted" testBadgeServiceIssueBadgeExhaustedBalance
it "should credit a second code to the existing purchase with its own payment and no second issuance" testBadgeServiceSecondCodeSamePurchaseKey
it "should sign the tier the code funds whatever tier the request names, and still refuse a tier mismatch on issueBadge" testBadgeServiceSignsFundedTier
it "should respond bad_request to a purchase carrying an upgrade, leaving the code unredeemed" testBadgeServiceUpgradeIsBadRequest
it "should respond bad_request to a reserved badgeExtra, leaving the code unredeemed" testBadgeServiceBadgeExtraIsBadRequest
it "should return only the entries after an asserted cursor, and the full history for an unknown or another purchase's one" testBadgeServiceIssueBadgeCursor
it "should serve the current month's cached credential across a clamped month boundary" testBadgeServiceCachedIssuanceAtClampedMonthBoundary
#if !defined(dbPostgres)
it "should have no table referencing both web_orders and badge_purchases" testBadgeServiceNoTableLinksOrdersToPurchases
#endif
it "should issue 10 codes with distinct hashes, no plaintext at rest, and revoke and report them by batch" testBadgeServiceCodesIssueRevokeStatus
it "should reject codes issue --type investor at parse time" testBadgeServiceCodesRejectsInvestorType
it "should sign a credential that verifies with the matching public key, and fail with a different one" testBadgeCredentialSignAndVerify
it "should set badgeExpiry to the next Sunday at 23:59:59 UTC" testBadgeCredentialExpiryIsSundayEndOfDay
it "should roll a periodEnd already on a Sunday to the following Sunday" testBadgeCredentialExpirySundayRollsToFollowingSunday
it "should reject a badgeRequest with non-empty badgeExtra as bad_request" testBadgeCredentialRejectsNonEmptyBadgeExtra
it "should load a valid issuer key file" testBadgeIssuerKeyLoadsValidFile
it "should fail fast on a missing issuer key file" testBadgeIssuerKeyMissingFile
it "should fail fast on an issuer key file without a 'secret' line" testBadgeIssuerKeyMalformedFile
it "should fail fast on a non-positive key_idx" testBadgeIssuerKeyNonPositiveIdx
-- C5: the client against the live service
it "should redeem a code from a profile with no prior connection and show the badge, with the client ledger matching the service's" testC5RedeemCodeShowsBadge
it "should present the redeemed badge to a contact connected before the redemption" testC5ContactSeesBadgeAfterRedeem
it "should write a legend badge for a legend code redeemed through the tier-less command" testC5RedeemLegendCodeGivesLegendBadge
it "should write nothing for an unknown, an already redeemed and an expired code" testC5RefusedCodesWriteNothing
it "should write nothing when the issued credential does not verify against the configured key" testC5UnverifiableCredentialWritesNothing
it "should answer internal and write nothing when no badge service address is configured" testC5NoServiceAddressConfigured
it "should answer internal and write nothing for apple and google payments" testC5StorePaymentsUnsupported
it "should answer internal and write nothing for both commands with the service stopped" testC5ServiceStoppedIsInternal
it "should supersede the first purchase on a second redemption, leaving its ledger and months alone" testC5SecondCodeSupersedesFirst
it "should return the four seeded offers and both prices to a profile with no prior connection, writing nothing" testC5CatalogFromFreshProfile
it "should issue the second period in one worker pass signalled by the badge state command, keeping the master key" testC5WorkerIssuesSecondPeriod
it "should not repoint the active profile from a redemption that finishes after a profile switch" testC5RedeemAfterProfileSwitchKeepsActiveUser
it "should answer code_used to the same code again, and redeem it into a new purchase once unredeemed" testC5SameCodeAgainThenUnredeemed
it "should redeem through a badge service address given as a full contact request URI" testC5FullContactUriRedeems
it "should write nothing for a redemption response it cannot record in full" testC5UnrecordableResponsesWriteNothing
it "should report a response of the wrong shape and one it cannot read, on both commands" testC5UnreadableResponsesAreReported
badgeProfile :: Profile
badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
serviceDbPrefix :: FilePath
serviceDbPrefix = "badge_service"
mkBadgeServiceOpts :: TestParams -> BadgeServiceOpts
mkBadgeServiceOpts TestParams {tmpPath = ps} =
BadgeServiceOpts
{ coreOptions =
testCoreOpts
{ dbOptions =
(dbOptions testCoreOpts)
#if defined(dbPostgres)
{dbSchemaPrefix = "client_" <> serviceDbPrefix}
#else
{dbFilePrefix = ps </> serviceDbPrefix}
#endif
},
serviceName = "SimpleX Badges",
clientService = True,
noAddress = False,
runCLI = False,
testing = True,
configFile = badgeServiceConfigPath ps,
-- the real clock, as in production; 'withBadgeServiceClock' overrides this field for the
-- tests that advance service time (B10)
serviceClock = getCurrentTime
}
badgeServiceConfigPath :: FilePath -> FilePath
badgeServiceConfigPath tmpPath = tmpPath </> "badge_service.ini"
-- The `badge keygen`-shaped file writeTestBadgeServiceSecrets generates the issuer key into,
-- named once so 'readTestIssuerPublicKey' reads back the very key the service loaded.
testIssuerKeyFile :: FilePath -> FilePath
testIssuerKeyFile tmpPath = tmpPath </> "badge-issuer.keys"
-- Generates a real issuer key file (the two-line `badge keygen` output: "secret ..\npublic
-- ..") and a real 32-byte code secret (base64-encoded), at fixed names under tmpPath. A6
-- doesn't read either file's contents -- that's B3 and B4 -- but the harness must still hand
-- every later step real files at real paths, per the config keys naming secrets as files.
writeTestBadgeServiceSecrets :: FilePath -> IO (FilePath, FilePath)
writeTestBadgeServiceSecrets tmpPath = do
let issuerKeyFile = testIssuerKeyFile tmpPath
codeSecretFile = tmpPath </> "badge-code.secret"
Right (BBSPublicKey pk, BBSSecretKey sk) <- bbsKeyGen
writeFile issuerKeyFile $ "secret " <> BC.unpack (strEncode sk) <> "\npublic " <> BC.unpack (strEncode pk) <> "\n"
codeSecret <- getRandomBytes 32
writeFile codeSecretFile $ BC.unpack (B64.encode codeSecret) <> "\n"
pure (issuerKeyFile, codeSecretFile)
issuerCodesIniLines :: FilePath -> FilePath -> [String]
issuerCodesIniLines issuerKeyFile codeSecretFile =
[ "[issuer]",
"key_file = " <> issuerKeyFile,
"key_idx = 1",
"",
"[codes]",
"secret_file = " <> codeSecretFile,
"default_expiry_days = 365"
]
-- The '[web]' section the harness writes into every ini it builds (D4), on the free port
-- 'withBadgeServiceAddress' bound for this test. 'base_url' is http, which the config parser
-- accepts only because the host is a loopback address.
webIniLines :: Int -> [String]
webIniLines port =
[ "[web]",
"port = " <> show port,
"base_url = " <> testWebBaseUrl port,
"support_contact = " <> testSupportContact
]
testWebBaseUrl :: Int -> String
testWebBaseUrl port = "http://127.0.0.1:" <> show port
-- Deliberately unlike anything the site, build.mjs or the plan carries, so that finding it in
-- the served page proves the value came from THIS ini rather than from a default, a fallback or
-- dev.html's placeholder.
testSupportContact :: String
testSupportContact = "https://example.invalid/support-from-ini"
-- Binds port 0, reads back the port the OS chose and closes the socket again. Used where an ini
-- needs a '[web] port' value that nothing will ever bind; the harness itself HOLDS the socket it
-- reserves instead (see 'withBadgeServiceWebAddress').
freeWebPort :: IO Int
freeWebPort = do
(port, sock) <- Warp.openFreePort
close sock
pure port
-- Writes a complete but minimal badge_service.ini (required sections only, no provider
-- section) at the path mkBadgeServiceOpts points BadgeServiceOpts's configFile at. Provider
-- sections are omitted until E2 and F1 add them, and '[web]' is written for them: A6 requires
-- it whenever a provider is configured. The port is the caller's, because the site's base URL
-- has to be known before the service starts (D4).
writeTestBadgeServiceConfig :: TestParams -> Int -> IO ()
writeTestBadgeServiceConfig ps = writeTestBadgeServiceConfigWith ps []
-- The same file with extra ini lines appended -- so far only a '[throttle]' override (B5
-- decision 5), which is how B10 drives the failure buckets to their limits in a handful of
-- requests instead of hundreds.
writeTestBadgeServiceConfigWith :: TestParams -> [String] -> Int -> IO ()
writeTestBadgeServiceConfigWith TestParams {tmpPath} extraLines port = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
writeFile (badgeServiceConfigPath tmpPath) $
unlines (issuerCodesIniLines issuerKeyFile codeSecretFile ++ ("" : webIniLines port) ++ extraLines)
withBadgeService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO ()
withBadgeService ps = withBadgeServiceConfig ps (writeTestBadgeServiceConfig ps) (pure ())
-- | 'withBadgeService' with the site's base URL handed to the body as a third argument (D4).
-- It is a separate entry point rather than a third argument on every existing body, because
-- forty-odd bodies that never make an HTTP request would gain an unused binder each.
withBadgeServiceWeb :: HasCallStack => TestParams -> (TestCC -> String -> String -> IO ()) -> IO ()
withBadgeServiceWeb ps = withBadgeServiceWebConfig ps (writeTestBadgeServiceConfig ps) (pure ())
withBadgeServiceWebConfig :: HasCallStack => TestParams -> (Int -> IO ()) -> IO () -> (TestCC -> String -> String -> IO ()) -> IO ()
withBadgeServiceWebConfig ps = withBadgeServiceWebClock ps getCurrentTime
-- Shared by withBadgeService and testBadgeServiceCompleteConfigStarts: the two-phase startup
-- dance (CreateMyAddress, then ShowMyAddress) is the same regardless of what the config looks
-- like, as long as it's valid; writeConfig is what varies. 'betweenPhases' runs after the
-- first phase's badge service has been killed and before the second one starts: the ONLY
-- window where nothing holds the database open, so a test that needs to seed a row directly
-- (e.g. B1's createPurchase, for a "known signer" case) must do it here, via a fresh
-- 'withFreshBadgeStore' -- opening a second connection to the SAME sqlite file WHILE the
-- service's own phase is running deadlocks against its writer lock (verified: reliably fails
-- 'createDBStore' with a pattern-match-on-Right, i.e. sqlite busy, when tried in that window).
withBadgeServiceConfig :: HasCallStack => TestParams -> (Int -> IO ()) -> IO () -> (TestCC -> String -> IO ()) -> IO ()
withBadgeServiceConfig ps = withBadgeServiceClock ps getCurrentTime
-- The same harness with the service's own clock replaced (A6: 'BadgeServiceEnv.now' is the only
-- clock any service component reads, and 'BadgeServiceOpts.serviceClock' is what
-- 'newBadgeServiceEnv' installs there). Both service starts get the same clock, so time is
-- continuous across the between-phases window. This is what lets B10 cross a month boundary or a
-- throttle bucket's refill window without any test sleeping -- see 'newTestClock'.
withBadgeServiceClock :: HasCallStack => TestParams -> IO UTCTime -> (Int -> IO ()) -> IO () -> (TestCC -> String -> IO ()) -> IO ()
withBadgeServiceClock ps clock writeConfig betweenPhases test =
withBadgeServiceAddress ps clock writeConfig betweenPhases $ \(sLink, _fullLink) withService ->
-- Second start: badge service takes the ShowMyAddress branch, then serves the test body.
withService $
withNewTestChatCfg ps testCfg "client" bobProfile $ \client ->
test client sLink
-- | The same, with the site's base URL as a third argument to the body -- see
-- 'withBadgeServiceWeb'.
withBadgeServiceWebClock :: HasCallStack => TestParams -> IO UTCTime -> (Int -> IO ()) -> IO () -> (TestCC -> String -> String -> IO ()) -> IO ()
withBadgeServiceWebClock ps clock writeConfig betweenPhases test =
withBadgeServiceWebAddress ps clock writeConfig betweenPhases $ \webUrl (sLink, _fullLink) withService ->
withService $
withNewTestChatCfg ps testCfg "client" bobProfile $ \client ->
test client sLink webUrl
-- | The same first phase, with the SECOND phase handed to the body as @withService@ instead of
-- wrapped around it, and with the service's contact address in both published forms.
--
-- Two things C5 needs and 'withBadgeServiceClock' cannot give: a chat client that OUTLIVES a
-- service restart (the code is redeemed, the service is stopped, its @codes@ row is unredeemed
-- and the service is started again, all with one client running), and a client running with the
-- service NOT started at all. A body that just wants the service up for its whole length calls
-- @withService@ once, which is what 'withBadgeServiceClock' does above.
--
-- The full contact request URI is the second element: it is one of the four target forms
-- 'Simplex.Chat.Library.Commands.resolveServiceTarget' accepts and, without it, only the short
-- link would ever be exercised.
withBadgeServiceAddress :: HasCallStack => TestParams -> IO UTCTime -> (Int -> IO ()) -> IO () -> ((String, String) -> (IO () -> IO ()) -> IO ()) -> IO ()
withBadgeServiceAddress ps clock writeConfig betweenPhases test =
badgeServicePhases ps ephemeralWebPort (pure ()) clock writeConfig betweenPhases test
-- | The same, with a web port the body can actually address, and its base URL as the FIRST
-- argument to the body. The port is reserved -- bound and HELD -- until the moment the serving
-- start begins, so that no other socket in this process, including the first phase's listener,
-- can be handed it in the meantime.
--
-- Only this family gets an addressable port, and no body in it starts the service more than
-- once: see 'ephemeralWebPort' for what goes wrong when two starts in one test process are given
-- the same port.
withBadgeServiceWebAddress :: HasCallStack => TestParams -> IO UTCTime -> (Int -> IO ()) -> IO () -> (String -> (String, String) -> (IO () -> IO ()) -> IO ()) -> IO ()
withBadgeServiceWebAddress ps clock writeConfig betweenPhases test =
bracket Warp.openFreePort (close . snd) $ \(port, reservedSocket) ->
badgeServicePhases ps port (close reservedSocket) clock writeConfig betweenPhases (test (testWebBaseUrl port))
-- The two-phase startup itself, shared by both: 'writeConfig' gets the port the SERVING start may
-- bind, the address-creating start always runs on an ephemeral one, and 'releasePort' runs
-- immediately before the serving start (the web variant hands back the port it reserved; the
-- plain one has nothing to release).
badgeServicePhases :: HasCallStack => TestParams -> Int -> IO () -> IO UTCTime -> (Int -> IO ()) -> IO () -> ((String, String) -> (IO () -> IO ()) -> IO ()) -> IO ()
badgeServicePhases ps webPort releasePort clock writeConfig betweenPhases test = do
let opts = (mkBadgeServiceOpts ps) {serviceClock = clock}
writeConfig webPort
phase1Config <- writeConfigOnEphemeralWebPort (badgeServiceConfigPath (tmpPath ps))
withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure ()
-- First start: badge service takes the CreateMyAddress branch.
runBadgeService testCfg opts {configFile = phase1Config} (pure ())
-- Reopen the DB to read the links the service created.
links <- withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
bs ##> "/sa"
links <- getContactLinks bs False
bs <## "auto_accept off"
pure links
betweenPhases
releasePort
test links (runBadgeService testCfg opts)
-- | Port 0 makes Warp bind an ephemeral port: the listener runs exactly as it does in production,
-- but nothing in the test can address it -- and nothing else in the process can collide with it.
-- Every service start uses this except the single serving start of a web test, because two starts
-- in one test process cannot share a port:
--
-- * 'runSimplexChat' starts the service's own callback with 'async' and never cancels it
-- ('Simplex.Chat.Core'), so killing a badge service leaves its 'raceAny_' -- and the web
-- listener's socket with it -- running until the test process exits. Production starts the
-- service once per process, so nothing there depends on that thread being reclaimed.
-- * a failed bind takes the whole service down, since the listener is an arm of that 'raceAny_'.
-- A second start on a port the first still holds leaves the test with no bot at all, not
-- merely without a site.
ephemeralWebPort :: Int
ephemeralWebPort = 0
-- The ini the address-creating start reads: the same file with '[web] port' rewritten to
-- 'ephemeralWebPort'. The section is rewritten rather than dropped, because A6 refuses to start a
-- config that has a provider section and no '[web]', which is what
-- 'testBadgeServiceCompleteConfigStarts' writes.
writeConfigOnEphemeralWebPort :: FilePath -> IO FilePath
writeConfigOnEphemeralWebPort path = do
ls <- T.lines <$> TIO.readFile path
let dest = path <> ".phase1"
TIO.writeFile dest $ T.unlines (rewrite "" ls)
pure dest
where
rewrite _ [] = []
rewrite section (l : ls)
| "[" `T.isPrefixOf` T.strip l = l : rewrite (T.strip l) ls
| section == "[web]" && "port" `T.isPrefixOf` T.strip l = T.pack ("port = " <> show ephemeralWebPort) : rewrite section ls
| otherwise = l : rewrite section ls
-- 'withAsync' rather than 'forkIO'/'killThread': its cleanup WAITS for the service thread to finish
-- unwinding instead of returning as soon as the exception is delivered, so what a start holds --
-- the web listener's socket above all (D4) -- is released before the next start of the same test.
-- It is not sufficient on its own: the thread it cancels is not the one that owns the listener,
-- because 'runSimplexChat' orphans that one. See 'ephemeralWebPort'.
runBadgeService :: ChatConfig -> BadgeServiceOpts -> IO () -> IO ()
runBadgeService cfg opts action =
withAsync (badgeService opts cfg) $ \_ -> do
threadDelay 500000
action
-- B5 RPC dispatcher -----------------------------------------------------------
-- Sends the JSON-encoded 'BadgeServiceRequest' unsigned.
sendServiceRequest :: TestCC -> String -> BadgeServiceRequest -> IO ()
sendServiceRequest client bsLink req =
client ##> ("/_service_request 1 " <> bsLink <> " " <> LBC.unpack (J.encode req))
-- Sends the JSON-encoded 'BadgeServiceRequest' signed with 'priv' (the agent verifies the
-- signature and delivers the corresponding public key as CEvtServiceRequest's signerKey).
sendSignedServiceRequest :: TestCC -> String -> C.PrivateKeyEd25519 -> BadgeServiceRequest -> IO ()
sendSignedServiceRequest client bsLink priv req =
client
##> ( "/_service_request 1 " <> bsLink <> " sign_key=" <> BC.unpack (strEncode (C.StoredPrivateKey priv))
<> " "
<> LBC.unpack (J.encode req)
)
-- Reads one raw "service response: {...}" line and returns the decoded JSON object, for
-- assertions that can't be pinned to one exact line (e.g. a retryAfter whose value depends on
-- wall-clock timing).
getServiceResponseObject :: HasCallStack => TestCC -> IO J.Object
getServiceResponseObject client = do
line <- getTermLine client
case T.stripPrefix "service response: " (T.pack line) of
Just json | Just (J.Object o) <- J.decode (LBC.pack (T.unpack json)) -> pure o
_ -> expectationFailure ("expected a service response line, got: " <> line) >> error "unreachable"
-- Decodes a service response object into 'BadgeServiceResponse' (B6): used by every B6 test
-- that inspects the catalog or the statement, rather than digging through the raw JSON object
-- the way B5's throttle tests do (those only ever need 'code'/'retryAfter', which never
-- justified the extra decode step).
getServiceResponse :: HasCallStack => TestCC -> IO BadgeServiceResponse
getServiceResponse client = do
obj <- getServiceResponseObject client
case JT.parseEither J.parseJSON (J.Object obj) :: Either String BadgeServiceResponse of
Right resp -> pure resp
Left err -> expectationFailure ("failed to decode service response: " <> err) >> error "unreachable"
testBadgeRequestCommand :: BadgeMasterKey -> ServicePayment -> BadgeServiceCommand
testBadgeRequestCommand masterKey payment =
BSCPurchaseBadge {badgeRequest = testBadgeRequest masterKey, payment, upgrade = Nothing}
-- StatementEntry/BadgeBalance/StatementEntryType are constructed positionally: only their
-- constructors are imported (see the import list above), not their field selectors, to avoid
-- colliding with BadgeLedgerEntry's identically-named fields used elsewhere in this file.
-- Field order: entryId, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType,
-- wasPausedSince, createdAt, entryType.
testIssueBadgeCommand :: BadgeMasterKey -> UTCTime -> BadgeServiceCommand
testIssueBadgeCommand masterKey now =
BSCIssueBadge
{ badgeRequest = testBadgeRequest masterKey,
balance = BadgeBalance (StatementEntry "test-entry" 1 1 now BTSupporter Nothing now (SECredit SCOpening))
}
-- version 0 is below minSupportedBadgeVersion (1): the version gate must reject it before
-- looking at the command at all, so 'getBadgeCatalog' (which needs no other fields) is enough
-- to isolate the gate.
testBadgeServiceUnsupportedVersion :: HasCallStack => TestParams -> IO ()
testBadgeServiceUnsupportedVersion ps =
withBadgeService ps $ \client bsLink -> do
client ##> ("/_service_request 1 " <> bsLink <> " {\"version\":0,\"request\":{\"type\":\"getBadgeCatalog\"}}")
client <## "service response: {\"code\":\"unsupported_version\",\"type\":\"error\"}"
-- A syntactically valid JSON object that does not decode into BadgeServiceRequest (missing
-- version and request) must fail at step 1, before the version gate ever runs.
testBadgeServiceMalformedRequest :: HasCallStack => TestParams -> IO ()
testBadgeServiceMalformedRequest ps =
withBadgeService ps $ \client bsLink -> do
client ##> ("/_service_request 1 " <> bsLink <> " {\"foo\":\"bar\"}")
client <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}"
-- A request signed by one key but asserting a different key as purchaseKey must be rejected
-- regardless of what it asks for.
testBadgeServiceSignerMismatch :: HasCallStack => TestParams -> IO ()
testBadgeServiceSignerMismatch ps =
withBadgeService ps $ \client bsLink -> do
(_signerPub, signerPriv) <- mkTestKeyPair
(assertedPub, _assertedPriv) <- mkTestKeyPair
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just assertedPub, request = BSCGetBadgeCatalog}
sendSignedServiceRequest client bsLink signerPriv req
client <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}"
-- issueBadge always requires an existing purchase record; a fresh, never-purchased key must
-- be rejected with unknown_purchase_key before it ever reaches the (not yet implemented) B7
-- handler.
testBadgeServiceIssueBadgeUnknownKey :: HasCallStack => TestParams -> IO ()
testBadgeServiceIssueBadgeUnknownKey ps =
withBadgeService ps $ \client bsLink -> do
(pub, priv) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = testIssueBadgeCommand masterKey now}
sendSignedServiceRequest client bsLink priv req
client <## "service response: {\"code\":\"unknown_purchase_key\",\"type\":\"error\"}"
-- The rule easy to get backwards (B5 brief): purchaseBadge from an unknown key is the normal
-- first-purchase case, not an identity error. B7's code classifier now answers it: "UNKNOWN-CODE"
-- normalizes to 11 characters and fails the check character, so it is code_invalid -- reached
-- only because the identity check let an unknown key through in the first place.
testBadgeServicePurchaseBadgeUnknownKeyIsNotUnknownPurchaseKey :: HasCallStack => TestParams -> IO ()
testBadgeServicePurchaseBadgeUnknownKeyIsNotUnknownPurchaseKey ps =
withBadgeService ps $ \client bsLink -> do
(pub, priv) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = testBadgeRequestCommand masterKey (SPCode "UNKNOWN-CODE")}
sendSignedServiceRequest client bsLink priv req
respObj <- getServiceResponseObject client
KM.lookup "code" respObj `shouldNotBe` Just (J.String "unknown_purchase_key")
KM.lookup "code" respObj `shouldBe` Just (J.String "code_invalid")
-- pauseBadge is always bad_request (decision 5 / §6), but ONLY once the signer/record
-- precondition passes -- a signer with a real purchase row (B1's createPurchase) must reach
-- that bad_request, not an identity error.
testBadgeServicePauseBadgeKnownSignerBadRequest :: HasCallStack => TestParams -> IO ()
testBadgeServicePauseBadgeKnownSignerBadRequest ps = do
(pub, priv) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
-- The purchase row is seeded in the gap between withBadgeServiceConfig's two startup
-- phases, reopening the SAME already-migrated store the harness itself reopens there to
-- read the invite link (chatStore, via a plain TestCC) -- NOT a fresh createDBStore with
-- just badgeServiceSchemaMigrations, which builds an isolated, from-scratch database (as
-- withFreshBadgeStore's other callers rely on) and mismatches against the real one, already
-- carrying the full chat/agent migration history the live service ran.
let seedPurchase =
withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost" -- consume, as the harness's own reopen does
void $ expectRight $ withServiceTransaction (chatStore (chatController bs)) $ \db -> createPurchase db pub masterKey BTSupporter now
withBadgeServiceConfig ps (writeTestBadgeServiceConfig ps) seedPurchase $ \client bsLink -> do
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = BSCPauseBadge}
sendSignedServiceRequest client bsLink priv req
client <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}"
-- The same command from a key with no purchase row at all must fail the identity check
-- instead, before pauseBadge's own (always bad_request) handling ever runs.
testBadgeServicePauseBadgeUnknownKey :: HasCallStack => TestParams -> IO ()
testBadgeServicePauseBadgeUnknownKey ps =
withBadgeService ps $ \client bsLink -> do
(pub, priv) <- mkTestKeyPair
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = BSCPauseBadge}
sendSignedServiceRequest client bsLink priv req
client <## "service response: {\"code\":\"unknown_purchase_key\",\"type\":\"error\"}"
-- Store-evidence verification is out of scope (§6): every non-code payment method is
-- permanently bad_request, not "not implemented".
testBadgeServicePurchaseBadgeAppleBadRequest :: HasCallStack => TestParams -> IO ()
testBadgeServicePurchaseBadgeAppleBadRequest ps =
withBadgeService ps $ \client bsLink -> do
(pub, priv) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = testBadgeRequestCommand masterKey (SPApple {jws = "test-jws"})}
sendSignedServiceRequest client bsLink priv req
client <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}"
-- With the per-signer bucket started at capacity 1 and zero tokens ([throttle] override, B5
-- decision 5), a single purchaseBadge{code} must be rejected BEFORE processing, with
-- rate_limited and a non-zero retryAfter -- the accounting (debit-on-failure) is B10's, this
-- step only asserts the pre-processing check.
testBadgeServicePurchaseCodeThrottlePreCheck :: HasCallStack => TestParams -> IO ()
testBadgeServicePurchaseCodeThrottlePreCheck ps = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets (tmpPath ps)
-- no [web] section: this ini also covers the case where the service runs with no listener at
-- all, which must leave the bot answering exactly as it does with one (D4)
let writeConfig _port =
writeFile (badgeServiceConfigPath (tmpPath ps)) $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ["", "[throttle]", "signer_failure_capacity = 1", "signer_failure_start_tokens = 0"]
withBadgeServiceConfig ps writeConfig (pure ()) $ \client bsLink -> do
(pub, priv) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = testBadgeRequestCommand masterKey (SPCode "TEST-CODE")}
sendSignedServiceRequest client bsLink priv req
respObj <- getServiceResponseObject client
KM.lookup "code" respObj `shouldBe` Just (J.String "rate_limited")
case KM.lookup "retryAfter" respObj of
Just (J.Number n) -> n `shouldSatisfy` (> 0)
other -> expectationFailure $ "expected a positive retryAfter, got: " <> show other
-- The convincing form (B5 brief): forces a genuine 'error' thunk hidden behind a Just, so it
-- is NOT forced by constructing or returning the response -- only by fully encoding it, which
-- is exactly the laziness gap a "catch around only the IO action" would miss. runHandler must
-- catch it, respond internal, and remain usable for the next call (proving the exception did
-- not corrupt anything or propagate past this function -- the property that keeps
-- processQueuedRequests' single-threaded forever loop alive for every other user).
testBadgeServiceCatchAllContainsPureException :: HasCallStack => TestParams -> IO ()
testBadgeServiceCatchAllContainsPureException _ps = do
let boom = error "boom: pure exception forced only during response encoding, not before" :: Text
badResponse = BSPError {code = BSEInternal, message = Just boom, retryAfter = Nothing}
caughtObj <- runHandler "test-req-pure-exception" (pure badResponse)
KM.lookup "code" caughtObj `shouldBe` Just (J.String "internal")
KM.lookup "message" caughtObj `shouldBe` Nothing -- never leaks the caught exception's own text
goodObj <- runHandler "test-req-after-pure-exception" (pure $ BSPError {code = BSEBadRequest, message = Nothing, retryAfter = Nothing})
KM.lookup "code" goodObj `shouldBe` Just (J.String "bad_request")
-- Builds a real BadgeServiceEnv directly (real issuer key + code secret files, production-
-- shaped [throttle] defaults) against an already-migrated store, without going through a live
-- service -- so the throttle's own STM state can be inspected and driven directly.
mkTestBadgeServiceEnv :: TestParams -> DBStore -> IO BadgeServiceEnv
mkTestBadgeServiceEnv TestParams {tmpPath} st = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let cfg =
BadgeServiceConfig
{ issuer = IssuerConfig issuerKeyFile 1,
codes = CodesConfig codeSecretFile 365,
web = Nothing,
btcpay = Nothing,
stripe = Nothing,
service = Nothing,
reconcile = Nothing,
throttle =
ThrottleConfig
{ signerFailure = BucketLimits {blCapacity = 10, blStartTokens = 10},
globalFailure = BucketLimits {blCapacity = 600, blStartTokens = 600},
catalog = BucketLimits {blCapacity = 600, blStartTokens = 600}
}
}
newBadgeServiceEnv cfg st getCurrentTime
-- Fix round 1 (unbounded per-signer map): the convincing form the review asked for. Driving
-- many distinct, never-before-seen signer keys through the pre-processing throttle check
-- (checkFailureBuckets, called for every signed purchaseBadge{code}) must NOT insert anything
-- into the per-signer bucket map -- a pre-check, however many times repeated or against
-- however many distinct attacker-minted keys, costs nothing. SignerBucketFamily's Haddock
-- states the property this proves directly: only an actual debit (a real failed redemption)
-- can grow the map.
testBadgeServiceThrottlePeekDoesNotGrowMap :: HasCallStack => TestParams -> IO ()
testBadgeServiceThrottlePeekDoesNotGrowMap ps =
withFreshBadgeStore ps $ \st -> do
bsEnv <- mkTestBadgeServiceEnv ps st
keys <- replicateM 500 (fst <$> mkTestKeyPair)
mapM_ (checkFailureBuckets bsEnv) keys
mapSize <- Map.size <$> readTVarIO (sbBuckets (signerFailureBucket bsEnv))
mapSize `shouldBe` 0
-- The other half: an ACTUAL failure (debitFailureBuckets, called by B7 after a classified
-- code_invalid/used/expired) DOES cost exactly one map entry per distinct signer -- the
-- intended, bounded cost (bounded by the shared global failure budget, since every debit also
-- spends one of its tokens; see SignerBucketFamily's Haddock). A sweep, given a `now'` far
-- enough past for that signer's own bucket to have fully refilled -- the injectable clock,
-- not a real sleep -- then reclaims every such entry.
testBadgeServiceThrottleDebitBoundedAndSweepEvicts :: HasCallStack => TestParams -> IO ()
testBadgeServiceThrottleDebitBoundedAndSweepEvicts ps =
withFreshBadgeStore ps $ \st -> do
bsEnv <- mkTestBadgeServiceEnv ps st
keys <- replicateM 20 (fst <$> mkTestKeyPair)
mapM_ (debitFailureBuckets bsEnv) keys
sizeAfterDebits <- Map.size <$> readTVarIO (sbBuckets (signerFailureBucket bsEnv))
sizeAfterDebits `shouldBe` 20 -- exactly one entry per distinct key that actually failed
-- 1 hour is far more than the 6 minutes a capacity-10/10-per-hour bucket needs to regain
-- the single token one debit spent; using the injectable clock, not a real sleep.
wellPastFullRefill <- addUTCTime 3600 <$> getCurrentTime
evicted <- atomically $ sweepSignerBuckets wellPastFullRefill (signerFailureBucket bsEnv)
evicted `shouldBe` 20
sizeAfterSweep <- Map.size <$> readTVarIO (sbBuckets (signerFailureBucket bsEnv))
sizeAfterSweep `shouldBe` 0
-- Fix round 1 (minor): a [throttle] capacity of 0 would otherwise reach bucketStatus's own
-- guard (an `error`, since a bucket that never refills has no finite retryAfter) and get
-- silently swallowed into a spurious internal by the catch-all. An operator typo should fail
-- fast at config-parse time instead, like every other malformed value in this file, naming
-- the offending key.
testBadgeServiceConfigThrottleZeroCapacity :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigThrottleZeroCapacity TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "zero-capacity-throttle.ini"
writeFile path $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ["", "[throttle]", "signer_failure_capacity = 0"]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("signer_failure_capacity" `isInfixOf`)
-- Applies every migration except 20260821_badge_service_web, then exercises that one
-- migration's up/down/up cycle directly, checking the three new tables appear and
-- disappear as expected.
testBadgeServiceWebOrderSchemaMigration :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebOrderSchemaMigration ps = do
let dbOpts = toDBOpts (dbOptions $ coreOptions $ mkBadgeServiceOpts ps) chatSuffix False chatDBFunctions
priorMigrations = filter ((/= migrationName) . name) badgeServiceSchemaMigrations
webOrderMigration = fromJust $ find ((== migrationName) . name) badgeServiceSchemaMigrations
downMigration = fromJust $ toDownMigration webOrderMigration
Right st <- createDBStore dbOpts priorMigrations (MigrationConfig MCError Nothing)
mapM_ (assertTableMissing st) newTables
runMigrationsToRun st $ MTRUp [webOrderMigration]
mapM_ (assertTableExists st) newTables
runMigrationsToRun st $ MTRDown [downMigration]
mapM_ (assertTableMissing st) newTables
runMigrationsToRun st $ MTRUp [webOrderMigration]
mapM_ (assertTableExists st) newTables
closeDBStore st
where
migrationName = "20260821_badge_service_web"
newTables = ["sx_badge_service_web_orders", "sx_badge_service_codes", "sx_badge_service_provider_events"]
assertTableExists st tbl =
withConnection st (\db -> DB.query_ db (tableCountQuery tbl)) `shouldReturn` [Only (0 :: Int)]
assertTableMissing st tbl = do
r <- try (withConnection st (\db -> DB.query_ db (tableCountQuery tbl))) :: IO (Either SomeException [Only Int])
case r of
Left _ -> pure ()
Right rows -> expectationFailure $ tbl <> " should not exist after down migration, got: " <> show rows
tableCountQuery tbl = fromString ("SELECT count(*) FROM " <> tbl)
-- Seeds a fresh database, seeds again, and asserts price/offer row counts are unchanged;
-- then deprecates one price directly with SQL (B1's setPriceStatus doesn't exist yet) and
-- asserts a further re-seed leaves it deprecated rather than reviving it.
testBadgeServiceCatalogSeeding :: HasCallStack => TestParams -> IO ()
testBadgeServiceCatalogSeeding ps = do
let dbOpts = toDBOpts (dbOptions $ coreOptions $ mkBadgeServiceOpts ps) chatSuffix False chatDBFunctions
Right st <- createDBStore dbOpts badgeServiceSchemaMigrations (MigrationConfig MCError Nothing)
seedCatalog st
pricesAfterFirstSeed <- rowCount st "sx_badge_service_badge_prices"
offersAfterFirstSeed <- rowCount st "sx_badge_service_badge_offers"
pricesAfterFirstSeed `shouldBe` 2
offersAfterFirstSeed `shouldBe` 4
[Only deprecatedPriceId] <-
withConnection st (\db -> DB.query_ db "SELECT price_id FROM sx_badge_service_badge_prices LIMIT 1") :: IO [Only Text]
withConnection
st
(\db -> DB.execute db "UPDATE sx_badge_service_badge_prices SET status = 'deprecated' WHERE price_id = ?" (Only deprecatedPriceId))
seedCatalog st
pricesAfterSecondSeed <- rowCount st "sx_badge_service_badge_prices"
offersAfterSecondSeed <- rowCount st "sx_badge_service_badge_offers"
pricesAfterSecondSeed `shouldBe` pricesAfterFirstSeed
offersAfterSecondSeed `shouldBe` offersAfterFirstSeed
[Only statusAfterReseed] <-
withConnection st (\db -> DB.query db "SELECT status FROM sx_badge_service_badge_prices WHERE price_id = ?" (Only deprecatedPriceId)) :: IO [Only Text]
statusAfterReseed `shouldBe` "deprecated"
closeDBStore st
where
rowCount :: DBStore -> String -> IO Int
rowCount st tbl = do
[Only n] <- withConnection st (\db -> DB.query_ db (fromString ("SELECT count(*) FROM " <> tbl)))
pure n
-- offerTotal must price 3 months at exactly 2x the monthly price and 12 months at exactly
-- 6x, for both badge types (UX §6.12).
testBadgeCatalogOfferTotal :: HasCallStack => TestParams -> IO ()
testBadgeCatalogOfferTotal _ps = do
now <- getCurrentTime
let BadgeCatalog {prices, offers} = defaultCatalog now
priceFor pid = fromJust $ find (\BadgePrice {priceId = pid'} -> pid' == pid) prices
mapM_ (assertOfferTotal priceFor) offers
where
assertOfferTotal priceFor offer@BadgeOffer {months, priceId = Just pid} = do
let BadgePrice {monthPrice = CurrencyAmount monthly} = priceFor pid
multiplier = if months == 3 then 2 else 6 :: Word32
case offerTotal (priceFor pid) (Just offer) of
Just (CurrencyAmount total) -> total `shouldBe` monthly * multiplier
Nothing -> expectationFailure "seeded offer must have a chargeable total"
assertOfferTotal _ BadgeOffer {priceId = Nothing} =
expectationFailure "seeded offer must be pinned to a price"
-- catalogTotals must fill total for all four seeded offers.
testBadgeCatalogTotalsFillsSeededOffers :: HasCallStack => TestParams -> IO ()
testBadgeCatalogTotalsFillsSeededOffers _ps = do
now <- getCurrentTime
let BadgeCatalog {offers} = catalogTotals (defaultCatalog now)
length offers `shouldBe` 4
all (\BadgeOffer {total} -> isJust total) offers `shouldBe` True
-- A Word8 subtraction of freeMonths from months is unsigned and unguarded: an offer with
-- freeMonths >= months (a typo, a future repricing) would wrap silently
-- (3 - 12 :: Word8 == 247) and hand out a wildly wrong charge. offerTotal must instead
-- answer Nothing -- a typed absence, not an 'error' -- so one malformed row read inside a
-- request (B6) can never take down the single-threaded request loop (§9).
testBadgeCatalogOfferTotalRejectsBadFreeMonths :: HasCallStack => TestParams -> IO ()
testBadgeCatalogOfferTotalRejectsBadFreeMonths _ps = do
now <- getCurrentTime
let BadgeCatalog {prices} = defaultCatalog now
price@BadgePrice {priceId} = fromJust $ find (\BadgePrice {badgeType} -> badgeType == BTSupporter) prices
badOffer =
BadgeOffer
{ offerId = BadgeOfferId "test-bad-offer-freeMonths-ge-months",
priceId = Just priceId,
months = 3,
discount = ODFreeMonths 12,
status = BISActive,
createdAt = now,
total = Nothing
}
offerTotal price (Just badOffer) `shouldBe` Nothing
-- The sibling hazard, on the ODDiscount side: a Word8 subtraction of percent from 100 is
-- unsigned and unguarded, so an offer with percent > 100 (a typo, a future repricing) would
-- wrap silently (100 - 101 :: Word8 == 255) and hand out a 2.55x OVERCHARGE, worse than the
-- freeMonths hazard above since it inflates the price instead of merely reading as
-- unavailable. offerTotal must instead answer Nothing, the same way, for the same reason.
testBadgeCatalogOfferTotalRejectsBadDiscount :: HasCallStack => TestParams -> IO ()
testBadgeCatalogOfferTotalRejectsBadDiscount _ps = do
now <- getCurrentTime
let BadgeCatalog {prices} = defaultCatalog now
price@BadgePrice {priceId} = fromJust $ find (\BadgePrice {badgeType} -> badgeType == BTSupporter) prices
badOffer =
BadgeOffer
{ offerId = BadgeOfferId "test-bad-offer-discount-gt-100",
priceId = Just priceId,
months = 3,
discount = ODDiscount 101,
status = BISActive,
createdAt = now,
total = Nothing
}
offerTotal price (Just badOffer) `shouldBe` Nothing
-- The third wrap in this money-computing module, after the two subtractions above: both arms of
-- offerTotal multiply a price by a month count, and a Word32 product wraps at 12 months above a
-- monthly price of ~35,791 currency units. A wrapped product is a WRONG CHARGE -- worse than the
-- freeMonths wrap, which only reads as unavailable -- so the intermediate is Word64 and the
-- result is range-checked back into the Word32 a CurrencyAmount holds. Unreachable at any
-- plausible price, and refused rather than trusted, exactly like its two siblings.
testBadgeCatalogOfferTotalRejectsOverflow :: HasCallStack => TestParams -> IO ()
testBadgeCatalogOfferTotalRejectsOverflow _ps = do
now <- getCurrentTime
let BadgeCatalog {prices} = defaultCatalog now
price@BadgePrice {priceId} = fromJust $ find (\BadgePrice {badgeType} -> badgeType == BTSupporter) prices
absurdPrice = price {monthPrice = CurrencyAmount (maxBound :: Word32)}
offerWith discount =
BadgeOffer
{ offerId = BadgeOfferId "test-overflow-offer",
priceId = Just priceId,
months = 12,
discount,
status = BISActive,
createdAt = now,
total = Nothing
}
-- 12 * maxBound overflows both ways round
offerTotal absurdPrice (Just (offerWith (ODDiscount 0))) `shouldBe` Nothing
offerTotal absurdPrice (Just (offerWith (ODFreeMonths 1))) `shouldBe` Nothing
-- but the same absurd price for exactly one month still fits, and is still priced
offerTotal absurdPrice Nothing `shouldBe` Just (CurrencyAmount maxBound)
-- BadgeItemStatus's JSON crosses the wire (BadgePrice/BadgeOffer.status), so pinning finding
-- 2's TextEncoding-derived encoding to what the earlier TH-derived instance produced proves
-- the change is invisible on the wire, not just asserted to be.
testBadgeItemStatusJsonWireFormat :: HasCallStack => TestParams -> IO ()
testBadgeItemStatusJsonWireFormat _ps = do
J.encode BISActive `shouldBe` "\"active\""
J.encode BISDeprecated `shouldBe` "\"deprecated\""
J.encode BISDisabled `shouldBe` "\"disabled\""
testBadgeServiceConfigMissingFile :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigMissingFile TestParams {tmpPath} = do
let path = tmpPath </> "missing.ini"
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` (path `isInfixOf`)
testBadgeServiceConfigUnparsableValue :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigUnparsableValue TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "unparsable.ini"
writeFile path $
unlines
[ "[issuer]",
"key_file = " <> issuerKeyFile,
"key_idx = not-a-number",
"",
"[codes]",
"secret_file = " <> codeSecretFile,
"default_expiry_days = 365"
]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("key_idx" `isInfixOf`)
testBadgeServiceConfigUnknownKey :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigUnknownKey TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "unknown-key.ini"
writeFile path $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ [ "",
"[web]",
"port = 8080",
"base_url = https://badges.example.org",
"support_contact = https://simplex.chat/contact",
"bogus_key = 1"
]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("bogus_key" `isInfixOf`)
testBadgeServiceConfigMissingIssuerSection :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigMissingIssuerSection TestParams {tmpPath} = do
(_issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "no-issuer.ini"
writeFile path $ unlines ["[codes]", "secret_file = " <> codeSecretFile, "default_expiry_days = 365"]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("[issuer]" `isInfixOf`)
testBadgeServiceConfigMissingCodesSection :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigMissingCodesSection TestParams {tmpPath} = do
(issuerKeyFile, _codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "no-codes.ini"
writeFile path $ unlines ["[issuer]", "key_file = " <> issuerKeyFile, "key_idx = 1"]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("[codes]" `isInfixOf`)
testBadgeServiceConfigHalfConfiguredBtcPay :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigHalfConfiguredBtcPay TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "half-btcpay.ini"
writeFile path $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ [ "",
"[web]",
"port = 8080",
"base_url = https://badges.example.org",
"support_contact = https://simplex.chat/contact",
"",
"[btcpay]",
"url = https://btcpay.example.org",
"store_id = teststore"
]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("btcpay" `isInfixOf`)
testBadgeServiceConfigHalfConfiguredStripe :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigHalfConfiguredStripe TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let stripeKeyFile = tmpPath </> "stripe.key"
path = tmpPath </> "half-stripe.ini"
writeFile path $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ [ "",
"[web]",
"port = 8080",
"base_url = https://badges.example.org",
"support_contact = https://simplex.chat/contact",
"",
"[stripe]",
"secret_key_file = " <> stripeKeyFile
]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("stripe" `isInfixOf`)
testBadgeServiceConfigProviderRequiresWeb :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigProviderRequiresWeb TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let apiKeyFile = tmpPath </> "btcpay-api.key"
webhookFile = tmpPath </> "btcpay-webhook.secret"
path = tmpPath </> "provider-no-web.ini"
writeFile apiKeyFile "api-key\n"
writeFile webhookFile "webhook-secret\n"
writeFile path $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ [ "",
"[btcpay]",
"url = https://btcpay.example.org",
"store_id = teststore",
"api_key_file = " <> apiKeyFile,
"webhook_secret_file = " <> webhookFile
]
Left err <- readBadgeServiceConfig path
err `shouldSatisfy` ("[web]" `isInfixOf`)
testBadgeServiceConfigMinimalStarts :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigMinimalStarts TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "minimal.ini"
writeFile path $ unlines (issuerCodesIniLines issuerKeyFile codeSecretFile)
result <- readBadgeServiceConfig path
case result of
Right BadgeServiceConfig {web} -> web `shouldBe` Nothing
Left err -> expectationFailure $ "expected a minimal config to parse, got: " <> err
-- Proves a fully populated ini -- issuer, codes, web and both providers -- starts the real
-- service end to end, not just that readBadgeServiceConfig accepts it.
testBadgeServiceCompleteConfigStarts :: HasCallStack => TestParams -> IO ()
testBadgeServiceCompleteConfigStarts ps@TestParams {tmpPath} =
withBadgeServiceConfig ps writeCompleteConfig (pure ()) $ \client bsLink -> do
-- What matters here is that the service starts and answers at all; the request omits
-- purchaseBadge's required badgeRequest on purpose, so the real dispatcher's decode step
-- (B5) fails it with bad_request -- a stable, step-independent response, unlike e.g.
-- getBadgeCatalog's answer, which will change once B6 lands.
let redeemReq =
"{\"version\":1,\"request\":{\"type\":\"purchaseBadge\",\"payment\":{\"type\":\"code\",\"code\":\"TEST-CODE\"}}}"
client ##> ("/_service_request 1 " <> bsLink <> " " <> redeemReq)
client <## "service response: {\"code\":\"bad_request\",\"type\":\"error\"}"
where
writeCompleteConfig port = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let apiKeyFile = tmpPath </> "btcpay-api.key"
btcWebhookFile = tmpPath </> "btcpay-webhook.secret"
stripeKeyFile = tmpPath </> "stripe.key"
stripeWebhookFile = tmpPath </> "stripe-webhook.secret"
writeFile apiKeyFile "btcpay-api-key\n"
writeFile btcWebhookFile "btcpay-webhook-secret\n"
writeFile stripeKeyFile "sk_test_123\n"
writeFile stripeWebhookFile "whsec_test_123\n"
writeFile (badgeServiceConfigPath tmpPath) $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ [ "",
"[web]",
"port = " <> show port,
"base_url = https://badges.example.org",
"support_contact = https://simplex.chat/contact",
"",
"[btcpay]",
"url = https://btcpay.example.org",
"store_id = teststore",
"api_key_file = " <> apiKeyFile,
"webhook_secret_file = " <> btcWebhookFile,
"",
"[stripe]",
"secret_key_file = " <> stripeKeyFile,
"webhook_secret_file = " <> stripeWebhookFile
]
-- B9 service address publication -----------------------------------------------------------
-- Proves both facts the brief's manual Verify line asks for: 'address_file' holds the SAME
-- address after the first start (before 'betweenPhases') as after the second, and that address
-- -- read straight out of the file, not from 'bsLink' -- is one a client's @/c@ accepts (sends a
-- connection request for, rather than rejecting as malformed). The badge service disables
-- auto-accept ('badgePostStartHook' passes 'False' to 'initializeBotAddress''), so "connection
-- request sent!" is as far as this goes and is the right stopping point -- 'BroadcastTests.hs'
-- (an auto-accepting bot) is the pattern this borrows the client side of.
--
-- Read strictly: Prelude's lazy 'readFile' would leave 'addressFile' open (a thunk holding the
-- handle) across 'betweenPhases' into the second start, and the second start's own write --
-- exclusive against any still-open handle, even a reader's -- would then fail with "resource
-- busy (file is locked)".
testBadgeServicePublishesAddressFile :: HasCallStack => TestParams -> IO ()
testBadgeServicePublishesAddressFile ps@TestParams {tmpPath} = do
let addressFile = tmpPath </> "bot_address.txt"
writeConfig _port = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
writeFile (badgeServiceConfigPath tmpPath) $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ["", "[service]", "address_file = " <> addressFile]
firstContentsRef <- newIORef T.empty
withBadgeServiceConfig
ps
writeConfig
(TIO.readFile addressFile >>= writeIORef firstContentsRef)
$ \client bsLink -> do
firstContents <- readIORef firstContentsRef
secondContents <- TIO.readFile addressFile
firstContents `shouldBe` secondContents
T.strip secondContents `shouldBe` T.pack bsLink
client ##> ("/c " <> T.unpack (T.strip secondContents))
client <## "connection request sent!"
-- B6 getBadgeCatalog -----------------------------------------------------------
-- A disabled price (and every offer pinned to it) must be absent from the RPC catalog, while
-- a deprecated price (and its offers) must still be present -- getActiveCatalog's own
-- invariant (already proved at the store level by testBadgeStoreSetPriceStatusDisabled),
-- surfaced here through the live RPC path handleGetBadgeCatalog actually calls. Also asserts
-- decision 8 at the wire boundary: every remaining offer's total is populated, so a client
-- never has to (and can't, since it doesn't have the prices) compute one itself -- deleting
-- the handler's catalogTotals call would still pass every other assertion in this test file
-- without this one.
testBadgeServiceGetCatalogDisabledDeprecated :: HasCallStack => TestParams -> IO ()
testBadgeServiceGetCatalogDisabledDeprecated ps = do
priceIdsRef <- newIORef Nothing
let seedStatuses =
withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
priceIds <- expectRight $ withServiceTransaction (chatStore (chatController bs)) $ \db -> do
BadgeCatalog {prices} <- getActiveCatalog db
case prices of
[BadgePrice {priceId = pid1}, BadgePrice {priceId = pid2}] -> do
setPriceStatus db pid1 BISDisabled
setPriceStatus db pid2 BISDeprecated
pure (pid1, pid2)
_ -> error "expected exactly the two default seeded prices"
writeIORef priceIdsRef (Just priceIds)
withBadgeServiceConfig ps (writeTestBadgeServiceConfig ps) seedStatuses $ \client bsLink -> do
Just (disabledId, deprecatedId) <- readIORef priceIdsRef
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Nothing, request = BSCGetBadgeCatalog}
sendServiceRequest client bsLink req
resp <- getServiceResponse client
case resp of
BSPBadgeCatalog {catalog = BadgeCatalog {prices, offers}} -> do
any (\BadgePrice {priceId} -> priceId == disabledId) prices `shouldBe` False
any (\BadgePrice {priceId} -> priceId == deprecatedId) prices `shouldBe` True
any (\BadgeOffer {priceId} -> priceId == Just disabledId) offers `shouldBe` False
any (\BadgeOffer {priceId} -> priceId == Just deprecatedId) offers `shouldBe` True
all (\BadgeOffer {total} -> isJust total) offers `shouldBe` True
other -> expectationFailure $ "expected BSPBadgeCatalog, got: " <> show other
-- getBadgeCatalog applies checkSignerRecord like every other signed command (B5): a signed
-- request from a key with no purchase row must fail the identity check before
-- handleGetBadgeCatalog ever runs, the same way B5 already proved for issueBadge/pauseBadge.
testBadgeServiceGetCatalogUnknownSignerKey :: HasCallStack => TestParams -> IO ()
testBadgeServiceGetCatalogUnknownSignerKey ps =
withBadgeService ps $ \client bsLink -> do
(pub, priv) <- mkTestKeyPair
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = BSCGetBadgeCatalog}
sendSignedServiceRequest client bsLink priv req
client <## "service response: {\"code\":\"unknown_purchase_key\",\"type\":\"error\"}"
-- StatementEntry is constructed/matched positionally (see the import list's Haddock): field
-- order entryId, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType,
-- wasPausedSince, createdAt, entryType. Used to compare two statements for equality without
-- needing an Eq instance on StatementEntry itself (there isn't one).
statementEntryKey :: StatementEntry -> (Text, Int, Int)
statementEntryKey (StatementEntry entryId changeMonths balanceMonths _ _ _ _ _) = (entryId, changeMonths, balanceMonths)
-- A signed getBadgeCatalog heals the purchase's ledger to `now` (B2's `advance`) in the SAME
-- transaction that reads the statement back (RPC "Statement and balance"), so the balance the
-- client is told is the balance the database holds. With balance_start_ts backdated two
-- months on a balance of 3, healing appends exactly one debit(lapse) of -2, leaving a balance
-- of 1; an identical second request must append nothing further, since the ledger is already
-- healed to (approximately) now.
testBadgeServiceGetCatalogHealsLedger :: HasCallStack => TestParams -> IO ()
testBadgeServiceGetCatalogHealsLedger ps = do
(pub, priv) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
purchaseIdRef <- newIORef Nothing
let seedBackdatedLedger =
withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
now <- getCurrentTime
let backdated = addMonths (-2) now
badgePurchaseId <- expectRight $ withServiceTransaction (chatStore (chatController bs)) $ \db -> do
BadgePurchaseRow {badgePurchaseId} <- createPurchase db pub masterKey BTSupporter now
_ <-
appendLedgerEntry
db
BadgeLedgerEntry
{ entryId = 0,
entryUuid = "test-opening-entry",
badgePurchaseId,
changeMonths = 3,
balanceMonths = 3,
balanceStartTs = backdated,
balanceBadgeType = BTSupporter,
wasPausedSince = Nothing,
serviceCreatedAt = now,
createdAt = now,
entryType = LECredit CTOpening
}
pure badgePurchaseId
writeIORef purchaseIdRef (Just badgePurchaseId)
statement1Ref <- newIORef Nothing
statement2Ref <- newIORef Nothing
withBadgeServiceConfig ps (writeTestBadgeServiceConfig ps) seedBackdatedLedger $ \client bsLink -> do
let req = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = BSCGetBadgeCatalog}
sendSignedServiceRequest client bsLink priv req
resp1 <- getServiceResponse client
case resp1 of
BSPBadgeCatalog {badgeStatement = Just stmt} -> writeIORef statement1Ref (Just stmt)
other -> expectationFailure $ "expected a statement on the first request, got: " <> show other
sendSignedServiceRequest client bsLink priv req
resp2 <- getServiceResponse client
case resp2 of
BSPBadgeCatalog {badgeStatement = Just stmt} -> writeIORef statement2Ref (Just stmt)
other -> expectationFailure $ "expected a statement on the second request, got: " <> show other
Just badgePurchaseId <- readIORef purchaseIdRef
Just BadgeStatement {entries = entries1} <- readIORef statement1Ref
Just BadgeStatement {entries = entries2} <- readIORef statement2Ref
case entries1 of
[_opening, StatementEntry _ changeMonths balanceMonths _ _ _ _ (SEDebit SDLapse)] -> do
changeMonths `shouldBe` (-2)
balanceMonths `shouldBe` 1
other -> expectationFailure $ "expected exactly [opening, lapse(-2)], got " <> show (length other) <> " entries"
map statementEntryKey entries2 `shouldBe` map statementEntryKey entries1 -- second request heals nothing further
-- the balance the RPC reported must match a freshly read getLastLedgerEntry -- reopened
-- only after the service (and its exclusive hold on the sqlite file) has been killed, same
-- as withBadgeServiceConfig's own between-phases reopen.
lastEntry <-
withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
expectRight $ withServiceTransaction (chatStore (chatController bs)) $ \db -> getLastLedgerEntry db badgePurchaseId
case (lastEntry, entries1) of
(Just BadgeLedgerEntry {balanceMonths = storedBalance}, [_, StatementEntry _ _ wireBalance _ _ _ _ _]) ->
storedBalance `shouldBe` wireBalance
_ -> expectationFailure "expected a stored last ledger entry matching the wire statement's balance"
-- The catalog bucket (B5 decision 5) is spent by every UNSIGNED getBadgeCatalog, success or
-- not; a signed request never touches it (bounded instead by requiring a purchase row).
-- Overriding the bucket to capacity 2 via A6's [throttle] harness: the first two unsigned
-- requests in the window succeed, a third gives rate_limited with a positive retryAfter, and
-- a signed request (from a key with a real purchase row) is unaffected by the drained bucket.
testBadgeServiceGetCatalogBucketThrottle :: HasCallStack => TestParams -> IO ()
testBadgeServiceGetCatalogBucketThrottle ps = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets (tmpPath ps)
(pub, priv) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
let writeConfig _port =
writeFile (badgeServiceConfigPath (tmpPath ps)) $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ["", "[throttle]", "catalog_capacity = 2", "catalog_start_tokens = 2"]
seedPurchase =
withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
now <- getCurrentTime
void $ expectRight $ withServiceTransaction (chatStore (chatController bs)) $ \db -> createPurchase db pub masterKey BTSupporter now
withBadgeServiceConfig ps writeConfig seedPurchase $ \client bsLink -> do
let unsignedReq = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Nothing, request = BSCGetBadgeCatalog}
signedReq = BadgeServiceRequest {version = VersionBadgeService 1, purchaseKey = Just pub, request = BSCGetBadgeCatalog}
expectCatalog label client' = do
resp <- getServiceResponse client'
case resp of
BSPBadgeCatalog {} -> pure ()
other -> expectationFailure $ label <> " expected BSPBadgeCatalog, got: " <> show other
-- drain the 2-token catalog bucket
sendServiceRequest client bsLink unsignedReq
expectCatalog "first unsigned request" client
sendServiceRequest client bsLink unsignedReq
expectCatalog "second unsigned request" client
-- a third request in the same window is rejected before processing
sendServiceRequest client bsLink unsignedReq
respObj <- getServiceResponseObject client
KM.lookup "code" respObj `shouldBe` Just (J.String "rate_limited")
case KM.lookup "retryAfter" respObj of
Just (J.Number n) -> n `shouldSatisfy` (> 0)
other -> expectationFailure $ "expected a positive retryAfter, got: " <> show other
-- a signed request bypasses the catalog bucket entirely, even fully drained
sendSignedServiceRequest client bsLink priv signedReq
expectCatalog "signed request" client
-- B7 purchaseBadge{code} and issueBadge, B8 codes tooling (B10) ---------------
--
-- B7's and B8's Verify lines are both "covered by B10", so everything below is the only check
-- those two steps have. Three rules shape how they assert:
-- * time is the injected clock (A6), never a sleep: 'withBadgeServiceClock' hands the service
-- an 'IORef UTCTime' the test moves forward;
-- * expected values are literal (fixed dates, fixed row shapes), never recomputed the way
-- production computes them;
-- * "wrote nothing" is a row count, never "the response looked the same".
-- | The instant every clock-driven test starts service time at. Fixed rather than derived from
-- 'getCurrentTime' so periods and expiries below are literal dates: 2026-03-10 is a Tuesday, so
-- a first period ends Friday 2026-04-10 and its credential expires Sunday 2026-04-12 (B4).
testClockStart :: UTCTime
testClockStart = UTCTime (fromGregorian 2026 3 10) noonUTC
noonUTC :: DiffTime
noonUTC = secondsToDiffTime (12 * 3600)
-- The end of a period issued at 'testClockStart': one month on, no clamping involved.
firstPeriodEnd :: UTCTime
firstPeriodEnd = UTCTime (fromGregorian 2026 4 10) noonUTC
-- And that period's credential expiry: the Sunday after 'firstPeriodEnd', spelled out rather
-- than computed with 'sundayAfter', which is the function under test.
firstCredentialExpiry :: UTCTime
firstCredentialExpiry = UTCTime (fromGregorian 2026 4 12) sundayEndOfDay
-- Far enough ahead of 'testClockStart' that no seeded code expires during a test.
testCodeExpiry :: UTCTime
testCodeExpiry = UTCTime (fromGregorian 2027 1 1) 0
-- Behind 'testClockStart': the code_expired case.
expiredCodeExpiry :: UTCTime
expiredCodeExpiry = UTCTime (fromGregorian 2026 1 1) 0
testCodeBatch :: Text
testCodeBatch = "b10-test-batch"
-- | The injectable service clock (A6) as a settable fixture: @readIORef@ is what
-- 'withBadgeServiceClock' installs as 'BadgeServiceEnv.now', the only clock any service
-- component reads. Moving it is how every time-dependent behaviour below is driven; no test
-- sleeps.
newTestClock :: UTCTime -> IO (IORef UTCTime)
newTestClock = newIORef
setTestClock :: IORef UTCTime -> UTCTime -> IO ()
setTestClock = writeIORef
advanceTestClockMonths :: IORef UTCTime -> Int -> IO ()
advanceTestClockMonths clock n = modifyIORef' clock (addMonths n)
advanceTestClockSeconds :: IORef UTCTime -> NominalDiffTime -> IO ()
advanceTestClockSeconds clock s = modifyIORef' clock (addUTCTime s)
-- | One client identity: the keypair the request is signed with (which is also the purchase
-- key) and the badge master key its credential is signed over. C4 mints a fresh one per
-- redemption, so most tests below use one signer per purchase.
data TestSigner = TestSigner
{ signerPub :: C.PublicKeyEd25519,
signerPriv :: C.PrivateKeyEd25519,
signerMaster :: BadgeMasterKey
}
newTestSigner :: IO TestSigner
newTestSigner = do
(signerPub, signerPriv) <- mkTestKeyPair
signerMaster <- BadgeMasterKey <$> getRandomBytes 32
pure TestSigner {signerPub, signerPriv, signerMaster}
badgeRequestFor :: TestSigner -> BadgeType -> Text -> BadgeRequest
badgeRequestFor TestSigner {signerMaster} badgeType badgeExtra =
BadgeRequest {masterKey = signerMaster, badgeInfo = BadgeInfo {badgeType, badgeExpiry = Nothing, badgeExtra}}
-- | A @purchaseBadge{code}@ request with the badgeRequest and upgrade spelled out, so the tests
-- that vary either (a tier the code does not fund, a reserved badgeExtra, an upgrade) go through
-- the same builder as a normal redemption.
purchaseRequest :: TestSigner -> BadgeRequest -> Maybe BadgeUpgrade -> Text -> BadgeServiceRequest
purchaseRequest TestSigner {signerPub} badgeRequest upgrade code =
BadgeServiceRequest
{ version = VersionBadgeService 1,
purchaseKey = Just signerPub,
request = BSCPurchaseBadge {badgeRequest, payment = SPCode code, upgrade}
}
-- The normal redemption: the tier the code funds, no reserved extra, no upgrade.
purchaseCodeRequest :: TestSigner -> BadgeType -> Text -> BadgeServiceRequest
purchaseCodeRequest signer badgeType = purchaseRequest signer (badgeRequestFor signer badgeType "") Nothing
-- | An @issueBadge@ asserting @entryUuid@ as the last entry the client holds. Only that field of
-- the asserted entry is read by the service ('assertedEntryId'); the rest is filler, constructed
-- positionally (see the import list's Haddock).
issueRequest :: TestSigner -> BadgeType -> Text -> Text -> BadgeServiceRequest
issueRequest signer@TestSigner {signerPub} badgeType badgeExtra entryUuid =
BadgeServiceRequest
{ version = VersionBadgeService 1,
purchaseKey = Just signerPub,
request =
BSCIssueBadge
{ badgeRequest = badgeRequestFor signer badgeType badgeExtra,
balance = BadgeBalance (StatementEntry entryUuid 1 1 testClockStart badgeType Nothing testClockStart (SECredit SCOpening))
}
}
-- An entry uuid the service holds for nobody.
unknownEntryUuid :: Text
unknownEntryUuid = "b10-no-such-entry"
sendRequest :: TestCC -> String -> TestSigner -> BadgeServiceRequest -> IO ()
sendRequest client bsLink TestSigner {signerPriv} = sendSignedServiceRequest client bsLink signerPriv
-- | Decodes one 'BSPBadgeCredential' and asserts, on every single one of them, that @receipt@ is
-- absent: nothing in this milestone issues a receipt, and B7 hardcodes 'Nothing' -- checking it
-- here is what makes that hold for every credential response in the file rather than one.
expectCredentialResponse :: HasCallStack => String -> TestCC -> IO (Maybe BadgeCredential, BadgeStatement)
expectCredentialResponse label client =
getServiceResponse client >>= \case
BSPBadgeCredential {credential, receipt, statement} -> do
(label, receipt) `shouldBe` (label, Nothing)
pure (credential, statement)
other -> expectationFailure (label <> ": expected BSPBadgeCredential, got: " <> show other) >> error "unreachable"
expectCredential :: HasCallStack => String -> TestCC -> IO (BadgeCredential, BadgeStatement)
expectCredential label client =
expectCredentialResponse label client >>= \case
(Just cred, statement) -> pure (cred, statement)
(Nothing, _) -> expectationFailure (label <> ": expected a credential, got none") >> error "unreachable"
expectErrorCode :: HasCallStack => String -> TestCC -> Text -> IO ()
expectErrorCode label client code = do
obj <- getServiceResponseObject client
(label, KM.lookup "code" obj) `shouldBe` (label, Just (J.String code))
-- rate_limited always carries a retryAfter the client can act on (B5).
expectRateLimited :: HasCallStack => String -> TestCC -> IO ()
expectRateLimited label client = do
obj <- getServiceResponseObject client
(label, KM.lookup "code" obj) `shouldBe` (label, Just (J.String "rate_limited"))
case KM.lookup "retryAfter" obj of
Just (J.Number n) -> (label, n > 0) `shouldBe` (label, True)
other -> expectationFailure $ label <> ": expected a positive retryAfter, got: " <> show other
-- | The wire statement as @(changeMonths, balanceMonths, entry type)@, for comparison against a
-- literal list. 'StatementEntry' is matched positionally (see the import list's Haddock).
statementShape :: BadgeStatement -> [(Int, Int, String)]
statementShape BadgeStatement {entries} = map entryShape entries
where
entryShape (StatementEntry _ changeMonths balanceMonths _ _ _ _ entryType) = (changeMonths, balanceMonths, entryTypeLabel entryType)
-- Spelled out rather than derived, so 'credit payment' with an invoiceId can never read the same
-- as one without: a code payment has no invoice (B7 §3), and that is what the client is told.
entryTypeLabel :: StatementEntryType -> String
entryTypeLabel = \case
SECredit (SCPayment Nothing) -> "credit payment (no invoiceId)"
SECredit (SCPayment (Just _)) -> "credit payment (with invoiceId)"
SECredit SCOpening -> "credit opening"
SECredit _ -> "credit (other)"
SEDebit SDBadge -> "debit badge"
SEDebit SDLapse -> "debit lapse"
SEDebit _ -> "debit (other)"
statementEntryIds :: BadgeStatement -> [Text]
statementEntryIds BadgeStatement {entries} = map (\(StatementEntry entryId _ _ _ _ _ _ _) -> entryId) entries
-- | Runs a store transaction against the service's database while the service is NOT running --
-- the harness's between-phases window, or after its body has returned. Opening a second
-- connection during a running phase deadlocks against the service's writer lock (see
-- 'withBadgeServiceConfig').
withServiceStore :: HasCallStack => TestParams -> (DB.Connection -> ExceptT ServiceError IO a) -> IO a
withServiceStore ps action =
withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
expectRight $ withServiceTransaction (chatStore (chatController bs)) action
withServiceDB :: HasCallStack => TestParams -> (DB.Connection -> IO a) -> IO a
withServiceDB ps action = withServiceStore ps (liftIO . action)
serviceRowCount :: DB.Connection -> String -> IO Int
serviceRowCount db table = do
[Only n] <- DB.query_ db (fromString ("SELECT count(*) FROM sx_badge_service_" <> table))
pure n
-- | The five row kinds one redemption writes, counted together: purchases, payments, ledger
-- entries, issuances, redeemed codes. Every "writes nothing" assertion below pins this tuple,
-- because an unchanged response is not evidence that nothing was written.
serviceRowCounts :: DB.Connection -> IO (Int, Int, Int, Int, Int)
serviceRowCounts db = do
purchases <- serviceRowCount db "badge_purchases"
payments <- serviceRowCount db "payments"
entries <- serviceRowCount db "badge_ledger"
issuances <- serviceRowCount db "badge_issuances"
[Only redeemed] <- DB.query_ db "SELECT count(*) FROM sx_badge_service_codes WHERE redeemed_purchase_id IS NOT NULL"
pure (purchases, payments, entries, issuances, redeemed)
-- | The ledger as the database holds it, in append order: @(changeMonths, balanceMonths,
-- entry_type, entry_credit_type, entry_debit_type, payment_id)@. Read as raw columns rather than
-- through 'getLedgerSince', so the assertion pins what was written rather than what the decoder
-- makes of it.
serviceLedgerRows :: DB.Connection -> IO [(Int, Int, Text, Maybe Text, Maybe Text, Maybe Text)]
serviceLedgerRows db =
DB.query_
db
"SELECT change_months, balance_months, entry_type, entry_credit_type, entry_debit_type, payment_id FROM sx_badge_service_badge_ledger ORDER BY entry_id"
-- | Stored timestamps are compared within a second: Postgres TIMESTAMPTZ truncates to
-- microseconds, so an exact 'UTCTime' round trip is not guaranteed on that backend (the same
-- reason 'testBadgeStoreUnredeemCode' gives). Every value compared this way differs from a wrong
-- one by days, never by microseconds.
shouldBeStoredAt :: HasCallStack => UTCTime -> UTCTime -> Expectation
shouldBeStoredAt stored expected
| abs (diffUTCTime stored expected) < 1 = pure ()
| otherwise = expectationFailure $ "stored timestamp " <> show stored <> " should have been " <> show expected
-- | Mints plaintext codes with B3's generator and stores only their hashes, exactly as B8's
-- @codes issue@ does. Returns the plaintexts, which from here on exist only in the test.
seedTestCodes :: HasCallStack => TestParams -> [(BadgeType, Word8, UTCTime)] -> IO [Text]
seedTestCodes ps specs = do
drg <- C.newRandom
plainCodes <- replicateM (length specs) (Codes.generateBatchCode drg)
withServiceStore ps $ \db -> insertCodes db (zipWith newCode plainCodes specs) testClockStart
pure plainCodes
where
newCode plain (badgeType, months, expiresAt) =
NewBadgeCode {codeHash = Codes.codeHash (Codes.normalizeCode plain), badgeType, months, batch = testCodeBatch, expiresAt}
-- One supporter code funding three months, the shape almost every test below starts from.
seedOneSupporterCode :: HasCallStack => TestParams -> IO Text
seedOneSupporterCode ps =
seedTestCodes ps [(BTSupporter, 3, testCodeExpiry)] >>= \case
[code] -> pure code
other -> expectationFailure ("expected exactly one seeded code, got " <> show (length other)) >> error "unreachable"
-- | A well-formed code (valid check character) that was never inserted: the @code_invalid@ that
-- does reach the database and finds nothing there.
mintUnknownCode :: IO Text
mintUnknownCode = C.newRandom >>= Codes.generateBatchCode
-- | The same code with its check character replaced by a different one. B3 rejects it in
-- 'classifyRedemption' before 'getCodeByHash' is ever forced, so this is the @code_invalid@ that
-- never reaches the database at all.
corruptCheckCharacter :: Text -> Text
corruptCheckCharacter code = T.init code <> (if T.last code == '0' then "1" else "0")
codeIsRedeemed :: HasCallStack => TestParams -> Text -> IO Bool
codeIsRedeemed ps code =
withServiceStore ps (\db -> getCodeByHash db (Codes.codeHash (Codes.normalizeCode code))) >>= \case
Just (BadgeCode {redeemedPurchaseId}, _) -> pure (isJust redeemedPurchaseId)
Nothing -> expectationFailure "expected the seeded code to exist" >> error "unreachable"
-- | The public half of the issuer key 'writeTestBadgeServiceSecrets' generated, read back from
-- the same two-line key file the service loaded its secret from -- so 'verifyCredential' below
-- verifies against the key that actually signed, not one the test made up.
readTestIssuerPublicKey :: HasCallStack => FilePath -> IO BBSPublicKey
readTestIssuerPublicKey tmpPath = do
contents <- BC.readFile (testIssuerKeyFile tmpPath)
case mapMaybe (BC.stripPrefix "public ") (BC.lines contents) of
(b64 : _) -> either (\e -> expectationFailure ("issuer key file: " <> e) >> error "unreachable") pure (strDecode b64)
[] -> expectationFailure "issuer key file has no 'public' line" >> error "unreachable"
-- B10 item 1 / brief 1-2: the happy path, end to end. Every assertion here is one nothing else
-- in the plan makes: the credential verifies under the issuer key the service actually loaded,
-- the ledger is exactly credit(payment) then debit(badge), the wire statement says the same, the
-- credit entry references the payments row that was written for it, and the issuance's stored
-- expiry is the credential's own (the disagreement 'credentialExpiry' refuses to allow).
testBadgeServiceRedeemCodeIssuesCredential :: HasCallStack => TestParams -> IO ()
testBadgeServiceRedeemCodeIssuesCredential ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
(cred, statement) <- expectCredential "redeem" client
statementShape statement `shouldBe` [(3, 3, "credit payment (no invoiceId)"), (-1, 2, "debit badge")]
previousEntryId statement `shouldBe` Nothing
let BadgeCredential {masterKey = credMasterKey, badgeInfo = BadgeInfo {badgeType, badgeExpiry, badgeExtra}} = cred
badgeType `shouldBe` BTSupporter
badgeExtra `shouldBe` ""
badgeExpiry `shouldBe` Just firstCredentialExpiry
credMasterKey `shouldBe` signerMaster signer
issuerPub <- readTestIssuerPublicKey (tmpPath ps)
verifyCredential issuerPub cred `shouldReturn` True
withServiceDB ps $ \db -> do
serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
[Only paymentRowId] <- DB.query_ db "SELECT payment_id FROM sx_badge_service_payments"
[Only purchasePaymentId] <- DB.query_ db "SELECT payment_id FROM sx_badge_service_badge_purchases"
ledgerRows <- serviceLedgerRows db
ledgerRows
`shouldBe` [ (3, 3, "credit", Just "payment", Nothing, Just paymentRowId),
(-1, 2, "debit", Nothing, Just "badge", Nothing)
]
purchasePaymentId `shouldBe` Just (paymentRowId :: Text)
[(issuanceExpiry, periodStart, periodEnd)] <-
DB.query_ db "SELECT expiry, period_start, period_end FROM sx_badge_service_badge_issuances"
issuanceExpiry `shouldBeStoredAt` firstCredentialExpiry
periodStart `shouldBeStoredAt` testClockStart
periodEnd `shouldBeStoredAt` firstPeriodEnd
-- nothing in this milestone issues a receipt, so no row records the hash of one
[Only receiptHashes] <- DB.query_ db "SELECT count(*) FROM sx_badge_service_payments WHERE receipt_hash IS NOT NULL"
(receiptHashes :: Int) `shouldBe` 0
-- B10 item 2 (first half) / brief 3: an identical repeat returns the SAME credential and writes
-- no row of any kind. The row counts are the assertion -- an idempotent handler and a handler
-- that redeemed the code twice can return identical-looking responses.
testBadgeServiceRedeemCodeIdempotent :: HasCallStack => TestParams -> IO ()
testBadgeServiceRedeemCodeIdempotent ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
let req = purchaseCodeRequest signer BTSupporter code
sendRequest client bsLink signer req
(cred1, statement1) <- expectCredential "first redemption" client
sendRequest client bsLink signer req
(cred2, statement2) <- expectCredential "identical repeat" client
cred2 `shouldBe` cred1
statementShape statement2 `shouldBe` statementShape statement1
statementEntryIds statement2 `shouldBe` statementEntryIds statement1
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- B10 item 2 (second half): a repeat AFTER months have lapsed still returns the same credential,
-- and appends exactly one debit(lapse) -- no second credit, payment, debit(badge), issuance or
-- redemption. The heal is intended (the RPC has the service heal before answering any
-- statement); a future reader must not "fix" it away.
testBadgeServiceReplayAfterLapseHealsOnce :: HasCallStack => TestParams -> IO ()
testBadgeServiceReplayAfterLapseHealsOnce ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
let req = purchaseCodeRequest signer BTSupporter code
sendRequest client bsLink signer req
(cred1, _) <- expectCredential "first redemption" client
-- two months on: the balance of 2 covered one of them, so exactly one month lapses
advanceTestClockMonths clock 2
sendRequest client bsLink signer req
(cred2, statement) <- expectCredential "replay after a lapse" client
cred2 `shouldBe` cred1
statementShape statement
`shouldBe` [(3, 3, "credit payment (no invoiceId)"), (-1, 2, "debit badge"), (-1, 1, "debit lapse")]
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 3, 1, 1)
-- Brief 4: the same code from a second purchase key is code_used, and that second key gets no
-- purchase row out of the attempt.
testBadgeServiceCodeUsedBySecondKey :: HasCallStack => TestParams -> IO ()
testBadgeServiceCodeUsedBySecondKey ps = do
clock <- newTestClock testClockStart
firstSigner <- newTestSigner
secondSigner <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
sendRequest client bsLink firstSigner (purchaseCodeRequest firstSigner BTSupporter code)
_ <- expectCredential "first key redeems" client
sendRequest client bsLink secondSigner (purchaseCodeRequest secondSigner BTSupporter code)
expectErrorCode "second key" client "code_used"
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- Brief 5 / B10 item 4 (the outcomes half) and item 5: every classified failure, each answered
-- with its own code, and none of them consuming the code it was presented. A revoked code must
-- read exactly like one that never existed.
testBadgeServiceCodeFailureOutcomes :: HasCallStack => TestParams -> IO ()
testBadgeServiceCodeFailureOutcomes ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codesRef <- newIORef []
let seedCodes =
seedTestCodes ps [(BTSupporter, 3, expiredCodeExpiry), (BTSupporter, 3, testCodeExpiry)] >>= \case
codes@[_expired, revoked] -> do
withServiceStore ps $ \db -> revokeCode db (Codes.codeHash (Codes.normalizeCode revoked)) testClockStart
writeIORef codesRef codes
other -> expectationFailure $ "expected two seeded codes, got " <> show (length other)
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCodes $ \client bsLink -> do
[expiredCode, revokedCode] <- readIORef codesRef
unknownCode <- mintUnknownCode
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter unknownCode)
expectErrorCode "unknown code" client "code_invalid"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter (corruptCheckCharacter unknownCode))
expectErrorCode "bad check character" client "code_invalid"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter revokedCode)
expectErrorCode "revoked code" client "code_invalid"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter expiredCode)
expectErrorCode "expired code" client "code_expired"
[expiredCode, revokedCode] <- readIORef codesRef
codeIsRedeemed ps expiredCode `shouldReturn` False
codeIsRedeemed ps revokedCode `shouldReturn` False
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (0, 0, 0, 0, 0)
-- B10 item 4 (the accounting half) and the brief's per-signer throttle bullet, in one sequence
-- against a bucket of 3 tokens. A success, a same-key replay, a reserved badgeExtra and a tier
-- mismatch must debit NOTHING -- if any of them did, the third genuine failure below would come
-- back rate_limited instead of code_invalid. The fourth genuine failure must be rate_limited,
-- which pins each of the three preceding ones at exactly one token. A fresh signer is unaffected
-- by another signer's drained bucket and gets its own outcome.
--
-- BOTH buckets are overridden, because "debits neither" is a claim about both and every debit
-- spends one token from each. The global budget is set to exactly one more than the number of
-- genuine failures here (3 + the fresh signer's one at the end), so a single spurious GLOBAL
-- debit anywhere in the four non-failure requests leaves nothing for that last request and turns
-- it into rate_limited. With the global left at its default 600, a service that debited the
-- shared budget on every success would have passed this example unchanged.
testBadgeServiceFailureDebitsBucketOncePerFailure :: HasCallStack => TestParams -> IO ()
testBadgeServiceFailureDebitsBucketOncePerFailure ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
otherSigner <- newTestSigner
codesRef <- newIORef []
let writeConfig =
writeTestBadgeServiceConfigWith
ps
[ "",
"[throttle]",
"signer_failure_capacity = 3",
"signer_failure_start_tokens = 3",
"global_failure_capacity = 4",
"global_failure_start_tokens = 4"
]
seedCodes = seedTestCodes ps [(BTSupporter, 3, testCodeExpiry), (BTLegend, 3, testCodeExpiry)] >>= writeIORef codesRef
withBadgeServiceClock ps (readIORef clock) writeConfig seedCodes $ \client bsLink -> do
[supporterCode, legendCode] <- readIORef codesRef
unknown1 <- mintUnknownCode
unknown2 <- mintUnknownCode
unknown3 <- mintUnknownCode
unknown4 <- mintUnknownCode
-- none of these four is a failed redemption, so none may spend a token
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter supporterCode)
_ <- expectCredential "success does not debit" client
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter supporterCode)
_ <- expectCredential "replay does not debit" client
sendRequest client bsLink signer (purchaseRequest signer (badgeRequestFor signer BTSupporter "reserved") Nothing supporterCode)
expectErrorCode "reserved badgeExtra does not debit" client "bad_request"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter legendCode)
expectErrorCode "tier mismatch does not debit" client "bad_request"
-- three genuine failures, one token each
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter unknown1)
expectErrorCode "first failure" client "code_invalid"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter (corruptCheckCharacter unknown2))
expectErrorCode "second failure (never reaches the database)" client "code_invalid"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter unknown3)
expectErrorCode "third failure" client "code_invalid"
-- the bucket is now empty: the fourth is refused before it is even classified
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter unknown4)
expectRateLimited "fourth failure" client
-- another signer's bucket is its own -- and this is also the global budget's last token, so
-- it only answers code_invalid if nothing above spent one it should not have
sendRequest client bsLink otherSigner (purchaseCodeRequest otherSigner BTSupporter unknown4)
expectErrorCode "fresh signer" client "code_invalid"
-- The brief's global-budget bullet and B10 item 13: the service-wide failure budget is drained by
-- three DIFFERENT signers, so the fourth -- a fresh signer presenting a perfectly VALID code --
-- is rate_limited before the code is classified. That leaves the code unredeemed, which is what
-- lets the same request succeed once the bucket has refilled. Time moves through A6's clock; no
-- test sleeps.
--
-- The clock is advanced in two PARTIAL steps rather than one full hour, so that "a rate_limited
-- rejection must not debit again" (item 13) is actually pinned rather than merely stated. Both
-- buckets refill at capacity tokens per hour, and 'debitBucket' clamps at zero, so a spurious
-- debit is invisible unless the bucket is left short of the threshold afterwards:
--
-- * 900s at capacity 3 puts the global budget at 0.75 tokens -- still below 1, so the valid
-- code is refused, with 0.25 of margin either side of the threshold;
-- * a further 600s adds 0.5. Without a debit that is 1.25 and the retry succeeds; with one it
-- is 0.5 and the retry would still be refused.
--
-- The per-signer capacity is 1 for the same reason, from the other side: it refills 1 token per
-- hour, so a signer bucket debited by the rejection would hold 1500/3600 = 0.42 tokens at the
-- retry and refuse it. Either bucket being debited by a rejection fails this example.
testBadgeServiceGlobalFailureBudgetRefills :: HasCallStack => TestParams -> IO ()
testBadgeServiceGlobalFailureBudgetRefills ps = do
clock <- newTestClock testClockStart
signers <- replicateM 3 newTestSigner
redeemer <- newTestSigner
codeRef <- newIORef ""
let writeConfig =
writeTestBadgeServiceConfigWith
ps
[ "",
"[throttle]",
"signer_failure_capacity = 1",
"signer_failure_start_tokens = 1",
"global_failure_capacity = 3",
"global_failure_start_tokens = 3"
]
seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) writeConfig seedCode $ \client bsLink -> do
code <- readIORef codeRef
forM_ (zip [1 :: Int ..] signers) $ \(n, failingSigner) -> do
unknownCode <- mintUnknownCode
sendRequest client bsLink failingSigner (purchaseCodeRequest failingSigner BTSupporter unknownCode)
expectErrorCode ("global budget failure " <> show n) client "code_invalid"
-- 0.75 of a token: refused, and far enough below the threshold that no rounding decides it
advanceTestClockSeconds clock 900
sendRequest client bsLink redeemer (purchaseCodeRequest redeemer BTSupporter code)
expectRateLimited "valid code with the global budget drained" client
-- +0.5: enough only if the rejection above spent nothing
advanceTestClockSeconds clock 600
sendRequest client bsLink redeemer (purchaseCodeRequest redeemer BTSupporter code)
(_, statement) <- expectCredential "same code after the budget refilled" client
statementShape statement `shouldBe` [(3, 3, "credit payment (no invoiceId)"), (-1, 2, "debit badge")]
-- exactly one purchase: the three throttled failures wrote nothing, and the rate_limited
-- request neither consumed the code nor created a purchase
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- Brief 6 / B10 item 9: two months on, issueBadge writes debit(lapse) and THEN debit(badge) --
-- in that order -- plus a second issuance; a third call inside the same month returns the cached
-- credential and writes nothing.
--
-- The code funds FOUR months, so a month is still funded after the second period is issued. Three
-- would work too now: exhausting the balance in an already-issued month is answered with that
-- month's cached credential since the B7 defect this step found was fixed, and
-- 'testBadgeServiceIssueBadgeCachedInLastFundedMonth' holds exactly that case. Four is kept
-- deliberately, so this example exercises the multi-period path with a balance still funded and
-- stays independent of the cached-in-the-last-month behaviour it used to collide with.
testBadgeServiceIssueBadgeSecondPeriod :: HasCallStack => TestParams -> IO ()
testBadgeServiceIssueBadgeSecondPeriod ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedTestCodes ps [(BTSupporter, 4, testCodeExpiry)] >>= writeIORef codeRef . head
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
(cred1, _) <- expectCredential "redeem" client
advanceTestClockMonths clock 2
let req = issueRequest signer BTSupporter "" unknownEntryUuid
sendRequest client bsLink signer req
(cred2, statement) <- expectCredential "second period" client
cred2 `shouldNotBe` cred1
statementShape statement
`shouldBe` [ (4, 4, "credit payment (no invoiceId)"),
(-1, 3, "debit badge"),
(-1, 2, "debit lapse"),
(-1, 1, "debit badge")
]
-- the period issued is [2026-05-10, 2026-06-10); 2026-06-10 is a Wednesday
badgeCredentialExpiry cred2 `shouldBe` Just (UTCTime (fromGregorian 2026 6 14) sundayEndOfDay)
sendRequest client bsLink signer req
(cred3, statement3) <- expectCredential "third call inside the same month" client
cred3 `shouldBe` cred2
statementShape statement3 `shouldBe` statementShape statement
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 4, 2, 1)
badgeCredentialExpiry :: BadgeCredential -> Maybe UTCTime
badgeCredentialExpiry BadgeCredential {badgeInfo = BadgeInfo {badgeExpiry}} = badgeExpiry
-- The regression guard for the B7 defect B10 found and fixed (plan §9): inside the month that the
-- LAST funded month paid for, the balance is zero AND the month is already issued, and 'issue'
-- refuses for both reasons at once. Classifying that by the balance answered @credential = null@
-- and lost the client a credential the service had already signed, stored and delivered --
-- exactly the retry-after-a-timeout case RPC §Idempotency exists for, which is C3's worker's
-- normal failure mode. The right answer is the cached credential, both at the instant of issue
-- and later in the same month; the month AFTER it is the genuinely exhausted case, which
-- 'testBadgeServiceIssueBadgeExhaustedBalance' holds.
testBadgeServiceIssueBadgeCachedInLastFundedMonth :: HasCallStack => TestParams -> IO ()
testBadgeServiceIssueBadgeCachedInLastFundedMonth ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedTestCodes ps [(BTSupporter, 1, testCodeExpiry)] >>= writeIORef codeRef . head
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
(cred1, statement1) <- expectCredential "redeem the only funded month" client
statementShape statement1 `shouldBe` [(1, 1, "credit payment (no invoiceId)"), (-1, 0, "debit badge")]
let req = issueRequest signer BTSupporter "" unknownEntryUuid
sendRequest client bsLink signer req
(cached, statement2) <- expectCredential "issueBadge in the month just issued" client
cached `shouldBe` cred1
statementShape statement2 `shouldBe` statementShape statement1
-- and ten days later, still inside the same period (which ends 2026-04-10)
advanceTestClockSeconds clock (10 * nominalDay)
sendRequest client bsLink signer req
(cachedLater, statement3) <- expectCredential "issueBadge ten days into the issued month" client
cachedLater `shouldBe` cred1
statementShape statement3 `shouldBe` statementShape statement1
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- Brief 7 / B10 item 10: an exhausted balance is not an error -- no credential, and a statement
-- ending at zero months that says why.
testBadgeServiceIssueBadgeExhaustedBalance :: HasCallStack => TestParams -> IO ()
testBadgeServiceIssueBadgeExhaustedBalance ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedTestCodes ps [(BTSupporter, 1, testCodeExpiry)] >>= writeIORef codeRef . head
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
(_, statement1) <- expectCredential "redeem one month" client
statementShape statement1 `shouldBe` [(1, 1, "credit payment (no invoiceId)"), (-1, 0, "debit badge")]
advanceTestClockMonths clock 1
sendRequest client bsLink signer (issueRequest signer BTSupporter "" unknownEntryUuid)
(credential, statement2) <- expectCredentialResponse "exhausted balance" client
credential `shouldBe` Nothing
statementShape statement2 `shouldBe` statementShape statement1
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- B10 items 8 and 15 / brief 9: a second code under a purchase key that already exists credits
-- the existing ledger, gets its OWN payments row while the purchase keeps pointing at the first
-- one, and -- because the month is already issued -- writes no second debit(badge) and no second
-- issuance, returning the credential that already exists. A code of a different tier under the
-- same key is bad_request.
testBadgeServiceSecondCodeSamePurchaseKey :: HasCallStack => TestParams -> IO ()
testBadgeServiceSecondCodeSamePurchaseKey ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codesRef <- newIORef []
let seedCodes =
seedTestCodes ps [(BTSupporter, 3, testCodeExpiry), (BTSupporter, 3, testCodeExpiry), (BTLegend, 3, testCodeExpiry)]
>>= writeIORef codesRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCodes $ \client bsLink -> do
[code1, code2, legendCode] <- readIORef codesRef
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code1)
(cred1, _) <- expectCredential "first code" client
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code2)
(cred2, statement) <- expectCredential "second code, same key" client
cred2 `shouldBe` cred1
statementShape statement
`shouldBe` [ (3, 3, "credit payment (no invoiceId)"),
(-1, 2, "debit badge"),
(3, 5, "credit payment (no invoiceId)")
]
sendRequest client bsLink signer (purchaseCodeRequest signer BTLegend legendCode)
expectErrorCode "a different tier under an existing key" client "bad_request"
withServiceDB ps $ \db -> do
serviceRowCounts db `shouldReturn` (1, 2, 3, 1, 2)
creditPayments <-
DB.query_ db "SELECT payment_id FROM sx_badge_service_badge_ledger WHERE entry_type = 'credit' ORDER BY entry_id"
[Only purchasePaymentId] <- DB.query_ db "SELECT payment_id FROM sx_badge_service_badge_purchases"
case creditPayments of
[Only firstPayment, Only secondPayment] -> do
(firstPayment :: Text) `shouldNotBe` secondPayment
purchasePaymentId `shouldBe` Just firstPayment
other -> expectationFailure $ "expected two credit entries with distinct payments, got: " <> show other
-- B10 item 6, the security assertion of this step, in the form C4 left it: the tier a credential
-- carries is the CODE's, never the request's. A client cannot know a code's tier before it
-- redeems it -- a code carries none (B3) and the response is what states it (core §5) -- so
-- 'purchaseBadge{code}' signs the tier the funding bought and ignores the one the request names
-- ('withFundedBadgeType', plan §9). A LEGEND request over a SUPPORTER code must therefore be
-- answered with a SUPPORTER credential, on a supporter ledger and a supporter purchase row.
--
-- __Each of the four assertions catches a different mutation__, and only the first is the proof
-- of the override:
--
-- * the CREDENTIAL tier is the one 'withFundedBadgeType' decides. Drop the override and this is
-- the only assertion that fails, with 'legend' -- the service handing out a signed credential
-- of a tier the code did not buy, which is the whole point of the test.
-- * the LEDGER tiers and the PURCHASE tier come from @writeTxn@'s own @badgeType@, which is the
-- code's on either side of that mutation. They pin 'planTxn'\/@writeTxn@ instead: that the
-- rows a redemption writes are the funding's tier and not the request's, which no other test
-- states.
-- * the REPLAY returns the credential already issued, rather than refusing a request whose tier
-- differs -- the guard C4 deleted with the refusal it belonged to.
--
-- 'issueBadge' is deliberately unchanged: there the tier is the purchase's own
-- 'current_badge_type', which the client HOLDS and must state, so a mismatch is still
-- bad_request -- and that is why the override lives at this one call site and not in
-- 'issueSignedBadge', which both paths share.
testBadgeServiceSignsFundedTier :: HasCallStack => TestParams -> IO ()
testBadgeServiceSignsFundedTier ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
sendRequest client bsLink signer (purchaseCodeRequest signer BTLegend code)
(cred1, statement) <- expectCredential "legend request with a supporter code" client
let BadgeCredential {badgeInfo = BadgeInfo {badgeType = credentialType}} = cred1
("credential tier" :: String, credentialType) `shouldBe` ("credential tier", BTSupporter)
statementShape statement `shouldBe` [(3, 3, "credit payment (no invoiceId)"), (-1, 2, "debit badge")]
-- the rows the redemption wrote carry the code's tier as well -- this pins writeTxn, not the
-- override (field 5 of StatementEntry, constructed positionally as everywhere else here)
let BadgeStatement {entries} = statement
entryTypes = map (\(StatementEntry _ _ _ _ bt _ _ _) -> bt) entries
("ledger tiers" :: String, entryTypes) `shouldBe` ("ledger tiers", [BTSupporter, BTSupporter])
-- the replay under the same legend request hands back the very same supporter credential
sendRequest client bsLink signer (purchaseCodeRequest signer BTLegend code)
(cred2, _) <- expectCredential "legend request replaying the same redemption" client
cred2 `shouldBe` cred1
sendRequest client bsLink signer (issueRequest signer BTLegend "" unknownEntryUuid)
expectErrorCode "issueBadge naming another tier" client "bad_request"
withServiceDB ps $ \db -> do
-- exactly what the one redemption writes: the replay and the issueBadge refusal wrote nothing
serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- likewise writeTxn's, not the override's
[Only purchaseType] <- DB.query_ db "SELECT current_badge_type FROM sx_badge_service_badge_purchases"
("purchase tier" :: String, purchaseType :: BadgeType) `shouldBe` ("purchase tier", BTSupporter)
-- B10 item 7: a purchase carrying an upgrade is refused before the payment is even looked at, so
-- the code is not consumed by a request whose upgrade would have been silently dropped.
testBadgeServiceUpgradeIsBadRequest :: HasCallStack => TestParams -> IO ()
testBadgeServiceUpgradeIsBadRequest ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
otherSigner <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
-- BadgeUpgrade is constructed positionally: its 'receipt' and 'balance' fields collide with
-- BadgeServiceResponse's and BSCIssueBadge's, so only its constructor is imported.
let upgrade =
BadgeUpgrade
(signerPub otherSigner)
"test-receipt"
(C.sign' (signerPriv otherSigner) "test-receipt")
(BadgeBalance (StatementEntry unknownEntryUuid 1 1 testClockStart BTSupporter Nothing testClockStart (SECredit SCOpening)))
sendRequest client bsLink signer (purchaseRequest signer (badgeRequestFor signer BTSupporter "") (Just upgrade) code)
expectErrorCode "purchase carrying an upgrade" client "bad_request"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
_ <- expectCredential "the same code still redeems" client
pure ()
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- B10 item 12, first half: a reserved (non-empty) badgeExtra is a CLIENT fault -- bad_request,
-- terminal -- refused by both handlers before anything is classified, planned or signed, leaving
-- the code unredeemed. Its counterpart, a signing failure answering 'internal', cannot be induced
-- from a test without a fault injector and is recorded as untested rather than folded in here:
-- conflating the two is the defect B7's fix round removed.
testBadgeServiceBadgeExtraIsBadRequest :: HasCallStack => TestParams -> IO ()
testBadgeServiceBadgeExtraIsBadRequest ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
sendRequest client bsLink signer (purchaseRequest signer (badgeRequestFor signer BTSupporter "reserved") Nothing code)
expectErrorCode "purchaseBadge with a reserved badgeExtra" client "bad_request"
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
_ <- expectCredential "the same code still redeems" client
sendRequest client bsLink signer (issueRequest signer BTSupporter "reserved" unknownEntryUuid)
expectErrorCode "issueBadge with a reserved badgeExtra" client "bad_request"
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- B10 item 11: the statement cursor. An assertion naming an entry the service holds returns only
-- what follows it and echoes it back; an unknown uuid, or -- the privacy assertion -- one
-- belonging to ANOTHER purchase, returns the complete history with previousEntryId absent, and
-- never a single entry of that other purchase.
testBadgeServiceIssueBadgeCursor :: HasCallStack => TestParams -> IO ()
testBadgeServiceIssueBadgeCursor ps = do
clock <- newTestClock testClockStart
signer <- newTestSigner
otherSigner <- newTestSigner
codesRef <- newIORef []
let seedCodes = seedTestCodes ps [(BTSupporter, 3, testCodeExpiry), (BTSupporter, 3, testCodeExpiry)] >>= writeIORef codesRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCodes $ \client bsLink -> do
[code, otherCode] <- readIORef codesRef
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
(_, ownStatement) <- expectCredential "own redemption" client
sendRequest client bsLink otherSigner (purchaseCodeRequest otherSigner BTSupporter otherCode)
(_, otherStatement) <- expectCredential "other purchase's redemption" client
ownEntries <- case statementEntryIds ownStatement of
ids@[_, _] -> pure ids
other -> expectationFailure ("expected two own entries, got: " <> show other) >> error "unreachable"
otherEntries <- case statementEntryIds otherStatement of
ids@[_, _] -> pure ids
other -> expectationFailure ("expected two other entries, got: " <> show other) >> error "unreachable"
-- a cursor the service holds for THIS purchase: only what follows it
sendRequest client bsLink signer (issueRequest signer BTSupporter "" (head ownEntries))
(_, afterCursor) <- expectCredential "cursor hit" client
statementEntryIds afterCursor `shouldBe` drop 1 ownEntries
previousEntryId afterCursor `shouldBe` Just (head ownEntries)
-- a uuid belonging to a different purchase resolves to nothing: the full own history follows
sendRequest client bsLink signer (issueRequest signer BTSupporter "" (head otherEntries))
(_, crossPurchase) <- expectCredential "cursor from another purchase" client
statementEntryIds crossPurchase `shouldBe` ownEntries
previousEntryId crossPurchase `shouldBe` Nothing
-- and so does one the service has never seen
sendRequest client bsLink signer (issueRequest signer BTSupporter "" unknownEntryUuid)
(_, unknownCursor) <- expectCredential "unknown cursor" client
statementEntryIds unknownCursor `shouldBe` ownEntries
previousEntryId unknownCursor `shouldBe` Nothing
-- three cached issueBadge calls wrote nothing: two purchases, two entries each
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (2, 2, 4, 2, 2)
-- B10 item 14, the regression guard for B7's §9 ruling: the cached credential is fetched by
-- probing the issuance table at NOW, not at @addMonths (-1) balanceStartTs@. Stepping a month
-- back from a clamped boundary (31 Jan + 1 month = 28 Feb; 28 Feb - 1 month = 28 Jan) lands
-- BEFORE the period it came from, so the brief's original formula returns the PREVIOUS month's
-- credential -- a wrong credential, not an absent one. Two consecutive periods are issued across
-- exactly that boundary so the wrong answer is available to be returned.
testBadgeServiceCachedIssuanceAtClampedMonthBoundary :: HasCallStack => TestParams -> IO ()
testBadgeServiceCachedIssuanceAtClampedMonthBoundary ps = do
clock <- newTestClock (UTCTime (fromGregorian 2025 12 31) noonUTC)
signer <- newTestSigner
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
withBadgeServiceClock ps (readIORef clock) (writeTestBadgeServiceConfig ps) seedCode $ \client bsLink -> do
code <- readIORef codeRef
-- period 1: [2025-12-31, 2026-01-31)
sendRequest client bsLink signer (purchaseCodeRequest signer BTSupporter code)
(cred1, _) <- expectCredential "period starting 2025-12-31" client
-- period 2: [2026-01-31, 2026-02-28) -- the clamped boundary
setTestClock clock (UTCTime (fromGregorian 2026 1 31) noonUTC)
sendRequest client bsLink signer (issueRequest signer BTSupporter "" unknownEntryUuid)
(cred2, _) <- expectCredential "period starting 2026-01-31" client
cred2 `shouldNotBe` cred1
-- inside period 2: the cached credential must be period 2's, never period 1's
setTestClock clock (UTCTime (fromGregorian 2026 2 15) noonUTC)
sendRequest client bsLink signer (issueRequest signer BTSupporter "" unknownEntryUuid)
(cached, _) <- expectCredential "inside the already-issued clamped month" client
cached `shouldBe` cred2
cached `shouldNotBe` cred1
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 3, 2, 1)
#if !defined(dbPostgres)
-- What one table holds, as the database itself reports it: its declared foreign-key targets and
-- its column names.
data TableRefs = TableRefs
{ refTable :: Text,
refFkTargets :: [Text],
refColumns :: [Text]
}
deriving (Show)
-- | A table holds an order (purchase) reference if it declares a foreign key to that table __or__
-- names a column after it. The name half is the load-bearing one for what comes next: a plain
-- @web_order_id TEXT@ with no @REFERENCES@ clause is invisible to 'pragma_foreign_key_list', and
-- D0 -- the very next step -- is what adds order tables. A false alarm from an innocent column
-- name (@sort_order@) is the intended trade: it costs one reader a minute, and the failure it
-- guards against costs the privacy claim the whole web checkout rests on.
holdsOrderRef :: TableRefs -> Bool
holdsOrderRef TableRefs {refFkTargets, refColumns} =
any ("web_orders" `T.isSuffixOf`) refFkTargets || any ("order" `T.isInfixOf`) refColumns
holdsPurchaseRef :: TableRefs -> Bool
holdsPurchaseRef TableRefs {refFkTargets, refColumns} =
any ("badge_purchases" `T.isSuffixOf`) refFkTargets || any ("purchase" `T.isInfixOf`) refColumns
-- The brief's schema assertion (§3 Linkage), and the regression guard for the privacy claim the
-- whole web-checkout design rests on: an order and a purchase must never be joinable, so NO table
-- may carry a reference to @web_orders and a reference to @badge_purchases at once. Enumerated
-- from the database itself (sqlite_master, each table's foreign keys and each table's columns),
-- not from the migration source, so it fails the day a later step adds such a column. Reads a
-- SQLite file, hence the guard: A3's Postgres run of this spec would break on it otherwise.
--
-- Three anti-vacuity controls, because an assertion that only ever says "no rows matched" is one
-- typo away from proving nothing: the enumeration must see both tables by name, it must see the
-- real declared foreign key from @codes to @badge_purchases, and -- the control for the half that
-- foreign keys cannot see -- a probe table carrying both columns with NO foreign keys at all must
-- be caught, then dropped before the real scan.
testBadgeServiceNoTableLinksOrdersToPurchases :: HasCallStack => TestParams -> IO ()
testBadgeServiceNoTableLinksOrdersToPurchases ps =
withFreshBadgeStore ps $ \st -> do
let refsOf table = do
fks <- withConnection st $ \db -> DB.query db "SELECT \"table\" FROM pragma_foreign_key_list(?)" (Only table)
cols <- withConnection st $ \db -> DB.query db "SELECT name FROM pragma_table_info(?)" (Only table)
pure TableRefs {refTable = table, refFkTargets = map fromOnly fks, refColumns = map fromOnly cols}
listTables =
map fromOnly
<$> withConnection
st
(\db -> DB.query_ db "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
-- control 3, first: exactly the shape D0 could add without a REFERENCES clause
withConnection st $ \db -> DB.execute_ db "CREATE TABLE b10_linkage_probe (web_order_id TEXT, badge_purchase_id INTEGER)"
probe <- refsOf "b10_linkage_probe"
(refTable probe, holdsOrderRef probe, holdsPurchaseRef probe) `shouldBe` ("b10_linkage_probe", True, True)
refFkTargets probe `shouldBe` [] -- and it is caught with no foreign key to go on
withConnection st $ \db -> DB.execute_ db "DROP TABLE b10_linkage_probe"
tableNames <- listTables
-- controls 1 and 2: the enumeration sees both tables, and sees a real declared foreign key
tableNames `shouldSatisfy` elem "sx_badge_service_web_orders"
tableNames `shouldSatisfy` elem "sx_badge_service_codes"
tableNames `shouldSatisfy` notElem "b10_linkage_probe"
codeRefs <- refsOf "sx_badge_service_codes"
refFkTargets codeRefs `shouldSatisfy` any ("badge_purchases" `T.isSuffixOf`)
references <- forM tableNames refsOf
map refTable (filter holdsOrderRef references) `shouldBe` ["sx_badge_service_web_orders"]
map refTable (filter (\refs -> holdsOrderRef refs && holdsPurchaseRef refs) references) `shouldBe` []
-- The column scan above is per-table and cannot see a join built out of two hops. This one
-- exists and is complete: @web_orders.invoice_id -> @invoices <- @payments.invoice_id, then
-- @payments.payment_id <- @badge_purchases.payment_id. It is empty only because
-- 'createCodePayment' hardcodes invoice_id NULL for a code payment -- nothing in the schema
-- enforces that, so it is asserted here, over rows shaped exactly as a settled web order and
-- a code redemption leave them.
withConnection st $ \db -> do
DB.execute_ db "INSERT INTO sx_badge_service_invoices (invoice_id, provider, price, amount, currency, expires_at, status, created_at, updated_at) VALUES ('b10-invoice','crypto',1000,1000,'USD','2026-03-10','paid','2026-03-10','2026-03-10')"
DB.execute_ db "INSERT INTO sx_badge_service_web_orders (order_id, invoice_id, method, short_ref, badge_type, months, status, created_at, updated_at) VALUES ('b10-order','b10-invoice','btc','B10RF','supporter',3,'paid','2026-03-10','2026-03-10')"
DB.execute_ db "INSERT INTO sx_badge_service_payments (payment_id, invoice_id, provider, status, created_at, updated_at) VALUES ('b10-payment', NULL, 'code', 'settled', '2026-03-10', '2026-03-10')"
DB.execute_ db "INSERT INTO sx_badge_service_badge_purchases (purchase_key, master_key, initial_badge_type, current_badge_type, payment_id, status, created_at, updated_at) VALUES (x'0102', x'0304', 'supporter', 'supporter', 'b10-payment', 'issued', '2026-03-10', '2026-03-10')"
-- one long line each: this module is compiled with CPP, which eats a backslash-newline string
-- gap as a line continuation before GHC ever lexes the literal
let joinedOrdersToPurchases = do
[Only n] <-
withConnection st $ \db ->
DB.query_ db "SELECT count(*) FROM sx_badge_service_web_orders o JOIN sx_badge_service_payments p ON p.invoice_id = o.invoice_id JOIN sx_badge_service_badge_purchases bp ON bp.payment_id = p.payment_id"
pure (n :: Int)
-- positive control: the join is written correctly and DOES resolve an order to a purchase the
-- moment a payment carries the order's invoice. Without this, "0 rows" could mean a typo.
withConnection st $ \db -> DB.execute_ db "UPDATE sx_badge_service_payments SET invoice_id = 'b10-invoice'"
joinedOrdersToPurchases `shouldReturn` 1
withConnection st $ \db -> DB.execute_ db "UPDATE sx_badge_service_payments SET invoice_id = NULL"
-- and with the payment shaped as this milestone writes it, there is no path from the order to
-- the purchase at all (docs/protocol/badges-web.md §7)
joinedOrdersToPurchases `shouldReturn` 0
#endif
-- B8's Verify line, formally owed by B10: @codes issue@ mints the requested number of codes,
-- prints each plaintext exactly once, stores only distinct hashes of them, @codes revoke@ revokes
-- the whole batch, @codes status@ reports what the row says, and a past @--expires@ is accepted.
-- Driven through 'runAdminCmd' -- the real entry point 'Main.hs' calls, opening its own store and
-- reading the same ini a service run does -- with stdout captured, since the plaintext codes exist
-- nowhere else.
testBadgeServiceCodesIssueRevokeStatus :: HasCallStack => TestParams -> IO ()
testBadgeServiceCodesIssueRevokeStatus ps@TestParams {tmpPath} = do
-- the admin subcommand never starts a listener, so this port is only ever written to the ini
freeWebPort >>= writeTestBadgeServiceConfig ps
let adminOpts cmd =
AdminOpts
{ adminCoreOptions = coreOptions (mkBadgeServiceOpts ps),
adminConfigFile = badgeServiceConfigPath tmpPath,
adminCmd = cmd
}
issueOpts batch expires =
CmdIssue IssueOpts {issueType = BTSupporter, issueMonths = 3, issueCount = 10, issueBatch = batch, issueExpires = expires}
runCodes label cmd = withCapturedStdout (tmpPath </> (label <> ".out")) (runAdminCmd (adminOpts cmd))
issued <- runCodes "issue" (issueOpts testCodeBatch Nothing)
length issued `shouldBe` 10
length (nub issued) `shouldBe` 10
all (Codes.verifyChecksum . Codes.normalizeCode) issued `shouldBe` True
length (nub (map (Codes.codeHash . Codes.normalizeCode) issued)) `shouldBe` 10
-- every printed code resolves to a stored row of its own -- @codes status@ hashes what it is
-- given and looks that up, so this is what proves the rows are keyed by the hash of exactly
-- what was printed. The revoke count below then confirms there are ten of them and no more.
forM_ (zip [1 :: Int ..] issued) $ \(n, code) -> do
status <- runCodes ("status-issued-" <> show n) (CmdStatus code)
(n, status) `shouldSatisfy` any (T.isInfixOf "status=unredeemed") . snd
#if !defined(dbPostgres)
-- the property the whole design hinges on: no plaintext code anywhere in the database file,
-- neither as printed nor as normalized. Reads the SQLite file, hence the guard.
dbBytes <- BC.readFile (tmpPath </> (serviceDbPrefix <> "_chat.db"))
-- positive control FIRST: the batch label is stored in the clear and must be found in these
-- bytes. Without it every assertion below is an absence, and an absence passes for the wrong
-- reason the day the rows move to another file, the file is renamed, or the scan reads a
-- database that was never written to.
(testCodeBatch, encodeUtf8 testCodeBatch `BS.isInfixOf` dbBytes) `shouldBe` (testCodeBatch, True)
forM_ issued $ \code -> do
(code, encodeUtf8 code `BS.isInfixOf` dbBytes) `shouldBe` (code, False)
(code, encodeUtf8 (Codes.normalizeCode code) `BS.isInfixOf` dbBytes) `shouldBe` (code, False)
#endif
statusBefore <- runCodes "status-before" (CmdStatus (head issued))
statusBefore `shouldSatisfy` any (T.isInfixOf "status=unredeemed")
statusBefore `shouldSatisfy` any (T.isInfixOf ("batch=" <> testCodeBatch))
statusBefore `shouldSatisfy` any (T.isInfixOf "type=supporter")
statusBefore `shouldSatisfy` any (T.isInfixOf "months=3")
-- a past --expires is stored exactly as given, with no validation beyond the date format
pastExpiry <- runCodes "issue-past" (issueOpts "b10-expired-batch" (Just (fromGregorian 2000 1 1)))
length pastExpiry `shouldBe` 10
statusPast <- runCodes "status-past" (CmdStatus (head pastExpiry))
statusPast `shouldSatisfy` any (T.isInfixOf "expires=2000-01-01")
revoked <- runCodes "revoke" (CmdRevoke testCodeBatch)
revoked `shouldBe` ["10 code(s) revoked in batch " <> testCodeBatch]
statusAfter <- runCodes "status-after" (CmdStatus (head issued))
statusAfter `shouldSatisfy` any (T.isInfixOf "status=revoked")
-- the other batch is untouched: revoke is scoped to the batch it names
statusOther <- runCodes "status-other-batch" (CmdStatus (head pastExpiry))
statusOther `shouldSatisfy` any (T.isInfixOf "status=unredeemed")
-- | Runs an action with stdout redirected to a file, returning the lines it printed. The only way
-- to read what @codes issue@ minted: the plaintext codes are printed once and never stored.
withCapturedStdout :: FilePath -> IO () -> IO [Text]
withCapturedStdout path action = do
saved <- hDuplicate stdout
h <- openFile path WriteMode
(hDuplicateTo h stdout >> action) `finally` do
hFlush stdout
hDuplicateTo saved stdout
hClose saved
hClose h
T.lines <$> TIO.readFile path
-- B8's remaining Verify item: lifetime badges are out of scope, so @--type investor@ is rejected
-- by the parser itself, before any store is opened.
testBadgeServiceCodesRejectsInvestorType :: HasCallStack => TestParams -> IO ()
testBadgeServiceCodesRejectsInvestorType _ps =
case O.execParserPure O.defaultPrefs (O.info adminCommandParser O.fullDesc) issueInvestor of
O.Failure failure -> fst (O.renderFailure failure "codes") `shouldSatisfy` isInfixOf "expected 'supporter' or 'legend'"
O.Success _ -> expectationFailure "expected --type investor to be rejected"
O.CompletionInvoked _ -> expectationFailure "expected --type investor to be rejected"
where
issueInvestor = ["issue", "--type", "investor", "--months", "3", "--count", "10", "--batch", "b10-investor"]
-- B1 store layer -------------------------------------------------------------
-- A fresh, migrated badge-service database, independent of any running bot: these tests
-- exercise BadgeService.Store directly against BadgeServiceTests' own store, the same way
-- testBadgeServiceCatalogSeeding does.
withFreshBadgeStore :: TestParams -> (DBStore -> IO a) -> IO a
withFreshBadgeStore ps test = do
let dbOpts = toDBOpts (dbOptions $ coreOptions $ mkBadgeServiceOpts ps) chatSuffix False chatDBFunctions
Right st <- createDBStore dbOpts badgeServiceSchemaMigrations (MigrationConfig MCError Nothing)
test st `finally` closeDBStore st
mkTestKeyPair :: IO (C.PublicKeyEd25519, C.PrivateKeyEd25519)
mkTestKeyPair = do
drg <- C.newRandom
(pub, priv :: C.PrivateKeyEd25519) <- atomically $ C.generateKeyPair drg
pure (pub, priv)
expectRight :: HasCallStack => IO (Either ServiceError a) -> IO a
expectRight action =
action >>= \case
Right a -> pure a
Left e -> expectationFailure ("expected Right, got: " <> show e) >> error "unreachable"
-- Creates a purchase, appends three ledger entries (an opening credit and two badge debits)
-- and asserts getLedgerSince returns all three in ascending entry_id order with the expected
-- changeMonths, and getLastLedgerEntry returns exactly the last one appended.
testBadgeStorePurchaseAndLedger :: HasCallStack => TestParams -> IO ()
testBadgeStorePurchaseAndLedger ps =
withFreshBadgeStore ps $ \st -> do
(purchaseKey, _) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
BadgePurchaseRow {badgePurchaseId, status} <-
expectRight $ withServiceTransaction st $ \db -> createPurchase db purchaseKey masterKey BTSupporter now
status `shouldBe` PSIssued
let mkEntry changeMonths entryType balanceMonths =
BadgeLedgerEntry
{ entryId = 0,
entryUuid = "test-entry-" <> tshow' (badgePurchaseId, changeMonths, balanceMonths),
badgePurchaseId,
changeMonths,
balanceMonths,
balanceStartTs = now,
balanceBadgeType = BTSupporter,
wasPausedSince = Nothing,
serviceCreatedAt = now,
createdAt = now,
entryType
}
tshow' = fromString . show
entry1 <- expectRight $ withServiceTransaction st $ \db -> appendLedgerEntry db (mkEntry 3 (LECredit CTOpening) 3)
entry2 <- expectRight $ withServiceTransaction st $ \db -> appendLedgerEntry db (mkEntry (-1) (LEDebit DTBadge) 2)
entry3 <- expectRight $ withServiceTransaction st $ \db -> appendLedgerEntry db (mkEntry (-1) (LEDebit DTBadge) 1)
entryId entry1 < entryId entry2 && entryId entry2 < entryId entry3 `shouldBe` True
allEntries <- expectRight $ withServiceTransaction st $ \db -> getLedgerSince db badgePurchaseId Nothing
map entryId allEntries `shouldBe` [entryId entry1, entryId entry2, entryId entry3]
map changeMonths allEntries `shouldBe` [3, -1, -1]
lastEntry <- expectRight $ withServiceTransaction st $ \db -> getLastLedgerEntry db badgePurchaseId
(entryId <$> lastEntry) `shouldBe` Just (entryId entry3)
sinceFirst <- expectRight $ withServiceTransaction st $ \db -> getLedgerSince db badgePurchaseId (Just (entryId entry1))
map entryId sinceFirst `shouldBe` [entryId entry2, entryId entry3]
-- setPriceStatus BISDisabled removes the price and every offer pinned to it from
-- getActiveCatalog, while getPriceById and getOfferById still resolve both.
testBadgeStoreSetPriceStatusDisabled :: HasCallStack => TestParams -> IO ()
testBadgeStoreSetPriceStatusDisabled ps =
withFreshBadgeStore ps $ \st -> do
seedCatalog st
BadgeCatalog {prices = seededPrices} <- expectRight $ withServiceTransaction st getActiveCatalog
let BadgePrice {priceId = targetPriceId} = head seededPrices
pinnedOfferIds <- expectRight $ withServiceTransaction st $ \db -> do
BadgeCatalog {offers} <- getActiveCatalog db
pure [offerId | BadgeOffer {offerId, priceId = Just pid} <- offers, pid == targetPriceId]
length pinnedOfferIds > 0 `shouldBe` True
_ <- expectRight $ withServiceTransaction st $ \db -> setPriceStatus db targetPriceId BISDisabled
BadgeCatalog {prices = pricesAfter, offers = offersAfter} <- expectRight $ withServiceTransaction st getActiveCatalog
any (\BadgePrice {priceId} -> priceId == targetPriceId) pricesAfter `shouldBe` False
any (\BadgeOffer {priceId} -> priceId == Just targetPriceId) offersAfter `shouldBe` False
priceStillById <- expectRight $ withServiceTransaction st $ \db -> getPriceById db targetPriceId
case priceStillById of
Just BadgePrice {status} -> status `shouldBe` BISDisabled
Nothing -> expectationFailure "disabled price should still resolve by id"
mapM_
( \oid -> do
offerStillById <- expectRight $ withServiceTransaction st $ \db -> getOfferById db oid
isJust offerStillById `shouldBe` True
)
pinnedOfferIds
-- Inserts a code, marks it redeemed by one purchase's key, and asserts getCodeByHash returns
-- that same purchase key alongside the code row.
testBadgeStoreGetCodeByHashRedeemer :: HasCallStack => TestParams -> IO ()
testBadgeStoreGetCodeByHashRedeemer ps =
withFreshBadgeStore ps $ \st -> do
(purchaseKey, _) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
BadgePurchaseRow {badgePurchaseId} <-
expectRight $ withServiceTransaction st $ \db -> createPurchase db purchaseKey masterKey BTSupporter now
codeHash <- getRandomBytes 32
let expiresAt = addUTCTime (30 * nominalDay) now
newCode = NewBadgeCode {codeHash, badgeType = BTSupporter, months = 3, batch = "test-batch", expiresAt}
_ <- expectRight $ withServiceTransaction st $ \db -> insertCodes db [newCode] now
beforeRedeem <- expectRight $ withServiceTransaction st $ \db -> getCodeByHash db codeHash
case beforeRedeem of
Just (_, redeemer) -> redeemer `shouldBe` Nothing
Nothing -> expectationFailure "code should exist before redemption"
_ <- expectRight $ withServiceTransaction st $ \db -> markCodeRedeemed db codeHash badgePurchaseId now
afterRedeem <- expectRight $ withServiceTransaction st $ \db -> getCodeByHash db codeHash
case afterRedeem of
Just (BadgeCode {redeemedPurchaseId}, redeemer) -> do
redeemedPurchaseId `shouldBe` Just badgePurchaseId
redeemer `shouldBe` Just purchaseKey
Nothing -> expectationFailure "code should exist after redemption"
-- markCodeRedeemed refuses a REVOKED code, not only an already-redeemed one. B8's `codes revoke`
-- runs in a second process against the same database, and the service separates classification
-- from this write by the signing IO, so a revocation issued in that window must not be silently
-- overwritten -- which is the whole point of being able to revoke a code that is being abused.
-- SECodeConflict is what the handler re-classifies on, and a revoked code then answers
-- code_invalid.
testBadgeStoreMarkCodeRedeemedRefusesRevoked :: HasCallStack => TestParams -> IO ()
testBadgeStoreMarkCodeRedeemedRefusesRevoked ps =
withFreshBadgeStore ps $ \st -> do
(purchaseKey, _) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
BadgePurchaseRow {badgePurchaseId} <-
expectRight $ withServiceTransaction st $ \db -> createPurchase db purchaseKey masterKey BTSupporter now
codeHash <- getRandomBytes 32
let newCode = NewBadgeCode {codeHash, badgeType = BTSupporter, months = 3, batch = "test-batch", expiresAt = addUTCTime (30 * nominalDay) now}
_ <- expectRight $ withServiceTransaction st $ \db -> insertCodes db [newCode] now
_ <- expectRight $ withServiceTransaction st $ \db -> revokeCode db codeHash now
conflicted <- withServiceTransaction st $ \db -> markCodeRedeemed db codeHash badgePurchaseId now
conflicted `shouldBe` Left SECodeConflict
-- and the refusal left the row alone: still revoked, still unredeemed
Just (BadgeCode {redeemedPurchaseId, redeemedAt, revokedAt}, redeemer) <-
expectRight $ withServiceTransaction st $ \db -> getCodeByHash db codeHash
redeemedPurchaseId `shouldBe` Nothing
redeemedAt `shouldBe` Nothing
isJust revokedAt `shouldBe` True
redeemer `shouldBe` Nothing
-- unredeemCode clears redeemed_purchase_id and redeemed_at and sets unredeemed_at, re-opening
-- the code for another redemption.
testBadgeStoreUnredeemCode :: HasCallStack => TestParams -> IO ()
testBadgeStoreUnredeemCode ps =
withFreshBadgeStore ps $ \st -> do
(purchaseKey, _) <- mkTestKeyPair
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
BadgePurchaseRow {badgePurchaseId} <-
expectRight $ withServiceTransaction st $ \db -> createPurchase db purchaseKey masterKey BTSupporter now
codeHash <- getRandomBytes 32
let expiresAt = addUTCTime (30 * nominalDay) now
newCode = NewBadgeCode {codeHash, badgeType = BTSupporter, months = 3, batch = "test-batch", expiresAt}
_ <- expectRight $ withServiceTransaction st $ \db -> insertCodes db [newCode] now
_ <- expectRight $ withServiceTransaction st $ \db -> markCodeRedeemed db codeHash badgePurchaseId now
unredeemAt <- getCurrentTime
_ <- expectRight $ withServiceTransaction st $ \db -> unredeemCode db codeHash unredeemAt
Just (BadgeCode {redeemedPurchaseId, redeemedAt, unredeemedAt}, redeemer) <-
expectRight $ withServiceTransaction st $ \db -> getCodeByHash db codeHash
redeemedPurchaseId `shouldBe` Nothing
redeemedAt `shouldBe` Nothing
-- Postgres TIMESTAMPTZ truncates to microseconds, so an exact Haskell UTCTime round-trip
-- isn't guaranteed on that backend; comparing within a second confirms the column was
-- actually set to the given time without depending on sub-microsecond precision surviving.
case unredeemedAt of
Just storedUnredeemedAt -> abs (diffUTCTime storedUnredeemedAt unredeemAt) < 1 `shouldBe` True
Nothing -> expectationFailure "unredeemed_at should be set after unredeemCode"
redeemer `shouldBe` Nothing
-- D0 store layer: orders, invoices, provider events ---------------------------
-- | A crypto order as D6 will write one: a supporter badge, 3 months, $14.00, with the address
-- and crypto amount E2's @getPaymentMethods@ returns. The catalog ids are passed in rather than
-- filled by a record update, which -XDuplicateRecordFields warns on, and only the first test
-- below seeds a catalog to pin them to.
testNewOrder :: Text -> Text -> UTCTime -> Maybe BadgePriceId -> Maybe BadgeOfferId -> NewWebOrder
testNewOrder orderId shortRef expiresAt priceId offerId =
NewWebOrder
{ orderId,
invoiceId = orderId <> "-invoice",
providerRef = Just (orderId <> "-btcpay"),
method = OMBtc,
shortRef,
badgeType = BTSupporter,
priceId,
offerId,
months = 3,
amount = CurrencyAmount 1400,
currency = "usd",
payUrl = Nothing,
paymentAddress = Just "bc1qd0teststoreaddress",
cryptoAmount = Just "0.00042",
expiresAt
}
-- | The order's id, for asserting WHICH order a lookup resolved to: 'WebOrder''s field selectors
-- are ambiguous under -XDuplicateRecordFields, so every read below goes through a pattern match.
orderIdOf :: WebOrder -> Text
orderIdOf WebOrder {orderId} = orderId
-- | 'serviceRowCount' against a 'DBStore' rather than an open connection, which is what the
-- store tests hold.
serviceRowCount' :: DBStore -> String -> IO Int
serviceRowCount' st table = withConnection st (`serviceRowCount` table)
-- | @web_orders.updated_at@, which no store function returns: 'WebOrder' carries it, but the
-- point of reading it raw here is to pin the column 'setOrderSettled' writes separately from
-- @settled_at@.
updatedAtOf :: HasCallStack => DBStore -> Text -> IO UTCTime
updatedAtOf st orderId = do
rows <- withConnection st $ \db -> DB.query db "SELECT updated_at FROM sx_badge_service_web_orders WHERE order_id = ?" (Only orderId)
case rows of
[Only at] -> pure at
_ -> expectationFailure ("expected exactly one order row for " <> show orderId) >> error "unreachable"
-- | The @invoices row as the database holds it: @(provider, price, amount, currency, status,
-- payment_crypto_currency)@. Read as raw columns rather than through 'getOrder', so the
-- assertions pin what was written rather than what the join makes of it -- and because
-- @invoices.status@ and @provider@ are read by nothing in production (A3), these are the only
-- reads of them that will ever exist.
serviceInvoiceRow :: HasCallStack => DBStore -> Text -> IO (Text, Int64, Int64, Text, Text, Maybe Text)
serviceInvoiceRow st invoiceId = do
rows <-
withConnection st $ \db ->
DB.query db "SELECT provider, price, amount, currency, status, payment_crypto_currency FROM sx_badge_service_invoices WHERE invoice_id = ?" (Only invoiceId)
case rows of
[row] -> pure row
_ -> expectationFailure ("expected exactly one invoice row for " <> show invoiceId <> ", got " <> show (length rows)) >> error "unreachable"
-- createOrder writes BOTH rows -- the @invoices row and the @web_orders row that references it --
-- and getOrder returns them joined, so one call yields the amount, currency, address, crypto
-- amount, payment URL and expiry E4 serves. The catalog is seeded first and the order pinned to a
-- real price and offer, so price_id/offer_id are exercised as the foreign keys they are rather
-- than as NULLs.
testBadgeStoreCreateOrderAndGet :: HasCallStack => TestParams -> IO ()
testBadgeStoreCreateOrderAndGet ps =
withFreshBadgeStore ps $ \st -> do
seedCatalog st
BadgeCatalog {prices, offers} <- expectRight $ withServiceTransaction st getActiveCatalog
Just BadgePrice {priceId = supporterPriceId} <- pure $ find (\BadgePrice {badgeType} -> badgeType == BTSupporter) prices
Just BadgeOffer {offerId = threeMonths} <- pure $ find (\BadgeOffer {priceId, months} -> priceId == Just supporterPriceId && months == 3) offers
now <- getCurrentTime
let expiry = addUTCTime (30 * 60) now
newOrder = testNewOrder "d0-order-1" "K3M7Q" expiry (Just supporterPriceId) (Just threeMonths)
_ <- expectRight $ withServiceTransaction st $ \db -> createOrder db newOrder now
-- both rows, and exactly one of each
serviceRowCount' st "web_orders" `shouldReturn` 1
serviceRowCount' st "invoices" `shouldReturn` 1
Just order <- expectRight $ withServiceTransaction st $ \db -> getOrder db "d0-order-1"
let WebOrder {orderId, invoiceId, providerRef, method, shortRef, badgeType, priceId, offerId, months, status} = order
WebOrder {amountPaid, settledAt, amount, currency, payUrl, paymentAddress, cryptoAmount, expiresAt, createdAt} = order
(orderId, invoiceId, shortRef) `shouldBe` ("d0-order-1", "d0-order-1-invoice", "K3M7Q")
(providerRef, method, badgeType, months) `shouldBe` (Just "d0-order-1-btcpay", OMBtc, BTSupporter, 3)
(priceId, offerId) `shouldBe` (Just supporterPriceId, Just threeMonths)
-- a new order is invoiced and unpaid: neither column a settlement writes is set
(status, amountPaid, settledAt) `shouldBe` (WOSInvoiced, Nothing, Nothing)
-- the invoice half of the join, which is the whole point of getOrder returning one record
(amount, currency) `shouldBe` (CurrencyAmount 1400, "usd")
(payUrl, paymentAddress, cryptoAmount) `shouldBe` (Nothing, Just "bc1qd0teststoreaddress", Just "0.00042")
expiresAt `shouldBeStoredAt` expiry
createdAt `shouldBeStoredAt` now
-- and the invoice columns nothing in this plan reads: provider is derived from the method
-- (btc -> crypto), price and amount both carry the charged total, the status is the order's
-- projected through orderInvoiceStatus (invoiced -> open), and payment_crypto_currency is
-- deliberately left NULL (A3)
serviceInvoiceRow st "d0-order-1-invoice" `shouldReturn` ("crypto", 1400, 1400, "usd", "open", Nothing)
unknown <- expectRight $ withServiceTransaction st $ \db -> getOrder db "d0-order-nonexistent"
(orderIdOf <$> unknown) `shouldBe` Nothing
-- getOrderByProviderRef and getOrderByShortRef each resolve exactly one order, and setOrderProviderRef
-- REPLACES the reference an order was created with -- which F3 needs, because a Stripe payment intent
-- is only known once the checkout session completes. Two orders exist throughout, so a lookup that
-- ignored its argument and returned the only row would fail.
testBadgeStoreOrderRefLookups :: HasCallStack => TestParams -> IO ()
testBadgeStoreOrderRefLookups ps =
withFreshBadgeStore ps $ \st -> do
now <- getCurrentTime
let expiry = addUTCTime (30 * 60) now
_ <- expectRight $ withServiceTransaction st $ \db -> createOrder db (testNewOrder "d0-order-1" "K3M7Q" expiry Nothing Nothing) now
_ <- expectRight $ withServiceTransaction st $ \db -> createOrder db (testNewOrder "d0-order-2" "T9WZ4" expiry Nothing Nothing) now
let byProviderRef ref = fmap orderIdOf <$> expectRight (withServiceTransaction st $ \db -> getOrderByProviderRef db ref)
byShortRef ref = fmap orderIdOf <$> expectRight (withServiceTransaction st $ \db -> getOrderByShortRef db ref)
byProviderRef "d0-order-1-btcpay" `shouldReturn` Just "d0-order-1"
byProviderRef "d0-order-2-btcpay" `shouldReturn` Just "d0-order-2"
-- the replacement: the old reference resolves to nothing, the new one to the same order
_ <- expectRight $ withServiceTransaction st $ \db -> setOrderProviderRef db "d0-order-1" "pi_d0_stripe_intent" now
byProviderRef "d0-order-1-btcpay" `shouldReturn` Nothing
byProviderRef "pi_d0_stripe_intent" `shouldReturn` Just "d0-order-1"
byProviderRef "d0-order-2-btcpay" `shouldReturn` Just "d0-order-2"
-- the bank-statement reference support resolves by (H2), and only that order
byShortRef "K3M7Q" `shouldReturn` Just "d0-order-1"
byShortRef "T9WZ4" `shouldReturn` Just "d0-order-2"
byShortRef "QQQQQ" `shouldReturn` Nothing
-- and every mutation names an order that must exist
refused <- withServiceTransaction st $ \db -> setOrderProviderRef db "d0-order-nonexistent" "pi_nothing" now
refused `shouldBe` Left SEOrderNotFound
-- getStuckOrders returns the orders H3's pass must re-read from the provider: still open
-- (invoiced or pending) and past their invoice expiry, oldest expiry first. A paid order is never
-- stuck however long ago its invoice expired -- late on-chain settlement is routine (E3) -- and an
-- unexpired order is not stuck yet.
testBadgeStoreStuckOrders :: HasCallStack => TestParams -> IO ()
testBadgeStoreStuckOrders ps =
withFreshBadgeStore ps $ \st -> do
now <- getCurrentTime
let daysAgo d = addUTCTime (negate d * nominalDay) now
create orderId shortRef expiry = expectRight $ withServiceTransaction st $ \db -> createOrder db (testNewOrder orderId shortRef expiry Nothing Nothing) now
-- Insertion order is the REVERSE of expiry order, and the ids sort the same wrong way
-- ("d0-stuck-newer" < "d0-stuck-oldest"), so the expected list is neither the natural scan
-- order nor an order-by-id: dropping the ORDER BY clause altogether fails this, not only
-- inverting it. Unordered is the realistic regression -- a WHERE-clause edit that changes
-- the plan, or Postgres reusing heap space after an update -- and a fixture whose insertion
-- order happens to match the expectation cannot see it.
create "d0-stuck-newer" "BBBBB" (daysAgo 1)
create "d0-stuck-oldest" "AAAAA" (daysAgo 3)
create "d0-settled" "CCCCC" (daysAgo 2)
create "d0-open" "DDDDD" (addUTCTime nominalDay now)
_ <- expectRight $ withServiceTransaction st $ \db -> updateOrderStatus db "d0-stuck-newer" WOSPending (Just (CurrencyAmount 700)) now
_ <- expectRight $ withServiceTransaction st $ \db -> setOrderSettled db "d0-settled" (CurrencyAmount 1400) now now
stuck <- expectRight $ withServiceTransaction st $ \db -> getStuckOrders db now
-- oldest expiry first, the paid one omitted, the unexpired one omitted
map orderIdOf stuck `shouldBe` ["d0-stuck-oldest", "d0-stuck-newer"]
map (\WebOrder {status} -> status) stuck `shouldBe` [WOSInvoiced, WOSPending]
-- the cutoff is the instant passed in, not the current time: two days back, only the oldest
-- has expired
earlier <- expectRight $ withServiceTransaction st $ \db -> getStuckOrders db (daysAgo 2)
map orderIdOf earlier `shouldBe` ["d0-stuck-oldest"]
-- updateOrderStatus records what arrived without settling: a partial payment moves the order to
-- pending and writes amount_paid, leaving settled_at NULL, and a later status change with no
-- amount keeps the amount already recorded (E5 renders it for an underpaid expiry). setOrderSettled
-- then writes settled_at, amount_paid and status = 'paid' together. Both move @invoices.status in
-- step, which is A3's invariant and is asserted from the column itself.
testBadgeStoreOrderStatusAndSettlement :: HasCallStack => TestParams -> IO ()
testBadgeStoreOrderStatusAndSettlement ps =
withFreshBadgeStore ps $ \st -> do
now <- getCurrentTime
let expiry = addUTCTime (30 * 60) now
invoiceStatusOf invoiceId = do
(_, _, _, _, status, _) <- serviceInvoiceRow st invoiceId
pure status
invoiceStatus = invoiceStatusOf "d0-order-1-invoice"
-- a second order nothing below touches: every assertion on it is what proves
-- setOrderInvoiceStatus resolves the invoice THROUGH the order it was given, rather
-- than updating whatever invoice rows exist
untouchedInvoiceStatus = invoiceStatusOf "d0-order-2-invoice"
orderState = do
Just WebOrder {status, amountPaid, settledAt} <- expectRight $ withServiceTransaction st $ \db -> getOrder db "d0-order-1"
pure (status, amountPaid, settledAt)
_ <- expectRight $ withServiceTransaction st $ \db -> createOrder db (testNewOrder "d0-order-1" "K3M7Q" expiry Nothing Nothing) now
_ <- expectRight $ withServiceTransaction st $ \db -> createOrder db (testNewOrder "d0-order-2" "T9WZ4" expiry Nothing Nothing) now
invoiceStatus `shouldReturn` "open"
untouchedInvoiceStatus `shouldReturn` "open"
-- a partial payment: recorded, not settled. The order moves invoiced -> pending; the invoice
-- does not move at all, because orderInvoiceStatus maps both onto ISOpen
_ <- expectRight $ withServiceTransaction st $ \db -> updateOrderStatus db "d0-order-1" WOSPending (Just (CurrencyAmount 700)) now
orderState `shouldReturn` (WOSPending, Just (CurrencyAmount 700), Nothing)
invoiceStatus `shouldReturn` "open"
untouchedInvoiceStatus `shouldReturn` "open"
-- an underpaid expiry: no new amount, and the one already recorded is kept rather than cleared
_ <- expectRight $ withServiceTransaction st $ \db -> updateOrderStatus db "d0-order-1" WOSExpired Nothing now
orderState `shouldReturn` (WOSExpired, Just (CurrencyAmount 700), Nothing)
invoiceStatus `shouldReturn` "expired"
-- late settlement after expiry, which is routine on-chain: all three columns at once. The
-- settlement instant is BEFORE `now` here, as an on-chain confirmation read after the fact
-- is, so it must not be written to updated_at -- hence the separate argument.
let settledAt = addUTCTime (negate (2 * 3600)) now
settledNow = addUTCTime 60 now
_ <- expectRight $ withServiceTransaction st $ \db -> setOrderSettled db "d0-order-1" (CurrencyAmount 1400) settledAt settledNow
(status, amountPaid, storedSettledAt) <- orderState
(status, amountPaid) `shouldBe` (WOSPaid, Just (CurrencyAmount 1400))
case storedSettledAt of
Just at -> at `shouldBeStoredAt` settledAt
Nothing -> expectationFailure "settled_at should be set by setOrderSettled"
-- the invoice leaves 'expired' for 'paid' with the order: the projection is applied on every
-- write, not only on the way out of 'open'
invoiceStatus `shouldReturn` "paid"
untouchedInvoiceStatus `shouldReturn` "open"
-- settled_at is the payment's instant, updated_at the row's: the settlement did not drag
-- updated_at back before the status change that preceded it
updatedAtOf st "d0-order-1" >>= (`shouldBeStoredAt` settledNow)
-- neither writer invents an order
missingUpdate <- withServiceTransaction st $ \db -> updateOrderStatus db "d0-order-nonexistent" WOSPending Nothing now
missingUpdate `shouldBe` Left SEOrderNotFound
missingSettle <- withServiceTransaction st $ \db -> setOrderSettled db "d0-order-nonexistent" (CurrencyAmount 1400) now now
missingSettle `shouldBe` Left SEOrderNotFound
-- A3's invariant is enforced, not merely upheld: an order that resolves to no invoice --
-- which createOrder cannot produce, but a future writer of this table could -- fails rather
-- than moving the order's status while leaving the invoice's behind. Planted with raw SQL
-- because no store function can reach that state.
withConnection st $ \db ->
DB.execute_ db "INSERT INTO sx_badge_service_web_orders (order_id, invoice_id, method, short_ref, badge_type, months, status, created_at, updated_at) VALUES ('d0-order-no-invoice',NULL,'btc','ZZZZZ','supporter',3,'invoiced','2026-03-10','2026-03-10')"
orphaned <- withServiceTransaction st $ \db -> updateOrderStatus db "d0-order-no-invoice" WOSPending Nothing now
orphaned `shouldBe` Left SEInvoiceNotFound
-- and the order's own UPDATE, which succeeded before the invoice write failed, was rolled
-- back with it -- the reason withServiceTransaction throws rather than returning Left
orphanStatus <- withConnection st $ \db -> DB.query db "SELECT status FROM sx_badge_service_web_orders WHERE order_id = ?" (Only ("d0-order-no-invoice" :: Text))
orphanStatus `shouldBe` [Only ("invoiced" :: Text)]
-- recordProviderEvent returns False ONLY for an event that has already been PROCESSED. A row whose
-- processed_at is NULL is one whose previous attempt died mid-settlement, so it comes back as True
-- and is processed again (E3, E7); treating it as a duplicate would strand a paid order forever.
-- markProviderEventProcessed is what flips that row, inside the settlement transaction.
testBadgeStoreProviderEventReplay :: HasCallStack => TestParams -> IO ()
testBadgeStoreProviderEventReplay ps =
withFreshBadgeStore ps $ \st -> do
now <- getCurrentTime
let record provider eventId at = expectRight $ withServiceTransaction st $ \db -> recordProviderEvent db provider eventId at
eventRow :: Text -> IO (UTCTime, Maybe UTCTime)
eventRow eventId = do
rows <-
withConnection st $ \db ->
DB.query db "SELECT received_at, processed_at FROM sx_badge_service_provider_events WHERE provider = 'crypto' AND event_id = ?" (Only eventId)
case rows of
[row] -> pure (row :: (UTCTime, Maybe UTCTime))
_ -> expectationFailure ("expected exactly one event row, got " <> show (length rows)) >> error "unreachable"
-- first delivery
record PPCrypto "d0-invoice-1-InvoiceSettled" now `shouldReturn` True
-- redelivered before the first attempt completed: reprocess, and the row is not duplicated
let redeliveredAt = addUTCTime 60 now
record PPCrypto "d0-invoice-1-InvoiceSettled" redeliveredAt `shouldReturn` True
serviceRowCount' st "provider_events" `shouldReturn` 1
-- the row still carries the FIRST delivery's time, and is still unprocessed
(receivedAt, unprocessed) <- eventRow "d0-invoice-1-InvoiceSettled"
receivedAt `shouldBeStoredAt` now
unprocessed `shouldBe` Nothing
-- the settlement transaction completes
let processedAt = addUTCTime 120 now
_ <- expectRight $ withServiceTransaction st $ \db -> markProviderEventProcessed db PPCrypto "d0-invoice-1-InvoiceSettled" processedAt
(_, storedProcessedAt) <- eventRow "d0-invoice-1-InvoiceSettled"
case storedProcessedAt of
Just at -> at `shouldBeStoredAt` processedAt
Nothing -> expectationFailure "processed_at should be set by markProviderEventProcessed"
-- and now the same event is a replay
record PPCrypto "d0-invoice-1-InvoiceSettled" (addUTCTime 180 now) `shouldReturn` False
-- dedup is keyed on (provider, event_id): the same id from another provider is a new event
record PPStripe "d0-invoice-1-InvoiceSettled" now `shouldReturn` True
-- as is a different event from the same provider
record PPCrypto "d0-invoice-1-InvoiceProcessing" now `shouldReturn` True
serviceRowCount' st "provider_events" `shouldReturn` 3
-- closing out an event that was never recorded is a mismatch between the two calls, not a no-op
missing <- withServiceTransaction st $ \db -> markProviderEventProcessed db PPCrypto "d0-invoice-1-never-recorded" now
missing `shouldBe` Left SEProviderEventNotFound
-- B4 issuer key + credential signing -----------------------------------------
sundayEndOfDay :: DiffTime
sundayEndOfDay = secondsToDiffTime (23 * 3600 + 59 * 60 + 59)
testBadgeRequest :: BadgeMasterKey -> BadgeRequest
testBadgeRequest masterKey = BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Nothing, badgeExtra = ""}}
-- Signs a real credential (via issueSignedBadge, never reimplementing BBS) and checks it
-- verifies with the matching public key -- and, the other side of the same property, fails
-- with an unrelated one.
testBadgeCredentialSignAndVerify :: HasCallStack => TestParams -> IO ()
testBadgeCredentialSignAndVerify _ps = do
Right (pk, sk) <- bbsKeyGen
Right (otherPk, _) <- bbsKeyGen
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
result <- issueSignedBadge 1 sk (testBadgeRequest masterKey) now
case result of
Left e -> expectationFailure $ "expected a signed credential, got: " <> show e
Right cred -> do
verifyCredential pk cred `shouldReturn` True
verifyCredential otherPk cred `shouldReturn` False
-- A periodEnd that is NOT already a Sunday must still land on a Sunday at 23:59:59 UTC.
testBadgeCredentialExpiryIsSundayEndOfDay :: HasCallStack => TestParams -> IO ()
testBadgeCredentialExpiryIsSundayEndOfDay _ps = do
Right (_, sk) <- bbsKeyGen
masterKey <- BadgeMasterKey <$> getRandomBytes 32
let periodEnd = UTCTime (fromGregorian 2026 8 20) 0 -- a Thursday
Right BadgeCredential {badgeInfo = BadgeInfo {badgeExpiry}} <- issueSignedBadge 1 sk (testBadgeRequest masterKey) periodEnd
case badgeExpiry of
Nothing -> expectationFailure "expected badgeExpiry to be set"
Just (UTCTime day tod) -> do
let (_, _, dow) = toWeekDate day -- 1 = Monday .. 7 = Sunday
dow `shouldBe` 7
tod `shouldBe` sundayEndOfDay
-- The boundary case: a periodEnd already on a Sunday must expire on the FOLLOWING Sunday, not
-- the same day -- a non-strict implementation would silently cost every such badge a week of
-- validity.
testBadgeCredentialExpirySundayRollsToFollowingSunday :: HasCallStack => TestParams -> IO ()
testBadgeCredentialExpirySundayRollsToFollowingSunday _ps = do
Right (_, sk) <- bbsKeyGen
masterKey <- BadgeMasterKey <$> getRandomBytes 32
let periodEnd = UTCTime (fromGregorian 2026 8 23) 0 -- already a Sunday
expectedExpiry = UTCTime (fromGregorian 2026 8 30) sundayEndOfDay -- the following Sunday
Right BadgeCredential {badgeInfo = BadgeInfo {badgeExpiry}} <- issueSignedBadge 1 sk (testBadgeRequest masterKey) periodEnd
badgeExpiry `shouldBe` Just expectedExpiry
-- issueBadge itself rejects a non-empty badgeExtra; issueSignedBadge must surface that as
-- BSEBadRequest rather than letting the raw BBS error leak.
testBadgeCredentialRejectsNonEmptyBadgeExtra :: HasCallStack => TestParams -> IO ()
testBadgeCredentialRejectsNonEmptyBadgeExtra _ps = do
Right (_, sk) <- bbsKeyGen
masterKey <- BadgeMasterKey <$> getRandomBytes 32
now <- getCurrentTime
let req = BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Nothing, badgeExtra = "reserved"}}
result <- issueSignedBadge 1 sk req now
result `shouldBe` Left BSEBadRequest
-- Round-trips a real `simplex-chat badge keygen`-shaped file (the format written by
-- writeTestBadgeServiceSecrets) through loadIssuerKey and checks the loaded secret matches.
testBadgeIssuerKeyLoadsValidFile :: HasCallStack => TestParams -> IO ()
testBadgeIssuerKeyLoadsValidFile TestParams {tmpPath} = do
Right (BBSPublicKey pk, sk@(BBSSecretKey skBytes)) <- bbsKeyGen
let path = tmpPath </> "valid-issuer.keys"
writeFile path $ "secret " <> BC.unpack (strEncode skBytes) <> "\npublic " <> BC.unpack (strEncode pk) <> "\n"
loaded <- loadIssuerKey path 1
loaded `shouldBe` sk
expectDies :: forall a. HasCallStack => IO a -> IO ()
expectDies action = do
r <- try action :: IO (Either ExitCode a)
case r of
Left (ExitFailure _) -> pure ()
Left ExitSuccess -> expectationFailure "expected a failing exit, got ExitSuccess"
Right _ -> expectationFailure "expected loadIssuerKey to fail fast, but it returned"
testBadgeIssuerKeyMissingFile :: HasCallStack => TestParams -> IO ()
testBadgeIssuerKeyMissingFile TestParams {tmpPath} =
expectDies $ loadIssuerKey (tmpPath </> "missing-issuer.keys") 1
testBadgeIssuerKeyMalformedFile :: HasCallStack => TestParams -> IO ()
testBadgeIssuerKeyMalformedFile TestParams {tmpPath} = do
let path = tmpPath </> "malformed-issuer.keys"
writeFile path "not the expected keygen output\n"
expectDies $ loadIssuerKey path 1
testBadgeIssuerKeyNonPositiveIdx :: HasCallStack => TestParams -> IO ()
testBadgeIssuerKeyNonPositiveIdx TestParams {tmpPath} = do
(issuerKeyFile, _) <- writeTestBadgeServiceSecrets tmpPath
expectDies $ loadIssuerKey issuerKeyFile 0
#if defined(dbPostgres)
runMigrationsToRun :: DBStore -> MigrationsToRun -> IO ()
runMigrationsToRun st = Migrations.run st Nothing
#else
runMigrationsToRun :: DBStore -> MigrationsToRun -> IO ()
runMigrationsToRun st = Migrations.run st Nothing True
#endif
-- C5 -- the client against the live badge service -----------------------------
--
-- C4's redeem path, C2's commands and C3's worker, driven through the app's own API against the
-- REAL service rather than a stub. C4's Verify line is "covered by C5", so everything below is
-- the only check that step has, and the rules B10 works to hold here too:
-- * time is injected on BOTH sides -- the service's clock (A6, 'withBadgeServiceAddress') and
-- the client's 'badgeCurrentTime' (C2) -- so no test sleeps and no test waits on
-- 'badgePassInterval';
-- * expected values are literal (fixed dates, fixed row shapes), never recomputed the way
-- production computes them;
-- * "wrote nothing" is a row count on BOTH databases, never "the error read the same".
badgeClientDbPrefix :: FilePath
badgeClientDbPrefix = "badge_client"
badgeSecondClientDbPrefix :: FilePath
badgeSecondClientDbPrefix = "badge_client2"
badgeStubDbPrefix :: FilePath
badgeStubDbPrefix = "badge_stub"
-- | The client config that reaches the badge service: the address it published and the issuer key
-- it actually loaded, so a credential it signs verifies here and one signed by anything else does
-- not.
badgeClientCfg :: String -> BBSPublicKey -> ChatConfig
badgeClientCfg address pk =
testCfg {badgeServiceAddress = Just (badgeServiceTarget address), badgePublicKeys = testBadgeKeys pk}
-- | Parses a published address into the 'ConnectTarget' 'badgeServiceAddress' holds. Both forms
-- the harness reads back decode through this: the short link and the full contact request URI.
badgeServiceTarget :: HasCallStack => String -> ConnectTarget 'CMContact
badgeServiceTarget address = case strDecode (BC.pack address) of
Right target -> target
Left e -> error $ "badge service address " <> address <> " is not a connect target: " <> e
-- | The C5 harness: B10's badge service on the given clock, and the client that reaches it.
--
-- The client's config is the body's, because the cases differ in one field each: the issuer key
-- (the unverifiable-credential case configures the WRONG one), the address (the resolution case
-- uses the full URI), the badge clock (the worker case shares the service's, the profile-switch
-- case gates it) and the agent's service request timeout (the stopped-service case has nothing
-- listening). Both published forms of the address are handed to the config builder and to the
-- body, which needs them to configure a SECOND client against the same service.
withBadgeClientCfg :: HasCallStack => TestParams -> IO UTCTime -> IO () -> ((String, String) -> BBSPublicKey -> ChatConfig) -> (TestCC -> (String, String) -> IO ()) -> IO ()
withBadgeClientCfg ps serviceClock seedCodes mkCfg test =
withBadgeServiceAddress ps serviceClock (writeTestBadgeServiceConfig ps) seedCodes $ \links withService -> do
pk <- readTestIssuerPublicKey (tmpPath ps)
withService $
withNewTestChatCfg ps (mkCfg links pk) badgeClientDbPrefix aliceProfile $ \alice ->
test alice links
-- | The common shape: ONE clock driving the service and the client's badge time alike, so every
-- date below is a literal on both sides and a month is crossed by moving one 'IORef'.
withBadgeClient :: HasCallStack => TestParams -> IORef UTCTime -> IO () -> (TestCC -> IO ()) -> IO ()
withBadgeClient ps clock seedCodes test =
withBadgeClientCfg ps (readIORef clock) seedCodes (sharedClockCfg clock) (\alice _ -> test alice)
sharedClockCfg :: IORef UTCTime -> (String, String) -> BBSPublicKey -> ChatConfig
sharedClockCfg clock (sLink, _fullLink) pk = (badgeClientCfg sLink pk) {badgeCurrentTime = readIORef clock}
-- | The same, on the REAL clock on both sides: for the example whose assertion depends on the
-- credential still being unexpired when something OTHER than the badge clock reads it -- a
-- CONTACT's copy of the profile, whose badge status is computed against wall-clock time
-- ('Simplex.Chat.Types.toLocalProfile') and would read as expired for a period issued in a fixed
-- past month.
withBadgeClientNow :: HasCallStack => TestParams -> IO () -> (TestCC -> (String, String) -> IO ()) -> IO ()
withBadgeClientNow ps seedCodes = withBadgeClientCfg ps getCurrentTime seedCodes (\(sLink, _) pk -> badgeClientCfg sLink pk)
-- | Runs an action against the CLIENT's store, the way 'withServiceDB' runs one against the
-- service's.
withClientStore :: TestCC -> (DB.Connection -> IO a) -> IO a
withClientStore TestCC {chatController = ChatController {chatStore}} = withTransaction chatStore
clientUser :: HasCallStack => TestCC -> IO User
clientUser TestCC {chatController = ChatController {currentUser}} =
readTVarIO currentUser >>= maybe (expectationFailure "no active user" >> error "unreachable") pure
-- | The rows one redemption writes on the CLIENT, counted together: payments, purchases, ledger
-- entries, issuances, and profiles pointing at a shown badge. Every "writes nothing" assertion
-- below pins this tuple, because an unchanged error message is not evidence that nothing was
-- written.
clientBadgeRowCounts :: TestCC -> IO (Int, Int, Int, Int, Int)
clientBadgeRowCounts cc =
withClientStore cc $ \db -> do
payments <- countRows db "payments"
purchases <- countRows db "badge_purchases"
entries <- countRows db "badge_ledger"
issuances <- countRows db "badge_issuances"
[Only shown] <- DB.query_ db "SELECT count(*) FROM users WHERE shown_badge_id IS NOT NULL"
pure (payments, purchases, entries, issuances, shown)
where
countRows db table = do
[Only n] <- DB.query_ db (fromString ("SELECT count(*) FROM " <> table))
pure n
noClientBadgeRows :: HasCallStack => String -> TestCC -> Expectation
noClientBadgeRows label cc = do
counts <- clientBadgeRowCounts cc
(label, counts) `shouldBe` (label, (0, 0, 0, 0, 0))
-- | The client's ledger replica as the database holds it, in append order.
type ClientLedgerRow = (Text, Int, Int, UTCTime, Text, Text, Maybe Text, Maybe Text, Maybe Text)
clientLedgerRows :: TestCC -> IO [ClientLedgerRow]
clientLedgerRows cc =
withClientStore cc $ \db ->
DB.query_ db "SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, entry_type, entry_credit_type, entry_debit_type, payment_id FROM badge_ledger ORDER BY entry_id"
-- | The same columns from the SERVICE's own ledger, which is what the replica is compared to.
type ServiceLedgerParityRow = (Text, Int, Int, UTCTime, Text, Text, Maybe Text, Maybe Text)
serviceLedgerParityRows :: DB.Connection -> IO [ServiceLedgerParityRow]
serviceLedgerParityRows db =
DB.query_ db "SELECT entry_uuid, change_months, balance_months, balance_start_ts, balance_badge_type, entry_type, entry_credit_type, entry_debit_type FROM sx_badge_service_badge_ledger ORDER BY entry_id"
-- | §10's row-for-row comparison of the two ledgers: @entry_uuid@, @change_months@,
-- @balance_months@, @balance_start_ts@, @balance_badge_type@ and the entry type must agree
-- exactly, and the client's @payment_id@ must be NULL -- the wire field is the SERVICE's invoice
-- id and the client column holds a CLIENT payments id, so carrying it across would tie the two
-- databases together (C1).
--
-- Compared entry by entry rather than list against list, so a failure names the entry.
shouldMatchServiceLedger :: HasCallStack => [ClientLedgerRow] -> [ServiceLedgerParityRow] -> Expectation
shouldMatchServiceLedger clientRows serviceRows = do
("ledger entry count" :: String, length clientRows) `shouldBe` ("ledger entry count", length serviceRows)
forM_ (zip3 [1 :: Int ..] clientRows serviceRows) $ \(n, c, s) -> do
let (cUuid, cChange, cBalance, cTs, cBadgeType, cType, cCredit, cDebit, cPayment) = c
(sUuid, sChange, sBalance, sTs, sBadgeType, sType, sCredit, sDebit) = s
label = "ledger entry " <> show n
(label, (cUuid, cChange, cBalance, cBadgeType, cType, cCredit, cDebit))
`shouldBe` (label, (sUuid, sChange, sBalance, sBadgeType, sType, sCredit, sDebit))
cTs `shouldBeStoredAt` sTs
(label <> " client payment_id", cPayment) `shouldBe` (label <> " client payment_id", Nothing)
-- | The two lines 'CRBadgeState' prints -- the badge and the web base url, which no test config
-- sets.
expectBadgeState :: HasCallStack => TestCC -> String -> Expectation
expectBadgeState cc line = do
cc <## line
cc <## "badge site: not configured"
-- | Nothing more is reported: no event followed the response.
reportsNoBadgeEvent :: HasCallStack => TestCC -> Expectation
reportsNoBadgeEvent cc = cc <// 500000
purchaseCodeCmd :: Text -> String
purchaseCodeCmd code = "/_badge purchase 1 {\"type\":\"code\",\"code\":\"" <> T.unpack code <> "\"}"
-- The badge state a 3-month code redeemed at 'testClockStart' leaves, spelled out: the period is
-- [2026-03-10, 2026-04-10) and the 2 months still funded run from its end, so the profile is paid
-- through 2026-06-10.
threeMonthBadgeState :: String
threeMonthBadgeState = "supporter badge 1 (shown): issued, 2 month(s) left, paid through 2026-06-10"
-- | The stored credential of every issuance the client holds, oldest period first.
clientIssuedCredentials :: HasCallStack => TestCC -> IO [BadgeCredential]
clientIssuedCredentials cc = do
rows <- withClientStore cc $ \db -> DB.query_ db "SELECT credential FROM badge_issuances ORDER BY period_start"
mapM decodeCredential rows
where
decodeCredential (Only (Binary bs)) =
maybe (expectationFailure "a stored badge credential does not decode" >> error "unreachable") pure $
J.decodeStrict (bs :: BC.ByteString)
-- | The master key of the client's only purchase, as the row holds it.
clientPurchaseMasterKey :: HasCallStack => TestCC -> IO BadgeMasterKey
clientPurchaseMasterKey cc =
withClientStore cc (\db -> DB.query_ db "SELECT master_key FROM badge_purchases") >>= \case
[Only (Binary mk)] -> pure $ BadgeMasterKey mk
other -> expectationFailure ("expected exactly one client purchase, got " <> show (length other)) >> error "unreachable"
credentialMasterKey :: BadgeCredential -> BadgeMasterKey
credentialMasterKey BadgeCredential {masterKey} = masterKey
credentialBadgeType :: BadgeCredential -> BadgeType
credentialBadgeType BadgeCredential {badgeInfo = BadgeInfo {badgeType}} = badgeType
-- The happy path ---------------------------------------------------------------
-- | A profile with no prior connection to the service redeems a seeded code on the first attempt,
-- and everything that redemption leaves behind is checked in one place: the reported state, the
-- profile's own badge, the five client rows and their columns, the credential's verification under
-- the key the service actually loaded, and the ledger replica against the service's own rows.
--
-- One 'IORef' is both clocks, so every date is a literal and the client's badge status is computed
-- at the instant the service issued the period.
testC5RedeemCodeShowsBadge :: HasCallStack => TestParams -> IO ()
testC5RedeemCodeShowsBadge ps = do
clock <- newTestClock testClockStart
codeRef <- newIORef ""
ledgerRef <- newIORef []
withBadgeClient ps clock (seedOneSupporterCode ps >>= writeIORef codeRef) $ \alice -> do
code <- readIORef codeRef
alice ##> purchaseCodeCmd code
expectBadgeState alice threeMonthBadgeState
-- the response IS the state: a successful redemption emits no CEvtBadgeChanged, which would
-- print the badge line a second time
reportsNoBadgeEvent alice
-- the profile carries the badge
alice ##> "/p"
alice <## "user profile: alice (Alice, * supporter)"
alice <## "use /p <name> [<bio>] to change it"
-- and the state reads the same when asked again (which also signals the worker, and it finds
-- the month already issued and does nothing)
alice ##> "/_badge state 1"
expectBadgeState alice threeMonthBadgeState
reportsNoBadgeEvent alice
-- the stored credential verifies under the key the service actually loaded -- which says the
-- credential is genuine, NOT that the client checked it: a client that stored blindly would
-- hold this same validly-signed credential, because the real service only signs valid ones.
-- What proves the check happens is 'testC5UnverifiableCredentialWritesNothing', where the
-- configured key is not the service's and nothing at all is written.
issuerPub <- readTestIssuerPublicKey (tmpPath ps)
cred <-
clientIssuedCredentials alice >>= \case
[c] -> pure c
other -> expectationFailure ("expected exactly one issuance, got " <> show (length other)) >> error "unreachable"
verifyCredential issuerPub cred `shouldReturn` True
credentialBadgeType cred `shouldBe` BTSupporter
-- and it is bound to the master key the purchase row holds
clientPurchaseMasterKey alice `shouldReturn` credentialMasterKey cred
-- exactly one row of each kind, with the columns C1 specified
clientBadgeRowCounts alice `shouldReturn` (1, 1, 2, 1, 1)
User {userId = aliceUserId} <- clientUser alice
withClientStore alice $ \db -> do
[paymentRow] <- DB.query_ db "SELECT payment_id, provider, invoice_id, status FROM payments"
let (paymentRowId, provider, invoiceId, paymentStatus) = paymentRow :: (Text, Text, Maybe Text, Text)
("payment" :: String, (provider, invoiceId, paymentStatus)) `shouldBe` ("payment", ("code", Nothing, "settled"))
[purchaseRow] <-
DB.query_ db "SELECT badge_purchase_id, user_id, initial_badge_type, current_badge_type, status, payment_id FROM badge_purchases"
let (purchaseId, purchaseUserId, initialType, currentType, purchaseStatus, purchasePayment) =
purchaseRow :: (Int64, Int64, Text, Text, Text, Maybe Text)
("purchase" :: String, (purchaseUserId, initialType, currentType, purchaseStatus, purchasePayment))
`shouldBe` ("purchase", (aliceUserId, "supporter", "supporter", "issued", Just paymentRowId))
-- the private key column is the client-only one C1 added, and without it the purchase could
-- never sign an issueBadge again
[Only privKey] <- DB.query_ db "SELECT purchase_priv_key FROM badge_purchases"
("purchase private key" :: String, isJust (privKey :: Maybe (Binary BC.ByteString)))
`shouldBe` ("purchase private key", True)
[Only shownBadgeId] <- DB.query db "SELECT shown_badge_id FROM users WHERE user_id = ?" (Only aliceUserId)
("shown badge" :: String, shownBadgeId) `shouldBe` ("shown badge", Just purchaseId)
-- the issuance carries the period the statement implied, and the credential's own expiry
[issuanceRow] <-
DB.query_ db "SELECT badge_purchase_id, badge_type, period_start, period_end, expiry, entry_id FROM badge_issuances"
let (issuancePurchase, issuanceType, periodStart, periodEnd, expiry, entryId) =
issuanceRow :: (Int64, Text, UTCTime, UTCTime, UTCTime, Maybe Int64)
("issuance" :: String, (issuancePurchase, issuanceType, entryId))
`shouldBe` ("issuance", (purchaseId, "supporter", Nothing))
periodStart `shouldBeStoredAt` testClockStart
periodEnd `shouldBeStoredAt` firstPeriodEnd
expiry `shouldBeStoredAt` firstCredentialExpiry
-- held for the parity check below: the service's database cannot be opened while the service
-- itself is running
clientLedgerRows alice >>= writeIORef ledgerRef
clientRows <- readIORef ledgerRef
withServiceDB ps $ \db -> do
serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
serviceLedgerParityRows db >>= shouldMatchServiceLedger clientRows
-- | The only assertion that the post-lock broadcast actually happens: a contact connected BEFORE
-- the redemption sees the badge on the profile afterwards, with no action of alice's beyond the
-- redemption itself.
--
-- The star on @alice *>@ is the assertion: a contact prints it only for a badge it verified and
-- found ACTIVE, and that status is computed against wall-clock time, hence the real clock here.
testC5ContactSeesBadgeAfterRedeem :: HasCallStack => TestParams -> IO ()
testC5ContactSeesBadgeAfterRedeem ps = do
codeRef <- newIORef ""
withBadgeClientNow ps (seedOneSupporterCode ps >>= writeIORef codeRef) $ \alice _links -> do
issuerPub <- readTestIssuerPublicKey (tmpPath ps)
withNewTestChatCfg ps (testCfg {badgePublicKeys = testBadgeKeys issuerPub}) badgeSecondClientDbPrefix bobProfile $ \bob -> do
connectUsers alice bob
-- before the redemption the profile bob holds carries no badge
alice #> "@bob hi"
bob <# "alice> hi"
code <- readIORef codeRef
alice ##> purchaseCodeCmd code
alice <##. "supporter badge 1 (shown): issued, 2 month(s) left, paid through "
alice <## "badge site: not configured"
-- the badge XInfo is delivered in order before this message, so bob has stored it
alice #> "@bob hi again"
bob <# "alice *> hi again"
-- | The client half of the funded-tier ruling: a LEGEND code redeemed through the same tier-less
-- command yields a LEGEND badge. The request names supporter ('badgeCodeRequest' hardcodes it,
-- plan §9) and the service signs the tier the code funds, so the purchase row, the issuance and
-- the profile must all take the tier the RESPONSE carries -- which fails the day 'createPurchase'
-- is fed the request's tier instead of the credential's.
testC5RedeemLegendCodeGivesLegendBadge :: HasCallStack => TestParams -> IO ()
testC5RedeemLegendCodeGivesLegendBadge ps = do
clock <- newTestClock testClockStart
codeRef <- newIORef ""
let seedCode = seedTestCodes ps [(BTLegend, 3, testCodeExpiry)] >>= writeIORef codeRef . head
withBadgeClient ps clock seedCode $ \alice -> do
code <- readIORef codeRef
alice ##> purchaseCodeCmd code
expectBadgeState alice "legend badge 1 (shown): issued, 2 month(s) left, paid through 2026-06-10"
alice ##> "/p"
alice <## "user profile: alice (Alice, * legend)"
alice <## "use /p <name> [<bio>] to change it"
cred <-
clientIssuedCredentials alice >>= \case
[c] -> pure c
other -> expectationFailure ("expected exactly one issuance, got " <> show (length other)) >> error "unreachable"
credentialBadgeType cred `shouldBe` BTLegend
withClientStore alice $ \db -> do
[purchaseTypes] <- DB.query_ db "SELECT initial_badge_type, current_badge_type FROM badge_purchases"
("purchase tier" :: String, purchaseTypes :: (Text, Text)) `shouldBe` ("purchase tier", ("legend", "legend"))
[Only issuanceType] <- DB.query_ db "SELECT badge_type FROM badge_issuances"
("issuance tier" :: String, issuanceType :: Text) `shouldBe` ("issuance tier", "legend")
-- The paths that must write nothing ---------------------------------------------
-- | The three service refusals a code can earn, each surfaced with its own code and each leaving
-- the client that asked with no row of any kind. The counts are the assertion: a client that wrote
-- a purchase row and then raised would produce the same three lines.
--
-- The @code_used@ case needs a SECOND client, not a second command from the first: C4 mints a
-- fresh purchase key per redemption, so the code is presented by a key the service has never seen
-- either way, and a second profile of the same app would prove nothing the second client does not.
testC5RefusedCodesWriteNothing :: HasCallStack => TestParams -> IO ()
testC5RefusedCodesWriteNothing ps = do
clock <- newTestClock testClockStart
codesRef <- newIORef []
let seedCodes = seedTestCodes ps [(BTSupporter, 3, expiredCodeExpiry), (BTSupporter, 3, testCodeExpiry)] >>= writeIORef codesRef
withBadgeClientCfg ps (readIORef clock) seedCodes (sharedClockCfg clock) $ \alice links -> do
[expiredCode, usedCode] <- readIORef codesRef
-- a code the service has never seen
unknownCode <- mintUnknownCode
alice ##> purchaseCodeCmd unknownCode
alice <## "badge service error: code_invalid"
noClientBadgeRows "after an unknown code" alice
-- one that alice then redeems for herself
alice ##> purchaseCodeCmd usedCode
expectBadgeState alice threeMonthBadgeState
pk <- readTestIssuerPublicKey (tmpPath ps)
withNewTestChatCfg ps (sharedClockCfg clock links pk) badgeSecondClientDbPrefix bobProfile $ \bob -> do
bob ##> purchaseCodeCmd usedCode
bob <## "badge service error: code_used"
noClientBadgeRows "after a code redeemed by someone else" bob
bob ##> purchaseCodeCmd expiredCode
bob <## "badge service error: code_expired"
noClientBadgeRows "after an expired code" bob
-- and alice's single redemption is untouched by either refusal
clientBadgeRowCounts alice `shouldReturn` (1, 1, 2, 1, 1)
-- | The credential is verified BEFORE the transaction opens, so a credential this client cannot
-- verify leaves nothing behind -- even though the service redeemed the code and will never hand it
-- back. The client is started with an issuer key that is not the service's, which is what a
-- rotated or spoofed issuer looks like from here.
testC5UnverifiableCredentialWritesNothing :: HasCallStack => TestParams -> IO ()
testC5UnverifiableCredentialWritesNothing ps = do
clock <- newTestClock testClockStart
codeRef <- newIORef ""
Right (otherPk, _otherSk) <- bbsKeyGen
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
cfg (sLink, _fullLink) _servicePk = (badgeClientCfg sLink otherPk) {badgeCurrentTime = readIORef clock}
withBadgeClientCfg ps (readIORef clock) seedCode cfg $ \alice _links -> do
code <- readIORef codeRef
alice ##> purchaseCodeCmd code
alice <## "badge service error: internal, badge credential does not verify against configured key"
noClientBadgeRows "after an unverifiable credential" alice
-- and the service DID redeem it: this is the operator-recoverable case C4's first concern names
code <- readIORef codeRef
codeIsRedeemed ps code `shouldReturn` True
withServiceDB ps $ \db -> serviceRowCounts db `shouldReturn` (1, 1, 2, 1, 1)
-- | With no badge service address configured, both commands that need one fail locally, contact
-- nothing and write nothing. This is the failure C3's worker already reports a pass with (see
-- 'Bots.BadgeManagerTests'), reached here through the two user-facing commands.
testC5NoServiceAddressConfigured :: HasCallStack => TestParams -> IO ()
testC5NoServiceAddressConfigured ps =
withNewTestChatCfg ps testCfg badgeClientDbPrefix aliceProfile $ \alice -> do
alice ##> "/_badge catalog 1"
alice <## "badge service error: internal, badge service address is not configured"
alice ##> purchaseCodeCmd "C5-NO-ADDRESS"
alice <## "badge service error: internal, badge service address is not configured"
reportsNoBadgeEvent alice
noClientBadgeRows "with no service address" alice
-- | Both store payment methods are reachable through the API today and are permanently refused
-- (plan §6): they present the evidence of a @payments@ row an app purchase flow created, and
-- neither that row nor its writer exists.
testC5StorePaymentsUnsupported :: HasCallStack => TestParams -> IO ()
testC5StorePaymentsUnsupported ps =
withNewTestChatCfg ps testCfg badgeClientDbPrefix aliceProfile $ \alice -> do
alice ##> "/_badge purchase 1 {\"type\":\"apple\",\"paymentId\":\"p\",\"jws\":\"j\"}"
alice <## "badge service error: internal, badge store payments are not supported"
alice ##> "/_badge purchase 1 {\"type\":\"google\",\"paymentId\":\"p\",\"token\":\"t\"}"
alice <## "badge service error: internal, badge store payments are not supported"
reportsNoBadgeEvent alice
noClientBadgeRows "after store payments" alice
-- | With the service's address published but nothing listening on it, both commands report the
-- transport failure as a badge service error and write nothing.
--
-- The agent's 'serviceRequestTimeout' is shortened to two seconds for this example only: its
-- default is thirty, which is longer than any terminal assertion waits, and what is being pinned
-- is what happens AFTER the timeout, not how long the default is.
--
-- The message asserted here is the TIMEOUT one, and that is the whole point of asserting it in
-- full: nothing is listening, so the request expires as @A_SERVICE ASETimeout@, which is the one
-- outcome of 'sendBadgeRequest' that may have been delivered. It must not tell the user the
-- request was not delivered, and it must not invite a retry of the same code, which would arrive
-- as a different signer and be answered @code_used@ (plan §9).
testC5ServiceStoppedIsInternal :: HasCallStack => TestParams -> IO ()
testC5ServiceStoppedIsInternal ps =
-- the two startup phases publish the service's address and then it stays down: 'withService' is
-- never called
withBadgeServiceAddress ps getCurrentTime (writeTestBadgeServiceConfig ps) (pure ()) $ \(sLink, _fullLink) _withService -> do
pk <- readTestIssuerPublicKey (tmpPath ps)
let cfg = (badgeClientCfg sLink pk) {agentConfig = testAgentCfg {serviceRequestTimeout = 2}}
withNewTestChatCfg ps cfg badgeClientDbPrefix aliceProfile $ \alice -> do
-- the catalog fetch spends nothing, so its timeout must NOT warn about a code
alice ##> "/_badge catalog 1"
alice <## badgeRetryableTimeoutError
alice ##> purchaseCodeCmd "C5-SERVICE-DOWN"
alice <## badgePurchaseTimeoutError
noClientBadgeRows "with the service stopped" alice
-- | 'sendBadgeRequest''s two timeout sentences, spelled out rather than derived from the function
-- under test: neither carries a @Details:@ tail, so the whole line is compared. Only a timed-out
-- @purchaseBadge@ warns that the code may be gone; every other command is safe to repeat.
badgePurchaseTimeoutError :: String
badgePurchaseTimeoutError =
"badge service error: internal, The badge service did not answer in time."
<> " The request may still have been delivered, so the code may already have been used,"
<> " and entering it again will not help. Please contact support."
badgeRetryableTimeoutError :: String
badgeRetryableTimeoutError =
"badge service error: internal, The badge service did not answer in time."
<> " Nothing was spent, so you can try again."
-- Supersession and the slot -----------------------------------------------------
-- | A second code redeemed on the same profile takes the slot: the first purchase becomes
-- @superseded@ and the second is @issued@ and shown. The first purchase keeps everything it had --
-- its ledger rows, its issuance and, above all, its unconsumed months, which never move between
-- purchases because purchases are unlinkable and the service cannot transfer a balance.
testC5SecondCodeSupersedesFirst :: HasCallStack => TestParams -> IO ()
testC5SecondCodeSupersedesFirst ps = do
clock <- newTestClock testClockStart
codesRef <- newIORef []
let seedCodes = seedTestCodes ps [(BTSupporter, 3, testCodeExpiry), (BTLegend, 3, testCodeExpiry)] >>= writeIORef codesRef
withBadgeClient ps clock seedCodes $ \alice -> do
[firstCode, secondCode] <- readIORef codesRef
alice ##> purchaseCodeCmd firstCode
expectBadgeState alice threeMonthBadgeState
firstLedger <- clientLedgerRows alice
alice ##> purchaseCodeCmd secondCode
expectBadgeState alice "legend badge 2 (shown): issued, 2 month(s) left, paid through 2026-06-10"
-- two of everything, and one shown badge
clientBadgeRowCounts alice `shouldReturn` (2, 2, 4, 2, 1)
User {userId = aliceUserId} <- clientUser alice
withClientStore alice $ \db -> do
statuses <- DB.query_ db "SELECT badge_purchase_id, current_badge_type, status FROM badge_purchases ORDER BY badge_purchase_id"
("purchase statuses" :: String, statuses :: [(Int64, Text, Text)])
`shouldBe` ("purchase statuses", [(1, "supporter", "superseded"), (2, "legend", "issued")])
[Only shownBadgeId] <- DB.query db "SELECT shown_badge_id FROM users WHERE user_id = ?" (Only aliceUserId)
("shown badge" :: String, shownBadgeId :: Maybe Int64) `shouldBe` ("shown badge", Just 2)
-- the superseded purchase still has its issuance
[Only firstIssuances] <- DB.query_ db "SELECT count(*) FROM badge_issuances WHERE badge_purchase_id = 1"
("superseded purchase issuances" :: String, firstIssuances :: Int) `shouldBe` ("superseded purchase issuances", 1)
-- and the months it did not spend are still on it
[Only firstBalance] <- DB.query_ db "SELECT balance_months FROM badge_ledger WHERE badge_purchase_id = 1 ORDER BY entry_id DESC LIMIT 1"
("superseded purchase months left" :: String, firstBalance :: Int) `shouldBe` ("superseded purchase months left", 2)
-- its ledger rows are the rows it had before the second redemption, unchanged and still first
afterLedger <- clientLedgerRows alice
("superseded purchase ledger" :: String, take (length firstLedger) afterLedger)
`shouldBe` ("superseded purchase ledger", firstLedger)
-- The catalog -------------------------------------------------------------------
-- | The catalog from a profile with no prior connection: the four seeded offers with the service's
-- own totals and both prices, written nowhere.
--
-- It is UNSIGNED, and this is what proves it: the service answers a SIGNED @getBadgeCatalog@ from
-- a key it holds no purchase for with @unknown_purchase_key@
-- ('testBadgeServiceGetCatalogUnknownSignerKey'), and this profile has never bought anything, so a
-- signed request could not have returned a catalog at all.
testC5CatalogFromFreshProfile :: HasCallStack => TestParams -> IO ()
testC5CatalogFromFreshProfile ps = do
clock <- newTestClock testClockStart
withBadgeClient ps clock (pure ()) $ \alice -> do
alice ##> "/_badge catalog 1"
-- neither catalog query orders its rows, so the six lines are compared as a set.
--
-- This comparison is also what would catch 'catalogRequest' starting to sign: a signed
-- getBadgeCatalog from this never-purchased key is answered with unknown_purchase_key, so
-- there would be no six lines to compare -- the example would fail here, on a
-- "badge service error: unknown_purchase_key" line, rather than passing quietly.
catalogLines <- replicateM 6 (getTermLine alice)
sort catalogLines
`shouldBe` sort
[ "price 2170da16-66e5-481f-9c75-6949e2dd14e1: supporter 700 usd per month, active",
"price 6a778279-753e-45db-8acc-0e382c1d054a: legend 7000 usd per month, active",
"offer 29e35444-2f85-43fb-8933-16875e6d3776: 3 month(s), {\"type\":\"freeMonths\",\"freeMonths\":1}, total 1400, active",
"offer 71bd8ad1-15c4-4735-b3d4-b12a670dfb7e: 12 month(s), {\"type\":\"freeMonths\",\"freeMonths\":6}, total 4200, active",
"offer 88885a0a-c407-4aaa-bf53-90f9de7bdfc0: 3 month(s), {\"type\":\"freeMonths\",\"freeMonths\":1}, total 14000, active",
"offer 35ca9daa-4dca-4cc1-af74-631674906cc9: 12 month(s), {\"type\":\"freeMonths\",\"freeMonths\":6}, total 42000, active"
]
reportsNoBadgeEvent alice
noClientBadgeRows "after the catalog" alice
-- The worker (C3) against the live service ---------------------------------------
-- | A month on, @\/_badge state@ signals the worker and ONE pass issues the second period against
-- the live service: a second issuance, one more @debit(badge)@ in the ledger, and a
-- 'CEvtBadgeChanged' reporting the new state. No test waits on 'badgePassInterval' -- the shared
-- clock is moved and the command is the trigger.
--
-- It also pins the master key across the two credentials. A pass that regenerated it would be
-- answered with a credential bound to the OLD key on the service's cached-issuance path, which is
-- unusable and raises no error anywhere ('badgeIssueRequest', plan §9); both credentials verify and
-- both carry the key @badge_purchases.master_key@ holds.
testC5WorkerIssuesSecondPeriod :: HasCallStack => TestParams -> IO ()
testC5WorkerIssuesSecondPeriod ps = do
clock <- newTestClock testClockStart
codeRef <- newIORef ""
ledgerRef <- newIORef []
withBadgeClient ps clock (seedOneSupporterCode ps >>= writeIORef codeRef) $ \alice -> do
code <- readIORef codeRef
alice ##> purchaseCodeCmd code
expectBadgeState alice threeMonthBadgeState
-- both clocks move together: the month the first period covered has ended
advanceTestClockMonths clock 1
alice ##> "/_badge state 1"
-- the state as it is BEFORE the pass the same command signalled
expectBadgeState alice threeMonthBadgeState
-- the pass, issuing [2026-04-10, 2026-05-10): one month spent, still paid through 2026-06-10
alice <## "supporter badge 1 (shown): issued, 1 month(s) left, paid through 2026-06-10"
reportsNoBadgeEvent alice
-- one more issuance and one more ledger entry, and nothing else
clientBadgeRowCounts alice `shouldReturn` (1, 1, 3, 2, 1)
withClientStore alice $ \db -> do
entryTypes <- DB.query_ db "SELECT entry_type, entry_credit_type, entry_debit_type FROM badge_ledger ORDER BY entry_id"
("ledger" :: String, entryTypes :: [(Text, Maybe Text, Maybe Text)])
`shouldBe` ( "ledger",
[ ("credit", Just "payment", Nothing),
("debit", Nothing, Just "badge"),
("debit", Nothing, Just "badge")
]
)
periods <- DB.query_ db "SELECT period_start, period_end FROM badge_issuances ORDER BY period_start"
case periods :: [(UTCTime, UTCTime)] of
[(firstStart, firstEnd), (secondStart, secondEnd)] -> do
firstStart `shouldBeStoredAt` testClockStart
firstEnd `shouldBeStoredAt` firstPeriodEnd
secondStart `shouldBeStoredAt` firstPeriodEnd
secondEnd `shouldBeStoredAt` UTCTime (fromGregorian 2026 5 10) noonUTC
other -> expectationFailure $ "expected two issuances, got: " <> show other
-- the master key is the purchase's on both credentials, and both verify
issuerPub <- readTestIssuerPublicKey (tmpPath ps)
purchaseMasterKey <- clientPurchaseMasterKey alice
creds <- clientIssuedCredentials alice
("issued credentials" :: String, length creds) `shouldBe` ("issued credentials", 2)
forM_ (zip [1 :: Int ..] creds) $ \(n, cred) -> do
verifyCredential issuerPub cred `shouldReturn` True
("credential " <> show n <> " master key", credentialMasterKey cred)
`shouldBe` ("credential " <> show n <> " master key", purchaseMasterKey)
-- and the second credential is not the first one served again
case creds of
[firstCred, secondCred] ->
("second period credential" :: String, secondCred == firstCred) `shouldBe` ("second period credential", False)
_ -> pure ()
-- held for the parity check below: the service's database cannot be opened while the service
-- itself is running
clientLedgerRows alice >>= writeIORef ledgerRef
-- §10's parity check over the WORKER's append path, not just the redemption's:
-- 'testC5RedeemCodeShowsBadge' compares a two-row ledger written in one transaction, while the
-- row this pass adds is appended against a cursor the client asserted, which is the only place
-- the two codecs can drift on an entry neither database wrote at the same time.
clientRows <- readIORef ledgerRef
withServiceDB ps $ \db -> do
serviceRowCounts db `shouldReturn` (1, 1, 3, 2, 1)
serviceLedgerParityRows db >>= shouldMatchServiceLedger clientRows
-- The active profile --------------------------------------------------------------
-- | A redemption that FINISHES after the active profile has been switched must not point the app
-- back at the profile it was redeemed for.
--
-- 'withUserId' checks activeness at ENTRY and the round trip that follows is seconds long, so the
-- guard in 'redeemBadgeCode' is load-bearing and needs the racing case, not the static one. The
-- client's 'badgeCurrentTime' is read after the request has been answered and before the first row
-- is written, which is exactly the window that guard covers, so gating that clock (C3's
-- 'BadgeGate') parks the redemption there while the profile is switched.
--
-- The redemption runs off the terminal's input loop, which is single-threaded: a @\/create user@
-- typed at it would queue BEHIND the parked redemption and could never race it.
testC5RedeemAfterProfileSwitchKeepsActiveUser :: HasCallStack => TestParams -> IO ()
testC5RedeemAfterProfileSwitchKeepsActiveUser ps = do
clock <- newTestClock testClockStart
gate <- newBadgeGate
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
cfg (sLink, _fullLink) pk = (badgeClientCfg sLink pk) {badgeCurrentTime = gatedClockAt testClockStart gate}
withBadgeClientCfg ps (readIORef clock) seedCode cfg $ \alice _links -> do
-- the chat-start pass reads the gated clock first; let it through so it cannot take the permit
-- the redemption needs. It finds no purchase and writes nothing.
allowPass gate
waitPasses gate 1
User {userId = aliceUserId} <- clientUser alice
code <- readIORef codeRef
redeeming <-
async $ runReaderT (execChatCommand' (APIPurchaseBadge aliceUserId (BPPCode code)) 0) (chatController alice)
-- the request has been sent and answered, and the redemption is parked on its clock read
waitPasses gate 2
alice ##> "/create user secret"
alice <## "user profile: secret"
alice <## "use /p <name> [<bio>] to change it"
User {userId = secretUserId} <- clientUser alice
("switched profile" :: String, secretUserId == aliceUserId) `shouldBe` ("switched profile", False)
-- released, the redemption writes its rows and answers
allowPass gate
wait redeeming >>= \case
Right _ -> pure ()
Left e -> expectationFailure $ "the redemption failed: " <> show e
-- the badge landed on the redeeming profile ...
withClientStore alice $ \db -> do
[Only purchaseOwner] <- DB.query_ db "SELECT user_id FROM badge_purchases"
("purchase owner" :: String, purchaseOwner :: Int64) `shouldBe` ("purchase owner", aliceUserId)
[Only shownBadgeId] <- DB.query db "SELECT shown_badge_id FROM users WHERE user_id = ?" (Only aliceUserId)
("redeeming profile shown badge" :: String, shownBadgeId :: Maybe Int64)
`shouldBe` ("redeeming profile shown badge", Just 1)
-- ... and the active profile did not move back to it
User {userId = activeAfter} <- clientUser alice
("active profile" :: String, activeAfter) `shouldBe` ("active profile", secretUserId)
-- Idempotency and recovery ---------------------------------------------------------
-- | The same code presented again from the same profile mints a FRESH purchase key, so the RPC's
-- "repeat the identical request" idempotency is unreachable from the client by design: the service
-- sees a second key and answers @code_used@. The client writes nothing and the first badge stays
-- shown.
--
-- After the operator clears the redemption, the same code redeems again into a NEW purchase and
-- supersedes the old one -- the recovery path C4's "a response lost in flight consumes the code"
-- depends on. H2's @codes unredeem@ CLI does not exist yet (@BadgeService.Admin@ has issue, revoke
-- and status only), so this drives B1's 'unredeemCode' directly, which is the function that CLI
-- will call.
testC5SameCodeAgainThenUnredeemed :: HasCallStack => TestParams -> IO ()
testC5SameCodeAgainThenUnredeemed ps = do
clock <- newTestClock testClockStart
codeRef <- newIORef ""
withBadgeServiceAddress ps (readIORef clock) (writeTestBadgeServiceConfig ps) (seedOneSupporterCode ps >>= writeIORef codeRef) $
\links withService -> do
pk <- readTestIssuerPublicKey (tmpPath ps)
-- the client is started OUTSIDE the service phases, so it outlives the restart the unredeem
-- needs: the service's database cannot be opened while the service holds it
withNewTestChatCfg ps (sharedClockCfg clock links pk) badgeClientDbPrefix aliceProfile $ \alice -> do
code <- readIORef codeRef
withService $ do
alice ##> purchaseCodeCmd code
expectBadgeState alice threeMonthBadgeState
-- the same code, the same profile, a new key
alice ##> purchaseCodeCmd code
alice <## "badge service error: code_used"
clientBadgeRowCounts alice `shouldReturn` (1, 1, 2, 1, 1)
alice ##> "/_badge state 1"
expectBadgeState alice threeMonthBadgeState
-- the operator clears the redemption while the service is stopped
withServiceStore ps $ \db -> unredeemCode db (Codes.codeHash (Codes.normalizeCode code)) testClockStart
withService $ do
alice ##> purchaseCodeCmd code
expectBadgeState alice "supporter badge 2 (shown): issued, 2 month(s) left, paid through 2026-06-10"
clientBadgeRowCounts alice `shouldReturn` (2, 2, 4, 2, 1)
withClientStore alice $ \db -> do
statuses <- DB.query_ db "SELECT badge_purchase_id, status FROM badge_purchases ORDER BY badge_purchase_id"
("purchase statuses" :: String, statuses :: [(Int64, Text)])
`shouldBe` ("purchase statuses", [(1, "superseded"), (2, "issued")])
-- Address resolution -----------------------------------------------------------------
-- | Whether a parsed badge service address is the FULL contact request URI form rather than the
-- short link every other example configures. Top level, and not a @case@ in the example itself,
-- because 'ConnectTarget' is a GADT and matching one in a local binding is what
-- @-Wgadt-mono-local-binds@ warns about.
isFullContactTarget :: ConnectTarget 'CMContact -> Bool
isFullContactTarget target = case target of
CTFullContact _ -> True
_ -> False
-- | The service's address given as a full contact request URI redeems exactly as the short link
-- does. 'resolveServiceTarget' accepts four target forms and, without this, only the short link
-- would ever be exercised. The 'CTName' and 'CTDomain' forms need a resolvable SimpleX name, which
-- this harness cannot publish; they are recorded as untested in the plan's §9.
testC5FullContactUriRedeems :: HasCallStack => TestParams -> IO ()
testC5FullContactUriRedeems ps = do
clock <- newTestClock testClockStart
codeRef <- newIORef ""
let seedCode = seedOneSupporterCode ps >>= writeIORef codeRef
cfg (_sLink, fullLink) pk = (badgeClientCfg fullLink pk) {badgeCurrentTime = readIORef clock}
withBadgeClientCfg ps (readIORef clock) seedCode cfg $ \alice (_sLink, fullLink) -> do
-- the configured target really is the FULL form: a full URI that had silently decoded as a
-- short link would make this the short-link example over again
("full contact URI target" :: String, isFullContactTarget (badgeServiceTarget fullLink))
`shouldBe` ("full contact URI target", True)
code <- readIORef codeRef
alice ##> purchaseCodeCmd code
expectBadgeState alice threeMonthBadgeState
clientBadgeRowCounts alice `shouldReturn` (1, 1, 2, 1, 1)
-- The stub responder --------------------------------------------------------------------
-- | A stub badge service: a chat client accepting service requests on a contact address of its
-- own, which the test answers with a canned body.
--
-- Six of C5's cases need a response the real service cannot produce -- an absent credential, a
-- statement carrying no issued period, a credential with no expiry, a statement carrying an entry
-- type this build cannot store, a response of the wrong shape for the command, and a body that is
-- not a 'BadgeServiceResponse' at all. Each is a branch of C4's 'redeemBadgeCode' whose whole
-- purpose is to write nothing, and without a responder that can be made to lie all six would be
-- untested.
--
-- The credential the stub signs is real -- 'bbsKeyGen' here and the public half in the client's
-- 'badgePublicKeys' -- so 'verifyUserBadge' passes and the branch under test is the one that
-- fails.
withBadgeStub :: HasCallStack => TestParams -> (TestCC -> TestCC -> BBSSecretKey -> IO ()) -> IO ()
withBadgeStub ps test = do
Right (pk, sk) <- bbsKeyGen
withNewTestChatCfgOpts_ ps testCfg testOpts badgeStubDbPrefix True badgeStubProfile $ \stub -> do
-- SREQ delivery gates on this flag, exactly as 'badgePostStartHook' sets it
atomically $ writeTVar (processServiceRequests (chatController stub)) True
-- a DR address: service RPC requires one, which is why the real service creates it with
-- pqRatchet 'Just True'
stub ##> "/ad pq_ratchet=on"
(sLink, _fullLink) <- getContactLinks stub True
withNewTestChatCfg ps (badgeClientCfg sLink pk) badgeClientDbPrefix aliceProfile $ \alice ->
test alice stub sk
badgeStubProfile :: Profile
badgeStubProfile = badgeProfile {displayName = "SimpleX Badge Stub"}
-- | Answers the one service request the stub is holding with 'body'. What the request carried is
-- B5's business, not C5's, so it is read and dropped.
answerStubRequest :: HasCallStack => TestCC -> J.Value -> IO ()
answerStubRequest stub body = do
reqLine <- getTermLine' (Just "service request") stub
reqId <- case stripPrefix "service request " reqLine of
Just i -> pure i
Nothing -> expectationFailure ("expected a service request line, got: " <> reqLine) >> error "unreachable"
-- 'signed by' precedes the request for a signed purchaseBadge and is absent for the unsigned
-- getBadgeCatalog
next <- getTermLine' (Just "signed by, or the request") stub
unless ("request: " `isPrefixOf` next) $ stub <##. "request: "
stub ##> ("/_service_response 1 " <> reqId <> " " <> LBC.unpack (J.encode body))
-- the command's own answer and the agent's SSENT event for the reply, in either order
forM_ [1 :: Int, 2] $ \_ -> do
l <- getTermLine' (Just "service reply") stub
unless ("service reply " `isPrefixOf` l) $
expectationFailure ("expected a service reply line, got: " <> l)
-- 'StatementEntry' is constructed positionally, as everywhere else in this module (see the import
-- list's Haddock): entryId, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType,
-- wasPausedSince, createdAt, entryType.
stubEntry :: Text -> Int -> Int -> UTCTime -> StatementEntryType -> StatementEntry
stubEntry entryId changeMonths balanceMonths balanceStartTs =
StatementEntry entryId changeMonths balanceMonths balanceStartTs BTSupporter Nothing testClockStart
-- | The statement a three-month code redemption produces: the credit, then the @debit(badge)@ that
-- carries the issued period.
stubRedeemStatement :: BadgeStatement
stubRedeemStatement =
BadgeStatement
{ entries =
[ stubEntry "c5-stub-credit" 3 3 testClockStart (SECredit (SCPayment Nothing)),
stubEntry "c5-stub-debit" (-1) 2 firstPeriodEnd (SEDebit SDBadge)
],
previousEntryId = Nothing
}
stubCredential :: HasCallStack => BBSSecretKey -> Maybe UTCTime -> IO BadgeCredential
stubCredential sk badgeExpiry = do
drg <- C.newRandom
mk <- generateMasterKey drg
let req = BadgeRequest {masterKey = mk, badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry, badgeExtra = ""}}
issueBadge 1 sk (VerifiedBadgeRequest req)
>>= either (\e -> expectationFailure ("the stub could not sign a credential: " <> e) >> error "unreachable") pure
stubCredentialResponse :: Maybe BadgeCredential -> BadgeStatement -> J.Value
stubCredentialResponse credential statement =
J.toJSON BSPBadgeCredential {credential, receipt = Nothing, statement}
-- | Every response a redemption can be refused for once it has arrived, each answered with the
-- message C4 raises and each leaving the client with no row of any kind.
--
-- The order is the order 'redeemBadgeCode' checks them in, and the last is the reason
-- 'checkStatementEntries' exists at all: an unstorable entry type reaching 'insertLedgerEntries'
-- would COMMIT the payment and purchase rows written above it, because a 'Left' inside @withStore@
-- does not roll the transaction back.
testC5UnrecordableResponsesWriteNothing :: HasCallStack => TestParams -> IO ()
testC5UnrecordableResponsesWriteNothing ps =
withBadgeStub ps $ \alice stub sk -> do
-- the whole line, not a prefix: every message below is complete, and a prefix assertion on
-- the unstorable cases would pass for a build that refused the WRONG entry type
let redeemAnsweredWith label body message = do
alice ##> purchaseCodeCmd "C5-STUB-CODE"
answerStubRequest stub body
alice <## message
noClientBadgeRows label alice
-- no credential at all: the exhausted balance of an issueBadge, which a redemption cannot be
redeemAnsweredWith
"after a redemption with no credential"
(stubCredentialResponse Nothing stubRedeemStatement)
"badge service error: internal, badge service redeemed the code without issuing a credential"
-- a statement that issued no period, so nothing says which month was bought
cred <- stubCredential sk (Just firstCredentialExpiry)
let creditOnly =
BadgeStatement
{ entries = [stubEntry "c5-stub-credit-only" 3 3 testClockStart (SECredit (SCPayment Nothing))],
previousEntryId = Nothing
}
redeemAnsweredWith
"after a statement with no issued period"
(stubCredentialResponse (Just cred) creditOnly)
"badge service error: internal, badge service issued no period for the redeemed code"
-- a credential with no expiry, which 'issueSignedBadge' never signs
noExpiryCred <- stubCredential sk Nothing
redeemAnsweredWith
"after a credential with no expiry"
(stubCredentialResponse (Just noExpiryCred) stubRedeemStatement)
"badge service error: internal, issued badge credential carries no expiry"
-- EVERY entry type this build cannot store, each refused BEFORE the transaction opens and each
-- named in the message it is refused with. All four go through the same
-- 'storedEntryType'/'encodeLedgerEntryType' pair, so one of them would have exercised the
-- code path -- but only naming each in its own assertion catches a build that rejects the
-- wrong one, and only the debit(badge) after it keeps 'issuedBadgePeriod' satisfied so the
-- storability check is what the redemption fails on.
(otherKey, _otherPriv) <- mkTestKeyPair
forM_ (unstorableEntryCases otherKey) $ \(label, entryType, reason) -> do
let unstorable =
BadgeStatement
{ entries =
[ stubEntry "c5-stub-unstorable" 3 3 testClockStart entryType,
stubEntry "c5-stub-debit" (-1) 2 firstPeriodEnd (SEDebit SDBadge)
],
previousEntryId = Nothing
}
redeemAnsweredWith
label
(stubCredentialResponse (Just cred) unstorable)
("badge service error: internal, badge service statement cannot be stored: SEInternalError {message = \"" <> reason <> "\"}")
-- | The four statement entry types this build cannot store, with the label of the case and the
-- reason 'Simplex.Chat.Store.Badges.storedEntryType' refuses each with.
--
-- The reasons are spelled out rather than derived from that function: a test that asked production
-- for the message it expects production to produce would pass whichever message production
-- produced. They carry a purchase KEY where the stored columns hold a purchase id, or a TEXT
-- charge id against an 'Int64' one, which is why none of them can be replicated today.
unstorableEntryCases :: C.PublicKeyEd25519 -> [(String, StatementEntryType, String)]
unstorableEntryCases otherKey =
[ ( "after a statement carrying credit(charge)",
SECredit (SCCharge "c5-charge"),
"cannot store badge ledger credit(charge): the stored LedgerCreditType.CTCharge types chargeId as Int64 while the wire and the charge_id column are both TEXT; subscriptions are out of scope"
),
( "after a statement carrying credit(transferIn)",
SECredit (SCTransferIn otherKey),
"cannot store badge ledger credit(transferIn): carries a purchase key where from_purchase_id holds a purchase id; transfers are out of scope"
),
( "after a statement carrying debit(upgrade)",
SEDebit (SDUpgrade otherKey),
"cannot store badge ledger debit(upgrade): carries a purchase key where to_purchase_id holds a purchase id; upgrades are out of scope"
),
( "after a statement carrying debit(transferOut)",
SEDebit (SDTransferOut otherKey),
"cannot store badge ledger debit(transferOut): carries a purchase key where to_purchase_id holds a purchase id; transfers are out of scope"
)
]
-- | A response of the wrong shape for the command it answers, and one that is not a
-- 'BadgeServiceResponse' at all, on both commands that send one.
--
-- Neither is a silent no-op: a service speaking a protocol this build does not know is reported,
-- with the response in the message for the first and the app-version sentence for the second.
testC5UnreadableResponsesAreReported :: HasCallStack => TestParams -> IO ()
testC5UnreadableResponsesAreReported ps =
withBadgeStub ps $ \alice stub sk -> do
cred <- stubCredential sk (Just firstCredentialExpiry)
let emptyCatalog = J.toJSON BSPBadgeCatalog {catalog = BadgeCatalog {prices = [], offers = []}, badgeStatement = Nothing}
credentialBody = stubCredentialResponse (Just cred) stubRedeemStatement
undecodable = J.object ["type" J..= ("badgeSomethingElse" :: Text)]
-- a catalog answering a purchase
alice ##> purchaseCodeCmd "C5-STUB-CODE"
answerStubRequest stub emptyCatalog
alice <##. "badge service error: internal, unexpected badge service response to purchaseBadge: "
noClientBadgeRows "after a catalog answering a purchase" alice
-- a credential answering a catalog
alice ##> "/_badge catalog 1"
answerStubRequest stub credentialBody
alice <##. "badge service error: internal, unexpected badge service response to getBadgeCatalog: "
-- a body that does not decode as a response at all, on both commands
alice ##> purchaseCodeCmd "C5-STUB-CODE"
answerStubRequest stub undecodable
alice <##. "badge service error: internal, The badge service answered with something this app version cannot read."
alice ##> "/_badge catalog 1"
answerStubRequest stub undecodable
alice <##. "badge service error: internal, The badge service answered with something this app version cannot read."
noClientBadgeRows "after unreadable responses" alice
-- D4 web listener -------------------------------------------------------------
-- One HTTP response, reduced to what the assertions below need. The body is a String because
-- everything asserted about it is a substring or an exact small file, and 'isInfixOf' is already
-- how this module reads text.
data WebResponse = WebResponse
{ wrStatus :: Int,
wrHeaders :: [(CI.CI BC.ByteString, BC.ByteString)],
wrBody :: String
}
-- One manager per test rather than one per request: a request-per-manager leaves a connection
-- pool behind for each call, and these tests make dozens.
newWebManager :: IO HTTP.Manager
newWebManager = HTTP.newManager HTTP.defaultManagerSettings
-- 'HTTP.parseRequest' (unlike 'parseUrlThrow') installs no status check, so a 404 comes back as
-- a response to assert on rather than as an exception. The request is sent as written -- the path
-- is not re-encoded, which is what lets the traversal cases below reach the server
-- percent-encoded, and the headers are not policed, which is what lets the malformed-request case
-- reach it at all.
webRequestWith :: (HTTP.Request -> HTTP.Request) -> HTTP.Manager -> String -> IO WebResponse
webRequestWith f mgr url = do
req <- HTTP.parseRequest url
r <- HTTP.httpLbs (f req) mgr
pure
WebResponse
{ wrStatus = statusCode (HTTP.responseStatus r),
wrHeaders = HTTP.responseHeaders r,
wrBody = LBC.unpack (HTTP.responseBody r)
}
webGet :: HTTP.Manager -> String -> IO WebResponse
webGet = webRequestWith id
-- With a method of the caller's choosing, for the per-route method assertions.
webRequest :: BC.ByteString -> HTTP.Manager -> String -> IO WebResponse
webRequest method = webRequestWith $ \req -> req {HTTP.method = method}
webHeader :: WebResponse -> BC.ByteString -> String
webHeader r name = maybe "" BC.unpack $ lookup (CI.mk name) (wrHeaders r)
-- The four headers D4 requires on EVERY response, checked as a client sees them. Called on a
-- 200, on a 404 and on the JSON endpoint, since "every response" is the actual requirement.
assertSecurityHeaders :: HasCallStack => String -> WebResponse -> IO ()
assertSecurityHeaders what r = do
(what, webHeader r "content-security-policy") `shouldBe` (what, "default-src 'self'")
(what, webHeader r "x-content-type-options") `shouldBe` (what, "nosniff")
(what, webHeader r "referrer-policy") `shouldBe` (what, "no-referrer")
(what, webHeader r "x-frame-options") `shouldBe` (what, "DENY")
-- Every asset URL the served page references, in document order: a resolved token ends at the
-- quote closing its attribute (or at the next tag, for one substituted into text), and an asset
-- URL contains neither character, so this reads what the browser would follow.
webAssetUrls :: String -> [String]
webAssetUrls body = map (T.unpack . ("/assets/" <>) . T.takeWhile (\c -> c /= '"' && c /= '<')) . drop 1 $ T.splitOn "/assets/" (T.pack body)
-- "/assets/<buildHash>/main.js" -> "<buildHash>"
webAssetHash :: HasCallStack => String -> String
webAssetHash url = case T.splitOn "/" (T.pack url) of
("" : "assets" : h : _ : _) -> T.unpack h
_ -> error $ "not an asset URL: " <> url
-- The relative specifiers in an emitted module, as `import ... from "./ui.js"` leaves them: tsc
-- does not rewrite them (decision 7), so the browser resolves them against the module's own URL.
relativeImports :: String -> [String]
relativeImports js = nub [t | (i, t) <- zip [0 :: Int ..] (map T.unpack (T.splitOn "\"" (T.pack js))), odd i, "./" `isPrefixOf` t]
-- GET / must return the page with every token resolved -- not one @@name@@ left, the non-file
-- token carrying the value from THIS ini, and every URL it names actually fetchable, which is the
-- outcome the tokens exist for. All of them sit under ONE prefix: see
-- testBadgeServiceWebModuleGraphUnderOnePrefix for why that is load-bearing.
testBadgeServiceWebIndexResolvesTokens :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebIndexResolvesTokens ps =
withBadgeServiceWeb ps $ \_client _bsLink webUrl -> do
mgr <- newWebManager
index <- webGet mgr webUrl
wrStatus index `shouldBe` 200
webHeader index "content-type" `shouldBe` "text/html; charset=utf-8"
-- the page itself is never cached: its asset URLs and its support link both change without
-- the page's own URL changing
webHeader index "cache-control" `shouldBe` "no-cache"
wrBody index `shouldSatisfy` (not . isInfixOf "@@")
wrBody index `shouldSatisfy` isInfixOf testSupportContact
let urls = webAssetUrls (wrBody index)
names = sort $ map (T.unpack . last . T.splitOn "/" . T.pack) urls
-- D2's index.html names four files; a later step that adds one adds a token and nothing else
names `shouldBe` ["logo-symbol-dark.svg", "logo-symbol-light.svg", "main.js", "styles.css"]
nub (map webAssetHash urls) `shouldSatisfy` ((== 1) . length)
forM_ urls $ \u -> do
r <- webGet mgr (webUrl <> u)
(u, wrStatus r) `shouldBe` (u, 200)
(u, webHeader r "cache-control") `shouldBe` (u, "public, max-age=31536000, immutable")
assertSecurityHeaders u r
-- and the two files that are not in dist/ are served with their own content types
css <- webGet mgr (webUrl <> head (filter ("/styles.css" `isSuffixOf`) urls))
webHeader css "content-type" `shouldBe` "text/css; charset=utf-8"
logo <- webGet mgr (webUrl <> head (filter ("-light.svg" `isSuffixOf`) urls))
webHeader logo "content-type" `shouldBe` "image/svg+xml"
-- The reason the build hash is one hash for the whole set and not one per file: tsc leaves
-- `from "./ui.js"` alone, so the browser resolves it against main.js's own directory. Every
-- sibling main.js imports must therefore answer under main.js's OWN prefix -- with a per-file
-- hash each would sit at a different one and the module graph would 404 after the entry point.
-- Asserted by following the specifiers out of the served bytes, not by reading the source.
testBadgeServiceWebModuleGraphUnderOnePrefix :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebModuleGraphUnderOnePrefix ps =
withBadgeServiceWeb ps $ \_client _bsLink webUrl -> do
mgr <- newWebManager
index <- webGet mgr webUrl
let mainUrl = head $ filter ("/main.js" `isSuffixOf`) (webAssetUrls (wrBody index))
prefix = "/assets/" <> webAssetHash mainUrl
mainJs <- webGet mgr (webUrl <> mainUrl)
wrStatus mainJs `shouldBe` 200
webHeader mainJs "content-type" `shouldBe` "text/javascript; charset=utf-8"
let specifiers = relativeImports (wrBody mainJs)
specifiers `shouldSatisfy` (not . null)
forM_ specifiers $ \specifier -> do
let url = prefix <> "/" <> drop 2 specifier
r <- webGet mgr (webUrl <> url)
(specifier, wrStatus r) `shouldBe` (specifier, 200)
-- the imported module's own imports must resolve at the same prefix, one level deeper
forM_ (relativeImports (wrBody r)) $ \nested -> do
nestedR <- webGet mgr (webUrl <> prefix <> "/" <> drop 2 nested)
(specifier, nested, wrStatus nestedR) `shouldBe` (specifier, nested, 200)
-- dev.html is in the binary (embedDir takes no predicate) and must never be routed, under the
-- asset prefix or at the root; and an asset named under any other prefix is 404, which is what
-- makes the immutable long-cache above safe. The same name under the RIGHT prefix is fetched in
-- the same test, so neither 404 can be passing for the wrong reason.
testBadgeServiceWebDevHtmlAndStalePrefixAre404 :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebDevHtmlAndStalePrefixAre404 ps =
withBadgeServiceWeb ps $ \_client _bsLink webUrl -> do
mgr <- newWebManager
index <- webGet mgr webUrl
let mainUrl = head $ filter ("/main.js" `isSuffixOf`) (webAssetUrls (wrBody index))
buildHash = webAssetHash mainUrl
staleHash = map (\c -> if c == 'a' then 'b' else 'a') buildHash
served <- webGet mgr (webUrl <> mainUrl)
wrStatus served `shouldBe` 200
stale <- webGet mgr (webUrl <> "/assets/" <> staleHash <> "/main.js")
wrStatus stale `shouldBe` 404
forM_ ["/dev.html", "/assets/" <> buildHash <> "/dev.html"] $ \path -> do
r <- webGet mgr (webUrl <> path)
(path, wrStatus r) `shouldBe` (path, 404)
-- the banner npm run build writes into dev.html: not merely a 404 status, but none of the
-- file's bytes in the response
(path, "Generated from index.html" `isInfixOf` wrBody r) `shouldBe` (path, False)
assertSecurityHeaders path r
-- "Every response" includes the ones no route produced: a 404 and a rejected method carry the
-- same four headers as the page. A framing or CSP header present only on the happy path is the
-- one a browser needs on the response an attacker can reach.
testBadgeServiceWebSecurityHeadersEverywhere :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebSecurityHeadersEverywhere ps =
withBadgeServiceWeb ps $ \_client _bsLink webUrl -> do
mgr <- newWebManager
forM_ ["", "/api/catalog", "/no-such-path", "/assets", "/assets/deadbeef/main.js"] $ \path -> do
r <- webGet mgr (webUrl <> path)
assertSecurityHeaders path r
-- and nothing tells the caller which server, or which version of it, is answering
(path, webHeader r "server") `shouldBe` (path, "")
-- The method is rejected PER ROUTE: a route that exists answers 405 and says what it takes,
-- a path that does not exist answers 404 whatever the method. A server-wide method guard
-- gets this second one wrong, and D6's POST /api/checkout would have to undo it.
post <- webRequest "POST" mgr (webUrl <> "/api/catalog")
wrStatus post `shouldBe` 405
webHeader post "allow" `shouldBe` "GET, HEAD"
assertSecurityHeaders "POST /api/catalog" post
postUnknown <- webRequest "POST" mgr (webUrl <> "/no-such-path")
wrStatus postUnknown `shouldBe` 404
assertSecurityHeaders "POST /no-such-path" postUnknown
-- HEAD is allowed on every route GET is
headIndex <- webRequest "HEAD" mgr webUrl
wrStatus headIndex `shouldBe` 200
-- A request Warp rejects before the application ever sees it: an oversized header block. Both
-- halves are asserted, and the headers first, because they can regress independently --
-- hardening this response by REPLACING Warp's own throws away the status it chose, and a
-- listener that answers 500 to a malformed request tells every client, and every provider
-- retrying a 5xx webhook (E3, F2), to send it again.
malformed <- webRequestWith (\req -> req {HTTP.requestHeaders = [("X-Overlong", BC.replicate 80000 'a')]}) mgr webUrl
assertSecurityHeaders "malformed request" malformed
wrStatus malformed `shouldBe` 400
-- /api/catalog answers from the DATABASE through catalogTotals, never from Catalog.hs's
-- defaults: the fixture disables one seeded price and deprecates the other, so the default
-- catalog (two prices, four offers) is the WRONG answer and an endpoint that served it would
-- fail here. The payload is decoded as the RPC 'BadgeCatalog' the app parses (A2), so the shape
-- is asserted by the decode and the totals by the values.
testBadgeServiceWebCatalogEndpoint :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebCatalogEndpoint ps = do
priceIdsRef <- newIORef Nothing
let seedStatuses =
withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
priceIds <- expectRight $ withServiceTransaction (chatStore (chatController bs)) $ \db -> do
BadgeCatalog {prices} <- getActiveCatalog db
case prices of
[BadgePrice {priceId = pid1}, BadgePrice {priceId = pid2}] -> do
setPriceStatus db pid1 BISDisabled
setPriceStatus db pid2 BISDeprecated
pure (pid1, pid2)
_ -> error "expected exactly the two default seeded prices"
writeIORef priceIdsRef (Just priceIds)
withBadgeServiceWebConfig ps (writeTestBadgeServiceConfig ps) seedStatuses $ \_client _bsLink webUrl -> do
Just (disabledId, deprecatedId) <- readIORef priceIdsRef
mgr <- newWebManager
r <- webGet mgr (webUrl <> "/api/catalog")
wrStatus r `shouldBe` 200
webHeader r "content-type" `shouldBe` "application/json"
case J.decode (LBC.pack (wrBody r)) :: Maybe BadgeCatalog of
Nothing -> expectationFailure $ "/api/catalog did not decode as BadgeCatalog: " <> wrBody r
Just BadgeCatalog {prices, offers} -> do
map (\BadgePrice {priceId} -> priceId) prices `shouldBe` [deprecatedId]
map (\BadgePrice {status} -> status) prices `shouldBe` [BISDeprecated]
any (\BadgeOffer {priceId} -> priceId == Just disabledId) offers `shouldBe` False
sort (map (\BadgeOffer {months} -> months) offers) `shouldBe` [3, 12]
-- the legend price is 7000/month: 3 months with one free is 14000 and 12 with six free
-- is 42000. Without catalogTotals every total here would be null.
sort (map (\BadgeOffer {total} -> fmap (\(CurrencyAmount n) -> n) total) offers) `shouldBe` [Just 14000, Just 42000]
-- A minimal site on disk for web_dir mode, in the same shape the embedded set has: dist/ beside
-- index.html and styles.css. Small enough that every byte the assertions compare is written here.
writeTestWebDir :: FilePath -> IO ()
writeTestWebDir dir = do
createDirectoryIfMissing True (dir </> "dist")
writeFile (dir </> "index.html") $
"<!doctype html><html><head><link rel=\"stylesheet\" href=\"@@styles.css@@\" /></head>"
<> "<body><a href=\"@@support_contact@@\">support</a>"
-- one literal @ in front of a token: build.mjs's regex fails at the first index, retries at
-- the NEXT one and resolves the token, so the service must too (§9). A scanner that skipped
-- both @s would leave this in the page as text, with no startup error to say so.
<> "<p>@@@styles.css@@</p>"
<> "<script type=\"module\" src=\"@@main.js@@\"></script></body></html>\n"
writeFile (dir </> "styles.css") webDirCssBefore
writeFile (dir </> "dist" </> "main.js") "export const site = \"web_dir\";\n"
writeFile (dir </> "dist" </> "dev.html") "<!-- dev.html must never be served -->\n"
webDirCssBefore :: String
webDirCssBefore = "body { color: rgb(1, 2, 3); }\n"
webDirCssAfter :: String
webDirCssAfter = "body { color: rgb(4, 5, 6); }\n"
-- What a traversal must not reach: a real, readable file one level above the served directory.
webDirSecret :: String
webDirSecret = "OUTSIDE-WEB-DIR-SECRET\n"
-- [web] web_dir (decision 2) serves the same URLs from disk, so an edit is visible on reload:
-- the assertions are the outcome of an edit (new bytes, new prefix, old prefix gone) rather than
-- the mechanism that produces it. Every response is no-store, because with the immutable
-- long-cache of the embedded mode a browser would not re-fetch the edited file at all.
--
-- The traversal cases are the one security-relevant route in this step. The percent-encoded one
-- is the case that matters: WAI decodes it into a SINGLE path segment "../../<file>", which a
-- server that joined the request path onto the directory would happily read.
testBadgeServiceWebDirServesFromDisk :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebDirServesFromDisk ps@TestParams {tmpPath} = do
let dir = tmpPath </> "web_dir_site"
secretFile = tmpPath </> "outside-web-dir.txt"
writeConfig port = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
writeFile (badgeServiceConfigPath tmpPath) $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ("" : webIniLines port)
++ ["web_dir = " <> dir]
writeTestWebDir dir
writeFile secretFile webDirSecret
withBadgeServiceWebConfig ps writeConfig (pure ()) $ \_client _bsLink webUrl -> do
mgr <- newWebManager
index <- webGet mgr webUrl
wrStatus index `shouldBe` 200
wrBody index `shouldSatisfy` (not . isInfixOf "@@")
wrBody index `shouldSatisfy` isInfixOf testSupportContact
let cssUrl = head $ filter ("/styles.css" `isSuffixOf`) (webAssetUrls (wrBody index))
-- the "@@@styles.css@@" above resolved, leaving exactly the one literal @ in front of it
wrBody index `shouldSatisfy` isInfixOf ("<p>@" <> cssUrl <> "</p>")
css <- webGet mgr (webUrl <> cssUrl)
wrStatus css `shouldBe` 200
wrBody css `shouldBe` webDirCssBefore
webHeader css "cache-control" `shouldBe` "no-store"
-- the edit: no restart, no rebuild
writeFile (dir </> "styles.css") webDirCssAfter
index' <- webGet mgr webUrl
let cssUrl' = head $ filter ("/styles.css" `isSuffixOf`) (webAssetUrls (wrBody index'))
cssUrl' `shouldSatisfy` (/= cssUrl)
css' <- webGet mgr (webUrl <> cssUrl')
wrBody css' `shouldBe` webDirCssAfter
-- the pre-edit URL is gone with the old build hash, which is the cache-busting property
stale <- webGet mgr (webUrl <> cssUrl)
wrStatus stale `shouldBe` 404
let buildHash = webAssetHash cssUrl'
dev <- webGet mgr (webUrl <> "/assets/" <> buildHash <> "/dev.html")
wrStatus dev `shouldBe` 404
-- nothing outside the directory, however the path is spelled
forM_ ["..%2F..%2Foutside-web-dir.txt", "../../outside-web-dir.txt", "%2E%2E%2F%2E%2E%2Foutside-web-dir.txt", ".%2E/..%2Foutside-web-dir.txt"] $ \path -> do
r <- webGet mgr (webUrl <> "/assets/" <> buildHash <> "/" <> path)
(path, wrStatus r) `shouldBe` (path, 404)
(path, webDirSecret `isInfixOf` wrBody r) `shouldBe` (path, False)
-- the file the traversals aimed at exists and is readable: the 404s above are the server
-- refusing, not a missing target
readFile secretFile `shouldReturn` webDirSecret
-- Every 500 this listener can be made to send must carry the four security headers, and there are
-- two ways to reach one -- which is the point of the example, since only one of them is the
-- application's own code:
--
-- * a dangling symlink: the walk lists it, the read fails, and 'readServedAssets' turns that
-- into the Left the route answers with;
-- * a directory inside dist/ with no permissions: the walk itself throws, and nothing in the
-- application catches it, so the response is the one Warp's 'setOnExceptionResponse' returns.
-- Warp's DEFAULT for that case is its own bare "Something went wrong" page with none of these
-- headers on it -- and it is not a web_dir curiosity: 'withServiceTransaction' catches only
-- ServiceRollback, so a database exception in /api/catalog leaves the same way, as will D6's
-- request decoding and E3's and F2's webhooks.
--
-- The fixture is asserted to serve a 200 both before the damage and after it is undone, so
-- neither 500 can be a service that was never working.
testBadgeServiceWebInternalErrorsCarryHeaders :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebInternalErrorsCarryHeaders ps@TestParams {tmpPath} = do
let dir = tmpPath </> "web_dir_broken"
danglingLink = dir </> "dist" </> "dangling.js"
unreadableDir = dir </> "dist" </> "unreadable"
writeConfig port = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
writeFile (badgeServiceConfigPath tmpPath) $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ("" : webIniLines port)
++ ["web_dir = " <> dir]
writeTestWebDir dir
withBadgeServiceWebConfig ps writeConfig (pure ()) $ \_client _bsLink webUrl -> do
mgr <- newWebManager
healthy <- webGet mgr webUrl
wrStatus healthy `shouldBe` 200
-- 1. a file the walk finds and the read cannot open
createFileLink "no-such-target.js" danglingLink
broken <- webGet mgr webUrl
wrStatus broken `shouldBe` 500
assertSecurityHeaders "dangling symlink" broken
webHeader broken "server" `shouldBe` ""
removeFile danglingLink
-- 2. an exception the application does not catch at all
createDirectoryIfMissing True unreadableDir
thrown <- (setPermissions unreadableDir emptyPermissions >> webGet mgr webUrl) `finally` setPermissions unreadableDir searchablePermissions
wrStatus thrown `shouldBe` 500
assertSecurityHeaders "unreadable directory" thrown
webHeader thrown "server" `shouldBe` ""
removeDirectoryRecursive unreadableDir
recovered <- webGet mgr webUrl
wrStatus recovered `shouldBe` 200
-- Owner-only rwx, which is what a directory needs to be walked again after 'emptyPermissions'.
searchablePermissions :: Permissions
searchablePermissions = setOwnerReadable True $ setOwnerWritable True $ setOwnerSearchable True emptyPermissions
-- A token naming a file that is not served must fail at STARTUP, naming the token, rather than
-- serving a page with a dead link in it. 'resolveWebPage' is what 'newWebServer' calls from
-- 'badgePreStartHook', before the bot starts. The same directory without the bad token resolves,
-- so the failure is the token and not the fixture.
testBadgeServiceWebUnresolvableTokenFails :: HasCallStack => TestParams -> IO ()
testBadgeServiceWebUnresolvableTokenFails TestParams {tmpPath} = do
let dir = tmpPath </> "web_dir_bad_token"
cfg =
WebConfig
{ webPort = 0,
webHost = "127.0.0.1",
webBaseUrl = "http://127.0.0.1",
webSupportContact = T.pack testSupportContact,
webBehindProxy = False,
webDir = Just dir
}
writeTestWebDir dir
resolveWebPage cfg >>= \case
Left e -> expectationFailure $ "expected the fixture to resolve, got: " <> e
Right _ -> pure ()
appendFile (dir </> "index.html") "<img src=\"@@logo-symbol-light.svg@@\" alt=\"\" />\n"
resolveWebPage cfg >>= \case
Right _ -> expectationFailure "expected a token naming no served file to fail"
Left e -> do
("logo-symbol-light.svg" `isInfixOf` e) `shouldBe` True
-- and it says what is in the set, since that is the only way to see the misspelling
("main.js" `isInfixOf` e) `shouldBe` True
-- [web] base_url is validated at startup as an absolute URL, https unless the host is a loopback
-- one -- the exception the local mock stack (plan §10) runs on. It ends up in provider return and
-- webhook URLs (E2, F1), which is not where a scheme-less value should first be noticed.
testBadgeServiceConfigBaseUrlValidated :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigBaseUrlValidated TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "base-url.ini"
writeBaseUrl baseUrl =
writeFile path $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ["", "[web]", "port = 8080", "base_url = " <> baseUrl, "support_contact = " <> testSupportContact]
forM_ ["http://badges.example.org", "http://badges.example.org:8080", "badges.example.org", "/checkout", "ftp://badges.example.org"] $ \baseUrl -> do
writeBaseUrl baseUrl
readBadgeServiceConfig path >>= \case
Right _ -> expectationFailure $ "expected " <> baseUrl <> " to be rejected"
Left err -> (baseUrl, "base_url" `isInfixOf` err) `shouldBe` (baseUrl, True)
forM_ ["https://badges.example.org", "http://localhost:8080", "http://127.0.0.1:8080", "http://[::1]:8080", "http://LOCALHOST:8080"] $ \baseUrl -> do
writeBaseUrl baseUrl
readBadgeServiceConfig path >>= \case
Left err -> expectationFailure $ "expected " <> baseUrl <> " to be accepted, got: " <> err
Right BadgeServiceConfig {web} -> (baseUrl, fmap webBaseUrl web) `shouldBe` (baseUrl, Just (T.pack baseUrl))
-- '[web] support_contact' goes into the page's one outbound link. Escaping it (D4) stops a quote
-- from breaking out of the href; it does nothing about the scheme, and 'javascript:' survives
-- escaping intact. The operator's ini is inside the trust boundary, so this is a typo guard --
-- but 'base_url' in the same section is validated and an unchecked neighbour is arbitrary.
testBadgeServiceConfigSupportContactValidated :: HasCallStack => TestParams -> IO ()
testBadgeServiceConfigSupportContactValidated TestParams {tmpPath} = do
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
let path = tmpPath </> "support-contact.ini"
writeSupportContact supportContact =
writeFile path $
unlines $
issuerCodesIniLines issuerKeyFile codeSecretFile
++ ["", "[web]", "port = 8080", "base_url = https://badges.example.org", "support_contact = " <> supportContact]
forM_ ["javascript:alert(1)", "JavaScript:alert(1)", "data:text/html,<h1>hi", "/contact", "simplex.chat/contact", "mailto:"] $ \value -> do
writeSupportContact value
readBadgeServiceConfig path >>= \case
Right _ -> expectationFailure $ "expected support_contact " <> value <> " to be rejected"
Left err -> (value, "support_contact" `isInfixOf` err) `shouldBe` (value, True)
forM_ ["https://simplex.chat/contact", "http://localhost:8080/support", "mailto:support@example.org"] $ \value -> do
writeSupportContact value
readBadgeServiceConfig path >>= \case
Left err -> expectationFailure $ "expected support_contact " <> value <> " to be accepted, got: " <> err
Right BadgeServiceConfig {web} -> (value, fmap webSupportContact web) `shouldBe` (value, Just (T.pack value))