packages feed

hstratus-auth-0.1.0.0: test/HStratus/LoginFSMSpec.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeFamilies #-}

{- |
Module      : HStratus.LoginFSMSpec
Copyright   : (c) 2026 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD-3-Clause

Tests for the iCloud login finite state machine in 'Network.HStratus.Internal.LoginFSM'.
-}
module HStratus.LoginFSMSpec
  ( spec
  )
where

import Data.List.NonEmpty (NonEmpty (..))
import Network.HStratus.Internal.LoginFSM
import Test.Hspec (Spec, describe, it, shouldBe)


newtype TestState s = TestState ()


data Script = Script
  { scriptCreds :: !Bool
  , scriptDir :: !Bool
  , scriptMkDir :: !Bool
  , scriptHasSavedSession :: !Bool
  , scriptSessionValid :: !Bool
  , scriptSrpInvalidKey :: !Bool
  , scriptAcct :: !Bool
  , scriptAcctTwoFa :: !Bool
  , scriptTwoFa :: ![Bool]
  , scriptTwoSa :: ![Bool]
  , scriptNoTrustedDevices :: !Bool
  , scriptTwoFaLocked :: !Bool
  }


allTrue :: Script
allTrue =
  Script
    { scriptCreds = True
    , scriptDir = True
    , scriptMkDir = True
    , scriptHasSavedSession = False
    , scriptSessionValid = False
    , scriptSrpInvalidKey = False
    , scriptAcct = True
    , scriptAcctTwoFa = False
    , scriptTwoFa = [True]
    , scriptTwoSa = [True]
    , scriptNoTrustedDevices = False
    , scriptTwoFaLocked = False
    }


popTwoFa :: TestM Bool
popTwoFa = TestM $ \s -> case scriptTwoFa s of
  (b : bs) -> (b, s{scriptTwoFa = bs})
  [] -> (True, s)


popTwoSa :: TestM Bool
popTwoSa = TestM $ \s -> case scriptTwoSa s of
  (b : bs) -> (b, s{scriptTwoSa = bs})
  [] -> (True, s)


instance LoginEvent TestM where
  type State TestM = TestState


  initial = pure (TestState ())


  ratifyCreds (TestState ()) = asksScript $ \s ->
    if scriptCreds s
      then GotCreds (TestState ())
      else NoCreds (TestState ())


  ratifyArtifactDir (TestState ()) = asksScript $ \s ->
    if scriptDir s
      then DirPresent (TestState ())
      else DirAbsent (TestState ())


  mkArtifactDir (TestState ()) = asksScript $ \s ->
    if scriptMkDir s
      then DirMade (TestState ())
      else NotMade (TestState ())


  loadSession (TestState ()) = asksScript $ \s ->
    if scriptHasSavedSession s
      then HasPriorSession (TestState ())
      else HasClientId (TestState ())


  validateSession (TestState ()) = asksScript $ \s ->
    if scriptSessionValid s
      then SessionStillValid (TestState ())
      else SessionStale (TestState ())


  srpInit (TestState ()) = pure (TestState ())


  srpComplete (TestState ()) = asksScript $ \s ->
    if scriptSrpInvalidKey s
      then SrpCompleteInvalidKey (TestState ())
      else SrpCompleteOk (TestState ())


  acctLogin (TestState ()) = asksScript $ \s ->
    if scriptAcctTwoFa s
      then AcctLogin2FA (TestState ())
      else
        if scriptAcct s
          then AcctLoginOk (TestState ())
          else AcctLogin2SA (TestState ())


  listTwoSaDevices (TestState ()) = pure (TestState ())


  beginTwoFa (TestState ()) _cfg = pure (TestState ())


  doTrust (TestState ()) = pure (TestState ())


  verifyTwoFa (TestState ()) _cfg = do
    result <- popTwoFa
    if result
      then pure $ TwoFaOk (TestState ())
      else asksScript $ \s ->
        if scriptTwoFaLocked s
          then TwoFaLocked (TestState ())
          else TwoFaRetry (TestState ())


  beginTwoSa (TestState ()) _cfg = pure (TestState ())


  verifyTwoSa (TestState ()) _cfg = do
    result <- popTwoSa
    pure $ if result then TwoSaOk (TestState ()) else TwoSaRetry (TestState ())


data Outcome
  = Authenticated
  | TwoFa
  | TwoSa
  | HaltCreds
  | HaltMkDir
  | HaltSrp
  | LockedByTwoFa
  deriving (Eq, Show)


outcomeOf :: LoginOutcome TestState -> Outcome
outcomeOf = \case
  LoginAuthenticated _ -> Authenticated
  LoginNeedsTwoFa _ -> TwoFa
  LoginNeedsTwoSa _ -> TwoSa
  LoginHaltCreds _ -> HaltCreds
  LoginHaltDir _ -> HaltMkDir
  LoginHaltSrp _ -> HaltSrp
  LoginHaltTwoFaLocked _ -> LockedByTwoFa


