packages feed

shomei-core-0.2.0.0: test/Shomei/Delegation/WorkflowSpec.hs

-- | Behavioral tests for the impersonation token-exchange workflow
-- ('Shomei.Delegation.Workflow'), run entirely through the in-memory interpreter
-- ('Shomei.Test.InMemory.runInMemory'). No cryptography, no database, no network.
--
-- The in-memory 'Shomei.SigningKey.Signer' fake renders 'AuthClaims' as JSON, so a
-- minted access token decodes straight back to 'AuthClaims' for inspection.
module Shomei.Delegation.WorkflowSpec (tests) where

import Data.Aeson (eitherDecode)
import Data.IORef (IORef, newIORef, readIORef)
import Data.Map.Strict qualified as Map
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text.Lazy qualified as TL
import Data.Text.Lazy.Encoding qualified as TLE
import Data.Time (UTCTime (..), addUTCTime, fromGregorian)
import Shomei.Account.Email.Domain (Email, emailText, mkEmail)
import Shomei.Account.LoginId.Domain (mkLoginId)
import Shomei.Account.Password.Domain (PlainPassword (..))
import Shomei.Account.User.Domain (User (..), UserStatus (UserActive, UserSuspended))
import Shomei.Account.User.Store (updateUserStatus)
import Shomei.Audit.Event.Domain qualified as Event
import Shomei.Authorization.Claims.Domain (Audience (..), AuthClaims (..), Issuer (..), Scope (..))
import Shomei.Config (ImpersonationConfig (..), ShomeiConfig (..), defaultShomeiConfig)
import Shomei.Delegation.Workflow (StartImpersonation (..), startImpersonation, stopImpersonation)
import Shomei.Error (AuthError (ImpersonationForbidden, ImpersonationTargetInvalid))
import Shomei.Id (SessionId, UserId, genUserId)
import Shomei.Session.Authentication.Workflow (signup)
import Shomei.Session.Command (SignupCommand (..))
import Shomei.Session.Domain (Session (..), SessionStatus (SessionRevoked))
import Shomei.Session.RefreshToken.Domain (PersistedRefreshToken (..))
import Shomei.Session.Store (revokeSession)
import Shomei.Session.Token.Domain (AccessToken (..), TokenPair (..))
import Shomei.Test.InMemory (World (..), emptyWorld, runInMemory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))

-- Fixtures -------------------------------------------------------------------

fixedTime :: UTCTime
fixedTime = UTCTime (fromGregorian 2026 1 1) 0

cfg :: ShomeiConfig
cfg = defaultShomeiConfig (Issuer "shomei") (Audience "shomei-clients")

impScope :: Scope
impScope = cfg.impersonationConfig.impersonateScope

customerEmail :: Email
customerEmail = mkEmail' "customer@example.com"

strongPw :: PlainPassword
strongPw = PlainPassword "correct horse battery staple"

mkEmail' :: Text -> Email
mkEmail' t = either (\e -> error ("bad test email: " <> show e)) id (mkEmail t)

expectRight :: (Show e) => Either e a -> IO a
expectRight = either (\e -> assertFailure ("expected Right, got Left: " <> show e)) pure

-- | Caller (operator) claims with the given scopes, issued at @iat@. The caller need
-- not be a stored user — the workflow only reads scopes/issuedAt/subject from the token.
callerClaims :: UserId -> SessionId -> Set Scope -> UTCTime -> AuthClaims
callerClaims uid sid scs iat =
  AuthClaims
    { subject = uid,
      sessionId = sid,
      issuer = cfg.issuer,
      audience = cfg.audience,
      issuedAt = iat,
      expiresAt = addUTCTime 900 iat,
      authTime = iat,
      scopes = scs,
      roles = Set.empty,
      permissions = Set.empty,
      actor = Nothing,
      extraClaims = mempty
    }

-- | Sign up the customer and return their (active) user id.
seedCustomer :: IORef World -> IO UserId
seedCustomer ref = do
  (user, _) <- expectRight =<< runInMemory ref (signup cfg (SignupCommand {loginId = either (error . show) id (mkLoginId (emailText customerEmail)), email = Just customerEmail, password = strongPw, displayName = Just "Customer"}))
  pure user.userId

-- | A real, live operator session with caller-selected claims.
operatorClaims :: IORef World -> Set Scope -> UTCTime -> IO AuthClaims
operatorClaims ref scopes issuedAt = do
  let email = mkEmail' "operator@example.com"
  (operator, pair) <- expectRight =<< runInMemory ref (signup cfg (SignupCommand {loginId = either (error . show) id (mkLoginId (emailText email)), email = Just email, password = strongPw, displayName = Just "Operator"}))
  signed <- decodeAccess pair.accessToken
  pure (callerClaims operator.userId signed.sessionId scopes issuedAt)

freshOperator :: IORef World -> IO AuthClaims
freshOperator ref = operatorClaims ref (Set.singleton impScope) fixedTime

mkStart :: AuthClaims -> UserId -> StartImpersonation
mkStart caller target =
  StartImpersonation
    { actorClaims = caller,
      targetUserId = target,
      reason = "Debugging support issue",
      ticketId = Just "SUP-1234",
      clientIp = Just "203.0.113.7"
    }

-- | Decode the JSON the in-memory signer renders back into 'AuthClaims'.
decodeAccess :: AccessToken -> IO AuthClaims
decodeAccess (AccessToken t) =
  either
    (\e -> assertFailure ("could not decode access token: " <> e))
    pure
    (eitherDecode (TLE.encodeUtf8 (TL.fromStrict t)))

