mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 13:29:44 +00:00
* 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>
354 lines
16 KiB
Haskell
354 lines
16 KiB
Haskell
{-# LANGUAGE LambdaCase #-}
|
|
{-# LANGUAGE NamedFieldPuns #-}
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
module Bots.BadgeService.StripeTests (badgeStripeTests) where
|
|
|
|
import BadgeService.Config (StripeConfig (..))
|
|
import BadgeService.Providers
|
|
( ListPass (..),
|
|
OrderDraft (..),
|
|
PaymentSignal (..),
|
|
Provider (..),
|
|
ProviderError (..),
|
|
ProviderInvoice (..),
|
|
Received (..),
|
|
settleWindow,
|
|
WebhookError (..),
|
|
)
|
|
import BadgeService.Providers.Stripe (IntentRead (..), signalOf, stripeProvider)
|
|
import Bots.BadgeService.FakeStripe
|
|
import Control.Monad (join)
|
|
import Data.Aeson ((.=))
|
|
import qualified Data.ByteString.Char8 as B8
|
|
import qualified Data.ByteString.Lazy as LB
|
|
import Data.Either (isLeft)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import Data.Time.Clock (UTCTime, getCurrentTime)
|
|
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
|
|
import Network.HTTP.Types (hAuthorization, parseSimpleQuery)
|
|
import Text.Read (readMaybe)
|
|
import Simplex.Chat.PaymentService.Types
|
|
( CardProvider (..),
|
|
CryptoCurrency (..),
|
|
CurrencyAmount (..),
|
|
ServicePaymentDestination (..),
|
|
ServicePaymentMethod (..),
|
|
)
|
|
import System.Timeout (timeout)
|
|
import Test.Hspec
|
|
|
|
badgeStripeTests :: Spec
|
|
badgeStripeTests = describe "badge stripe adapter" $ do
|
|
describe "against the fake Stripe" $ do
|
|
it "creates a PaymentIntent and reads back the client secret" testFakeCreatesCard
|
|
it "sends the create form Stripe documents, over HTTP Basic" testFakeCreateBody
|
|
it "is refused when the secret key is wrong, rather than passing silently" testFakeWrongSecretKey
|
|
it "walks requires_payment_method through processing to succeeded, then refuses a cancel" testFakeLifecycle
|
|
it "cancels an open intent once, then refuses a second cancel" testFakeCancel
|
|
it "refuses to cancel an intent while it is processing" testFakeCancelRefusedWhileProcessing
|
|
it "reads the expand query on every read" testFakeReadExpands
|
|
it "closes on a canceled intent" testFakeCanceled
|
|
it "makes a 500 at checkout a ProviderError, creating nothing" testFakeCreate500
|
|
it "makes a 500 on a read a ProviderError the next read recovers from" testFakeRead500
|
|
it "refuses a crypto method, since Stripe offers none" testRefusesCrypto
|
|
it "makes an unknown intent status a ProviderError, not a silent no-signal" testFakeReadUnknownStatus
|
|
it "settles a succeeded intent with no charge at read time" testSettledNoChargeReadTime
|
|
describe "listing intents to settle" $ do
|
|
it "lists by a created window, never status=open" testFakeListWindow
|
|
it "moves the settled and canceled intents, at read time, leaving the open one" testFakeListsOpen
|
|
it "skips a listed intent that carries no id" testFakeListSkipsIdless
|
|
it "walks starting_after across pages, gathering every intent" testFakeListPages
|
|
it "records an anomaly when a page ends with no cursor id" testFakeListCursorGap
|
|
it "aborts the whole pass on a 429" testFakeList429
|
|
describe "verifying the webhook signature" $ do
|
|
it "accepts a signed succeeded event and names its intent" testWebhookVerifies
|
|
it "answers a valid but unhandled event with no intent" testWebhookUnhandled
|
|
it "accepts any v1 during a rotation, refusing only when none matches" testWebhookRotatedSignature
|
|
it "refuses a missing, malformed, or tampered signature" testWebhookMalformed
|
|
it "accepts a signature up to 15 minutes from now, either way, and refuses one further out" testWebhookTimestampWindow
|
|
|
|
fiftyFourDollars :: OrderDraft
|
|
fiftyFourDollars = OrderDraft {odAmount = CurrencyAmount 5400, odCurrency = "usd"}
|
|
|
|
fiftyFourDollarsReceived :: Received
|
|
fiftyFourDollarsReceived = Received {rcvAmount = CurrencyAmount 5400, rcvCrypto = Nothing, rcvDue = Nothing}
|
|
|
|
nothingReceived :: Received
|
|
nothingReceived = Received {rcvAmount = CurrencyAmount 0, rcvCrypto = Nothing, rcvDue = Nothing}
|
|
|
|
fixtureChargeAt :: UTCTime
|
|
fixtureChargeAt = posixSecondsToUTCTime 1700000000
|
|
|
|
exampleCeiling :: Int
|
|
exampleCeiling = 20000000
|
|
|
|
failWith :: HasCallStack => String -> IO a
|
|
failWith msg = expectationFailure msg >> error msg
|
|
|
|
withProvider :: HasCallStack => (FakeStripe -> Provider -> IO a) -> IO a
|
|
withProvider action = bounded $ withFakeStripe $ \fake -> stripeProvider (fsConfig fake) >>= action fake
|
|
where
|
|
bounded act = timeout exampleCeiling act >>= maybe (failWith "the fake stripe did not answer within 20s") pure
|
|
|
|
createdInvoice :: HasCallStack => Provider -> ServicePaymentMethod -> IO ProviderInvoice
|
|
createdInvoice p spm =
|
|
pCreateInvoice p spm fiftyFourDollars >>= \case
|
|
Right inv -> pure inv
|
|
Left e -> failWith ("expected an invoice, got " <> show e)
|
|
|
|
isCardSecret :: ServicePaymentDestination -> Bool
|
|
isCardSecret = \case
|
|
SPDCard CPStripe secret -> secret /= ""
|
|
_ -> False
|
|
|
|
testFakeCreatesCard :: IO ()
|
|
testFakeCreatesCard = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef, piDestination} <- createdInvoice p (SPMCard CPStripe)
|
|
piDestination `shouldSatisfy` isCardSecret
|
|
fakeIntentIds fake `shouldReturn` [piProviderRef]
|
|
posts <- apiRequests fake "POST" []
|
|
length posts `shouldBe` 1
|
|
|
|
testFakeCreateBody :: IO ()
|
|
testFakeCreateBody = withProvider $ \fake p -> do
|
|
_ <- createdInvoice p (SPMCard CPStripe)
|
|
posts <- apiRequests fake "POST" []
|
|
case posts of
|
|
[created] -> do
|
|
let form = parseSimpleQuery (LB.toStrict (frBody created))
|
|
lookup "amount" form `shouldBe` Just "5400"
|
|
lookup "currency" form `shouldBe` Just "usd"
|
|
-- Card only keeps the client confirm from redirecting the top window out of the embedded frame.
|
|
lookup "allowed_payment_method_types[]" form `shouldBe` Just "card"
|
|
lookup hAuthorization (frHeaders created) `shouldSatisfy` maybe False ("Basic " `B8.isPrefixOf`)
|
|
_ -> expectationFailure ("expected one create, got " <> show (length posts))
|
|
|
|
testFakeWrongSecretKey :: IO ()
|
|
testFakeWrongSecretKey = bounded $ withFakeStripe $ \fake -> do
|
|
p <- stripeProvider (fsConfig fake) {sSecretKey = "sk_test_wrong"}
|
|
r <- pCreateInvoice p (SPMCard CPStripe) fiftyFourDollars
|
|
r `shouldSatisfy` namesInError "401"
|
|
fakeIntentIds fake `shouldReturn` []
|
|
where
|
|
bounded act = timeout exampleCeiling act >>= maybe (failWith "the fake stripe did not answer within 20s") pure
|
|
|
|
testFakeLifecycle :: IO ()
|
|
testFakeLifecycle = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef = pid} <- createdInvoice p (SPMCard CPStripe)
|
|
pReadInvoice p pid `shouldReturn` Right Nothing
|
|
setIntentState fake pid ["status" .= ("processing" :: Text)]
|
|
pReadInvoice p pid `shouldReturn` Right Nothing
|
|
setIntentState fake pid ["status" .= ("succeeded" :: Text)]
|
|
pReadInvoice p pid `shouldReturn` Right (Just (SigSettled fiftyFourDollarsReceived fixtureChargeAt))
|
|
pCancelInvoice p pid >>= (`shouldSatisfy` namesInError "payment_intent_unexpected_state")
|
|
fakeIntentStatus fake pid `shouldReturn` Just "succeeded"
|
|
cancels <- apiRequests fake "POST" [pid, "cancel"]
|
|
length cancels `shouldBe` 1
|
|
|
|
testFakeCancelRefusedWhileProcessing :: IO ()
|
|
testFakeCancelRefusedWhileProcessing = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef = pid} <- createdInvoice p (SPMCard CPStripe)
|
|
setIntentState fake pid ["status" .= ("processing" :: Text)]
|
|
pCancelInvoice p pid >>= (`shouldSatisfy` namesInError "payment_intent_unexpected_state")
|
|
fakeIntentStatus fake pid `shouldReturn` Just "processing"
|
|
|
|
testFakeCancel :: IO ()
|
|
testFakeCancel = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef = pid} <- createdInvoice p (SPMCard CPStripe)
|
|
pCancelInvoice p pid `shouldReturn` Right ()
|
|
fakeIntentStatus fake pid `shouldReturn` Just "canceled"
|
|
pReadInvoice p pid `shouldReturn` Right (Just (SigClosed nothingReceived))
|
|
pCancelInvoice p pid >>= (`shouldSatisfy` namesInError "payment_intent_unexpected_state")
|
|
|
|
testFakeReadExpands :: IO ()
|
|
testFakeReadExpands = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef = pid} <- createdInvoice p (SPMCard CPStripe)
|
|
_ <- pReadInvoice p pid
|
|
gets <- apiRequests fake "GET" [pid]
|
|
case gets of
|
|
[g] -> lookup "expand[]" (frQuery g) `shouldBe` Just (Just "latest_charge")
|
|
_ -> expectationFailure ("expected one read, got " <> show (length gets))
|
|
|
|
testFakeCanceled :: IO ()
|
|
testFakeCanceled = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef = pid} <- createdInvoice p (SPMCard CPStripe)
|
|
setIntentState fake pid ["status" .= ("canceled" :: Text)]
|
|
-- A nonzero received amount here would write a phantom payment for money the buyer never sent.
|
|
pReadInvoice p pid `shouldReturn` Right (Just (SigClosed nothingReceived))
|
|
|
|
testFakeCreate500 :: IO ()
|
|
testFakeCreate500 = withProvider $ \fake p -> do
|
|
failNextCalls fake 1 500
|
|
r <- pCreateInvoice p (SPMCard CPStripe) fiftyFourDollars
|
|
r `shouldSatisfy` namesInError "500"
|
|
fakeIntentIds fake `shouldReturn` []
|
|
|
|
testFakeRead500 :: IO ()
|
|
testFakeRead500 = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef = pid} <- createdInvoice p (SPMCard CPStripe)
|
|
setIntentState fake pid ["status" .= ("succeeded" :: Text)]
|
|
failNextCalls fake 1 500
|
|
r <- pReadInvoice p pid
|
|
r `shouldSatisfy` namesInError "500"
|
|
pReadInvoice p pid `shouldReturn` Right (Just (SigSettled fiftyFourDollarsReceived fixtureChargeAt))
|
|
|
|
testRefusesCrypto :: IO ()
|
|
testRefusesCrypto = withProvider $ \_ p ->
|
|
pCreateInvoice p (SPMCrypto CCBtc) fiftyFourDollars >>= (`shouldSatisfy` isLeft)
|
|
|
|
-- | An unknown status must be an error rather than Right Nothing, which would tell the poller the
|
|
-- intent had not changed and leave the order to expire.
|
|
testFakeReadUnknownStatus :: IO ()
|
|
testFakeReadUnknownStatus = withProvider $ \fake p -> do
|
|
ProviderInvoice {piProviderRef = pid} <- createdInvoice p (SPMCard CPStripe)
|
|
setIntentState fake pid ["status" .= ("frozen" :: Text)]
|
|
pReadInvoice p pid >>= (`shouldSatisfy` namesInError "unknown status")
|
|
|
|
-- | Stripe can report an intent succeeded before its charge is expanded. With no charge to date it,
|
|
-- the settlement instant falls back to the read time rather than an epoch.
|
|
testSettledNoChargeReadTime :: IO ()
|
|
testSettledNoChargeReadTime = do
|
|
now <- getCurrentTime
|
|
let ir = IntentRead {irId = "pi_test_x", irStatus = "succeeded", irAmountReceived = 5400, irChargeCreated = Nothing}
|
|
signalOf now ir `shouldBe` Right (Just (SigSettled fiftyFourDollarsReceived now))
|
|
|
|
-- | A status=open query returns only open intents, which carry no signal, so the poll lists by
|
|
-- creation time instead to catch a settlement the webhook missed. The window reaches back the
|
|
-- settle window plus an intent's own lifetime.
|
|
testFakeListWindow :: IO ()
|
|
testFakeListWindow = withProvider $ \fake p -> do
|
|
askedAt <- getCurrentTime
|
|
_ <- pListOpen p
|
|
answeredAt <- getCurrentTime
|
|
gets <- apiRequests fake "GET" []
|
|
case gets of
|
|
(listed : _) -> do
|
|
lookup "status" (frQuery listed) `shouldBe` Nothing
|
|
case join (lookup "created[gte]" (frQuery listed)) >>= readMaybe . B8.unpack of
|
|
Nothing -> expectationFailure ("no readable created[gte] in " <> show (frQuery listed))
|
|
Just sent -> do
|
|
let window = truncate settleWindow + 60 * toInteger fakeSessionMinutes
|
|
seconds t = floor (utcTimeToPOSIXSeconds t) :: Integer
|
|
sent `shouldSatisfy` \s -> s >= seconds askedAt - window && s <= seconds answeredAt - window
|
|
[] -> expectationFailure "expected at least one list request"
|
|
|
|
testFakeListsOpen :: IO ()
|
|
testFakeListsOpen = withProvider $ \_ p -> do
|
|
askedAt <- getCurrentTime
|
|
r <- pListOpen p
|
|
answeredAt <- getCurrentTime
|
|
case r of
|
|
Right ListPass {lpMoved} -> do
|
|
map fst lpMoved `shouldSatisfy` elem "pi_test_a"
|
|
map fst lpMoved `shouldSatisfy` elem "pi_test_b"
|
|
map fst lpMoved `shouldSatisfy` notElem "pi_test_open"
|
|
case lookup "pi_test_a" lpMoved of
|
|
Just (SigSettled rcv settledAt) -> do
|
|
rcv `shouldBe` fiftyFourDollarsReceived
|
|
settledAt `shouldSatisfy` \t -> t >= askedAt && t <= answeredAt
|
|
other -> failWith ("pi_test_a should settle at read time, got " <> show other)
|
|
lookup "pi_test_b" lpMoved `shouldBe` Just (SigClosed nothingReceived)
|
|
Left e -> failWith ("expected a list pass, got " <> show e)
|
|
|
|
testFakeListSkipsIdless :: IO ()
|
|
testFakeListSkipsIdless = withProvider $ \_ p ->
|
|
pListOpen p >>= \case
|
|
Right ListPass {lpSkipped} -> map fst lpSkipped `shouldBe` [Nothing]
|
|
Left e -> failWith ("expected a list pass, got " <> show e)
|
|
|
|
testFakeListPages :: IO ()
|
|
testFakeListPages = withProvider $ \fake p -> do
|
|
useListPageSize fake 1
|
|
pListOpen p >>= \case
|
|
Right ListPass {lpMoved} -> do
|
|
map fst lpMoved `shouldSatisfy` elem "pi_test_a"
|
|
map fst lpMoved `shouldSatisfy` elem "pi_test_b"
|
|
Left e -> failWith ("expected a list pass, got " <> show e)
|
|
|
|
-- | Stripe pages by the last row's id. A page whose last row has no id leaves no cursor, so the
|
|
-- rest cannot be walked; that must surface as an anomaly, not a clean pass that silently drops
|
|
-- the intents beyond it.
|
|
testFakeListCursorGap :: IO ()
|
|
testFakeListCursorGap = withProvider $ \fake p -> do
|
|
useListFixture fake "intent-list-cursor-gap"
|
|
useListPageSize fake 2
|
|
pListOpen p >>= \case
|
|
Right ListPass {lpMoved, lpSkipped} -> do
|
|
map fst lpMoved `shouldSatisfy` elem "pi_test_a"
|
|
map fst lpMoved `shouldSatisfy` notElem "pi_test_c"
|
|
map snd lpSkipped `shouldSatisfy` any ("no id to page from" `T.isInfixOf`)
|
|
Left e -> failWith ("expected a list pass, got " <> show e)
|
|
|
|
testFakeList429 :: IO ()
|
|
testFakeList429 = withProvider $ \fake p -> do
|
|
failNextCalls fake 1 429
|
|
pListOpen p >>= (`shouldSatisfy` namesInError "429")
|
|
|
|
testWebhookVerifies :: IO ()
|
|
testWebhookVerifies = withProvider $ \fake p -> do
|
|
let secret = sWebhookSecret (fsConfig fake)
|
|
body = stripeEvent "payment_intent.succeeded" "pi_test_a"
|
|
pVerifyWebhook p signedAt (stripeSigHeader secret 1700000000 body) (LB.toStrict body) `shouldBe` Right (Just "pi_test_a")
|
|
pVerifyWebhook p signedAt (stripeSigHeader (secret <> "0") 1700000000 body) (LB.toStrict body) `shouldSatisfy` isRefused
|
|
|
|
testWebhookUnhandled :: IO ()
|
|
testWebhookUnhandled = withProvider $ \fake p -> do
|
|
let secret = sWebhookSecret (fsConfig fake)
|
|
body = stripeEvent "charge.refunded" "pi_test_a"
|
|
pVerifyWebhook p signedAt (stripeSigHeader secret 1700000000 body) (LB.toStrict body) `shouldBe` Right Nothing
|
|
|
|
-- | Stripe sends one v1 per active secret while a signing secret is being rotated, so a valid
|
|
-- signature can be any of them, not only the first.
|
|
testWebhookRotatedSignature :: IO ()
|
|
testWebhookRotatedSignature = withProvider $ \fake p -> do
|
|
let secret = sWebhookSecret (fsConfig fake)
|
|
t = 1700000000 :: Int
|
|
body = stripeEvent "payment_intent.succeeded" "pi_test_a"
|
|
good = stripeHexSig secret t body
|
|
wrong = stripeHexSig (secret <> "0") t body
|
|
header sigs = [("Stripe-Signature", "t=" <> B8.pack (show t) <> B8.concat [",v1=" <> s | s <- sigs])]
|
|
pVerifyWebhook p signedAt (header [wrong, good]) (LB.toStrict body) `shouldBe` Right (Just "pi_test_a")
|
|
pVerifyWebhook p signedAt (header [wrong, wrong]) (LB.toStrict body) `shouldSatisfy` isRefused
|
|
|
|
testWebhookMalformed :: IO ()
|
|
testWebhookMalformed = withProvider $ \fake p -> do
|
|
let secret = sWebhookSecret (fsConfig fake)
|
|
body = stripeEvent "payment_intent.succeeded" "pi_test_a"
|
|
raw = LB.toStrict body
|
|
pVerifyWebhook p signedAt [] raw `shouldSatisfy` isRefused
|
|
pVerifyWebhook p signedAt [("Stripe-Signature", "t=1700000000")] raw `shouldSatisfy` isRefused
|
|
pVerifyWebhook p signedAt [("Stripe-Signature", "t=1700000000,v1=not hex")] raw `shouldSatisfy` isRefused
|
|
pVerifyWebhook p signedAt (stripeSigHeader secret 1700000000 body) (raw <> "x") `shouldSatisfy` isRefused
|
|
|
|
testWebhookTimestampWindow :: IO ()
|
|
testWebhookTimestampWindow = withProvider $ \fake p -> do
|
|
let secret = sWebhookSecret (fsConfig fake)
|
|
body = stripeEvent "payment_intent.succeeded" "pi_test_a"
|
|
signedOffBy offset = pVerifyWebhook p signedAt (stripeSigHeader secret (1700000000 + offset) body) (LB.toStrict body)
|
|
window = 15 * 60
|
|
signedOffBy (negate window) `shouldBe` Right (Just "pi_test_a")
|
|
signedOffBy window `shouldBe` Right (Just "pi_test_a")
|
|
signedOffBy (negate window - 1) `shouldSatisfy` isStale
|
|
signedOffBy (window + 1) `shouldSatisfy` isStale
|
|
|
|
-- | The test signatures are made at this time, so they pass the 15-minute check.
|
|
signedAt :: UTCTime
|
|
signedAt = posixSecondsToUTCTime 1700000000
|
|
|
|
namesInError :: Text -> Either ProviderError a -> Bool
|
|
namesInError what = \case
|
|
Left (ProviderError e) -> what `T.isInfixOf` e
|
|
Right _ -> False
|
|
|
|
isRefused :: Either WebhookError (Maybe Text) -> Bool
|
|
isRefused = \case
|
|
Left (WebhookError _) -> True
|
|
_ -> False
|
|
|
|
isStale :: Either WebhookError (Maybe Text) -> Bool
|
|
isStale = \case
|
|
Left (WebhookStale _) -> True
|
|
_ -> False
|