completionOutcomeOf :: CompletionOutcome TestState -> Outcome
completionOutcomeOf = \case
  CompletionAuthenticated _ -> Authenticated
  CompletionNeedsTwoFa _ -> TwoFa
  CompletionNeedsTwoSa _ -> TwoSa
  CompletionTwoFaLocked _ -> LockedByTwoFa


runScript :: Script -> Outcome
runScript s = outcomeOf $ runTestM loginProcess s


runTwoFaScript :: Script -> Outcome
runTwoFaScript s = completionOutcomeOf $ runTestM (twoFaProcess (TestState ()) dummyTwoFaConfig) s


runTwoSaScript :: Script -> Outcome
runTwoSaScript s = completionOutcomeOf $ runTestM (twoSaProcess (TestState ()) dummyTwoSaConfig) s


dummyTwoFaConfig :: TwoFaConfig
dummyTwoFaConfig = TwoFaConfig{tfcPickPhone = \_ -> pure Nothing, tfcReadCode = \_ -> pure ""}


dummyTwoSaConfig :: TwoSaConfig
dummyTwoSaConfig =
  TwoSaConfig
    { tscPickDevice = \(d :| _) -> pure d
    , tscReadCode = pure ""
    }


spec :: Spec
spec = do
  describe "LoginFSM.loginProcess" $ do
    it "halts when credentials are missing" $
      runScript (allTrue{scriptCreds = False}) `shouldBe` HaltCreds

    it "halts when the artifact directory cannot be created" $
      runScript (allTrue{scriptDir = False, scriptMkDir = False}) `shouldBe` HaltMkDir

    it "halts with invalid SRP key when the server public value is bad" $
      runScript (allTrue{scriptSrpInvalidKey = True}) `shouldBe` HaltSrp

    it "reaches Authenticated on the happy path" $
      runScript allTrue `shouldBe` Authenticated

    it "produces LoginNeedsTwoFa when account login signals 2FA required" $
      runScript (allTrue{scriptAcctTwoFa = True}) `shouldBe` TwoFa

    it "reaches Requires2SA when account login signals 2SA required" $
      runScript (allTrue{scriptAcct = False}) `shouldBe` TwoSa

    it "creates the artifact directory when absent then reaches Authenticated" $
      runScript (allTrue{scriptDir = False}) `shouldBe` Authenticated

    it "returns Authenticated immediately when the saved session is still valid" $
      runScript (allTrue{scriptHasSavedSession = True, scriptSessionValid = True}) `shouldBe` Authenticated

    it "falls through to SRP when the saved session is stale" $
      runScript (allTrue{scriptHasSavedSession = True, scriptSessionValid = False}) `shouldBe` Authenticated

  describe "LoginFSM.twoFaProcess" $ do
    it "reaches Authenticated when 2FA verification succeeds on the first attempt" $
      runTwoFaScript allTrue `shouldBe` Authenticated

    it "retries and reaches Authenticated after a failed 2FA verification" $
      runTwoFaScript (allTrue{scriptTwoFa = [False, True]}) `shouldBe` Authenticated

    it "reaches Requires2SA when account login signals 2SA required after 2FA" $
      runTwoFaScript (allTrue{scriptAcct = False}) `shouldBe` TwoSa

    it "still reaches Authenticated when noTrustedDevices is True" $
      runTwoFaScript (allTrue{scriptNoTrustedDevices = True}) `shouldBe` Authenticated

    it "halts with TwoFaLocked when the code is rejected and the server signals the account is locked" $
      runTwoFaScript (allTrue{scriptTwoFa = [False], scriptTwoFaLocked = True}) `shouldBe` LockedByTwoFa

  describe "LoginFSM.twoSaProcess" $ do
    it "reaches Authenticated when 2SA verification succeeds on the first attempt" $
      runTwoSaScript allTrue `shouldBe` Authenticated

    it "retries and reaches Authenticated after a failed 2SA verification" $
      runTwoSaScript (allTrue{scriptTwoSa = [False, True]}) `shouldBe` Authenticated

    it "reaches Requires2SA when account login signals 2SA required after 2SA" $
      runTwoSaScript (allTrue{scriptAcct = False}) `shouldBe` TwoSa


newtype TestM a = TestM (Script -> (a, Script))


instance Functor TestM where
  fmap f (TestM m) = TestM $ \s -> let (a, s') = m s in (f a, s')


instance Applicative TestM where
  pure a = TestM (a,)
  TestM mf <*> TestM ma = TestM $ \s ->
    let (f, s') = mf s
        (a, s'') = ma s'
     in (f a, s'')


instance Monad TestM where
  return = pure
  TestM ma >>= f = TestM $ \s ->
    let (a, s') = ma s
        TestM mb = f a
     in mb s'


runTestM :: TestM a -> Script -> a
runTestM (TestM m) s = fst (m s)


asksScript :: (Script -> a) -> TestM a
asksScript f = TestM $ \s -> (f s, s)