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