packages feed

hstratus-auth-0.1.0.0: test/HStratus/TrustSpec.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}

{- |
Module      : HStratus.TrustSpec
Copyright   : (c) 2023 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD3
-}
module HStratus.TrustSpec (spec, encode, genTrustData, genTrustedList, jsonKeysOf) where

import Data.Aeson (Key, ToJSON (..), Value (..), decode, eitherDecodeFileStrict, encode)
import Data.Aeson.KeyMap (fromList)
import qualified Data.Aeson.KeyMap as KeyMap
import Data.List (sort)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Data.Maybe (catMaybes)
import Data.String.Conv (toS)
import Data.Text (Text)
import qualified HStratus.Examples as Examples
import Network.HStratus.Internal.Trust
import Paths_hstratus_auth (getDataFileName)
import System.IO.Silently (silence)
import Test.Hspec
  ( Spec
  , context
  , describe
  , it
  , shouldBe
  )
import Test.Hspec.Benri (endsJust, endsNothing, endsRight)
import Test.Main (withStdin)
import Test.QuickCheck
  ( Arbitrary (arbitrary)
  , Gen
  , Property
  , chooseInt
  , elements
  , forAll
  , frequency
  , listOf1
  , vectorOf
  )
import Test.QuickCheck.Monadic (assert, monadicIO, pick, run)


spec :: Spec
spec = describe "module Network.HStratus.Trust" $ do
  describe "TrustData" $ do
    context "parsing generated examples to/from JSON" $ do
      it "should succeed" prop_jsonRoundtripTrustData
    context "parsing a hand-crafted Apple-shaped fixture" $ do
      it "should succeed" $ do
        fp <- getDataFileName "testdata/trust_data_test.json"
        eitherDecodeFileStrict fp `endsRight` expectedTrustData
  describe "CodeStatus JSON field names" $ do
    it "uses the server field names" $
      jsonKeysOf (CodeStatus 6 False False False False)
        `shouldBe` Just (sort ["length", "tooManyCodesSent", "tooManyCodesValidated", "securityCodeLocked", "securityCodeCooldown"])
  describe "CodeStatus JSON parsing" $ do
    it "defaults all boolean fields to False when absent" $
      decode "{\"length\":6}" `shouldBe` Just (CodeStatus 6 False False False False)
  describe "TrustedPhone JSON field names" $ do
    it "uses the server field names" $
      jsonKeysOf (TrustedPhone 1 "+81 test" (Just "sms"))
        `shouldBe` Just (sort ["id", "numberWithDialCode", "pushMode"])
  describe "TrustedDevice JSON field names" $ do
    it "uses the server field names" $
      jsonKeysOf (TrustedDevice "id1" "iPhone" "iPhone14")
        `shouldBe` Just (sort ["id", "name", "modelName"])
  describe "TrustedDevice JSON parsing" $ do
    it "defaults modelName to empty string when absent" $
      decode "{\"id\":\"1\",\"name\":\"iPhone\"}" `shouldBe` Just (TrustedDevice "1" "iPhone" "")

  describe "Setup2SADevice" $ do
    context "parsing generated examples to/from JSON" $ do
      it "should succeed" prop_jsonRoundtripSetup2SADevice

  describe "setup2SADeviceLabel" $ do
    it "returns phoneNumber when present" $
      setup2SADeviceLabel (mkDevice [("phoneNumber", String "+1234")]) `shouldBe` "+1234"
    it "returns name when phoneNumber is absent" $
      setup2SADeviceLabel (mkDevice [("name", String "iPhone")]) `shouldBe` "iPhone"
    it "prefers phoneNumber over name" $
      setup2SADeviceLabel (mkDevice [("phoneNumber", String "+1234"), ("name", String "iPhone")]) `shouldBe` "+1234"
    it "returns (unknown) when neither field is present" $
      setup2SADeviceLabel (mkDevice [("deviceId", String "abc")]) `shouldBe` "(unknown)"

  describe "selectPhone" $ do
    context "when the selected input" $ do
      context "is a number within the range" $ do
        it "should succeed" (prop_selectsWithNonMaxIndex selectPhone genTrustedPhone)
      context "is the maximum number" $ do
        it "should succeed" (prop_selectsWithMaxIndex selectPhone genTrustedPhone)

  describe "selectDevice" $ do
    context "when the selected input" $ do
      context "is a number within the range" $ do
        it "should succeed" (prop_selectsWithNonMaxIndex selectDevice genTrustedDevice)
      context "is the maximum number" $ do
        it "should succeed" (prop_selectsWithMaxIndex selectDevice genTrustedDevice)

  describe "selectSetupDevice" $ do
    context "when the selected input" $ do
      context "is a number within the range" $ do
        it "should succeed" (prop_selectsWithNonMaxIndex selectSetupDevice genSetup2SADevice)
      context "is the maximum number" $ do
        it "should succeed" (prop_selectsWithMaxIndex selectSetupDevice genSetup2SADevice)

  describe "selectTwoFaPhone" $ do
    context "when noTrustedDevices is True" $ do
      it "returns the first phone without prompting" $
        silence (selectTwoFaPhone (mkTrustData True [twoFaPhone1, twoFaPhone2]))
          `endsJust` twoFaPhone1
      it "returns Nothing when the phone list is empty" $
        endsNothing $
          silence (selectTwoFaPhone (mkTrustData True []))
    context "when noTrustedDevices is False" $ do
      context "and the phone list is empty" $ do
        it "returns Nothing without prompting" $
          endsNothing $
            silence (selectTwoFaPhone (mkTrustData False []))
      context "and the user presses Enter" $ do
        it "returns Nothing" $
          withStdin "\n" $
            endsNothing $
              silence (selectTwoFaPhone (mkTrustData False [twoFaPhone1]))
      context "and the user enters a valid index" $ do
        it "returns the selected phone" $
          withStdin "2" $
            silence (selectTwoFaPhone (mkTrustData False [twoFaPhone1, twoFaPhone2]))
              `endsJust` twoFaPhone2
      context "and the user first enters an invalid index" $ do
        it "retries and returns the selected phone" $
          withStdin (toS ("99\n1" :: String)) $
            silence (selectTwoFaPhone (mkTrustData False [twoFaPhone1, twoFaPhone2]))
              `endsJust` twoFaPhone1


