packages feed

password-3.0.0.0: test/tasty/Validate.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# OPTIONS_GHC -Wno-orphans #-}

module Validate where

import Control.Monad (replicateM)
import Data.Char (chr, isAsciiLower, isAsciiUpper, isControl, isDigit)
import Data.List (nub)
import Data.Text (Text)
import qualified Data.Text as T
import Test.QuickCheck.Instances.Text ()
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (Assertion, assertBool, assertEqual, testCase)
import Test.Tasty.QuickCheck (Arbitrary (..), Gen, Property, choose,
                              elements, oneof, shuffle, suchThat, testProperty,
                              withMaxSuccess, (===))
#if !MIN_VERSION_base(4,13,0)
import Data.Semigroup ((<>))
#endif

import Data.Password.Types (Password, mkPassword)
import Data.Password.Validate hiding (ValidationResult(..))
import qualified Data.Password.Validate as V

import TestPolicy (testPolicy)
-- | Set of tests used for testing validate module
testValidate :: TestTree
testValidate =
  testGroup
    "Validation"
    [ testDefaultCharSetPredicate
    , testDefaultCharSet
    , testCase "defaultCharSetPredicate accepts all of defaultCharSet" $
        assertBool "defaultCharSet includes characters defaultCharSetPredicate disallows"
          $ all p defaultCharSet
    , testGroup "validatePasswordPolicy" validatePasswordPolicyTests

    , testProperty
        "Generator will always generate valid passwordPolicy"
        $ isRight . validatePasswordPolicy
    , testProperty "Valid password always true" prop_ValidPassword
    , testProperty "Generator will always generate valid FailedReason"
        (\(InvalidPassword reason _ _) -> either isValidPolicyReason isValidReason reason)
    , testProperty "validatePassword return appropriate value" prop_InvalidPassword
    ]
  where
    p = getCharSetPredicate defaultCharSetPredicate

isRight :: Either a b -> Bool
isRight = either (const False) (const True)

-- | Test that the defaultCharSetPredicate only allows non-control
-- characters from the ASCII character set.
testDefaultCharSetPredicate :: TestTree
testDefaultCharSetPredicate = testGroup "defaultCharSetPredicate"
    [ testCase "Return only non-control characters" $
        assertEqual
          "allows control characters"
          asciiNonControlChars
          $ filter p asciiCharSet
    , testCase "Return true on all non-control characters" $
        assertEqual
          "disallows valid characters"
          asciiControlChars
          $ filter (not . p) asciiCharSet
    ]
  where
    p = getCharSetPredicate defaultCharSetPredicate
    asciiCharSet, asciiControlChars, asciiNonControlChars :: [Char]
    asciiCharSet = chr <$> [0 .. 127]
    asciiControlChars = filter isControl asciiCharSet
    asciiNonControlChars = filter (not . isControl) asciiCharSet

testDefaultCharSet :: TestTree
testDefaultCharSet =
  testCase "defaultCharSet should contain all the expected characters" $ do
    hasCharacters isAsciiUpper ['A'..'Z'] "uppercase"
    hasCharacters isAsciiLower ['a'..'z'] "lowercase"
    hasCharacters isDigit ['0'..'9'] "digit"
    hasCharacters isSpecial specials "special"
  where
    specials = " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
    -- Check that the characters filtered from defaultCharSet is as expected
    hasCharacters :: (Char -> Bool) -> String -> String -> Assertion
    hasCharacters pre expected s =
      assertEqual msg expected $ filter pre defaultCharSet
      where
        msg = "Unexpected amount of " <> s <> " characters"

defaultPassword :: Password
defaultPassword = "Abcd3fgh"

