Files
simplex-chat/tests/Bots/BadgeService/FakeStripe.hs
T
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

428 lines
16 KiB
Haskell

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Bots.BadgeService.FakeStripe
( FakeStripe (..),
FakeRequest (..),
withFakeStripe,
fakeSessionMinutes,
setIntentState,
failNextCalls,
useListPageSize,
useListFixture,
apiRequests,
fakeIntentIds,
fakeIntentStatus,
stripeSigHeader,
stripeHexSig,
stripeEvent,
)
where
import BadgeService.Config (StripeConfig (..))
import Control.Concurrent.STM
import Crypto.Hash (Digest, SHA256)
import Crypto.MAC.HMAC (HMAC, hmac, hmacGetDigest)
import qualified Data.Aeson as J
import Data.Aeson.Key (Key)
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import Data.Aeson.Types (Pair)
import Data.ByteArray.Encoding (Base (Base16, Base64), convertToBase)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Lazy as LB
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
-- http-client is imported qualified because its Request has requestHeaders, requestBody and
-- queryString just as WAI's does.
import Network.HTTP.Client (Manager)
import qualified Network.HTTP.Client as HTTP
import Network.HTTP.Types
( Query,
Status,
badRequest400,
hAuthorization,
hContentType,
methodGet,
methodPost,
mkStatus,
notFound404,
ok200,
parseSimpleQuery,
statusCode,
statusMessage,
unauthorized401,
)
import Network.HTTP.Types.Header (Header)
import Network.Wai (Application, Response, pathInfo, queryString, requestHeaders, requestMethod, responseLBS, strictRequestBody)
import qualified Network.Wai.Handler.Warp as Warp
import Simplex.Messaging.Util (tshow)
import System.FilePath ((</>))
import Text.Read (readMaybe)
fixtureDir :: FilePath
fixtureDir = "apps" </> "simplex-badge-service" </> "test-fixtures" </> "stripe"
-- | A restricted `rk_` key shaped like Stripe's, the kind the service is configured with and never a live one.
fakeSecretKey :: Text
fakeSecretKey = "rk_test_51QfakeKEY0000000000000000"
fakeWebhookSecret :: Text
fakeWebhookSecret = "whsec_3d8f5c6a2b1e4f7089abcdef01234567"
fakeSessionMinutes :: Int
fakeSessionMinutes = 60
data FakeRequest = FakeRequest
{ frMethod :: ByteString,
frPath :: [Text],
frQuery :: Query,
frHeaders :: [Header],
frBody :: LB.ByteString
}
deriving (Eq, Show)
data IntentState = IntentState
{ isStatus :: Text,
isAmount :: Int,
isCurrency :: Text
}
deriving (Eq, Show)
data FailPlan = FailPlan {fpCalls :: Int, fpStatus :: Int}
deriving (Eq, Show)
data FakeState = FakeState
{ fsIntents :: M.Map Text IntentState,
fsNextId :: Int,
fsFail :: FailPlan,
fsList :: Text,
fsPageSize :: Maybe Int,
fsRequests :: [FakeRequest]
}
initialState :: FakeState
initialState =
FakeState
{ fsIntents = M.empty,
fsNextId = 1,
fsFail = FailPlan {fpCalls = 0, fpStatus = 0},
fsList = "intent-list",
fsPageSize = Nothing,
fsRequests = []
}
data FakeStripe = FakeStripe
{ fsConfig :: StripeConfig,
fsBaseUrl :: String,
fsManager :: Manager,
fsState :: TVar FakeState
}
-- | Warp chooses the port, because a fixed one could collide with a server left over from an
-- earlier run and the test would pass against that instead.
withFakeStripe :: (FakeStripe -> IO a) -> IO a
withFakeStripe action = do
fsState <- newTVarIO initialState
fsManager <- HTTP.newManager HTTP.defaultManagerSettings
Warp.testWithApplication (pure (fakeApp fsState)) $ \prt -> do
let fsBaseUrl = "http://127.0.0.1:" <> show prt
action FakeStripe {fsConfig = fakeConfig (T.pack fsBaseUrl), fsBaseUrl, fsManager, fsState}
fakeConfig :: Text -> StripeConfig
fakeConfig host =
StripeConfig
{ sSecretKey = fakeSecretKey,
sPublishableKey = "pk_test_x",
sWebhookSecret = fakeWebhookSecret,
sSessionMinutes = fakeSessionMinutes,
sHost = host
}
-- | Stripe-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "t.body">.
stripeSigHeader :: Text -> Int -> LB.ByteString -> [Header]
stripeSigHeader secret t body =
[("Stripe-Signature", "t=" <> B8.pack (show t) <> ",v1=" <> stripeHexSig secret t body)]
stripeHexSig :: Text -> Int -> LB.ByteString -> ByteString
stripeHexSig secret t body = convertToBase Base16 digest
where
signed = TE.encodeUtf8 (T.pack (show t) <> ".") <> LB.toStrict body
digest :: Digest SHA256
digest = hmacGetDigest (hmac (TE.encodeUtf8 secret) signed :: HMAC SHA256)
-- | A minimal event of the shape @{type, data:{object:{id}}}@. The verify signs these exact bytes,
-- so a valid signature cannot mean two different things.
stripeEvent :: Text -> Text -> LB.ByteString
stripeEvent eventType pid =
J.encode (J.object ["type" J..= eventType, "data" J..= J.object ["object" J..= J.object ["id" J..= pid]]])
fixtureResponse :: Text -> IO J.Value
fixtureResponse name = do
raw <- LB.readFile path
case J.eitherDecode raw of
Left e -> fail (path <> ": " <> e)
Right (J.Object o) -> case (KM.lookup "_fixture" o, KM.lookup "response" o) of
(Just (J.String _), Just v) -> pure v
_ -> fail (path <> ": a fixture is {\"_fixture\": <provenance>, \"response\": <body>}")
Right _ -> fail (path <> ": a fixture is a JSON object")
where
path = fixtureDir </> T.unpack name <> ".json"
-- | An unknown status falls back to the open body but is patched to the status that was set, so a
-- status this build has never seen still reaches the adapter verbatim.
intentFixture :: IntentState -> Text
intentFixture IntentState {isStatus} = case isStatus of
"succeeded" -> "intent-succeeded"
"canceled" -> "intent-canceled"
_ -> "intent-open"
patchIntent :: Text -> IntentState -> J.Value -> J.Value
patchIntent pid IntentState {isStatus, isAmount, isCurrency} =
setField "id" (J.String pid)
. setField "client_secret" (J.String (pid <> "_secret_test"))
. setField "status" (J.String isStatus)
. setField "amount_received" (J.toJSON (if isStatus == "succeeded" then isAmount else (0 :: Int)))
. setField "currency" (J.String isCurrency)
setField :: Key -> J.Value -> J.Value -> J.Value
setField k v = \case
J.Object o -> J.Object (KM.insert k v o)
other -> other
fakeApp :: TVar FakeState -> Application
fakeApp stv req respond = do
body <- strictRequestBody req
case pathInfo req of
["_state", pid] | isPost -> control body (setState pid)
["_fail"] | isPost -> control body setFail
["_paging"] | isPost -> control body setPaging
["_fixtures"] | isPost -> control body setFixtures
"v1" : "payment_intents" : rest -> apiCall body rest
_ -> refuse notFound404 "no such path on the fake stripe"
where
verb = requestMethod req
isPost = verb == methodPost
isGet = verb == methodGet
refuse st message = respond (errorResponse st message)
ok = respond (jsonResponse ok200 (J.object ["ok" J..= True]))
control b act = case J.decode b of
Just (J.Object o) ->
act o >>= \case
Nothing -> ok
Just message -> refuse badRequest400 message
_ -> refuse badRequest400 "a control call takes a JSON object"
setState pid o = case unknownKeys stateKeys o of
Just message -> pure (Just message)
Nothing -> atomically $ do
st <- readTVar stv
case M.lookup pid (fsIntents st) of
Nothing -> pure (Just ("no intent " <> pid <> " was created here"))
Just s -> do
let s' = s {isStatus = fromMaybe (isStatus s) (textField "status" o)}
writeTVar stv st {fsIntents = M.insert pid s' (fsIntents st)}
pure Nothing
setFail o = case unknownKeys ["calls", "status"] o of
Just message -> pure (Just message)
Nothing -> case (intField "calls" o, intField "status" o) of
(Just calls, Just code) -> do
atomically (modifyTVar' stv (\st -> st {fsFail = FailPlan {fpCalls = calls, fpStatus = code}}))
pure Nothing
_ -> pure (Just "_fail takes {\"calls\": <n>, \"status\": <http status>}")
setPaging o = case unknownKeys ["size"] o of
Just message -> pure (Just message)
Nothing -> case intField "size" o of
Just size -> do
atomically (modifyTVar' stv (\st -> st {fsPageSize = Just size}))
pure Nothing
Nothing -> pure (Just "_paging takes {\"size\": <n>}")
setFixtures o = case unknownKeys ["list"] o of
Just message -> pure (Just message)
Nothing -> case textField "list" o of
Just name -> do
atomically (modifyTVar' stv (\st -> st {fsList = name}))
pure Nothing
Nothing -> pure (Just "_fixtures takes {\"list\": <fixture name>}")
apiCall b rest = do
atomically $
modifyTVar' stv $ \st ->
st {fsRequests = FakeRequest verb (pathInfo req) (queryString req) (requestHeaders req) b : fsRequests st}
case lookup hAuthorization (requestHeaders req) of
Just given | given == expectedAuth -> injectingFailure (route b rest)
_ -> refuse unauthorized401 "Authorization must be HTTP Basic with the secret key as username"
-- Stripe authenticates the secret key as the Basic username with an empty password, which is
-- how http-client's applyBasicAuth builds the header.
expectedAuth = "Basic " <> convertToBase Base64 (TE.encodeUtf8 (fakeSecretKey <> ":"))
injectingFailure act = do
failing <- atomically $ do
st <- readTVar stv
case fsFail st of
plan@FailPlan {fpCalls, fpStatus} | fpCalls > 0 -> do
writeTVar stv st {fsFail = plan {fpCalls = fpCalls - 1}}
pure (Just fpStatus)
_ -> pure Nothing
case failing of
Just code -> refuse (mkStatus code "Injected Failure") "the fake was told to fail this call"
Nothing -> act
route b = \case
[] | isPost -> createIntent b
[] | isGet -> listIntents
[pid] | isGet -> withIntent pid $ \s -> serve (intentFixture s) (patchIntent pid s)
[pid, "cancel"] | isPost -> cancelIntent pid
_ -> refuse notFound404 "no such payment_intents path on the fake stripe"
createIntent b = do
let form = parseSimpleQuery (LB.toStrict b)
amount = fromMaybe 0 (lookup "amount" form >>= readMaybe . B8.unpack)
currency = maybe "usd" TE.decodeUtf8 (lookup "currency" form)
(pid, s) <- atomically $ do
st <- readTVar stv
let pid = "pi_test_" <> T.justifyRight 4 '0' (tshow (fsNextId st))
s = IntentState {isStatus = "requires_payment_method", isAmount = amount, isCurrency = currency}
writeTVar stv st {fsNextId = fsNextId st + 1, fsIntents = M.insert pid s (fsIntents st)}
pure (pid, s)
serve (intentFixture s) (patchIntent pid s)
-- Real Stripe can't cancel a card payment that is processing, paid or already cancelled.
cancelIntent pid = do
cancelled <- atomically $ do
st <- readTVar stv
case M.lookup pid (fsIntents st) of
Nothing -> pure Nothing
Just s
| isStatus s `elem` ["processing", "succeeded", "canceled"] -> pure (Just (Left s))
| otherwise -> do
let s' = s {isStatus = "canceled"}
writeTVar stv st {fsIntents = M.insert pid s' (fsIntents st)}
pure (Just (Right s'))
case cancelled of
Nothing -> refuse notFound404 ("no intent " <> pid <> " on the fake stripe")
Just (Left s) ->
respond . stripeError badRequest400 "payment_intent_unexpected_state" $
"You cannot cancel this PaymentIntent because it has a status of " <> isStatus s <> "."
Just (Right s') -> serve (intentFixture s') (patchIntent pid s')
listIntents = do
st <- readTVarIO stv
full <- fixtureResponse (fsList st)
let (page, more) = pageItems (fsPageSize st) (queryText "starting_after" (queryString req)) (listItems full)
respond (jsonResponse ok200 (J.object ["object" J..= ("list" :: Text), "data" J..= page, "has_more" J..= more]))
serve name patch = do
v <- fixtureResponse name
respond (jsonResponse ok200 (patch v))
withIntent pid act = do
intents <- fsIntents <$> readTVarIO stv
case M.lookup pid intents of
Just s -> act s
Nothing -> refuse notFound404 ("no intent " <> pid <> " on the fake stripe")
listItems :: J.Value -> [J.Value]
listItems = \case
J.Object o -> case KM.lookup "data" o of
Just (J.Array vs) -> foldr (:) [] vs
_ -> []
_ -> []
-- | No page size serves the whole array. With one, `starting_after` names the id after which
-- the page begins, and `has_more` says whether anything is left.
pageItems :: Maybe Int -> Maybe Text -> [J.Value] -> ([J.Value], Bool)
pageItems Nothing _ items = (items, False)
pageItems (Just size) after items = (take size rest, length rest > size)
where
rest = case after of
Nothing -> items
Just a -> drop 1 (dropWhile ((/= Just a) . intentId) items)
intentId :: J.Value -> Maybe Text
intentId = \case
J.Object o -> case KM.lookup "id" o of
Just (J.String i) -> Just i
_ -> Nothing
_ -> Nothing
queryText :: ByteString -> Query -> Maybe Text
queryText k q = case lookup k q of
Just (Just v) -> Just (TE.decodeUtf8 v)
_ -> Nothing
jsonResponse :: Status -> J.Value -> Response
jsonResponse st v = responseLBS st [(hContentType, "application/json")] (J.encode v)
errorResponse :: Status -> Text -> Response
errorResponse st = stripeError st (TE.decodeUtf8 (statusMessage st))
stripeError :: Status -> Text -> Text -> Response
stripeError st code message = jsonResponse st (J.object ["error" J..= J.object ["message" J..= message, "code" J..= code]])
stateKeys :: [Key]
stateKeys = ["status"]
unknownKeys :: [Key] -> J.Object -> Maybe Text
unknownKeys known o = case filter (`notElem` known) (KM.keys o) of
k : _ -> Just ("this control call does not set " <> K.toText k)
[] -> Nothing
textField :: Key -> J.Object -> Maybe Text
textField k o = case KM.lookup k o of
Just (J.String t) -> Just t
_ -> Nothing
intField :: Key -> J.Object -> Maybe Int
intField k o = case KM.lookup k o of
Just n -> case J.fromJSON n of
J.Success i -> Just i
J.Error _ -> Nothing
Nothing -> Nothing
controlPost :: FakeStripe -> String -> J.Value -> IO ()
controlPost FakeStripe {fsBaseUrl, fsManager} path v = do
req <- HTTP.parseRequest (fsBaseUrl <> path)
r <- HTTP.httpLbs req {HTTP.method = methodPost, HTTP.requestBody = HTTP.RequestBodyLBS (J.encode v)} fsManager
case statusCode (HTTP.responseStatus r) of
200 -> pure ()
code -> fail ("fake stripe " <> path <> " answered " <> show code <> ": " <> show (HTTP.responseBody r))
setIntentState :: FakeStripe -> Text -> [Pair] -> IO ()
setIntentState fake pid fields = controlPost fake ("/_state/" <> T.unpack pid) (J.object fields)
failNextCalls :: FakeStripe -> Int -> Int -> IO ()
failNextCalls fake calls code = controlPost fake "/_fail" (J.object ["calls" J..= calls, "status" J..= code])
useListPageSize :: FakeStripe -> Int -> IO ()
useListPageSize fake size = controlPost fake "/_paging" (J.object ["size" J..= size])
useListFixture :: FakeStripe -> Text -> IO ()
useListFixture fake name = controlPost fake "/_fixtures" (J.object ["list" J..= name])
fakeRequests :: FakeStripe -> IO [FakeRequest]
fakeRequests FakeStripe {fsState} = reverse . fsRequests <$> readTVarIO fsState
fakeIntentIds :: FakeStripe -> IO [Text]
fakeIntentIds FakeStripe {fsState} = M.keys . fsIntents <$> readTVarIO fsState
fakeIntentStatus :: FakeStripe -> Text -> IO (Maybe Text)
fakeIntentStatus FakeStripe {fsState} pid = fmap isStatus . M.lookup pid . fsIntents <$> readTVarIO fsState
apiRequests :: FakeStripe -> ByteString -> [Text] -> IO [FakeRequest]
apiRequests fake verb segments = filter matching <$> fakeRequests fake
where
matching FakeRequest {frMethod, frPath} =
frMethod == verb && frPath == ["v1", "payment_intents"] <> segments