hstratus-auth-0.1.0.0: test/HStratus/Http/HeadersSpec.hs
{-# LANGUAGE OverloadedStrings #-}
{- |
Module : HStratus.Http.HeadersSpec
Copyright : (c) 2026 Tim Emiola
Maintainer : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD-3-Clause
Tests for HTTP header handling in the iCloud authentication flow.
-}
module HStratus.Http.HeadersSpec (spec) where
import Data.Aeson (decode, encodeFile)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as BS8
import Data.IORef (readIORef)
import Data.List (find)
import Data.Maybe (fromJust)
import HStratus.Mock (Scenario (..), defaultScenario, withMockAppCapturing)
import Network.HStratus.Http (fetchTrustData, login, loginWith, mkApiWith, requestSmsCode, verifySmsCode)
import Network.HStratus.Http.Endpoints (Endpoints (..))
import Network.HStratus.Internal.Session (SavedHeaders (..), savedHeadersPath)
import Network.HStratus.Session (Credentials (..), Session (..))
import Network.HStratus.Trust (Setup2SADevice, TrustedPhone (..))
import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)
import Network.HTTP.Types (RequestHeaders, hAccept, hContentType, methodPost)
import System.IO.Temp (withSystemTempDirectory)
import System.Posix.Files (setFileMode)
import Test.Hspec (Spec, describe, it, shouldSatisfy)
spec :: Spec
spec = describe "Network.HStratus.Http request headers" $ do
it "signin/init sends Content-Type and Accept: application/json" $
withCapturedLogin (\_ -> pure ()) $ \captured ->
headersFor "/appleauth/auth/signin/init" captured
`shouldSatisfy` hasJsonContentHeaders
it "signin/complete sends Content-Type and Accept: application/json" $
withCapturedLogin (\_ -> pure ()) $ \captured ->
headersFor "/appleauth/auth/signin/complete" captured
`shouldSatisfy` hasJsonContentHeaders
it "2sv/trust sends Accept: application/json" $
withCapturedTwoFa $ \captured ->
headersFor "/appleauth/auth/2sv/trust" captured
`shouldSatisfy` hasJsonAccept
it "validate sends Content-Type and Accept: application/json" $
withCapturedLogin writeSavedHeaders $ \captured ->
headersFor "/setup/ws/1/validate" captured
`shouldSatisfy` hasJsonContentHeaders
it "accountLogin sends Content-Type and Accept: application/json" $
withCapturedLogin (\_ -> pure ()) $ \captured ->
headersFor "/setup/ws/1/accountLogin" captured
`shouldSatisfy` hasJsonContentHeaders
it "accountLogin sends Origin and Referer" $
withCapturedLogin (\_ -> pure ()) $ \captured ->
headersFor "/setup/ws/1/accountLogin" captured
`shouldSatisfy` hasOrigin
it "signin/init sends X-Apple-Widget-Key" $
withCapturedLogin (\_ -> pure ()) $ \captured ->
headersFor "/appleauth/auth/signin/init" captured
`shouldSatisfy` hasWidgetKey
it "2sv/trust sends X-Apple-Widget-Key" $
withCapturedTwoFa $ \captured ->
headersFor "/appleauth/auth/2sv/trust" captured
`shouldSatisfy` hasWidgetKey
it "verify/trusteddevice/securitycode sends Content-Type, Accept: application/json, and X-Apple-Widget-Key" $
withCapturedTwoFa $ \captured ->
headersFor "/appleauth/auth/verify/trusteddevice/securitycode" captured
`shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs)
it "listDevices sends Accept: application/json and X-Apple-Widget-Key" $
withCapturedTwoSa $ \captured ->
headersFor "/setup/ws/1/listDevices" captured
`shouldSatisfy` (\hs -> hasJsonAccept hs && hasWidgetKey hs)
it "sendVerificationCode sends Content-Type, Accept: application/json, and X-Apple-Widget-Key" $
withCapturedTwoSa $ \captured ->
headersFor "/setup/ws/1/sendVerificationCode" captured
`shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs)
it "validateVerificationCode sends Content-Type, Accept: application/json, and X-Apple-Widget-Key" $
withCapturedTwoSa $ \captured ->
headersFor "/setup/ws/1/validateVerificationCode" captured
`shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs)
it "GET /appleauth/auth sends scnt, X-Apple-ID-Session-Id, and X-Apple-Widget-Key" $
withCapturedFetchTrustData $ \captured ->
headersFor "/appleauth/auth" captured
`shouldSatisfy` (\hs -> hasWidgetKey hs && hasScnt hs && hasSessionId hs)
it "requestSmsCode sends Content-Type, Accept, Widget-Key, scnt, and X-Apple-ID-Session-Id" $
withCapturedRequestSms $ \captured ->
headersFor "/appleauth/auth/verify/phone" captured
`shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs && hasScnt hs && hasSessionId hs)
it "verifySmsCode sends Content-Type, Accept, Widget-Key, scnt, and X-Apple-ID-Session-Id" $
withCapturedVerifySms $ \captured ->
headersFor "/verify/phone/securitycode" captured
`shouldSatisfy` (\hs -> hasJsonContentHeaders hs && hasWidgetKey hs && hasScnt hs && hasSessionId hs)
withCapturedLogin :: (FilePath -> IO ()) -> ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
withCapturedLogin setup action =
withSystemTempDirectory "icloud-auth-headers" $ \tmpDir -> do
setup tmpDir
withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
mgr <- newManager defaultManagerSettings
api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
_ <- login api
captured <- readIORef capturedRef
action captured
withCapturedTwoFa :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
withCapturedTwoFa action =
withSystemTempDirectory "icloud-auth-headers-2fa" $ \tmpDir ->
withMockAppCapturing defaultScenario{snAccountLoginNeeds2FA = 1} $ \serverPort capturedRef -> do
mgr <- newManager defaultManagerSettings
api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
_ <- loginWith (\_ -> pure "123456") (\_ -> pure Nothing) (\_ -> pure testDevice) api
captured <- readIORef capturedRef
action captured
withCapturedTwoSa :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
withCapturedTwoSa action =
withSystemTempDirectory "icloud-auth-headers-2sa" $ \tmpDir ->
withMockAppCapturing defaultScenario{snAccountLoginNeeds2SA = True} $ \serverPort capturedRef -> do
mgr <- newManager defaultManagerSettings
api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
_ <- loginWith (\_ -> pure "0") (\_ -> pure Nothing) (\_ -> pure testDevice) api
captured <- readIORef capturedRef
action captured
withCapturedFetchTrustData :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
withCapturedFetchTrustData action =
withSystemTempDirectory "icloud-auth-headers-trustdata" $ \tmpDir -> do
writeSavedHeadersWithSession tmpDir
withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
mgr <- newManager defaultManagerSettings
api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
_ <- fetchTrustData api
captured <- readIORef capturedRef
action captured
writeSavedHeaders :: FilePath -> IO ()
writeSavedHeaders tmpDir = do
let creds = Credentials "alice@example.com" "password123"
shPath = savedHeadersPath tmpDir creds
hdrs = SavedHeaders Nothing Nothing (Just "test-token") Nothing Nothing
encodeFile shPath hdrs
setFileMode shPath 0o600
writeSavedHeadersWithSession :: FilePath -> IO ()
writeSavedHeadersWithSession tmpDir = do
let creds = Credentials "alice@example.com" "password123"
shPath = savedHeadersPath tmpDir creds
hdrs = SavedHeaders Nothing (Just "test-session-id") Nothing Nothing (Just "test-scnt")
encodeFile shPath hdrs
setFileMode shPath 0o600
withCapturedRequestSms :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
withCapturedRequestSms action =
withSystemTempDirectory "icloud-auth-headers-req-sms" $ \tmpDir -> do
writeSavedHeadersWithSession tmpDir
withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
mgr <- newManager defaultManagerSettings
api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
requestSmsCode api testPhone
captured <- readIORef capturedRef
action captured
withCapturedVerifySms :: ([(ByteString, RequestHeaders)] -> IO ()) -> IO ()
withCapturedVerifySms action =
withSystemTempDirectory "icloud-auth-headers-verify-sms" $ \tmpDir -> do
writeSavedHeadersWithSession tmpDir
withMockAppCapturing defaultScenario $ \serverPort capturedRef -> do
mgr <- newManager defaultManagerSettings
api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
_ <- verifySmsCode api testPhone "654321"
captured <- readIORef capturedRef
action captured
testPhone :: TrustedPhone
testPhone = TrustedPhone 1 "+81 test" (Just "sms")
headersFor :: ByteString -> [(ByteString, RequestHeaders)] -> Maybe RequestHeaders
headersFor pathFragment captured =
fmap snd $ find (\(p, _) -> pathFragment `BS8.isSuffixOf` p) captured
hasJsonAccept :: Maybe RequestHeaders -> Bool
hasJsonAccept Nothing = False
hasJsonAccept (Just hs) = any (\(n, v) -> n == hAccept && v == "application/json") hs
hasJsonContentType :: Maybe RequestHeaders -> Bool
hasJsonContentType Nothing = False
hasJsonContentType (Just hs) = any (\(n, v) -> n == hContentType && v == "application/json") hs
hasWidgetKey :: Maybe RequestHeaders -> Bool
hasWidgetKey Nothing = False
hasWidgetKey (Just hs) = any (\(n, _) -> n == "X-Apple-Widget-Key") hs
hasOrigin :: Maybe RequestHeaders -> Bool
hasOrigin Nothing = False
hasOrigin (Just hs) = any (\(n, _) -> n == "Origin") hs
hasJsonContentHeaders :: Maybe RequestHeaders -> Bool
hasJsonContentHeaders hs = hasJsonAccept hs && hasJsonContentType hs
hasScnt :: Maybe RequestHeaders -> Bool
hasScnt Nothing = False
hasScnt (Just hs) = any (\(n, _) -> n == "scnt") hs
hasSessionId :: Maybe RequestHeaders -> Bool
hasSessionId Nothing = False
hasSessionId (Just hs) = any (\(n, _) -> n == "X-Apple-ID-Session-Id") hs
testSession :: FilePath -> Session
testSession topDir =
Session
{ sessionCreds = Credentials "alice@example.com" "password123"
, sessionTopDir = topDir
, sessionClientId = "test-client-id"
}
testEndpoints :: Int -> Endpoints
testEndpoints serverPort =
Endpoints
{ epHome = "http://127.0.0.1:" <> BS8.pack (show serverPort)
, epAuth = mockReq "/appleauth/auth"
, epSetup = mockReq "/setup/ws/1"
, epWidgetKey = "test-widget-key"
}
where
mockReq reqPath =
defaultRequest
{ host = "127.0.0.1"
, port = serverPort
, secure = False
, method = methodPost
, path = reqPath
}
testDevice :: Setup2SADevice
testDevice =
fromJust $
decode
"{\"deviceType\":\"SMS\",\"areaCode\":\"\",\"phoneNumber\":\"*******58\",\"deviceId\":\"1\"}"