packages feed

shomei-core-0.2.0.0: test/Shomei/ServiceAccountStoreSpec.hs

{-# LANGUAGE DataKinds #-}

-- | Pure tests for the in-memory 'Shomei.ServiceAccount.Store' interpreter
-- ('Shomei.Test.InMemory.runServiceAccountStore').
--
-- They prove the persistence contract EP-4's @client_credentials@ grant builds on, against the
-- fake 'World': an account can be created and found by its client id; its secret can be rotated
-- (the new hash is what a later lookup sees, and @rotated_at@ is stamped); it can be revoked
-- (status flips, @revoked_at@ is stamped, and the row survives so the lookup still resolves);
-- and the listing is newest-first. No database is involved — the same behavior is re-proven
-- against real PostgreSQL by @shomei-postgres@'s integration test.
--
-- Each case runs its port actions inside 'runInMemory' and asserts on the returned values in
-- 'IO', which is how the sibling 'Shomei.PasskeyStoreSpec' is written.
module Shomei.ServiceAccountStoreSpec (tests) where

import Control.Monad.IO.Class (MonadIO)
import Data.IORef (IORef, newIORef)
import Data.Maybe (isNothing)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Time (UTCTime (..), addUTCTime, fromGregorian)
import Shomei.Authorization.Claims.Domain (Scope (..))
import Shomei.Id (ServiceAccountDbId, UserId, genServiceAccountDbId, genUserId, idText)
import Shomei.ServiceAccount.Domain
  ( NewServiceAccount (..),
    ServiceAccount (..),
    ServiceAccountStatus (..),
  )
import Shomei.ServiceAccount.Store
  ( createServiceAccount,
    findServiceAccountByClientId,
    listServiceAccounts,
    revokeServiceAccount,
    rotateServiceAccountSecret,
  )
import Shomei.Test.InMemory (World, emptyWorld, runInMemory)
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "ServiceAccountStore (in-memory)"
    [ testCase "create then find by client id" createAndFind,
      testCase "find by an unknown client id returns Nothing" findUnknown,
      testCase "rotate replaces the hash and stamps rotated_at" rotateSecret,
      testCase "revoke flips status, stamps revoked_at, and keeps the row" revoke,
      testCase "list is newest-first" listNewestFirst
    ]

-- Field accessors: OverloadedRecordDot is unreliable for these DuplicateRecordFields
-- records (MasterPlan 3 discovery), so read them by record-pattern matching.

saStatus :: ServiceAccount -> ServiceAccountStatus
saStatus ServiceAccount {status} = status

saSecretHash :: ServiceAccount -> Text
saSecretHash ServiceAccount {secretHash} = secretHash

saRotatedAt :: ServiceAccount -> Maybe UTCTime
saRotatedAt ServiceAccount {rotatedAt} = rotatedAt

saRevokedAt :: ServiceAccount -> Maybe UTCTime
saRevokedAt ServiceAccount {revokedAt} = revokedAt

saClientId :: ServiceAccount -> Text
saClientId ServiceAccount {clientId} = clientId

saAllowedScopes :: ServiceAccount -> Set Scope
saAllowedScopes ServiceAccount {allowedScopes} = allowedScopes

saId :: ServiceAccount -> ServiceAccountDbId
saId ServiceAccount {serviceAccountId} = serviceAccountId

saDisplayName :: ServiceAccount -> Text
saDisplayName ServiceAccount {displayName} = displayName

t0 :: UTCTime
t0 = UTCTime (fromGregorian 2026 7 10) 0

newWorld :: IO (IORef World)
newWorld = newIORef (emptyWorld t0)

ingestScope :: Set Scope
ingestScope = Set.singleton (Scope "kawa:ingest")

-- | Build a 'NewServiceAccount' whose @client_id@ is its id's TypeID text, exactly as the CLI
-- does. Runs in any 'MonadIO' so it can be called straight from an 'Eff' block.
mkNew :: (MonadIO m) => UserId -> UTCTime -> Text -> m NewServiceAccount
mkNew uid createdAt displayName = do
  said <- genServiceAccountDbId
  pure
    NewServiceAccount
      { serviceAccountId = said,
        clientId = idText said,
        userId = uid,
        secretHash = "hash-one",
        displayName,
        allowedScopes = ingestScope,
        createdAt
      }

createAndFind :: IO ()
createAndFind = do
  ref <- newWorld
  (created, found) <- runInMemory ref do
    uid <- genUserId
    new <- mkNew uid t0 "rei connector"
    created <- createServiceAccount new
    found <- findServiceAccountByClientId (saClientId created)
    pure (created, found)
  saStatus created @?= ServiceAccountActive
  saRotatedAt created @?= Nothing
  saRevokedAt created @?= Nothing
  saAllowedScopes created @?= ingestScope
  fmap saId found @?= Just (saId created)

findUnknown :: IO ()
findUnknown = do
  ref <- newWorld
  found <- runInMemory ref (findServiceAccountByClientId "svcacct_nope")
  assertBool "unknown client id must not resolve" (isNothing found)

rotateSecret :: IO ()
rotateSecret = do
  ref <- newWorld
  let rotatedTime = addUTCTime 3600 t0
  found <- runInMemory ref do
    uid <- genUserId
    new <- mkNew uid t0 "rei connector"
    created <- createServiceAccount new
    rotateServiceAccountSecret (saId created) "hash-two" rotatedTime
    findServiceAccountByClientId (saClientId created)
  fmap saSecretHash found @?= Just "hash-two"
  fmap saRotatedAt found @?= Just (Just rotatedTime)
  -- Rotation does not revoke.
  fmap saStatus found @?= Just ServiceAccountActive

revoke :: IO ()
revoke = do
  ref <- newWorld
  let revokedTime = addUTCTime 7200 t0
  found <- runInMemory ref do
    uid <- genUserId
    new <- mkNew uid t0 "rei connector"
    created <- createServiceAccount new
    revokeServiceAccount (saId created) revokedTime
    -- The row survives revocation: the grant workflow must be able to see that this client
    -- exists and is revoked, so it can refuse it exactly as it refuses a wrong secret.
    findServiceAccountByClientId (saClientId created)
  fmap saStatus found @?= Just ServiceAccountRevoked
  fmap saRevokedAt found @?= Just (Just revokedTime)

listNewestFirst :: IO ()
listNewestFirst = do
  ref <- newWorld
  accounts <- runInMemory ref do
    uid <- genUserId
    older <- mkNew uid t0 "older"
    newer <- mkNew uid (addUTCTime 60 t0) "newer"
    _ <- createServiceAccount older
    _ <- createServiceAccount newer
    listServiceAccounts
  map saDisplayName accounts @?= ["newer", "older"]