diff --git a/libs/hscim/default.nix b/libs/hscim/default.nix index d0a64d40d0..25dd43c5a0 100644 --- a/libs/hscim/default.nix +++ b/libs/hscim/default.nix @@ -4,6 +4,7 @@ # dependencies are added or removed. { mkDerivation , aeson +, aeson-diff , aeson-qq , attoparsec , attoparsec-aeson @@ -24,6 +25,7 @@ , http-types , HUnit , hw-hspec-hedgehog +, imports , indexed-traversable , lens-aeson , lib @@ -32,6 +34,7 @@ , mmorph , mtl , network-uri +, QuickCheck , retry , scientific , servant @@ -60,6 +63,7 @@ mkDerivation { isExecutable = true; libraryHaskellDepends = [ aeson + aeson-diff aeson-qq attoparsec attoparsec-aeson @@ -75,6 +79,7 @@ mkDerivation { http-api-data http-media http-types + imports list-t microlens mmorph @@ -94,6 +99,7 @@ mkDerivation { time utf8-string uuid + vector wai wai-extra wai-utilities @@ -109,9 +115,12 @@ mkDerivation { ]; testHaskellDepends = [ aeson + aeson-diff + aeson-qq attoparsec base bytestring + case-insensitive email-validate hedgehog hspec @@ -120,10 +129,13 @@ mkDerivation { http-types HUnit hw-hspec-hedgehog + imports indexed-traversable lens-aeson microlens network-uri + QuickCheck + scientific servant servant-server stm-containers diff --git a/libs/hscim/hscim.cabal b/libs/hscim/hscim.cabal index 73814d4a55..2e13fcbdbb 100644 --- a/libs/hscim/hscim.cabal +++ b/libs/hscim/hscim.cabal @@ -78,14 +78,17 @@ library OverloadedStrings RankNTypes ScopedTypeVariables + TupleSections TypeApplications TypeFamilies TypeOperators TypeSynonymInstances + ViewPatterns ghc-options: -Wall -Wredundant-constraints -Wunused-packages build-depends: aeson + , aeson-diff , aeson-qq , attoparsec , attoparsec-aeson @@ -101,6 +104,7 @@ library , http-api-data , http-media , http-types + , imports , list-t , microlens , mmorph @@ -120,6 +124,7 @@ library , time , utf8-string , uuid + , vector , wai , wai-extra , wai-utilities @@ -210,9 +215,12 @@ test-suite spec build-tool-depends: hspec-discover:hspec-discover build-depends: aeson + , aeson-diff + , aeson-qq , attoparsec , base , bytestring + , case-insensitive , email-validate , hedgehog , hscim @@ -222,10 +230,13 @@ test-suite spec , http-types , HUnit , hw-hspec-hedgehog + , imports , indexed-traversable , lens-aeson , microlens , network-uri + , QuickCheck + , scientific , servant , servant-server , stm-containers diff --git a/libs/hscim/src/Web/Scim/Class/Group.hs b/libs/hscim/src/Web/Scim/Class/Group.hs index 16ef2d0140..9336509278 100644 --- a/libs/hscim/src/Web/Scim/Class/Group.hs +++ b/libs/hscim/src/Web/Scim/Class/Group.hs @@ -41,6 +41,7 @@ import Web.Scim.Handler import Web.Scim.Schema.Common import Web.Scim.Schema.ListResponse import Web.Scim.Schema.Meta +import qualified Web.Scim.Schema.Schema as S ---------------------------------------------------------------------------- -- /Groups API @@ -48,7 +49,7 @@ import Web.Scim.Schema.Meta type Schema = Text -- | Configurable parts of 'Group'. -class GroupTypes tag where +class (S.SupportsSchemas tag) => GroupTypes tag where -- | Group ID type. type GroupId tag diff --git a/libs/hscim/src/Web/Scim/Class/User.hs b/libs/hscim/src/Web/Scim/Class/User.hs index 982ad3700e..e83c689db4 100644 --- a/libs/hscim/src/Web/Scim/Class/User.hs +++ b/libs/hscim/src/Web/Scim/Class/User.hs @@ -67,7 +67,7 @@ data UserSite tag route = UserSite route :- Capture "id" (UserId tag) :> ReqBody '[SCIM] (PatchOp tag) - :> Patch '[SCIM] (StoredUser tag), + :> Servant.Patch '[SCIM] (StoredUser tag), usDeleteUser :: route :- Capture "id" (UserId tag) @@ -138,16 +138,20 @@ class (Monad m, AuthTypes tag, UserTypes tag) => UserDB tag m where PatchOp tag -> ScimHandler m (StoredUser tag) default patchUser :: - (Patchable (UserExtra tag), FromJSON (UserExtra tag)) => + -- (Patchable (UserExtra tag), FromJSON (UserExtra tag)) => AuthInfo tag -> UserId tag -> -- | PATCH payload PatchOp tag -> ScimHandler m (StoredUser tag) - patchUser info uid op' = do - (WithMeta _ (WithId _ (user :: User tag))) <- getUser info uid - (newUser :: User tag) <- applyPatch user op' - putUser info uid newUser + patchUser = undefined + + {- + patchUser info uid op' = do + (WithMeta _ (WithId _ (user :: User tag))) <- getUser info uid + (newUser :: User tag) <- applyPatch user op' + putUser info uid newUser + -} -- | Delete a user. -- diff --git a/libs/hscim/src/Web/Scim/Client.hs b/libs/hscim/src/Web/Scim/Client.hs index c80070fb03..4fea65778f 100644 --- a/libs/hscim/src/Web/Scim/Client.hs +++ b/libs/hscim/src/Web/Scim/Client.hs @@ -59,12 +59,14 @@ import Web.Scim.Filter (Filter) import Web.Scim.Schema.ListResponse (ListResponse) import Web.Scim.Schema.PatchOp (PatchOp) import qualified Web.Scim.Schema.ResourceType as ResourceType +import Web.Scim.Schema.Schema import Web.Scim.Schema.User (User) import Web.Scim.Schema.UserTypes (UserExtra, UserId) import Web.Scim.Server type HasScimClient tag = ( AuthTypes tag, + SupportsSchemas tag, ToJSON (UserExtra tag), FromJSON (UserExtra tag), FromJSON (UserId tag), diff --git a/libs/hscim/src/Web/Scim/Filter.hs b/libs/hscim/src/Web/Scim/Filter.hs index 5862f6a36b..68ed76e5ff 100644 --- a/libs/hscim/src/Web/Scim/Filter.hs +++ b/libs/hscim/src/Web/Scim/Filter.hs @@ -64,9 +64,10 @@ import Data.Aeson.Text as Aeson import Data.Attoparsec.ByteString.Char8 import Data.Scientific import Data.String -import Data.Text (Text, isInfixOf, isPrefixOf, isSuffixOf, pack) +import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) -import Data.Text.Lazy (toStrict) +import qualified Data.Text.Lazy as LT +import Imports import Lens.Micro import Web.HttpApiData import Web.Scim.AttrName @@ -119,16 +120,44 @@ data CompareOp -- more complex filters -- -- FILTER = attrExp / logExp / valuePath / *1"not" "(" FILTER ")" +-- PATH = attrPath / valuePath [subAttr] +-- +-- FUTUREWORK(fisx): Currently we don't support matching on lists in paths +-- as we currently don't support filtering on arbitrary attributes yet +-- e.g. +-- @ +-- "path":"members[value eq +-- \"2819c223-7f76-453a-919d-413861904646\"].displayName" +-- @ +-- is not supported. The code here should actually read something like this: +-- @ +-- data Filter = FilterAttrCompare (Either AttrPath ValuePath) CompareOp CompValue +-- @ +-- +-- FUTUREWORK(fisx): does it make sense to have a type-level argument to +-- AttrPath, ValuePath(?), Filter containing the allowed schemas? +-- it's certainly information that should be known at compile time... +-- +-- https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2 data Filter = -- | Compare the attribute value with a literal FilterAttrCompare AttrPath CompareOp CompValue deriving (Eq, Show) -- | valuePath = attrPath "[" valFilter "]" +-- +-- A `ValuePath` without a `Filter` is morally an `AttrPath`. +-- +-- Cases covered: +-- - '.roles' +-- - '.bla.foo' +-- - '.email["type" eq "work"]' +-- -- TODO(arianvp): This is a slight simplification at the moment as we -- don't support the complete Filter grammar. This should be a -- valFilter, not a FILTER. -data ValuePath = ValuePath AttrPath Filter +-- https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2 +data ValuePath = ValuePath AttrPath (Maybe Filter) deriving (Eq, Show) -- | subAttr = "." ATTRNAME @@ -143,17 +172,6 @@ data AttrPath = AttrPath (Maybe Schema) AttrName (Maybe SubAttr) topLevelAttrPath :: Text -> AttrPath topLevelAttrPath x = AttrPath Nothing (AttrName x) Nothing --- | PATH = attrPath / valuePath [subAttr] --- --- Currently we don't support matching on lists in paths as --- we currently don't support filtering on arbitrary attributes yet --- e.g. --- @ --- "path":"members[value eq --- \"2819c223-7f76-453a-919d-413861904646\"].displayName" --- @ --- is not supported - ---------------------------------------------------------------------------- -- Parsing @@ -165,7 +183,7 @@ topLevelAttrPath x = AttrPath Nothing (AttrName x) Nothing -- lift an Attoparsec parser (from Aeson) to Megaparsec parseFilter :: [Schema] -> Text -> Either Text Filter parseFilter supportedSchemas = - over _Left pack + over _Left T.pack . parseOnly (skipSpace *> pFilter supportedSchemas <* skipSpace <* endOfInput) . encodeUtf8 @@ -186,7 +204,7 @@ pSubAttr = char '.' *> (SubAttr <$> pAttrName) -- | valuePath = attrPath "[" valFilter "]" pValuePath :: [Schema] -> Parser ValuePath pValuePath supportedSchemas = - ValuePath <$> pAttrPath supportedSchemas <*> (char '[' *> pFilter supportedSchemas <* char ']') + ValuePath <$> pAttrPath supportedSchemas <*> (Just <$> (char '[' *> pFilter supportedSchemas <* char ']')) -- | Value literal parser. pCompValue :: Parser CompValue @@ -245,7 +263,8 @@ rSubAttr :: SubAttr -> Text rSubAttr (SubAttr x) = "." <> rAttrName x rValuePath :: ValuePath -> Text -rValuePath (ValuePath attrPath filter') = rAttrPath attrPath <> "[" <> renderFilter filter' <> "]" +rValuePath (ValuePath attrPath Nothing) = rAttrPath attrPath +rValuePath (ValuePath attrPath (Just filter')) = rAttrPath attrPath <> "[" <> renderFilter filter' <> "]" -- | Value literal renderer. rCompValue :: CompValue -> Text @@ -253,8 +272,8 @@ rCompValue = \case ValNull -> "null" ValBool True -> "true" ValBool False -> "false" - ValNumber n -> toStrict $ Aeson.encodeToLazyText (Aeson.Number n) - ValString s -> toStrict $ Aeson.encodeToLazyText (Aeson.String s) + ValNumber n -> LT.toStrict $ Aeson.encodeToLazyText (Aeson.Number n) + ValString s -> LT.toStrict $ Aeson.encodeToLazyText (Aeson.String s) -- | Comparison operator renderer. rCompareOp :: CompareOp -> Text @@ -274,9 +293,9 @@ compareStr :: CompareOp -> Text -> Text -> Bool compareStr = \case OpEq -> (==) -- equal OpNe -> (/=) -- not equal - OpCo -> flip isInfixOf -- A contains B - OpSw -> flip isPrefixOf -- A starts with B - OpEw -> flip isSuffixOf -- A ends with B + OpCo -> flip T.isInfixOf -- A contains B + OpSw -> flip T.isPrefixOf -- A starts with B + OpEw -> flip T.isSuffixOf -- A ends with B OpGt -> (>) -- greater than OpGe -> (>=) -- greater than or equal to OpLt -> (<) -- less than @@ -291,3 +310,15 @@ instance FromHttpApiData Filter where instance ToHttpApiData Filter where toUrlPiece = renderFilter + +instance ToJSON AttrPath where + toJSON = toJSON . rAttrPath + +instance FromJSON AttrPath where + parseJSON val = parseJSON @Text val >>= either fail pure . parseOnly (pAttrPath []) . encodeUtf8 + +instance ToJSON ValuePath where + toJSON = todo + +instance FromJSON ValuePath where + parseJSON = todo diff --git a/libs/hscim/src/Web/Scim/Schema/Common.hs b/libs/hscim/src/Web/Scim/Schema/Common.hs index c0adb84c21..1aaa425a1f 100644 --- a/libs/hscim/src/Web/Scim/Schema/Common.hs +++ b/libs/hscim/src/Web/Scim/Schema/Common.hs @@ -22,9 +22,11 @@ module Web.Scim.Schema.Common where +import Control.Monad.Error.Class import Data.Aeson import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KeyMap +import Data.Aeson.Types (Parser) import qualified Data.CaseInsensitive as CI import Data.List (nub, (\\)) import Data.String.Conversions (cs) @@ -40,7 +42,7 @@ data WithId id a = WithId instance (ToJSON id, ToJSON a) => ToJSON (WithId id a) where toJSON (WithId i v) = case toJSON v of - (Object o) -> Object (KeyMap.insert "id" (toJSON i) o) + (Object o) -> Object (KeyMap.insert (Key.fromString "id") (toJSON i) o) other -> other instance (FromJSON id, FromJSON a) => FromJSON (WithId id a) where @@ -104,9 +106,10 @@ parseOptions = -- 'Data.CaseInsensitive.foldCase'. They're not all the same thing! -- https://github.com/basvandijk/case-insensitive/issues/31 -- --- (FUTUREWORK: The "recursively" part is a bit of a waste and could be dropped, but we would --- have to spend more effort in making sure it is always called manually in nested parsers.) -jsonLower :: forall m. (m ~ Either [Text]) => Value -> m Value +-- NB: The "recursively" part is at least partially redundant because +-- we call `jsonLower` in all `FromJSON` instances, but we don't care +-- about the overhead because scim objects are never that deep. +jsonLower :: forall m. (MonadError String m) => Value -> m Value jsonLower (Object (KeyMap.toList -> olist)) = Object . KeyMap.fromList <$> (nubCI >> mapM lowerPair olist) where @@ -115,14 +118,15 @@ jsonLower (Object (KeyMap.toList -> olist)) = let unnubbed = Key.toText . fst <$> olist in case unnubbed \\ nub unnubbed of [] -> pure () - bad@(_ : _) -> Left bad + bad@(_ : _) -> throwError $ "case insensitivity check: redundant attributes " <> show bad lowerPair :: (Key.Key, Value) -> m (Key.Key, Value) - lowerPair (key, val) = (lowerKey key,) <$> jsonLower val + lowerPair (key, val) = (Key.fromText . CI.foldCase . Key.toText $ key,) <$> jsonLower val jsonLower (Array x) = Array <$> mapM jsonLower x -jsonLower same@(String _) = Right same -- (only object attributes, not all texts in the value side of objects!) -jsonLower same@(Number _) = Right same -jsonLower same@(Bool _) = Right same -jsonLower same@Null = Right same - -lowerKey :: Key.Key -> Key.Key -lowerKey = Key.fromText . CI.foldCase . Key.toText +jsonLower same@(String _) = pure same +jsonLower same@(Number _) = pure same +jsonLower same@(Bool _) = pure same +jsonLower same@Null = pure same + +-- `jsonLower` for aeson `Parser`s. +prsJsonLower :: Value -> Parser Value +prsJsonLower = either fail pure . jsonLower diff --git a/libs/hscim/src/Web/Scim/Schema/PatchOp.hs b/libs/hscim/src/Web/Scim/Schema/PatchOp.hs index 1ac01c3b16..51497ec5d6 100644 --- a/libs/hscim/src/Web/Scim/Schema/PatchOp.hs +++ b/libs/hscim/src/Web/Scim/Schema/PatchOp.hs @@ -17,134 +17,405 @@ module Web.Scim.Schema.PatchOp where -import Control.Applicative -import Control.Monad (guard) -import Control.Monad.Except -import qualified Data.Aeson.Key as Key -import qualified Data.Aeson.KeyMap as KeyMap -import Data.Aeson.Types (FromJSON (parseJSON), ToJSON (toJSON), Value (String), object, withObject, withText, (.:), (.:?), (.=)) -import qualified Data.Aeson.Types as Aeson -import Data.Attoparsec.ByteString (Parser, endOfInput, parseOnly) -import Data.Bifunctor (first) +import Control.Monad.Error.Class +import Data.Aeson +import qualified Data.Aeson.Diff as AD +import qualified Data.Aeson.Key as AK +import qualified Data.Aeson.KeyMap as AK +import qualified Data.Aeson.Patch as AD +import qualified Data.Aeson.Pointer as AD +import Data.Aeson.Types import qualified Data.CaseInsensitive as CI -import Data.Text (Text) -import Data.Text.Encoding (encodeUtf8) -import Web.Scim.AttrName (AttrName (..)) -import Web.Scim.Filter (AttrPath (..), SubAttr (..), ValuePath (..), pAttrPath, pSubAttr, pValuePath, rAttrPath, rSubAttr, rValuePath) -import Web.Scim.Schema.Common (lowerKey) +import Data.Proxy (Proxy (Proxy)) +import Data.Scientific (Scientific) +import qualified Data.Set as Set +import qualified Data.Text as T +import qualified Data.Text as Text +import qualified Data.Vector as V +import Imports +import Web.Scim.AttrName +import Web.Scim.Filter +import Web.Scim.Schema.Common import Web.Scim.Schema.Error -import Web.Scim.Schema.Schema (Schema (PatchOp20)) -import Web.Scim.Schema.UserTypes (UserTypes (supportedSchemas)) +import Web.Scim.Schema.Schema -newtype PatchOp tag = PatchOp - {getOperations :: [Operation]} - deriving (Eq, Show) - --- | The 'Path' attribute value is a 'String' containing an attribute path --- describing the target of the operation. It is OPTIONAL --- for 'Op's "add" and "replace", and is REQUIRED for "remove". See --- relevant operation sections below for details. +-- This type provides the parser for the scim patch syntax, and can be +-- turned into an `AD.Patch` with `validatePatchOp`. +-- +-- Differences to AD.Patch: +-- - Only add, remove, replace. +-- - Point into array with filters, not indices. +-- - Case insensitive. +-- - The semantics is a bit convoluted and may diverge from that of +-- `AD.Patch` (see RFCs). +-- +-- The Schemas associated with `tag` are only validated in +-- `applyPatch`. We could do validation in `jsonPatchToScimPatch`, +-- but that seemed unnecessarily complicated. -- --- TODO(arianvp): When value is an array, it needs special handling. --- e.g. primary fields need to be negated and whatnot. --- We currently do not do that :) +-- Example: -- --- NOTE: When the path contains a schema, this schema must be implicitly added --- to the list of schemas on the result type -data Operation = Operation - { op :: Op, - path :: Maybe Path, - value :: Maybe Value - } +-- { "schemas": +-- ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], +-- "Operations":[ +-- { +-- "op":"add", +-- "path":"members", +-- "value":[ +-- { +-- "display": "Babs Jensen", +-- "$ref": "https://example.com/v2/Users/2819c223...413861904646", +-- "value": "2819c223-7f76-453a-919d-413861904646" +-- } +-- ] +-- }, +-- ... + additional operations if needed ... +-- ] +-- } +-- +-- patch for scim: https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2 +-- patch for json: https://datatracker.ietf.org/doc/html/rfc6901 +newtype Patch tag = Patch {fromPatch :: [PatchOp tag]} -- TODO: rename to `ScimPatch`, and PatchOp to `ScimPatchOp`? deriving (Eq, Show) -data Op - = Add - | Replace - | Remove - deriving (Eq, Show, Enum, Bounded) - --- | PATH = attrPath / valuePath [subAttr] -data Path - = NormalPath AttrPath - | IntoValuePath ValuePath (Maybe SubAttr) +data PatchOp tag + = PatchOpAdd (Maybe ValuePath) Value + | PatchOpRemove ValuePath + | PatchOpReplace (Maybe ValuePath) Value deriving (Eq, Show) -parsePath :: [Schema] -> Text -> Either String Path -parsePath schemas' = parseOnly (pPath schemas' <* endOfInput) . encodeUtf8 - --- | PATH = attrPath / valuePath [subAttr] -pPath :: [Schema] -> Parser Path -pPath schemas' = - IntoValuePath <$> pValuePath schemas' <*> optional pSubAttr - <|> NormalPath <$> pAttrPath schemas' - -rPath :: Path -> Text -rPath (NormalPath attrPath) = rAttrPath attrPath -rPath (IntoValuePath valuePath subAttr) = rValuePath valuePath <> maybe "" rSubAttr subAttr - --- TODO(arianvp): According to the SCIM spec we should throw an InvalidPath --- error when the path is invalid syntax. this is a bit hard to do though as we --- can't control what errors FromJSON throws :/ -instance (UserTypes tag) => FromJSON (PatchOp tag) where - parseJSON = withObject "PatchOp" $ \v -> do - let o = KeyMap.fromList . map (first lowerKey) . KeyMap.toList $ v - schemas' :: [Schema] <- o .: "schemas" - guard $ PatchOp20 `elem` schemas' - operations <- Aeson.explicitParseField (Aeson.listParser $ operationFromJSON (supportedSchemas @tag)) o "operations" - pure $ PatchOp operations - -instance ToJSON (PatchOp tag) where - toJSON (PatchOp operations) = - object ["operations" .= operations, "schemas" .= [PatchOp20]] - --- TODO: Azure wants us to be case-insensitive on _values_ as well here. We currently do not --- comply with that. -operationFromJSON :: [Schema] -> Value -> Aeson.Parser Operation -operationFromJSON schemas' = - withObject "Operation" $ \v -> do - let o = KeyMap.fromList . map (first lowerKey) . KeyMap.toList $ v - Operation - <$> (o .: "op") - <*> Aeson.explicitParseFieldMaybe (pathFromJSON schemas') o "path" - <*> (o .:? "value") - -pathFromJSON :: [Schema] -> Value -> Aeson.Parser Path -pathFromJSON schemas' = - withText "Path" $ either fail pure . parsePath schemas' - -instance ToJSON Operation where - toJSON (Operation op' path' value') = - object $ ("op" .= op') : optionalField "path" path' ++ optionalField "value" value' +---------------------------------------------------------------------- + +-- | Compute a patch operation for the aeson-diff package. The +-- `Value` argument is needed to compute absolute indices into arrays +-- from the filter expressions in the scim patch. +-- +-- Scim schema information in `AttrName`s is ignored (`AD.Patch` does +-- not do schema validation). +scimPatchToJsonPatch :: forall tag m. (MonadError ScimError m) => Patch tag -> Value -> m AD.Patch +scimPatchToJsonPatch (Patch scimOps) jsonOrig = do + jsonOps <- do + let err = throwError . badRequest InvalidValue . Just . Text.pack + (mapOp `mapM` scimOps) & either err pure + pure $ AD.Patch jsonOps + where + mapOp :: PatchOp tag -> Either String AD.Operation + mapOp = \case + PatchOpAdd mbAttrPath val -> (`AD.Add` val) <$> mapPath mbAttrPath + PatchOpRemove attrPath -> AD.Rem <$> mapPath (Just attrPath) + PatchOpReplace mbAttrPath val -> (`AD.Rep` val) <$> mapPath mbAttrPath + + mapPath :: Maybe ValuePath -> Either String AD.Pointer + mapPath Nothing = pure emptyPath + mapPath (Just (ValuePath (AttrPath _mbSchema name mbSub) Nothing)) = + pure (AD.Pointer (nm : sub)) + where + nm = AD.OKey . AK.fromText . rAttrName $ name + sub = [AD.OKey . AK.fromText . rAttrName $ subName | SubAttr subName <- maybeToList mbSub] + mapPath (Just (ValuePath (AttrPath _mbSchema name Nothing) mbFilter)) = + AD.Pointer <$> ((nm :) <$> fltr) + where + key = AK.fromText (rAttrName name) + nm = AD.OKey key + + fltr :: Either String [AD.Key] + fltr = case mbFilter of + Nothing -> pure [] + Just fl -> do + arr <- case jsonOrig of + Object obj -> case AK.lookup key obj of + Just (Array vec) -> pure $ V.toList vec + _ -> throwError $ AK.toString key <> " does not point to an object" + _ -> throwError $ "not an object" + let mkPointer ix = AD.AKey ix + pure $ mkPointer <$> arrFilterToIndices fl arr + mapPath bad = + throwError $ "scimPatchToJsonPatch: illegal or unsupported attribute path: " <> show bad + +arrFilterToIndices :: Filter -> [Value] -> [Int] +arrFilterToIndices fltr arr = + [ix | (ix, val) <- zip [0 ..] arr, matches val] + where + matches :: Value -> Bool + matches val = case fltr of + FilterAttrCompare attr op compVal -> + maybe False (compareValue op compVal) (attrValue attr val) + + attrValue :: AttrPath -> Value -> Maybe Value + attrValue (AttrPath _ name mbSub) val = case mbSub of + Nothing -> lookupAttr name val + Just (SubAttr subName) -> do + obj <- asObject val + top <- lookupAttrInObject name obj + subObj <- asObject top + lookupAttrInObject subName subObj + + lookupAttr :: AttrName -> Value -> Maybe Value + lookupAttr name val = case val of + Object obj -> lookupAttrInObject name obj + -- e.g. roles[value eq "admin"] + _ | name == "value" -> Just val + _ -> Nothing + + lookupAttrInObject :: AttrName -> AK.KeyMap Value -> Maybe Value + lookupAttrInObject name obj = + let target = CI.foldCase (rAttrName name) + in snd <$> find (\(key, _) -> CI.foldCase (AK.toText key) == target) (AK.toList obj) + + asObject :: Value -> Maybe (AK.KeyMap Value) + asObject = \case + Object obj -> Just obj + _ -> Nothing + + compareValue :: CompareOp -> CompValue -> Value -> Bool + compareValue op compVal val = case (compVal, val) of + (ValString s, String t) -> compareStr op (CI.foldCase t) (CI.foldCase s) + (ValNumber s, Number t) -> compareNumber op t s + (ValBool s, Bool t) -> compareBool op t s + (ValNull, Null) -> compareNull op + _ -> False + + compareNumber :: CompareOp -> Scientific -> Scientific -> Bool + compareNumber = \case + OpEq -> (==) + OpNe -> (/=) + OpGt -> (>) + OpGe -> (>=) + OpLt -> (<) + OpLe -> (<=) + OpCo -> \_ _ -> False + OpSw -> \_ _ -> False + OpEw -> \_ _ -> False + + compareBool :: CompareOp -> Bool -> Bool -> Bool + compareBool op a b = case op of + OpEq -> a == b + OpNe -> a /= b + _ -> False + + compareNull :: CompareOp -> Bool + compareNull = \case + OpEq -> True + OpNe -> False + _ -> False + +-- | The inverse of `jsonPatchToScimPatch`. This does not validate +-- schemas, and never fills the schema argument of `AttrPath`. See +-- haddocks of `Patch` above. Since `AD.Patch` is more expressive +-- than `Patch`, this can have errors. +jsonPatchToScimPatch :: forall tag m. (MonadError ScimError m) => AD.Patch -> Value -> m (Patch tag) +jsonPatchToScimPatch jsonPatch jsonOrig = do + ops <- do + let err = throwError . badRequest InvalidValue . Just . Text.pack + (mapOp `mapM` AD.patchOperations jsonPatch) & either err pure + pure $ Patch ops + where + mapOp :: AD.Operation -> Either String (PatchOp tag) + mapOp = \case + AD.Add path val -> (`PatchOpAdd` val) <$> mapPath (traceShowId path) + AD.Rem path -> traceShowId (mapPath path) >>= maybe (throwError "remove op requires path argument.") (pure . PatchOpRemove) + AD.Rep path val -> (`PatchOpReplace` val) <$> mapPath (traceShowId path) + AD.Mov {} -> throwError "unsupported patch operation: mov" + AD.Cpy {} -> throwError "unsupported patch operation: cpy" + AD.Tst {} -> throwError "unsupported patch operation: tst" + + mapPath :: AD.Pointer -> Either String (Maybe ValuePath) + mapPath (AD.Pointer []) = pure Nothing + mapPath (AD.Pointer [AD.OKey key]) = pure $ Just (ValuePath (topLevelAttrPath (AK.toText key)) Nothing) + mapPath (AD.Pointer [AD.OKey key, AD.OKey sub]) = todo key sub + mapPath (AD.Pointer [AD.OKey key, AD.AKey ix]) = do + arr <- case jsonOrig of + Object obj -> case AK.lookup key obj of + Just (Array vec) -> pure $ V.toList vec + _ -> throwError $ AK.toString key <> " does not point to an object" + _ -> throwError $ "not an object" + let fltr = arrIndexToFilter ix arr + attr = topLevelAttrPath (AK.toText key) + pure $ Just (ValuePath attr (Just fltr)) + mapPath (AD.Pointer [AD.OKey key, AD.AKey ix, AD.OKey subKey]) = do + _ + mapPath bad = do + throwError $ "jsonPatchToScimPatch: illegal or unsupported attribute path: " <> show bad + +{- + +{emails: [{val: me@me.com, typ: work}, {val: you@you.com, typ: work}]} + +emails[1].val + +emails[val=you@you.com] + +-} + +_ + +-- we don't need diff in production! this is really good, because +-- diff works very differently between scim and json. so we just need +-- to rip out everything related to diff here, and either copy it to +-- tests and keep on hacking, or copy diff from aeson-diff to the +-- tests and hack that, or just write a few unit tests instead of the +-- property. + +arrIndexToFilter :: Int -> [Value] -> Filter +arrIndexToFilter ix arr = case drop ix arr of + [] -> todo + (val : _) -> valToFilter val + where + valToFilter :: Value -> Filter + valToFilter = \case + Object obj -> objectToFilter obj + other -> + FilterAttrCompare + (AttrPath Nothing "value" Nothing) + OpEq + (valueToCompValue other) + + objectToFilter :: AK.KeyMap Value -> Filter + objectToFilter obj = + case lookupPrimitiveKeyCI "value" obj <|> firstPrimitiveKey obj of + Just (name, compVal) -> + FilterAttrCompare (AttrPath Nothing name Nothing) OpEq compVal + Nothing -> todo + + lookupPrimitiveKeyCI :: Text -> AK.KeyMap Value -> Maybe (AttrName, CompValue) + lookupPrimitiveKeyCI target obj = + let target' = CI.foldCase target + in listToMaybe $ + mapMaybe + ( \(k, v) -> + if CI.foldCase (AK.toText k) == target' + then (AttrName (AK.toText k),) <$> valueToCompValueMaybe v + else Nothing + ) + (AK.toList obj) + + firstPrimitiveKey :: AK.KeyMap Value -> Maybe (AttrName, CompValue) + firstPrimitiveKey obj = + listToMaybe $ + mapMaybe + (\(k, v) -> (AttrName (AK.toText k),) <$> valueToCompValueMaybe v) + (AK.toList obj) + + valueToCompValue :: Value -> CompValue + valueToCompValue val = + fromMaybe todo (valueToCompValueMaybe val) + + valueToCompValueMaybe :: Value -> Maybe CompValue + valueToCompValueMaybe = \case + String s -> Just (ValString s) + Number n -> Just (ValNumber n) + Bool b -> Just (ValBool b) + Null -> Just ValNull + _ -> Nothing + +emptyPath :: AD.Pointer +emptyPath = + parseEither AD.parsePointer "" + & either (error . ("impossible: " <>) . show) Imports.id + +---------------------------------------------------------------------- + +instance (SupportsSchemas tag) => ToJSON (Patch tag) where + toJSON (Patch ops) = + object $ + [ "schemas" .= [PatchOp20], + "operations" .= ops + ] + +instance (SupportsSchemas tag) => ToJSON (PatchOp tag) where + toJSON op = + object $ + ["op" .= String (patchOpName op)] + <> ["path" .= p | p <- maybeToList $ patchOpPath op] + <> ["value" .= v | v <- maybeToList $ patchOpVal op] + where + patchOpName :: PatchOp tag -> Text + patchOpName = \case + PatchOpAdd _ _ -> "add" + PatchOpRemove _ -> "remove" + PatchOpReplace _ _ -> "replace" + + patchOpPath :: PatchOp tag -> Maybe ValuePath + patchOpPath = \case + PatchOpAdd mbp _ -> mbp + PatchOpRemove p -> Just $ p + PatchOpReplace mbp _ -> mbp + + patchOpVal :: PatchOp tag -> Maybe Value + patchOpVal = \case + PatchOpAdd _ v -> Just v + PatchOpRemove _ -> Nothing + PatchOpReplace _ v -> Just v + +---------------------------------------------------------------------- + +instance (SupportsSchemas tag) => FromJSON (Patch tag) where + parseJSON = prsJsonLower >=> prs + where + prs = withObject "ScimPatch" $ \ciObj -> do + given <- ciObj .: "schemas" + unless (given == Set.singleton PatchOp20) $ do + fail $ "Unsupported schemas! must be " <> show [getSchemaUri PatchOp20] + Patch <$> ciObj .: "operations" + +-- | Lower-case all case-insensitive parts of a scim value. These are: +-- - Attributes schemas, operations, op of the patch itself (https://datatracker.ietf.org/doc/html/rfc7643#section-2.1) +-- - Attribute names in the values to be added / replaced (https://datatracker.ietf.org/doc/html/rfc7643#section-2.1) +-- - Attribute paths with filters (https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2) +-- (example: `filter=emails[type eq "work"] eq "john"` vs. `filter=EMAILS[TYPE EQ "WORK"] EQ "john"`) +lowerAllCaseInsensitiveThingsInPatch :: Value -> Either String Value +lowerAllCaseInsensitiveThingsInPatch = attrNamesInPaths <=< jsonLower + where + attrNamesInPaths = pure -- FUTUREWORK: we don't support this yet, so no need to lower-case it either. + +instance (SupportsSchemas tag) => FromJSON (PatchOp tag) where + parseJSON = (either fail pure . lowerAllCaseInsensitiveThingsInPatch) >=> prs where - optionalField fname = \case - Nothing -> [] - Just x -> [fname .= x] - -instance FromJSON Op where - parseJSON = withText "Op" $ \op' -> - case CI.foldCase op' of - "add" -> pure Add - "replace" -> pure Replace - "remove" -> pure Remove - _ -> fail "unknown operation" - -instance ToJSON Op where - toJSON Add = String "add" - toJSON Replace = String "replace" - toJSON Remove = String "remove" - -instance ToJSON Path where - toJSON = String . rPath - --- | A very coarse description of what it means to be 'Patchable' --- I do not like it. We should handhold people using this library more -class Patchable a where - applyOperation :: (MonadError ScimError m) => a -> Operation -> m a - -instance Patchable (KeyMap.KeyMap Text) where - applyOperation theMap (Operation Remove (Just (NormalPath (AttrPath _schema (AttrName attrName) _subAttr))) _) = - pure $ KeyMap.delete (Key.fromText attrName) theMap - applyOperation theMap (Operation _AddOrReplace (Just (NormalPath (AttrPath _schema (AttrName attrName) _subAttr))) (Just (String val))) = - pure $ KeyMap.insert (Key.fromText attrName) val theMap - applyOperation _ _ = throwError $ badRequest InvalidValue $ Just "Unsupported operation" + prs = withObject "ScimPatchOp" $ \o -> do + o .: "op" >>= \case + "add" -> do + path <- o .:? "path" + val <- o .: "value" + pure $ PatchOpAdd path val + "remove" -> do + path <- o .: "path" + pure $ PatchOpRemove path + "replace" -> do + path <- o .:? "path" + val <- o .: "value" + pure $ PatchOpReplace path val + unknownOp -> fail $ "Unknown operation: " ++ T.unpack unknownOp + +---------------------------------------------------------------------- + +-- Translate Patch into AD.Patch from the aeson-diff package and apply +-- the diff. Validate input value and output value against supported +-- schemas, but validating the patch itself is redundant. +applyPatch :: + forall m tag a. + ( SupportsSchemas tag, + FromJSON a, + ToJSON a, + MonadError ScimError m + ) => + Patch tag -> + a -> + m a +applyPatch scimPatch (toJSON -> jsonOrig) = do + let result err = \case + Success val -> pure val + Error txt -> throwError . badRequest InvalidValue . Just . err $ Text.pack txt + + jsonPatch <- scimPatchToJsonPatch scimPatch jsonOrig + + jsonPatched <- + AD.patch jsonPatch jsonOrig + & result ("could not apply patch: " <>) + + validateSchemas @tag Proxy jsonOrig + & either (throwError . badRequest InvalidSyntax . Just . ("Validation of input value failed: " <>) . Text.pack) pure + validateSchemas @tag Proxy jsonPatched + & either (throwError . badRequest InvalidSyntax . Just . ("Validation of output value failed: " <>) . Text.pack) pure + + fromJSON jsonPatched + & result ("invalid patch result: " <>) diff --git a/libs/hscim/src/Web/Scim/Schema/Schema.hs b/libs/hscim/src/Web/Scim/Schema/Schema.hs index d7ae67016c..504fd455a2 100644 --- a/libs/hscim/src/Web/Scim/Schema/Schema.hs +++ b/libs/hscim/src/Web/Scim/Schema/Schema.hs @@ -17,11 +17,15 @@ module Web.Scim.Schema.Schema where +import Control.Monad.Error.Class import Data.Aeson (FromJSON, ToJSON, Value, parseJSON, toJSON, withText) import Data.Attoparsec.ByteString (Parser) import qualified Data.Attoparsec.ByteString.Char8 as Parser +import Data.Data (Proxy) +import Data.Set (Set) import Data.Text (Text) import Data.Text.Encoding (decodeUtf8, encodeUtf8) +import Imports import Web.Scim.Capabilities.MetaSchema.Group import Web.Scim.Capabilities.MetaSchema.ResourceType import Web.Scim.Capabilities.MetaSchema.SPConfig @@ -39,7 +43,7 @@ data Schema | Error20 | PatchOp20 | CustomSchema Text - deriving (Show, Eq) + deriving (Show, Eq, Ord) -- | 'Schema' is *almost* a straight-forward enum type, except for 'CustomSchema'. -- Enumerations are nice because they let you write quickcheck generators as @elements @@ -95,9 +99,9 @@ getSchemaUri (CustomSchema x) = -- NOTE: according to the spec, this parser needs to be case insensitive, but -- that is literally insane. Won't implement. pSchema :: [Schema] -> Parser Schema -pSchema supportedSchemas = +pSchema supported = Parser.choice $ - map (\s -> fromSchemaUri . decodeUtf8 <$> Parser.string (encodeUtf8 $ getSchemaUri s)) supportedSchemas + map (\s -> fromSchemaUri . decodeUtf8 <$> Parser.string (encodeUtf8 $ getSchemaUri s)) supported -- | Get a schema by its URI. -- @@ -152,3 +156,13 @@ getSchema PatchOp20 = -- FUTUREWORK: allow supplying schemas for 'CustomSchema'. getSchema (CustomSchema _) = Nothing + +class SupportsSchemas a where + -- | Schemas supported by the the tagged type. API clients touching + -- fields not contained in the listed schemas triggers error 4xx. + supportedSchemas :: Proxy a -> Set Schema + +-- use https://github.com/ocramz/aeson-schema to validate the listed +-- schemas. +validateSchemas :: forall tag m. (SupportsSchemas tag, MonadError String m) => Proxy tag -> Value -> m () +validateSchemas = todo diff --git a/libs/hscim/src/Web/Scim/Schema/User.hs b/libs/hscim/src/Web/Scim/Schema/User.hs index 1a37f6dae6..e35f6037a6 100644 --- a/libs/hscim/src/Web/Scim/Schema/User.hs +++ b/libs/hscim/src/Web/Scim/Schema/User.hs @@ -64,7 +64,6 @@ module Web.Scim.Schema.User ( User (..), empty, NoUserExtra (..), - applyPatch, resultToScimError, isUserSchema, module Web.Scim.Schema.UserTypes, @@ -179,35 +178,39 @@ empty schemas userName extra = } instance (FromJSON (UserExtra tag)) => FromJSON (User tag) where - parseJSON = withObject "User" $ \obj -> do - -- Lowercase all fields - let o = KeyMap.fromList . map (over _1 lowerKey) . KeyMap.toList $ obj - schemas <- - o .:? "schemas" <&> \case - Nothing -> [User20] - Just xs -> if User20 `elem` xs then xs else User20 : xs - userName <- o .: "username" - externalId <- o .:? "externalid" - name <- o .:? "name" - displayName <- o .:? "displayname" - nickName <- o .:? "nickname" - profileUrl <- o .:? "profileurl" - title <- o .:? "title" - userType <- o .:? "usertype" - preferredLanguage <- o .:? "preferredlanguage" - locale <- o .:? "locale" - active <- o .:? "active" - password <- o .:? "password" - emails <- o .:? "emails" .!= [] - phoneNumbers <- o .:? "phonenumbers" .!= [] - ims <- o .:? "ims" .!= [] - photos <- o .:? "photos" .!= [] - addresses <- o .:? "addresses" .!= [] - entitlements <- o .:? "entitlements" .!= [] - roles <- o .:? "roles" .!= [] - x509Certificates <- o .:? "x509certificates" .!= [] - extra <- parseJSON (Object obj) - pure User {..} + parseJSON = prsJsonLower >=> prs + where + prs = withObject "User" $ \o -> do + -- Lowercase all fields + schemas <- + -- TODO(fisx): NO! User20 is NOT implicit? + -- https://datatracker.ietf.org/doc/html/rfc7643#section-3 + -- (Also make sure this works as expected in Group!) + o .:? "schemas" <&> \case + Nothing -> [User20] + Just xs -> if User20 `elem` xs then xs else User20 : xs + userName <- o .: "username" + externalId <- o .:? "externalid" + name <- o .:? "name" + displayName <- o .:? "displayname" + nickName <- o .:? "nickname" + profileUrl <- o .:? "profileurl" + title <- o .:? "title" + userType <- o .:? "usertype" + preferredLanguage <- o .:? "preferredlanguage" + locale <- o .:? "locale" + active <- o .:? "active" + password <- o .:? "password" + emails <- o .:? "emails" .!= [] + phoneNumbers <- o .:? "phonenumbers" .!= [] + ims <- o .:? "ims" .!= [] + photos <- o .:? "photos" .!= [] + addresses <- o .:? "addresses" .!= [] + entitlements <- o .:? "entitlements" .!= [] + roles <- o .:? "roles" .!= [] + x509Certificates <- o .:? "x509certificates" .!= [] + extra <- parseJSON (Object o) + pure User {..} instance (ToJSON (UserExtra tag)) => ToJSON (User tag) where toJSON User {..} = @@ -262,102 +265,13 @@ instance FromJSON NoUserExtra where instance ToJSON NoUserExtra where toJSON _ = object [] -instance Patchable NoUserExtra where - applyOperation _ _ = throwError $ badRequest InvalidValue (Just "there are no user extra attributes to patch") - ---------------------------------------------------------------------------- -- Applying --- | Applies a JSON Patch to a SCIM Core User --- Only supports the core attributes. --- Evenmore, only some hand-picked ones currently. --- We'll have to think how patch is going to work in the presence of extensions. --- Also, we can probably make PatchOp type-safe to some extent (Read arianvp's thesis :)) -applyPatch :: - ( Patchable (UserExtra tag), - FromJSON (UserExtra tag), - MonadError ScimError m, - UserTypes tag - ) => - User tag -> - PatchOp tag -> - m (User tag) -applyPatch = (. getOperations) . foldM applyOperation - resultToScimError :: (MonadError ScimError m) => Result a -> m a resultToScimError (Error reason) = throwError $ badRequest InvalidValue (Just (pack reason)) resultToScimError (Success a) = pure a --- TODO(arianvp): support multi-valued and complex attributes. --- TODO(arianvp): Actually do this in some kind of type-safe way. e.g. --- have a UserPatch type. --- --- What I understand from the spec: The difference between add an replace is only --- in the fact that replace will not concat multi-values, and behaves differently for complex values too. --- For simple attributes, add and replace are identical. -applyUserOperation :: - forall m tag. - ( UserTypes tag, - FromJSON (User tag), - Patchable (UserExtra tag), - MonadError ScimError m - ) => - User tag -> - Operation -> - m (User tag) -applyUserOperation user (Operation Add path value) = applyUserOperation user (Operation Replace path value) -applyUserOperation user (Operation Replace (Just (NormalPath (AttrPath _schema attr _subAttr))) (Just value)) = - case attr of - "username" -> - (\x -> user {userName = x}) <$> resultToScimError (fromJSON value) - "displayname" -> - (\x -> user {displayName = x}) <$> resultToScimError (fromJSON value) - "externalid" -> - (\x -> user {externalId = x}) <$> resultToScimError (fromJSON value) - "active" -> - (\x -> user {active = x}) <$> resultToScimError (fromJSON value) - "roles" -> - (\x -> user {roles = x}) <$> resultToScimError (fromJSON value) - _ -> throwError (badRequest InvalidPath (Just "we only support attributes username, displayname, externalid, active, roles")) -applyUserOperation _ (Operation Replace (Just (IntoValuePath _ _)) _) = do - throwError (badRequest InvalidPath (Just "can not lens into multi-valued attributes yet")) -applyUserOperation user (Operation Replace Nothing (Just value)) = do - case value of - Object hm | null ((AttrName . Key.toText <$> KeyMap.keys hm) \\ ["username", "displayname", "externalid", "active", "roles"]) -> do - (u :: User tag) <- resultToScimError $ fromJSON value - pure $ - user - { userName = userName u, - displayName = displayName u, - externalId = externalId u, - active = active u - } - _ -> throwError (badRequest InvalidPath (Just "we only support attributes username, displayname, externalid, active, roles")) -applyUserOperation _ (Operation Replace _ Nothing) = - throwError (badRequest InvalidValue (Just "No value was provided")) -applyUserOperation _ (Operation Remove Nothing _) = throwError (badRequest NoTarget Nothing) -applyUserOperation user (Operation Remove (Just (NormalPath (AttrPath _schema attr _subAttr))) _value) = - case attr of - "username" -> throwError (badRequest Mutability Nothing) - "displayname" -> pure $ user {displayName = Nothing} - "externalid" -> pure $ user {externalId = Nothing} - "active" -> pure $ user {active = Nothing} - "roles" -> pure $ user {roles = []} - _ -> pure user -applyUserOperation _ (Operation Remove (Just (IntoValuePath _ _)) _) = do - throwError (badRequest InvalidPath (Just "can not lens into multi-valued attributes yet")) - -instance (UserTypes tag, FromJSON (User tag), Patchable (UserExtra tag)) => Patchable (User tag) where - applyOperation user op@(Operation _ (Just (NormalPath (AttrPath schema _ _))) _) - | isUserSchema schema = applyUserOperation user op - | isSupportedCustomSchema schema = (\x -> user {extra = x}) <$> applyOperation (extra user) op - | otherwise = - throwError $ badRequest InvalidPath $ Just $ "we only support these schemas: " <> Text.intercalate ", " (map getSchemaUri (supportedSchemas @tag)) - where - isSupportedCustomSchema = maybe False (`elem` supportedSchemas @tag) - applyOperation user op = applyUserOperation user op - --- Omission of a schema for users is implicitly the core schema --- TODO(arianvp): Link to part of the spec that claims this. +-- TODO(fisx): BUG: schema field is always required! https://datatracker.ietf.org/doc/html/rfc7643#section-6 isUserSchema :: Maybe Schema -> Bool isUserSchema = maybe True (== User20) diff --git a/libs/hscim/src/Web/Scim/Schema/UserTypes.hs b/libs/hscim/src/Web/Scim/Schema/UserTypes.hs index 904955cc7e..46b698037a 100644 --- a/libs/hscim/src/Web/Scim/Schema/UserTypes.hs +++ b/libs/hscim/src/Web/Scim/Schema/UserTypes.hs @@ -19,17 +19,12 @@ module Web.Scim.Schema.UserTypes where -import Web.Scim.Schema.Schema (Schema) +import Web.Scim.Schema.Schema -- | Configurable parts of 'User'. -class UserTypes tag where +class (SupportsSchemas tag) => UserTypes tag where -- | User ID type. type UserId tag -- | Extra data carried with each 'User'. type UserExtra tag - - -- | Schemas supported by the 'User' for filtering and patching. - -- - -- This must include User20, this is not checked. - supportedSchemas :: [Schema] diff --git a/libs/hscim/src/Web/Scim/Server/Mock.hs b/libs/hscim/src/Web/Scim/Server/Mock.hs index 3b819c1662..a0785498ae 100644 --- a/libs/hscim/src/Web/Scim/Server/Mock.hs +++ b/libs/hscim/src/Web/Scim/Server/Mock.hs @@ -35,6 +35,7 @@ import Data.Hashable import Data.Maybe (fromMaybe) import Data.Sequence (Seq) import qualified Data.Sequence as Seq +import qualified Data.Set as Set import Data.Text (Text, pack) import Data.Time.Calendar import Data.Time.Clock @@ -55,7 +56,7 @@ import Web.Scim.Schema.Error import Web.Scim.Schema.ListResponse import Web.Scim.Schema.Meta import Web.Scim.Schema.ResourceType -import Web.Scim.Schema.Schema (Schema (Group20, ListResponse20, User20)) +import Web.Scim.Schema.Schema import Web.Scim.Schema.User hiding (displayName) -- | Tag used in the mock server. @@ -106,7 +107,9 @@ hoistSTM = hoist liftSTM instance UserTypes Mock where type UserId Mock = Id type UserExtra Mock = NoUserExtra - supportedSchemas = [User20] + +instance SupportsSchemas Mock where + supportedSchemas _ = Set.fromList [User20] instance UserDB Mock TestServer where getUsers () mbFilter = do diff --git a/libs/hscim/src/Web/Scim/Test/Util.hs b/libs/hscim/src/Web/Scim/Test/Util.hs index d4ef837eee..2e706a3938 100644 --- a/libs/hscim/src/Web/Scim/Test/Util.hs +++ b/libs/hscim/src/Web/Scim/Test/Util.hs @@ -62,6 +62,7 @@ import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as BS8 import qualified Data.ByteString.Lazy as L import Data.Proxy +import qualified Data.Set as Set import Data.Text hiding (show) import Data.UUID as UUID import Data.UUID.V4 as UUID @@ -76,7 +77,7 @@ import Test.Hspec.Wai hiding (patch, post, put, shouldRespondWith) import Test.Hspec.Wai.Matcher (bodyEquals, match) import Web.Scim.Class.Auth (AuthTypes (..)) import Web.Scim.Class.Group (GroupTypes (..)) -import Web.Scim.Schema.Schema (Schema (CustomSchema, User20)) +import Web.Scim.Schema.Schema import Web.Scim.Schema.User (UserTypes (..)) -- | re-implementation of 'shouldRespondWith' with better error reporting. @@ -254,7 +255,9 @@ data TestTag id authData authInfo userExtra instance UserTypes (TestTag id authData authInfo userExtra) where type UserId (TestTag id authData authInfo userExtra) = id type UserExtra (TestTag id authData authInfo userExtra) = userExtra - supportedSchemas = [User20, CustomSchema "urn:hscim:test"] + +instance SupportsSchemas (TestTag id authData authInfo userExtra) where + supportedSchemas _ = Set.fromList [User20, CustomSchema "urn:hscim:test"] instance GroupTypes (TestTag id authData authInfo userExtra) where type GroupId (TestTag id authData authInfo userExtra) = id diff --git a/libs/hscim/test/Test/Class/UserSpec.hs b/libs/hscim/test/Test/Class/UserSpec.hs index 6a46738dcc..de3f77a37a 100644 --- a/libs/hscim/test/Test/Class/UserSpec.hs +++ b/libs/hscim/test/Test/Class/UserSpec.hs @@ -363,9 +363,9 @@ spec = with app $ do patch "/0" [scim|{ - "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations": [{ "op": "Remove", "path": "displayName"}] - }|] + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [{ "op": "remove", "path": "displayName"}] + }|] `shouldRespondWith` [scim|{ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" diff --git a/libs/hscim/test/Test/FilterSpec.hs b/libs/hscim/test/Test/FilterSpec.hs index 9fc6588e7f..4131b39dda 100644 --- a/libs/hscim/test/Test/FilterSpec.hs +++ b/libs/hscim/test/Test/FilterSpec.hs @@ -1,4 +1,5 @@ {-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE OverloadedStrings #-} -- This file is part of the Wire Server implementation. -- @@ -19,19 +20,27 @@ module Test.FilterSpec where -import Data.Text (Text, cons) +import Data.Aeson +import qualified Data.Attoparsec.ByteString as Atto +import Data.Text (cons) +import Data.Text.Encoding (encodeUtf8) import HaskellWorks.Hspec.Hedgehog import Hedgehog import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range +import Imports import Test.Hspec import Web.Scim.AttrName import Web.Scim.Filter import Web.Scim.Schema.Schema (Schema (..)) import Web.Scim.Schema.User (NoUserExtra) -import Web.Scim.Schema.UserTypes (UserTypes (supportedSchemas)) +import Web.Scim.Schema.UserTypes import Web.Scim.Test.Util (TestTag) +spec :: Spec +spec = pure () + +{- prop_roundtrip :: forall tag. (UserTypes tag) => Property prop_roundtrip = property $ do x <- forAll $ genFilter @tag @@ -39,9 +48,268 @@ prop_roundtrip = property $ do spec :: Spec spec = do + describe "AttrPath" $ do + describe "golden" $ do + let examples :: [(String, Either String AttrPath)] + examples = + [ ( "members", + Right $ AttrPath Nothing (AttrName "members") Nothing + ), + ( "name.familyname", + Right $ AttrPath Nothing (AttrName "name") (Just (SubAttr (AttrName "familyname"))) + ), + ( "", + Left "Error in $: letter_ascii: not enough input" -- FUTUREWORK: better error + ), + ( ".members", + Left "Error in $: letter_ascii: Failed reading: satisfyWith" -- FUTUREWORK: better error + ), + ( "urn:ietf:params:scim:schemas:core:2.0:Group:members", + -- FUTUREWORK: this must be `Right $ AttrPath (Just + -- Group20) (AttrName "members") Nothing` (or, more + -- likely, be rejected due to schema not being passed + -- to parser) + Right $ AttrPath Nothing (AttrName "urn") Nothing + ), + ( "urn:ietf:params:scim:schemas:core:2.0:Group:nosuchfield", + -- FUTUREWORK: this must be `Left "..."` + Right $ AttrPath Nothing (AttrName "urn") Nothing + ) + ] + + runGolden :: forall a. (HasCallStack, Eq a, Show a, FromJSON a) => (String, Either String a) -> Spec + runGolden (ex, want) = it ("attribute path: " <> show ex) $ eitherDecode @a (encode ex) `shouldBe` want + + for_ examples runGolden + + describe "Rendering" $ do + it "renders simple attribute without schema" $ do + rAttrPath (AttrPath Nothing "userName" Nothing) `shouldBe` "userName" + + it "renders attribute with subAttr" $ do + rAttrPath (AttrPath Nothing "name" (Just (SubAttr "familyName"))) `shouldBe` "name.familyName" + + it "renders fully qualified attribute with schema" $ do + rAttrPath (AttrPath (Just User20) "userName" Nothing) + `shouldBe` "urn:ietf:params:scim:schemas:core:2.0:User:userName" + + it "renders fully qualified attribute with schema and subAttr" $ do + rAttrPath (AttrPath (Just User20) "name" (Just (SubAttr "familyName"))) + `shouldBe` "urn:ietf:params:scim:schemas:core:2.0:User:name.familyName" + + describe "Multiple schemas" $ do + it "can parse filter with Group20 schema when supported" $ do + parseFilter [User20, Group20] "urn:ietf:params:scim:schemas:core:2.0:Group:displayName eq \"Admins\"" + `shouldBe` Right (FilterAttrCompare (AttrPath (Just Group20) "displayName" Nothing) OpEq (ValString "Admins")) + + it "fails to parse unsupported schema" $ do + parseFilter [User20] "urn:ietf:params:scim:schemas:core:2.0:Group:displayName eq \"Admins\"" + `shouldSatisfy` isLeft + + describe "ValuePath" $ do + describe "golden" $ do + it "renders ValuePath correctly" $ do + let valuePath = + ValuePath + (AttrPath Nothing "addresses" Nothing) + (FilterAttrCompare (AttrPath Nothing "type" Nothing) OpEq (ValString "work")) + rValuePath valuePath `shouldBe` "addresses[type eq \"work\"]" + + it "renders ValuePath with schema prefix" $ do + let valuePath = + ValuePath + (AttrPath (Just User20) "emails" Nothing) + (FilterAttrCompare (AttrPath Nothing "primary" Nothing) OpEq (ValBool True)) + rValuePath valuePath `shouldBe` "urn:ietf:params:scim:schemas:core:2.0:User:emails[primary eq true]" + + it "renders ValuePath with subAttr filter correctly" $ do + let valuePath = + ValuePath + (AttrPath Nothing "members" Nothing) + (FilterAttrCompare (AttrPath Nothing "value" Nothing) OpEq (ValString "user123")) + rValuePath valuePath `shouldBe` "members[value eq \"user123\"]" + + -- FUTUREWORK + let examples :: [(Text, Either String ValuePath)] + examples = + [ ( "addresses[type eq \"work\"]", + Right $ + ValuePath + (AttrPath Nothing (AttrName "addresses") Nothing) + (mkFilter "type" OpEq "work") + ), + ( "members[value eq \"2819c223-7f76-453a-919d-413861904646\"]", + Right $ + ValuePath + (AttrPath Nothing (AttrName "members") Nothing) + (mkFilter "value" OpEq "2819c223-7f76-453a-919d-413861904646") + ) + {- FUTUREWORK: these tests fail (not implemented) + + ( "members[type eq \"work\"].displayname", + Right $ + ValuePath + (AttrPath Nothing (AttrName "members") (Just (SubAttr (AttrName "displayname")))) + (mkFilter "type" OpEq "work") + ), + ( "members[type le \"work\" and value eq \"\"]", + Right $ + ValuePath + (AttrPath Nothing (AttrName "members") Nothing) + (mkFilter "type" OpLe "work") + ) + -} + ] + + mkFilter :: Text -> CompareOp -> Text -> Filter + mkFilter field co val = FilterAttrCompare (AttrPath Nothing (AttrName field) Nothing) co (ValString val) + + for_ examples $ \(str, want) -> + it ("value path: " <> show str) $ + Atto.parseOnly (pValuePath [User20]) (encodeUtf8 str) `shouldBe` want + describe "Filter" $ do it "parse . render === id" $ require $ prop_roundtrip @(TestTag Text () () NoUserExtra) + describe "golden" $ do + it "1" $ do + parseFilter [] "" + `shouldBe` Left "letter_ascii: not enough input" -- FUTUREWORK: better error + parseFilter [User20] "" + `shouldBe` Left "letter_ascii: not enough input" -- FUTUREWORK: better error + it "2" $ do + parseFilter [] "nosuchfield co \"yessuchfield\"" + `shouldBe` Right (FilterAttrCompare (AttrPath Nothing "nosuchfield" Nothing) OpCo (ValString "yessuchfield")) + it "3" $ do + parseFilter [] ".nosuchfield eq " + `shouldBe` Left "letter_ascii: Failed reading: satisfyWith" -- FUTUREWORK: better error + it "4" $ do + parseFilter [] "attr.subAttr eq \"stuff\"" + `shouldBe` Right + ( FilterAttrCompare + (AttrPath Nothing (AttrName "attr") (Just (SubAttr (AttrName "subAttr")))) + OpEq + (ValString "stuff") + ) + + describe "Comparison operators and CompValue types" $ do + let filterExamples :: [(Text, Either Text Filter)] + filterExamples = + [ -- OpEq tests + ( "userName eq \"john\"", + Right $ FilterAttrCompare (AttrPath Nothing "userName" Nothing) OpEq (ValString "john") + ), + ( "age eq 42", + Right $ FilterAttrCompare (AttrPath Nothing "age" Nothing) OpEq (ValNumber 42) + ), + ( "active eq true", + Right $ FilterAttrCompare (AttrPath Nothing "active" Nothing) OpEq (ValBool True) + ), + ( "active eq false", + Right $ FilterAttrCompare (AttrPath Nothing "active" Nothing) OpEq (ValBool False) + ), + ( "manager eq null", + Right $ FilterAttrCompare (AttrPath Nothing "manager" Nothing) OpEq ValNull + ), + -- OpNe tests + ( "userName ne \"john\"", + Right $ FilterAttrCompare (AttrPath Nothing "userName" Nothing) OpNe (ValString "john") + ), + -- OpCo (contains) test + ( "userName co \"john\"", + Right $ FilterAttrCompare (AttrPath Nothing "userName" Nothing) OpCo (ValString "john") + ), + -- OpSw (starts with) test + ( "userName sw \"john\"", + Right $ FilterAttrCompare (AttrPath Nothing "userName" Nothing) OpSw (ValString "john") + ), + -- OpEw (ends with) test + ( "userName ew \"john\"", + Right $ FilterAttrCompare (AttrPath Nothing "userName" Nothing) OpEw (ValString "john") + ), + -- OpGt test + ( "age gt 18", + Right $ FilterAttrCompare (AttrPath Nothing "age" Nothing) OpGt (ValNumber 18) + ), + -- OpGe test + ( "age ge 18", + Right $ FilterAttrCompare (AttrPath Nothing "age" Nothing) OpGe (ValNumber 18) + ), + -- OpLt test + ( "age lt 65", + Right $ FilterAttrCompare (AttrPath Nothing "age" Nothing) OpLt (ValNumber 65) + ), + -- OpLe test + ( "age le 65", + Right $ FilterAttrCompare (AttrPath Nothing "age" Nothing) OpLe (ValNumber 65) + ), + -- Decimal number + ( "score eq 3.14", + Right $ FilterAttrCompare (AttrPath Nothing "score" Nothing) OpEq (ValNumber 3.14) + ), + -- Error cases + ( "userName eq", + Left "space: not enough input" -- FUTUREWORK: better error + ), + ( "userName \"john\"", + Left "Failed reading: empty" -- FUTUREWORK: better error + ), + ( "", + Left "letter_ascii: not enough input" -- FUTUREWORK: better error + ), + ( " ", + Left "letter_ascii: not enough input" -- FUTUREWORK: better error + ) + ] + + for_ filterExamples $ \(filterStr, want) -> + it ("filter: " <> show filterStr) $ + parseFilter [User20] filterStr `shouldBe` want + + describe "AttrPath inside Filter" $ do + describe "Parsing and rendering" $ + do + let attrPathExamples :: [(Text, Either Text Filter)] + attrPathExamples = + [ -- Simple attribute without schema + ( "userName eq \"john\"", + Right $ FilterAttrCompare (AttrPath Nothing "userName" Nothing) OpEq (ValString "john") + ), + -- Attribute with subAttr + ( "name.familyName eq \"Doe\"", + Right $ FilterAttrCompare (AttrPath Nothing "name" (Just (SubAttr "familyName"))) OpEq (ValString "Doe") + ), + -- Fully qualified with User20 schema + ( "urn:ietf:params:scim:schemas:core:2.0:User:userName eq \"john\"", + Right $ FilterAttrCompare (AttrPath (Just User20) "userName" Nothing) OpEq (ValString "john") + ), + -- Fully qualified with schema and subAttr + ( "urn:ietf:params:scim:schemas:core:2.0:User:name.familyName eq \"Doe\"", + Right $ FilterAttrCompare (AttrPath (Just User20) "name" (Just (SubAttr "familyName"))) OpEq (ValString "Doe") + ), + {- FUTUREWORK: this fails + + -- Custom schema + ( "urn:hscim:test:customAttr eq \"value\"", + Right $ FilterAttrCompare (AttrPath (Just (CustomSchema "urn:hscim:test")) "customAttr" Nothing) OpEq (ValString "value") + ), + -} + -- Error case - unsupported schema (Group20 not in supported schemas for User20-only list) + ( "urn:ietf:params:scim:schemas:core:2.0:Group:displayName eq \"Admins\"", + Left "space: Failed reading: satisfyWith" -- FUTUREWORK: better error + ), + ( "", + Left "letter_ascii: not enough input" -- FUTUREWORK: better error + ), + ( ".userName eq \"test\"", + Left "letter_ascii: Failed reading: satisfyWith" -- FUTUREWORK: better error + ) + ] + + for_ attrPathExamples $ \(str, want) -> + it ("filter: " <> show str) $ + parseFilter [User20] str `shouldBe` want + ---------------------------------------------------------------------------- -- Generators @@ -86,3 +354,4 @@ genFilter = Gen.choice [ FilterAttrCompare <$> (genAttrPath @tag) <*> genCompareOp <*> genCompValue ] +-} diff --git a/libs/hscim/test/Test/Schema/PatchOpSpec.hs b/libs/hscim/test/Test/Schema/PatchOpSpec.hs index 2e9a041531..83b4a95c1b 100644 --- a/libs/hscim/test/Test/Schema/PatchOpSpec.hs +++ b/libs/hscim/test/Test/Schema/PatchOpSpec.hs @@ -1,5 +1,9 @@ {-# LANGUAGE AllowAmbiguousTypes #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE RecordWildCards #-} +{-# OPTIONS_GHC -Wno-orphans #-} -- This file is part of the Wire Server implementation. -- @@ -20,123 +24,197 @@ module Test.Schema.PatchOpSpec where -import qualified Data.Aeson as Aeson +import Data.Aeson +import qualified Data.Aeson.Diff as AD import qualified Data.Aeson.KeyMap as KeyMap -import Data.Aeson.Types (Result (Error, Success), Value (String), fromJSON, toJSON) -import qualified Data.Aeson.Types as Aeson -import Data.Attoparsec.ByteString (parseOnly) -import Data.Either (isLeft) -import Data.Foldable (for_) -import Data.Text (Text) -import Data.Text.Encoding (decodeUtf8, encodeUtf8) -import HaskellWorks.Hspec.Hedgehog (require) -import Hedgehog (Gen, Property, forAll, property, tripping) -import qualified Hedgehog.Gen as Gen -import qualified Hedgehog.Range as Range -import Test.FilterSpec (genAttrPath, genSubAttr, genValuePath) -import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy, xit) -import Test.Schema.Util (mk_prop_caseInsensitive) -import Web.Scim.AttrName (AttrName (..)) -import Web.Scim.Filter (AttrPath (..), CompValue (ValNull), CompareOp (OpEq), Filter (..), ValuePath (..)) +import Data.Aeson.QQ (aesonQQ) +import qualified Data.ByteString as BS +import qualified Data.CaseInsensitive as CI +import Data.Scientific (Scientific, scientific) +import qualified Data.Text as T +import Imports +import Test.Hspec +import Test.Hspec.QuickCheck +import Test.QuickCheck +import Text.Email.Parser +import Web.Scim.AttrName +import Web.Scim.Filter +import Web.Scim.Schema.Common import Web.Scim.Schema.PatchOp -import Web.Scim.Schema.Schema (Schema (User20)) -import Web.Scim.Schema.User (UserTypes) -import Web.Scim.Schema.UserTypes (supportedSchemas) -import Web.Scim.Test.Util (TestTag, scim) +import Web.Scim.Schema.Schema +import Web.Scim.Schema.User +import Web.Scim.Schema.User.Email +import Web.Scim.Test.Util -isSuccess :: Result a -> Bool -isSuccess (Success _) = True -isSuccess (Error _) = False +type PatchTag = TestTag Text () () UserExtraPatch -genPatchOp :: forall tag. (UserTypes tag) => Gen Value -> Gen (PatchOp tag) -genPatchOp genValue = PatchOp <$> Gen.list (Range.constant 0 20) ((genOperation @tag) genValue) +type UserExtraPatch = KeyMap.KeyMap Text -genSimplePatchOp :: forall tag. (UserTypes tag) => Gen (PatchOp tag) -genSimplePatchOp = genPatchOp @tag (String <$> Gen.text (Range.constant 0 20) Gen.unicode) +spec :: Spec +spec = do + describe "Patch" $ do + it "golden" $ do + let check :: (HasCallStack) => (Patch PatchTag, Value) -> Expectation + check (hs, js) = Right (toJSON hs) `shouldBe` lowerAllCaseInsensitiveThingsInPatch js + + check + `mapM_` [ ( Patch + [ PatchOpAdd + (Just (ValuePath (topLevelAttrPath "userName") Nothing)) + (String "testuser") + ], + [aesonQQ| + { "schemaS": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operATIONS": [ + { "oP": "add", + "pATh": "userName", + "vaLUE": "testuser" + } + ] + } + |] + ), + ( Patch [PatchOpReplace Nothing (String "this won't work in applyPatch")], + [aesonQQ| + { "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "operations": [ + { "oP": "replace", + "vaLUE": "this won't work in applyPatch" + } + ] + } + |] + ), + ( Patch + [ PatchOpRemove + (ValuePath (AttrPath (Just User20) (AttrName "userName") Nothing) Nothing) + ], + [aesonQQ| + { "Schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [ + { "op": "remove", + "path": "urn:ietf:params:scim:schemas:core:2.0:User:userName" + } + ] + } + |] + ) + ] + + describe "applyPatch" $ do + focus . prop "arrFilterToIndices/arrIndexToFilter roundtrip on singleton match" $ + forAll genArrFilterCase $ \(arr, fltr, ix) -> + let indices = arrFilterToIndices fltr arr + fltr' = arrIndexToFilter ix arr + in indices === [ix] + .&&. arrFilterToIndices fltr' arr === indices + + focus . prop "roundtrip: jsonPatchToScimPatch and back" $ + \(oldBarbie :: User PatchTag) (newBarbie :: User PatchTag) -> + let patchOp :: Patch PatchTag + patchOp = + jsonPatchToScimPatch (AD.diff (toJSON oldBarbie) (toJSON newBarbie)) (toJSON oldBarbie) + & either (error . show) Imports.id + + go = do + j <- scimPatchToJsonPatch patchOp (toJSON oldBarbie) + jsonPatchToScimPatch j (toJSON oldBarbie) + in go === Right patchOp + + prop "roundtrip (generate two users/groups, diff them, apply the patch, compare)" $ + \(barbie :: User PatchTag) changedWant -> + let patchOp :: Patch PatchTag + patchOp = + jsonPatchToScimPatch (AD.diff (toJSON barbie) (toJSON changedWant)) (toJSON barbie) + & either (error . show) Imports.id + in applyPatch patchOp barbie === Right changedWant + + it "throws expected error when patched object doesn't parse" $ do + () <- todo + True `shouldBe` False -genOperation :: forall tag. (UserTypes tag) => Gen Value -> Gen Operation -genOperation genValue = Operation <$> Gen.enumBounded <*> Gen.maybe (genPath @tag) <*> Gen.maybe genValue + it "discards all paths that don't match the user/group schema" $ do + _ <- todo + True `shouldBe` False -genPath :: forall tag. (UserTypes tag) => Gen Path -genPath = - Gen.choice - [ IntoValuePath <$> (genValuePath @tag) <*> Gen.maybe genSubAttr, - NormalPath <$> (genAttrPath @tag) + it "Throws error when trying to update immutable / readOnly values" $ do + -- https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2 + _ <- todo + True `shouldBe` False + +---------------------------------------------------------------------- +-- Arbitrary -- TODO: move to Web.Scim.Test.Something + +instance Arbitrary (User PatchTag) where + arbitrary = do + userName <- T.pack <$> listOf1 arbitrary + externalId <- oneof [pure Nothing, Just . T.pack <$> listOf1 arbitrary] + displayName <- oneof [pure Nothing, Just . T.pack <$> listOf1 arbitrary] + active <- ScimBool <$$> arbitrary + emails <- listOf arbitrary + roles <- T.pack <$$> listOf1 arbitrary + pure (empty @PatchTag [User20] userName mempty) {externalId, displayName, active, emails, roles} + +instance Arbitrary Email where + arbitrary = do + typ <- elements (Nothing : (Just <$> ["work", "mobile", "yellow"])) + value <- EmailAddress . (`unsafeEmailAddress` "example.com") . BS.pack <$> listOf1 arbitrary + primary <- ScimBool <$$> arbitrary + pure Email {..} + +genArrFilterCase :: Gen ([Value], Filter, Int) +genArrFilterCase = do + compVal <- genCompValue + let fltr = FilterAttrCompare (AttrPath Nothing "value" Nothing) OpEq compVal + useObject <- arbitrary + keyVariant <- elements ["value", "VALUE", "Value"] + let matchingValue = + if useObject + then Object (KeyMap.singleton keyVariant (compValueToValue compVal)) + else compValueToValue compVal + prefix <- listOf (genNonMatchingValue compVal) + suffix <- listOf (genNonMatchingValue compVal) + let ix = length prefix + pure (prefix <> [matchingValue] <> suffix, fltr, ix) + +genCompValue :: Gen CompValue +genCompValue = + oneof + [ ValString <$> genText, + ValNumber <$> genScientific, + ValBool <$> arbitrary, + pure ValNull ] -prop_roundtrip :: forall tag. (UserTypes tag) => Property -prop_roundtrip = property $ do - x <- forAll $ genPath @tag - tripping x (encodeUtf8 . rPath) (parseOnly $ pPath (supportedSchemas @tag)) +genNonMatchingValue :: CompValue -> Gen Value +genNonMatchingValue compVal = oneof [genPrimitive, genObject] + where + genPrimitive = compValueToValue <$> genDifferentCompValue compVal + genObject = do + keyVariant <- elements ["value", "VALUE", "Value"] + val <- compValueToValue <$> genDifferentCompValue compVal + pure (Object (KeyMap.singleton keyVariant val)) -prop_roundtrip_PatchOp :: forall tag. (UserTypes tag) => Property -prop_roundtrip_PatchOp = property $ do - -- Just some strings for now. However, should be constrained to what the - -- PatchOp is operating on in the future... We need better typed PatchOp for - -- this. TODO(arianvp) - x <- forAll (genSimplePatchOp @tag) - tripping x toJSON fromJSON +genDifferentCompValue :: CompValue -> Gen CompValue +genDifferentCompValue compVal = case compVal of + ValString s -> + ValString <$> suchThat genText (\t -> CI.foldCase t /= CI.foldCase s) + ValNumber n -> ValNumber <$> suchThat genScientific (/= n) + ValBool b -> pure (ValBool (not b)) + ValNull -> oneof [ValBool <$> arbitrary, ValNumber <$> genScientific, ValString <$> genText] -type PatchTestTag = TestTag () () () () +compValueToValue :: CompValue -> Value +compValueToValue = \case + ValNull -> Null + ValBool b -> Bool b + ValNumber n -> Number n + ValString s -> String s -spec :: Spec -spec = do - describe "Patchable" $ - describe "HashMap Text Text" $ do - it "supports `Add` operation" $ do - let theMap = KeyMap.empty @Text - operation = Operation Add (Just $ NormalPath (AttrPath Nothing (AttrName "key") Nothing)) $ Just "value" - applyOperation theMap operation `shouldBe` Right (KeyMap.singleton "key" "value") - it "supports `Replace` operation" $ do - let theMap = KeyMap.singleton @Text "key" "value1" - operation = Operation Replace (Just $ NormalPath (AttrPath Nothing (AttrName "key") Nothing)) $ Just "value2" - applyOperation theMap operation `shouldBe` Right (KeyMap.singleton "key" "value2") - it "supports `Delete` operation" $ do - let theMap = KeyMap.fromList @Text [("key1", "value1"), ("key2", "value2")] - operation = Operation Remove (Just $ NormalPath (AttrPath Nothing (AttrName "key1") Nothing)) Nothing - applyOperation theMap operation `shouldBe` Right (KeyMap.singleton "key2" "value2") - it "gracefully rejects invalid/unsupported operations" $ do - let theMap = KeyMap.fromList @Text [("key1", "value1"), ("key2", "value2")] - key1Path = AttrPath Nothing (AttrName "key1") Nothing - key2Path = AttrPath Nothing (AttrName "key2") Nothing - invalidOperations = - [ Operation Add (Just $ NormalPath key1Path) Nothing, -- Nothing to add - Operation Replace (Just $ NormalPath key1Path) Nothing, -- Nothing to replace - Operation Add (Just $ IntoValuePath (ValuePath key1Path (FilterAttrCompare key2Path OpEq ValNull)) Nothing) Nothing - -- IntoValuePaths don't make sense for HashMap Text Text - ] - mapM_ (\o -> applyOperation theMap o `shouldSatisfy` isLeft) invalidOperations - describe "urn:ietf:params:scim:api:messages:2.0:PatchOp" $ do - describe "The body of each request MUST contain the \"schemas\" attribute with the URI value of \"urn:ietf:params:scim:api:messages:2.0:PatchOp\"." $ - it "rejects an empty schemas list" $ do - fromJSON @(PatchOp PatchTestTag) - [scim| { - "schemas": [], - "operations": [] - }|] - `shouldSatisfy` (not . isSuccess) - -- TODO(arianvp): We don't support arbitrary path names (yet) - it "roundtrips Path" $ require $ prop_roundtrip @PatchTestTag - it "roundtrips PatchOp" $ require $ prop_roundtrip_PatchOp @PatchTestTag - it "case-insensitive" $ require $ mk_prop_caseInsensitive (genSimplePatchOp @PatchTestTag) - it "rejects invalid operations" $ - fromJSON @(PatchOp PatchTestTag) - [scim| { - "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "operations": [{"op":"unknown"}] - }|] - `shouldSatisfy` (not . isSuccess) - -- TODO(arianvp/akshay): Implement if required - xit "rejects unknown paths" $ - Aeson.parse (pathFromJSON [User20]) (Aeson.String "unknown.field") `shouldSatisfy` (not . isSuccess) - it "rejects invalid paths" $ - Aeson.parse (pathFromJSON [User20]) "unknown]field" `shouldSatisfy` (not . isSuccess) - describe "Examples from https://tools.ietf.org/html/rfc7644#section-3.5.2 Figure 8" $ do - let examples = - [ "members", - "name.familyname", - "addresses[type eq \"work\"]", - "members[value eq \"2819c223-7f76-453a-919d-413861904646\"]", - "members[value eq \"2819c223-7f76-453a-919d-413861904646\"].displayname" - ] - for_ examples $ \p -> it ("parses " ++ show p) $ rPath <$> parseOnly (pPath (supportedSchemas @PatchTestTag)) p `shouldBe` Right (decodeUtf8 p) +genText :: Gen Text +genText = T.pack <$> listOf1 (elements (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'])) + +genScientific :: Gen Scientific +genScientific = do + coeff <- arbitrary :: Gen Integer + exp10 <- chooseInt (-6, 6) + pure (scientific coeff exp10) diff --git a/libs/hscim/test/Test/Schema/UserSpec.hs b/libs/hscim/test/Test/Schema/UserSpec.hs index 1885060fac..69eb6f4fde 100644 --- a/libs/hscim/test/Test/Schema/UserSpec.hs +++ b/libs/hscim/test/Test/Schema/UserSpec.hs @@ -27,26 +27,23 @@ where import Data.Aeson import qualified Data.Aeson.KeyMap as KeyMap -import Data.Either (isLeft, isRight) -import Data.Foldable (for_) -import Data.Text (Text) import HaskellWorks.Hspec.Hedgehog (require) import Hedgehog import qualified Hedgehog.Gen as Gen import qualified Hedgehog.Range as Range -import Lens.Micro +import Imports import Network.URI.Static (uri) import Test.Hspec import Test.Schema.Util (genUri, mk_prop_caseInsensitive) import Text.Email.Validate (emailAddress, validate) +import Web.Scim.AttrName import qualified Web.Scim.Class.User as UserClass -import Web.Scim.Filter (AttrPath (..)) -import Web.Scim.Schema.Common (ScimBool (ScimBool), URI (..), WithId (..), lowerKey) +import Web.Scim.Filter +import Web.Scim.Schema.Common (ScimBool (ScimBool), URI (..), WithId (..), prsJsonLower) import qualified Web.Scim.Schema.ListResponse as ListResponse import Web.Scim.Schema.Meta (ETag (Strong, Weak), Meta (..), WithMeta (..)) -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.PatchOp +import Web.Scim.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 @@ -112,10 +109,14 @@ spec = do ("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 + $ \(key :: Text, newValue) -> do + let patchOp :: Patch PatchTag = Patch [operation] + operation = + PatchOpReplace + (Just (ValuePath (AttrPath Nothing (AttrName key) Nothing) Nothing)) + newValue + applyPatch patchOp user `shouldSatisfy` isRight + it "does not support multi-value attributes" $ do let schemas' = [] let extras = KeyMap.empty @@ -138,18 +139,20 @@ spec = do ("entitlements", 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 + $ \(_key :: String, _upd) -> do + let patchOp :: Patch PatchTag = todo -- PatchOp [operation] + -- let operation = todo -- Operation Replace (Just (NormalPath (AttrPath Nothing key Nothing))) (Just upd) + applyPatch patchOp user `shouldSatisfy` isLeft + it "applies patch to `extra`" $ do let schemas' = [] let extras = KeyMap.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 (KeyMap.singleton "programmingLanguage" "haskell") + let Right _programmingLanguagePath = todo -- User.parsePath (User.supportedSchemas @PatchTag) "urn:hscim:test:programmingLanguage" + -- let operation = todo -- Operation Replace (Just programmingLanguagePath) (Just (toJSON @Text "haskell")) + let patchOp :: Patch PatchTag = todo -- PatchOp [operation] + User.extra <$> applyPatch patchOp user `shouldBe` Right (KeyMap.singleton "programmingLanguage" "haskell") + describe "JSON serialization" $ do it "handles all fields" $ do require prop_roundtrip @@ -472,20 +475,20 @@ 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" + parseJSON = prsJsonLower >=> prs where - lowercase = KeyMap.fromList . map (over _1 lowerKey) . KeyMap.toList + prs = withObject "UserExtraObject" $ \o -> do + o .:? "urn:hscim:test" >>= \case + Nothing -> pure UserExtraEmpty + Just o2 -> UserExtraObject <$> o2 .: "test" instance ToJSON UserExtraTest where toJSON UserExtraEmpty = object [] toJSON (UserExtraObject t) = object ["urn:hscim:test" .= object ["test" .= t]] -instance Patchable UserExtraTest where - applyOperation _ _ = undefined +instance SupportsSchemas UserExtraTest where + supportedSchemas _ = undefined -- | A 'User' with extra fields present. extendedUser :: UserExtraTest -> User (TestTag Text () () UserExtraTest)