-- Tests ----------------------------------------------------------------------

tests :: TestTree
tests =
  testGroup
    "Shomei.Delegation.Workflow"
    [ testHappyPath,
      testMissingScope,
      testStaleCaller,
      testRevokedCaller,
      testSuspendedCaller,
      testSelfTarget,
      testUnknownTarget,
      testInactiveTarget,
      testStop
    ]

testHappyPath :: TestTree
testHappyPath = testCase "fresh scoped caller impersonating an active target succeeds" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedCustomer ref
  caller <- freshOperator ref
  (session, access) <- expectRight =<< runInMemory ref (startImpersonation cfg (mkStart caller target))
  -- the delegated session records the operator as actor
  session.actor @?= Just caller.subject
  session.userId @?= target
  -- the token names the customer as subject and the operator as actor
  claims <- decodeAccess access
  claims.subject @?= target
  claims.actor @?= Just caller.subject
  -- no refresh token was minted for the delegated session
  world <- readIORef ref
  let refsForSession = filter (\PersistedRefreshToken {sessionId = s} -> s == session.sessionId) (Map.elems world.refreshTokens)
  assertBool "delegated session has no refresh token" (null refsForSession)
  -- an ImpersonationStarted event carrying both ids + the reason was published
  assertBool "ImpersonationStarted published" (any (matchesStarted caller.subject target) world.publishedEvents)
  where
    matchesStarted actorId subj = \case
      Event.ImpersonationStarted d ->
        d.actorUserId == actorId
          && d.subjectUserId == subj
          && d.reason == "Debugging support issue"
      _ -> False

testMissingScope :: TestTree
testMissingScope = testCase "caller without the impersonate scope is forbidden" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedCustomer ref
  caller <- operatorClaims ref Set.empty fixedTime
  res <- runInMemory ref (startImpersonation cfg (mkStart caller target))
  fmap (const ()) res @?= Left ImpersonationForbidden

testStaleCaller :: TestTree
testStaleCaller = testCase "caller whose token predates the freshness window is forbidden" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedCustomer ref
  -- issued one second before the freshness window opens
  let stale = addUTCTime (negate (cfg.impersonationConfig.actorFreshnessWindow + 1)) fixedTime
  caller <- operatorClaims ref (Set.singleton impScope) stale
  res <- runInMemory ref (startImpersonation cfg (mkStart caller target))
  fmap (const ()) res @?= Left ImpersonationForbidden

testRevokedCaller :: TestTree
testRevokedCaller = testCase "caller with a revoked session is forbidden" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedCustomer ref
  caller <- freshOperator ref
  runInMemory ref (revokeSession caller.sessionId fixedTime)
  res <- runInMemory ref (startImpersonation cfg (mkStart caller target))
  fmap (const ()) res @?= Left ImpersonationForbidden

testSuspendedCaller :: TestTree
testSuspendedCaller = testCase "suspended caller is forbidden" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedCustomer ref
  caller <- freshOperator ref
  _ <- runInMemory ref (updateUserStatus caller.subject [UserActive] UserSuspended fixedTime)
  res <- runInMemory ref (startImpersonation cfg (mkStart caller target))
  fmap (const ()) res @?= Left ImpersonationForbidden

testSelfTarget :: TestTree
testSelfTarget = testCase "impersonating yourself is an invalid target" do
  ref <- newIORef (emptyWorld fixedTime)
  caller <- freshOperator ref
  res <- runInMemory ref (startImpersonation cfg (mkStart caller caller.subject))
  fmap (const ()) res @?= Left ImpersonationTargetInvalid

testUnknownTarget :: TestTree
testUnknownTarget = testCase "unknown target is an invalid target" do
  ref <- newIORef (emptyWorld fixedTime)
  caller <- freshOperator ref
  ghost <- genUserId
  res <- runInMemory ref (startImpersonation cfg (mkStart caller ghost))
  fmap (const ()) res @?= Left ImpersonationTargetInvalid

testInactiveTarget :: TestTree
testInactiveTarget = testCase "suspended target is an invalid target" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedCustomer ref
  _ <- runInMemory ref (updateUserStatus target [UserActive] UserSuspended fixedTime)
  caller <- freshOperator ref
  res <- runInMemory ref (startImpersonation cfg (mkStart caller target))
  fmap (const ()) res @?= Left ImpersonationTargetInvalid

testStop :: TestTree
testStop = testCase "stopImpersonation revokes the delegated session and audits the stop" do
  ref <- newIORef (emptyWorld fixedTime)
  target <- seedCustomer ref
  caller <- freshOperator ref
  (session, access) <- expectRight =<< runInMemory ref (startImpersonation cfg (mkStart caller target))
  delegatedClaims <- decodeAccess access
  _ <- expectRight =<< runInMemory ref (stopImpersonation delegatedClaims)
  world <- readIORef ref
  -- the delegated session is now revoked
  case Map.lookup session.sessionId world.sessions of
    Just s -> s.status @?= SessionRevoked
    Nothing -> assertFailure "delegated session vanished"
  assertBool "ImpersonationStopped published" (any (matchesStopped caller.subject target) world.publishedEvents)
  where
    matchesStopped actorId subj = \case
      Event.ImpersonationStopped d -> d.actorUserId == actorId && d.subjectUserId == subj
      _ -> False