packages feed

espial-0.0.40: test/Model/PasswordHashSpec.hs

{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}

module Model.PasswordHashSpec (spec) where

import Model.Custom
  ( HashAlgoConfig (..),
    PasswordHash (..),
    bcryptPolicy,
    hashPasswordBCrypt,
    hashPasswordBCryptWithPolicy,
    hashPasswordWith,
    needsRehash,
    validatePasswordHash,
  )
import TestImport
import Types (DB)

-- * Helpers

insertUser :: PasswordHash -> DB (Key User)
insertUser pwHash = insert $ User "alice" pwHash Nothing False False True True False False True Nothing

fetchStoredHash :: DB (Maybe PasswordHash)
fetchStoredHash = fmap (userPasswordHash . entityVal) <$> getBy (UniqueUserName "alice")

bcryptTarget :: HashAlgoConfig
bcryptTarget = HashAlgoBCrypt bcryptPolicy

-- * Spec

spec :: Spec
spec = do
  -- Pure/IO tests: no database needed.

  describe "hashPasswordBCrypt" $ do
    it "produces a BCrypt hash string" $ do
      h <- hashPasswordBCrypt "secret"
      unPasswordHash h `shouldSatisfy` isPrefixOf "$2"

    it "uses a random salt (same password yields different hashes)" $ do
      h1 <- hashPasswordBCrypt "secret"
      h2 <- hashPasswordBCrypt "secret"
      h1 `shouldNotBe` h2

    it "matches hashPasswordWith (HashAlgoBCrypt _)" $ do
      h <- hashPasswordWith bcryptTarget "secret"
      unPasswordHash h `shouldSatisfy` isPrefixOf "$2"

  describe "validatePasswordHash / BCrypt" $ do
    it "accepts the correct password" $ do
      h <- hashPasswordBCryptWithPolicy bcryptTestPolicy "correct"
      validatePasswordHash h "correct" `shouldBe` True

    it "rejects a wrong password" $ do
      h <- hashPasswordBCryptWithPolicy bcryptTestPolicy "correct"
      validatePasswordHash h "wrong" `shouldBe` False

  describe "validatePasswordHash / unknown format" $
    it "rejects an unrecognised hash string" $
      validatePasswordHash (PasswordHash "notahash") "anything" `shouldBe` False

  describe "needsRehash" $ do
    it "returns False for a BCrypt hash matching the target algorithm" $ do
      h <- hashPasswordBCryptWithPolicy bcryptTestPolicy "pass"
      needsRehash bcryptTarget h `shouldBe` False

    it "returns True for a hash in an unrecognised format" $
      needsRehash bcryptTarget (PasswordHash "notahash") `shouldBe` True

  -- Integration tests: require the full app + database.

  describe "authenticatePassword" $ withApp $ do
    it "succeeds for a user with a BCrypt hash" $ do
      void $ runDB $ do
        pw <- liftIO (hashPasswordBCryptWithPolicy bcryptTestPolicy "pass")
        insertUser pw
      mUser <- runDB $ authenticatePassword bcryptTarget "alice" "pass"
      liftIO $ mUser `shouldSatisfy` isJust

    it "fails with a wrong password" $ do
      void $ runDB $ do
        pw <- liftIO (hashPasswordBCryptWithPolicy bcryptTestPolicy "pass")
        insertUser pw
      mUser <- runDB $ authenticatePassword bcryptTarget "alice" "wrong"
      liftIO $ mUser `shouldSatisfy` isNothing

    it "returns Nothing for a non-existent user" $ do
      mUser <- runDB $ authenticatePassword bcryptTarget "nobody" "pass"
      liftIO $ mUser `shouldSatisfy` isNothing

    it "does not rehash an already-current BCrypt hash on successful login" $ do
      void $ runDB $ do
        pw <- liftIO (hashPasswordBCryptWithPolicy bcryptTestPolicy "pass")
        insertUser pw
      before' <- runDB fetchStoredHash
      _ <- runDB $ authenticatePassword bcryptTarget "alice" "pass"
      after' <- runDB fetchStoredHash
      liftIO $ after' `shouldBe` before'