validatePasswordPolicyTests :: [TestTree]
validatePasswordPolicyTests =
  [ testCase "defaultPasswordPolicy is valid" validDefaultPasswordPolicy
  , testCase "defaultCharSetPredicate is valid" validDefaultCharSetPredicate
  , testCase "defaultPassword is valid" validDefaultPassword
  , testProperty "upper case always valid" $
      validProp $ \i -> defaultPasswordPolicy{uppercaseChars = i}
  , testProperty "lower case always valid" $
      validProp $ \i -> defaultPasswordPolicy{lowercaseChars = i}
  , testProperty "digits always valid" $
      validProp $ \i -> defaultPasswordPolicy{digitChars = i}
  , testProperty "special chars always valid" $
      validProp $ \i -> defaultPasswordPolicy{specialChars = i}
  , testProperty "minimumLength always valid (maximumLength = maxBound)" $
      validProp $ \i ->
        defaultPasswordPolicy {
          minimumLength = i,
          maximumLength = maxBound
        }
  , testProperty "minimumLength <= maximumLength always valid" $
      \i -> let maxLen = maximumLength defaultPasswordPolicy
                mLength = [InvalidLength i maxLen | i > maxLen]
                policy = defaultPasswordPolicy { minimumLength = i }
                result = fromValidPasswordPolicy <$> validatePasswordPolicy policy
                expected = case mLength of
                  [] -> Right policy
                  _ -> Left mLength
            in result === expected
  , testProperty "maximumLength => always valid" $
      \i -> let minLen = minimumLength defaultPasswordPolicy
                mLength = [InvalidLength minLen i | i < minLen]
                mZero = [MaxLengthBelowZero i | i <= 0]
                mCategoryAmount = mkCategoryAmount policy
                reasons = mZero ++ mLength ++ mCategoryAmount
                policy = defaultPasswordPolicy { maximumLength = i }
                result = fromValidPasswordPolicy <$> validatePasswordPolicy policy
                expected = case reasons of
                  [] -> Right policy
                  _ -> Left reasons
            in result === expected
  , testGroup "TemplateHaskell" templateHaskellTest
  ]
  where
    mkCategoryAmount p =
        [ CategoryAmountsAboveMaxLength (maximumLength p) allCategoryAmounts
        | allCategoryAmounts > maxLength
        , maxLength > 0
        ]
      where
        maxLength = maximumLength p
        allCategoryAmounts = sum $ max 0 <$> [lowercaseChars p, uppercaseChars p, digitChars p, specialChars p]
    validProp f i =
      let p = f i
          result = fromValidPasswordPolicy <$> validatePasswordPolicy p
          reason = mkCategoryAmount p
          expected = case reason of
            [] -> Right p
            _ -> Left reason
      in result === expected

validDefaultPasswordPolicy :: Assertion
validDefaultPasswordPolicy = do
  assertBool "defaultPasswordPolicy isn't a valid policy"
    $ isRight $ validatePasswordPolicy defaultPasswordPolicy
  assertBool "defaultPasswordPolicy_ isn't a valid policy"
    $ Right defaultPasswordPolicy_ == validatePasswordPolicy (fromValidPasswordPolicy defaultPasswordPolicy_)

templateHaskellTest :: [TestTree]
templateHaskellTest =
    [ testCase "allButCSP check" $ assertBool
        "testPolicy doesn't have unique values"
        $ let ns = V.allButCSP testPolicy
          in nub ns == ns
    , testCase "Internal consistency" $ assertEqual
        "validatePasswordPolicyTH switched values between fields"
        testPolicy
        $ fromValidPasswordPolicy $(validatePasswordPolicyTH testPolicy)
    , testCase "defaultPassword" $ assertBool
        "valid policy from TH can't parse defaultPassword"
        $ isValidPassword $(validatePasswordPolicyTH defaultPasswordPolicy) defaultPassword
    , testCase "TH ignores charSetPredicate (always uses default)" $ assertBool
        "TH still parses defaultPassword when pred is initially bad"
        $ isValidPassword
            $(validatePasswordPolicyTH defaultPasswordPolicy{ charSetPredicate = CharSetPredicate $ const False })
            defaultPassword
    , testProperty "prop test for charSetPredicate after TH" $ \(PredInts i) ->
        let policy = $(validatePasswordPolicyTH defaultPasswordPolicy{ charSetPredicate = CharSetPredicate $ const False})
            p = getCharSetPredicate . charSetPredicate $ fromValidPasswordPolicy policy
            c = chr i
        in p c == (c `elem` defaultCharSet)
    ]

