badges: add the managed group

This commit is contained in:
shum committed 2026-09-28 15:47:40 +00:00
1 parent f0dc9c06b7
commit b078a382db
18 files changed
+2647 -203

No files matched your search

+67 -27
View File
@@ -12,6 +12,7 @@ module Bots.BadgeService.BotTests where
import BadgeService.Codes (issueOneCode)
import BadgeService.Config (BadgeIssuerKey (..), readServiceConfig)
import BadgeService.Group (GroupEvent)
import Bots.BadgeService.ConfigTests (withIssuer)
import BadgeService.Options
import BadgeService.Service
@@ -23,7 +24,7 @@ import ChatClient
import ChatTests.DBUtils
import ChatTests.Utils
import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Concurrent.STM (atomically, readTMVar)
import Control.Concurrent.STM (TQueue, atomically, readTMVar)
import Control.Monad (forM_, void, when)
import Control.Exception (finally)
import qualified Data.Aeson as J
@@ -47,7 +48,7 @@ import Simplex.Chat.Badges.Ledger (addMonths, creditTypeTag, debitTypeTag, endOf
import Simplex.Chat.Badges.Service
import Simplex.Chat.Badges.Types (BadgeCodePaymentStatus (..))
import Simplex.Chat.Bot.Store (withDB')
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatResponse (CRCustomChatResponse))
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), ChatError (..), ChatErrorType (..), ChatResponse (CRCustomChatResponse))
import Simplex.Chat.Core (sendChatCmdStr)
import Simplex.Chat.Options (CoreChatOpts (..))
import Simplex.Chat.Options.DB
@@ -79,16 +80,18 @@ badgeServiceTests = do
it "should refuse a code that has not been paid for" testRedeemUnpaidCode
it "should refuse a badge code past its redemption deadline" testExpiredCode
it "should keep answering a code redeemed before its deadline" testRedeemedBeforeTheDeadline
it "should refuse a revoked badge code, and refuse to revoke it twice" testRevokedCode
it "should refuse a revoked badge code, and report a second revoke as already revoked" testRevokedCode
it "should refuse to revoke a code that was redeemed, and keep its badge" testRevokeRedeemedCode
it "should answer revoking an unknown code as no such code" testRevokeUnknownCode
it "should answer code_invalid to a code that is both unpaid and revoked" testRevokedUnpaidCode
it "should refuse a revoke with trailing input, leaving the code live" testRevokeRejectsTrailingInput
it "should refuse to issue a code with an unknown badge type or a nonsense month count" testIssueRejectsBadArguments
it "should refuse a request whose purchaseKey is not the verified signer" testPurchaseKeyMismatch
it "should refuse to start unless the issuer secret is the key trusted at its index" testIssuerKeyMustMatchConfig
it "should refuse to start when the [issuer] key is not one clients trust" testIssuerIniKeyMustBeTrusted
it "should credit a code's months and issue one credential per month" testCodeMonthsRenew
it "should return the stored credential for a repeat inside an issued period" testRepeatInsideIssuedPeriod
it "should redeem a multi-use code to its limit" testMultiUseWithoutGroup
it "should redeem a multi-use code to its limit with no group to track it" testMultiUseWithoutGroup
it "should not spend a multi-use code again when its holder redeems it after the badge ended" testMultiUseRepeatAfterExpiry
it "should answer internal, spending no use, when a holder's stored credential is unreadable" testUnreadableCredentialIsInternal
it "should lapse only the months that elapsed while the client was away" testLapseWhileAway
@@ -115,8 +118,11 @@ badgeServiceTests = do
it "should broadcast the current profile when a renewal presents a badge" testRenewalKeepsProfileEdits
it "should present the month already issued when a previous pass did not" testPresentationCatchesUp
badgeBotName :: Text
badgeBotName = "SimpleX Badges"
badgeProfile :: Profile
badgeProfile = Profile {displayName = "SimpleX Badges", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
badgeProfile = Profile {displayName = badgeBotName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
serviceDbPrefix :: FilePath
serviceDbPrefix = "badge_service"
@@ -137,7 +143,7 @@ mkBadgeServiceOpts TestParams {tmpPath = ps} secretKey =
{dbFilePrefix = ps </> serviceDbPrefix}
#endif
},
serviceName = "SimpleX Badges",
serviceName = badgeBotName,
clientService = True,
noAddress = False,
runCLI = False,
@@ -202,11 +208,15 @@ withBadgeServiceEnv ps test = do
issueCode :: HasCallStack => ChatController -> BadgeType -> Int -> IO BadgeCode
issueCode cc badgeType months = issueCodeAs cc badgeType months "free"
revokeCodeAs :: HasCallStack => ChatController -> BadgeCode -> IO T.Text
revokeCodeAs cc code =
sendChatCmdStr cc ("//revoke " <> T.unpack (formatBadgeCode code)) >>= \case
Right (CRCustomChatResponse _ response) -> pure response
Left e -> pure (T.pack (show e))
revokeCodeAs :: HasCallStack => ChatController -> BadgeCode -> IO (Either T.Text T.Text)
revokeCodeAs cc code = revokeRaw cc (formatBadgeCode code)
-- | Left is a refusal or a failure the service answers as a command error.
revokeRaw :: HasCallStack => ChatController -> T.Text -> IO (Either T.Text T.Text)
revokeRaw cc args =
sendChatCmdStr cc ("//revoke " <> T.unpack args) >>= \case
Right (CRCustomChatResponse _ response) -> pure (Right response)
Left (ChatError (CECommandError e)) -> pure (Left (T.pack e))
r -> error $ "revoke failed: " <> show (() <$ r)
issueCodeAs :: HasCallStack => ChatController -> BadgeType -> Int -> String -> IO BadgeCode
@@ -323,11 +333,13 @@ testIssueRejectsBadArguments ps =
refuses ""
issueRaw cc "supporter 255 paid" >>= (`shouldSatisfy` isRight)
issueRaw :: ChatController -> String -> IO (Either () ())
-- | Left is a refusal or a failure the service answers as a command error.
issueRaw :: HasCallStack => ChatController -> String -> IO (Either T.Text ())
issueRaw cc args =
sendChatCmdStr cc ("//issue " <> args) >>= \case
Right CRCustomChatResponse {} -> pure $ Right ()
_ -> pure $ Left ()
Left (ChatError (CECommandError e)) -> pure (Left (T.pack e))
r -> error $ "issue failed: " <> show (() <$ r)
testRedeemSecondCode :: HasCallStack => TestParams -> IO ()
testRedeemSecondCode ps =
@@ -369,12 +381,16 @@ testRedeemSameCodeOtherProfile ps =
showActiveUser alice "alice (Alice, * supporter)"
serviceCmd :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse
serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} purchaseKey request =
badgeServiceResponse bsIssuerKey bsController (Just purchaseKey) reqObject
where
reqObject = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of
J.Object o -> o
_ -> error "badge service request must encode as an object"
serviceCmd BadgeServiceEnv {bsIssuerKey, bsController} = serviceCmdWith bsIssuerKey bsController Nothing
serviceCmdWith :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> C.PublicKeyEd25519 -> BadgeServiceCommand -> IO BadgeServiceResponse
serviceCmdWith key cc trackerQ_ purchaseKey request =
badgeServiceResponse key cc trackerQ_ (Just purchaseKey) (requestObject purchaseKey request)
requestObject :: HasCallStack => C.PublicKeyEd25519 -> BadgeServiceCommand -> J.Object
requestObject purchaseKey request = case J.toJSON BadgeServiceRequest {version = currentBadgeServiceVersion, purchaseKey = Just purchaseKey, request} of
J.Object o -> o
_ -> error "badge service request must encode as an object"
entryOf :: StatementEntry -> (Int, Int, UTCTime)
entryOf StatementEntry {changeMonths, balanceMonths, balanceStartTs} = (changeMonths, balanceMonths, balanceStartTs)
@@ -418,11 +434,16 @@ newPurchaseKeys = do
(purchaseKey,) <$> generateMasterKey g
redeemAsNewPurchase :: HasCallStack => BadgeServiceEnv -> BadgeCode -> IO BadgeServiceResponse
redeemAsNewPurchase env code = newPurchaseKeys >>= \keys -> redeemWithKeys env keys code
redeemAsNewPurchase BadgeServiceEnv {bsIssuerKey, bsController} = redeemWithQueue bsIssuerKey bsController Nothing . badgeCodeText
redeemWithKeys :: HasCallStack => BadgeServiceEnv -> (C.PublicKeyEd25519, BadgeMasterKey) -> BadgeCode -> IO BadgeServiceResponse
redeemWithKeys env (purchaseKey, masterKey) code = serviceCmd env purchaseKey BSCRedeemBadgeCode {masterKey, code = badgeCodeText code}
redeemWithQueue :: HasCallStack => BadgeIssuerKey -> ChatController -> Maybe (TQueue GroupEvent) -> Text -> IO BadgeServiceResponse
redeemWithQueue key cc trackerQ_ codeText = do
(purchaseKey, masterKey) <- newPurchaseKeys
serviceCmdWith key cc trackerQ_ purchaseKey BSCRedeemBadgeCode {masterKey, code = codeText}
assertBalance :: HasCallStack => BadgeServiceEnv -> C.PublicKeyEd25519 -> StatementEntry -> IO BadgeServiceResponse
assertBalance env purchaseKey lastEntry =
serviceCmd env purchaseKey BSCIssueBadge {balance = BadgeBalance {lastEntry}}
@@ -525,7 +546,7 @@ testUnreadableCredentialIsInternal ps =
redeem holder >>= (`shouldAnswerError` BSEInternal)
codeUses cc code `shouldReturn` Just (2, 2)
-- The operator's //issue makes single-use codes only, so multi-use codes are issued directly.
-- Multi-use codes come from the group command, and this service has no group.
issueMultiUseCode :: HasCallStack => ChatController -> BadgeType -> Int -> Int -> IO BadgeCode
issueMultiUseCode cc badgeType months uses =
issueOneCode cc badgeType months CPSFree uses >>= \case
@@ -810,7 +831,7 @@ testRevokedMultiUseHolderRenews ps =
code <- issueMultiUseCode cc BTSupporter 3 2
redeemFirstBadge alice code
redeemed <- ledgerRows (chatController alice) "badge_ledger"
revokeCodeAs cc code `shouldReturn` "revoked"
revokeCodeAs cc code `shouldReturn` Right "revoked"
redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeInvalid)
codeUses cc code `shouldReturn` Just (2, 1)
-- A holder whose reply was lost retries with the same key and still gets its badge.
@@ -1497,7 +1518,7 @@ testRevokeRedeemedCode ps =
alice <## "supporter badge - active"
alice <##. "expires "
refused <- revokeCodeAs cc code
refused `shouldSatisfy` T.isInfixOf "redeemed already, so it cannot be revoked"
refused `shouldBe` Left "code was redeemed already, so it cannot be revoked"
alice ##> ("/_redeem_badge_code 1 " <> codeArg code)
alice <## "badge already redeemed"
@@ -1507,15 +1528,34 @@ testRevokeUnknownCode ps =
g <- C.newRandom
code <- randomBadgeCode g
unknown <- revokeCodeAs cc code
unknown `shouldSatisfy` T.isInfixOf "no such code"
unknown `shouldBe` Left "no such code"
testRevokedCode :: HasCallStack => TestParams -> IO ()
testRevokedCode ps =
withBadgeService ps $ \clientCfg _ cc ->
withNewTestChatCfg ps clientCfg "alice" aliceProfile $ \alice -> do
paid <- issueCodeAs cc BTSupporter 1 "paid"
revokeCodeAs cc paid `shouldReturn` "revoked"
revokeCodeAs cc paid `shouldReturn` Right "revoked"
alice ##> ("/_redeem_badge_code 1 " <> codeArg paid)
alice <## "cannot redeem badge code: badge service error: code_invalid"
second <- revokeCodeAs cc paid
second `shouldSatisfy` T.isInfixOf "revoked already"
revokeCodeAs cc paid `shouldReturn` Right "already revoked"
testRevokedUnpaidCode :: HasCallStack => TestParams -> IO ()
testRevokedUnpaidCode ps =
withBadgeServiceEnv ps $ \env@BadgeServiceEnv {bsController = cc} -> do
code <- issueCodeAs cc BTSupporter 1 "unpaid"
revokeCodeAs cc code `shouldReturn` Right "revoked"
redeemAsNewPurchase env code >>= (`shouldAnswerError` BSECodeInvalid)
testRevokeRejectsTrailingInput :: HasCallStack => TestParams -> IO ()
testRevokeRejectsTrailingInput ps =
withBadgeService ps $ \_ _ cc -> do
code <- issueCodeAs cc BTSupporter 1 "paid"
refused <- revokeRaw cc (formatBadgeCode code <> " junk")
-- If the refused call revoked the code anyway, this fails with "already revoked".
revokeCodeAs cc code `shouldReturn` Right "revoked"
refused `shouldBe` Left badgeCmdUsage
-- The text is spelled out rather than taken from the service, so a change there fails here.
badgeCmdUsage :: Text
badgeCmdUsage = "use: //issue supporter|legend|investor [months 1-255] [paid|unpaid|free], or //revoke <code>"
+68 -27
View File
@@ -36,6 +36,7 @@ badgeConfigTests = describe "badge service config" $ do
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 "ignores a section nothing reads rather than refusing the file" testUnknownSectionStillBoots
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
@@ -46,16 +47,17 @@ badgeConfigTests = describe "badge service config" $ do
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
groupConfigTests
fullIni :: T.Text
fullIni =
T.unlines
[ "[listener]",
"static_dir = /srv/badges",
-- [group] stays before [btcpay], since tests append btcpay keys to this fixture.
"[group]",
"display_name = SimpleX Badges",
"description = badge ops desk",
"[btcpay]",
"host = https://btcpay.example.org",
"api_key = token-value",
@@ -224,19 +226,25 @@ testExpiryMinutes = do
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
withIni (fullIni <> "speed_polcy = LowSpeed\ntrust_forwaded_for = on\n[poll]\nwaiting_secnds = 5\n[stripe]\nsecret_ky = rk_test_x\n") $ \p -> do
Right ini <- readIniFile p
unknownKeys ini `shouldMatchList` ["btcpay.speed_polcy", "btcpay.trust_forwaded_for", "poll.waiting_secnds", "dev.chat_redem"]
unknownKeys ini `shouldMatchList` ["btcpay.speed_polcy", "btcpay.trust_forwaded_for", "poll.waiting_secnds", "stripe.secret_ky"]
withIni (T.replace "[btcpay]" "[btcpai]" fullIni) $ \wrongSection -> do
Right sectionIni <- readIniFile wrongSection
unknownKeys sectionIni `shouldContain` ["[btcpai]"]
withIni ("chat_redeem = on\n" <> fullIni) $ \stray -> do
withIni ("static_dir = /srv/badges\n" <> fullIni) $ \stray -> do
Right strayIni <- readIniFile stray
unknownKeys strayIni `shouldContain` ["chat_redeem, written above the first section header"]
unknownKeys strayIni `shouldContain` ["static_dir, written above the first section header"]
withIni fullIni $ \clean -> do
Right cleanIni <- readIniFile clean
unknownKeys cleanIni `shouldBe` []
testUnknownSectionStillBoots :: IO ()
testUnknownSectionStillBoots =
parseIni (fullIni <> "[legacy]\nsetting = on\n") >>= \r -> case r of
Right cfg -> lStaticDir (listener cfg) `shouldBe` "/srv/badges"
Left e -> expectationFailure ("a section nothing reads must be ignored, not refused: " <> e)
-- | 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 ()
@@ -252,7 +260,7 @@ 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 <> "[poll]\n; idle_seconds = 5\n")
accepts (fullIni <> "# a hash comment, with no newline after it")
where
accepts t =
@@ -345,25 +353,58 @@ testIssuerOldFormat = do
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
parseIni :: T.Text -> IO (Either String ServiceConfig)
parseIni t = withIni t readServiceConfig
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)
groupConfigTests :: Spec
groupConfigTests = describe "group config" $ do
it "parses display_name and description" testGroupNameAndDescription
it "defaults description to Nothing" testGroupNoDescription
it "treats a blank description as absent" testGroupBlankDescription
it "refuses a display_name no group can be created under" testGroupInvalidName
it "suggests no name when no character of display_name is valid" testGroupNoValidName
it "requires display_name when the section is present" testGroupMissingName
it "leaves group Nothing when the section is absent" testGroupAbsent
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)
listenerIni :: T.Text
listenerIni = "[listener]\nstatic_dir = /srv/web\n"
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"
groupIni :: T.Text -> IO (Either String ServiceConfig)
groupIni body = parseIni (listenerIni <> "[group]\n" <> body)
withDev :: T.Text -> (Either String ServiceConfig -> IO a) -> IO a
withDev keys act = withIni (fullIni <> "[dev]\n" <> keys) $ \p -> readServiceConfig p >>= act
testGroupNameAndDescription :: IO ()
testGroupNameAndDescription = do
r <- groupIni "display_name = SimpleX Badges\ndescription = Welcome\n"
fmap group r `shouldBe` Right (Just GroupConfig {gDisplayName = "SimpleX Badges", gDescription = Just "Welcome"})
testGroupNoDescription :: IO ()
testGroupNoDescription = do
r <- groupIni "display_name = X\n"
fmap group r `shouldBe` Right (Just GroupConfig {gDisplayName = "X", gDescription = Nothing})
testGroupBlankDescription :: IO ()
testGroupBlankDescription = do
r <- groupIni "display_name = X\ndescription = \n"
fmap group r `shouldBe` Right (Just GroupConfig {gDisplayName = "X", gDescription = Nothing})
testGroupInvalidName :: IO ()
testGroupInvalidName = do
r <- groupIni "display_name = Значки (staging)\n"
case r of
Left e -> e `shouldBe` "group.display_name \"Значки (staging)\" is not a valid group name, the closest valid name is \"Значки staging\""
Right cfg -> expectationFailure ("a name the core refuses must not boot, and this one kept " <> show (group cfg))
testGroupNoValidName :: IO ()
testGroupNoValidName = do
r <- groupIni "display_name = !!!\n"
fmap group r `shouldBe` Left "group.display_name \"!!!\" is not a valid group name"
testGroupMissingName :: IO ()
testGroupMissingName = do
r <- groupIni "description = hi\n"
fmap group r `shouldBe` Left "group.display_name is required"
testGroupAbsent :: IO ()
testGroupAbsent = do
r <- parseIni listenerIni
fmap group r `shouldBe` Right Nothing
File diff suppressed because it is too large. Load diff
+177
View File
@@ -0,0 +1,177 @@
{-# LANGUAGE OverloadedStrings #-}
module Bots.BadgeService.GroupTests where
import BadgeService.Config (GroupConfig (..))
import BadgeService.Group (GroupAction (..), GroupEvent (..), TrackerAction (..), coalesceTrackerRefreshes, inertGroupConfig, noOwnerHint, orphanHint, trackerDecision)
import BadgeService.Group.Command
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime (..), addUTCTime)
import Simplex.Chat.Badges (BadgeType (..))
import Simplex.Chat.Badges.Code (BadgeCode, badgeCodeText, randomBadgeCode)
import Simplex.Chat.Controller (ChatCommand (DeleteGroup, MemberRole))
import Simplex.Chat.Library.Commands (parseChatCommand)
import Simplex.Chat.Types (GroupProfile (..))
import Simplex.Chat.Types.Shared (GroupMemberRole (..))
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Util (tshow)
import Test.Hspec
badgeGroupTests :: Spec
badgeGroupTests = describe "badge group" $ do
let p = groupCmdAction GROwner
issueUsage = ReplyText "use: /issue <type> [months <M>] [uses <N>]"
bulkUsage = ReplyText "use: /bulk <type> [months <M>] count <B>"
revokeUsage = ReplyText "use: /revoke <code>"
it "issue defaults" $ p "/issue supporter" `shouldBe` RunCmd (GCIssue BTSupporter 1 1)
it "issue with months and uses" $ p "/issue legend months 6 uses 50" `shouldBe` RunCmd (GCIssue BTLegend 6 50)
it "bulk with count" $ p "/bulk supporter count 20" `shouldBe` RunCmd (GCBulk BTSupporter 1 20)
it "rejects values past the upper bounds" $ do
p "/issue supporter uses 1001" `shouldBe` issueUsage
p "/bulk supporter count 101" `shouldBe` bulkUsage
p "/issue supporter months 256" `shouldBe` issueUsage
it "rejects zero values" $ do
p "/bulk supporter count 0" `shouldBe` bulkUsage
p "/issue supporter months 0" `shouldBe` issueUsage
it "rejects values past the machine word" $ do
let past64 = tshow (2 ^ (64 :: Int) + 1 :: Integer)
p ("/issue supporter uses " <> past64) `shouldBe` issueUsage
p ("/bulk supporter count " <> past64) `shouldBe` bulkUsage
p ("/issue supporter months " <> past64) `shouldBe` issueUsage
it "accepts the upper bounds" $ do
p "/issue supporter uses 1000" `shouldBe` RunCmd (GCIssue BTSupporter 1 1000)
p "/bulk supporter count 100" `shouldBe` RunCmd (GCBulk BTSupporter 1 100)
p "/issue supporter months 255" `shouldBe` RunCmd (GCIssue BTSupporter 255 1)
it "revoke" $ do
code <- newCode
p ("/revoke " <> badgeCodeText code) `shouldBe` RunCmd (GCRevoke code)
it "authorization, per role, as (issue, bulk, revoke)" $ do
someCode <- newCode
let runs role t = case groupCmdAction role t of
RunCmd _ -> True
_ -> False
allowed role =
( runs role "/issue supporter",
runs role "/bulk supporter count 1",
runs role ("/revoke " <> badgeCodeText someCode)
)
roles = [GRUnknown "future", GRRelay, GRObserver, GRAuthor, GRMember, GRModerator, GRAdmin, GROwner]
map (\role -> (role, allowed role)) roles
`shouldBe` [ (GRUnknown "future", (False, False, False)),
(GRRelay, (False, False, False)),
(GRObserver, (False, False, False)),
(GRAuthor, (False, False, False)),
(GRMember, (False, False, False)),
(GRModerator, (True, True, False)),
(GRAdmin, (True, True, True)),
(GROwner, (True, True, True))
]
describe "log hints give commands --run-cli parses for a group name with a space" $ do
let hintCommand = fst . T.breakOn " in --run-cli" . snd . T.breakOnEnd " with "
parsedHint hint = let cmd = hintCommand hint in (cmd, parseChatCommand (encodeUtf8 cmd))
it "owner" $
case parsedHint (T.replace "<member>" "alice" $ noOwnerHint "SimpleX Badges_1") of
(_, Right (MemberRole g m GROwner)) -> (g, m) `shouldBe` ("SimpleX Badges_1", "alice")
(cmd, r) -> expectationFailure $ "owner command " <> show cmd <> " parsed as " <> show r
it "orphan group" $
case parsedHint (orphanHint "SimpleX Badges_1") of
(_, Right (DeleteGroup g)) -> g `shouldBe` "SimpleX Badges_1"
(cmd, r) -> expectationFailure $ "delete command " <> show cmd <> " parsed as " <> show r
describe "classifying a group message" $ do
it "answers an advertised command that does not parse" $ do
map p
[ "/issue",
"/issue supporter uses 0",
"/issue supporter 3",
"/issue suporter",
"/issue supporter uses 5",
"/bulk supporter",
"/revoke",
"/revoke not-a-badge-code"
]
`shouldBe` replicate 5 issueUsage <> [bulkUsage, revokeUsage, revokeUsage]
it "answers a mistyped command only to a sender who may run it" $ do
map (`groupCmdAction` "/issue supporter uses 0") [GRMember, GRModerator, GRAdmin]
`shouldBe` [IgnoreMsg, issueUsage, issueUsage]
map (`groupCmdAction` "/bulk supporter") [GRMember, GRModerator, GRAdmin]
`shouldBe` [IgnoreMsg, bulkUsage, bulkUsage]
map (`groupCmdAction` "/revoke not-a-badge-code") [GRMember, GRModerator, GRAdmin]
`shouldBe` [IgnoreMsg, IgnoreMsg, revokeUsage]
it "accepts a command padded with whitespace" $ do
p " /issue supporter " `shouldBe` RunCmd (GCIssue BTSupporter 1 1)
p "\t/bulk supporter count 1" `shouldBe` RunCmd (GCBulk BTSupporter 1 1)
it "says nothing to anything that is not an advertised command" $
map p ["", "hello", "issue supporter", "/issued supporter", "/help", "see /issue above"]
`shouldBe` replicate 6 IgnoreMsg
it "says nothing to a command the sender may not run" $ do
map (`groupCmdAction` "/issue supporter") [GRMember, GRAuthor] `shouldBe` [IgnoreMsg, IgnoreMsg]
groupCmdAction GRMember "/bulk supporter count 1" `shouldBe` IgnoreMsg
it "tells a sender below admin that their revoke did not happen" $ do
code <- newCode
map (`groupCmdAction` ("/revoke " <> badgeCodeText code)) [GRMember, GRModerator]
`shouldBe` replicate 2 (ReplyText "only admins can revoke codes, and this code is now visible to the group - ask an admin to revoke it")
describe "configured group name and description" $ do
let cfg name descr = GroupConfig {gDisplayName = name, gDescription = descr}
profile name descr =
GroupProfile
{ displayName = name,
fullName = "",
shortDescr = Nothing,
description = descr,
image = Nothing,
publicGroup = Nothing,
groupPreferences = Nothing,
memberAdmission = Nothing
}
it "says nothing when the config matches the group profile" $ do
inertGroupConfig (cfg "SimpleX Badges" (Just "badge ops desk")) (profile "SimpleX Badges" (Just "badge ops desk"))
`shouldBe` Nothing
inertGroupConfig (cfg "SimpleX Badges" Nothing) (profile "SimpleX Badges" Nothing) `shouldBe` Nothing
it "says nothing when an omitted description meets an empty one" $
inertGroupConfig (cfg "SimpleX Badges" Nothing) (profile "SimpleX Badges" (Just "")) `shouldBe` Nothing
it "reports a name the config would change" $
inertGroupConfig (cfg "SimpleX Badges 2026" (Just "badge ops desk")) (profile "SimpleX Badges" (Just "badge ops desk"))
`shouldBe` Just "badge group config is not applied to an existing group: display_name \"SimpleX Badges 2026\", group has \"SimpleX Badges\""
it "shows a non-ASCII name as written" $
inertGroupConfig (cfg "Значки" Nothing) (profile "SimpleX Badges" Nothing)
`shouldBe` Just "badge group config is not applied to an existing group: display_name \"Значки\", group has \"SimpleX Badges\""
it "reports a description the config would change or remove" $ do
inertGroupConfig (cfg "SimpleX Badges" (Just "new desk")) (profile "SimpleX Badges" (Just "badge ops desk"))
`shouldBe` Just "badge group config is not applied to an existing group: description \"new desk\", group has \"badge ops desk\""
inertGroupConfig (cfg "SimpleX Badges" Nothing) (profile "SimpleX Badges" (Just "badge ops desk"))
`shouldBe` Just "badge group config is not applied to an existing group: description \"\", group has \"badge ops desk\""
it "reports both fields when both would change" $
inertGroupConfig (cfg "SimpleX Badges 2026" (Just "new desk")) (profile "SimpleX Badges" Nothing)
`shouldBe` Just
"badge group config is not applied to an existing group: \
\display_name \"SimpleX Badges 2026\", group has \"SimpleX Badges\"; \
\description \"new desk\", group has \"\""
describe "tracker" $
it "edits within 24h, reposts after" $ do
let t0 = UTCTime (fromGregorian 2026 1 1) 0
within = addUTCTime (23 * 3600) t0
past = addUTCTime (25 * 3600) t0
trackerDecision within t0 `shouldBe` Edit
trackerDecision past t0 `shouldBe` Repost
describe "coalescing tracker refreshes" $ do
it "keeps one refresh per code, carrying the highest claim" $ do
code <- newCode
coalesceTrackerRefreshes [GETracker 1 code 1, GETracker 1 code 3, GETracker 2 code 1]
`shouldBe` [GETracker 1 code 3, GETracker 2 code 1]
it "keeps the highest claim whichever order it queued in" $ do
code <- newCode
coalesceTrackerRefreshes [GETracker 1 code 3, GETracker 1 code 1] `shouldBe` [GETracker 1 code 3]
it "keeps every other event, in order" $ do
code <- newCode
coalesceTrackerRefreshes
[ GEInGroup 7 (GACommand GRAdmin "a"),
GETracker 1 code 1,
GEInGroup 7 GAJoined,
GETracker 1 code 2
]
`shouldBe` [GEInGroup 7 (GACommand GRAdmin "a"), GEInGroup 7 GAJoined, GETracker 1 code 2]
newCode :: IO BadgeCode
newCode = C.newRandom >>= randomBadgeCode
+42 -7
View File
@@ -14,7 +14,7 @@ import BadgeService.Poller
import BadgeService.Providers
import BadgeService.Providers.BTCPay (btcpayProvider, listPageSize, maxListPages)
import BadgeService.Providers.Stripe (stripeProvider)
import BadgeService.Store (KeyPurchase (..), KeyRedemption (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getCodePurchaseForKey, insertBadgeCode, revokeCode)
import BadgeService.Store (KeyPurchase (..), KeyRedemption (..), ManagedGroup (..), NewCodePurchase (..), RevokeResult (..), createCodePurchase, getCodePurchaseForKey, getManagedGroup, insertBadgeCode, insertManagedGroup, markOwnerBootstrapped, revokeCode)
import BadgeService.Store.Invoices
import BadgeService.Waiters (awaitStatus, newWaiters, publish, waitingCount)
import BadgeService.Web.Server
@@ -42,7 +42,7 @@ import Data.Char (toLower)
import Data.Either (isLeft)
import Data.IORef (IORef, atomicModifyIORef', newIORef, readIORef)
import Data.Int (Int64)
import Data.List (sort, sortOn)
import Data.List (isInfixOf, sort, sortOn)
import qualified Data.Map.Strict as Map
import Data.Maybe (catMaybes, isJust, isNothing, fromMaybe, mapMaybe)
import Data.Text (Text)
@@ -138,7 +138,7 @@ badgeWebTests = do
describe "badge service schema" $ do
it "carries the five service-only columns" testServiceColumns
it "refuses a duplicate provider_ref" testProviderRefUnique
it "M20260918 adds the multi-use columns" testGroupOpsColumns
it "M20260918 adds the multi-use columns and the group table" testGroupOpsColumns
it "M20260918 defaults a fresh code to one use with none spent" testGroupOpsRedeemCounts
it "migrates all the way down and up again" testSchemaDownUpCycle
it "rolls back only the group migration and re-applies it, keeping a code redeemed twice spent" testGroupOpsDownUp
@@ -154,6 +154,9 @@ badgeWebTests = do
it "a revoked code cannot be redeemed, and a redeemed code cannot be revoked" testRevokeAndRedeemExcludeEachOther
it "a multi-use code can be revoked while it has uses left, and not once they are gone" testRevokeMultiUseCode
it "every timestamp round-trips to the second" testTimestampRoundTrip
describe "managed group" $ do
it "round-trips and bootstraps once" testManagedGroupRoundTripsAndBootstrapsOnce
it "refuses a second row and burns the flag per group" testManagedGroupIsSingleRow
describe "multi-use" $ do
it "finds a key's own purchase with its newest credential, and none for another key" testKeyPurchaseLookup
it "gives concurrent claims exactly the code's uses, each a distinct count" testMultiUseConcurrentClaimsUpToLimit
@@ -321,7 +324,9 @@ testGroupOpsColumns = withServiceStore assertGroupOpsColumns
assertGroupOpsColumns :: HasCallStack => DBStore -> IO ()
assertGroupOpsColumns st = do
columnsOf st "sx_badge_service_badge_codes"
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["redeem_limit", "redeem_count"])
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["redeem_limit", "redeem_count", "group_item_id", "group_item_sent_at"])
columnsOf st "sx_badge_service_group"
>>= (`shouldSatisfy` \cs -> all (`elem` cs) ["group_id", "group_link", "owner_bootstrapped", "created_at"])
runMigrations :: DBStore -> MigrationsToRun -> IO ()
#if defined(dbPostgres)
@@ -336,6 +341,7 @@ testSchemaDownUpCycle = withServiceStore $ \st -> do
length downMigrations `shouldBe` length badgeServiceSchemaMigrations
runMigrations st $ MTRDown downMigrations
columnsOf st "sx_badge_service_badge_codes" `shouldReturn` []
columnsOf st "sx_badge_service_group" `shouldReturn` []
runMigrations st $ MTRUp badgeServiceSchemaMigrations
assertGroupOpsColumns st
@@ -532,7 +538,7 @@ testRevokeAndRedeemExcludeEachOther = withServiceStore $ \st -> do
redeem badgeCodeId = claimUse st badgeCodeId now keys
revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now
revokedFirst <- newCode "revoked-first"
revoke "revoked-first" `shouldReturn` Revoked
revoke "revoked-first" `shouldReturn` Revoked revokedFirst
redeem revokedFirst `shouldReturn` Nothing
codeCounts st "revoked-first" `shouldReturn` Just (1, 0)
redeemedFirst <- newCode "redeemed-first"
@@ -549,7 +555,7 @@ testRevokeMultiUseCode = withServiceStore $ \st -> do
revoke codeHash = withTransaction st $ \db -> revokeCode db codeHash now
partlyUsed <- newCode "partly-used"
isJust <$> redeem partlyUsed `shouldReturn` True
revoke "partly-used" `shouldReturn` Revoked
revoke "partly-used" `shouldReturn` Revoked partlyUsed
redeem partlyUsed `shouldReturn` Nothing
usedUp <- newCode "used-up"
isJust <$> redeem usedUp `shouldReturn` True
@@ -689,6 +695,35 @@ testTimestampRoundTrip = withServiceStore $ \st -> do
irExpiresAt row `shouldBe` truncated
irCreatedAt row `shouldBe` truncated
testManagedGroupRoundTripsAndBootstrapsOnce :: IO ()
testManagedGroupRoundTripsAndBootstrapsOnce = withServiceStore $ \st -> do
now <- truncateToSecond <$> getCurrentTime
beforeInsert <- withTransaction st getManagedGroup
beforeInsert `shouldBe` Nothing
withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now
afterInsert <- withTransaction st getManagedGroup
afterInsert `shouldBe` Just ManagedGroup {mgGroupId = 42, mgGroupLink = "https://link", mgOwnerBootstrapped = False}
show afterInsert `shouldNotSatisfy` ("https://link" `isInfixOf`)
firstMark <- withTransaction st (`markOwnerBootstrapped` 42)
secondMark <- withTransaction st (`markOwnerBootstrapped` 42)
(firstMark, secondMark) `shouldBe` (True, False)
testManagedGroupIsSingleRow :: IO ()
testManagedGroupIsSingleRow = withServiceStore $ \st -> do
now <- truncateToSecond <$> getCurrentTime
withTransaction st $ \db -> insertManagedGroup db 42 "https://link" now
withTransaction st $ \db -> insertManagedGroup db 43 "https://other" now
withTransaction st $ \db -> insertManagedGroup db 42 "https://again" now
managedGroupRows st `shouldReturn` [(42, "https://link")]
withTransaction st (`markOwnerBootstrapped` 43) `shouldReturn` False
(fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just False
withTransaction st (`markOwnerBootstrapped` 42) `shouldReturn` True
(fmap mgOwnerBootstrapped <$> withTransaction st getManagedGroup) `shouldReturn` Just True
managedGroupRows :: DBStore -> IO [(Int64, Text)]
managedGroupRows st =
withConnection st $ \db -> DB.query_ db "SELECT group_id, group_link FROM sx_badge_service_group ORDER BY group_id"
insertCode :: DBStore -> ByteString -> BadgeCodePaymentStatus -> Int -> UTCTime -> IO Int64
insertCode st codeHash paymentStatus redeemLimit now =
withTransaction st $ \db -> insertBadgeCode db codeHash BTSupporter 1 paymentStatus redeemLimit now
@@ -943,7 +978,7 @@ testServiceConfig staticDir trustForwarded =
stripe = Nothing,
poll = PollConfig {pWaitingSeconds = 3, pIdleSeconds = 60},
issuer = Nothing,
devChatRedeem = False
group = Nothing
}
testServeWebappOff :: IO ()
+6 -1
View File
@@ -7,6 +7,8 @@ import Bots.BadgeService.BTCPayTests
import Bots.BadgeService.BotTests
import Bots.BadgeService.CatalogTests
import Bots.BadgeService.ConfigTests
import Bots.BadgeService.GroupIntegrationTests
import Bots.BadgeService.GroupTests
import Bots.BadgeService.StripeTests
import Bots.BadgeService.WaitersTests
import Bots.BadgeService.WebTests
@@ -74,6 +76,7 @@ main = do
badgeConfigTests
badgeWebTests
badgeCatalogTests
badgeGroupTests
badgeWaitersTests
badgeBTCPayTests
badgeStripeTests
@@ -102,7 +105,9 @@ main = do
describe "SimpleX chat client" chatTests
xdescribe'' "SimpleX Broadcast bot" broadcastBotTests
xdescribe'' "SimpleX Directory service bot" directoryServiceTests
xdescribe'' "SimpleX badge service e2e" badgeServiceTests
xdescribe'' "SimpleX badge service e2e" $ do
badgeServiceTests
describe "managed group" badgeGroupIntegrationTests
describe "Remote session" remoteTests
#if !defined(dbPostgres)
xdescribe'' "Save query plans" saveQueryPlans