mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
1269 lines
70 KiB
Haskell
1269 lines
70 KiB
Haskell
{-# LANGUAGE CPP #-}
|
|
{-# LANGUAGE DuplicateRecordFields #-}
|
|
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
{-# LANGUAGE PatternSynonyms #-}
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
module Bots.BadgeServiceTests where
|
|
|
|
import BadgeService.Catalog (catalogTotals, defaultCatalog, offerTotal, seedCatalog)
|
|
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 (..),
|
|
checkFailureBuckets,
|
|
debitFailureBuckets,
|
|
newBadgeServiceEnv,
|
|
readBadgeServiceConfig,
|
|
sweepSignerBuckets,
|
|
)
|
|
import BadgeService.Credentials (issueSignedBadge, loadIssuerKey)
|
|
import BadgeService.Options
|
|
import BadgeService.Service
|
|
import BadgeService.Store
|
|
import ChatClient
|
|
import ChatTests.DBUtils
|
|
import ChatTests.Utils
|
|
import Control.Concurrent (forkIO, killThread, threadDelay)
|
|
import Control.Concurrent.STM (atomically, readTVarIO)
|
|
import Control.Exception (SomeException, finally, try)
|
|
import Control.Monad (replicateM, void)
|
|
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 Data.IORef (newIORef, readIORef, writeIORef)
|
|
import Data.List (find, isInfixOf)
|
|
import qualified Data.Map.Strict as Map
|
|
import Data.Maybe (fromJust, isJust)
|
|
import Data.String (fromString)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Time.Calendar (fromGregorian)
|
|
import Data.Time.Calendar.WeekDate (toWeekDate)
|
|
import Data.Time.Clock (DiffTime, UTCTime (..), addUTCTime, diffUTCTime, getCurrentTime, nominalDay, secondsToDiffTime)
|
|
import Data.Word (Word32)
|
|
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeMasterKey (..), BadgeRequest (..), BadgeType (..), 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 (..),
|
|
StatementCreditType (SCOpening),
|
|
StatementDebitType (SDLapse),
|
|
StatementEntry (StatementEntry),
|
|
StatementEntryType (SECredit, SEDebit),
|
|
pattern VersionBadgeService,
|
|
)
|
|
import Simplex.Chat.Badges.Types
|
|
( BadgeItemStatus (..),
|
|
BadgeLedgerEntry (..),
|
|
BadgeOfferId (..),
|
|
BadgePurchaseStatus (..),
|
|
LedgerCreditType (..),
|
|
LedgerDebitType (..),
|
|
LedgerEntryType (..),
|
|
OfferDiscount (..),
|
|
)
|
|
import Simplex.Chat.Controller (ChatConfig, ChatController (chatStore))
|
|
import Simplex.Chat.Options (CoreChatOpts (..))
|
|
import Simplex.Chat.Options.DB
|
|
import Simplex.Chat.PaymentService (ServicePayment (..))
|
|
import Simplex.Chat.PaymentService.Types (CurrencyAmount (..))
|
|
import Simplex.Chat.Types (ChatPeerType (..), Profile (..))
|
|
import Simplex.Messaging.Agent.Store.Common (DBStore, withConnection)
|
|
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 (strEncode)
|
|
import System.Exit (ExitCode (..))
|
|
import System.FilePath ((</>))
|
|
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
|
|
#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 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 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 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 clear both redemption columns and set unredeemed_at" testBadgeStoreUnredeemCode
|
|
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
|
|
|
|
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
|
|
}
|
|
|
|
badgeServiceConfigPath :: FilePath -> FilePath
|
|
badgeServiceConfigPath tmpPath = tmpPath </> "badge_service.ini"
|
|
|
|
-- 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 = tmpPath </> "badge-issuer.keys"
|
|
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"
|
|
]
|
|
|
|
-- 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.
|
|
writeTestBadgeServiceConfig :: TestParams -> IO ()
|
|
writeTestBadgeServiceConfig TestParams {tmpPath} = do
|
|
(issuerKeyFile, codeSecretFile) <- writeTestBadgeServiceSecrets tmpPath
|
|
writeFile (badgeServiceConfigPath tmpPath) $ unlines (issuerCodesIniLines issuerKeyFile codeSecretFile)
|
|
|
|
withBadgeService :: HasCallStack => TestParams -> (TestCC -> String -> IO ()) -> IO ()
|
|
withBadgeService ps = withBadgeServiceConfig ps (writeTestBadgeServiceConfig ps) (pure ())
|
|
|
|
-- 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 -> IO () -> IO () -> (TestCC -> String -> IO ()) -> IO ()
|
|
withBadgeServiceConfig ps writeConfig betweenPhases test = do
|
|
let opts = mkBadgeServiceOpts ps
|
|
writeConfig
|
|
withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure ()
|
|
-- First start: badge service takes the CreateMyAddress branch.
|
|
runBadgeService testCfg opts (pure ())
|
|
-- Reopen the DB to read the link the service created.
|
|
bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do
|
|
bs <## "subscribed 1 connections on server localhost"
|
|
bs ##> "/sa"
|
|
(sLink, _) <- getContactLinks bs False
|
|
bs <## "auto_accept off"
|
|
pure sLink
|
|
betweenPhases
|
|
-- Second start: badge service takes the ShowMyAddress branch, then serves the test body.
|
|
runBadgeService testCfg opts $
|
|
withNewTestChatCfg ps testCfg "client" bobProfile $ \client ->
|
|
test client bsLink
|
|
|
|
runBadgeService :: ChatConfig -> BadgeServiceOpts -> IO () -> IO ()
|
|
runBadgeService cfg opts action = do
|
|
t <- forkIO $ badgeService opts cfg
|
|
threadDelay 500000
|
|
action `finally` killThread t
|
|
|
|
-- 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)
|
|
let writeConfig =
|
|
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
|
|
|
|
-- 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
|
|
|
|
-- 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 = 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 = 0",
|
|
"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
|
|
]
|
|
|
|
-- 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 =
|
|
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
|
|
|
|
-- 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"
|
|
|
|
-- 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
|
|
|
|
-- 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
|