packages feed

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

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : HStratus.SessionSpec
Copyright   : (c) 2023 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD3
-}
module HStratus.SessionSpec (spec) where

import Control.Monad (when)
import Data.Aeson (decode, eitherDecodeFileStrict, encode, encodeFile, object, (.=))
import Data.Aeson.Types (parseJSON, parseMaybe)
import Data.Bits ((.&.))
import Data.Either (isLeft)
import Data.List (isInfixOf, sort)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe)
import Data.String (IsString (..))
import Data.Text (Text)
import qualified Data.Text.IO as Text
import Data.Word (Word16)
import HStratus.TrustSpec (jsonKeysOf)
import Network.HStratus.Internal.Session
  ( SavedHeaders (..)
  , accountDataPath
  , accountDataRequires2FA
  , accountDataRequires2SA
  , appBase
  , checkSecureMode
  , clientIdPath
  , cookiePath
  , credentialsPath
  , encodeFileAtomic
  , loadAccountData
  , loadSavedHeaders
  , requireSecureFile
  , saveAccountData
  , saveCredentialsTo
  , savedHeadersPath
  , unknownAccountData
  , updateSessionSavedHeaders
  , (</>)
  )
import Network.HStratus.Session (AccountData (..), Credentials (..), Session (..), Webservice (..), loadSession)
import System.Directory (createDirectory, doesFileExist)
import System.Environment (setEnv)
import System.IO.Error (ioeGetErrorString)
import System.IO.Temp (withSystemTempDirectory)
import System.Posix.Files (fileMode, getFileStatus, setFileMode)
import Test.Hspec
  ( Spec
  , anyIOException
  , around
  , context
  , describe
  , it
  , shouldBe
  , shouldReturn
  , shouldSatisfy
  , shouldThrow
  )
import Test.QuickCheck
  ( Arbitrary (arbitrary)
  , Gen
  , Property
  , elements
  , frequency
  , listOf
  )
import Test.QuickCheck.Monadic (assert, monadicIO, pick, run)


spec :: Spec
spec = do
  secureFileSpec
  secureWriteSpec
  checkSessionFilesSpec
  sessionSpec
  saveCredentialsSpec
  accountDataSpec


secureFileSpec :: Spec
secureFileSpec = describe "module Network.HStratus.Internal.Session (file security)" $ do
  describe "checkSecureMode" $ do
    it "accepts mode 0o600" $
      checkSecureMode 0o600 "/tmp/test" `shouldBe` Right ()
    it "accepts mode 0o400 (read-only owner)" $
      checkSecureMode 0o400 "/tmp/test" `shouldBe` Right ()
    it "accepts mode 0o700 (owner-execute)" $
      checkSecureMode 0o700 "/tmp/test" `shouldBe` Right ()
    it "rejects mode 0o644 (world-readable)" $
      checkSecureMode 0o644 "/tmp/test" `shouldSatisfy` isLeft
    it "rejects mode 0o640 (group-readable)" $
      checkSecureMode 0o640 "/tmp/test" `shouldSatisfy` isLeft
    it "rejects mode 0o604 (world-readable, no group)" $
      checkSecureMode 0o604 "/tmp/test" `shouldSatisfy` isLeft
    it "includes the path in the error message" $
      case checkSecureMode 0o644 "/some/path" of
        Left msg -> "/some/path" `isInfixOf` msg `shouldBe` True
        Right () -> fail "expected Left"
    it "includes chmod hint in the error message" $
      case checkSecureMode 0o644 "/some/path" of
        Left msg -> "chmod 600" `isInfixOf` msg `shouldBe` True
        Right () -> fail "expected Left"
  describe "requireSecureFile" $ around withTmpDir $ do
    it "does nothing when the file is absent" $ \tmpDir -> do
      requireSecureFile (tmpDir </> "absent.txt")
    it "does nothing when the file has mode 0o600" $ \tmpDir -> do
      let path = tmpDir </> "secure.txt"
      writeFile path "content"
      setFileMode path 0o600
      requireSecureFile path
    it "throws when the file has mode 0o644" $ \tmpDir -> do
      let path = tmpDir </> "insecure.txt"
      writeFile path "content"
      setFileMode path 0o644
      requireSecureFile path `shouldThrow` anyIOException
    it "includes the path in the error for a bad-permission file" $ \tmpDir -> do
      let path = tmpDir </> "insecure.txt"
      writeFile path "content"
      setFileMode path 0o644
      requireSecureFile path
        `shouldThrow` (\e -> tmpDir `isInfixOf` ioeGetErrorString e)


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