newtype PredInts = PredInts { unPredInts :: Int } deriving (Eq, Show)

instance Arbitrary PredInts where
    arbitrary = PredInts <$> choose (0, 260)

validDefaultCharSetPredicate :: Assertion
validDefaultCharSetPredicate =
  assertEqual "defaultCharSetPredicate isn't valid" []
    $ validateCharSetPredicate defaultPasswordPolicy

validDefaultPassword :: Assertion
validDefaultPassword =
  assertEqual "defaultPassword isn't valid" V.ValidPassword
    $ validatePassword defaultPasswordPolicy_ defaultPassword

--------------------------------------------------------------------------------
-- Typeclass instances
--------------------------------------------------------------------------------

-- | Generate valid PasswordPolicy
instance Arbitrary PasswordPolicy where
  arbitrary = do
    minimumLength <- genCharLength
    uppercaseChars <- genCharLength
    lowercaseChars <- genCharLength
    specialChars <- genCharLength
    digitChars <- genCharLength
    let sumLength = sum [uppercaseChars, lowercaseChars, specialChars, digitChars]
    let minMaxLength = max minimumLength sumLength
    maximumLength <- choose (minMaxLength, minMaxLength + 10)
    let charSetPredicate = defaultCharSetPredicate
    return PasswordPolicy{..}
    where
      genCharLength :: Gen Int
      genCharLength = choose (1, 10)

instance Arbitrary CharacterCategory where
  arbitrary = elements [Uppercase, Lowercase, Special, Digit]

-- This is needed for testing (QuickCheck requires given datatype to have Show instance)
instance Show CharSetPredicate where
  show _ = "Predicate"

--------------------------------------------------------------------------------
-- Tests
--------------------------------------------------------------------------------

-- | Test that 'validatePassword' will always return empty list if the password
-- is valid
prop_ValidPassword :: ValidPassword -> Property
prop_ValidPassword (ValidPassword passwordPolicy password) =
  withMaxSuccess 1000 $
    case validatePasswordPolicy passwordPolicy of
      Left _ -> error "PasswordPolicy is invalid"
      Right validPolicy -> validatePassword validPolicy (mkPassword password) === V.ValidPassword

-- | Data type used to generate valid password and 'PasswordPolicy' associated
-- with it
data ValidPassword = ValidPassword
  { validPasswordPolicy   :: !PasswordPolicy
  , validPasswordText     :: !Text
  } deriving (Show)

instance Arbitrary ValidPassword where
  arbitrary = do
    policy@PasswordPolicy{..} <- arbitrary
    passLength <- choose (minimumLength, maximumLength)
    passwordText <- genPassword passLength policy
    return $ ValidPassword policy passwordText
    where
      genPassword :: Int -> PasswordPolicy -> Gen Text
      genPassword passLength PasswordPolicy{..} = do
        upperCase <- genStr uppercaseChars isAsciiUpper
        lowerCase <- genStr lowercaseChars isAsciiLower
        specialChar <- genStr specialChars isSpecial
        digit <- genStr digitChars isDigit
        let requiredChars = upperCase <> lowerCase <> specialChar <> digit
        let toFill = passLength - length requiredChars
        fillChars <- replicateM toFill $
            arbitrary `suchThat` getCharSetPredicate charSetPredicate
        T.pack <$> shuffle (fillChars <> requiredChars)
      genStr :: Int -> (Char -> Bool) -> Gen String
      genStr num predicate = replicateM num (arbitrary `suchThat` predicate)

prop_InvalidPassword :: InvalidPassword -> Property
prop_InvalidPassword (InvalidPassword failedReason passwordPolicy password) =
  withMaxSuccess 1000 $ (===) expected $
    case validatePasswordPolicy passwordPolicy of
      Left policyReasons -> Left policyReasons
      Right validPolicy -> Right $ validatePassword validPolicy (mkPassword password)
  where
    expected = either (Left . pure) (Right . V.InvalidPassword . pure) failedReason

