packages feed

yesod-session-persist-0.0.0.0: tests/Yesod/Session/Persist/YesodSpec.hs

module Yesod.Session.Persist.YesodSpec
  ( spec
  ) where

import Test.Prelude

import Control.Concurrent.STM.TVar (readTVarIO)
import Data.Aeson (encode, object)
import Data.List qualified as List
import Data.Sequence qualified as Seq
import Data.Text.Encoding (encodeUtf8)
import Network.Wai (Middleware)
import Network.Wai.Test (simpleHeaders)
import Time
import Web.Cookie (SetCookie (..), def, parseSetCookie)
import Yesod.Session.Persist
import Yesod.Session.Persist.YesodApp (App (..), Route (..), newApp)
import Yesod.Session.Storage.Operation
import Yesod.Test
  ( YesodExample
  , bodyEquals
  , getTestYesod
  , request
  , setMethod
  , setRequestBody
  , setUrl
  , withResponse
  )

spec :: Spec
spec =
  withApp defaultTimingOptions $ do
    context "Yesod App" $ do
      specify @(YesodExample App ()) "Sets a session cookie" $ do
        app <- getTestYesod
        now <- liftIO $ readTVarIO app.mock.currentTime

        -- Make a request to a normal route
        request $ do setUrl HomeR; setMethod "GET"

        -- Since there was no session, one should be inserted
        sessionKey :: SessionKey <- do
          transcript <- liftIO $ takeTranscript app.mock.mockStorage
          case toList transcript of
            [StorageOperation' (InsertSession s)] -> pure s.key
            _ -> liftIO $ fail $ show transcript

        -- We should receive a set-cookie header
        assertSetCookie
          $ def
            { setCookieName = "session-key"
            , setCookieValue = encodeUtf8 sessionKey.text
            , setCookiePath = Just "/"
            , setCookieExpires =
                Just
                  $ truncateToSeconds
                  $ addUTCTime (60 * 60 * 8) now
            , setCookieHttpOnly = True
            }

      specify @(YesodExample App ())
        "Doesn't set a session cookie if sessions are disabled"
        $ do
          app <- getTestYesod

          -- Make a request to a route without session management
          request $ do setUrl PingR; setMethod "GET"

          -- No storage operations should have been performed
          transcript <- liftIO $ takeTranscript app.mock.mockStorage
          liftIO $ transcript `shouldBe` Seq.empty

          -- No cookie should be set
          assertNoSetCookie

      specify @(YesodExample App ()) "Saves the session"
        $ do
          app <- getTestYesod

          -- Log in
          request $ do
            setUrl LogInR
            setMethod "POST"
            setRequestBody $ encode $ object [("uid", "xyz")]

          sessionKey :: SessionKey <- do
            transcript <- liftIO $ takeTranscript app.mock.mockStorage
            case toList transcript of
              [StorageOperation' (InsertSession s)] -> pure s.key
              _ -> liftIO $ fail $ show transcript

          replicateM_ 3 $ do
            -- A short pause should not affect anything
            advanceTime 90 app.mock

            -- Verify that we're now logged in
            request $ do setUrl UserR; setMethod "GET"
            bodyEquals (show @ByteString "xyz")

            liftIO $ do
              transcript <- takeTranscript app.mock.mockStorage
              toList transcript
                `shouldBe` [StorageOperation' (GetSession sessionKey)]

      specify @(YesodExample App ()) "Does not load an expired session"
        $ do
          app <- getTestYesod

          request $ do
            setUrl LogInR
            setMethod "POST"
            setRequestBody $ encode $ object [("uid", "xyz")]

          -- A pause longer than the idle timeout should kill the session
          advanceTime (60 * 60 * 10) app.mock

          request $ do setUrl UserR; setMethod "GET"
          bodyEquals "-"

      specify @(YesodExample App ()) "rotates the key when 'rotateSessionKey' is used" $ do
        app <- getTestYesod

        -- Log in
        request $ do
          setUrl LogInR
          setMethod "POST"
          setRequestBody $ encode $ object [("uid", "xyz")]

        -- Get the session
        transcript <- liftIO $ takeTranscript app.mock.mockStorage
        sessionKey :: SessionKey <-
          case toList transcript of
            [StorageOperation' (InsertSession s)] -> pure s.key
            _ -> liftIO $ fail $ show transcript

        -- Make a request to the route that does a key rotation
        request $ do setUrl RotateR; setMethod "GET"

        -- The old session should be deleted
        transcript' <- liftIO $ takeTranscript app.mock.mockStorage
        liftIO
          $ List.take 2 (toList transcript')
          `shouldBe` [ StorageOperation' (GetSession sessionKey)
                     , StorageOperation' (DeleteSession sessionKey)
                     ]

        -- But we're still logged in
        request $ do setUrl UserR; setMethod "GET"
        bodyEquals (show @ByteString "xyz")

      specify @(YesodExample App ()) "rotates the key on auth changes"
        $ do
          app <- getTestYesod

          -- Log in
          request $ do
            setUrl LogInR
            setMethod "POST"
            setRequestBody $ encode $ object [("uid", "xyz")]

          -- Get the session
          transcript <- liftIO $ takeTranscript app.mock.mockStorage
          sessionKey :: SessionKey <-
            case toList transcript of
              [StorageOperation' (InsertSession s)] -> pure s.key
              _ -> liftIO $ fail $ show transcript

          -- Log in differently
          request $ do
            setUrl LogInR
            setMethod "POST"
            setRequestBody $ encode $ object [("uid", "hello")]

          -- The old session should be deleted
          transcript' <- liftIO $ takeTranscript app.mock.mockStorage
          liftIO
            $ List.take 2 (toList transcript')
            `shouldBe` [ StorageOperation' (GetSession sessionKey)
                       , StorageOperation' (DeleteSession sessionKey)
                       ]

          -- But we're still logged in as the new user
          request $ do setUrl UserR; setMethod "GET"
          bodyEquals (show @ByteString "hello")

withApp :: TimingOptions NominalDiffTime -> SpecWith (App, Middleware) -> Spec
withApp timing = around ((=<< newApp timing) . (. (,id :: Middleware)))

-- | Assert that the response contains a set-cookie header that parses to a particular value
assertSetCookie :: SetCookie -> YesodExample site ()
assertSetCookie expected =
  withResponse $ \response ->
    case List.lookup "set-cookie" $ simpleHeaders response of
      Just value -> liftIO $ parseSetCookie value `shouldBe` expected
      Nothing -> liftIO $ expectationFailure "no set-cookie header present"

-- | Fail if set-cookie is present in the response
assertNoSetCookie :: YesodExample site ()
assertNoSetCookie =
  withResponse $ \response ->
    case List.lookup "set-cookie" $ simpleHeaders response of
      Just value ->
        liftIO $ expectationFailure $ "expected no set-cookie, but got " <> show value
      Nothing -> pure ()

-- | Round a time to seconds, because that is the precision in cookie headers
truncateToSeconds :: UTCTime -> UTCTime
truncateToSeconds x =
  x {utctDayTime = secondsToDiffTime (truncate (utctDayTime x))}