packages feed

hscim-0.4.0: test/Test/Class/UserSpec.hs

{-# LANGUAGE QuasiQuotes #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2022 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.Class.UserSpec
  ( spec,
  )
where

import Data.ByteString.Lazy (ByteString)
import Network.Wai (Application)
import Servant (Proxy (Proxy))
import Servant.API.Generic
import Test.Hspec
import Test.Hspec.Wai hiding (patch, post, put, shouldRespondWith)
import Web.Scim.Server (UserAPI, mkapp, userServer)
import Web.Scim.Server.Mock
import Web.Scim.Test.Util

app :: IO Application
app = do
  storage <- emptyTestStorage
  let auth = Just "authorized"
  pure $ mkapp @Mock (Proxy @(UserAPI Mock)) (toServant (userServer auth)) (nt storage)

spec :: Spec
spec = with app $ do
  describe "GET & POST /Users" $ do
    it "responds with [] in empty environment" $ do
      get "/" `shouldRespondWith` emptyList
    it "can insert then retrieve stored Users" $ do
      post "/" newBarbara `shouldRespondWith` 201
      post "/" newJim `shouldRespondWith` 201
      get "/" `shouldRespondWith` allUsers
    describe "filtering" $ do
      it "can filter by username" $ do
        post "/" newBarbara `shouldRespondWith` 201
        get "/?filter=userName eq \"bjensen\"" `shouldRespondWith` onlyBarbara
      it "is case-insensitive regarding syntax" $ do
        post "/" newBarbara `shouldRespondWith` 201
        get "/?filter=USERName EQ \"bjensen\"" `shouldRespondWith` onlyBarbara
      it "is case-insensitive regarding usernames" $ do
        post "/" newBarbara `shouldRespondWith` 201
        get "/?filter=userName eq \"BJensen\"" `shouldRespondWith` onlyBarbara
      it "handles malformed filter syntax" $ do
        post "/" newBarbara `shouldRespondWith` 201
        get "/?filter=userName eqq \"bjensen\"" `shouldRespondWith` 400
      -- TODO: would be nice to check the error message as well

      it "handles type errors in comparisons" $ do
        post "/" newBarbara `shouldRespondWith` 201
        get "/?filter=userName eq true" `shouldRespondWith` 400

  describe "GET /Users/:id" $ do
    it "responds with 404 for unknown user" $ do
      get "/9999" `shouldRespondWith` 404
    -- FUTUREWORK: currently it returns 404:
    -- https://github.com/haskell-servant/servant/issues/1155
    xit "responds with 401 for unparseable user ID" $ do
      get "/unparseable" `shouldRespondWith` 401
    it "retrieves stored user" $ do
      post "/" newBarbara `shouldRespondWith` 201
      -- the test implementation stores users with uid [0,1..n-1]
      get "/0" `shouldRespondWith` barbara
  describe "PUT /Users/:id" $ do
    it "overwrites the user" $ do
      post "/" newBarbara `shouldRespondWith` 201
      put "/0" barbUpdate0 `shouldRespondWith` updatedBarb0
    it "does not create new users" $ do
      post "/" newBarbara `shouldRespondWith` 201
      put "/9999" newBarbara `shouldRespondWith` 404
  -- TODO(arianvp): Perhaps we want to make this an acceptance spec.
  describe "PATCH /Users/:id" $ do
    describe "Add" $ do
      -- TODO(arianvp): Implement and test multi-value fields properly
      -- TODO(arianvp): We need to merge multi-value fields, but not supported yet
      -- TODO(arianvp): Add and Replace tests currently identical, because of lack of multi-value
      it "adds all fields if no target" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
              "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
              "Operations": [{
                "op": "Add",
                "value": {
                  "userName": "arian",
                  "displayName": "arian"
                }

              }]
        }|]
          `shouldRespondWith` [scim|
          {
            "schemas": [
              "urn:ietf:params:scim:schemas:core:2.0:User"
            ],
            "userName": "arian",
            "displayName": "arian",
            "id": "0",
            "meta": {
              "resourceType": "User",
              "location": "https://example.com/Users/id",
              "created": "2018-01-01T00:00:00Z",
              "version": "W/\"testVersion\"",
              "lastModified": "2018-01-01T00:00:00Z"
            }
          }
        |]
            { matchStatus = 200
            }
      it "adds fields if they didn't exist yet" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{
            "op": "Add",
            "path": "displayName",
            "value": "arian"
          }]
        }|]
          `shouldRespondWith` [scim|
          {
            "schemas": [
              "urn:ietf:params:scim:schemas:core:2.0:User"
            ],
            "userName": "bjensen",
            "displayName": "arian",
            "id": "0",
            "meta": {
              "resourceType": "User",
              "location": "https://example.com/Users/id",
              "created": "2018-01-01T00:00:00Z",
              "version": "W/\"testVersion\"",
              "lastModified": "2018-01-01T00:00:00Z"
            }
          }
        |]
            { matchStatus = 200
            }
      it "replaces individual simple fields" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{
            "op": "Add",
            "path": "userName",
            "value": "arian"
          }]
        }|]
          `shouldRespondWith` [scim|
          {
            "schemas": [
              "urn:ietf:params:scim:schemas:core:2.0:User"
            ],
            "userName": "arian",
            "displayName": "bjensen2",
            "id": "0",
            "meta": {
              "resourceType": "User",
              "location": "https://example.com/Users/id",
              "created": "2018-01-01T00:00:00Z",
              "version": "W/\"testVersion\"",
              "lastModified": "2018-01-01T00:00:00Z"
            }
          }
        |]
            { matchStatus = 200
            }
      -- TODO(arianvp): I think this is better done with quickcheck test.
      -- Generate some adds, replaces, removes and then an invalid one However,
      -- for this we need to be able to generate valid patches but a patch does
      -- not limit by type what fields it lenses in to. It is a very untyped
      -- thingy currently.
      it "PatchOp is atomic. Either fully applies or not at all" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{
            "op": "Add",
            "path": "userName",
            "value": "arian"
          },
          { "op": "Add",
            "path": "displayName",
            "value": 5
          }]}|]
          `shouldRespondWith` 400
        get "/0" `shouldRespondWith` smallUserGet {matchStatus = 200}
    describe "Replace" $ do
      -- TODO(arianvp): Implement and test multi-value fields properly
      it "adds all fields if no target" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
              "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
              "Operations": [{
                "op": "Replace",
                "value": {
                  "userName": "arian",
                  "displayName": "arian"
                }

              }]
        }|]
          `shouldRespondWith` [scim|
          {
            "schemas": [
              "urn:ietf:params:scim:schemas:core:2.0:User"
            ],
            "userName": "arian",
            "displayName": "arian",
            "id": "0",
            "meta": {
              "resourceType": "User",
              "location": "https://example.com/Users/id",
              "created": "2018-01-01T00:00:00Z",
              "version": "W/\"testVersion\"",
              "lastModified": "2018-01-01T00:00:00Z"
            }
          }
        |]
            { matchStatus = 200
            }
      it "adds fields if they didn't exist yet" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{
            "op": "Replace",
            "path": "displayName",
            "value": "arian"
          }]
        }|]
          `shouldRespondWith` [scim|
          {
            "schemas": [
              "urn:ietf:params:scim:schemas:core:2.0:User"
            ],
            "userName": "bjensen",
            "displayName": "arian",
            "id": "0",
            "meta": {
              "resourceType": "User",
              "location": "https://example.com/Users/id",
              "created": "2018-01-01T00:00:00Z",
              "version": "W/\"testVersion\"",
              "lastModified": "2018-01-01T00:00:00Z"
            }
          }
        |]
            { matchStatus = 200
            }
      it "replaces individual simple fields" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{
            "op": "Replace",
            "path": "userName",
            "value": "arian"
          }]
        }|]
          `shouldRespondWith` [scim|
          {
            "schemas": [
              "urn:ietf:params:scim:schemas:core:2.0:User"
            ],
            "userName": "arian",
            "displayName": "bjensen2",
            "id": "0",
            "meta": {
              "resourceType": "User",
              "location": "https://example.com/Users/id",
              "created": "2018-01-01T00:00:00Z",
              "version": "W/\"testVersion\"",
              "lastModified": "2018-01-01T00:00:00Z"
            }
          }
        |]
            { matchStatus = 200
            }
      it "PatchOp is atomic. Either fully applies or not at all" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{
            "op": "Replace",
            "path": "userName",
            "value": "arian"
          },
          { "op": "Replace",
            "path": "displayName",
            "value": 5
          }]}|]
          `shouldRespondWith` 400
        get "/0" `shouldRespondWith` smallUserGet {matchStatus = 200}
    describe "Remove" $ do
      it "fails if no target" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" barbUpdate0 -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{ "op": "Remove" }]
        }|]
          `shouldRespondWith` [scim|{
          "scimType":"noTarget","status":"400","schemas":["urn:ietf:params:scim:api:messages:2.0:Error"]
        }|]
            { matchStatus = 400
            }
      it "fails if removing immutable" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" barbUpdate0 -- reset
        patch
          "/0"
          [scim|{
          "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
          "Operations": [{ "op": "Remove", "path": "userName"}]
        }|]
          `shouldRespondWith` [scim|{
          "scimType":"mutability","status":"400","schemas":["urn:ietf:params:scim:api:messages:2.0:Error"]
        }|]
            { matchStatus = 400
            }
      it "deletes the specified attribute" $ do
        post "/" newBarbara `shouldRespondWith` 201
        _ <- put "/0" smallUser -- reset
        patch
          "/0"
          [scim|{
          "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"
          ],
          "userName": "bjensen",
          "id": "0",
          "meta": {
            "resourceType": "User",
            "location": "https://example.com/Users/id",
            "created": "2018-01-01T00:00:00Z",
            "version": "W/\"testVersion\"",
            "lastModified": "2018-01-01T00:00:00Z"
          }
        }|]
            { matchStatus = 200
            }
  describe "DELETE /Users/:id" $ do
    it "responds with 404 for unknown user" $ do
      delete "/9999" `shouldRespondWith` 404
    it "deletes a stored user" $ do
      post "/" newBarbara `shouldRespondWith` 201
      delete "/0" `shouldRespondWith` 204
      -- user should be gone
      get "/0" `shouldRespondWith` 404
      delete "/0" `shouldRespondWith` 404

