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

795 lines
36 KiB
Haskell

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Bots.BadgeService.BTCPayTests (badgeBTCPayTests) where
import BadgeService.Config (BTCPayConfig (..))
import BadgeService.Providers
( Funded (..),
ListPass (..),
OrderDraft (..),
PaymentSignal (..),
Provider (..),
ProviderError (..),
ProviderInvoice (..),
Received (..),
WebhookError (..),
settleWindow,
)
import BadgeService.Providers.BTCPay (btcMethodId, btcpayProvider, listSignals, minorToDecimal, paymentMethodsSignal, verifyBTCPaySig, xmrMethodId)
import Bots.BadgeService.FakeBTCPay
import Control.Exception (evaluate)
import Control.Monad (join)
import Data.Aeson ((.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString.Char8 as B8
import qualified Data.ByteString.Lazy as LB
import Data.Char (toUpper)
import Data.Foldable (toList)
import Data.Scientific (Scientific, scientific)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (UTCTime, getCurrentTime)
import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
import Network.HTTP.Types.Header (Header, hAuthorization)
import Simplex.Chat.PaymentService.Types
( CryptoCurrency (..),
CurrencyAmount (..),
ServicePaymentDestination (..),
ServicePaymentMethod (..),
)
import System.Timeout (timeout)
import Test.Hspec
import Text.Read (readMaybe)
badgeBTCPayTests :: Spec
badgeBTCPayTests = describe "badge btcpay adapter" $ do
describe "minor units to the provider's decimal string" $
it "pins the eight values a wrong pad would charge wrongly" testMinorToDecimal
describe "the status table" $ do
it "settles on Settled, timed by the latest Settled payment" testSettled
it "falls back to the read time when a Settled invoice was marked by hand" testSettledManuallyMarked
it "funds on Processing" testProcessing
it "closes on Expired and on Invalid" testClosed
it "funds on New with something received" testNewWithPayment
it "funds on New with a payment worth less than a minor unit" testNewWithDust
it "reports no change on New with nothing received" testNewWithNothing
it "refuses a decimal whose exponent would build a billion-digit number" testAbsurdExponentIsRefused
it "clamps a figure too wrong to charge, rather than wrapping it" testAbsurdAmountIsClamped
it "refuses the same exponent sent as a JSON number, without formatting it" testAbsurdExponentAsANumberIsRefused
it "accepts a rate written at full decimal scale" testLongRateIsAccepted
it "names an unknown status rather than reporting no change" testUnknownStatus
it "does not know Complete, which is the legacy invoice API's name" testNoCompleteStatus
it "names the payment methods it found when none is one of ours" testUnknownPaymentMethod
it "names a response it cannot read" testUnreadableResponse
describe "amounts" $ do
it "reads paymentMethodPaid and never totalPaid" testReadsPaymentMethodPaid
it "keeps the crypto amount exactly as the provider wrote it" testCryptoAmountVerbatim
it "multiplies the decimals exactly, where a Double would be a cent out" testExactDecimalMultiplication
it "reads a numeric field sent as a JSON number as well as a string" testNumberAsWellAsString
describe "the list pass" $ do
it "skips an invoice whose status it does not know, and settles the rest" testListSkipsUnknownStatus
it "skips an invoice it cannot parse, and settles the rest" testListSkipsUnparseable
it "skips an invoice paid by a method it does not know, and settles the rest" testListSkipsUnknownMethod
it "fails the pass when the payment methods are absent, rather than reporting health" testListFailsWithoutPaymentMethods
it "skips an invoice it cannot even name, unaccounted for" testListSkipsWithoutId
it "fails the pass on a response it cannot read" testListFailsOnUnreadableBody
describe "BTCPay-Sig" $ do
it "accepts the indented bytes as received, for each acted-on type" testWebhookAccepts
it "finds the header whatever its case" testWebhookHeaderCase
it "accepts an uppercase hex signature" testWebhookUppercaseHex
it "ignores a valid signature over a type this service does not act on" testWebhookIgnoresOtherType
it "ignores a valid signature over a payload with no event in it" testWebhookIgnoresUnreadableBody
it "rejects the same body signed with a different secret" testWebhookWrongSecret
it "rejects a re-serialised body, whose bytes are not the ones signed" testWebhookReserialised
it "rejects a missing, unprefixed or non-hex signature" testWebhookMalformed
describe "against the fake Greenfield" $ do
it "creates a bitcoin invoice and reads back where to pay" testFakeCreatesBtc
it "creates a monero invoice and reads back where to pay" testFakeCreatesXmr
it "sends the api key as `token <key>` on every call it makes" testFakeSendsApiKey
it "is refused when the api key is wrong, rather than passing silently" testFakeWrongApiKey
it "sends the documented create body, and no redirectURL" testFakeCreateBody
it "walks New to Processing to Settled, two calls per read" testFakeLifecycle
it "closes on Expired and on Invalid" testFakeClosed
it "names a status the provider invented" testFakeUnknownStatus
it "reads paymentMethodPaid over the wire, never totalPaid" testFakePaymentMethodPaid
it "makes a 500 at checkout a ProviderError" testFakeCreate500
it "abandons an invoice it created but could not learn the address of" testFakeCreatePostFailure
it "names the payment method a created invoice does not offer" testFakeCreateWrongMethod
it "names a payment method with no destination, and writes nothing" testFakeCreateNoDestination
it "names a payment method with no amount, and writes nothing" testFakeCreateNoAmount
it "refuses an answer past the size it will hold, and reads again after" testFakeOversizeAnswer
it "makes a 500 on a read a ProviderError the next read recovers from" testFakeRead500
it "verifies a webhook with the secret it was configured with" testFakeWebhookSecretWiring
it "keeps totalPaid distinguishable in every fixture an amount is asserted from" testFixturesGuardTotalPaid
it "keeps due distinguishable from networkFee, so the wrong field cannot be read" testFixturesGuardDue
describe "the list pass against the fake Greenfield" $ do
it "returns the invoices whose state moved and omits the one that did not" testFakeListMoved
it "bounds the request at 72 hours plus one invoice window" testFakeListWindow
it "skips an invoice whose status it does not know, and settles the rest" testFakeListAlienStatus
it "skips an invoice paid by a method it does not know, and settles the rest" testFakeListAlienMethod
it "fails the pass when the payment methods are absent" testFakeListNoPaymentMethods
it "skips an invoice it cannot even name, unaccounted for" testFakeListNoId
testMinorToDecimal :: IO ()
testMinorToDecimal =
map (minorToDecimal . CurrencyAmount) [0, 5, 99, 100, 700, 4200, 42000, 100000000]
`shouldBe` ["0.00", "0.05", "0.99", "1.00", "7.00", "42.00", "420.00", "1000000.00"]
readTime :: UTCTime
readTime = posixSecondsToUTCTime 1800000000
ref :: Text
ref = "GREENFIELDINVOICEID"
btcPaid :: Text -> [J.Value] -> J.Value
btcPaid paid payments =
J.object
[ "paymentMethodId" .= btcMethodId,
"destination" .= ("bc1qexampleaddress" :: Text),
"amount" .= ("0.00050000" :: Text),
"rate" .= ("108000.12" :: Text),
"paymentMethodPaid" .= paid,
"totalPaid" .= ("9.99999999" :: Text),
"due" .= dueAfter paid,
"networkFee" .= ("0.00000500" :: Text),
"payments" .= payments
]
-- | The due each payment figure leaves owed on a 0.00050000 invoice. The dust case does not use
-- the difference between amount and paid, because a partial payment adds a network fee, so a body
-- computed by subtraction would also pass an adapter that subtracted instead of reading the field.
dueAfter :: Text -> Text
dueAfter = \case
"0.00050000" -> "0.00000000"
"0.00000001" -> "0.00050499"
_ -> "0.00050000"
payment :: Text -> Integer -> J.Value
payment status receivedDate =
J.object
[ "id" .= ("txid" <> T.pack (show receivedDate)),
"status" .= status,
"receivedDate" .= receivedDate,
"value" .= ("0.00025000" :: Text),
"fee" .= ("0.00000100" :: Text)
]
methodsBody :: [J.Value] -> LB.ByteString
methodsBody = J.encode
signalOf :: Text -> [J.Value] -> Either ProviderError (Maybe PaymentSignal)
signalOf status methods = paymentMethodsSignal readTime ref status (methodsBody methods)
halfMilliBtc :: Received
halfMilliBtc = Received {rcvAmount = CurrencyAmount 5400, rcvCrypto = Just "0.00050000", rcvDue = Just "0.00000000"}
nothingReceived :: Text -> Received
nothingReceived due = Received {rcvAmount = CurrencyAmount 0, rcvCrypto = Nothing, rcvDue = Just due}
testSettled :: IO ()
testSettled =
signalOf "Settled" [btcPaid "0.00050000" [payment "Settled" 1700003600, payment "Settled" 1700000000, payment "Processing" 1700007200]]
`shouldBe` Right (Just (SigSettled halfMilliBtc (posixSecondsToUTCTime 1700003600)))
testSettledManuallyMarked :: IO ()
testSettledManuallyMarked =
signalOf "Settled" [btcPaid "0.00050000" []]
`shouldBe` Right (Just (SigSettled halfMilliBtc readTime))
testProcessing :: IO ()
testProcessing =
signalOf "Processing" [btcPaid "0.00050000" [payment "Processing" 1700000000]]
`shouldBe` Right (Just (SigFunded halfMilliBtc PaidInFull))
testClosed :: IO ()
testClosed = do
signalOf "Expired" [btcPaid "0.00050000" [payment "Processing" 1700000000]]
`shouldBe` Right (Just (SigClosed halfMilliBtc))
signalOf "Invalid" [btcPaid "0.00000000" []]
`shouldBe` Right (Just (SigClosed (nothingReceived "0.00050000")))
testNewWithPayment :: IO ()
testNewWithPayment =
signalOf "New" [btcPaid "0.00050000" [payment "Processing" 1700000000]]
`shouldBe` Right (Just (SigFunded halfMilliBtc PaidInPart))
testNewWithDust :: IO ()
testNewWithDust =
signalOf "New" [btcPaid "0.00000001" [payment "Processing" 1700000000]]
`shouldBe` Right (Just (SigFunded Received {rcvAmount = CurrencyAmount 0, rcvCrypto = Just "0.00000001", rcvDue = Just "0.00050499"} PaidInPart))
testNewWithNothing :: IO ()
testNewWithNothing = signalOf "New" [btcPaid "0.00000000" []] `shouldBe` Right Nothing
-- | A figure with an absurd exponent parses as a Scientific and then asks for a number with that
-- many digits, which never returns. The test bounds the call with a timeout, since the guard must
-- refuse it before anything rounds or formats it.
testAbsurdExponentIsRefused :: IO ()
testAbsurdExponentIsRefused = do
answered <- timeout 5000000 (evaluate (signalOf "Settled" [btcPaid "1e1000000000" []]))
case answered of
Nothing -> expectationFailure "a decimal with a billion-digit exponent was not refused"
Just r -> r `shouldSatisfy` namesInError "exponent out of range"
-- | BTCPay sends `receivedDate` as a bare JSON number, so the same absurd figure must be refused
-- in that form too, and the refusal must not format it on the way out or the guard rebuilds it.
testAbsurdExponentAsANumberIsRefused :: IO ()
testAbsurdExponentAsANumberIsRefused = do
let huge = J.Number (scientific 1 1000000000)
method =
J.object
[ "paymentMethodId" .= btcMethodId,
"destination" .= ("bc1qexampleaddress" :: Text),
"amount" .= huge,
"rate" .= ("108000.12" :: Text),
"paymentMethodPaid" .= ("0.00050000" :: Text),
"due" .= ("0.00000000" :: Text),
"networkFee" .= ("0.00000500" :: Text),
"payments" .= ([] :: [J.Value])
]
answered <- timeout 5000000 (evaluate (signalOf "Settled" [method]))
case answered of
Nothing -> expectationFailure "a JSON number with a billion-digit exponent was not refused"
Just r -> r `shouldSatisfy` namesInError "exponent out of range"
testLongRateIsAccepted :: IO ()
testLongRateIsAccepted =
signalOf "Settled" [longRate] `shouldSatisfy` \case
Right (Just SigSettled {}) -> True
_ -> False
where
longRate =
J.object
[ "paymentMethodId" .= btcMethodId,
"destination" .= ("bc1qexampleaddress" :: Text),
"amount" .= ("0.00050000" :: Text),
"rate" .= ("0.3333333333333333333333333333" :: Text),
"paymentMethodPaid" .= ("0.00050000" :: Text),
"due" .= ("0.00000000" :: Text),
"networkFee" .= ("0.00000500" :: Text),
"payments" .= ([] :: [J.Value])
]
testUnknownStatus :: IO ()
testUnknownStatus = signalOf "Frobnicated" [btcPaid "0.00050000" []] `shouldSatisfy` namesInError "Frobnicated"
testNoCompleteStatus :: IO ()
testNoCompleteStatus =
signalOf "Complete" [btcPaid "0.00050000" [payment "Settled" 1700000000]] `shouldSatisfy` namesInError "Complete"
testUnknownPaymentMethod :: IO ()
testUnknownPaymentMethod = do
let ltc = J.object ["paymentMethodId" .= ("LTC-CHAIN" :: Text), "rate" .= ("1" :: Text), "paymentMethodPaid" .= ("0" :: Text)]
signalOf "Settled" [ltc] `shouldSatisfy` namesInError "LTC-CHAIN"
signalOf "Settled" [ltc] `shouldSatisfy` namesInError btcMethodId
signalOf "Settled" [ltc] `shouldSatisfy` namesInError xmrMethodId
testUnreadableResponse :: IO ()
testUnreadableResponse =
paymentMethodsSignal readTime ref "Settled" "<html>502 Bad Gateway</html>" `shouldSatisfy` namesInError "could not read the response"
namesInError :: Text -> Either ProviderError a -> Bool
namesInError what = \case
Left (ProviderError e) -> what `T.isInfixOf` e
Right _ -> False
listEntry :: Text -> Text -> Maybe [J.Value] -> J.Value
listEntry invId status methods =
J.object $
[ "id" .= invId,
"status" .= status,
"additionalStatus" .= ("None" :: Text),
"amount" .= ("54.00" :: Text),
"currency" .= ("USD" :: Text)
]
<> maybe [] (\ms -> ["paymentMethods" .= ms]) methods
oursSettled :: J.Value
oursSettled = listEntry ref "Settled" (Just [btcPaid "0.00050000" [payment "Settled" 1700000000]])
oursSettledSignal :: (Text, PaymentSignal)
oursSettledSignal = (ref, SigSettled halfMilliBtc (posixSecondsToUTCTime 1700000000))
movedOnly :: [(Text, PaymentSignal)] -> Either ProviderError ListPass
movedOnly moved = Right ListPass {lpMoved = moved, lpSkipped = []}
skippedReason :: HasCallStack => Either ProviderError ListPass -> IO Text
skippedReason = fmap snd . skipped
skipped :: HasCallStack => Either ProviderError ListPass -> IO (Maybe Text, Text)
skipped = \case
Right ListPass {lpSkipped = [skip]} -> pure skip
other -> failWith ("expected exactly one skipped invoice, got " <> show other)
testListSkipsUnknownStatus :: IO ()
testListSkipsUnknownStatus = do
let alien = listEntry "SOMEONEELSESINVOICE" "Frobnicated" (Just [btcPaid "0.00050000" []])
r = listSignals readTime (J.encode [alien, oursSettled])
lpMoved <$> r `shouldBe` Right [oursSettledSignal]
(skippedRef, reason) <- skipped r
reason `shouldSatisfy` T.isInfixOf "Frobnicated"
reason `shouldSatisfy` T.isInfixOf "SOMEONEELSESINVOICE"
skippedRef `shouldBe` Just "SOMEONEELSESINVOICE"
-- | Each invoice is decoded on its own, not the whole page at once, because XMR payments come from
-- a BTCPay plugin and one malformed entry must not stop every other invoice settling on every pass.
testListSkipsUnparseable :: IO ()
testListSkipsUnparseable = do
let noStatus =
J.object
[ "paymentMethodId" .= btcMethodId,
"destination" .= ("bc1qexampleaddress" :: Text),
"amount" .= ("0.00050000" :: Text),
"rate" .= ("108000.12" :: Text),
"paymentMethodPaid" .= ("0.00050000" :: Text),
"due" .= ("0.00000000" :: Text),
"networkFee" .= ("0.00000500" :: Text),
"payments" .= [J.object ["receivedDate" .= (1700000000 :: Int)]]
]
broken = listEntry "MALFORMEDINVOICE" "Settled" (Just [noStatus])
r = listSignals readTime (J.encode [broken, oursSettled])
lpMoved <$> r `shouldBe` Right [oursSettledSignal]
(skippedRef, reason) <- skipped r
skippedRef `shouldBe` Just "MALFORMEDINVOICE"
reason `shouldSatisfy` T.isInfixOf "status"
testListSkipsUnknownMethod :: IO ()
testListSkipsUnknownMethod = do
let ltc =
J.object
[ "paymentMethodId" .= ("BTC-LN" :: Text),
"rate" .= ("108000.12" :: Text),
"paymentMethodPaid" .= ("0.00050000" :: Text),
"payments" .= ([] :: [J.Value])
]
alien = listEntry "SOMEONEELSESINVOICE" "Settled" (Just [ltc])
r = listSignals readTime (J.encode [alien, oursSettled])
lpMoved <$> r `shouldBe` Right [oursSettledSignal]
skippedReason r >>= (`shouldSatisfy` T.isInfixOf "BTC-LN")
testListFailsWithoutPaymentMethods :: IO ()
testListFailsWithoutPaymentMethods = do
listSignals readTime (J.encode [listEntry ref "Settled" Nothing]) `shouldSatisfy` namesInError "paymentMethods"
testListSkipsWithoutId :: IO ()
testListSkipsWithoutId = do
let noId = J.object ["status" .= ("Settled" :: Text), "paymentMethods" .= [btcPaid "0.00050000" []]]
r = listSignals readTime (J.encode [oursSettled, noId])
lpMoved <$> r `shouldBe` Right [oursSettledSignal]
(skippedRef, reason) <- skipped r
skippedRef `shouldBe` Nothing
reason `shouldSatisfy` T.isInfixOf "id"
testListFailsOnUnreadableBody :: IO ()
testListFailsOnUnreadableBody =
listSignals readTime "<html>502 Bad Gateway</html>" `shouldSatisfy` namesInError "could not read the response"
testReadsPaymentMethodPaid :: IO ()
testReadsPaymentMethodPaid = do
signalOf "Settled" [btcPaid "0.00050000" [payment "Settled" 1700000000]]
`shouldBe` Right (Just (SigSettled halfMilliBtc (posixSecondsToUTCTime 1700000000)))
signalOf "New" [btcPaid "0.00000000" []] `shouldBe` Right Nothing
testCryptoAmountVerbatim :: IO ()
testCryptoAmountVerbatim =
case signalOf "Processing" [btcPaid "0.00050000" []] of
Right (Just (SigFunded r _)) -> rcvCrypto r `shouldBe` Just "0.00050000"
other -> expectationFailure ("expected SigFunded, got " <> show other)
testExactDecimalMultiplication :: IO ()
testExactDecimalMultiplication = do
let atRateOne paid =
J.object
[ "paymentMethodId" .= btcMethodId,
"rate" .= ("1" :: Text),
"paymentMethodPaid" .= (paid :: Text),
"payments" .= ([] :: [J.Value])
]
case signalOf "Processing" [atRateOne "0.545"] of
Right (Just (SigFunded r _)) -> rcvAmount r `shouldBe` CurrencyAmount 54
other -> expectationFailure ("expected SigFunded, got " <> show other)
-- The exponent gate bounds how long a figure may be, not how large, so a negative or eleven-digit
-- value still reaches the conversion. Unclamped, both wrap Word32 into a positive amount that reads
-- as money received, and the invoice then holds against every sweep and answers every cancel funded.
testAbsurdAmountIsClamped :: IO ()
testAbsurdAmountIsClamped = do
chargeFor "-1" `shouldBe` Just (CurrencyAmount 0)
chargeFor "99999999999" `shouldBe` Just (CurrencyAmount 4294967295)
where
chargeFor paid = case signalOf "Processing" [atRateOne paid] of
Right (Just (SigFunded r _)) -> Just (rcvAmount r)
_ -> Nothing
atRateOne paid =
J.object
[ "paymentMethodId" .= btcMethodId,
"rate" .= ("1" :: Text),
"paymentMethodPaid" .= (paid :: Text),
"payments" .= ([] :: [J.Value])
]
testNumberAsWellAsString :: IO ()
testNumberAsWellAsString = do
let asNumbers =
"[{\"paymentMethodId\":\"BTC-CHAIN\",\"rate\":108000.12,\"paymentMethodPaid\":0.00050000,\
\\"payments\":[{\"status\":\"Settled\",\"receivedDate\":1700000000}]}]"
case paymentMethodsSignal readTime ref "Settled" asNumbers of
Right (Just (SigSettled r t)) -> do
rcvAmount r `shouldBe` CurrencyAmount 5400
rcvCrypto r `shouldBe` Just "0.00050000"
t `shouldBe` posixSecondsToUTCTime 1700000000
other -> expectationFailure ("expected SigSettled, got " <> show other)
secret :: Text
secret = "3d8f5c6a2b1e4f7089abcdef01234567"
verifyEvent :: [Header] -> LB.ByteString -> Either WebhookError (Maybe Text)
verifyEvent hdrs body = verifyBTCPaySig secret hdrs (LB.toStrict body)
testWebhookAccepts :: IO ()
testWebhookAccepts =
mapM_
(\t -> let b = webhookEvent t ref in verifyEvent (webhookSigHeader secret b) b `shouldBe` Right (Just ref))
["InvoiceProcessing", "InvoiceSettled", "InvoiceExpired", "InvoiceInvalid"]
testWebhookHeaderCase :: IO ()
testWebhookHeaderCase = do
let b = webhookEvent "InvoiceSettled" ref
verifyEvent [("btcpay-sig", "sha256=" <> webhookHexSig secret b)] b `shouldBe` Right (Just ref)
testWebhookUppercaseHex :: IO ()
testWebhookUppercaseHex = do
let b = webhookEvent "InvoiceSettled" ref
verifyEvent [("BTCPay-Sig", "sha256=" <> B8.map toUpper (webhookHexSig secret b))] b `shouldBe` Right (Just ref)
testWebhookIgnoresOtherType :: IO ()
testWebhookIgnoresOtherType = do
let b = webhookEvent "InvoiceReceivedPayment" ref
verifyEvent (webhookSigHeader secret b) b `shouldBe` Right Nothing
testWebhookIgnoresUnreadableBody :: IO ()
testWebhookIgnoresUnreadableBody = do
let b = "{\n \"type\": \"InvoiceSettled\"\n}\n"
verifyEvent (webhookSigHeader secret b) b `shouldBe` Right Nothing
testWebhookWrongSecret :: IO ()
testWebhookWrongSecret = do
let b = webhookEvent "InvoiceSettled" ref
verifyEvent (webhookSigHeader "3d8f5c6a2b1e4f7089abcdef01234568" b) b `shouldSatisfy` isRefused
testWebhookReserialised :: IO ()
testWebhookReserialised = do
let b = webhookEvent "InvoiceSettled" ref
reserialised = maybe "" J.encode (J.decode b :: Maybe J.Value)
reserialised `shouldNotBe` b
verifyEvent (webhookSigHeader secret b) reserialised `shouldSatisfy` isRefused
testWebhookMalformed :: IO ()
testWebhookMalformed = do
let b = webhookEvent "InvoiceSettled" ref
verifyEvent [] b `shouldSatisfy` isRefused
verifyEvent [("BTCPay-Sig", "")] b `shouldSatisfy` isRefused
verifyEvent [("BTCPay-Sig", webhookHexSig secret b)] b `shouldSatisfy` isRefused
verifyEvent [("BTCPay-Sig", "sha512=" <> webhookHexSig secret b)] b `shouldSatisfy` isRefused
verifyEvent [("BTCPay-Sig", "sha256=not hex at all")] b `shouldSatisfy` isRefused
verifyEvent [("BTCPay-Sig", "sha256=")] b `shouldSatisfy` isRefused
isRefused :: Either WebhookError (Maybe Text) -> Bool
isRefused = \case
Left (WebhookError _) -> True
_ -> False
btcAddress, xmrAddress :: Text
btcAddress = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
xmrAddress = "44AFFq5kSiGBoZ4NMDwYtN18obc8AemS33DBLWs3H7otXft3XjrpDtQGv7SqSsaBYBb98uNbr2VBBEt7f2wfn3RVGQBEP3A"
fixtureSettledAt :: UTCTime
fixtureSettledAt = posixSecondsToUTCTime 1700003600
listSettledRef, listProcessingRef :: Text
listSettledRef = "SettledInvoiceRefAAAA"
listProcessingRef = "ProcessingInvoiceRefB"
xmrReceived :: Received
xmrReceived = Received {rcvAmount = CurrencyAmount 5400, rcvCrypto = Just "0.32095000", rcvDue = Just "0.00000000"}
fiftyFourDollars :: OrderDraft
fiftyFourDollars =
OrderDraft
{ odAmount = CurrencyAmount 5400,
odCurrency = "usd"
}
exampleCeiling :: Int
exampleCeiling = 20000000
failWith :: HasCallStack => String -> IO a
failWith msg = expectationFailure msg >> error msg
withProvider :: HasCallStack => (FakeBTCPay -> Provider -> IO a) -> IO a
withProvider = withProviderConfigured id
withProviderConfigured :: HasCallStack => (BTCPayConfig -> BTCPayConfig) -> (FakeBTCPay -> Provider -> IO a) -> IO a
withProviderConfigured configure action =
bounded $ withFakeBTCPay $ \fake -> btcpayProvider (configure (fbConfig fake)) >>= action fake
where
bounded act = timeout exampleCeiling act >>= maybe (failWith "the fake greenfield 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)
testFakeCreatesBtc :: IO ()
testFakeCreatesBtc = withProvider $ \fake p -> do
ProviderInvoice {piProviderRef, piDestination} <- createdInvoice p (SPMCrypto CCBtc)
fakeInvoiceIds fake `shouldReturn` [piProviderRef]
piDestination `shouldBe` SPDCrypto CCBtc btcAddress "0.00050000"
ms <- apiRequests fake "GET" ["invoices", piProviderRef, "payment-methods"]
length ms `shouldBe` 1
testFakeCreatesXmr :: IO ()
testFakeCreatesXmr = withProvider $ \fake p -> do
ProviderInvoice {piProviderRef, piDestination} <- createdInvoice p (SPMCrypto CCXmr)
piDestination `shouldBe` SPDCrypto CCXmr xmrAddress "0.32095000"
posts <- apiRequests fake "POST" ["invoices"]
case posts of
[created] -> chosenPaymentMethods (frBody created) `shouldBe` Just ["XMR-CHAIN"]
_ -> expectationFailure ("expected one create, got " <> show (length posts))
fakeInvoiceIds fake `shouldReturn` [piProviderRef]
chosenPaymentMethods :: LB.ByteString -> Maybe [Text]
chosenPaymentMethods body = case J.decode body of
Just (J.Object o) | Just (J.Object c) <- KM.lookup "checkout" o -> case KM.lookup "paymentMethods" c of
Just v -> case J.fromJSON v of
J.Success ms -> Just ms
J.Error _ -> Nothing
Nothing -> Nothing
_ -> Nothing
testFakeSendsApiKey :: IO ()
testFakeSendsApiKey = withProvider $ \fake p -> do
_ <- createdInvoice p (SPMCrypto CCBtc)
_ <- pListOpen p
rs <- fakeRequests fake
length rs `shouldBe` 4
map (lookup hAuthorization . frHeaders) rs
`shouldBe` replicate 4 (Just ("token " <> TE.encodeUtf8 fakeApiKey))
testFakeWrongApiKey :: IO ()
testFakeWrongApiKey = withProviderConfigured (\c -> c {bApiKey = "not-the-api-key"}) $ \fake p -> do
r <- pCreateInvoice p (SPMCrypto CCBtc) fiftyFourDollars
r `shouldSatisfy` namesInError "401"
rd <- pReadInvoice p "FakeInvoiceRef0001"
rd `shouldSatisfy` namesInError "401"
rs <- fakeRequests fake
length rs `shouldBe` 3
fakeInvoiceIds fake `shouldReturn` []
testFakeCreateBody :: IO ()
testFakeCreateBody = withProvider $ \fake p -> do
_ <- createdInvoice p (SPMCrypto CCBtc)
posts <- apiRequests fake "POST" ["invoices"]
case posts of
[created] ->
J.decode (frBody created)
`shouldBe` Just
( J.object
[ "amount" .= ("54.00" :: Text),
"currency" .= ("USD" :: Text),
"checkout"
.= J.object
[ "expirationMinutes" .= fakeExpiryMinutes,
"speedPolicy" .= ("MediumSpeed" :: Text),
"paymentTolerance" .= (0.5 :: Double),
"paymentMethods" .= ([btcMethodId] :: [Text])
]
]
)
_ -> expectationFailure ("expected one create, got " <> show (length posts))
testFakeLifecycle :: IO ()
testFakeLifecycle = withProvider $ \fake p -> do
ProviderInvoice {piProviderRef = invRef} <- createdInvoice p (SPMCrypto CCBtc)
pReadInvoice p invRef `shouldReturn` Right Nothing
setInvoiceState fake invRef ["paymentMethodPaid" .= ("0.00025000" :: Text)]
pReadInvoice p invRef
`shouldReturn` Right (Just (SigFunded Received {rcvAmount = CurrencyAmount 2700, rcvCrypto = Just "0.00025000", rcvDue = Just "0.00000000"} PaidInPart))
setInvoiceState fake invRef ["status" .= ("Processing" :: Text), "additionalStatus" .= ("PaidPartial" :: Text)]
pReadInvoice p invRef
`shouldReturn` Right (Just (SigFunded Received {rcvAmount = CurrencyAmount 2700, rcvCrypto = Just "0.00025000", rcvDue = Just "0.00000000"} PaidInFull))
setInvoiceState fake invRef ["status" .= ("Settled" :: Text), "paymentMethodPaid" .= ("0.00050000" :: Text)]
pReadInvoice p invRef `shouldReturn` Right (Just (SigSettled halfMilliBtc fixtureSettledAt))
details <- apiRequests fake "GET" ["invoices", invRef]
methods <- apiRequests fake "GET" ["invoices", invRef, "payment-methods"]
(length details, length methods) `shouldBe` (4, 5)
testFakeClosed :: IO ()
testFakeClosed = withProvider $ \fake p -> do
ProviderInvoice {piProviderRef = invRef} <- createdInvoice p (SPMCrypto CCBtc)
setInvoiceState fake invRef ["status" .= ("Expired" :: Text), "paymentMethodPaid" .= ("0.00050000" :: Text)]
pReadInvoice p invRef `shouldReturn` Right (Just (SigClosed halfMilliBtc))
setInvoiceState fake invRef ["status" .= ("Invalid" :: Text), "paymentMethodPaid" .= ("0.00000000" :: Text)]
pReadInvoice p invRef `shouldReturn` Right (Just (SigClosed (nothingReceived "0.00050000")))
testFakeUnknownStatus :: IO ()
testFakeUnknownStatus = withProvider $ \fake p -> do
ProviderInvoice {piProviderRef = invRef} <- createdInvoice p (SPMCrypto CCBtc)
setInvoiceState fake invRef ["status" .= ("Frobnicated" :: Text)]
r <- pReadInvoice p invRef
r `shouldSatisfy` namesInError "Frobnicated"
testFakePaymentMethodPaid :: IO ()
testFakePaymentMethodPaid = withProvider $ \fake p -> do
totalPaidStaysDistinguishable "payment-methods-btc"
ProviderInvoice {piProviderRef = invRef} <- createdInvoice p (SPMCrypto CCBtc)
setInvoiceState fake invRef ["status" .= ("Settled" :: Text), "paymentMethodPaid" .= ("0.00050000" :: Text)]
pReadInvoice p invRef `shouldReturn` Right (Just (SigSettled halfMilliBtc fixtureSettledAt))
testFakeCreate500 :: IO ()
testFakeCreate500 = withProvider $ \fake p -> do
failNextCalls fake 1 500
r <- pCreateInvoice p (SPMCrypto CCBtc) fiftyFourDollars
r `shouldSatisfy` namesInError "500"
fakeInvoiceIds fake `shouldReturn` []
testFakeCreatePostFailure :: IO ()
testFakeCreatePostFailure = withProvider $ \fake p -> do
failAfterCalls 1 fake 1 500
r <- pCreateInvoice p (SPMCrypto CCBtc) fiftyFourDollars
r `shouldSatisfy` namesInError "500"
ids <- fakeInvoiceIds fake
length ids `shouldBe` 1
r `shouldSatisfy` \case Right _ -> False; Left _ -> True
testFakeCreateWrongMethod :: IO ()
testFakeCreateWrongMethod = withProvider $ \fake p -> do
usePaymentMethodsFixture fake "payment-methods-xmr"
r <- pCreateInvoice p (SPMCrypto CCBtc) fiftyFourDollars
r `shouldSatisfy` namesInError "offers no BTC-CHAIN"
r `shouldSatisfy` namesInError "XMR-CHAIN"
testFakeCreateNoDestination :: IO ()
testFakeCreateNoDestination = withProvider $ \fake p -> do
usePaymentMethodsFixture fake "payment-methods-no-destination"
r <- pCreateInvoice p (SPMCrypto CCBtc) fiftyFourDollars
r `shouldSatisfy` namesInError "has no destination"
testFakeCreateNoAmount :: IO ()
testFakeCreateNoAmount = withProvider $ \fake p -> do
usePaymentMethodsFixture fake "payment-methods-no-amount"
r <- pCreateInvoice p (SPMCrypto CCBtc) fiftyFourDollars
r `shouldSatisfy` namesInError "has no amount"
testFakeOversizeAnswer :: IO ()
testFakeOversizeAnswer = withProvider $ \fake p -> do
ProviderInvoice {piProviderRef = invRef} <- createdInvoice p (SPMCrypto CCBtc)
answerOversize fake True
pReadInvoice p invRef >>= (`shouldSatisfy` namesInError "over 10485760 bytes")
answerOversize fake False
pReadInvoice p invRef >>= \case
Right _ -> pure ()
Left e -> expectationFailure ("the next read must recover, and got " <> show e)
testFakeRead500 :: IO ()
testFakeRead500 = withProvider $ \fake p -> do
ProviderInvoice {piProviderRef = invRef} <- createdInvoice p (SPMCrypto CCBtc)
setInvoiceState fake invRef ["status" .= ("Settled" :: Text), "paymentMethodPaid" .= ("0.00050000" :: Text)]
failNextCalls fake 1 500
r <- pReadInvoice p invRef
r `shouldSatisfy` namesInError "500"
pReadInvoice p invRef `shouldReturn` Right (Just (SigSettled halfMilliBtc fixtureSettledAt))
testFakeWebhookSecretWiring :: IO ()
testFakeWebhookSecretWiring = withProvider $ \fake p -> do
let configured = bWebhookSecret (fbConfig fake)
b = webhookEvent "InvoiceSettled" ref
pVerifyWebhook p readTime (webhookSigHeader configured b) (LB.toStrict b) `shouldBe` Right (Just ref)
pVerifyWebhook p readTime (webhookSigHeader (configured <> "0") b) (LB.toStrict b) `shouldSatisfy` isRefused
-- | An amount assertion only proves the adapter reads paymentMethodPaid rather than totalPaid while
-- the two differ in the fixture, so this checks that directly.
totalPaidStaysDistinguishable :: HasCallStack => Text -> IO ()
totalPaidStaysDistinguishable name = do
v <- fixtureResponse name
case paymentMethodsIn v of
[] -> expectationFailure (T.unpack name <> " carries no payment method")
ms -> mapM_ distinguishable ms
where
distinguishable m
| paidNothing m = pure ()
| otherwise = KM.lookup "totalPaid" m `shouldNotBe` KM.lookup "paymentMethodPaid" m
paidNothing m = case KM.lookup "paymentMethodPaid" m of
Just (J.String t) -> (J.decodeStrict (TE.encodeUtf8 t) :: Maybe Scientific) == Just 0
_ -> False
paymentMethodsIn :: J.Value -> [J.Object]
paymentMethodsIn = \case
J.Object o -> [o | KM.member "paymentMethodPaid" o] <> concatMap paymentMethodsIn (KM.elems o)
J.Array vs -> concatMap paymentMethodsIn (toList vs)
_ -> []
testFixturesGuardTotalPaid :: IO ()
testFixturesGuardTotalPaid =
mapM_
totalPaidStaysDistinguishable
["payment-methods-btc", "payment-methods-xmr", "invoice-list", "invoice-list-alien-status", "invoice-list-alien-method"]
-- | With due equal to networkFee a fixture cannot tell which field the adapter reads, and
-- reading the fee would report a part-paid invoice as covered.
dueStaysDistinguishable :: HasCallStack => Text -> IO ()
dueStaysDistinguishable name = do
v <- fixtureResponse name
case paymentMethodsIn v of
[] -> expectationFailure (T.unpack name <> " carries no payment method")
ms -> mapM_ distinguishable ms
where
distinguishable m = case (KM.lookup "due" m, KM.lookup "networkFee" m) of
(Just d, Just f) -> d `shouldNotBe` f
_ -> pure ()
testFixturesGuardDue :: IO ()
testFixturesGuardDue =
mapM_
dueStaysDistinguishable
["payment-methods-btc", "payment-methods-xmr", "invoice-list", "invoice-list-alien-status", "invoice-list-alien-method"]
testFakeListMoved :: IO ()
testFakeListMoved = withProvider $ \_ p ->
pListOpen p
`shouldReturn` movedOnly
[ (listSettledRef, SigSettled halfMilliBtc fixtureSettledAt),
(listProcessingRef, SigFunded xmrReceived PaidInFull)
]
testFakeListWindow :: IO ()
testFakeListWindow = withProvider $ \fake p -> do
askedAt <- getCurrentTime
_ <- pListOpen p
answeredAt <- getCurrentTime
gets <- apiRequests fake "GET" ["invoices"]
case gets of
[listed] -> do
lookup "includePaymentMethods" (frQuery listed) `shouldBe` Just (Just "true")
case join (lookup "startDate" (frQuery listed)) >>= readMaybe . B8.unpack of
Nothing -> expectationFailure ("no readable startDate in " <> show (frQuery listed))
Just sent -> do
let window = truncate settleWindow + 60 * toInteger fakeExpiryMinutes
seconds t = floor (utcTimeToPOSIXSeconds t) :: Integer
sent `shouldSatisfy` \s -> s >= seconds askedAt - window && s <= seconds answeredAt - window
_ -> expectationFailure ("expected one list request, got " <> show (length gets))
testFakeListAlienStatus :: IO ()
testFakeListAlienStatus = withProvider $ \fake p -> do
useListFixture fake "invoice-list-alien-status"
r <- pListOpen p
lpMoved <$> r `shouldBe` Right [(listSettledRef, SigSettled halfMilliBtc fixtureSettledAt)]
skippedReason r >>= (`shouldSatisfy` T.isInfixOf "Frobnicated")
testFakeListAlienMethod :: IO ()
testFakeListAlienMethod = withProvider $ \fake p -> do
useListFixture fake "invoice-list-alien-method"
r <- pListOpen p
lpMoved <$> r `shouldBe` Right [(listSettledRef, SigSettled halfMilliBtc fixtureSettledAt)]
skippedReason r >>= (`shouldSatisfy` T.isInfixOf "BTC-LN")
testFakeListNoPaymentMethods :: IO ()
testFakeListNoPaymentMethods = withProvider $ \fake p -> do
useListFixture fake "invoice-list-no-payment-methods"
r <- pListOpen p
r `shouldSatisfy` namesInError "paymentMethods"
testFakeListNoId :: IO ()
testFakeListNoId = withProvider $ \fake p -> do
useListFixture fake "invoice-list-no-id"
r <- pListOpen p
(skippedRef, reason) <- skipped r
skippedRef `shouldBe` Nothing
reason `shouldSatisfy` T.isInfixOf "id"