secureWriteSpec :: Spec
secureWriteSpec = describe "module Network.HStratus.Internal.Session (secure writes)" $ do
  context "saveCredentialsTo" $ around withTmpDir $ do
    it "writes credentials.json with mode 0o600" $ \tmpDir -> do
      saveCredentialsTo tmpDir exampleCred
      shouldHaveMode600 (credentialsPath tmpDir)
  context "updateSessionSavedHeaders" $ around useTmp $ do
    it "writes session headers with mode 0o600" $ \appRoot -> do
      saveCredentialsTo appRoot exampleCred
      s <- loadSession
      updateSessionSavedHeaders s id
      shouldHaveMode600 (savedHeadersPath appRoot (sessionCreds s))
  context "saveAccountData" $ around useTmp $ do
    it "writes account-data with mode 0o600" $ \appRoot -> do
      saveCredentialsTo appRoot exampleCred
      s <- loadSession
      saveAccountData s unknownAccountData
      shouldHaveMode600 (accountDataPath appRoot (sessionCreds s))
  context "loadClientId (new file)" $ around useTmp $ do
    it "creates client-id file with mode 0o600" $ \appRoot -> do
      saveCredentialsTo appRoot exampleCred
      s <- loadSession
      shouldHaveMode600 (clientIdPath appRoot (sessionCreds s))


checkSessionFilesSpec :: Spec
checkSessionFilesSpec = describe "checkSessionFiles / loadSession" $ around useTmp $ do
  it "succeeds when credentials.json has mode 0o600" $ \appRoot -> do
    saveCredentialsTo appRoot exampleCred
    _ <- loadSession
    pure ()
  it "fails when credentials.json has mode 0o644" $ \appRoot -> do
    saveCredentialsTo appRoot exampleCred
    setFileMode (credentialsPath appRoot) 0o644
    loadSession `shouldThrow` anyIOException
  it "fails when session headers file has mode 0o644" $ \appRoot -> do
    saveCredentialsTo appRoot exampleCred
    s <- loadSession
    updateSessionSavedHeaders s id
    setFileMode (savedHeadersPath appRoot (sessionCreds s)) 0o644
    loadSession `shouldThrow` anyIOException


-- save credential somewhere
-- confirm Session loads

-- save credentials
-- save a pre-existing clientId
-- loadSession; confirm loaded session as the clientId

-- save credentials
-- save generated SavedHeaders
-- loadSession; confirm saveHeaders are loaded

sessionSpec :: Spec
sessionSpec = describe "module Network.HStratus.Session" $ do
  context "Using an example Credential" $ do
    let topDir = "/tmp/icloud_authspec"
    context "cookiePath" $ do
      it "should be computed correctly" $ do
        let want = "/tmp/icloud_authspec/myaccountid-applecom.cookies.txt"
        cookiePath topDir exampleCred `shouldBe` want

    context "savedHeadersPath" $ do
      it "should be computed correctly" $ do
        let want = "/tmp/icloud_authspec/myaccountid-applecom.session.json"
        savedHeadersPath topDir exampleCred `shouldBe` want

    context "clientIdPath" $ do
      it "should be computed correctly" $ do
        let want = "/tmp/icloud_authspec/myaccountid-applecom.client-id.txt"
        clientIdPath topDir exampleCred `shouldBe` want
  savedHeadersFieldNamesSpec
  loadSessionSpec
  loadSavedHeadersSpec
  updateSessionSavedHeadersSpec
  encodeFileAtomicSpec


savedHeadersFieldNamesSpec :: Spec
savedHeadersFieldNamesSpec = describe "SavedHeaders JSON field names" $ do
  it "uses the expected field names" $
    jsonKeysOf (SavedHeaders (Just "a") (Just "b") (Just "c") (Just "d") (Just "e"))
      `shouldBe` Just (sort ["country", "session_id", "session_token", "trust_token", "counter"])