smallUser :: ByteString
smallUser =
  [scim|
        { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
          "userName":"bjensen",
          "displayName": "bjensen2"
        }|]

smallUserGet :: ResponseMatcher
smallUserGet =
  [scim|
{
  "schemas": [
    "urn:ietf:params:scim:schemas:core:2.0:User"
  ],
  "userName": "bjensen",
  "displayName": "bjensen2",
  "id": "0",
  "meta": {
    "resourceType": "User",
    "location": "https://example.com/Users/id",
    "created": "2018-01-01T00:00:00Z",
    "version": "W/\"testVersion\"",
    "lastModified": "2018-01-01T00:00:00Z"
  }
}
|]

newBarbara :: ByteString
newBarbara =
  [scim|
        { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
          "userName":"bjensen",
          "externalId":"bjensen",
          "name":{
            "formatted":"Ms. Barbara J Jensen III",
            "familyName":"Jensen",
            "givenName":"Barbara"
          }
        }|]

newJim :: ByteString
newJim =
  [scim|
        { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
          "userName":"jim",
          "externalId":"jim",
          "name":{
            "formatted":"Jim",
            "familyName":"",
            "givenName":"Jim"
          }
        }|]

barbara :: ResponseMatcher
barbara =
  [scim|
        { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
          "userName":"bjensen",
          "name": {
            "givenName":"Barbara",
            "formatted":"Ms. Barbara J Jensen III",
            "familyName":"Jensen"
          },
          "id":"0",
          "externalId":"bjensen",
          "meta":{
            "resourceType":"User",
            "location":"https://example.com/Users/id",
            "created":"2018-01-01T00:00:00Z",
            "version":"W/\"testVersion\"",
            "lastModified":"2018-01-01T00:00:00Z"
          }
        }|]

