hstratus-auth-0.1.0.0: test/HStratus/Mock.hs
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : HStratus.Mock
Copyright : (c) 2026 Tim Emiola
Maintainer : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD-3-Clause
A configurable mock iCloud server for hstratus-auth integration tests.
-}
module HStratus.Mock
( Scenario (..)
, SrpOutcome (..)
, defaultScenario
, withMockApp
, withMockAppCapturing
)
where
import Data.ByteString (ByteString)
import qualified Data.ByteString.Lazy as LBS
import Data.IORef (IORef, modifyIORef', newIORef, readIORef, writeIORef)
import Network.HTTP.Types (HeaderName, RequestHeaders, hContentType, mkStatus, status200, status204, status400, status401, status404, status409)
import Network.Wai (Application, pathInfo, rawPathInfo, requestHeaders, requestMethod, responseLBS)
import Network.Wai.Handler.Warp (testWithApplication)
import Paths_hstratus_auth (getDataFileName)
data SrpOutcome = SrpOk | SrpNeeds2FA
deriving (Eq, Show)
data Scenario = Scenario
{ snValidate :: Bool
, snSrpOutcome :: SrpOutcome
, snValidateCodeFails :: Bool
-- ^ when True, the first validateVerificationCode call returns 400
, snAccountLoginNeeds2SA :: Bool
-- ^ when True, the first accountLogin call returns a 2SA-required response
, snAccountLoginNeeds2FA :: Int
-- ^ countdown: serve login_2fa_test.json while > 0, then loginWorking
, snSrpCompleteEmptyError :: Bool
-- ^ when True, signin/complete returns 401 with no body or Content-Type
, snVerifyCodeLocks :: Bool
-- ^ when True, POST verify/trusteddevice/securitycode returns 400 and GET /appleauth/auth returns locked trust data
, snVerifyDeviceCodeFails :: Bool
-- ^ when True, the first POST verify/trusteddevice/securitycode returns 400 (non-locking retry)
, snSrpInitReturnsRetryCode :: Maybe Int
-- ^ when Just n, signin/init returns status n on the first call, then succeeds
}
defaultScenario :: Scenario
defaultScenario =
Scenario
{ snValidate = True
, snSrpOutcome = SrpOk
, snValidateCodeFails = False
, snAccountLoginNeeds2SA = False
, snAccountLoginNeeds2FA = 0
, snSrpCompleteEmptyError = False
, snVerifyCodeLocks = False
, snVerifyDeviceCodeFails = False
, snSrpInitReturnsRetryCode = Nothing
}
jsonHeaders :: [(HeaderName, ByteString)]
jsonHeaders = [(hContentType, "application/json"), ("X-Apple-Session-Token", "mock-session-token-value")]
lockedTrustData :: LBS.ByteString
lockedTrustData =
"{\"securityCode\":{\"length\":6,\"tooManyCodesSent\":false,\"tooManyCodesValidated\":false\
\,\"securityCodeLocked\":true,\"securityCodeCooldown\":false}\
\,\"trustedPhoneNumbers\":[{\"id\":1,\"numberWithDialCode\":\"+1 test\",\"pushMode\":null}]\
\,\"noTrustedDevices\":false}"
withMockApp :: Scenario -> (Int -> IO a) -> IO a
withMockApp scenario action =
withMockAppCapturing scenario $ \port _ -> action port
withMockAppCapturing :: Scenario -> (Int -> IORef [(ByteString, RequestHeaders)] -> IO a) -> IO a
withMockAppCapturing scenario action = do
srpInit <- LBS.readFile =<< getDataFileName "testdata/srp_init_ok_test.json"
login2fa <- LBS.readFile =<< getDataFileName "testdata/login_2fa_test.json"
login2sa <- LBS.readFile =<< getDataFileName "testdata/login_2sa_test.json"
loginWorking <- LBS.readFile =<< getDataFileName "testdata/login_working_test.json"
listDevices <- LBS.readFile =<< getDataFileName "testdata/trusted_devices_test.json"
trustData <- LBS.readFile =<< getDataFileName "testdata/trust_data_test.json"
codeAttemptsRef <- newIORef (0 :: Int)
deviceCodeAttemptsRef <- newIORef (0 :: Int)
accountLoginRef <- newIORef (0 :: Int)
srpInitRetryRef <- newIORef (0 :: Int)
capturedRef <- newIORef []
testWithApplication
( pure $
mockApp
scenario
capturedRef
srpInit
login2fa
login2sa
loginWorking
listDevices
trustData
codeAttemptsRef
deviceCodeAttemptsRef
accountLoginRef
srpInitRetryRef
)
(\port -> action port capturedRef)
mockApp
:: Scenario
-> IORef [(ByteString, RequestHeaders)]
-> LBS.ByteString
-> LBS.ByteString
-> LBS.ByteString
-> LBS.ByteString
-> LBS.ByteString
-> LBS.ByteString
-> IORef Int
-> IORef Int
-> IORef Int
-> IORef Int
-> Application
mockApp
scenario
capturedRef
srpInit
login2fa
login2sa
loginWorking
listDevices
trustData
codeAttemptsRef
deviceCodeAttemptsRef
accountLoginRef
srpInitRetryRef
req
respond = do
modifyIORef' capturedRef ((rawPathInfo req, requestHeaders req) :)
let method = requestMethod req
segs = pathInfo req
json st body = responseLBS st jsonHeaders body
resp <- case (method, segs) of
("GET", ["appleauth", "auth"]) ->
pure $
json status200 $
if snVerifyCodeLocks scenario then lockedTrustData else trustData
("POST", ["appleauth", "auth", "signin", "init"]) -> do
n <- readIORef srpInitRetryRef
writeIORef srpInitRetryRef (n + 1)
pure $ case snSrpInitReturnsRetryCode scenario of
Just code | n == 0 -> responseLBS (mkStatus code "") [] ""
_ -> json status200 srpInit
("POST", ["appleauth", "auth", "signin", "complete"]) ->
pure $
if snSrpCompleteEmptyError scenario
then responseLBS status401 [] ""
else case snSrpOutcome scenario of
SrpOk -> json status200 "{}"
SrpNeeds2FA -> responseLBS status409 jsonHeaders "{}"
("GET", ["appleauth", "auth", "2sv", "trust"]) ->
pure $ json status200 "{}"
("PUT", ["appleauth", "auth", "verify", "trusteddevice", "securitycode"]) ->
pure $ responseLBS status204 [] ""
("POST", ["appleauth", "auth", "verify", "trusteddevice", "securitycode"]) -> do
n <- readIORef deviceCodeAttemptsRef
writeIORef deviceCodeAttemptsRef (n + 1)
pure $
if snVerifyCodeLocks scenario
then responseLBS status400 [] ""
else
if snVerifyDeviceCodeFails scenario && n == 0
then responseLBS status400 [] ""
else json status200 "{}"
("PUT", ["appleauth", "auth", "verify", "phone"]) ->
pure $ json status200 "{}"
("POST", ["appleauth", "auth", "verify", "phone", "securitycode"]) ->
pure $ json status200 "true"
("GET", ["setup", "ws", "1", "listDevices"]) ->
pure $ json status200 listDevices
("POST", ["setup", "ws", "1", "sendVerificationCode"]) ->
pure $ json status200 "{}"
("POST", ["setup", "ws", "1", "validateVerificationCode"]) -> do
n <- readIORef codeAttemptsRef
writeIORef codeAttemptsRef (n + 1)
pure $
if snValidateCodeFails scenario && n == 0
then responseLBS status400 [] ""
else json status200 "{}"
("POST", ["setup", "ws", "1", "validate"]) ->
pure $
if snValidate scenario
then json status200 "{}"
else responseLBS status401 [] ""
("POST", ["setup", "ws", "1", "accountLogin"]) -> do
n <- readIORef accountLoginRef
writeIORef accountLoginRef (n + 1)
pure $
if snAccountLoginNeeds2FA scenario > n
then json status200 login2fa
else
if snAccountLoginNeeds2SA scenario && n == 0
then json status200 login2sa
else json status200 loginWorking
_ -> pure $ responseLBS status404 [] "not found"
respond resp