-- | Data type used to generate password which does not follow one of the policies
-- as well as 'InvalidReason', 'CharSetPredicate', and 'PasswordPolicy' associated with it
data InvalidPassword = InvalidPassword
  { invalidPassFailedReason :: !(Either InvalidPolicyReason InvalidReason)
  , invalidPassPolicy       :: !PasswordPolicy
  , invalidPasswordText     :: !Text
  } deriving (Show)

instance Arbitrary InvalidPassword where
  arbitrary = do
    reason <- genFailedReason emptyPolicy
    let charSetP = updateCharSetPredicate defaultCharSetPredicate reason
        updatedPolicy = (updatePolicy emptyPolicy reason){ charSetPredicate = charSetP }
    passText <- genInvalidPassword updatedPolicy reason
    return $ InvalidPassword reason updatedPolicy (T.pack passText)
    where
      genFailedReason :: PasswordPolicy -> Gen (Either InvalidPolicyReason InvalidReason)
      genFailedReason policy@PasswordPolicy{..} =
        oneof
          [ Right <$> genTooShort maximumLength,
            Right <$> genTooLong minimumLength,
            Right <$> genNotEnoughRequiredChars,
            Right <$> genInvalidChar defaultCharSetPredicate,
            Left <$> oneof
              [ MaxLengthBelowZero <$> (arbitrary `suchThat` (<= 0))
              , genInvalidLength policy
              , genInvalidCategoryAmountTotal policy
              , InvalidCharSetPredicate <$> arbitrary <*> choose (minimumLength, maximumLength)
              ]
          ]
      genTooShort :: Int -> Gen InvalidReason
      genTooShort maxLength = do
        requiredLength <- choose (1, maxLength - 1)
        actualLength <- choose (0, requiredLength - 1)
        return $ PasswordTooShort requiredLength actualLength
      genTooLong :: Int -> Gen InvalidReason
      genTooLong minLength = do
        requiredLength <- choose (minLength + 1, 30)
        actualLength <- choose (requiredLength + 1, 50)
        return $ PasswordTooLong requiredLength actualLength
      genNotEnoughRequiredChars :: Gen InvalidReason
      genNotEnoughRequiredChars = do
        required <- choose (1, 3)
        actual <- choose (0, required - 1)
        category <- arbitrary
        return $ NotEnoughReqChars category required actual
      genInvalidChar :: CharSetPredicate -> Gen InvalidReason
      genInvalidChar (CharSetPredicate predicate) = do
        num <- choose (1, 3)
        let arbitraryInvalidChar = arbitrary `suchThat` (not . predicate)
        chrs <- replicateM num arbitraryInvalidChar
        return $ InvalidCharacters (T.pack chrs)
      genInvalidLength :: PasswordPolicy -> Gen InvalidPolicyReason
      genInvalidLength PasswordPolicy{..} = do
        let sumReq = sum [uppercaseChars, lowercaseChars, specialChars, digitChars]
        minLength <- choose (sumReq, maximumLength - 1) `suchThat` (> 0)
        return $ InvalidLength maximumLength minLength
      genInvalidCategoryAmountTotal :: PasswordPolicy -> Gen InvalidPolicyReason
      genInvalidCategoryAmountTotal p = do
          maxLength <- arbitrary `suchThat` (> minimumLength p)
          let totalAmount = maxLength + 1
          return $ CategoryAmountsAboveMaxLength maxLength totalAmount
      -- Update 'PasswordPolicy' based upon 'InvalidReason'
      updatePolicy :: PasswordPolicy -> Either InvalidPolicyReason InvalidReason -> PasswordPolicy
      updatePolicy policy = \case
        Right (PasswordTooShort req _actual) -> policy {minimumLength = req}
        Right (PasswordTooLong req _actual) -> policy {maximumLength = req}
        Right (InvalidCharacters _invalidChars) -> policy
        Right (NotEnoughReqChars category req _actual) ->
          case category of
            Uppercase -> policy {uppercaseChars = req}
            Lowercase -> policy {lowercaseChars = req}
            Special   -> policy {specialChars = req}
            Digit     -> policy {digitChars = req}
        Left (MaxLengthBelowZero num) ->
          policy {
            minimumLength = num - 1,
            maximumLength = num
          }
        Left (InvalidLength minLength maxLength) ->
          policy {
            minimumLength = minLength,
            maximumLength = maxLength
          }
        Left (CategoryAmountsAboveMaxLength maxLength totalAmount) ->
          policy {
              maximumLength = maxLength,
              lowercaseChars = totalAmount
          }
        Left (InvalidCharSetPredicate category num) ->
          case category of
            Uppercase -> policy {uppercaseChars = num}
            Lowercase -> policy {lowercaseChars = num}
            Special   -> policy {specialChars = num}
            Digit     -> policy {digitChars = num}
      updateCharSetPredicate :: CharSetPredicate -> Either InvalidPolicyReason InvalidReason -> CharSetPredicate
      updateCharSetPredicate predicate = \case
        Right (InvalidCharacters invalidChars) ->
          CharSetPredicate $ \c -> getCharSetPredicate predicate c && c `notElem` T.unpack invalidChars
        Left (InvalidCharSetPredicate category _num) ->
          let filterPre = categoryToPredicate category
          in CharSetPredicate $ \c -> getCharSetPredicate predicate c && (not . filterPre) c
        _others -> predicate
      genInvalidPassword :: PasswordPolicy -> Either InvalidPolicyReason InvalidReason -> Gen String
      genInvalidPassword policy@PasswordPolicy{..} = \case
        Right (PasswordTooShort _req actual) -> genPassword actual charSetPredicate
        Right (PasswordTooLong _req actual) -> genPassword actual charSetPredicate
        Right (NotEnoughReqChars category _req actual) -> do
          passwordLength <- genPasswordLength policy
          let pre = categoryToPredicate category
          let usableCharsets = CharSetPredicate $ \c -> not (pre c) && getCharSetPredicate charSetPredicate c
          requiredChars <- replicateM actual (arbitrary `suchThat` pre)
          passwordText <- genPassword (passwordLength - actual) usableCharsets
          shuffle (passwordText <> requiredChars)
        Right (InvalidCharacters chrs) -> do
          passwordLength <- genPasswordLength policy
          passwordText <- genPassword (passwordLength - T.length chrs) charSetPredicate
          -- Here, we make sure that the order of invalid characters are apporiate
          -- or else the test will fail. For instance this will make the test fail
          -- since the order of the characters are different
          -- e.g. [InvalidChar "一二三"] /= [InvalidChar "三二一"]
          let s = T.unpack chrs
          shuffle (passwordText <> s) `suchThat` checkOrder charSetPredicate s
        Left (MaxLengthBelowZero _invalid) -> genPassword minimumLength charSetPredicate
        _others -> do
          passwordLength <- genPasswordLength policy
          genPassword passwordLength charSetPredicate
      genPassword :: Int -> CharSetPredicate -> Gen String
      genPassword num (CharSetPredicate predicate) = replicateM num (arbitrary `suchThat` predicate)
      genPasswordLength :: PasswordPolicy -> Gen Int
      genPasswordLength PasswordPolicy{..} = choose (minimumLength, maximumLength)
      checkOrder :: CharSetPredicate -> String -> String -> Bool
      checkOrder (CharSetPredicate predicate) invalidChars password =
        let filteredChars = filter (not . predicate) password
         in invalidChars == filteredChars

-- | Check if given 'InvalidReason' is valid
isValidReason :: InvalidReason -> Bool
isValidReason = \case
  InvalidCharacters _ -> True
  PasswordTooLong required actual -> required < actual
  PasswordTooShort required actual -> required > actual
  NotEnoughReqChars _ required actual -> required > actual

isValidPolicyReason :: InvalidPolicyReason -> Bool
isValidPolicyReason = \case
  InvalidLength minLength maxLength -> minLength > maxLength
  MaxLengthBelowZero num -> num <= 0
  CategoryAmountsAboveMaxLength maxLength num -> num > maxLength
  InvalidCharSetPredicate _category num -> num > 0

-- | 'PasswordPolicy' used for testing
--
-- Required characters are turned off so that it's much more easier to test.
emptyPolicy :: PasswordPolicy
emptyPolicy = PasswordPolicy 8 32 0 0 0 0 defaultCharSetPredicate