allUsers :: ResponseMatcher
allUsers =
  [scim|
        { "schemas":["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
          "totalResults": 2,
          "itemsPerPage": 2,
          "startIndex": 1,
          "Resources":
            [{ "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
               "userName":"bjensen",
               "externalId":"bjensen",
               "id" : "0",
               "name":{
                 "formatted":"Ms. Barbara J Jensen III",
                 "familyName":"Jensen",
                 "givenName":"Barbara"
               },
               "meta":{
                 "resourceType":"User",
                 "location":"https://example.com/Users/id",
                 "created":"2018-01-01T00:00:00Z",
                 "version":"W/\"testVersion\"",
                 "lastModified":"2018-01-01T00:00:00Z"
               }
             },
             { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
               "userName":"jim",
               "externalId":"jim",
               "id" : "1",
               "name":{
                 "formatted":"Jim",
                 "familyName":"",
                 "givenName":"Jim"
               },
               "meta":{
                 "resourceType":"User",
                 "location":"https://example.com/Users/id",
                 "created":"2018-01-01T00:00:00Z",
                 "version":"W/\"testVersion\"",
                 "lastModified":"2018-01-01T00:00:00Z"
               }
             }]
        }|]

onlyBarbara :: ResponseMatcher
onlyBarbara =
  [scim|
        { "schemas":["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
          "totalResults": 1,
          "itemsPerPage": 1,
          "startIndex": 1,
          "Resources":
            [{ "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
               "userName":"bjensen",
               "externalId":"bjensen",
               "id" : "0",
               "name":{
                 "formatted":"Ms. Barbara J Jensen III",
                 "familyName":"Jensen",
                 "givenName":"Barbara"
               },
               "meta":{
                 "resourceType":"User",
                 "location":"https://example.com/Users/id",
                 "created":"2018-01-01T00:00:00Z",
                 "version":"W/\"testVersion\"",
                 "lastModified":"2018-01-01T00:00:00Z"
               }
             }]
        }|]

-- source: https://tools.ietf.org/html/rfc7644#section-3.5.1 (p. 30)
barbUpdate0 :: ByteString
barbUpdate0 =
  [scim|
        { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
          "id":"0",
          "userName":"bjensen",
          "externalId":"bjensen",
          "name":{
            "formatted":"Ms. Barbara J Jensen III",
            "familyName":"Jensen",
            "givenName":"Barbara",
            "middleName":"Jane"
          },
          "roles":[],
          "emails":[
            {
              "value":"bjensen@example.com"
            },
            {
              "value":"babs@jensen.org"
            }
          ]
        }|]

updatedBarb0 :: ResponseMatcher
updatedBarb0 =
  [scim|
        { "schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
          "id":"0",
          "userName":"bjensen",
          "externalId":"bjensen",
          "name":{
            "formatted":"Ms. Barbara J Jensen III",
            "familyName":"Jensen",
            "givenName":"Barbara",
            "middleName":"Jane"
          },
          "emails":[
            {
              "value":"bjensen@example.com"
            },
            {
              "value":"babs@jensen.org"
            }
          ],
          "meta":{
            "resourceType":"User",
            "location":"https://example.com/Users/id",
            "created":"2018-01-01T00:00:00Z",
            "version":"W/\"testVersion\"",
            "lastModified":"2018-01-01T00:00:00Z"
          }
        }|]

emptyList :: ResponseMatcher
emptyList =
  [scim|
       { "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
         "Resources":[],
         "totalResults":0,
         "itemsPerPage":0,
         "startIndex":1
       }|]