packages feed

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

{-# LANGUAGE OverloadedStrings #-}

module HStratus.ApiLoggerSpec (spec) where

import Data.Aeson (Value, decode)
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy.Char8 as LBS8
import Data.List (isPrefixOf)
import HStratus.Mock (defaultScenario, withMockApp)
import Network.HStratus.Http (ApiLogger, fileLogger, login, mkApiWith, redactingLogger, withLogger)
import Network.HStratus.Http.Endpoints (Endpoints (..))
import Network.HStratus.Session (Credentials (..), Session (..))
import Network.HTTP.Client (Request (..), defaultManagerSettings, defaultRequest, newManager)
import Network.HTTP.Types (methodPost)
import System.FilePath ((</>))
import System.IO (Handle, IOMode (..), withFile)
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec (Spec, describe, it, shouldContain, shouldNotBe, shouldNotContain, shouldSatisfy)


spec :: Spec
spec = describe "Network.HStratus.Http.fileLogger" $ do
  it "writes a log entry for each HTTP request during login" $
    withSystemTempDirectory "icloud-auth-log" $ \tmpDir -> do
      let logPath = tmpDir </> "requests.log"
      withLoginLog tmpDir logPath $ \contents ->
        filter (== "---") (lines contents) `shouldSatisfy` (not . null)

  it "each entry contains the HTTP method, URI and response status" $
    withSystemTempDirectory "icloud-auth-log" $ \tmpDir -> do
      let logPath = tmpDir </> "requests.log"
      withLoginLog tmpDir logPath $ \contents -> do
        contents `shouldContain` "POST"
        contents `shouldContain` "signin/init"
        contents `shouldContain` "200"

  it "response bodies are valid JSON" $
    withSystemTempDirectory "icloud-auth-log" $ \tmpDir -> do
      let logPath = tmpDir </> "requests.log"
      withLoginLog tmpDir logPath $ \contents -> do
        let body = firstBody contents
        (decode (LBS8.pack body) :: Maybe Value) `shouldNotBe` Nothing

  describe "redactingLogger" $ do
    it "replaces sensitive header values with <redacted>" $
      withSystemTempDirectory "icloud-auth-redact" $ \tmpDir -> do
        let logPath = tmpDir </> "requests.log"
        withLoginLogUsing redactingLogger tmpDir logPath $ \contents ->
          contents `shouldContain` "<redacted>"
    it "preserves the method, URI and status line" $
      withSystemTempDirectory "icloud-auth-redact" $ \tmpDir -> do
        let logPath = tmpDir </> "requests.log"
        withLoginLogUsing redactingLogger tmpDir logPath $ \contents -> do
          contents `shouldContain` "POST"
          contents `shouldContain` "signin/init"
          contents `shouldContain` "200"
    it "does not write raw Set-Cookie values" $
      withSystemTempDirectory "icloud-auth-redact" $ \tmpDir -> do
        let logPath = tmpDir </> "requests.log"
        withLoginLogUsing fileLogger tmpDir logPath $ \verboseContents ->
          withLoginLogUsing redactingLogger tmpDir (logPath <> ".redacted") $ \redactedContents -> do
            let cookieLines = filter ("Set-Cookie:" `isPrefixOf`) (lines verboseContents)
            case cookieLines of
              [] -> pure ()
              (firstLine : _) -> redactedContents `shouldNotContain` drop (length ("Set-Cookie: " :: String)) firstLine


{- | Extract the body of the first log entry.
Format: summary line, request headers, blank line, response headers, blank line, body, "---".
-}
firstBody :: String -> String
firstBody contents =
  let skipSection = drop 1 . dropWhile (not . null)
      bodyLines = takeWhile (/= "---") $ skipSection $ skipSection $ drop 1 (lines contents)
   in unlines bodyLines


withLoginLog :: FilePath -> FilePath -> (String -> IO a) -> IO a
withLoginLog = withLoginLogUsing fileLogger


withLoginLogUsing :: (Handle -> ApiLogger) -> FilePath -> FilePath -> (String -> IO a) -> IO a
withLoginLogUsing mkLogger tmpDir logPath action = do
  let sessionDir = tmpDir </> "session"
  withFile logPath WriteMode $ \logHandle ->
    withMockApp defaultScenario $ \serverPort -> do
      mgr <- newManager defaultManagerSettings
      api <-
        withLogger (mkLogger logHandle)
          <$> mkApiWith (testSession sessionDir) (testEndpoints serverPort) mgr
      _ <- login api
      pure ()
  contents <- readFile logPath
  action contents


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
      }