shomei-core-0.2.0.0: test/Shomei/Account/Admin/WorkflowSpec.hs
-- | The audited admin lifecycle workflows (EP-2): status transitions, session revocation, and
-- the actor recorded on every event.
--
-- These run on the in-memory interpreters, which implement the same semantics as the PostgreSQL
-- ones (@shomei-postgres/test/Main.hs@ pins the SQL side of listing and revocation).
module Shomei.Account.Admin.WorkflowSpec (tests) where
import Control.Concurrent.Async (mapConcurrently)
import Control.Monad (replicateM)
import Data.IORef (IORef, newIORef, readIORef)
import Data.Time (UTCTime (..), fromGregorian)
import Effectful (Eff)
import Shomei.Account.Admin.Workflow (deleteUser, reinstateUser, revokeOneSession, revokeUserSessions, suspendUser)
import Shomei.Account.Email.Domain (mkEmail)
import Shomei.Account.LoginId.Domain (LoginId, mkLoginId)
import Shomei.Account.Password.Domain (PlainPassword (..))
import Shomei.Account.User.Domain (User (..), UserStatus (..))
import Shomei.Account.User.Store (findUserById)
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Authorization.Claims.Domain (Audience (..), Issuer (..))
import Shomei.Config (ShomeiConfig, defaultShomeiConfig)
import Shomei.Error (AuthError (..))
import Shomei.Id (genSessionId, genUserId)
import Shomei.Session.Authentication.Workflow (LoginResult (..), login, signup)
import Shomei.Session.Command (ClientContext (..), LoginCommand (..), SignupCommand (..))
import Shomei.Session.Domain (Session (..), SessionStatus (SessionActive))
import Shomei.Session.Domain qualified as Session
import Shomei.Session.LoginAttempt.Domain (AccountKey (..), ClientIp (..))
import Shomei.Session.Store (listSessionsForUser)
import Shomei.Test.InMemory (World (..), emptyWorld, runInMemory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))
tests :: TestTree
tests =
testGroup
"Shomei.Account.Admin.Workflow"
[ testSuspendFlipsStatusRevokesSessionsAndRecordsActor,
testConcurrentSuspendsAuditOnce,
testStrictTransitions,
testDeleteIsTerminal,
testReinstateRestoresLogin,
testRevokeUserSessionsCountsOnlyActiveOnes,
testRevokeOneSessionRecordsActor,
testMissingTargetsAreNotFound
]
cfg :: ShomeiConfig
cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")
strongPw :: PlainPassword
strongPw = PlainPassword "correct horse battery staple"
aliceLogin :: LoginId
aliceLogin = either (\e -> error ("bad test login id: " <> show e)) id (mkLoginId "alice@example.com")
ctx :: ClientContext
ctx = ClientContext {clientIp = ClientIp "1.2.3.4", accountKey = AccountKey "k-alice"}
signupCmd :: SignupCommand
signupCmd =
SignupCommand
{ loginId = aliceLogin,
email = Just (either (\e -> error ("bad test email: " <> show e)) id (mkEmail "alice@example.com")),
password = strongPw,
displayName = Nothing
}
loginCmd :: LoginCommand
loginCmd = LoginCommand {loginId = aliceLogin, password = strongPw}
orFail :: (Show e) => Either e a -> Eff es a
orFail = either (\e -> error ("workflow failed: " <> show e)) pure
withWorld :: (IORef World -> IO a) -> IO a
withWorld k = newIORef (emptyWorld (UTCTime (fromGregorian 2026 1 1) 0)) >>= k
-- | Suspension does three things at once, and all three are the point: the status flips, the
-- live sessions die, and the audit event names the administrator who did it. A suspension nobody
-- can be held responsible for is not an administrative action.
testSuspendFlipsStatusRevokesSessionsAndRecordsActor :: TestTree
testSuspendFlipsStatusRevokesSessionsAndRecordsActor =
testCase "suspend: status flips, sessions die, the event names the actor" $ withWorld \ref -> do
(targetId, suspended, admin, sessions) <- runInMemory ref do
(user, _) <- orFail =<< signup cfg signupCmd
admin <- genUserId
_ <- orFail =<< suspendUser admin user.userId
after <- findUserById user.userId
sessions <- listSessionsForUser user.userId
pure (user.userId, after, admin, sessions)
fmap (.status) suspended @?= Just UserSuspended
assertBool "no session is left active" (all ((/= SessionActive) . (.status)) sessions)
published <- (.publishedEvents) <$> readIORef ref
case [d | Event.UserSuspended d <- published] of
[d] -> do
d.actor @?= Just admin
d.userId @?= targetId
other -> assertFailure ("expected exactly one user_suspended event, got " <> show (length other))
-- | The status transition itself is the linearization point: exactly one of many administrators
-- may move the same active account to suspended, and only that winner performs the revocation
-- and audit tail.
testConcurrentSuspendsAuditOnce :: TestTree
testConcurrentSuspendsAuditOnce =
testCase "100 concurrent suspends have one winner and one audit event" $ withWorld \ref -> do
signupResult <- runInMemory ref (signup cfg signupCmd)
(user, _) <- either (assertFailure . ("signup failed: " <>) . show) pure signupResult
admins <- runInMemory ref (replicateM 100 genUserId)
results <- mapConcurrently (\admin -> runInMemory ref (suspendUser admin user.userId)) admins
length (filter (== Right ()) results) @?= 1
length (filter (== Left InvalidUserStatus) results) @?= 99
published <- (.publishedEvents) <$> readIORef ref
length [() | Event.UserSuspended _ <- published] @?= 1
-- | Suspending twice is a 'InvalidUserStatus', not a silent success: two administrators handling
-- one incident must be able to tell which of them changed the state.
testStrictTransitions :: TestTree
testStrictTransitions =
testCase "wrong-state transitions are InvalidUserStatus, never silent" $ withWorld \ref -> do
(doubleSuspend, reinstateActive) <- runInMemory ref do
(user, _) <- orFail =<< signup cfg signupCmd
admin <- genUserId
_ <- orFail =<< suspendUser admin user.userId
doubleSuspend <- suspendUser admin user.userId
_ <- orFail =<< reinstateUser admin user.userId
reinstateActive <- reinstateUser admin user.userId
pure (doubleSuspend, reinstateActive)
doubleSuspend @?= Left InvalidUserStatus
reinstateActive @?= Left InvalidUserStatus
-- | Soft delete is terminal: a deleted user still exists (the audit trail references them) but
-- accepts no further transition.
testDeleteIsTerminal :: TestTree
testDeleteIsTerminal =
testCase "delete is a soft, terminal state" $ withWorld \ref -> do
(after, redelete, reinstate) <- runInMemory ref do
(user, _) <- orFail =<< signup cfg signupCmd
admin <- genUserId
_ <- orFail =<< deleteUser admin user.userId
after <- findUserById user.userId
redelete <- deleteUser admin user.userId
reinstate <- reinstateUser admin user.userId
pure (after, redelete, reinstate)
fmap (.status) after @?= Just UserDeleted
redelete @?= Left InvalidUserStatus
reinstate @?= Left InvalidUserStatus
-- | Reinstatement returns the account to service. The old sessions stay revoked — the user logs
-- in again, which is the whole point of having killed them.
testReinstateRestoresLogin :: TestTree
testReinstateRestoresLogin =
testCase "a reinstated user can log in again; the killed sessions stay dead" $ withWorld \ref -> do
(loginWhileSuspended, loginAfterReinstate, oldSessionStatuses) <- runInMemory ref do
(user, _) <- orFail =<< signup cfg signupCmd
admin <- genUserId
_ <- orFail =<< suspendUser admin user.userId
blocked <- login cfg ctx loginCmd
_ <- orFail =<< reinstateUser admin user.userId
allowed <- login cfg ctx loginCmd
sessions <- listSessionsForUser user.userId
-- The signup session, revoked by the suspension, must still be revoked.
pure (blocked, allowed, [s.status | s <- drop 1 sessions])
-- The workflow says UserNotActive; the HTTP layer collapses it to the generic invalid_login
-- so the API never discloses account state to an unauthenticated caller.
loginWhileSuspended @?= Left UserNotActive
case loginAfterReinstate of
Right (LoginComplete _ _) -> pure ()
Right (MfaRequired _) -> assertFailure "unexpected MFA challenge"
Left e -> assertFailure ("a reinstated user must log in, got " <> show e)
assertBool "the pre-suspension session was not resurrected" (all (/= SessionActive) oldSessionStatuses)
-- | The count is the number of sessions this call actually ended, so an operator reading
-- "revoked 0 sessions" learns something true rather than "revoked 3" about three corpses.
testRevokeUserSessionsCountsOnlyActiveOnes :: TestTree
testRevokeUserSessionsCountsOnlyActiveOnes =
testCase "revokeUserSessions counts only the sessions it ended" $ withWorld \ref -> do
(firstCount, secondCount, admin) <- runInMemory ref do
(user, _) <- orFail =<< signup cfg signupCmd
admin <- genUserId
_ <- orFail =<< login cfg ctx loginCmd -- a second live session
first' <- orFail =<< revokeUserSessions admin user.userId
second' <- orFail =<< revokeUserSessions admin user.userId
pure (first', second', admin)
firstCount @?= 2
secondCount @?= 0
published <- (.publishedEvents) <$> readIORef ref
let adminRevocations = [d | Event.SessionRevoked d <- published, d.revokedBy == Just admin]
length adminRevocations @?= 2
testRevokeOneSessionRecordsActor :: TestTree
testRevokeOneSessionRecordsActor =
testCase "revokeOneSession revokes exactly one session and names the actor" $ withWorld \ref -> do
(sessions, admin) <- runInMemory ref do
(user, _) <- orFail =<< signup cfg signupCmd
admin <- genUserId
_ <- orFail =<< login cfg ctx loginCmd
allSessions <- listSessionsForUser user.userId
case allSessions of
(newest : _) -> do
_ <- orFail =<< revokeOneSession admin newest.sessionId
pure ()
[] -> error "expected two sessions"
after <- listSessionsForUser user.userId
pure (after, admin)
map (.status) sessions @?= [Session.SessionRevoked, SessionActive]
published <- (.publishedEvents) <$> readIORef ref
[d.revokedBy | Event.SessionRevoked d <- published] @?= [Just admin]
testMissingTargetsAreNotFound :: TestTree
testMissingTargetsAreNotFound =
testCase "a target that does not exist is UserNotFound / SessionNotFound" $ withWorld \ref -> do
(suspendMissing, revokeMissing, revokeGhostSession) <- runInMemory ref do
admin <- genUserId
ghost <- genUserId
ghostSession <- genSessionId
suspendMissing <- suspendUser admin ghost
-- revokeUserSessions on an unknown user is not an error: they have no sessions to end.
revokeMissing <- revokeUserSessions admin ghost
revokeGhostSession <- revokeOneSession admin ghostSession
pure (suspendMissing, revokeMissing, revokeGhostSession)
suspendMissing @?= Left UserNotFound
revokeMissing @?= Right 0
revokeGhostSession @?= Left SessionNotFound