core, plan: badge manager worker for monthly reissue

This commit is contained in:
shum
2026-08-27 10:28:26 +00:00
parent f6cbf15963
commit 0f70692ba6
9 changed files with 647 additions and 13 deletions
@@ -146,7 +146,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat
| B10 | Service integration tests | B7, B8 | ☑ |
| C1 | `Store/Badges.hs`: client badge store | A1, A2 | ☑ |
| C2 | Commands, responses, events, parsers, View | A2, B2, C1 | ☑ |
| C3 | `BadgeManager` worker | C1, C2 | ☐ |
| C3 | `BadgeManager` worker | C1, C2 | ☑ |
| C4 | Redeem path wired end to end | B6, B7, B9, C3 | ☐ |
| C5 | Client and service integration tests | B10, C4 | ☐ |
| D0 | Store layer: orders, invoices, provider events | A3, A5, B1 | ☐ |
@@ -689,9 +689,7 @@ Phase C ends with a chat client that can redeem a code minted by B8 and show the
**Do:** Reduced from core §6 to this scope.
```haskell
newtype BadgeManager = BadgeManager
{ badgeWorkers :: TMap UserId Worker -- agent Worker: doWork TMVar, restart on crash
}
badgeWorkers :: TMap UserId Worker -- a ChatController field: agent Worker, doWork TMVar, restart on crash
```
- Reuse the agent `Worker` framework from simplexmq's `Simplex.Messaging.Agent.Client`. `getAgentWorker` is used at `Library/Subscriber.hs:4024,4091,4331` (imported at `:82`); `hasWorkToDo'` and `cancelWorker` are exported by the same module but have no in-repo caller yet. The `TMap … Worker` controller fields follow `deliveryTaskWorkers` and `relayRequestWorkers` (`Controller.hs:307-309`).
@@ -699,7 +697,7 @@ newtype BadgeManager = BadgeManager
- Add a `CLBadge UserId` constructor to `ChatLockEntity` (`Store/Shared.hs:68`), whose derived `Eq` and `Ord` still hold, and give it a branch in `enityLockString` (`Library/Commands.hs:3639-3646`): `CLBadge userId -> "Badge " <> tshow userId`. That case is exhaustive with no wildcard and the build uses `-Werror=incomplete-patterns` (`simplex-chat.cabal:338`), so the new constructor breaks the build until the branch exists.
- Define `withBadgeLock :: Text -> UserId -> CM a -> CM a`, `withEntityLock name . CLBadge`, with an `INLINE` pragma, beside the existing wrappers at `Library/Internal.hs:133-139`, and lock every signed badge operation with it. There is no separate lock map: the badge lock is an ordinary entity lock over the shared `entityLocks` map (`Controller.hs:295`), so the `chatLock`-first order is inherited rather than reimplemented, and no new lock order is introduced.
- The worker calls `sendBadgeRequest :: Maybe C.PrivateKeyEd25519 -> BadgeServiceRequest -> CM BadgeServiceResponse`, the single send path C4 implements in `Library/Commands.hs`. At this step it is a stub with that signature returning `BSPError` with code `internal` and the message `not implemented`, so the worker compiles and its mechanics are testable; C4 gives it the lazy connection and the signing.
- Per pass, for the shown purchase: re-issue when the balance is positive and the month is unissued, by calling `issueBadge`. Apply the response with C1's writers, in one transaction: `insertLedgerEntries` for the statement verbatim, then `createIssuance` for the new period. The purchase row itself does not change on a re-issue, since it is already `issued`; only a new issuance row is written. Verify it with C2's `verifyUserBadge` before the transaction and call `setUserBadge` inside it, writing the `User` it returns to the `currentUser` TVar; a credential that fails verification is discarded and nothing is written. Release the badge lock, then call C2's `presentUserBadgeToContacts` with that `User`, which takes `chatLock`, so the monthly pass never broadcasts while holding a lock. Emit `CEvtBadgeChanged` on change.
- Per pass, for the shown purchase: re-issue when the balance is positive and the month is unissued — the last ledger entry's `balanceMonths > 0` and `balanceStartTs <= now`, which is `issue`'s own guard and never the balance alone (B2) — by calling `issueBadge`, signed with the purchase's own key and carrying the purchase row's master key unchanged (§9). Apply the response with C1's writers, in one transaction: `insertLedgerEntries` for the statement verbatim, then `createIssuance` for the period the statement's last `debit(badge)` entry implies, with `ledgerEntryId = Nothing` (§9). The purchase row itself does not change on a re-issue, since it is already `issued`; only a new issuance row is written. Verify it with C2's `verifyUserBadge` before the transaction and call `setUserBadge` inside it, writing the `User` it returns to the `currentUser` TVar; a credential that fails verification is discarded and nothing is written. Release the badge lock, then call C2's `presentUserBadgeToContacts` with that `User`, which takes `chatLock`, so the monthly pass never broadcasts while holding a lock. Emit `CEvtBadgeChanged` on change.
- A response with `credential = Nothing` is not an error: it is the exhausted balance B7 defines. Store the statement's ledger rows, write no issuance, leave the profile's badge as it is, and emit `CEvtBadgeChanged` only if the stored state changed. The pass is not suppressed afterwards: a pass on a zero balance reads the last ledger entry and stops, which is cheap, and suppressing it would need a re-arming signal that nothing sends.
- Three things trigger a pass, and nothing else does:
- the chat controller start signals every user's worker, beside the other worker starts in `startChatController`'s `start` block (`Library/Commands.hs:250-252`, `startDeliveryWorkers`, `startRelayRequestWorker_`, `startCleanupManager`), which has `users` in hand and so suits a per-user worker. `src/Simplex/Chat.hs:196-198,242-244` is `newChatController` and only initialises the maps;
@@ -708,7 +706,7 @@ newtype BadgeManager = BadgeManager
- `hasWorkToDo'` signals the `doWork` TMVar in all three cases, and the timer is the badge worker's own loop rather than a shared scheduler. Tests drive a pass with `APIGetBadgeState` rather than the timer, so no test waits on wall-clock time.
- Out of scope here: invoice reconciliation, store evidence, the alert timer, `CEvtBadgeAlert`, and Monday presentation. Those stay deferred in §6.
**Verify:** In `tests/Bots/BadgeManagerTests.hs`, registered in the test stanza's `other-modules` and in `tests/Test.hs` under the **`Supporter badges`** path inside the `testBracket` bracket, so CI runs it (§4 rules 7 and 8): with a purchase row inserted by C1, calling the pass's apply-response function with a `BSPBadgeCredential` whose credential was signed by a test key through `Simplex.Chat.Badges.issueBadge`, against a controller started with `testCfg {badgePublicKeys = testBadgeKeys pk}` (`tests/ChatTests/Profiles.hs:295`), writes the issuance row and the statement's ledger rows and emits `CEvtBadgeChanged`; a credential signed by a different key is rejected and writes nothing; a response with `credential = Nothing` writes the ledger rows and no issuance; a second signal arriving during a pass does not run concurrently. No service is started, and no RPC is sent, so the test needs no stub point. `tests/BadgeTests.hs` cannot host this test, being a plain `Spec` (A2). The month-boundary re-issue against the live service is asserted in C5.
**Verify:** In `tests/Bots/BadgeManagerTests.hs`, registered in the test stanza's `other-modules` and in `tests/Test.hs` under the **`Supporter badges`** path inside the `testBracket` bracket, so CI runs it (§4 rules 7 and 8): with a purchase row inserted by C1, composing the pass's two halves — `storeBadgeIssueResponse`, which the pass runs under the badge lock, and `presentBadgeChange`, which it runs with the lock released (§9) — over a `BSPBadgeCredential` whose credential was signed by a test key through `Simplex.Chat.Badges.issueBadge`, against a controller started with `testCfg {badgePublicKeys = testBadgeKeys pk}` (`tests/ChatTests/Profiles.hs:295`), writes the issuance row and the statement's ledger rows and emits `CEvtBadgeChanged`; a credential signed by a different key is rejected and writes nothing; a response with `credential = Nothing` writes the ledger rows and no issuance; a second signal arriving during a pass does not run concurrently. No service is started, and no RPC is sent, so the test needs no stub point. `tests/BadgeTests.hs` cannot host this test, being a plain `Spec` (A2). The month-boundary re-issue against the live service is asserted in C5.
#### C4 — Redeem path wired end to end
@@ -1399,6 +1397,16 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
- **C2 — the new commands go in `undocumentedCommands`, not `cliCommands`, and neither list documents anything.** The step said to add them to `cliCommands` "so they reach `bots/api/COMMANDS.md` and both generated clients". Both lists are exemptions from `tests/APIDocs.hs:76`'s completeness check; a command reaches `COMMANDS.md` and the clients only through `chatCommandsDocs`. `cliCommands` holds the terminal-syntax commands and `undocumentedCommands` the `API*` ones, so the three `API*` commands go in the latter. The only regenerated files are therefore `bots/api/TYPES.md`, `types.ts` and `_types.py`, all three changed by `CEBadgeServiceError`/`BadgeServiceErrorCode` alone; `COMMANDS.md` and `EVENTS.md` are unchanged, and the step's Files line is corrected accordingly. Documenting the three commands properly belongs with C4, when they do something.
- **C2 — the step's last manual check contradicts the step.** "`/_badge catalog 1` reaching the service at the overridden address" cannot hold while the same step stubs `APIGetBadgeCatalog` with `not implemented`. What was checked instead: `--badge-service-address` parses a contact link and is rejected when it is not one, `/_badge catalog 1` and `/_badge purchase 1 <json>` both return the stub `CEBadgeServiceError`, and the issuer-key override is exercised end to end through `/badge add` — a credential signed by a locally generated key at index 1 verifies with `--badge-issuer-key 1:KEY` and fails without it ("does not verify against configured key"), while a credential at index 2 fails with "unknown badge key index" when only index 1 is overridden, which is what proves REPLACE rather than merge. Verify corrected above. **C4 owns the reaching-the-service check.**
- **C2 — `addUserBadge` reads `badgeCurrentTime`, not `getCurrentTime`.** The step introduces the field for C3 and C4 only. Wiring the one existing client-side badge clock read through it too costs nothing (the default is `getCurrentTime`, so `AddBadge` is unchanged) and means the field is load-bearing from the step that adds it rather than from the step that first needs to override it. **It has no behavioural test yet** — nothing in this step can observe a different clock — so C5 is the first place the override is proved to work.
- **C3 — `badgeWorkers` is a `ChatController` field, not a `BadgeManager` record.** The step's code block wraps one `TMap UserId Worker` in a newtype, from core §6's five-field record; the other four fields (`badgeLocks`, `badgeReads`, `badgeBoundaries`, `badgeTimerAsync`) are all out of this step's scope — the lock is an ordinary entity lock over `entityLocks`, the timer is the worker's own loop, and the other two serve steps §6 defers — so the wrapper would wrap exactly one field and add an indirection at every use. The field sits beside `deliveryTaskWorkers` and `relayRequestWorkers`, which the same bullet says to follow.
- **C3 — `badgeReads` is not added and `APIGetBadgeState` records nothing.** Core §6 has the command record its request time there; the only thing that reads that map is the lapsed-balance signed `getBadgeCatalog`, which this step does not send. The command signals the worker and stores nothing.
- **C3 — the issued period is read from the statement's last `debit(badge)`, and no issuance is written without one.** The step says "`createIssuance` for the new period" without saying where the period comes from, and the wire carries neither end of it: the credential's expiry is `sundayAfter periodEnd`, which is not invertible, and `addMonths` is not additive under clamping, so stepping a month back from a balance start does not name the period either (the same trap `BadgeService.Service.resolveIssue` documents). `issue` sets `balanceStartTs` to the period's END and the service writes the `debit(badge)` last, so the statement's last entry gives `periodEnd` and the `balanceStartTs` in force immediately before it gives `periodStart` — from the entry before it in the statement, or from the last entry the client held. A statement carrying no `debit(badge)` still puts its credential on the profile, because a month the user paid for is never lost (B10), but writes no issuance row.
- **C3 — `NewBadgeIssuance.ledgerEntryId` stays `Nothing`.** C1's entry above left this to C3. `badge_issuances.entry_id REFERENCES badge_ledger` has no `ON DELETE`, so linking issuances to local ledger rows would oblige every REPLACE to null those references before deleting — for a join nothing in this plan reads. The client's issuances therefore carry no ledger reference; the service's own rows still do.
- **C3 — the master key of `issueBadge` is the purchase row's, never a freshly generated one.** The service does not check `badgeRequest.masterKey` against `badge_purchases.master_key` (§9, above), so a second redemption under the same purchase key with a rotated master key takes the cached-issuance path and is answered with a credential bound to the OLD key — unusable, and with no error raised anywhere. Keeping the key stable per purchase closes that from the client side, without a service change.
- **C3 — the pass is two functions, split at the lock.** The step's Verify line names one "apply-response function", but the presentation must happen with the badge lock RELEASED (C2's `presentUserBadgeToContacts` takes `chatLock`), so the pass is `withBadgeLock … (… storeBadgeIssueResponse …) >>= presentBadgeChange`. Both halves are production functions and the tests compose them exactly as the pass does; only the stubbed send sits between them. C4's redeem path has the same obligation.
- **C3 — the worker's timer is `race_` against `threadDelay'`, not `timeout`.** `badgePassInterval` is a `NominalDiffTime` (following `cleanupManagerInterval`) and its default of a day is 86,400,000,000 microseconds, which does not fit an `Int` on 32-bit builds; `threadDelay'` takes an `Int64` and chunks it, as the delivery and relay-request workers already use it. The loop waits on `takeTMVar doWork` racing that delay, so the signal is CONSUMED before the pass reads any state and a signal arriving during a pass runs one more pass rather than being swallowed by it.
- **C3 — a badge request that fails is reported to the app.** Neither the step nor core §6 says what to do with a `BSPError` from a pass. It is surfaced with `eToView` as `CEBadgeServiceError`, as the delivery worker surfaces its failures, which is also what makes a pass observable to a test while `sendBadgeRequest` is a stub.
- **C3 — `badgeCurrentTime` is proved by C3, not by C5.** C2's entry above says C5 is the first place the override is proved to work. It is proved here: `Bots.BadgeManagerTests` injects a clock that counts and gates the passes, which is what lets the worker tests be deterministic without waiting on `badgePassInterval` or on wall-clock time at all.
- **C3 — the Postgres cross-check was not run for this step.** SQLite-only: `cabal test --test-options='-m "Supporter badges" -m "Badge service"'`, `CI` unset. The `--flags=client_postgres` cross-check B10 established is optional per-step and was skipped here rather than run; nothing in this step is guarded `#if !defined(dbPostgres)`, so there is no reason to expect a backend-specific gap, but it has not been exercised against Postgres and that remains open until it is.
## 10. End-to-end verification
+1
View File
@@ -714,6 +714,7 @@ test-suite simplex-chat-test
BadgeService.Store.Migrate
Bots.BadgeCodeTests
Bots.BadgeLedgerTests
Bots.BadgeManagerTests
Bots.BadgeServiceTests
Bots.BadgeStoreTests
Broadcast.Bot
+3
View File
@@ -82,6 +82,7 @@ defaultChatConfig =
badgeServiceAddress = Nothing,
badgeWebBaseUrl = "",
badgeCurrentTime = getCurrentTime,
badgePassInterval = nominalDay,
confirmMigrations = MCConsole,
-- this property should NOT use operator = Nothing
-- non-operator servers can be passed via options
@@ -202,6 +203,7 @@ newChatController
deliveryTaskWorkers <- TM.emptyIO
deliveryJobWorkers <- TM.emptyIO
relayRequestWorkers <- TM.emptyIO
badgeWorkers <- TM.emptyIO
relayGroupLinkChecksAsync <- newTVarIO Nothing
webPreviewState <- forM webPreviewConfig $ \_ -> newWebPreviewState
chatRelayTests <- TM.emptyIO
@@ -248,6 +250,7 @@ newChatController
deliveryTaskWorkers,
deliveryJobWorkers,
relayRequestWorkers,
badgeWorkers,
relayGroupLinkChecksAsync,
webPreviewState,
chatRelayTests,
+8
View File
@@ -160,6 +160,10 @@ data ChatConfig = ChatConfig
-- sleeping. Defaults to 'getCurrentTime'; nothing but a test ever replaces it. This is the
-- client twin of @BadgeServiceOpts.serviceClock@.
badgeCurrentTime :: IO UTCTime,
-- | How long a badge worker waits for a signal before running a pass anyway. A month
-- boundary is the only event the pass waits for, so a daily wake is frequent enough; a pass
-- with the current month already issued reads one ledger row and stops.
badgePassInterval :: NominalDiffTime,
confirmMigrations :: MigrationConfirmation,
presetServers :: PresetServers,
shortLinkPresetServers :: NonEmpty SMPServer,
@@ -325,6 +329,10 @@ data ChatController = ChatController
deliveryTaskWorkers :: TMap DeliveryWorkerKey Worker,
deliveryJobWorkers :: TMap DeliveryWorkerKey Worker,
relayRequestWorkers :: TMap Int Worker, -- single global worker with key 1 is used to fit into existing worker management framework
-- | The 'BadgeManager': one worker per user, because badge state is per profile. The worker
-- holds no queue — a trigger only signals its @doWork@, and each pass derives its work from
-- the stored purchase and ledger, so lost and duplicated signals are harmless.
badgeWorkers :: TMap UserId Worker,
relayGroupLinkChecksAsync :: TVar (Maybe (Async ())),
webPreviewState :: Maybe WebPreviewState,
chatRelayTests :: TMap ConnId RelayTest,
+279 -7
View File
@@ -56,10 +56,22 @@ import Data.Type.Equality
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeRequest (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Badges.Months (addMonths)
import Simplex.Chat.Badges.Service (BadgeServiceErrorCode (..))
import Simplex.Chat.Badges.Types (BadgeLedgerEntry (..), UserBadge (..), UserBadgeState (..))
import Simplex.Chat.Badges.Service
( BadgeBalance (..),
BadgeServiceCommand (..),
BadgeServiceErrorCode (..),
BadgeServiceRequest (..),
BadgeServiceResponse (..),
BadgeStatement (..),
StatementCreditType (..),
StatementDebitType (..),
StatementEntry (..),
StatementEntryType (..),
currentBadgeVersion,
)
import Simplex.Chat.Badges.Types (BadgeLedgerEntry (..), LedgerCreditType (..), LedgerDebitType (..), LedgerEntryType (..), UserBadge (..), UserBadgeState (..))
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Call
import Simplex.Chat.Controller
@@ -80,7 +92,7 @@ import Simplex.Chat.Library.Internal
import Simplex.Chat.Stats
import Simplex.Chat.Store
import Simplex.Chat.Store.AppSettings
import Simplex.Chat.Store.Badges (UserBadgePurchase (..), getLastBadgeLedgerEntry, getShownPurchase)
import Simplex.Chat.Store.Badges (NewBadgeIssuance (..), UserBadgePurchase (..), createIssuance, getLastBadgeLedgerEntry, getShownPurchase, insertLedgerEntries)
import Simplex.Chat.Store.ContactRequest
import Simplex.Chat.Store.Connections
import Simplex.Chat.Store.Delivery
@@ -99,7 +111,8 @@ import qualified Simplex.Chat.Util as U
import Simplex.Chat.Web (webPreviewWorker)
import Simplex.FileTransfer.Description (FileDescriptionURI (..), maxFileSizeHard)
import Simplex.Messaging.Agent
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
import Simplex.Messaging.Agent.Client (cancelWorker, getAgentWorker)
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), Worker (..), allRoles)
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.Entity
import Simplex.Messaging.Agent.Store.Interface (execSQL)
@@ -254,6 +267,7 @@ startChatController mainApp enableSndFiles serviceRequests = do
startDeliveryWorkers
startRelayRequestWorker_
startCleanupManager
startBadgeWorkers users
void $ forkIO $ mapM_ startExpireCIs users
startRelayChecks users
startWebPreview users
@@ -350,7 +364,11 @@ restoreCalls = do
atomically $ writeTVar calls callsMap
stopChatController :: ChatController -> IO ()
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession} = do
stopChatController ChatController {smpAgent, agentAsync = s, sndFiles, rcvFiles, expireCIFlags, remoteHostSessions, remoteCtrlSession, badgeWorkers} = do
-- the map is swapped out BEFORE the workers are cancelled: 'cancelWorker' leaves a worker's
-- action TMVar empty, so one left in the map would make the next 'getBadgeWorker' for that
-- user block on it forever
atomically (swapTVar badgeWorkers M.empty) >>= mapM_ cancelWorker
readTVarIO remoteHostSessions >>= mapM_ (cancelRemoteHost False . snd)
atomically (stateTVar remoteCtrlSession (,Nothing)) >>= mapM_ (cancelRemoteCtrl False . snd)
disconnectAgentClient smpAgent
@@ -3559,6 +3577,9 @@ processChatCommand cxt nm = \case
APIGetBadgeState userId -> withUserId userId $ \user -> do
badgeState <- getUserBadgeState user
ChatConfig {badgeWebBaseUrl} <- asks config
-- the badge screen opening or regaining focus is one of the three triggers of a pass, so a
-- profile that has just crossed a month boundary re-issues without waiting for the timer
lift $ void $ getBadgeWorker True userId
pure CRBadgeState {user, badgeState, badgeWebBaseUrl}
APIGetBadgeCatalog _ -> throwChatError badgeNotImplemented
APIPurchaseBadge {} -> throwChatError badgeNotImplemented
@@ -3664,6 +3685,7 @@ processChatCommand cxt nm = \case
CLUserContact ucId -> "UserContact " <> tshow ucId
CLContactRequest crId -> "ContactRequest " <> tshow crId
CLFile fId -> "File " <> tshow fId
CLBadge uId -> "Badge " <> tshow uId
DebugEvent event -> toView event >> ok_
GetAgentSubsTotal userId -> withUserId userId $ \user -> do
users <- withStore' $ \db -> getUsers db
@@ -5176,11 +5198,20 @@ getUserBadgeState User {userId} = withFastStore $ \db ->
badgePaidThrough :: BadgeLedgerEntry -> UTCTime
badgePaidThrough BadgeLedgerEntry {balanceMonths, balanceStartTs} = addMonths balanceMonths balanceStartTs
-- | A badge failure that is the CLIENT's own, reported in the shape a service failure is
-- reported in: no 'BadgeServiceErrorCode' denotes a local failure, so it is 'BSEInternal' with
-- the reason as the message.
localBadgeError :: Text -> ChatErrorType
localBadgeError t = CEBadgeServiceError {badgeError = BSEInternal, badgeErrorMessage = Just t, retryAfter = Nothing}
-- | The placeholder failure of the badge commands C4 implements. It is a 'CEBadgeServiceError'
-- rather than a command error because that is the error C4 raises from them, so the stub does
-- not train a client to expect a different shape from the one it will get.
badgeNotImplemented :: ChatErrorType
badgeNotImplemented = CEBadgeServiceError {badgeError = BSEInternal, badgeErrorMessage = Just "not implemented", retryAfter = Nothing}
badgeNotImplemented = localBadgeError notImplementedMessage
notImplementedMessage :: Text
notImplementedMessage = "not implemented"
-- | Checks that a credential was signed by a configured issuer key.
--
@@ -5224,6 +5255,247 @@ addUserBadge user cred@(BadgeCredential _ _ _ info) = do
asks currentUser >>= atomically . (`writeTVar` Just user')
presentUserBadgeToContacts user'
-- BadgeManager ----------------------------------------------------------------
--
-- One worker per user (badge state is per profile), over the agent 'Worker' framework the
-- controller already uses for delivery and relay requests. The worker holds no queue: a trigger
-- only signals its @doWork@, and each pass derives its work from the stored purchase and its
-- ledger, so lost and duplicated signals are harmless.
-- | The badge worker of one user, creating it if it has none; @hasWork@ signals a pass.
--
-- Two of the three triggers signal through this call — chat start ('startBadgeWorkers') and
-- @APIGetBadgeState@ — and nothing else does. The third is the worker's own timer, which is
-- inside 'runBadgeWorker' rather than a signal.
getBadgeWorker :: Bool -> UserId -> CM' Worker
getBadgeWorker hasWork userId = do
ws <- asks badgeWorkers
a <- asks smpAgent
getAgentWorker "badge" hasWork a userId ws $ runBadgeWorker userId
-- | Signals every user's badge worker at chat start, beside the other worker starts: a profile
-- that crossed a month boundary while the app was closed re-issues without waiting for the timer.
startBadgeWorkers :: [User] -> CM' ()
startBadgeWorkers = mapM_ $ \User {userId} -> void $ getBadgeWorker True userId
-- | The worker loop: wait for a signal or for 'badgePassInterval' to elapse, then run one pass.
--
-- The signal is TAKEN rather than read, and taken before the pass reads any state, so a signal
-- arriving while a pass runs is not consumed by it and runs one more pass afterwards. Two
-- passes for one user therefore never overlap: the framework runs one thread per worker, and
-- that thread is here.
--
-- The timer is this loop's own wait rather than a shared scheduler, and it is 'threadDelay''
-- rather than 'timeout' because a day in microseconds does not fit an 'Int' on 32-bit builds.
runBadgeWorker :: UserId -> Worker -> CM ()
runBadgeWorker userId Worker {doWork} = do
interval <- asks $ badgePassInterval . config
forever $ do
liftIO $ race_ (atomically $ takeTMVar doWork) (threadDelay' $ diffToMicroseconds interval)
badgeManagerPass userId `catchAllErrors` eToView
-- | One pass for one user: the signed half under the per-user badge lock, then its presentation
-- and event OUTSIDE that lock, since 'presentUserBadgeToContacts' takes 'chatLock'.
badgeManagerPass :: UserId -> CM ()
badgeManagerPass userId = do
user <- withFastStore (`getUser` userId)
withBadgeLock "badgeManagerPass" userId (issueDueBadgePeriod user) >>= presentBadgeChange
-- | Re-issues the shown purchase's current month when its balance funds one and the month is
-- unissued. Everything it does — the state it reads, the request it sends and the rows it
-- writes — happens under the per-user badge lock, so it cannot interleave with a command that
-- sends a signed badge request for the same profile.
issueDueBadgePeriod :: User -> CM BadgeChange
issueDueBadgePeriod user@User {userId} = do
now <- liftIO =<< asks (badgeCurrentTime . config)
-- the purchase and its last entry are read in ONE transaction, so the balance asserted to the
-- service cannot be from a ledger that changed between the two reads
due_ <- withFastStore $ \db ->
getShownPurchase db userId >>= \case
Nothing -> pure Nothing
Just p@UserBadgePurchase {badgePurchaseId = pId} -> Just . (p,) <$> getLastBadgeLedgerEntry db pId
case due_ of
Just (purchase@UserBadgePurchase {purchasePrivKey}, Just lastEntry)
| badgePeriodDue now lastEntry -> do
balance <- assertedBadgeBalance lastEntry
resp <- sendBadgeRequest (Just purchasePrivKey) (badgeIssueRequest purchase balance)
storeBadgeIssueResponse now user purchase (Just lastEntry) resp
_ -> pure BadgeUnchanged
-- | Whether the month the balance would fund is unissued: a positive balance whose coverage
-- window has already started.
--
-- This is @BadgeService.Ledger.issue@'s own guard, applied to the last entry the client holds.
-- A previous issue moved 'balanceStartTs' to the END of the period it issued, so
-- @balanceStartTs > now@ means the current month is already issued — and that is why the two
-- reasons not to issue must be told apart by 'balanceStartTs' and never by the balance alone:
-- once the last funded month has been issued both hold at once.
badgePeriodDue :: UTCTime -> BadgeLedgerEntry -> Bool
badgePeriodDue now BadgeLedgerEntry {balanceMonths, balanceStartTs} = balanceMonths > 0 && balanceStartTs <= now
-- | The balance assertion of @issueBadge@: the last entry the client holds, back in the shape it
-- arrived in. The service matches it by @entryId@ and returns what follows it; a mismatch is
-- healed on the service side and answered with the complete history or an @opening@ credit.
--
-- The three entry types that cannot be put back on the wire are refused rather than coerced:
-- they carry a purchase KEY where the stored columns hold a purchase id, or an @Int64@ charge id
-- against a TEXT one, exactly as 'Simplex.Chat.Store.Badges' refuses them in the other
-- direction. Nothing can write such a row today, so this raises a chat error rather than
-- branching the pass.
assertedBadgeBalance :: BadgeLedgerEntry -> CM BadgeBalance
assertedBadgeBalance BadgeLedgerEntry {entryUuid, changeMonths, balanceMonths, balanceStartTs, balanceBadgeType, wasPausedSince, serviceCreatedAt, entryType} = do
entryType' <- either (throwChatError . localBadgeError) pure $ wireEntryType entryType
pure . BadgeBalance $
StatementEntry
{ entryId = entryUuid,
changeMonths,
balanceMonths,
balanceStartTs,
balanceBadgeType,
wasPausedSince,
-- the wire's createdAt is the SERVICE's clock, which is the column the replica keeps it
-- in; created_at is when this client replicated the row and is not the service's to read
createdAt = serviceCreatedAt,
entryType = entryType'
}
-- | The inverse of 'Simplex.Chat.Store.Badges.storedEntryType', for the one entry the client
-- asserts back to the service. @credit(payment)@ loses nothing on the way out: the stored
-- @payment_id@ is a CLIENT payments id and the wire field is the SERVICE's @invoiceId@, which a
-- replicated entry never carries.
wireEntryType :: LedgerEntryType -> Either Text StatementEntryType
wireEntryType = \case
LECredit creditType -> SECredit <$> case creditType of
CTPayment {} -> Right SCPayment {invoiceId = Nothing}
CTSupport -> Right SCSupport
CTOpening -> Right SCOpening
CTUnknown {tag, json} -> Right SCUnknown {tag, json}
CTCharge {} -> unsupported "credit(charge)"
CTTransferIn {} -> unsupported "credit(transferIn)"
LEDebit debitType -> SEDebit <$> case debitType of
DTRefund -> Right SDRefund
DTSupport -> Right SDSupport
DTBadge -> Right SDBadge
DTLapse -> Right SDLapse
DTUnknown {tag, json} -> Right SDUnknown {tag, json}
DTUpgrade {} -> unsupported "debit(upgrade)"
DTTransferOut {} -> unsupported "debit(transferOut)"
where
unsupported what = Left $ "cannot assert badge ledger " <> what <> " to the badge service"
-- | The @issueBadge@ request for a purchase, signed by that purchase's key.
--
-- __The master key is the purchase row's, never a freshly generated one.__ The service does not
-- check the master key of an @issueBadge@ against the one it recorded for the purchase, and a
-- request that rotated it would take the service's cached-issuance path and be answered with a
-- credential bound to the OLD key — unusable, with no error raised anywhere. Keeping it stable
-- per purchase key closes that from this side.
--
-- 'badgeExpiry' is left absent and 'badgeExtra' empty: the service overrides the expiry with its
-- own (the Sunday after the period it issues) and refuses a non-empty extra.
badgeIssueRequest :: UserBadgePurchase -> BadgeBalance -> BadgeServiceRequest
badgeIssueRequest UserBadgePurchase {purchaseKey, masterKey, currentBadgeType} balance =
BadgeServiceRequest
{ version = currentBadgeVersion,
purchaseKey = Just purchaseKey,
request =
BSCIssueBadge
{ badgeRequest = BadgeRequest {masterKey, badgeInfo = BadgeInfo {badgeType = currentBadgeType, badgeExpiry = Nothing, badgeExtra = ""}},
balance
}
}
-- | The single send path to the badge service, which C4 replaces with the lazy connection and
-- the signing. Until then every badge request fails locally, which is what the two stub command
-- handlers already report.
sendBadgeRequest :: Maybe C.PrivateKeyEd25519 -> BadgeServiceRequest -> CM BadgeServiceResponse
sendBadgeRequest _signKey _request = pure BSPError {code = BSEInternal, message = Just notImplementedMessage, retryAfter = Nothing}
-- | What one applied badge response changed, which is what decides what happens once the badge
-- lock is released.
data BadgeChange
= -- | Nothing was written: no work, a service error, or a credential that failed verification.
BadgeUnchanged
| -- | Ledger rows were stored and the profile's own badge is untouched — the exhausted
-- balance, which is not an error.
BadgeLedgerChanged User
| -- | A new credential is on the profile, so it is presented to contacts as well as reported.
BadgeIssued User
-- | Applies one @issueBadge@ response, in ONE transaction, and reports what it changed.
--
-- The caller must hold the per-user badge lock and must NOT hold it while passing the result to
-- 'presentBadgeChange'.
--
-- A credential that fails verification is discarded and NOTHING is written, the statement
-- included: a response whose credential is not ours is not a response to trust the rest of. An
-- absent credential is the opposite — the exhausted balance the service defines, whose statement
-- is stored like any other.
storeBadgeIssueResponse :: UTCTime -> User -> UserBadgePurchase -> Maybe BadgeLedgerEntry -> BadgeServiceResponse -> CM BadgeChange
storeBadgeIssueResponse now user UserBadgePurchase {badgePurchaseId = pId} heldEntry_ = \case
BSPBadgeCredential {credential = Nothing, statement} -> do
withStore $ \db -> insertLedgerEntries db pId statement now
ledgerChange
BSPBadgeCredential {credential = Just cred, statement} ->
verifyUserBadge cred >>= \case
Left e -> badgeFailed e
Right () -> case cred of
BadgeCredential {badgeInfo = info@BadgeInfo {badgeType, badgeExpiry = Just expiry}} -> do
user' <- withStore $ \db -> do
insertLedgerEntries db pId statement now
-- the issuance is written only for a statement that carries the period it issued;
-- ledgerEntryId stays absent (see 'issuedBadgePeriod' and plan §9)
forM_ (issuedBadgePeriod heldEntry_ statement) $ \(periodStart, periodEnd) ->
liftIO . void $
createIssuance db NewBadgeIssuance {badgePurchaseId = pId, badgeType, periodStart, periodEnd, expiry, ledgerEntryId = Nothing, credential = cred} now
liftIO $ setUserBadge db user (Just (OwnBadge cred (mkBadgeStatus now (Just True) info)))
asks currentUser >>= atomically . (`writeTVar` Just user')
pure $ BadgeIssued user'
-- 'issueSignedBadge' always sets the expiry, so this is a malformed response rather than
-- a lifetime credential, and a lifetime badge has no issuance row to write anyway
_ -> badgeFailed "issued badge credential carries no expiry"
BSPError {code, message, retryAfter} ->
BadgeUnchanged <$ eToView (ChatError CEBadgeServiceError {badgeError = code, badgeErrorMessage = message, retryAfter})
r -> badgeFailed $ "unexpected badge service response to issueBadge: " <> tshow r
where
badgeFailed e = BadgeUnchanged <$ eToView (ChatError $ localBadgeError e)
-- the ledger the statement left, against the one that was held: nothing else the badge state
-- reports comes from the ledger, so an unchanged last entry is an unchanged state
ledgerChange = do
lastEntry_ <- withFastStore $ \db -> getLastBadgeLedgerEntry db pId
pure $ if ledgerPosition lastEntry_ == ledgerPosition heldEntry_ then BadgeUnchanged else BadgeLedgerChanged user
ledgerPosition = fmap $ \BadgeLedgerEntry {entryUuid, balanceMonths, balanceStartTs} -> (entryUuid, balanceMonths, balanceStartTs)
-- | The period a statement issued, when it carries one.
--
-- The service writes the @debit(badge)@ LAST, after any lapse and credit, and @issue@ sets
-- 'balanceStartTs' to the period's END, so the last entry's 'balanceStartTs' is the period end
-- and the one in force immediately before it is the period start. That predecessor is the entry
-- before it in the statement, or, for a statement that appends, the last entry the client
-- already held. A complete history that contains a @debit(badge)@ always contains it: a balance
-- can only have become positive through a credit entry.
--
-- The period is NOT derived from the credential: its expiry is the Sunday after the period end,
-- and that is not invertible.
issuedBadgePeriod :: Maybe BadgeLedgerEntry -> BadgeStatement -> Maybe (UTCTime, UTCTime)
issuedBadgePeriod heldEntry_ BadgeStatement {entries} = case reverse entries of
StatementEntry {entryType = SEDebit SDBadge, balanceStartTs = periodEnd} : earlier -> (,periodEnd) <$> periodStart earlier
_ -> Nothing
where
periodStart (StatementEntry {balanceStartTs} : _) = Just balanceStartTs
periodStart [] = (\BadgeLedgerEntry {balanceStartTs} -> balanceStartTs) <$> heldEntry_
-- | The pass's unlocked half: a new credential is presented to contacts, and any change at all
-- is reported to the app. It must be called with the per-user badge lock RELEASED, because
-- 'presentUserBadgeToContacts' takes 'chatLock'.
presentBadgeChange :: BadgeChange -> CM ()
presentBadgeChange = \case
BadgeUnchanged -> pure ()
BadgeLedgerChanged user -> badgeChanged user
BadgeIssued user -> presentUserBadgeToContacts user >> badgeChanged user
where
badgeChanged user = toView . CEvtBadgeChanged user =<< getUserBadgeState user
assertDirectAllowed :: User -> MsgDirection -> Contact -> CMEventTag e -> CM ()
assertDirectAllowed user dir ct event =
unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $
+12
View File
@@ -159,6 +159,18 @@ withFileLock :: Text -> Int64 -> CM a -> CM a
withFileLock name = withEntityLock name . CLFile
{-# INLINE withFileLock #-}
-- | Serializes the signed badge operations of one profile: the 'BadgeManager' pass and every
-- command that sends a signed badge request take it, so one such operation per user is in
-- flight and no two of them interleave their reads and writes of that profile's badge rows.
--
-- It is an ordinary entity lock over the shared 'entityLocks' map, so it inherits the
-- @chatLock@-first order rather than introducing one. A holder must RELEASE it before calling
-- anything that takes @chatLock@ itself — 'Simplex.Chat.Library.Commands.presentUserBadgeToContacts'
-- in particular.
withBadgeLock :: Text -> UserId -> CM a -> CM a
withBadgeLock name = withEntityLock name . CLBadge
{-# INLINE withBadgeLock #-}
useServerCfgs :: forall p. UserProtocol p => SProtocolType p -> RandomAgentServers -> [(Text, ServerOperator)] -> [UserServer p] -> NonEmpty (ServerCfg p)
useServerCfgs p RandomAgentServers {smpServers, xftpServers} opDomains =
fromMaybe (rndAgentServers p) . L.nonEmpty . agentServerCfgs p opDomains
+4
View File
@@ -73,6 +73,10 @@ data ChatLockEntity
| CLUserContact Int64
| CLContactRequest Int64
| CLFile Int64
| -- | One signed badge operation per profile at a time: the monthly issuance pass and the
-- commands that send signed badge requests all take it, so they cannot interleave their
-- reads and writes of that profile's purchase, ledger and badge rows.
CLBadge UserId
deriving (Eq, Ord)
-- These error type constructors must be added to mobile apps
+324
View File
@@ -0,0 +1,324 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
-- | Tests for the @BadgeManager@ worker: what one pass writes when a response is applied, and
-- that the signals arriving during a pass run one more pass rather than a concurrent one.
--
-- Registered under the "Supporter badges" hspec path so CI runs it (plan §4 rule 8), inside
-- 'testBracket', which is what gives these tests a chat controller. No badge service is started
-- and no RPC is sent: 'sendBadgeRequest' is C3's stub, so the tests apply a response to the pass
-- directly, composing the pass's two production halves ('storeBadgeIssueResponse' under the
-- badge lock in production, then 'presentBadgeChange' outside it) exactly as
-- 'issueDueBadgePeriod' and 'badgeManagerPass' compose them.
--
-- __No test waits on 'badgePassInterval'.__ A pass is driven by @\/_badge state@ (which signals
-- the worker) and paced by the injected 'badgeCurrentTime', which every pass reads exactly once
-- before it reads any state — so the test can count passes and hold one open without sleeping.
module Bots.BadgeManagerTests (badgeManagerTests) where
import ChatClient
import ChatTests.DBUtils
import ChatTests.Profiles (testBadgeKeys)
import ChatTests.Utils
import Control.Concurrent.STM (retry)
import Control.Monad (forM_, unless)
import Control.Monad.Except (runExceptT)
import Control.Monad.Reader (runReaderT)
import qualified Data.Aeson as J
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock (UTCTime (..), secondsToDiffTime)
import Simplex.Chat.Badges (BadgeCredential (..), BadgeInfo (..), BadgeRequest (..), BadgeType (..), VerifiedBadgeRequest (..), generateMasterKey, issueBadge)
import Simplex.Chat.Badges.Months (addMonths, sundayAfter)
import Simplex.Chat.Badges.Service
( BadgeServiceResponse (..),
BadgeStatement (..),
StatementCreditType (..),
StatementDebitType (..),
StatementEntry (..),
StatementEntryType (..),
)
import Simplex.Chat.Badges.Types (BadgeLedgerEntry (..))
import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), CM)
import Simplex.Chat.Library.Commands (presentBadgeChange, storeBadgeIssueResponse)
import Simplex.Chat.Store.Badges (UserBadgePurchase (..), createCodePayment, createPurchase, getLastBadgeLedgerEntry, insertLedgerEntries, setShownPurchase)
import Simplex.Chat.Types (User (..))
import Simplex.Messaging.Agent.Store.Common (withTransaction)
import Simplex.Messaging.Agent.Store.DB (Binary (..))
import qualified Simplex.Messaging.Agent.Store.DB as DB
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.BBS (BBSPublicKey, BBSSecretKey, bbsKeyGen)
import Test.Hspec hiding (it)
import System.Timeout (timeout)
import UnliftIO.STM
#if defined(dbPostgres)
import Database.PostgreSQL.Simple (Only (..))
#else
import Database.SQLite.Simple (Only (..))
#endif
badgeManagerTests :: SpecWith TestParams
badgeManagerTests = do
describe "applying an issueBadge response" $ do
it "stores the statement, writes the issuance, sets the profile badge and reports the change" testIssuedCredentialApplied
it "discards a credential signed by another key and writes nothing at all" testForeignCredentialRejected
it "an exhausted balance stores the statement, writes no issuance and keeps the badge" testExhaustedBalanceApplied
describe "the badge worker" $
it "collapses the signals that arrive during a pass into one more pass, never a concurrent one" testSignalsDuringPassRunOnePass
-- Fixtures --------------------------------------------------------------------
-- | The instant the fixture's balance starts running, and the start of the period the pass
-- issues. Whole seconds: Postgres @TIMESTAMPTZ@ keeps microseconds while 'getCurrentTime' has
-- nanosecond resolution on Linux, so only a rounded instant survives both backends.
periodStart :: UTCTime
periodStart = UTCTime (fromGregorian 2026 3 1) (secondsToDiffTime 0)
-- | The end of that period, which is where @issue@ leaves @balanceStartTs@.
periodEnd :: UTCTime
periodEnd = addMonths 1 periodStart
-- | Inside the period, so the fixture's balance funds an unissued month at this instant.
passNow :: UTCTime
passNow = UTCTime (fromGregorian 2026 3 15) (secondsToDiffTime 0)
heldEntryUuid :: Text
heldEntryUuid = "held-credit"
-- | The purchase's ledger before the pass: two months credited, running from 'periodStart'.
creditEntry :: StatementEntry
creditEntry = fixtureEntry heldEntryUuid 2 2 periodStart (SECredit SCPayment {invoiceId = Nothing})
-- | The @debit(badge)@ an issue writes, and the only thing the client can read the issued period
-- back from: one month spent, and @balanceStartTs@ moved to the END of the period issued.
issuedDebitEntry :: StatementEntry
issuedDebitEntry = fixtureEntry "issued-debit" (-1) 1 periodEnd (SEDebit SDBadge)
fixtureEntry :: Text -> Int -> Int -> UTCTime -> StatementEntryType -> StatementEntry
fixtureEntry entryId changeMonths balanceMonths balanceStartTs entryType =
StatementEntry
{ entryId,
changeMonths,
balanceMonths,
balanceStartTs,
balanceBadgeType = BTSupporter,
wasPausedSince = Nothing,
-- the service's clock, identical for every entry of one statement
createdAt = passNow,
entryType
}
-- | Counts and paces the badge passes of a controller. Every pass reads 'badgeCurrentTime'
-- exactly once, before it reads any state, so 'passes' is the number of passes that have STARTED
-- and a pass gets no further until the test grants a permit.
--
-- Tests that apply a response themselves grant none: that parks the pass chat start signals
-- before it can read the store, so it can never race their fixtures.
data BadgeGate = BadgeGate {passes :: TVar Int, permits :: TVar Int}
gatedClock :: BadgeGate -> IO UTCTime
gatedClock BadgeGate {passes, permits} = do
atomically $ modifyTVar' passes (+ 1)
atomically $ readTVar permits >>= \n -> if n > 0 then writeTVar permits (n - 1) else retry
pure passNow
allowPass :: BadgeGate -> IO ()
allowPass BadgeGate {permits} = atomically $ modifyTVar' permits (+ 1)
-- | Waits for @n@ passes to have started. Bounded, so a worker that never runs fails the example
-- instead of hanging it; it is not a wait on badge time.
waitPasses :: HasCallStack => BadgeGate -> Int -> Expectation
waitPasses BadgeGate {passes} n =
timeout 10000000 (atomically $ readTVar passes >>= \m -> unless (m >= n) retry) >>= \case
Just () -> pure ()
Nothing -> expectationFailure $ "timed out waiting for badge pass " <> show n
-- | Nothing more is reported: the assertion that a pass emitted no event and raised no error.
reportsNothing :: HasCallStack => TestCC -> Expectation
reportsNothing cc = cc <// 500000
-- | A controller with the test issuer key at index 1 and a gated badge clock.
withBadgeChat :: HasCallStack => TestParams -> BBSPublicKey -> (BadgeGate -> TestCC -> IO ()) -> IO ()
withBadgeChat ps pk test = do
gate <- BadgeGate <$> newTVarIO 0 <*> newTVarIO 0
let cfg = testCfg {badgePublicKeys = testBadgeKeys pk, badgeCurrentTime = gatedClock gate}
withNewTestChatCfg ps cfg "alice" aliceProfile $ test gate
-- | A shown, issued purchase with two months credited, and the last ledger entry a pass would
-- assert to the service.
setupShownPurchase :: TestCC -> IO (UserBadgePurchase, BadgeLedgerEntry)
setupShownPurchase cc = do
User {userId} <- testUser cc
drg <- C.newRandom
(pubKey, privKey) <- atomically $ C.generateKeyPair drg
mk <- generateMasterKey drg
r <- withChatStore cc $ \db -> do
paymentId <- createCodePayment db periodStart
p@UserBadgePurchase {badgePurchaseId = pId} <- createPurchase db userId pubKey privKey mk BTSupporter paymentId periodStart
runExceptT $ do
setShownPurchase db userId pId
insertLedgerEntries db pId BadgeStatement {entries = [creditEntry], previousEntryId = Nothing} periodStart
(p,) <$> getLastBadgeLedgerEntry db pId
case r of
Right (p, Just e) -> pure (p, e)
_ -> fail "badge fixture: the purchase or its credit entry was not stored"
testUser :: TestCC -> IO User
testUser TestCC {chatController = ChatController {currentUser}} =
readTVarIO currentUser >>= maybe (fail "no current user") pure
withChatStore :: TestCC -> (DB.Connection -> IO a) -> IO a
withChatStore TestCC {chatController = ChatController {chatStore}} = withTransaction chatStore
runCM :: TestCC -> CM a -> IO a
runCM TestCC {chatController} action = runReaderT (runExceptT action) chatController >>= either (fail . show) pure
-- | The pass's second half, composed exactly as 'badgeManagerPass' composes it: the response is
-- stored (in production under the per-user badge lock) and the change it made is then presented
-- and reported (in production with that lock released).
applyIssueResponse :: TestCC -> UserBadgePurchase -> Maybe BadgeLedgerEntry -> BadgeServiceResponse -> IO ()
applyIssueResponse cc purchase heldEntry_ resp = do
user <- testUser cc
runCM cc $ storeBadgeIssueResponse passNow user purchase heldEntry_ resp >>= presentBadgeChange
-- | A credential for the fixture's period, signed by the given issuer key. The master key is the
-- PURCHASE's, as the request the worker sends carries it.
issuedCredential :: BBSSecretKey -> UserBadgePurchase -> IO BadgeCredential
issuedCredential sk UserBadgePurchase {masterKey} = do
let badgeInfo = BadgeInfo {badgeType = BTSupporter, badgeExpiry = Just (sundayAfter periodEnd), badgeExtra = ""}
issueBadge 1 sk (VerifiedBadgeRequest BadgeRequest {masterKey, badgeInfo}) >>= either fail pure
badgeCredentialResponse :: Maybe BadgeCredential -> BadgeStatement -> BadgeServiceResponse
badgeCredentialResponse credential statement = BSPBadgeCredential {credential, receipt = Nothing, statement}
-- Assertions ------------------------------------------------------------------
lastLedgerEntry :: TestCC -> Int64 -> IO (Maybe BadgeLedgerEntry)
lastLedgerEntry cc pId =
withChatStore cc (\db -> runExceptT $ getLastBadgeLedgerEntry db pId) >>= either (fail . show) pure
ledgerUuids :: TestCC -> Int64 -> IO [Text]
ledgerUuids cc pId =
map fromOnly
<$> withChatStore cc (\db -> DB.query db "SELECT entry_uuid FROM badge_ledger WHERE badge_purchase_id = ? ORDER BY entry_id ASC" (Only pId))
-- | @(badge_purchase_id, badge_type, period_start, period_end, expiry, entry_id)@ of every
-- issuance, read from the table rather than from what 'createIssuance' returned.
issuanceRows :: TestCC -> IO [(Int64, Text, UTCTime, UTCTime, UTCTime, Maybe Int64)]
issuanceRows cc =
withChatStore cc $ \db ->
DB.query_ db "SELECT badge_purchase_id, badge_type, period_start, period_end, expiry, entry_id FROM badge_issuances ORDER BY period_start ASC"
-- | The credential stored with the only issuance, decoded from the JSON it is stored as.
storedCredential :: TestCC -> IO BadgeCredential
storedCredential cc = do
rows <- withChatStore cc $ \db -> DB.query_ db "SELECT credential FROM badge_issuances"
case rows of
[Only (Binary bs)] -> maybe (fail "stored badge credential does not decode") pure (J.decodeStrict (bs :: ByteString))
_ -> fail $ "expected exactly one badge issuance, got " <> show (length rows)
hasNoBadge :: HasCallStack => TestCC -> Expectation
hasNoBadge cc = do
cc ##> "/p"
cc <## "user profile: alice (Alice)"
cc <## "use /p <name> [<bio>] to change it"
hasSupporterBadge :: HasCallStack => TestCC -> Expectation
hasSupporterBadge cc = do
cc ##> "/p"
cc <## "user profile: alice (Alice, * supporter)"
cc <## "use /p <name> [<bio>] to change it"
-- Tests -----------------------------------------------------------------------
-- | The whole write set of one issued period, in one place: the statement's rows, the issuance
-- naming the period the @debit(badge)@ implies, the credential on the profile, and the event.
testIssuedCredentialApplied :: HasCallStack => TestParams -> IO ()
testIssuedCredentialApplied ps = do
Right (pk, sk) <- bbsKeyGen
withBadgeChat ps pk $ \_gate alice -> do
(purchase@UserBadgePurchase {badgePurchaseId = pId}, heldEntry) <- setupShownPurchase alice
cred <- issuedCredential sk purchase
let statement = BadgeStatement {entries = [issuedDebitEntry], previousEntryId = Just heldEntryUuid}
applyIssueResponse alice purchase (Just heldEntry) (badgeCredentialResponse (Just cred) statement)
-- the balance left, and the date it is paid through: one month from the END of the period
-- just issued, never the credential's expiry
alice <## "supporter badge 1 (shown): issued, 1 month(s) left, paid through 2026-05-01"
ledgerUuids alice pId `shouldReturn` [heldEntryUuid, "issued-debit"]
-- period_start is the balanceStartTs in force before the debit(badge) and period_end is the
-- one the debit left; entry_id stays NULL (plan §9)
issuanceRows alice `shouldReturn` [(pId, "supporter", periodStart, periodEnd, sundayAfter periodEnd, Nothing)]
storedCredential alice `shouldReturn` cred
hasSupporterBadge alice
-- | The credential verifies against a key the controller is not configured with, so the whole
-- response is discarded — the statement included, since a response whose credential is not ours
-- is not one to trust the rest of.
testForeignCredentialRejected :: HasCallStack => TestParams -> IO ()
testForeignCredentialRejected ps = do
Right (pk, _sk) <- bbsKeyGen
Right (_otherPk, otherSk) <- bbsKeyGen
withBadgeChat ps pk $ \_gate alice -> do
(purchase@UserBadgePurchase {badgePurchaseId = pId}, heldEntry) <- setupShownPurchase alice
cred <- issuedCredential otherSk purchase
let statement = BadgeStatement {entries = [issuedDebitEntry], previousEntryId = Just heldEntryUuid}
applyIssueResponse alice purchase (Just heldEntry) (badgeCredentialResponse (Just cred) statement)
alice <## "badge service error: internal, badge credential does not verify against configured key"
ledgerUuids alice pId `shouldReturn` [heldEntryUuid]
issuanceRows alice `shouldReturn` []
hasNoBadge alice
reportsNothing alice
-- | @credential = Nothing@ is the exhausted balance, not an error: the statement is stored, no
-- issuance is written, the profile's badge is left alone, and the event is emitted only because
-- the stored state actually changed — re-delivering the same statement emits nothing.
testExhaustedBalanceApplied :: HasCallStack => TestParams -> IO ()
testExhaustedBalanceApplied ps = do
Right (pk, _sk) <- bbsKeyGen
withBadgeChat ps pk $ \_gate alice -> do
(purchase@UserBadgePurchase {badgePurchaseId = pId}, heldEntry) <- setupShownPurchase alice
let lapsed = fixtureEntry "lapse-1" (-2) 0 (addMonths 2 periodStart) (SEDebit SDLapse)
statement = BadgeStatement {entries = [lapsed], previousEntryId = Just heldEntryUuid}
applyIssueResponse alice purchase (Just heldEntry) (badgeCredentialResponse Nothing statement)
alice <## "supporter badge 1 (shown): issued, 0 month(s) left, paid through 2026-05-01"
ledgerUuids alice pId `shouldReturn` [heldEntryUuid, "lapse-1"]
issuanceRows alice `shouldReturn` []
hasNoBadge alice
-- the same statement again changes nothing, so nothing is reported
heldEntry' <- lastLedgerEntry alice pId
applyIssueResponse alice purchase heldEntry' (badgeCredentialResponse Nothing statement)
ledgerUuids alice pId `shouldReturn` [heldEntryUuid, "lapse-1"]
reportsNothing alice
-- | Three signals arriving while a pass runs leave the worker with ONE more pass to run, not
-- three and not a concurrent one. The gated clock is what makes this deterministic: the pass
-- chat start signalled is parked at its first line, so every signal below provably arrives
-- while it is still running.
testSignalsDuringPassRunOnePass :: HasCallStack => TestParams -> IO ()
testSignalsDuringPassRunOnePass ps = do
Right (pk, _sk) <- bbsKeyGen
withBadgeChat ps pk $ \gate alice -> do
waitPasses gate 1
_ <- setupShownPurchase alice
forM_ [1 :: Int .. 3] $ \_ -> do
alice ##> "/_badge state 1"
alice <## "supporter badge 1 (shown): issued, 2 month(s) left, paid through 2026-05-01"
alice <## "badge site: not configured"
-- the three signals did not start a pass of their own: the first one is still running
readTVarIO (passes gate) `shouldReturn` 1
-- released, it finds the month due and reports what C3's stub send path answers
allowPass gate
alice <## "badge service error: internal, not implemented"
waitPasses gate 2
allowPass gate
alice <## "badge service error: internal, not implemented"
-- and that is the only pass the three signals bought, however many they were
reportsNothing alice
readTVarIO (passes gate) `shouldReturn` 2
+2
View File
@@ -5,6 +5,7 @@
import Bots.BadgeCodeTests
import Bots.BadgeLedgerTests
import Bots.BadgeManagerTests
import Bots.BadgeServiceTests
import Bots.BadgeStoreTests
import Bots.BroadcastTests
@@ -90,6 +91,7 @@ main = do
describe "Mobile API Tests" mobileTests
#endif
describe "Supporter badges store" badgeStoreTests
describe "Supporter badges manager" badgeManagerTests
describe "SimpleX chat client" chatTests
xdescribe'' "SimpleX Broadcast bot" broadcastBotTests
xdescribe'' "SimpleX Directory service bot" directoryServiceTests