packages feed

libjwt-typed-0.2: test/Properties.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeOperators #-}

module Properties
  ( spec
  )
where

import           Web.Libjwt
import           Generators
import           Libjwt.Classes
import           Libjwt.JsonByteString
import           Libjwt.JwtValidation           ( runValidation )
import           Libjwt.NumericDate             ( minusSeconds )

import           Control.Arrow                  ( left )
import           Control.Exception              ( displayException )

import           Control.Monad.Trans.Reader     ( runReader )

import           Data.Aeson                     ( FromJSON
                                                , ToJSON
                                                , encode
                                                , decode
                                                )

import           Data.Default

import           Data.Either.Validation        as V

import           Data.Functor                   ( (<&>) )
import           Data.List.NonEmpty             ( NonEmpty )

import           Data.Time.Calendar             ( Day )
import           Data.Time.Clock                ( UTCTime )

import           Data.UUID                      ( UUID )

import           GHC.Generics

import           Test.Hspec
import           Test.Hspec.QuickCheck          ( modifyArgs
                                                , prop
                                                )

import           Test.QuickCheck

import           Test.QuickCheck.Instances      ( )

import           Prelude                 hiding ( exp )

spec :: Spec
spec = modifyArgs quickcheckArgs $ do
  prop "enc-dec-roundtrip-reg-only" prop_encode_decode_roundtrip_reg_only
  prop "enc-dec-roundtrip"          prop_encode_decode_roundtrip
  prop "enc-dec-roundtrip-ns"       prop_encode_decode_roundtrip_ns
  prop "enc-dec-roundtrip-comp"     prop_encode_decode_rountrip_comp
  prop "enc-dec-roundtrip-aeson"    prop_encode_decode_roundtrip_aeson
  prop "from-to-generic"            prop_from_to_generic
  prop "validity"                   prop_validity
 where
  quickcheckArgs args = args { maxSuccess      = 4000
                             , maxDiscardRatio = 5
                             , maxSize         = maxSize args `div` 2
                             , maxShrinks      = 1000
                             }

prop_encode_decode_roundtrip_poly
  :: (Decode (PrivateClaims pc ns), Encode (PrivateClaims pc ns), Show a, Eq a)
  => (Payload pc ns -> a)
  -> (a -> Payload pc ns)
  -> a
  -> Property
prop_encode_decode_roundtrip_poly project embed a =
  forAll genAlgorithm $ \(SomeAlgorithm algorithm) ->
    left
        displayException
        (   decodeByteString algorithm (getToken $ sign algorithm $ embed a)
        <&> project
        .   payload
        .   getDecoded
        )
      === pure a

prop_encode_decode_roundtrip_reg_only :: Payload Empty 'NoNs -> Property
prop_encode_decode_roundtrip_reg_only = prop_encode_decode_roundtrip_poly id id

prop_encode_decode_roundtrip
  :: Payload
       '["intField" ->> Int, "dateField" ->> UTCTime, "textField" ->> JwtText, "optField" ->> Maybe JwtString, "arrayField" ->> [JwtText], "nonEmptyField" ->> NonEmpty ASCII]
       'NoNs
  -> Property
prop_encode_decode_roundtrip = prop_encode_decode_roundtrip_poly id id

prop_encode_decode_roundtrip_ns
  :: Payload
       '["dayField" ->> Day, "arrayField" ->> [Int], "stringField" ->> JwtString]
       ( 'SomeNs "https://example.com")
  -> Property
prop_encode_decode_roundtrip_ns = prop_encode_decode_roundtrip_poly id id

prop_encode_decode_rountrip_comp
  :: Payload
       '["user_name" ->> JwtText, "is_root" ->> Bool, "type" ->> Flag AccountType, "client_id" ->> UUID, "created" ->> UTCTime, "accounts" ->> NonEmpty (UUID, JwtText)]
       'NoNs
  -> Property
prop_encode_decode_rountrip_comp = prop_encode_decode_roundtrip_poly id id

data AccountType = Regular | Pro | Admin
  deriving stock (Show, Eq, Generic)

instance AFlag AccountType

instance Arbitrary AccountType where
  arbitrary = elements [Regular, Pro, Admin]

data ClaimObj = MkClaimObj { userName :: JwtText, isRoot :: Bool, clientId :: UUID, createdAt :: UTCTime }
  deriving stock (Show, Eq, Generic)

instance ToPrivateClaims ClaimObj
instance FromPrivateClaims ClaimObj

instance Arbitrary ClaimObj where
  arbitrary =
    MkClaimObj <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary

  shrink MkClaimObj { userName, isRoot, clientId, createdAt } =
    MkClaimObj
      <$> shrink userName
      <*> pure isRoot
      <*> shrink clientId
      <*> shrink createdAt

prop_from_to_generic :: ClaimObj -> Property
prop_from_to_generic = prop_encode_decode_roundtrip_poly
  (fromPrivateClaims . privateClaims)
  (\claimObj' -> def { privateClaims = toPrivateClaims claimObj' })

data Account = MkAccount { account_name :: JwtText, account_id :: UUID }
  deriving stock (Show, Eq, Generic)

instance FromJSON Account
instance ToJSON Account

instance JwtRep JsonByteString Account where
  rep   = Json . encode
  unRep = decode . toJson

instance JsonBuilder Account
instance JsonParser Account

instance Arbitrary Account where
  arbitrary = MkAccount <$> arbitrary <*> arbitrary
  shrink (MkAccount name id) = MkAccount <$> shrink name <*> shrink id

prop_encode_decode_roundtrip_aeson
  :: Payload '["user_id" ->> Int, "accounts" ->> NonEmpty Account] 'NoNs
  -> Property
prop_encode_decode_roundtrip_aeson = prop_encode_decode_roundtrip_poly id id

prop_validity
  :: UTCTime -> ValidationSettings -> Payload Empty 'NoNs -> Property
prop_validity time settings@Settings { leeway, appName } payload@ClaimsSet { exp, nbf, aud = Aud rs }
  | now >= validFrom && now < validTo && appropriate
  = label "valid" $ classify triviallyValid "trivial" $ success validate
  | otherwise
  = label "invalid" $ failure validate
 where
  now       = fromUTC time

  validFrom = case nbf of
    Nbf Nothing    -> minBound
    Nbf (Just sth) -> sth `minusSeconds` leeway
  validTo = case exp of
    Exp Nothing    -> maxBound
    Exp (Just sth) -> sth `plusSeconds` leeway

  appropriate    = null rs || maybe False (`elem` rs) appName

  triviallyValid = exp == Exp Nothing && nbf == Nbf Nothing && null rs

  validate       = runReader (runValidation settings mempty payload) time

  success (V.Success _) = property True
  success errors        = counterexample (show errors) False

  failure (V.Failure _) = property True
  failure other         = counterexample (show other) False