loadSessionSpec :: Spec
loadSessionSpec = describe "loadSession" $ around useTmp $ do
  context "with an invalid credentials file" $ do
    it "should fail to load" $ \appRoot ->
      failsOnBadCredentials appRoot `shouldThrow` anyIOException
  context "with only the credentials file" $ do
    it "should load a Session with a new clientId" prop_loadsSession
  context "with a clientId file available" $ do
    it "should load the saved clientId" prop_readsStoredCliendId
  context "with a saved headers file" $ do
    it "should load the saved headers" prop_readsStoredSavedHeaders


loadSavedHeadersSpec :: Spec
loadSavedHeadersSpec = describe "loadSavedHeaders" $ around useTmp $ do
  context "with an invalid saved headers file" $ do
    it "should fail to load" $ \appRoot ->
      failsOnBadSavedHeaders appRoot `shouldThrow` anyIOException
    it "includes a re-login hint in the error message" $ \appRoot ->
      failsOnBadSavedHeaders appRoot
        `shouldThrow` (\e -> "hstratus auth login" `isInfixOf` ioeGetErrorString e)


updateSessionSavedHeadersSpec :: Spec
updateSessionSavedHeadersSpec = describe "updateSessionSavedHeaders" $ around useTmp $ do
  context "when some SaveHeaders are already saved" $ do
    it "should update to the new headers" $ prop_updatesSavedHeaders True
  context "when No SaveHeaders have been saved" $ do
    it "should update to the new headers" $ prop_updatesSavedHeaders True


useTmp :: (FilePath -> IO a) -> IO a
useTmp = withSystemTempDirectory "icloud-auth" . asConfigHome


setupInvalid :: FilePath -> IO ()
setupInvalid path = Text.writeFile path "[}"


failsOnBadCredentials :: FilePath -> IO Session
failsOnBadCredentials appRoot = do
  let path = credentialsPath appRoot
  setupInvalid path
  setFileMode path 0o600
  loadSession


failsOnBadSavedHeaders :: FilePath -> IO SavedHeaders
failsOnBadSavedHeaders appRoot = do
  saveCredentialsTo appRoot exampleCred
  let shPath = savedHeadersPath appRoot exampleCred
  setupInvalid shPath
  setFileMode shPath 0o600
  s <- loadSession
  loadSavedHeaders s


asConfigHome :: (FilePath -> IO a) -> FilePath -> IO a
asConfigHome action root = do
  setEnv "XDG_CONFIG_HOME" root
  let appRoot = root </> appBase
  createDirectory appRoot
  action appRoot


prop_loadsSession :: FilePath -> Property
prop_loadsSession appRoot = monadicIO $ do
  preCreds <- pick genPreCredentials
  let creds = asCreds preCreds
  s <- run $ do
    saveCredentialsTo appRoot creds
    loadSession
  assert $ sessionClientId s /= "" && creds == sessionCreds s


prop_readsStoredCliendId :: FilePath -> Property
prop_readsStoredCliendId appRoot = monadicIO $ do
  preCreds <- pick genPreCredentials
  fakeId <- pick $ genIndexedSuffix "client-id-"
  let creds = asCreds preCreds
  session <- run $ do
    saveCredentialsTo appRoot creds
    let cidPath = clientIdPath appRoot creds
    Text.writeFile cidPath fakeId
    setFileMode cidPath 0o600
    loadSession
  assert $ fakeId == sessionClientId session


prop_readsStoredSavedHeaders :: FilePath -> Property
prop_readsStoredSavedHeaders appRoot = monadicIO $ do
  preCreds <- pick genPreCredentials
  savedHdrs <- pick genSaveHeaders
  let creds = asCreds preCreds
  savedHdrs' <- run $ do
    saveCredentialsTo appRoot creds
    s <- loadSession
    updateSessionSavedHeaders s (const savedHdrs)
    loadSavedHeaders s
  assert $ savedHdrs == savedHdrs'


prop_updatesSavedHeaders :: Bool -> FilePath -> Property
prop_updatesSavedHeaders storeInitial appRoot = monadicIO $ do
  preCreds <- pick genPreCredentials
  savedHdrs <- pick genSaveHeaders
  newHdrs <- pick genSaveHeaders
  let creds = asCreds preCreds
  loadedHdrs <- run $ do
    saveCredentialsTo appRoot creds
    when storeInitial $ do
      let shPath = savedHeadersPath appRoot creds
      encodeFile shPath savedHdrs
      setFileMode shPath 0o600
    s <- loadSession
    updateSessionSavedHeaders s (const newHdrs)
    loadSavedHeaders s
  assert $ newHdrs == loadedHdrs


