diff --git a/test/server/lib/Scim.ts b/test/server/lib/Scim.ts new file mode 100644 index 00000000..cbfc5806 --- /dev/null +++ b/test/server/lib/Scim.ts @@ -0,0 +1,363 @@ +import axios from 'axios'; +import capitalize from 'lodash/capitalize'; +import { assert } from 'chai'; +import { TestServer } from 'test/gen-server/apiUtils'; +import { configForUser } from 'test/gen-server/testUtils'; +import * as testUtils from 'test/server/testUtils'; + +function scimConfigForUser(user: string) { + const config = configForUser(user); + return { + ...config, + headers: { + ...config.headers, + 'Content-Type': 'application/scim+json' + } + }; +} + +const chimpy = scimConfigForUser('Chimpy'); +const kiwi = scimConfigForUser('Kiwi'); +const anon = configForUser('Anonymous'); + +const USER_CONFIG_BY_NAME = { + chimpy, + kiwi, + anon, +}; + +type UserConfigByName = typeof USER_CONFIG_BY_NAME; + +describe('Scim', () => { + let oldEnv: testUtils.EnvironmentSnapshot; + let server: TestServer; + let homeUrl: string; + const userIdByName: {[name in keyof UserConfigByName]?: number} = {}; + + const scimUrl = (path: string) => (homeUrl + '/api/scim/v2' + path); + + before(async function () { + oldEnv = new testUtils.EnvironmentSnapshot(); + process.env.GRIST_DEFAULT_EMAIL = 'chimpy@getgrist.com'; + process.env.TYPEORM_DATABASE = ':memory:'; + server = new TestServer(this); + homeUrl = await server.start(); + const userNames = Object.keys(USER_CONFIG_BY_NAME) as Array; + for (const user of userNames) { + userIdByName[user] = await getUserId(user); + } + }); + + after(async () => { + oldEnv.restore(); + await server.stop(); + }); + + function personaToSCIMMYUserWithId(user: keyof UserConfigByName) { + return toSCIMUserWithId(user, userIdByName[user]!); + } + + function toSCIMUserWithId(user: string, id: number) { + return { + ...toSCIMUserWithoutId(user), + id: String(id), + meta: { resourceType: 'User', location: '/api/scim/v2/Users/' + id }, + }; + } + + function toSCIMUserWithoutId(user: string) { + return { + schemas: [ 'urn:ietf:params:scim:schemas:core:2.0:User' ], + userName: user + '@getgrist.com', + name: { formatted: capitalize(user) }, + displayName: capitalize(user), + preferredLanguage: 'en', + locale: 'en', + emails: [ { value: user + '@getgrist.com', primary: true } ] + }; + } + + async function getUserId(user: string) { + return (await server.dbManager.getUserByLogin(user + '@getgrist.com'))!.id; + } + + function checkEndpointNotAccessibleForNonAdminUsers( + method: 'get' | 'post' | 'put' | 'patch' | 'delete', + path: string + ) { + function makeCallWith(user: keyof UserConfigByName) { + if (method === 'get' || method === 'delete') { + return axios[method](scimUrl(path), USER_CONFIG_BY_NAME[user]); + } + return axios[method](scimUrl(path), {}, USER_CONFIG_BY_NAME[user]); + } + it('should return 401 for anonymous', async function () { + const res: any = await makeCallWith('anon'); + assert.equal(res.status, 401); + }); + + it('should return 401 for kiwi', async function () { + const res: any = await makeCallWith('kiwi'); + assert.equal(res.status, 401); + }); + } + + describe('/Me', function () { + async function checkGetMeAs(user: keyof UserConfigByName, expected: any) { + const res = await axios.get(scimUrl('/Me'), USER_CONFIG_BY_NAME[user]); + assert.equal(res.status, 200); + assert.deepInclude(res.data, expected); + } + + it(`should return the current user for chimpy`, async function () { + return checkGetMeAs('chimpy', personaToSCIMMYUserWithId('chimpy')); + }); + + it(`should return the current user for kiwi`, async function () { + return checkGetMeAs('kiwi', personaToSCIMMYUserWithId('kiwi')); + }); + + it('should return 401 for anonymous', async function () { + const res = await axios.get(scimUrl('/Me'), anon); + assert.equal(res.status, 401); + }); + }); + + describe('/Users/{id}', function () { + + it('should return the user of id=1 for chimpy', async function () { + const res = await axios.get(scimUrl('/Users/1'), chimpy); + + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: '1', + displayName: 'Chimpy', + userName: 'chimpy@getgrist.com' + }); + }); + + it('should return 404 when the user is not found', async function () { + const res = await axios.get(scimUrl('/Users/1000'), chimpy); + assert.equal(res.status, 404); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '404', + detail: 'User with ID 1000 not found' + }); + }); + + checkEndpointNotAccessibleForNonAdminUsers('get', '/Users/1'); + }); + + describe('GET /Users', function () { + it('should return all users for chimpy', async function () { + const res = await axios.get(scimUrl('/Users'), chimpy); + assert.equal(res.status, 200); + assert.isAbove(res.data.totalResults, 0, 'should have retrieved some users'); + assert.deepInclude(res.data.Resources, personaToSCIMMYUserWithId('chimpy')); + assert.deepInclude(res.data.Resources, personaToSCIMMYUserWithId('kiwi')); + }); + + checkEndpointNotAccessibleForNonAdminUsers('get', '/Users'); + }); + + describe('POST /Users/.search', function () { + const SEARCH_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:SearchRequest'; + it('should return all users for chimpy order by userName in descending order', async function () { + const res = await axios.post(scimUrl('/Users/.search'), { + schemas: [SEARCH_SCHEMA], + sortBy: 'userName', + sortOrder: 'descending', + }, chimpy); + assert.equal(res.status, 200); + assert.isAbove(res.data.totalResults, 0, 'should have retrieved some users'); + const users = res.data.Resources.map((r: any) => r.userName); + assert.include(users, 'chimpy@getgrist.com'); + assert.include(users, 'kiwi@getgrist.com'); + const indexOfChimpy = users.indexOf('chimpy@getgrist.com'); + const indexOfKiwi = users.indexOf('kiwi@getgrist.com'); + assert.isBelow(indexOfKiwi, indexOfChimpy, 'kiwi should come before chimpy'); + }); + + it('should filter the users by userName', async function () { + const res = await axios.post(scimUrl('/Users/.search'), { + schemas: [SEARCH_SCHEMA], + attributes: ['userName'], + filter: 'userName sw "chimpy"', + }, chimpy); + assert.equal(res.status, 200); + assert.equal(res.data.totalResults, 1); + assert.deepEqual(res.data.Resources[0], { id: String(userIdByName['chimpy']), userName: 'chimpy@getgrist.com' }, + "should have retrieved only chimpy's username and not other attribute"); + }); + + checkEndpointNotAccessibleForNonAdminUsers('post', '/Users/.search'); + }); + + describe('POST /Users', function () { // Create a new users + it('should create a new user', async function () { + const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId('newuser1'), chimpy); + assert.equal(res.status, 201); + const newUserId = await getUserId('newuser1'); + assert.deepEqual(res.data, toSCIMUserWithId('newuser1', newUserId)); + }); + + it('should allow creating a new user given only their email passed as username', async function () { + const res = await axios.post(scimUrl('/Users'), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: 'new.user2@getgrist.com', + }, chimpy); + assert.equal(res.status, 201); + assert.equal(res.data.userName, 'new.user2@getgrist.com'); + assert.equal(res.data.displayName, 'new.user2'); + }); + + it('should reject when passed email differs from username', async function () { + const res = await axios.post(scimUrl('/Users'), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: 'username@getgrist.com', + emails: [{ value: 'emails.value@getgrist.com' }], + }, chimpy); + assert.equal(res.status, 400); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '400', + detail: 'Email and userName must be the same', + scimType: 'invalidValue' + }); + }); + + it('should disallow creating a user with the same email', async function () { + const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId('chimpy'), chimpy); + assert.equal(res.status, 409); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '409', + detail: 'An existing user with the passed email exist.', + scimType: 'uniqueness' + }); + }); + + checkEndpointNotAccessibleForNonAdminUsers('post', '/Users'); + }); + + describe('PUT /Users/{id}', function () { + let userToUpdateId: number; + const userToUpdateEmailLocalPart = 'user-to-update'; + + beforeEach(async function () { + userToUpdateId = await getUserId(userToUpdateEmailLocalPart); + }); + afterEach(async function () { + await server.dbManager.deleteUser({ userId: userToUpdateId }, userToUpdateId); + }); + + it('should update an existing user', async function () { + const userToUpdateProperties = { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: userToUpdateEmailLocalPart + '-now-updated@getgrist.com', + displayName: 'User to Update', + photos: [{ value: 'https://example.com/photo.jpg', type: 'photo', primary: true }], + locale: 'fr', + }; + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), userToUpdateProperties, chimpy); + assert.equal(res.status, 200); + const refreshedUser = await axios.get(scimUrl(`/Users/${userToUpdateId}`), chimpy); + assert.deepEqual(refreshedUser.data, { + ...userToUpdateProperties, + id: String(userToUpdateId), + meta: { resourceType: 'User', location: `/api/scim/v2/Users/${userToUpdateId}` }, + emails: [ { value: userToUpdateProperties.userName, primary: true } ], + name: { formatted: userToUpdateProperties.displayName }, + preferredLanguage: 'fr', + }); + }); + + it('should reject when passed email differs from username', async function () { + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: userToUpdateEmailLocalPart + '@getgrist.com', + emails: [{ value: 'whatever@getgrist.com', primary: true }], + }, chimpy); + assert.equal(res.status, 400); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '400', + detail: 'Email and userName must be the same', + scimType: 'invalidValue' + }); + }); + + it('should disallow updating a user with the same email', async function () { + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), toSCIMUserWithoutId('chimpy'), chimpy); + assert.equal(res.status, 409); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '409', + detail: 'SQLITE_CONSTRAINT: UNIQUE constraint failed: logins.email', + scimType: 'uniqueness' + }); + }); + + it('should return 404 when the user is not found', async function () { + const res = await axios.put(scimUrl('/Users/1000'), toSCIMUserWithoutId('chimpy'), chimpy); + assert.equal(res.status, 404); + assert.deepEqual(res.data, { + schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ], + status: '404', + detail: 'unable to find user to update' + }); + }); + + it('should deduce the name from the displayEmail when not provided', async function () { + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: 'my-email@getgrist.com', + }, chimpy); + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: String(userToUpdateId), + userName: 'my-email@getgrist.com', + displayName: 'my-email', + }); + }); + + it('should normalize the passed email for the userName and keep the case for email.value', async function () { + const newEmail = 'my-EMAIL@getgrist.com'; + const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: newEmail, + }, chimpy); + assert.equal(res.status, 200); + assert.deepInclude(res.data, { + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: String(userToUpdateId), + userName: newEmail.toLowerCase(), + displayName: 'my-EMAIL', + emails: [{ value: newEmail, primary: true }] + }); + }); + + checkEndpointNotAccessibleForNonAdminUsers('put', '/Users/1'); + }); + + describe('PATCH /Users/{id}', function () { + it('should update the user of id=1', async function () { + throw new Error("This is a reminder :)"); + }); + checkEndpointNotAccessibleForNonAdminUsers('patch', '/Users/1'); + }); + + describe('DELETE /Users/{id}', function () { + it('should delete the user of id=1', async function () { + throw new Error("This is a reminder :)"); + }); + checkEndpointNotAccessibleForNonAdminUsers('delete', '/Users/1'); + }); + + it('should fix the 401 response for authenticated users', function () { + throw new Error("This is a reminder :)"); + }); +});