mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
Merge 153fa5cdb8
into 346c9fc0b4
This commit is contained in:
commit
b7df70ecec
@ -443,6 +443,10 @@ export class HomeDBManager extends EventEmitter {
|
||||
return this._usersManager.getUser(userId, options);
|
||||
}
|
||||
|
||||
public async getUsers() {
|
||||
return this._usersManager.getUsers();
|
||||
}
|
||||
|
||||
public async getFullUser(userId: number) {
|
||||
return this._usersManager.getFullUser(userId);
|
||||
}
|
||||
@ -542,6 +546,10 @@ export class HomeDBManager extends EventEmitter {
|
||||
return this._usersManager.deleteUser(scope, userIdToDelete, name);
|
||||
}
|
||||
|
||||
public async overrideUser(userId: number, props: UserProfile) {
|
||||
return this._usersManager.overrideUser(userId, props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a QueryResult for the given organization. The orgKey
|
||||
* can be a string (the domain from url) or the id of an org. If it is
|
||||
@ -3542,7 +3550,7 @@ export class HomeDBManager extends EventEmitter {
|
||||
// Get the user objects which map to non-null values in the userDelta.
|
||||
const userIds = Object.keys(userDelta).filter(userId => userDelta[userId])
|
||||
.map(userIdStr => parseInt(userIdStr, 10));
|
||||
const users = await this._usersManager.getUsers(userIds, manager);
|
||||
const users = await this._usersManager.getUsersByIds(userIds, manager);
|
||||
|
||||
// Add unaffected users to the delta so that we have a record of where they are.
|
||||
groups.forEach(grp => {
|
||||
|
@ -386,7 +386,7 @@ export class UsersManager {
|
||||
// Set the user's name if our provider knows it. Otherwise use their username
|
||||
// from email, for lack of something better. If we don't have a profile at this
|
||||
// time, then leave the name blank in the hopes of learning it when the user logs in.
|
||||
user.name = (profile && (profile.name || email.split('@')[0])) || '';
|
||||
user.name = (profile && this._getNameOrDeduceFromEmail(profile.name, email)) || '';
|
||||
needUpdate = true;
|
||||
}
|
||||
if (!user.picture && profile && profile.picture) {
|
||||
@ -561,6 +561,32 @@ export class UsersManager {
|
||||
.filter(fullProfile => fullProfile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update users with passed property. Optional user properties that are missing will be reset to their default value.
|
||||
*/
|
||||
public async overrideUser(userId: number, props: UserProfile): Promise<User> {
|
||||
return await this._connection.transaction(async manager => {
|
||||
const user = await this.getUser(userId, {includePrefs: true});
|
||||
if (!user) { throw new ApiError("unable to find user to update", 404); }
|
||||
const login = user.logins[0];
|
||||
user.name = this._getNameOrDeduceFromEmail(props.name, props.email);
|
||||
user.picture = props.picture || '';
|
||||
user.options = {...(user.options || {}), locale: props.locale ?? undefined};
|
||||
if (props.email) {
|
||||
login.email = normalizeEmail(props.email);
|
||||
login.displayEmail = props.email;
|
||||
}
|
||||
await manager.save([user, login]);
|
||||
|
||||
return (await this.getUser(userId))!;
|
||||
});
|
||||
}
|
||||
|
||||
public async getUsers() {
|
||||
return await User.find({relations: ["logins"]});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* ==================================
|
||||
*
|
||||
@ -660,7 +686,7 @@ export class UsersManager {
|
||||
/**
|
||||
* Returns a Promise for an array of User entites for the given userIds.
|
||||
*/
|
||||
public async getUsers(userIds: number[], optManager?: EntityManager): Promise<User[]> {
|
||||
public async getUsersByIds(userIds: number[], optManager?: EntityManager): Promise<User[]> {
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@ -744,6 +770,10 @@ export class UsersManager {
|
||||
return id;
|
||||
}
|
||||
|
||||
private _getNameOrDeduceFromEmail(name: string, email: string) {
|
||||
return name || email.split('@')[0];
|
||||
}
|
||||
|
||||
// This deals with the problem posed by receiving a PermissionDelta specifying a
|
||||
// role for both alice@x and Alice@x. We do not distinguish between such emails.
|
||||
// If there are multiple indistinguishabe emails, we preserve just one of them,
|
||||
|
@ -164,6 +164,7 @@ export class MergedServer {
|
||||
this.flexServer.addUpdatesCheck();
|
||||
// todo: add support for home api to standalone app
|
||||
this.flexServer.addHomeApi();
|
||||
this.flexServer.addScimApi();
|
||||
this.flexServer.addBillingApi();
|
||||
this.flexServer.addNotifier();
|
||||
this.flexServer.addAuditLogger();
|
||||
|
@ -86,6 +86,7 @@ import * as path from 'path';
|
||||
import * as serveStatic from "serve-static";
|
||||
import {ConfigBackendAPI} from "app/server/lib/ConfigBackendAPI";
|
||||
import {IGristCoreConfig} from "app/server/lib/configCore";
|
||||
import { buildScimRouter } from './scim';
|
||||
|
||||
// Health checks are a little noisy in the logs, so we don't show them all.
|
||||
// We show the first N health checks:
|
||||
@ -886,6 +887,19 @@ export class FlexServer implements GristServer {
|
||||
new ApiServer(this, this.app, this._dbManager, this._widgetRepository = buildWidgetRepository(this));
|
||||
}
|
||||
|
||||
public addScimApi() {
|
||||
if (this._check('scim', 'api', 'homedb', 'json', 'api-mw')) { return; }
|
||||
|
||||
const scimRouter = isAffirmative(process.env.GRIST_ENABLE_SCIM) ?
|
||||
buildScimRouter(this._dbManager, this._installAdmin) :
|
||||
() => {
|
||||
throw new ApiError('SCIM API is not enabled', 501);
|
||||
};
|
||||
|
||||
this.app.use('/api/scim', scimRouter);
|
||||
}
|
||||
|
||||
|
||||
public addBillingApi() {
|
||||
if (this._check('billing-api', 'homedb', 'json', 'api-mw')) { return; }
|
||||
this._getBilling();
|
||||
|
14
app/server/lib/scim/index.ts
Normal file
14
app/server/lib/scim/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import * as express from 'express';
|
||||
|
||||
import { buildScimRouterv2 } from './v2/ScimV2Api';
|
||||
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||
import { InstallAdmin } from '../InstallAdmin';
|
||||
|
||||
const buildScimRouter = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => {
|
||||
const v2 = buildScimRouterv2(dbManager, installAdmin);
|
||||
const scim = express.Router();
|
||||
scim.use('/v2', v2);
|
||||
return scim;
|
||||
};
|
||||
|
||||
export { buildScimRouter };
|
6
app/server/lib/scim/v2/ScimTypes.ts
Normal file
6
app/server/lib/scim/v2/ScimTypes.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface RequestContext {
|
||||
path: string;
|
||||
isAdmin: boolean;
|
||||
isScimUser: boolean;
|
||||
}
|
||||
|
164
app/server/lib/scim/v2/ScimUserController.ts
Normal file
164
app/server/lib/scim/v2/ScimUserController.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { ApiError } from 'app/common/ApiError';
|
||||
import { HomeDBManager, Scope } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||
import SCIMMY from 'scimmy';
|
||||
import { toSCIMMYUser, toUserProfile } from './ScimUserUtils';
|
||||
import { RequestContext } from './ScimTypes';
|
||||
|
||||
class ScimUserController {
|
||||
private static _getIdFromResource(resource: any) {
|
||||
const id = parseInt(resource.id, 10);
|
||||
if (Number.isNaN(id)) {
|
||||
throw new SCIMMY.Types.Error(400, 'invalidValue', 'Invalid passed user ID');
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private _dbManager: HomeDBManager,
|
||||
private _checkAccess: (context: RequestContext) => void
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets a single user with the passed ID.
|
||||
*
|
||||
* @param resource The SCIMMY user resource performing the operation
|
||||
* @param context The request context
|
||||
*/
|
||||
public async getSingleUser(resource: any, context: RequestContext) {
|
||||
return this._runAndHandleErrors(context, async () => {
|
||||
const id = ScimUserController._getIdFromResource(resource);
|
||||
const user = await this._dbManager.getUser(id);
|
||||
if (!user) {
|
||||
throw new SCIMMY.Types.Error(404, null!, `User with ID ${id} not found`);
|
||||
}
|
||||
return toSCIMMYUser(user);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all users or filters them based on the passed filter.
|
||||
*
|
||||
* @param resource The SCIMMY user resource performing the operation
|
||||
* @param context The request context
|
||||
*/
|
||||
public async getUsers(resource: any, context: RequestContext) {
|
||||
return this._runAndHandleErrors(context, async () => {
|
||||
const { filter } = resource;
|
||||
const scimmyUsers = (await this._dbManager.getUsers()).map(user => toSCIMMYUser(user));
|
||||
return filter ? filter.match(scimmyUsers) : scimmyUsers;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new user with the passed data.
|
||||
*
|
||||
* @param data The data to create the user with
|
||||
* @param context The request context
|
||||
*/
|
||||
public async createUser(data: any, context: RequestContext) {
|
||||
return this._runAndHandleErrors(context, async () => {
|
||||
await this._checkEmailCanBeUsed(data.userName);
|
||||
const userProfile = toUserProfile(data);
|
||||
const newUser = await this._dbManager.getUserByLoginWithRetry(userProfile.email, {
|
||||
profile: userProfile
|
||||
});
|
||||
return toSCIMMYUser(newUser);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides a user with the passed data.
|
||||
*
|
||||
* @param resource The SCIMMY user resource performing the operation
|
||||
* @param data The data to override the user with
|
||||
* @param context The request context
|
||||
*/
|
||||
public async overrideUser(resource: any, data: any, context: RequestContext) {
|
||||
return this._runAndHandleErrors(context, async () => {
|
||||
const id = ScimUserController._getIdFromResource(resource);
|
||||
await this._checkEmailCanBeUsed(data.userName, id);
|
||||
const updatedUser = await this._dbManager.overrideUser(id, toUserProfile(data));
|
||||
return toSCIMMYUser(updatedUser);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user with the passed ID.
|
||||
*
|
||||
* @param resource The SCIMMY user resource performing the operation
|
||||
* @param context The request context
|
||||
*/
|
||||
public async deleteUser(resource: any, context: RequestContext) {
|
||||
return this._runAndHandleErrors(context, async () => {
|
||||
const id = ScimUserController._getIdFromResource(resource);
|
||||
const fakeScope: Scope = { userId: id };
|
||||
// FIXME: deleteUser should probably better not requiring a scope.
|
||||
await this._dbManager.deleteUser(fakeScope, id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the passed callback and handles any errors that might occur.
|
||||
* Also checks if the user has access to the operation.
|
||||
* Any public method of this class should be run through this method.
|
||||
*
|
||||
* @param context The request context to check access for the user
|
||||
* @param cb The callback to run
|
||||
*/
|
||||
private async _runAndHandleErrors<T>(context: RequestContext, cb: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
this._checkAccess(context);
|
||||
return await cb();
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 409) {
|
||||
throw new SCIMMY.Types.Error(err.status, 'uniqueness', err.message);
|
||||
}
|
||||
throw new SCIMMY.Types.Error(err.status, null!, err.message);
|
||||
}
|
||||
if (err instanceof SCIMMY.Types.Error) {
|
||||
throw err;
|
||||
}
|
||||
// By default, return a 500 error
|
||||
throw new SCIMMY.Types.Error(500, null!, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the passed email can be used for a new user or by the existing user.
|
||||
*
|
||||
* @param email The email to check
|
||||
* @param userIdToUpdate The ID of the user to update. Pass this when updating a user,
|
||||
* so it won't raise an error if the passed email is already used by this user.
|
||||
*/
|
||||
private async _checkEmailCanBeUsed(email: string, userIdToUpdate?: number) {
|
||||
const existingUser = await this._dbManager.getExistingUserByLogin(email);
|
||||
if (existingUser !== undefined && existingUser.id !== userIdToUpdate) {
|
||||
throw new SCIMMY.Types.Error(409, 'uniqueness', 'An existing user with the passed email exist.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const getScimUserConfig = (
|
||||
dbManager: HomeDBManager, checkAccess: (context: RequestContext) => void
|
||||
) => {
|
||||
const controller = new ScimUserController(dbManager, checkAccess);
|
||||
|
||||
return {
|
||||
egress: async (resource: any, context: RequestContext) => {
|
||||
if (resource.id) {
|
||||
return await controller.getSingleUser(resource, context);
|
||||
}
|
||||
return await controller.getUsers(resource, context);
|
||||
},
|
||||
ingress: async (resource: any, data: any, context: RequestContext) => {
|
||||
if (resource.id) {
|
||||
return await controller.overrideUser(resource, data, context);
|
||||
}
|
||||
return await controller.createUser(data, context);
|
||||
},
|
||||
degress: async (resource: any, context: RequestContext) => {
|
||||
return await controller.deleteUser(resource, context);
|
||||
}
|
||||
};
|
||||
};
|
46
app/server/lib/scim/v2/ScimUserUtils.ts
Normal file
46
app/server/lib/scim/v2/ScimUserUtils.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { normalizeEmail } from "app/common/emails";
|
||||
import { UserProfile } from "app/common/LoginSessionAPI";
|
||||
import { User } from "app/gen-server/entity/User.js";
|
||||
import SCIMMY from "scimmy";
|
||||
|
||||
/**
|
||||
* Converts a user from your database to a SCIMMY user
|
||||
*/
|
||||
export function toSCIMMYUser(user: User) {
|
||||
if (!user.logins) {
|
||||
throw new Error("User must have at least one login");
|
||||
}
|
||||
const locale = user.options?.locale ?? "en";
|
||||
return new SCIMMY.Schemas.User({
|
||||
id: String(user.id),
|
||||
userName: user.loginEmail,
|
||||
displayName: user.name,
|
||||
name: {
|
||||
formatted: user.name,
|
||||
},
|
||||
locale,
|
||||
preferredLanguage: locale, // Assume preferredLanguage is the same as locale
|
||||
photos: user.picture ? [{
|
||||
value: user.picture,
|
||||
type: "photo",
|
||||
primary: true
|
||||
}] : undefined,
|
||||
emails: [{
|
||||
value: user.logins[0].displayEmail,
|
||||
primary: true,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
export function toUserProfile(scimUser: any, existingUser?: User): UserProfile {
|
||||
const emailValue = scimUser.emails?.[0]?.value;
|
||||
if (emailValue && normalizeEmail(emailValue) !== normalizeEmail(scimUser.userName)) {
|
||||
throw new SCIMMY.Types.Error(400, 'invalidValue', 'Email and userName must be the same');
|
||||
}
|
||||
return {
|
||||
name: scimUser.displayName ?? existingUser?.name,
|
||||
picture: scimUser.photos?.[0]?.value,
|
||||
locale: scimUser.locale,
|
||||
email: emailValue ?? scimUser.userName ?? existingUser?.loginEmail,
|
||||
};
|
||||
}
|
52
app/server/lib/scim/v2/ScimV2Api.ts
Normal file
52
app/server/lib/scim/v2/ScimV2Api.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import * as express from 'express';
|
||||
import { HomeDBManager } from 'app/gen-server/lib/homedb/HomeDBManager';
|
||||
import SCIMMY from "scimmy";
|
||||
import SCIMMYRouters from "scimmy-routers";
|
||||
import { RequestWithLogin } from 'app/server/lib/Authorizer';
|
||||
import { InstallAdmin } from 'app/server/lib/InstallAdmin';
|
||||
import { RequestContext } from './ScimTypes';
|
||||
import { getScimUserConfig } from './ScimUserController';
|
||||
|
||||
const WHITELISTED_PATHS_FOR_NON_ADMINS = [ "/Me", "/Schemas", "/ResourceTypes", "/ServiceProviderConfig" ];
|
||||
|
||||
const buildScimRouterv2 = (dbManager: HomeDBManager, installAdmin: InstallAdmin) => {
|
||||
const v2 = express.Router();
|
||||
|
||||
function checkAccess(context: RequestContext) {
|
||||
const {isAdmin, isScimUser, path } = context;
|
||||
if (!isAdmin && !isScimUser && !WHITELISTED_PATHS_FOR_NON_ADMINS.includes(path)) {
|
||||
throw new SCIMMY.Types.Error(403, null!, 'You are not authorized to access this resource');
|
||||
}
|
||||
}
|
||||
|
||||
SCIMMY.Resources.declare(SCIMMY.Resources.User, getScimUserConfig(dbManager, checkAccess));
|
||||
|
||||
const scimmyRouter = new SCIMMYRouters({
|
||||
type: 'bearer',
|
||||
handler: async (request: express.Request) => {
|
||||
const mreq = request as RequestWithLogin;
|
||||
if (mreq.userId === undefined) {
|
||||
// Note that any Error thrown here is automatically converted into a 403 response by SCIMMYRouters.
|
||||
throw new Error('You are not authorized to access this resource!');
|
||||
}
|
||||
|
||||
if (mreq.userId === dbManager.getAnonymousUserId()) {
|
||||
throw new Error('Anonymous user cannot access SCIM resources');
|
||||
}
|
||||
|
||||
return String(mreq.userId); // SCIMMYRouters requires the userId to be a string.
|
||||
},
|
||||
context: async (mreq: RequestWithLogin): Promise<RequestContext> => {
|
||||
const isAdmin = await installAdmin.isAdminReq(mreq);
|
||||
const isScimUser = Boolean(
|
||||
process.env.GRIST_SCIM_EMAIL && mreq.user?.loginEmail === process.env.GRIST_SCIM_EMAIL
|
||||
);
|
||||
const path = mreq.path;
|
||||
return { isAdmin, isScimUser, path };
|
||||
}
|
||||
}) as express.Router; // Have to cast it into express.Router. See https://github.com/scimmyjs/scimmy-routers/issues/24
|
||||
|
||||
return v2.use('/', scimmyRouter);
|
||||
};
|
||||
|
||||
export { buildScimRouterv2 };
|
@ -189,6 +189,8 @@
|
||||
"redis": "3.1.1",
|
||||
"redlock": "3.1.2",
|
||||
"saml2-js": "4.0.2",
|
||||
"scimmy": "1.2.4",
|
||||
"scimmy-routers": "1.2.2",
|
||||
"short-uuid": "3.1.1",
|
||||
"slugify": "1.6.6",
|
||||
"swagger-ui-dist": "5.11.0",
|
||||
|
@ -20,6 +20,7 @@ import { assert } from 'chai';
|
||||
import Sinon, { SinonSandbox, SinonSpy } from 'sinon';
|
||||
import { EntityManager } from 'typeorm';
|
||||
import winston from 'winston';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
import {delay} from 'app/common/delay';
|
||||
|
||||
@ -948,6 +949,68 @@ describe('UsersManager', function () {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('overrideUser()', function () {
|
||||
it('should reject when user is not found', async function () {
|
||||
disableLoggingLevel('debug');
|
||||
|
||||
const promise = db.overrideUser(NON_EXISTING_USER_ID, {
|
||||
email: 'whatever@getgrist.com',
|
||||
name: 'whatever',
|
||||
});
|
||||
|
||||
await assert.isRejected(promise, 'unable to find user to update');
|
||||
});
|
||||
|
||||
it('should update user information', async function () {
|
||||
const localPart = 'overrideuser-updates-user-info';
|
||||
const newLocalPart = 'overrideuser-updates-user-info-new';
|
||||
const user = await createUniqueUser(localPart);
|
||||
const newInfo: UserProfile = {
|
||||
name: 'new name',
|
||||
email: makeEmail(newLocalPart).toUpperCase(),
|
||||
picture: 'https://mypic.com/me.png',
|
||||
locale: 'fr-FR',
|
||||
};
|
||||
|
||||
await db.overrideUser(user.id, newInfo);
|
||||
|
||||
const updatedUser = await getOrCreateUser(newLocalPart);
|
||||
assert.deepInclude(updatedUser, {
|
||||
id: user.id,
|
||||
name: newInfo.name,
|
||||
picture: newInfo.picture,
|
||||
options: {locale: newInfo.locale},
|
||||
});
|
||||
assert.deepInclude(updatedUser.logins[0], {
|
||||
email: newInfo.email.toLowerCase(),
|
||||
displayEmail: newInfo.email,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsers()', function () {
|
||||
it('should return all users with their logins', async function () {
|
||||
const localPart = 'getUsers-user';
|
||||
const existingUser = await createUniqueUser(localPart);
|
||||
const users = await db.getUsers();
|
||||
assert.isAbove(users.length, 2);
|
||||
const mapUsersById = new Map(users.map(user => [user.id, user]));
|
||||
|
||||
// Check that we retrieve the existing user in the result with all their property
|
||||
// except the personalOrg
|
||||
const existingUserInResult = mapUsersById.get(existingUser.id);
|
||||
assertExists(existingUserInResult);
|
||||
assertExists(existingUserInResult.logins);
|
||||
assert.lengthOf(existingUserInResult.logins, 1);
|
||||
assert.deepEqual(existingUserInResult, omit(existingUser, 'personalOrg'));
|
||||
|
||||
// Check that we retrieve special accounts among the result
|
||||
assert.exists(mapUsersById.get(db.getSupportUserId()));
|
||||
assert.exists(mapUsersById.get(db.getEveryoneUserId()));
|
||||
assert.exists(mapUsersById.get(db.getAnonymousUserId()));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('class method without db setup', function () {
|
||||
|
@ -612,6 +612,7 @@ export async function createServer(port: number, initDb = createInitialDb): Prom
|
||||
flexServer.addAccessMiddleware();
|
||||
flexServer.addApiMiddleware();
|
||||
flexServer.addHomeApi();
|
||||
flexServer.addScimApi();
|
||||
flexServer.addApiErrorHandlers();
|
||||
await initDb(flexServer.getHomeDBManager().connection);
|
||||
flexServer.summary();
|
||||
|
@ -35,6 +35,7 @@ async function activateServer(home: FlexServer, docManager: DocManager) {
|
||||
await home.addLandingPages();
|
||||
home.addHomeApi();
|
||||
home.addAuditLogger();
|
||||
home.addScimApi();
|
||||
await home.addTelemetry();
|
||||
await home.addDoc();
|
||||
home.addApiErrorHandlers();
|
||||
|
751
test/server/lib/Scim.ts
Normal file
751
test/server/lib/Scim.ts
Normal file
@ -0,0 +1,751 @@
|
||||
import axios from 'axios';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
import { assert } from 'chai';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
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 charon = scimConfigForUser('Charon');
|
||||
const anon = scimConfigForUser('Anonymous');
|
||||
|
||||
const USER_CONFIG_BY_NAME = {
|
||||
chimpy,
|
||||
kiwi,
|
||||
anon,
|
||||
};
|
||||
|
||||
type UserConfigByName = typeof USER_CONFIG_BY_NAME;
|
||||
|
||||
describe('Scim', () => {
|
||||
testUtils.setTmpLogLevel('error');
|
||||
|
||||
const setupTestServer = (env: NodeJS.ProcessEnv) => {
|
||||
let homeUrl: string;
|
||||
let oldEnv: testUtils.EnvironmentSnapshot;
|
||||
let server: TestServer;
|
||||
|
||||
before(async function () {
|
||||
oldEnv = new testUtils.EnvironmentSnapshot();
|
||||
process.env.TYPEORM_DATABASE = ':memory:';
|
||||
Object.assign(process.env, env);
|
||||
server = new TestServer(this);
|
||||
homeUrl = await server.start();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
oldEnv.restore();
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
return {
|
||||
scimUrl: (path: string) => (homeUrl + '/api/scim/v2' + path),
|
||||
getDbManager: () => server.dbManager,
|
||||
};
|
||||
};
|
||||
|
||||
describe('when disabled', function () {
|
||||
const { scimUrl } = setupTestServer({});
|
||||
|
||||
it('should return 501 for /api/scim/v2/Users', async function () {
|
||||
const res = await axios.get(scimUrl('/Users'), chimpy);
|
||||
assert.equal(res.status, 501);
|
||||
assert.deepEqual(res.data, { error: 'SCIM API is not enabled' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('when enabled using GRIST_ENABLE_SCIM=1', function () {
|
||||
const { scimUrl, getDbManager } = setupTestServer({
|
||||
GRIST_ENABLE_SCIM: '1',
|
||||
GRIST_DEFAULT_EMAIL: 'chimpy@getgrist.com',
|
||||
GRIST_SCIM_EMAIL: 'charon@getgrist.com',
|
||||
});
|
||||
const userIdByName: {[name in keyof UserConfigByName]?: number} = {};
|
||||
|
||||
before(async function () {
|
||||
const userNames = Object.keys(USER_CONFIG_BY_NAME) as Array<keyof UserConfigByName>;
|
||||
for (const user of userNames) {
|
||||
userIdByName[user] = await getOrCreateUserId(user);
|
||||
}
|
||||
});
|
||||
|
||||
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 getOrCreateUserId(user: string) {
|
||||
return (await getDbManager().getUserByLogin(user + '@getgrist.com'))!.id;
|
||||
}
|
||||
|
||||
async function cleanupUser(userId: number) {
|
||||
if (await getDbManager().getUser(userId)) {
|
||||
await getDbManager().deleteUser({ userId: userId }, userId);
|
||||
}
|
||||
}
|
||||
|
||||
function checkCommonErrors(
|
||||
method: 'get' | 'post' | 'put' | 'patch' | 'delete',
|
||||
path: string,
|
||||
validBody: object = {}
|
||||
) {
|
||||
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), validBody, USER_CONFIG_BY_NAME[user]);
|
||||
}
|
||||
|
||||
it('should return 401 for anonymous', async function () {
|
||||
const res = await makeCallWith('anon');
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('should return 403 for kiwi', async function () {
|
||||
const res = await makeCallWith('kiwi');
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ],
|
||||
status: '403',
|
||||
detail: 'You are not authorized to access this resource'
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('should return a 500 in case of unknown Error', async function () {
|
||||
const sandbox = Sinon.createSandbox();
|
||||
try {
|
||||
const error = new Error('Some unexpected Error');
|
||||
|
||||
// Stub all the dbManager methods called by the controller
|
||||
sandbox.stub(getDbManager(), 'getUsers').throws(error);
|
||||
sandbox.stub(getDbManager(), 'getUser').throws(error);
|
||||
sandbox.stub(getDbManager(), 'getUserByLoginWithRetry').throws(error);
|
||||
sandbox.stub(getDbManager(), 'overrideUser').throws(error);
|
||||
sandbox.stub(getDbManager(), 'deleteUser').throws(error);
|
||||
|
||||
const res = await makeCallWith('chimpy');
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ],
|
||||
status: '500',
|
||||
detail: error.message
|
||||
});
|
||||
assert.equal(res.status, 500);
|
||||
} finally {
|
||||
sandbox.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it.skip('should allow operation like PATCH for kiwi', async function () {
|
||||
// SKIPPING this test: only the GET verb is currently implemented by SCIMMY for the /Me endpoint.
|
||||
// Issue created here: https://github.com/scimmyjs/scimmy/issues/47
|
||||
const patchBody = {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
|
||||
Operations: [{
|
||||
op: "replace",
|
||||
path: 'locale',
|
||||
value: 'fr',
|
||||
}],
|
||||
};
|
||||
const res = await axios.patch(scimUrl('/Me'), patchBody, kiwi);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.data, {
|
||||
...personaToSCIMMYUserWithId('kiwi'),
|
||||
locale: 'fr',
|
||||
preferredLanguage: 'en',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /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'
|
||||
});
|
||||
});
|
||||
|
||||
checkCommonErrors('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'));
|
||||
});
|
||||
|
||||
it('should handle pagination', async function () {
|
||||
const endpointPaginated = '/Users?count=1&sortBy=id';
|
||||
{
|
||||
const firstPage = await axios.get(scimUrl(endpointPaginated), chimpy);
|
||||
assert.equal(firstPage.status, 200);
|
||||
assert.lengthOf(firstPage.data.Resources, 1);
|
||||
const firstPageResourceId = parseInt(firstPage.data.Resources[0].id);
|
||||
assert.equal(firstPageResourceId, 1);
|
||||
}
|
||||
|
||||
{
|
||||
const secondPage = await axios.get(scimUrl(endpointPaginated + '&startIndex=2'), chimpy);
|
||||
assert.equal(secondPage.status, 200);
|
||||
assert.lengthOf(secondPage.data.Resources, 1);
|
||||
const secondPageResourceId = parseInt(secondPage.data.Resources[0].id);
|
||||
assert.equal(secondPageResourceId, 2);
|
||||
}
|
||||
});
|
||||
|
||||
checkCommonErrors('get', '/Users');
|
||||
});
|
||||
|
||||
describe('POST /Users/.search', function () {
|
||||
const SEARCH_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:SearchRequest';
|
||||
|
||||
const searchExample = {
|
||||
schemas: [SEARCH_SCHEMA],
|
||||
sortBy: 'userName',
|
||||
sortOrder: 'descending',
|
||||
};
|
||||
|
||||
it('should return all users for chimpy order by userName in descending order', async function () {
|
||||
const res = await axios.post(scimUrl('/Users/.search'), searchExample, 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 also allow access for user Charon (the one refered in GRIST_SCIM_EMAIL)', async function () {
|
||||
const res = await axios.post(scimUrl('/Users/.search'), searchExample, charon);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
checkCommonErrors('post', '/Users/.search', searchExample);
|
||||
});
|
||||
|
||||
describe('POST /Users', function () { // Create a new users
|
||||
async function withUserName(userName: string, cb: (userName: string) => Promise<void>) {
|
||||
try {
|
||||
await cb(userName);
|
||||
} finally {
|
||||
const user = await getDbManager().getExistingUserByLogin(userName + "@getgrist.com");
|
||||
if (user) {
|
||||
await cleanupUser(user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
it('should create a new user', async function () {
|
||||
await withUserName('newuser1', async (userName) => {
|
||||
const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId(userName), chimpy);
|
||||
assert.equal(res.status, 201);
|
||||
const newUserId = await getOrCreateUserId(userName);
|
||||
assert.deepEqual(res.data, toSCIMUserWithId(userName, newUserId));
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow creating a new user given only their email passed as username', async function () {
|
||||
await withUserName('new.user2', async (userName) => {
|
||||
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, userName + '@getgrist.com');
|
||||
assert.equal(res.data.displayName, userName);
|
||||
});
|
||||
});
|
||||
|
||||
it('should also allow user Charon to create a user (the one refered in GRIST_SCIM_EMAIL)', async function () {
|
||||
await withUserName('new.user.by.charon', async (userName) => {
|
||||
const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId(userName), charon);
|
||||
assert.equal(res.status, 201);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject when passed email differs from username', async function () {
|
||||
await withUserName('username', async (userName) => {
|
||||
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.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'
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disallow creating a user with the same email', async function () {
|
||||
const res = await axios.post(scimUrl('/Users'), toSCIMUserWithoutId('chimpy'), chimpy);
|
||||
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'
|
||||
});
|
||||
assert.equal(res.status, 409);
|
||||
});
|
||||
|
||||
checkCommonErrors('post', '/Users', toSCIMUserWithoutId('some-user'));
|
||||
});
|
||||
|
||||
describe('PUT /Users/{id}', function () {
|
||||
let userToUpdateId: number;
|
||||
const userToUpdateEmailLocalPart = 'user-to-update';
|
||||
|
||||
beforeEach(async function () {
|
||||
userToUpdateId = await getOrCreateUserId(userToUpdateEmailLocalPart);
|
||||
});
|
||||
afterEach(async function () {
|
||||
await cleanupUser(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.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'
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('should disallow updating a user with the same email as another user\'s', async function () {
|
||||
const res = await axios.put(scimUrl(`/Users/${userToUpdateId}`), toSCIMUserWithoutId('chimpy'), chimpy);
|
||||
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'
|
||||
});
|
||||
assert.equal(res.status, 409);
|
||||
});
|
||||
|
||||
it('should return 404 when the user is not found', async function () {
|
||||
const res = await axios.put(scimUrl('/Users/1000'), toSCIMUserWithoutId('whoever'), chimpy);
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ],
|
||||
status: '404',
|
||||
detail: 'unable to find user to update'
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
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 return 400 when the user id is malformed', async function () {
|
||||
const res = await axios.put(scimUrl('/Users/not-an-id'), toSCIMUserWithoutId('whoever'), chimpy);
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ],
|
||||
status: '400',
|
||||
detail: 'Invalid passed user ID',
|
||||
scimType: 'invalidValue'
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
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 }]
|
||||
});
|
||||
});
|
||||
|
||||
checkCommonErrors('put', '/Users/1', toSCIMUserWithoutId('chimpy'));
|
||||
});
|
||||
|
||||
describe('PATCH /Users/{id}', function () {
|
||||
let userToPatchId: number;
|
||||
const userToPatchEmailLocalPart = 'user-to-patch';
|
||||
beforeEach(async function () {
|
||||
userToPatchId = await getOrCreateUserId(userToPatchEmailLocalPart);
|
||||
});
|
||||
afterEach(async function () {
|
||||
await cleanupUser(userToPatchId);
|
||||
});
|
||||
|
||||
const validPatchBody = (newName: string) => ({
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
|
||||
Operations: [{
|
||||
op: "replace",
|
||||
path: "displayName",
|
||||
value: newName,
|
||||
}, {
|
||||
op: "replace",
|
||||
path: "locale",
|
||||
value: 'fr'
|
||||
}],
|
||||
});
|
||||
|
||||
it('should replace values of an existing user', async function () {
|
||||
const newName = 'User to Patch new Name';
|
||||
const res = await axios.patch(scimUrl(`/Users/${userToPatchId}`), validPatchBody(newName), chimpy);
|
||||
assert.equal(res.status, 200);
|
||||
const refreshedUser = await axios.get(scimUrl(`/Users/${userToPatchId}`), chimpy);
|
||||
assert.deepEqual(refreshedUser.data, {
|
||||
...toSCIMUserWithId(userToPatchEmailLocalPart, userToPatchId),
|
||||
displayName: newName,
|
||||
name: { formatted: newName },
|
||||
locale: 'fr',
|
||||
preferredLanguage: 'fr',
|
||||
});
|
||||
});
|
||||
|
||||
checkCommonErrors('patch', '/Users/1', validPatchBody('new name2'));
|
||||
});
|
||||
|
||||
describe('DELETE /Users/{id}', function () {
|
||||
let userToDeleteId: number;
|
||||
const userToDeleteEmailLocalPart = 'user-to-delete';
|
||||
|
||||
beforeEach(async function () {
|
||||
userToDeleteId = await getOrCreateUserId(userToDeleteEmailLocalPart);
|
||||
});
|
||||
afterEach(async function () {
|
||||
await cleanupUser(userToDeleteId);
|
||||
});
|
||||
|
||||
it('should delete some user', async function () {
|
||||
const res = await axios.delete(scimUrl(`/Users/${userToDeleteId}`), chimpy);
|
||||
assert.equal(res.status, 204);
|
||||
const refreshedUser = await axios.get(scimUrl(`/Users/${userToDeleteId}`), chimpy);
|
||||
assert.equal(refreshedUser.status, 404);
|
||||
});
|
||||
|
||||
it('should return 404 when the user is not found', async function () {
|
||||
const res = await axios.delete(scimUrl('/Users/1000'), chimpy);
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ],
|
||||
status: '404',
|
||||
detail: 'user not found'
|
||||
});
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
checkCommonErrors('delete', '/Users/1');
|
||||
});
|
||||
|
||||
describe('POST /Bulk', function () {
|
||||
let usersToCleanupEmails: string[];
|
||||
|
||||
beforeEach(async function () {
|
||||
usersToCleanupEmails = [];
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
for (const email of usersToCleanupEmails) {
|
||||
const user = await getDbManager().getExistingUserByLogin(email);
|
||||
if (user) {
|
||||
await cleanupUser(user.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should return statuses for each operation', async function () {
|
||||
const putOnUnknownResource = { method: 'PUT', path: '/Users/1000', value: toSCIMUserWithoutId('chimpy') };
|
||||
const validCreateOperation = {
|
||||
method: 'POST', path: '/Users/', data: toSCIMUserWithoutId('bulk-user3'), bulkId: '1'
|
||||
};
|
||||
usersToCleanupEmails.push('bulk-user3');
|
||||
const createOperationWithUserNameConflict = {
|
||||
method: 'POST', path: '/Users/', data: toSCIMUserWithoutId('chimpy'), bulkId: '2'
|
||||
};
|
||||
const res = await axios.post(scimUrl('/Bulk'), {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'],
|
||||
Operations: [
|
||||
putOnUnknownResource,
|
||||
validCreateOperation,
|
||||
createOperationWithUserNameConflict,
|
||||
],
|
||||
}, chimpy);
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const newUserID = await getOrCreateUserId('bulk-user3');
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ "urn:ietf:params:scim:api:messages:2.0:BulkResponse" ],
|
||||
Operations: [
|
||||
{
|
||||
method: "PUT",
|
||||
location: "/api/scim/v2/Users/1000",
|
||||
status: "400",
|
||||
response: {
|
||||
schemas: [
|
||||
"urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
],
|
||||
status: "400",
|
||||
scimType: "invalidSyntax",
|
||||
detail: "Expected 'data' to be a single complex value in BulkRequest operation #1"
|
||||
}
|
||||
}, {
|
||||
method: "POST",
|
||||
bulkId: "1",
|
||||
location: "/api/scim/v2/Users/" + newUserID,
|
||||
status: "201"
|
||||
}, {
|
||||
method: "POST",
|
||||
bulkId: "2",
|
||||
status: "409",
|
||||
response: {
|
||||
schemas: [
|
||||
"urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
],
|
||||
status: "409",
|
||||
scimType: "uniqueness",
|
||||
detail: "An existing user with the passed email exist."
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 when no operations are provided', async function () {
|
||||
const res = await axios.post(scimUrl('/Bulk'), {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'],
|
||||
Operations: [],
|
||||
}, chimpy);
|
||||
assert.equal(res.status, 400);
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ 'urn:ietf:params:scim:api:messages:2.0:Error' ],
|
||||
status: '400',
|
||||
detail: "BulkRequest request body must contain 'Operations' attribute with at least one operation",
|
||||
scimType: 'invalidValue'
|
||||
});
|
||||
});
|
||||
|
||||
it('should disallow accessing resources to kiwi', async function () {
|
||||
const creationOperation = {
|
||||
method: 'POST', path: '/Users', data: toSCIMUserWithoutId('bulk-user4'), bulkId: '1'
|
||||
};
|
||||
usersToCleanupEmails.push('bulk-user4');
|
||||
const selfPutOperation = { method: 'PUT', path: '/Me', value: toSCIMUserWithoutId('kiwi') };
|
||||
const res = await axios.post(scimUrl('/Bulk'), {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'],
|
||||
Operations: [
|
||||
creationOperation,
|
||||
selfPutOperation,
|
||||
],
|
||||
}, kiwi);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(res.data, {
|
||||
schemas: [ "urn:ietf:params:scim:api:messages:2.0:BulkResponse" ],
|
||||
Operations: [
|
||||
{
|
||||
method: "POST",
|
||||
bulkId: "1",
|
||||
status: "403",
|
||||
response: {
|
||||
detail: "You are not authorized to access this resource",
|
||||
schemas: [ "urn:ietf:params:scim:api:messages:2.0:Error" ],
|
||||
status: "403"
|
||||
}
|
||||
}, {
|
||||
// When writing this test, the SCIMMY implementation does not yet support PUT operations on /Me.
|
||||
// This reflects the current behavior, but it may change in the future.
|
||||
// Change this test if the behavior changes.
|
||||
// It is probably fine to allow altering oneself even for non-admins.
|
||||
method: "PUT",
|
||||
location: "/Me",
|
||||
status: "400",
|
||||
response: {
|
||||
schemas: [
|
||||
"urn:ietf:params:scim:api:messages:2.0:Error"
|
||||
],
|
||||
status: "400",
|
||||
detail: "Invalid 'path' value '/Me' in BulkRequest operation #2",
|
||||
scimType: "invalidValue"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('should disallow accessing resources to anonymous', async function () {
|
||||
const creationOperation = {
|
||||
method: 'POST', path: '/Users', data: toSCIMUserWithoutId('bulk-user5'), bulkId: '1'
|
||||
};
|
||||
usersToCleanupEmails.push('bulk-user5');
|
||||
const res = await axios.post(scimUrl('/Bulk'), {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:BulkRequest'],
|
||||
Operations: [creationOperation],
|
||||
}, anon);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow fetching the Scim schema when autenticated', async function () {
|
||||
const res = await axios.get(scimUrl('/Schemas'), kiwi);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepInclude(res.data, {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
|
||||
});
|
||||
assert.property(res.data, 'Resources');
|
||||
assert.deepInclude(res.data.Resources[0], {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Schema'],
|
||||
id: 'urn:ietf:params:scim:schemas:core:2.0:User',
|
||||
name: 'User',
|
||||
description: 'User Account',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow fetching the Scim resource types when autenticated', async function () {
|
||||
const res = await axios.get(scimUrl('/ResourceTypes'), kiwi);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepInclude(res.data, {
|
||||
schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
|
||||
});
|
||||
assert.property(res.data, 'Resources');
|
||||
assert.deepInclude(res.data.Resources[0], {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ResourceType'],
|
||||
name: 'User',
|
||||
endpoint: '/Users',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow fetching the Scim service provider config when autenticated', async function () {
|
||||
const res = await axios.get(scimUrl('/ServiceProviderConfig'), kiwi);
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepInclude(res.data, {
|
||||
schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'],
|
||||
});
|
||||
assert.property(res.data, 'patch');
|
||||
assert.property(res.data, 'bulk');
|
||||
assert.property(res.data, 'filter');
|
||||
});
|
||||
});
|
||||
});
|
10
yarn.lock
10
yarn.lock
@ -7346,6 +7346,16 @@ schema-utils@^3.2.0:
|
||||
ajv "^6.12.5"
|
||||
ajv-keywords "^3.5.2"
|
||||
|
||||
scimmy-routers@1.2.2:
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/scimmy-routers/-/scimmy-routers-1.2.2.tgz#e1fa506a8cdb0ba04a25e09a365bd726cd781585"
|
||||
integrity sha512-qDB7DKb2cnujJzEgVdON8EnjZfs6oY+MJQkkCHbihNrQeRjSaEOAC9ohb6dGfMZdahYS0CZIJwGhvZlS6rkKsg==
|
||||
|
||||
scimmy@1.2.4:
|
||||
version "1.2.4"
|
||||
resolved "https://registry.yarnpkg.com/scimmy/-/scimmy-1.2.4.tgz#3d708d9a5f3c7b3e00d848dcb8f0910d7c409509"
|
||||
integrity sha512-5i+LwGL7ON61jH+KxL6flpy5h/ABhgx7tc9AdL3KMh9TfHidWl7KHrbD0cJN5bJ5Fb1nOTze8d+PbFl2bZYEJQ==
|
||||
|
||||
selenium-webdriver@^4.20.0:
|
||||
version "4.20.0"
|
||||
resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.20.0.tgz#14941ab4a59e8956a5e4b4491a8ba2bd6619d1ac"
|
||||
|
Loading…
Reference in New Issue
Block a user