packages feed

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

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

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

Tests for the iCloud HTTP client using a mock server.
-}
module HStratus.HttpMockSpec (spec) where

import Control.Exception (try)
import Data.Aeson (decode, encodeFile)
import Data.Bits ((.&.))
import qualified Data.ByteString.Char8 as BS8
import Data.IORef (newIORef, readIORef, writeIORef)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Maybe (fromJust)
import qualified Data.Text as Text
import HStratus.Mock (Scenario (..), SrpOutcome (..), defaultScenario, withMockApp, withMockAppCapturing)
import Network.HStratus.Http
  ( Api
  , AuthState (..)
  , complete2SAWith
  , login
  , loginWith
  , mkApiWith
  )
import Network.HStratus.Http.Endpoints (Endpoints (..))
import Network.HStratus.Internal.HttpErrors (AuthError (..))
import Network.HStratus.Internal.Session (SavedHeaders (..), cookiePath, 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 (methodPost)
import System.FilePath ((</>))
import System.IO.Temp (withSystemTempDirectory)
import System.Posix.Files (fileMode, getFileStatus, setFileMode)
import Test.Hspec (Spec, describe, it, shouldBe, shouldReturn, shouldSatisfy)


spec :: Spec
spec = describe "Network.HStratus.Http.login" $ do
  it "returns Authenticated on fresh login" $
    loginShouldAuthenticate defaultScenario

  it "creates the session directory when absent then returns Authenticated" $
    withSystemTempDirectory "icloud-auth-mock" $ \tmpDir ->
      withMockApi (tmpDir </> "session") defaultScenario $ \api -> do
        isAuthenticated <$> login api `shouldReturn` True

  it "returns Authenticated when saved headers are valid" $
    withSystemTempDirectory "icloud-auth-mock" $ \tmpDir -> do
      writeSavedHeaders tmpDir
      withMockApi tmpDir defaultScenario $ \api -> do
        isAuthenticated <$> login api `shouldReturn` True

  it "falls through to fresh login when validate returns 401" $
    withSystemTempDirectory "icloud-auth-mock" $ \tmpDir -> do
      writeSavedHeaders tmpDir
      withMockApi tmpDir defaultScenario{snValidate = False} $ \api -> do
        isAuthenticated <$> login api `shouldReturn` True

  it "completes 2FA automatically when accountLogin requires 2FA" $
    withFreshMockApi "icloud-auth-mock" defaultScenario{snAccountLoginNeeds2FA = 1} $ \api -> do
      isAuthenticated <$> loginWith (\_ -> pure "123456") (\_ -> pure Nothing) (\_ -> pure testDevice) api `shouldReturn` True

  it "complete2SA returns Authenticated after 2SA challenge" $
    withFreshMockApi "icloud-auth-2sa" defaultScenario $ \api -> do
      result <- complete2SAWith (\_ -> pure testDevice) (pure "0") api (testDevice :| [])
      isAuthenticated result `shouldBe` True

  it "completes 2SA automatically when account login signals 2SA required" $
    withFreshMockApi "icloud-auth-2sa-login" defaultScenario{snAccountLoginNeeds2SA = True} $ \api -> do
      isAuthenticated <$> loginWith (\_ -> pure "0") (\_ -> pure Nothing) (\_ -> pure testDevice) api `shouldReturn` True

  it "throws UnexpectedResponse with HTTP status when error response has no body" $
    withFreshMockApi "icloud-auth-empty-err" defaultScenario{snSrpCompleteEmptyError = True} $ \api -> do
      let throwsBadRequest (Left (UnexpectedResponse msg)) = "bad request" `Text.isPrefixOf` msg
          throwsBadRequest _otherwise = False
      result <- try (login api) :: IO (Either AuthError AuthState)
      result `shouldSatisfy` throwsBadRequest

  it "complete2SA retries when the first verification code is wrong" $ do
    codeRef <- newIORef ["wrongcode", "0"]
    let readCode = do
          codes <- readIORef codeRef
          case codes of
            [] -> fail "no more codes"
            (c : rest) -> writeIORef codeRef rest >> pure c
    withFreshMockApi "icloud-auth-2sa-retry" defaultScenario{snValidateCodeFails = True} $ \api -> do
      result <- complete2SAWith (\_ -> pure testDevice) readCode api (testDevice :| [])
      isAuthenticated result `shouldBe` True

  it "completes 2FA via SMS when phone selector returns a phone" $
    withFreshMockApi "icloud-auth-2fa-sms" defaultScenario{snAccountLoginNeeds2FA = 1} $ \api -> do
      isAuthenticated <$> loginWith (\_ -> pure "654321") (\_ -> pure (Just testPhone)) (\_ -> pure testDevice) api
        `shouldReturn` True

  it "calls GET /appleauth/auth when accountLogin requires 2FA" $
    withSystemTempDirectory "icloud-auth-fetches-trust" $ \tmpDir -> do
      let scenario = defaultScenario{snSrpOutcome = SrpNeeds2FA, snAccountLoginNeeds2FA = 1}
      withMockAppCapturing scenario $ \serverPort capturedRef -> do
        mgr <- newManager defaultManagerSettings
        api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
        _ <- loginWith (\_ -> pure "123456") (\_ -> pure Nothing) (\_ -> pure testDevice) api
        captured <- readIORef capturedRef
        map fst captured `shouldSatisfy` elem "/appleauth/auth"

  it "completes 2FA via device push after retrying when the first code is wrong" $ do
    codeRef <- newIORef ["wrongcode", "123456"]
    let readCode = do
          codes <- readIORef codeRef
          case codes of
            [] -> fail "no more codes"
            (c : rest) -> writeIORef codeRef rest >> pure c
    withFreshMockApi "icloud-auth-2fa-retry" defaultScenario{snAccountLoginNeeds2FA = 1, snVerifyDeviceCodeFails = True} $ \api ->
      isAuthenticated <$> loginWith (const readCode) (\_ -> pure Nothing) (\_ -> pure testDevice) api
        `shouldReturn` True

  it "retries signin/init and completes login when first response is 421" $
    loginShouldAuthenticate defaultScenario{snSrpInitReturnsRetryCode = Just 421}

  it "retries signin/init and completes login when first response is 450" $
    loginShouldAuthenticate defaultScenario{snSrpInitReturnsRetryCode = Just 450}

  it "retries signin/init and completes login when first response is 500" $
    loginShouldAuthenticate defaultScenario{snSrpInitReturnsRetryCode = Just 500}

  it "throws TwoFactorLocked when the server signals the account is locked" $
    withFreshMockApi "icloud-auth-2fa-locked" defaultScenario{snAccountLoginNeeds2FA = 1, snVerifyCodeLocks = True} $ \api -> do
      result <- try (loginWith (\_ -> pure "wrongcode") (\_ -> pure Nothing) (\_ -> pure testDevice) api) :: IO (Either AuthError AuthState)
      result `shouldSatisfy` (\case Left TwoFactorLocked -> True; _ -> False)

  it "sets cookie jar mode to 0o600 after login" $
    withSystemTempDirectory "icloud-auth-cookie-perms" $ \tmpDir ->
      withMockApi tmpDir defaultScenario $ \api -> do
        _ <- login api
        let creds = Credentials "alice@example.com" "password123"
            jarPath = cookiePath tmpDir creds
        shouldHaveMode600 jarPath


shouldHaveMode600 :: FilePath -> IO ()
shouldHaveMode600 filePath = do
  mode <- fileMode <$> getFileStatus filePath
  (mode .&. 0o777) `shouldBe` 0o600


withMockApi :: FilePath -> Scenario -> (Api -> IO a) -> IO a
withMockApi tmpDir scenario action =
  withMockApp scenario $ \serverPort -> do
    mgr <- newManager defaultManagerSettings
    api <- mkApiWith (testSession tmpDir) (testEndpoints serverPort) mgr
    action api


withFreshMockApi :: String -> Scenario -> (Api -> IO a) -> IO a
withFreshMockApi prefix scenario action =
  withSystemTempDirectory prefix $ \tmpDir ->
    withMockApi tmpDir scenario action


loginShouldAuthenticate :: Scenario -> IO ()
loginShouldAuthenticate scenario =
  withFreshMockApi "icloud-auth-mock" scenario $ \api -> do
    result <- login api
    isAuthenticated result `shouldBe` True


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
      }


writeSavedHeaders :: FilePath -> IO ()
writeSavedHeaders tmpDir = do
  let creds = Credentials "alice@example.com" "password123"
      hdrsPath = savedHeadersPath tmpDir creds
      hdrs = SavedHeaders Nothing Nothing (Just "test-token") Nothing Nothing
  encodeFile hdrsPath hdrs
  setFileMode hdrsPath 0o600


isAuthenticated :: AuthState -> Bool
isAuthenticated (Authenticated _ _) = True
isAuthenticated _ = False


testDevice :: Setup2SADevice
testDevice =
  fromJust $
    decode
      "{\"deviceType\":\"SMS\",\"areaCode\":\"\",\"phoneNumber\":\"*******58\",\"deviceId\":\"1\"}"


testPhone :: TrustedPhone
testPhone = TrustedPhone 1 "+81 test" (Just "sms")