prop_jsonRoundtripTrustData :: Property
prop_jsonRoundtripTrustData = forAll genTrustData $ \td ->
  decode (encode td) == Just td


prop_jsonRoundtripSetup2SADevice :: Property
prop_jsonRoundtripSetup2SADevice = forAll genSetup2SADevice $ \d ->
  decode (encode d) == Just d


mkDevice :: [(Key, Value)] -> Setup2SADevice
mkDevice = Setup2SADevice . fromList


genSetup2SADevice :: Gen Setup2SADevice
genSetup2SADevice = do
  phone <- genExWordMaybe
  devId <- genExWord
  let pairs =
        catMaybes
          [ Just ("deviceId", String devId)
          , fmap (\p -> ("phoneNumber", String p)) phone
          ]
  pure $ Setup2SADevice $ fromList pairs


genCodeStatus :: Gen CodeStatus
genCodeStatus =
  CodeStatus
    <$> arbitrary
    <*> arbitrary
    <*> arbitrary
    <*> arbitrary
    <*> arbitrary


genTrustedPhone :: Gen TrustedPhone
genTrustedPhone = TrustedPhone <$> arbitrary <*> genExWord <*> genExWordMaybe


genTrustedDevice :: Gen TrustedDevice
genTrustedDevice = TrustedDevice <$> genExWord <*> genExWord <*> genExWord


genTrustedList :: Gen TrustedList
genTrustedList =
  frequency
    [ (1, TrustedPhoneNumbers . NE.fromList <$> listOf1 genTrustedPhone)
    , (1, TrustedDevices . NE.fromList <$> listOf1 genTrustedDevice)
    ]


twoFaPhone1 :: TrustedPhone
twoFaPhone1 = TrustedPhone 1 "+81 test-1" (Just "sms")


twoFaPhone2 :: TrustedPhone
twoFaPhone2 = TrustedPhone 2 "+1 test-2" Nothing


mkTrustData :: Bool -> [TrustedPhone] -> TrustData
mkTrustData noDevices phones =
  TrustData
    { tdList = case NE.nonEmpty phones of
        Just nep -> TrustedPhoneNumbers nep
        Nothing -> TrustedDevices (TrustedDevice "" "" "" :| [])
    , tdSecurityCode = CodeStatus 6 False False False False
    , tdNoTrustedDevices = noDevices
    }


useIOSelector :: (Eq a) => (NonEmpty a -> IO a) -> (Int, a, NonEmpty a) -> IO Bool
useIOSelector selector (idx, want, xs) = do
  withStdin (toS $ show idx) $ do
    selected <- silence $ selector xs
    pure $ selected == want


prop_selectsWithNonMaxIndex :: (Eq a, Show a) => (NonEmpty a -> IO a) -> Gen a -> Property
prop_selectsWithNonMaxIndex selector generator = monadicIO $ do
  pick (genWithNonMaxIndex generator) >>= run . useIOSelector selector >>= assert


prop_selectsWithMaxIndex :: (Eq a, Show a) => (NonEmpty a -> IO a) -> Gen a -> Property
prop_selectsWithMaxIndex selector generator = monadicIO $ do
  let useMax (_ignoredIndex, _ignoredSelection, xs) = (NE.length xs, NE.last xs, xs)
      withNonMax = genWithNonMaxIndex generator
  pick (fmap useMax withNonMax) >>= run . useIOSelector selector >>= assert


genWithNonMaxIndex :: Gen a -> Gen (Int, a, NonEmpty a)
genWithNonMaxIndex sourceGen = do
  low <- chooseInt (1, 5)
  high <- chooseInt (low, 10)
  xs <- NE.fromList <$> vectorOf high sourceGen -- vectorOf high always gives exactly high elements, high >= 1
  pure (low, NE.toList xs !! (low - 1), xs) -- low in [1..high], so in-bounds


genTrustData :: Gen TrustData
genTrustData = TrustData <$> genTrustedList <*> genCodeStatus <*> arbitrary


genExWord :: Gen Text
genExWord = elements Examples.wordz


genExWordMaybe :: Gen (Maybe Text)
genExWordMaybe = frequency [(1, pure Nothing), (1, Just <$> genExWord)]


jsonKeysOf :: (ToJSON a) => a -> Maybe [Key]
jsonKeysOf x = case toJSON x of
  Object o -> Just (sort $ KeyMap.keys o)
  _ -> Nothing


expectedTrustData :: TrustData
expectedTrustData =
  TrustData
    { tdList = TrustedPhoneNumbers (TrustedPhone 1 "+81 \x2022\x2022 \x2022\x2022\x2022\x2022 \x2022\&34" (Just "sms") :| [])
    , tdSecurityCode = CodeStatus 6 False False False False
    , tdNoTrustedDevices = False
    }