Files
simplex-chat/tests/Bots/BadgeService/ConfigTests.hs
19e70faeec badges: webapp feature branch (#7548)
* badges: webapp (#7433)

* badges: service migrations, store and catalog

* badges: BTCPay provider and settlement poller

* badges: web listener and /api endpoints

* web: checkout single-page app

* badges: tests and BTCPay fixtures

* badges: README and ini reference

* badges: fix hex16 build on GHC 8.10.7

* badges: Stripe card lane

* badges: fix Stripe card checkout, add theming

* badges: add a discount row to the order summary

* badges: site navbar, embedding, theme, Forget move

* badges: use SB code prefix in web checkout

* badges: rename sxb app namespace to sb

* badges: embed checkout nav via site; keep original app navbar

* badges: post iframe height, apply site background when embedded

* badges: embed dark surfaces, steadier iframe height

* badges: hide app footer when embedded

* badges: size embedded body to content, not viewport

* badges: declare color-scheme to stop reload flash

* badges: fade shell in on load, no reload blank

* badges: prerender app shell into index.html

* badges: pre-paint theme, hide shell on deep reload

* badges: logo returns to landing client-side

* badges: embedded wizard back, buy-a-code, resume

* badges: signal app-managed screens, resume across reload

* badges: rebuild wizard history on deep load so Back walks it

* badges: carry welcome-page height as the iframe floor

* badges: keep selection on Buy a code; rename to Your codes

* badges: read web shell as UTF-8, not locale

* badges: resume the exact paid order after Stripe card redirect

* badges: move docker deploy under scripts

* badges: add serve_webapp toggle and webapp export

* badges: wire split webapp deploy in docker config

* badges: quiet agent logs by default

* badges: resume card redirect in the embedded frame

* badges: migrate Stripe adapter to PaymentIntents

* badges: correct Stripe restricted key scopes in ini example

* badges: card via Payment Element and PaymentIntents

* badges: fix stale Checkout Session wording in Stripe adapter

* badges: fix stale CheckoutActions reference in card comment

* badges: order shell stylesheet before bootstrap script

* badges: remove development card stand-in

* badges: theme the Stripe card form with the site palette

* badges: exclude web from the Haskell build stage

* badges: unify invoice cancel and mark canceled

* badges: default log level to info

* badges: unify closed-invoice buy-again button

* badges: mute agent connection logs at info level

* badges: show purchase time in local timezone in Your codes

* badges: log service events on own channel, quiet agent

* badges: fold service migrations into one baseline

* badges: run compose on postgres over host network

* badges: use high-res hero art

* badges: add web CI to catch stale builds

* badges: rebuild web shell from committed source

* badges: normalize invoice-code link and columns

* badges: drop unused columns, rename index

* badges: note deferred receipt_hash in migrations

* badges: apply code-review fixes

* badges: reduce comments across service and web

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: improve web page (#7546)

* badges: improve web page

* improve layout

* improve layout

* fix

* small changes

---------

Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>

* badges: read one issuer key from the ini

* badges: move and group the service tests

* badges: service fixes (#7567)

* badges: match the redeem error wording in tests

* badges: drop unused imports in the bot tests

* badges: cancel Stripe orders when they expire

* badges: correct the Stripe config and docs

* badges: refuse to revoke a redeemed code

* badges: make the fake Stripe cancel like Stripe

* badges: limit replayed webhook deliveries

---------

Co-authored-by: sh <37271604+shumvgolove@users.noreply.github.com>
Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
Co-authored-by: shum <github.shum@liber.li>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2026-09-25 09:01:51 +00:00

370 lines
16 KiB
Haskell

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Bots.BadgeService.ConfigTests where
import BadgeService.Config
import qualified Data.ByteString.Char8 as B
import Data.Either (isLeft)
import Data.Ini (readIniFile)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Simplex.Messaging.Encoding.String (strDecode)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import Test.Hspec
import UnliftIO.Temporary (withTempDirectory)
badgeConfigTests :: Spec
badgeConfigTests = describe "badge service config" $ do
it "applies every documented default" testDefaults
it "falls back to the default host when the value is blank" testBlankHost
it "disables a provider whose section is absent" testAbsentSection
it "refuses an incomplete provider section, naming the key" testIncompleteSection
it "refuses a missing static_dir" testAbsentStaticDir
it "reads serve_webapp off and a webapp_export_dir" testWebappSplitDeployment
it "applies every documented stripe default" testStripeDefaults
it "disables stripe when the section is absent" testStripeAbsent
it "refuses an incomplete stripe section, naming the key" testStripeIncomplete
it "bounds stripe session_minutes to 1-1440" testStripeSessionMinutesRange
it "accepts HighSpeed" (testSpeedPolicyAccepted "HighSpeed" HighSpeed)
it "accepts LowMediumSpeed" (testSpeedPolicyAccepted "LowMediumSpeed" LowMediumSpeed)
it "accepts LowSpeed" (testSpeedPolicyAccepted "LowSpeed" LowSpeed)
it "refuses a numeric speed policy" testSpeedPolicyName
it "accepts a poll cadence and refuses a zero or negative one" testPollCadence
it "accepts an expiry window and refuses a zero or negative one" testExpiryMinutes
it "accepts a payment tolerance and refuses one that settles for a satoshi" testPaymentTolerance
it "refuses a host that would send the api key in the clear" testHostMustBeHttps
it "names a setting nothing reads, in every section it parses" testUnknownKeysAreNamed
it "refuses a file one malformed line would silently truncate" testMalformedLineRefused
it "accepts a comment or blank line after the last setting" testTrailingCommentIsAccepted
it "reports a missing file rather than throwing" testMissingFileIsReported
it "has no issuer key when the section is absent" testIssuerAbsent
it "reads the index and the private key it signs with" testIssuerKey
it "refuses a section without an index" testIssuerIndexMissing
it "refuses a section without a private key" testIssuerSecretMissing
it "refuses an index that is not a positive whole number" testIssuerIndexInvalid
it "refuses a private key that is not a valid issuer secret" testIssuerBadSecret
it "names the old default and key_<n> settings, then refuses the boot" testIssuerOldFormat
it "leaves chat redemption off when the dev section is absent" testDevRedeemAbsent
it "reads chat_redeem = on" testDevRedeemOn
it "reads chat_redeem = off" testDevRedeemOff
it "refuses a chat_redeem that is not on or off" testDevRedeemNotBoolean
fullIni :: T.Text
fullIni =
T.unlines
[ "[listener]",
"static_dir = /srv/badges",
"[btcpay]",
"host = https://btcpay.example.org",
"api_key = token-value",
"store_id = store-value",
"webhook_secret = secret-value"
]
withIni :: T.Text -> (FilePath -> IO a) -> IO a
withIni t f = do
createDirectoryIfMissing True "tests/tmp"
withTempDirectory "tests/tmp" "badge-ini" $ \d -> do
let p = d </> "badge_service.ini"
T.writeFile p t
f p
testDefaults :: IO ()
testDefaults = withIni fullIni $ \p -> do
Right cfg <- readServiceConfig p
let ListenerConfig {lHost, lPort, lStaticDir, lServeWebapp, lWebappExportDir, lTrustForwardedFor} = listener cfg
lHost `shouldBe` "127.0.0.1"
lPort `shouldBe` 8080
lStaticDir `shouldBe` "/srv/badges"
lServeWebapp `shouldBe` True
lWebappExportDir `shouldBe` Nothing
lTrustForwardedFor `shouldBe` False
let PollConfig {pWaitingSeconds, pIdleSeconds} = poll cfg
pWaitingSeconds `shouldBe` 3
pIdleSeconds `shouldBe` 60
case btcpay cfg of
Nothing -> expectationFailure "the btcpay section was present"
Just BTCPayConfig {bExpiryMinutes, bSpeedPolicy, bPaymentTolerance} -> do
bExpiryMinutes `shouldBe` 60
bSpeedPolicy `shouldBe` MediumSpeed
bPaymentTolerance `shouldBe` 0.5
testBlankHost :: IO ()
testBlankHost =
withIni (T.replace "[listener]\n" "[listener]\nhost = \n" fullIni) $ \p -> do
Right cfg <- readServiceConfig p
lHost (listener cfg) `shouldBe` "127.0.0.1"
testAbsentSection :: IO ()
testAbsentSection = withIni (T.unlines (takeWhile (/= "[btcpay]") (T.lines fullIni))) $ \p -> do
Right cfg <- readServiceConfig p
btcpay cfg `shouldBe` Nothing
testIncompleteSection :: IO ()
testIncompleteSection =
withIni (T.replace "webhook_secret = secret-value" "" fullIni) $ \p -> do
r <- readServiceConfig p
case r of
Left e -> e `shouldContain` "webhook_secret"
Right _ -> expectationFailure "an incomplete btcpay section must fail at boot"
testAbsentStaticDir :: IO ()
testAbsentStaticDir =
withIni (T.replace "static_dir = /srv/badges\n" "" fullIni) $ \p ->
readServiceConfig p >>= (`shouldSatisfy` isLeft)
testWebappSplitDeployment :: IO ()
testWebappSplitDeployment =
withIni (T.replace "static_dir = /srv/badges\n" "static_dir = /srv/badges\nserve_webapp = off\nwebapp_export_dir = /srv/web\n" fullIni) $ \p -> do
Right cfg <- readServiceConfig p
let ListenerConfig {lServeWebapp, lWebappExportDir} = listener cfg
lServeWebapp `shouldBe` False
lWebappExportDir `shouldBe` Just "/srv/web"
fullStripeIni :: T.Text
fullStripeIni =
fullIni
<> T.unlines
[ "[stripe]",
"secret_key = rk_test_x",
"publishable_key = pk_test_x",
"webhook_secret = whsec_x"
]
testStripeDefaults :: IO ()
testStripeDefaults = withIni fullStripeIni $ \p -> do
Right cfg <- readServiceConfig p
case stripe cfg of
Nothing -> expectationFailure "the stripe section was present"
Just StripeConfig {sSessionMinutes, sHost} -> do
sSessionMinutes `shouldBe` 60
sHost `shouldBe` "https://api.stripe.com"
testStripeAbsent :: IO ()
testStripeAbsent = withIni fullIni $ \p -> do
Right cfg <- readServiceConfig p
stripe cfg `shouldBe` Nothing
testStripeIncomplete :: IO ()
testStripeIncomplete =
withIni (T.replace "webhook_secret = whsec_x" "" fullStripeIni) $ \p -> do
r <- readServiceConfig p
case r of
Left e -> e `shouldContain` "webhook_secret"
Right _ -> expectationFailure "an incomplete stripe section must fail at boot"
testStripeSessionMinutesRange :: IO ()
testStripeSessionMinutesRange = do
refuses "session_minutes = -5\n"
refuses "session_minutes = 0\n"
refuses "session_minutes = 1441\n"
refuses "session_minutes = 2000\n"
accepts "session_minutes = 1\n" 1
accepts "session_minutes = 1440\n" 1440
where
refuses value =
withIni (fullStripeIni <> value) $ \p -> do
r <- readServiceConfig p
case r of
Left e -> e `shouldContain` "session_minutes"
Right _ -> expectationFailure ("stripe." <> T.unpack (T.strip value) <> " is outside 1-1440 and must fail")
accepts value expected =
withIni (fullStripeIni <> value) $ \p -> do
r <- readServiceConfig p
case r of
Right cfg -> fmap sSessionMinutes (stripe cfg) `shouldBe` Just expected
Left e -> expectationFailure ("stripe." <> T.unpack (T.strip value) <> " is inside 1-1440 and must parse, but: " <> e)
testSpeedPolicyAccepted :: T.Text -> SpeedPolicy -> IO ()
testSpeedPolicyAccepted name expected =
withIni (fullIni <> "speed_policy = " <> name <> "\n") $ \p -> do
Right cfg <- readServiceConfig p
case btcpay cfg of
Nothing -> expectationFailure "the btcpay section was present"
Just BTCPayConfig {bSpeedPolicy} -> bSpeedPolicy `shouldBe` expected
testSpeedPolicyName :: IO ()
testSpeedPolicyName =
withIni (fullIni <> "speed_policy = 2\n") $ \p ->
readServiceConfig p >>= (`shouldSatisfy` isLeft)
testPollCadence :: IO ()
testPollCadence = do
withPoll "waiting_seconds = 1\nidle_seconds = 5\n" $ \r -> case r of
Right cfg -> poll cfg `shouldBe` PollConfig {pWaitingSeconds = 1, pIdleSeconds = 5}
Left e -> expectationFailure ("a one-second cadence is legal: " <> e)
mapM_
(\v -> withPoll v (`shouldSatisfy` isLeft))
[ "waiting_seconds = 0\n",
"idle_seconds = 0\n",
"waiting_seconds = -3\n",
"idle_seconds = -1\n",
-- 2^64 + 4 would wrap to a legal 4 seconds on a machine-width read.
"idle_seconds = 18446744073709551620\n"
]
where
withPoll keys act = withIni (fullIni <> "[poll]\n" <> keys) $ \p -> readServiceConfig p >>= act
testExpiryMinutes :: IO ()
testExpiryMinutes = do
withExpiry "expiry_minutes = 1\n" $ \r -> case r of
Right cfg -> (bExpiryMinutes <$> btcpay cfg) `shouldBe` Just 1
Left e -> expectationFailure ("a one-minute window is legal: " <> e)
mapM_
( \v ->
withExpiry v $ \r -> case r of
Left e -> e `shouldContain` "expiry_minutes"
Right _ -> expectationFailure ("btcpay." <> T.unpack (T.strip v) <> " must not boot")
)
["expiry_minutes = 0\n", "expiry_minutes = -60\n"]
where
withExpiry key act = withIni (fullIni <> key) $ \p -> readServiceConfig p >>= act
testUnknownKeysAreNamed :: IO ()
testUnknownKeysAreNamed =
withIni (fullIni <> "speed_polcy = LowSpeed\ntrust_forwaded_for = on\n[poll]\nwaiting_secnds = 5\n[dev]\nchat_redem = on\n") $ \p -> do
Right ini <- readIniFile p
unknownKeys ini `shouldMatchList` ["btcpay.speed_polcy", "btcpay.trust_forwaded_for", "poll.waiting_secnds", "dev.chat_redem"]
withIni (T.replace "[btcpay]" "[btcpai]" fullIni) $ \wrongSection -> do
Right sectionIni <- readIniFile wrongSection
unknownKeys sectionIni `shouldContain` ["[btcpai]"]
withIni ("chat_redeem = on\n" <> fullIni) $ \stray -> do
Right strayIni <- readIniFile stray
unknownKeys strayIni `shouldContain` ["chat_redeem, written above the first section header"]
withIni fullIni $ \clean -> do
Right cleanIni <- readIniFile clean
unknownKeys cleanIni `shouldBe` []
-- | The ini parser stops at the first line it cannot read and keeps what it has, so without this a
-- missing `=` in [listener] would silently drop every section below it, and the provider with it.
testMalformedLineRefused :: IO ()
testMalformedLineRefused =
withIni (T.replace "static_dir = /srv/badges" "static_dir = /srv/badges\ntrust_forwarded_for on" fullIni) $ \p ->
readServiceConfig p >>= \r -> case r of
Left e -> e `shouldContain` "malformed"
Right cfg -> expectationFailure ("a truncated file must not boot, and this one kept " <> show (btcpay cfg))
-- | The strictness that refuses a truncated file must not refuse a trailing comment or blank line,
-- since `iniParser` stops before one and commenting out the last section is enough to produce it.
testTrailingCommentIsAccepted :: IO ()
testTrailingCommentIsAccepted = do
accepts (fullIni <> "; rotated the api key on 2026-09-01\n")
accepts (fullIni <> "\n\n")
accepts (fullIni <> "[dev]\n; chat_redeem = on\n")
accepts (fullIni <> "# a hash comment, with no newline after it")
where
accepts t =
withIni t $ \p ->
readServiceConfig p >>= \r -> case r of
Right _ -> pure ()
Left e -> expectationFailure ("a legal file was refused: " <> e)
-- | The caller prints the reason under the path it was asked for, so naming the file here as well
-- would put it in the line twice.
testMissingFileIsReported :: IO ()
testMissingFileIsReported =
readServiceConfig "tests/tmp/no-such-badge_service.ini" >>= \r -> case r of
Left e -> do
e `shouldContain` "does not exist"
e `shouldNotContain` "no-such-badge_service.ini"
Right _ -> expectationFailure "a file that is not there cannot be read"
testPaymentTolerance :: IO ()
testPaymentTolerance = do
withTolerance "payment_tolerance = 2.5\n" $ \r -> case r of
Right cfg -> (bPaymentTolerance <$> btcpay cfg) `shouldBe` Just 2.5
Left e -> expectationFailure ("two and a half percent is legal: " <> e)
mapM_
( \v ->
withTolerance v $ \r -> case r of
Left e -> e `shouldContain` "payment_tolerance"
Right _ -> expectationFailure ("btcpay." <> T.unpack (T.strip v) <> " must not boot")
)
["payment_tolerance = 100\n", "payment_tolerance = -1\n", "payment_tolerance = half\n"]
where
withTolerance key act = withIni (fullIni <> key) $ \p -> readServiceConfig p >>= act
testHostMustBeHttps :: IO ()
testHostMustBeHttps =
withIni (T.replace "https://" "http://" fullIni) $ \p ->
readServiceConfig p >>= \r -> case r of
Left e -> e `shouldContain` "https"
Right _ -> expectationFailure "an http host carries the api key in the clear"
-- A real 32-byte base64url secret, as `simplex-chat badge keygen` prints it.
issuerSecret :: T.Text
issuerSecret = "Ea5wG-J2mQjPBu9YfSJRKPnGnzoIdEE-8VaMh_wY2Bg="
withIssuer :: [T.Text] -> (FilePath -> IO a) -> IO a
withIssuer ls = withIni (fullIni <> T.unlines ("[issuer]" : ls))
issuerRefusal :: [T.Text] -> IO String
issuerRefusal ls =
withIssuer ls readServiceConfig >>= \r -> case r of
Left e -> pure e
Right cfg -> expectationFailure ("[issuer] " <> show ls <> " must not boot, it read " <> show (issuer cfg)) >> pure ""
testIssuerAbsent :: IO ()
testIssuerAbsent = withIni fullIni $ \p -> do
Right cfg <- readServiceConfig p
issuer cfg `shouldBe` Nothing
testIssuerKey :: IO ()
testIssuerKey =
withIssuer ["index = 3", "private_key = " <> issuerSecret] $ \p -> do
Right cfg <- readServiceConfig p
Right sk <- pure (strDecode (B.pack (T.unpack issuerSecret)))
issuer cfg `shouldBe` Just BadgeIssuerKey {keyIdx = 3, secretKey = sk}
testIssuerIndexMissing :: IO ()
testIssuerIndexMissing =
issuerRefusal ["private_key = " <> issuerSecret] `shouldReturn` "issuer.index is required"
testIssuerSecretMissing :: IO ()
testIssuerSecretMissing =
issuerRefusal ["index = 1"] `shouldReturn` "issuer.private_key is required"
testIssuerIndexInvalid :: IO ()
testIssuerIndexInvalid =
mapM_
(\v -> issuerRefusal ["index = " <> v, "private_key = " <> issuerSecret] `shouldReturn` "issuer.index must be a positive whole number")
["0", "-1", "one", "1.5", "key_1", "18446744073709551617"]
testIssuerBadSecret :: IO ()
testIssuerBadSecret =
issuerRefusal ["index = 1", "private_key = not-a-key"]
`shouldReturn` "issuer.private_key is not a valid issuer secret; use the value from `simplex-chat badge keygen`"
testIssuerOldFormat :: IO ()
testIssuerOldFormat = do
let old = ["default = key_1", "key_1 = " <> issuerSecret]
withIssuer old $ \p -> do
Right ini <- readIniFile p
unknownKeys ini `shouldMatchList` ["issuer.default", "issuer.key_1"]
issuerRefusal old `shouldReturn` "issuer.index is required"
testDevRedeemAbsent :: IO ()
testDevRedeemAbsent = withIni fullIni $ \p -> do
Right cfg <- readServiceConfig p
devChatRedeem cfg `shouldBe` False
testDevRedeemOn :: IO ()
testDevRedeemOn = withDev "chat_redeem = on\n" $ \r -> case r of
Right cfg -> devChatRedeem cfg `shouldBe` True
Left e -> expectationFailure ("[dev] chat_redeem = on is legal: " <> e)
testDevRedeemOff :: IO ()
testDevRedeemOff = withDev "chat_redeem = off\n" $ \r -> case r of
Right cfg -> devChatRedeem cfg `shouldBe` False
Left e -> expectationFailure ("[dev] chat_redeem = off is legal: " <> e)
testDevRedeemNotBoolean :: IO ()
testDevRedeemNotBoolean = withDev "chat_redeem = true\n" $ \r -> case r of
Left e -> e `shouldContain` "chat_redeem"
Right _ -> expectationFailure "only on and off are accepted, so a typo cannot silently disarm the gate"
withDev :: T.Text -> (Either String ServiceConfig -> IO a) -> IO a
withDev keys act = withIni (fullIni <> "[dev]\n" <> keys) $ \p -> readServiceConfig p >>= act