saveCredentialsSpec :: Spec
saveCredentialsSpec = describe "saveCredentialsTo" $ do
  context "in a pre-existing directory" $ around withTmpDir $ do
    it "creates the credentials file" $ \tmpDir -> do
      saveCredentialsTo tmpDir exampleCred
      doesFileExist (credentialsPath tmpDir) `shouldReturn` True
    it "round-trips credentials through JSON" prop_saveLoadCredentials
    it "overwrites when called a second time" prop_overwritesCredentials
  context "when the target directory does not exist" $ do
    it "creates the directory and the file" $
      withSystemTempDirectory "icloud-auth-creds" $ \tmp -> do
        let target = tmp </> "new-subdir"
        saveCredentialsTo target exampleCred
        doesFileExist (credentialsPath target) `shouldReturn` True


withTmpDir :: (FilePath -> IO a) -> IO a
withTmpDir = withSystemTempDirectory "icloud-auth-creds"


prop_saveLoadCredentials :: FilePath -> Property
prop_saveLoadCredentials tmpDir = monadicIO $ do
  creds <- asCreds <$> pick genPreCredentials
  result <- run $ do
    saveCredentialsTo tmpDir creds
    eitherDecodeFileStrict (credentialsPath tmpDir)
  assert $ result == Right creds


prop_overwritesCredentials :: FilePath -> Property
prop_overwritesCredentials tmpDir = monadicIO $ do
  creds1 <- asCreds <$> pick genPreCredentials
  creds2 <- asCreds <$> pick genPreCredentials
  result <- run $ do
    saveCredentialsTo tmpDir creds1
    saveCredentialsTo tmpDir creds2
    eitherDecodeFileStrict (credentialsPath tmpDir)
  assert $ result == Right creds2


exampleCred :: Credentials
exampleCred =
  Credentials
    { credAccountName = "my-account-id@apple.com"
    , credPassword = "notasecret"
    }


type PreCredentials = (Text, Text)


asCreds :: PreCredentials -> Credentials
asCreds (credAccountName, credPassword) = Credentials{credAccountName, credPassword}


genPreCredentials :: Gen PreCredentials
genPreCredentials =
  let mkId x = "account-" <> x <> "@apple.com"
   in (,) <$> genIndexedTemplate mkId <*> genIndexedSuffix "password-"


genSaveHeaders :: Gen SavedHeaders
genSaveHeaders =
  let arb pre = frequency [(2, pure Nothing), (1, Just <$> genIndexedSuffix pre)]
   in SavedHeaders
        <$> arb "country-"
        <*> arb "session-id-"
        <*> arb "session-token-"
        <*> arb "trust-token-"
        <*> arb "counter="


genWord16 :: Gen Word16
genWord16 = arbitrary


genIndexedSuffix :: (Monoid a, IsString a) => a -> Gen a
genIndexedSuffix pre = genIndexedTemplate (pre <>)


genIndexedTemplate :: (IsString a) => (a -> a) -> Gen a
genIndexedTemplate plate = plate . fromString . show <$> genWord16


