packages feed

hscim-0.3.4: test/Test/Schema/UserSpec.hs

{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE ViewPatterns #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2020 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

module Test.Schema.UserSpec
  ( spec,
  )
where

import Data.Aeson
import Data.Either (isLeft, isRight)
import Data.Foldable (for_)
import qualified Data.HashMap.Strict as HM
import Data.Text (Text, toLower, toUpper)
import HaskellWorks.Hspec.Hedgehog (require)
import Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import Lens.Micro
import Network.URI.Static (uri)
import Test.Hspec
import Text.Email.Validate (emailAddress)
import Web.Scim.Filter (AttrPath (..))
import Web.Scim.Schema.Common (ScimBool (ScimBool), URI (..))
import Web.Scim.Schema.PatchOp (Op (..), Operation (..), PatchOp (..), Patchable (..), Path (..))
import qualified Web.Scim.Schema.PatchOp as PatchOp
import Web.Scim.Schema.Schema (Schema (..))
import Web.Scim.Schema.User (NoUserExtra (..), User (..))
import qualified Web.Scim.Schema.User as User
import Web.Scim.Schema.User.Address as Address
import Web.Scim.Schema.User.Certificate as Certificate
import Web.Scim.Schema.User.Email as Email
import Web.Scim.Schema.User.IM as IM
import Web.Scim.Schema.User.Name as Name
import Web.Scim.Schema.User.Phone as Phone
import Web.Scim.Schema.User.Photo as Photo
import Web.Scim.Test.Util

prop_roundtrip :: Property
prop_roundtrip = property $ do
  user <- forAll genUser
  tripping user toJSON fromJSON

-- TODO(arianvp): Note that this only tests the top-level fields.
-- extrac this to a generic test and also do this for sub-properties
prop_caseInsensitive :: Property
prop_caseInsensitive = property $ do
  user <- forAll genUser
  let (Object user') = toJSON user
  let user'' = HM.foldlWithKey' (\u k v -> HM.insert (toUpper k) v u) user' HM.empty
  let user''' = HM.foldlWithKey' (\u k v -> HM.insert (toLower k) v u) user' HM.empty
  fromJSON (Object user'') === Success user
  fromJSON (Object user''') === Success user

type PatchTag = TestTag Text () () UserExtraPatch

type UserExtraPatch = HM.HashMap Text Text

spec :: Spec
spec = do
  describe "applyPatch" $ do
    it "only applies patch for supported fields" $ do
      let schemas' = []
      let extras = HM.empty
      let user :: User PatchTag = User.empty schemas' "hello" extras
      for_
        [ ("username", String "lol"),
          ("displayname", String "lol"),
          ("externalid", String "lol"),
          ("active", Bool True)
        ]
        $ \(key, upd) -> do
          let operation = Operation Replace (Just (NormalPath (AttrPath Nothing key Nothing))) (Just upd)
          let patchOp = PatchOp [operation]
          User.applyPatch user patchOp `shouldSatisfy` isRight
    it "does not support multi-value attributes" $ do
      let schemas' = []
      let extras = HM.empty
      let user :: User PatchTag = User.empty schemas' "hello" extras
      for_
        [ ("schemas", toJSON @[Schema] mempty),
          ("name", toJSON @Name emptyName),
          ("nickName", toJSON @Text mempty),
          ("profileUrl", toJSON @URI (URI [uri|https://example.com|])),
          ("title", toJSON @Text mempty),
          ("userType", toJSON @Text mempty),
          ("preferredLanguage", toJSON @Text mempty),
          ("locale", toJSON @Text mempty),
          ("password", toJSON @Text mempty),
          ("emails", toJSON @[Email] mempty),
          ("phoneNumbers", toJSON @[Phone] mempty),
          ("ims", toJSON @[IM] mempty),
          ("photos", toJSON @[Photo] mempty),
          ("addresses", toJSON @[Address] mempty),
          ("entitlements", toJSON @[Text] mempty),
          ("roles", toJSON @[Text] mempty),
          ("x509Certificates", toJSON @[Certificate] mempty)
        ]
        $ \(key, upd) -> do
          let operation = Operation Replace (Just (NormalPath (AttrPath Nothing key Nothing))) (Just upd)
          let patchOp = PatchOp [operation]
          User.applyPatch user patchOp `shouldSatisfy` isLeft
    it "applies patch to `extra`" $ do
      let schemas' = []
      let extras = HM.empty
      let user :: User PatchTag = User.empty schemas' "hello" extras
      let Right programmingLanguagePath = PatchOp.parsePath (User.supportedSchemas @PatchTag) "urn:hscim:test:programmingLanguage"
      let operation = Operation Replace (Just programmingLanguagePath) (Just (toJSON @Text "haskell"))
      let patchOp = PatchOp [operation]
      User.extra <$> (User.applyPatch user patchOp) `shouldBe` Right (HM.singleton "programmingLanguage" "haskell")
  describe "JSON serialization" $ do
    it "handles all fields" $ do
      require prop_roundtrip
      toJSON completeUser `shouldBe` completeUserJson
      eitherDecode (encode completeUserJson) `shouldBe` Right completeUser
    it "has defaults for all optional and multi-valued fields" $ do
      toJSON minimalUser `shouldBe` minimalUserJson
      eitherDecode (encode minimalUserJson) `shouldBe` Right minimalUser
    it "treats 'null' and '[]' as absence of fields" $
      eitherDecode (encode minimalUserJsonRedundant) `shouldBe` Right minimalUser
    it "allows casing variations in field names" $ do
      require prop_caseInsensitive
      eitherDecode (encode minimalUserJsonNonCanonical) `shouldBe` Right minimalUser
    it "doesn't require the 'schemas' field" $
      eitherDecode (encode minimalUserJsonNoSchemas) `shouldBe` Right minimalUser
    it "doesn't add 'extra' if it's an empty object" $ do
      toJSON (extendedUser UserExtraEmpty) `shouldBe` extendedUserEmptyJson
      eitherDecode (encode extendedUserEmptyJson)
        `shouldBe` Right (extendedUser UserExtraEmpty)
    it "encodes and decodes 'extra' correctly" $ do
      toJSON (extendedUser (UserExtraObject "foo")) `shouldBe` extendedUserObjectJson
      eitherDecode (encode extendedUserObjectJson)
        `shouldBe` Right (extendedUser (UserExtraObject "foo"))

genName :: Gen Name
genName =
  Name
    <$> Gen.maybe (Gen.text (Range.constant 0 20) Gen.unicode)
    <*> Gen.maybe (Gen.text (Range.constant 0 20) Gen.unicode)
    <*> Gen.maybe (Gen.text (Range.constant 0 20) Gen.unicode)
    <*> Gen.maybe (Gen.text (Range.constant 0 20) Gen.unicode)
    <*> Gen.maybe (Gen.text (Range.constant 0 20) Gen.unicode)
    <*> Gen.maybe (Gen.text (Range.constant 0 20) Gen.unicode)

genUri :: Gen URI
genUri = Gen.element [URI [uri|https://example.com|]]

-- TODO(arianvp) Generate the lists too, but first need better support for SCIM
-- lists in the first place
genUser :: Gen (User (TestTag Text () () NoUserExtra))
genUser = do
  schemas' <- pure [User20] -- TODO random schemas or?
  userName' <- Gen.text (Range.constant 0 20) Gen.unicode
  externalId' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  name' <- Gen.maybe genName
  displayName' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  nickName' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  profileUrl' <- Gen.maybe $ genUri
  title' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  userType' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  preferredLanguage' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  locale' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  active' <- Gen.maybe $ (ScimBool <$> Gen.bool)
  password' <- Gen.maybe $ Gen.text (Range.constant 0 20) Gen.unicode
  emails' <- pure [] -- Gen.list (Range.constant 0 20) genEmail
  phoneNumbers' <- pure [] -- Gen.list (Range.constant 0 20) genPhone
  ims' <- pure [] -- Gen.list (Range.constant 0 20) genIM
  photos' <- pure [] -- Gen.list (Range.constant 0 20) genPhoto
  addresses' <- pure [] -- Gen.list (Range.constant 0 20) genAddress
  entitlements' <- pure [] -- Gen.list (Range.constant 0 20) (Gen.text (Range.constant 0 20) Gen.unicode)
  roles' <- pure [] -- Gen.list (Range.constant 0 20) (Gen.text (Range.constant 0 10) Gen.unicode)
  x509Certificates' <- pure [] -- Gen.list (Range.constant 0 20) genCertificate
  pure $
    User
      { schemas = schemas',
        userName = userName',
        externalId = externalId',
        name = name',
        displayName = displayName',
        nickName = nickName',
        profileUrl = profileUrl',
        title = title',
        userType = userType',
        preferredLanguage = preferredLanguage',
        locale = locale',
        active = active',
        password = password',
        emails = emails',
        phoneNumbers = phoneNumbers',
        ims = ims',
        photos = photos',
        addresses = addresses',
        entitlements = entitlements',
        roles = roles',
        x509Certificates = x509Certificates',
        extra = NoUserExtra
      }

-- | A 'User' with all attributes present.
completeUser :: User (TestTag Text () () NoUserExtra)
completeUser =
  User
    { schemas = [User20],
      userName = "sample userName",
      externalId = Just "sample externalId",
      name =
        Just $
          Name
            { Name.formatted = Just "sample formatted name",
              Name.familyName = Nothing,
              Name.givenName = Nothing,
              Name.middleName = Nothing,
              Name.honorificPrefix = Nothing,
              Name.honorificSuffix = Nothing
            },
      displayName = Just "sample displayName",
      nickName = Just "sample nickName",
      profileUrl = Just (URI [uri|https://example.com|]),
      title = Just "sample title",
      userType = Just "sample userType",
      preferredLanguage = Just "da, en-gb;q=0.8, en;q=0.7",
      locale = Just "en-US",
      active = Just (ScimBool True),
      password = Just "sample password",
      emails =
        [ Email
            { Email.typ = Just "work",
              Email.value =
                maybe
                  (error "couldn't parse email")
                  EmailAddress2
                  (emailAddress "user@example.com"),
              Email.primary = Nothing
            }
        ],
      phoneNumbers =
        [ Phone
            { Phone.typ = Just "work",
              Phone.value = Just "+15417543010"
            }
        ],
      ims =
        [ IM
            { IM.typ = Just "Wire",
              IM.value = Just "@user"
            }
        ],
      photos =
        [ Photo
            { Photo.typ = Just "userpic",
              Photo.value = Just (URI [uri|https://example.com/userpic.png|])
            }
        ],
      addresses =
        [ Address
            { Address.formatted = Just "sample Address",
              Address.streetAddress = Nothing,
              Address.locality = Nothing,
              Address.region = Nothing,
              Address.postalCode = Nothing,
              Address.country = Nothing,
              Address.typ = Just "home",
              Address.primary = Just (ScimBool True)
            }
        ],
      entitlements = ["sample entitlement"],
      roles = ["sample role"],
      x509Certificates =
        [ Certificate
            { Certificate.typ = Just "sample certificate type",
              Certificate.value = Just "sample certificate"
            }
        ],
      extra = NoUserExtra
    }

-- | Reference encoding of 'completeUser'.
completeUserJson :: Value
completeUserJson =
  [scim|
{
  "roles": [
    "sample role"
  ],
  "x509Certificates": [
    {
      "value": "sample certificate",
      "type": "sample certificate type"
    }
  ],
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "locale": "en-US",
  "addresses": [
    {
      "formatted": "sample Address",
      "primary": true,
      "type": "home"
    }
  ],
  "userName": "sample userName",
  "phoneNumbers": [
    {
      "value": "+15417543010",
      "type": "work"
    }
  ],
  "active": true,
  "photos": [
    {
      "value": "https://example.com/userpic.png",
      "type": "userpic"
    }
  ],
  "name": {
    "formatted": "sample formatted name"
  },
  "password": "sample password",
  "emails": [
    {
      "value": "user@example.com",
      "type": "work"
    }
  ],
  "ims": [
    {
      "value": "@user",
      "type": "Wire"
    }
  ],
  "preferredLanguage": "da, en-gb;q=0.8, en;q=0.7",
  "entitlements": [
    "sample entitlement"
  ],
  "displayName": "sample displayName",
  "nickName": "sample nickName",
  "profileUrl": "https://example.com",
  "title": "sample title",
  "externalId": "sample externalId",
  "userType": "sample userType"
}
|]

-- | A 'User' with all attributes empty (if possible).
minimalUser :: User (TestTag Text () () NoUserExtra)
minimalUser = User.empty [User20] "sample userName" NoUserExtra

-- | Reference encoding of 'minimalUser'.
minimalUserJson :: Value
minimalUserJson =
  [scim|
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "userName": "sample userName"
}
|]

-- | An encoding of 'minimalUser' with redundant @null@s and @[]@s for missing
-- fields.
minimalUserJsonRedundant :: Value
minimalUserJsonRedundant =
  [scim|
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "roles": [],
  "x509Certificates": [],
  "locale": null,
  "addresses": [],
  "userName": "sample userName",
  "phoneNumbers": [],
  "active": null,
  "photos": [],
  "name": null,
  "password": null,
  "emails": [],
  "ims": [],
  "preferredLanguage": null,
  "entitlements": [],
  "displayName": null,
  "nickName": null,
  "profileUrl": null,
  "title": null,
  "externalId": null,
  "userType": null
}
|]

-- | An encoding of 'minimalUser' with non-canonical field name casing.
minimalUserJsonNonCanonical :: Value
minimalUserJsonNonCanonical =
  [scim|
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "USERname": "sample userName"
}
|]

-- | An encoding of 'minimalUser' without the @schemas@ field.
minimalUserJsonNoSchemas :: Value
minimalUserJsonNoSchemas =
  [scim|
{
  "userName": "sample userName"
}
|]

data UserExtraTest = UserExtraEmpty | UserExtraObject {test :: Text}
  deriving (Show, Eq)

instance FromJSON UserExtraTest where
  parseJSON = withObject "UserExtraObject" $ \(lowercase -> o) -> do
    o .:? "urn:hscim:test" >>= \case
      Nothing -> pure UserExtraEmpty
      Just (lowercase -> o2) -> UserExtraObject <$> o2 .: "test"
    where
      lowercase = HM.fromList . map (over _1 toLower) . HM.toList

instance ToJSON UserExtraTest where
  toJSON UserExtraEmpty = object []
  toJSON (UserExtraObject t) =
    object ["urn:hscim:test" .= object ["test" .= t]]

instance Patchable UserExtraTest where
  applyOperation _ _ = undefined

-- | A 'User' with extra fields present.
extendedUser :: UserExtraTest -> User (TestTag Text () () UserExtraTest)
extendedUser e =
  (User.empty [User20, CustomSchema "urn:hscim:test"] "sample userName" e)

-- | Encoding of @extendedUser UserExtraEmpty@.
extendedUserEmptyJson :: Value
extendedUserEmptyJson =
  [scim|
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User",
    "urn:hscim:test"
  ],
  "userName": "sample userName"
}
|]

-- | Encoding of @extendedUser (UserExtraObject "foo")@.
extendedUserObjectJson :: Value
extendedUserObjectJson =
  [scim|
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User",
    "urn:hscim:test"
  ],
  "userName": "sample userName",
  "urn:hscim:test": {
    "test": "foo"
  }
}
|]