feat(core): Add GET /users endpoints to public API (#6360)

This commit is contained in:
Ricardo Espinoza 2023-06-21 07:22:00 -04:00 committed by GitHub
parent 25b92169ae
commit 6ab350209d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 679 additions and 39 deletions

View file

@ -186,6 +186,10 @@ export class License {
return (this.getFeatureValue(LICENSE_QUOTAS.VARIABLES_LIMIT) ?? -1) as number;
}
getUsersLimit(): number {
return this.getFeatureValue(LICENSE_QUOTAS.USERS_LIMIT) as number;
}
getPlanName(): string {
return (this.getFeatureValue('planName') ?? 'Community') as string;
}

View file

@ -0,0 +1,19 @@
get:
x-eov-operation-id: getUser
x-eov-operation-handler: v1/handlers/users/users.handler.ee
tags:
- User
summary: Get user by ID/Email
description: Retrieve a user from your instance. Only available for the instance owner.
parameters:
- $ref: '../schemas/parameters/userIdentifier.yml'
- $ref: '../schemas/parameters/includeRole.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/user.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'

View file

@ -0,0 +1,20 @@
get:
x-eov-operation-id: getUsers
x-eov-operation-handler: v1/handlers/users/users.handler.ee
tags:
- User
summary: Retrieve all users
description: Retrieve all users from your instance. Only available for the instance owner.
parameters:
- $ref: '../../../../shared/spec/parameters/limit.yml'
- $ref: '../../../../shared/spec/parameters/cursor.yml'
- $ref: '../schemas/parameters/includeRole.yml'
responses:
'200':
description: Operation successful.
content:
application/json:
schema:
$ref: '../schemas/userList.yml'
'401':
$ref: '../../../../shared/spec/responses/unauthorized.yml'

View file

@ -0,0 +1,8 @@
name: includeRole
in: query
description: Whether to include the user's role or not.
required: false
schema:
type: boolean
example: true
default: false

View file

@ -0,0 +1,7 @@
name: id
in: path
description: The ID or email of the user.
required: true
schema:
type: string
format: identifier

View file

@ -0,0 +1,25 @@
readOnly: true
type: object
properties:
id:
type: number
readOnly: true
example: 1
name:
type: string
example: owner
readOnly: true
scope:
type: string
readOnly: true
example: global
createdAt:
type: string
description: Time the role was created.
format: date-time
readOnly: true
updatedAt:
type: string
description: Last time the role was updated.
format: date-time
readOnly: true

View file

@ -0,0 +1,40 @@
required:
- email
type: object
properties:
id:
type: string
readOnly: true
example: 123e4567-e89b-12d3-a456-426614174000
email:
type: string
format: email
example: john.doe@company.com
firstName:
maxLength: 32
type: string
description: User's first name
readOnly: true
example: john
lastName:
maxLength: 32
type: string
description: User's last name
readOnly: true
example: Doe
isPending:
type: boolean
description: Whether the user finished setting up their account in response to the invitation (true) or not (false).
readOnly: true
createdAt:
type: string
description: Time the user was created.
format: date-time
readOnly: true
updatedAt:
type: string
description: Last time the user was updated.
format: date-time
readOnly: true
globalRole:
$ref: './role.yml'

View file

@ -0,0 +1,11 @@
type: object
properties:
data:
type: array
items:
$ref: './user.yml'
nextCursor:
type: string
description: Paginate through users by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection.
nullable: true
example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA

View file

@ -0,0 +1,71 @@
import type express from 'express';
import { clean, getAllUsersAndCount, getUser } from './users.service.ee';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import {
authorize,
validCursor,
validLicenseWithUserQuota,
} from '../../shared/middlewares/global.middleware';
import type { UserRequest } from '@/requests';
import { InternalHooks } from '@/InternalHooks';
import Container from 'typedi';
export = {
getUser: [
validLicenseWithUserQuota,
authorize(['owner']),
async (req: UserRequest.Get, res: express.Response) => {
const { includeRole = false } = req.query;
const { id } = req.params;
const user = await getUser({ withIdentifier: id, includeRole });
if (!user) {
return res.status(404).json({
message: `Could not find user with id: ${id}`,
});
}
const telemetryData = {
user_id: req.user.id,
public_api: true,
};
void Container.get(InternalHooks).onUserRetrievedUser(telemetryData);
return res.json(clean(user, { includeRole }));
},
],
getUsers: [
validLicenseWithUserQuota,
validCursor,
authorize(['owner']),
async (req: UserRequest.Get, res: express.Response) => {
const { offset = 0, limit = 100, includeRole = false } = req.query;
const [users, count] = await getAllUsersAndCount({
includeRole,
limit,
offset,
});
const telemetryData = {
user_id: req.user.id,
public_api: true,
};
void Container.get(InternalHooks).onUserRetrievedAllUsers(telemetryData);
return res.json({
data: clean(users, { includeRole }),
nextCursor: encodeNextCursor({
offset,
limit,
numberOfTotalRecords: count,
}),
});
},
],
};

View file

@ -0,0 +1,68 @@
import { Container } from 'typedi';
import { RoleRepository, UserRepository } from '@db/repositories';
import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User';
import pick from 'lodash/pick';
import { validate as uuidValidate } from 'uuid';
export function isInstanceOwner(user: User): boolean {
return user.globalRole.name === 'owner';
}
export async function getWorkflowOwnerRole(): Promise<Role> {
return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail();
}
export const getSelectableProperties = (table: 'user' | 'role'): string[] => {
return {
user: ['id', 'email', 'firstName', 'lastName', 'createdAt', 'updatedAt', 'isPending'],
role: ['id', 'name', 'scope', 'createdAt', 'updatedAt'],
}[table];
};
export async function getUser(data: {
withIdentifier: string;
includeRole?: boolean;
}): Promise<User | null> {
return Container.get(UserRepository).findOne({
where: {
...(uuidValidate(data.withIdentifier) && { id: data.withIdentifier }),
...(!uuidValidate(data.withIdentifier) && { email: data.withIdentifier }),
},
relations: data?.includeRole ? ['globalRole'] : undefined,
});
}
export async function getAllUsersAndCount(data: {
includeRole?: boolean;
limit?: number;
offset?: number;
}): Promise<[User[], number]> {
const users = await Container.get(UserRepository).find({
where: {},
relations: data?.includeRole ? ['globalRole'] : undefined,
skip: data.offset,
take: data.limit,
});
const count = await Container.get(UserRepository).count();
return [users, count];
}
function pickUserSelectableProperties(user: User, options?: { includeRole: boolean }) {
return pick(
user,
getSelectableProperties('user').concat(options?.includeRole ? ['globalRole'] : []),
);
}
export function clean(user: User, options?: { includeRole: boolean }): Partial<User>;
export function clean(users: User[], options?: { includeRole: boolean }): Array<Partial<User>>;
export function clean(
users: User[] | User,
options?: { includeRole: boolean },
): Array<Partial<User>> | Partial<User> {
return Array.isArray(users)
? users.map((user) => pickUserSelectableProperties(user, options))
: pickUserSelectableProperties(users, options);
}

View file

@ -1,12 +0,0 @@
import { Container } from 'typedi';
import { RoleRepository } from '@db/repositories';
import type { Role } from '@db/entities/Role';
import type { User } from '@db/entities/User';
export function isInstanceOwner(user: User): boolean {
return user.globalRole.name === 'owner';
}
export async function getWorkflowOwnerRole(): Promise<Role> {
return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail();
}

View file

@ -11,7 +11,7 @@ import { addNodeIds, replaceInvalidCredentials } from '@/WorkflowHelpers';
import type { WorkflowRequest } from '../../../types';
import { authorize, validCursor } from '../../shared/middlewares/global.middleware';
import { encodeNextCursor } from '../../shared/services/pagination.service';
import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service';
import { getWorkflowOwnerRole, isInstanceOwner } from '../users/users.service.ee';
import {
getWorkflowById,
getSharedWorkflow,

View file

@ -8,7 +8,7 @@ import * as Db from '@/Db';
import type { User } from '@db/entities/User';
import { WorkflowEntity } from '@db/entities/WorkflowEntity';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { isInstanceOwner } from '../users/users.service';
import { isInstanceOwner } from '../users/users.service.ee';
import type { Role } from '@db/entities/Role';
import config from '@/config';
import { START_NODES } from '@/constants';

View file

@ -16,6 +16,8 @@ externalDocs:
servers:
- url: /api/v1
tags:
- name: User
description: Operations about users
- name: Audit
description: Operations about security audit
- name: Execution
@ -29,34 +31,38 @@ tags:
paths:
/audit:
$ref: "./handlers/audit/spec/paths/audit.yml"
$ref: './handlers/audit/spec/paths/audit.yml'
/credentials:
$ref: "./handlers/credentials/spec/paths/credentials.yml"
$ref: './handlers/credentials/spec/paths/credentials.yml'
/credentials/{id}:
$ref: "./handlers/credentials/spec/paths/credentials.id.yml"
$ref: './handlers/credentials/spec/paths/credentials.id.yml'
/credentials/schema/{credentialTypeName}:
$ref: "./handlers/credentials/spec/paths/credentials.schema.id.yml"
$ref: './handlers/credentials/spec/paths/credentials.schema.id.yml'
/executions:
$ref: "./handlers/executions/spec/paths/executions.yml"
$ref: './handlers/executions/spec/paths/executions.yml'
/executions/{id}:
$ref: "./handlers/executions/spec/paths/executions.id.yml"
$ref: './handlers/executions/spec/paths/executions.id.yml'
/workflows:
$ref: "./handlers/workflows/spec/paths/workflows.yml"
$ref: './handlers/workflows/spec/paths/workflows.yml'
/workflows/{id}:
$ref: "./handlers/workflows/spec/paths/workflows.id.yml"
$ref: './handlers/workflows/spec/paths/workflows.id.yml'
/workflows/{id}/activate:
$ref: "./handlers/workflows/spec/paths/workflows.id.activate.yml"
$ref: './handlers/workflows/spec/paths/workflows.id.activate.yml'
/workflows/{id}/deactivate:
$ref: "./handlers/workflows/spec/paths/workflows.id.deactivate.yml"
$ref: './handlers/workflows/spec/paths/workflows.id.deactivate.yml'
/users:
$ref: './handlers/users/spec/paths/users.yml'
/users/{id}:
$ref: './handlers/users/spec/paths/users.id.yml'
/source-control/pull:
$ref: "./handlers/sourceControl/spec/paths/sourceControl.yml"
components:
schemas:
$ref: "./shared/spec/schemas/_index.yml"
$ref: './shared/spec/schemas/_index.yml'
responses:
$ref: "./shared/spec/responses/_index.yml"
$ref: './shared/spec/responses/_index.yml'
parameters:
$ref: "./shared/spec/parameters/_index.yml"
$ref: './shared/spec/parameters/_index.yml'
securitySchemes:
ApiKeyAuth:
type: apiKey

View file

@ -1,9 +1,13 @@
/* eslint-disable @typescript-eslint/no-invalid-void-type */
import type express from 'express';
import { Container } from 'typedi';
import type { AuthenticatedRequest, PaginatedRequest } from '../../../types';
import { decodeCursor } from '../services/pagination.service';
import { License } from '@/License';
const UNLIMITED_USERS_QUOTA = -1;
export const authorize =
(authorizedRoles: readonly string[]) =>
@ -46,3 +50,18 @@ export const validCursor = (
return next();
};
export const validLicenseWithUserQuota = (
req: express.Request,
res: express.Response,
next: express.NextFunction,
): express.Response | void => {
const license = Container.get(License);
if (license.getUsersLimit() !== UNLIMITED_USERS_QUOTA) {
return res.status(403).json({
message: '/users path can only be used with a valid license. See https://n8n.io/pricing/',
});
}
return next();
};

View file

@ -8,3 +8,7 @@ WorkflowId:
$ref: '../../../handlers/workflows/spec/schemas/parameters/workflowId.yml'
IncludeData:
$ref: '../../../handlers/executions/spec/schemas/parameters/includeData.yml'
UserIdentifier:
$ref: '../../../handlers/users/spec/schemas/parameters/userIdentifier.yml'
IncludeRole:
$ref: '../../../handlers/users/spec/schemas/parameters/includeRole.yml'

View file

@ -1,26 +1,32 @@
Error:
$ref: "./error.yml"
$ref: './error.yml'
Role:
$ref: './../../../handlers/users/spec/schemas/role.yml'
Execution:
$ref: "./../../../handlers/executions/spec/schemas/execution.yml"
$ref: './../../../handlers/executions/spec/schemas/execution.yml'
Node:
$ref: "./../../../handlers/workflows/spec/schemas/node.yml"
$ref: './../../../handlers/workflows/spec/schemas/node.yml'
Tag:
$ref: "./../../../handlers/workflows/spec/schemas/tag.yml"
$ref: './../../../handlers/workflows/spec/schemas/tag.yml'
Workflow:
$ref: "./../../../handlers/workflows/spec/schemas/workflow.yml"
$ref: './../../../handlers/workflows/spec/schemas/workflow.yml'
WorkflowSettings:
$ref: "./../../../handlers/workflows/spec/schemas/workflowSettings.yml"
$ref: './../../../handlers/workflows/spec/schemas/workflowSettings.yml'
ExecutionList:
$ref: "./../../../handlers/executions/spec/schemas/executionList.yml"
$ref: './../../../handlers/executions/spec/schemas/executionList.yml'
WorkflowList:
$ref: "./../../../handlers/workflows/spec/schemas/workflowList.yml"
$ref: './../../../handlers/workflows/spec/schemas/workflowList.yml'
Credential:
$ref: "./../../../handlers/credentials/spec/schemas/credential.yml"
$ref: './../../../handlers/credentials/spec/schemas/credential.yml'
CredentialType:
$ref: "./../../../handlers/credentials/spec/schemas/credentialType.yml"
$ref: './../../../handlers/credentials/spec/schemas/credentialType.yml'
Audit:
$ref: "./../../../handlers/audit/spec/schemas/audit.yml"
$ref: './../../../handlers/audit/spec/schemas/audit.yml'
Pull:
$ref: "./../../../handlers/sourceControl/spec/schemas/pull.yml"
ImportResult:
$ref: "./../../../handlers/sourceControl/spec/schemas/importResult.yml"
UserList:
$ref: './../../../handlers/users/spec/schemas/userList.yml'
User:
$ref: './../../../handlers/users/spec/schemas/user.yml'

View file

@ -84,6 +84,7 @@ export const enum LICENSE_FEATURES {
export const enum LICENSE_QUOTAS {
TRIGGER_LIMIT = 'quota:activeWorkflows',
VARIABLES_LIMIT = 'quota:maxVariables',
USERS_LIMIT = 'quota:users',
}
export const CREDENTIAL_BLANKING_VALUE = '__n8n_BLANK_VALUE_e5362baf-c777-4d57-a609-6eaf1f9e87f6';

View file

@ -9,7 +9,7 @@ import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces';
import { LicenseService } from './License.service';
import { License } from '@/License';
import type { AuthenticatedRequest, LicenseRequest } from '@/requests';
import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service';
import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service.ee';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks';

View file

@ -0,0 +1,343 @@
import type express from 'express';
import validator from 'validator';
import { v4 as uuid } from 'uuid';
import config from '@/config';
import type { Role } from '@/databases/entities/Role';
import { randomApiKey } from '../shared/random';
import * as utils from '../shared/utils';
import * as testDb from '../shared/testDb';
import { License } from '@/License';
let app: express.Application;
let globalOwnerRole: Role;
let globalMemberRole: Role;
const licenseLike = {
getUsersLimit: jest.fn().mockReturnValue(-1),
};
utils.mockInstance(License, licenseLike);
beforeAll(async () => {
app = await utils.initTestServer({
endpointGroups: ['publicApi'],
applyAuth: false,
enablePublicAPI: true,
});
await testDb.init();
const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles();
globalOwnerRole = fetchedGlobalOwnerRole;
globalMemberRole = fetchedGlobalMemberRole;
});
beforeEach(async () => {
// do not combine calls - shared tables must be cleared first and separately
await testDb.truncate(['SharedCredentials', 'SharedWorkflow']);
await testDb.truncate(['User', 'Workflow', 'Credentials']);
config.set('userManagement.disabled', false);
config.set('userManagement.isInstanceOwnerSetUp', true);
config.set('userManagement.emails.mode', 'smtp');
});
afterAll(async () => {
await testDb.terminate();
});
describe('With license unlimited quota:users', () => {
test('GET /users should fail due to missing API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
await testDb.createUser();
const response = await authOwnerAgent.get('/users');
expect(response.statusCode).toBe(401);
});
test('GET /users should fail due to invalid API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
owner.apiKey = null;
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
const response = await authOwnerAgent.get('/users');
expect(response.statusCode).toBe(401);
});
test('GET /users should fail due to member trying to access owner only endpoint', async () => {
const member = await testDb.createUser({ apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: member,
});
const response = await authOwnerAgent.get('/users');
expect(response.statusCode).toBe(403);
});
test('GET /users should return all users', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
await testDb.createUser();
const response = await authOwnerAgent.get('/users');
expect(response.statusCode).toBe(200);
expect(response.body.data.length).toBe(2);
expect(response.body.nextCursor).toBeNull();
for (const user of response.body.data) {
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
globalRole,
password,
resetPasswordToken,
isPending,
createdAt,
updatedAt,
} = user;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeUndefined();
expect(createdAt).toBeDefined();
expect(updatedAt).toBeDefined();
}
});
test('GET /users/:identifier should fail due to missing API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
await testDb.createUser();
const response = await authOwnerAgent.get(`/users/${owner.id}`);
expect(response.statusCode).toBe(401);
});
test('GET /users/:identifier should fail due to invalid API Key', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
owner.apiKey = null;
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
const response = await authOwnerAgent.get(`/users/${owner.id}`);
expect(response.statusCode).toBe(401);
});
test('GET /users/:identifier should fail due to member trying to access owner only endpoint', async () => {
const member = await testDb.createUser({ apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: member,
});
const response = await authOwnerAgent.get(`/users/${member.id}`);
expect(response.statusCode).toBe(403);
});
test('GET /users/:email with non-existing email should return 404', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
const response = await authOwnerAgent.get('/users/jhondoe@gmail.com');
expect(response.statusCode).toBe(404);
});
test('GET /users/:id with non-existing id should return 404', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
const response = await authOwnerAgent.get(`/users/${uuid()}`);
expect(response.statusCode).toBe(404);
});
test('GET /users/:email should return a user', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
const response = await authOwnerAgent.get(`/users/${owner.email}`);
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
globalRole,
password,
resetPasswordToken,
isPending,
createdAt,
updatedAt,
} = response.body;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(isPending).toBe(false);
expect(globalRole).toBeUndefined();
expect(createdAt).toBeDefined();
expect(updatedAt).toBeDefined();
});
test('GET /users/:id should return a pending user', async () => {
const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() });
const { id: memberId } = await testDb.createUserShell(globalMemberRole);
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: owner,
});
const response = await authOwnerAgent.get(`/users/${memberId}`);
expect(response.statusCode).toBe(200);
const {
id,
email,
firstName,
lastName,
personalizationAnswers,
globalRole,
password,
resetPasswordToken,
isPending,
createdAt,
updatedAt,
} = response.body;
expect(validator.isUUID(id)).toBe(true);
expect(email).toBeDefined();
expect(firstName).toBeDefined();
expect(lastName).toBeDefined();
expect(personalizationAnswers).toBeUndefined();
expect(password).toBeUndefined();
expect(resetPasswordToken).toBeUndefined();
expect(globalRole).toBeUndefined();
expect(createdAt).toBeDefined();
expect(isPending).toBeDefined();
expect(isPending).toBeTruthy();
expect(updatedAt).toBeDefined();
});
});
describe('With license without quota:users', () => {
beforeEach(async () => {
utils.mockInstance(License, { getUsersLimit: jest.fn().mockReturnValue(null) });
});
test('GET /users should fail due to invalid license', async () => {
const member = await testDb.createUser({ apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: member,
});
const response = await authOwnerAgent.get('/users');
expect(response.statusCode).toBe(403);
});
test('GET /users/:id should fail due to invalid license', async () => {
const member = await testDb.createUser({ apiKey: randomApiKey() });
const authOwnerAgent = utils.createAgent(app, {
apiPath: 'public',
version: 1,
auth: true,
user: member,
});
const response = await authOwnerAgent.get(`/users/${member.id}`);
expect(response.statusCode).toBe(403);
});
});