accountDataSpec :: Spec
accountDataSpec = describe "module Network.HStratus.Session (AccountData)" $ do
  context "AccountData" $ do
    it "round-trips through JSON encoding" prop_jsonRoundtripAccountData
  context "accountDataRequires2FA" $ do
    it "is True when hsaVersion == 2 and challenged" $
      accountDataRequires2FA (mkAccountData 2 True (Just False)) `shouldBe` True
    it "is True when hsaVersion == 2, not challenged, but browser explicitly untrusted" $
      accountDataRequires2FA (mkAccountData 2 False (Just False)) `shouldBe` True
    it "is False when hsaVersion == 2, not challenged, and browser trusted" $
      accountDataRequires2FA (mkAccountData 2 False (Just True)) `shouldBe` False
    it "is False when hsaVersion == 2, not challenged, and hsaTrustedBrowser absent" $
      accountDataRequires2FA (mkAccountData 2 False Nothing) `shouldBe` False
    it "is False when hsaVersion is 1" $
      accountDataRequires2FA (mkAccountData 1 True (Just False)) `shouldBe` False
    it "is False when hsaVersion is 3 (unknown version)" $
      accountDataRequires2FA (mkAccountData 3 True (Just False)) `shouldBe` False
  context "accountDataRequires2SA" $ do
    it "is True when hsaVersion is 1" $
      accountDataRequires2SA (mkAccountData 1 False (Just False)) `shouldBe` True
    it "is False when hsaVersion is 2" $
      accountDataRequires2SA (mkAccountData 2 False (Just False)) `shouldBe` False
    it "is False when hsaVersion is 0" $
      accountDataRequires2SA (mkAccountData 0 False (Just False)) `shouldBe` False
  context "AccountData JSON parsing" $ do
    it "fails to parse from null JSON" $
      (decode "null" :: Maybe AccountData) `shouldBe` Nothing
    it "fails to parse when dsInfo is absent" $
      (decode "{}" :: Maybe AccountData) `shouldBe` Nothing
  context "saveAccountData / loadAccountData" $ around useTmp $ do
    it "round-trips in a temp directory" prop_saveLoadAccountData


prop_jsonRoundtripAccountData :: Property
prop_jsonRoundtripAccountData = monadicIO $ do
  ad <- pick genAccountData
  assert $ decode (encode ad) == Just ad


prop_saveLoadAccountData :: FilePath -> Property
prop_saveLoadAccountData appRoot = monadicIO $ do
  preCreds <- pick genPreCredentials
  ad <- pick genAccountData
  let creds = asCreds preCreds
  loaded <- run $ do
    saveCredentialsTo appRoot creds
    s <- loadSession
    saveAccountData s ad
    loadAccountData s
  assert $ Just ad == loaded


mkAccountData :: Int -> Bool -> Maybe Bool -> AccountData
mkAccountData ver challenged trusted =
  AccountData
    { adHsaVersion = ver
    , adHsaChallengeRequired = challenged
    , adHsaTrustedBrowser = trusted
    , adWebservices = Map.empty
    , adRaw = object []
    }


genAccountData :: Gen AccountData
genAccountData = do
  adHsaVersion <- abs <$> (arbitrary :: Gen Int)
  adHsaChallengeRequired <- (arbitrary :: Gen Bool)
  adHsaTrustedBrowser <- elements [Nothing, Just True, Just False]
  adWebservices <- Map.fromList <$> listOf genWsPair
  let trustedField = maybe [] (\t -> ["hsaTrustedBrowser" .= t]) adHsaTrustedBrowser
      v =
        object $
          [ "dsInfo" .= object ["hsaVersion" .= adHsaVersion]
          , "hsaChallengeRequired" .= adHsaChallengeRequired
          , "webservices"
              .= fmap
                (\(Webservice url st) -> object $ ["url" .= url] <> maybe [] (\s -> ["status" .= s]) st)
                adWebservices
          ]
            <> trustedField
  pure $ fromMaybe unknownAccountData (parseMaybe parseJSON v)
 where
  genWsPair :: Gen (Text, Webservice)
  genWsPair = (,) <$> elements wsNames <*> genWebservice
  genWebservice :: Gen Webservice
  genWebservice = Webservice <$> genIndexedSuffix "https://example.com/" <*> elements [Nothing, Just "active", Just "inactive"]
  wsNames = ["findme", "contacts", "calendar", "mail"]


encodeFileAtomicSpec :: Spec
encodeFileAtomicSpec = describe "encodeFileAtomic" $ around useTmp $ do
  it "round-trips a JSON value" $ \appRoot -> do
    let path = appRoot </> "test.json"
        value = object ["key" .= ("value" :: Text)]
    encodeFileAtomic path value
    result <- eitherDecodeFileStrict path
    result `shouldBe` Right value
  it "overwrites an existing file" $ \appRoot -> do
    let path = appRoot </> "test.json"
        old = object ["version" .= (1 :: Int)]
        new = object ["version" .= (2 :: Int)]
    encodeFileAtomic path old
    encodeFileAtomic path new
    result <- eitherDecodeFileStrict path
    result `shouldBe` Right new