diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 7921136561..3452bc75ca 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -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; } diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml new file mode 100644 index 0000000000..0d3c86c4ce --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.id.yml @@ -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' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml new file mode 100644 index 0000000000..1d61843553 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/paths/users.yml @@ -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' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/parameters/includeRole.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/parameters/includeRole.yml new file mode 100644 index 0000000000..5c560b8f67 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/parameters/includeRole.yml @@ -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 diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/parameters/userIdentifier.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/parameters/userIdentifier.yml new file mode 100644 index 0000000000..2292813a13 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/parameters/userIdentifier.yml @@ -0,0 +1,7 @@ +name: id +in: path +description: The ID or email of the user. +required: true +schema: + type: string + format: identifier diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/role.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/role.yml new file mode 100644 index 0000000000..4bd1faaa81 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/role.yml @@ -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 diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/user.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/user.yml new file mode 100644 index 0000000000..e0403962ae --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/user.yml @@ -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' diff --git a/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/userList.yml b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/userList.yml new file mode 100644 index 0000000000..212b26d64a --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/spec/schemas/userList.yml @@ -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 diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts new file mode 100644 index 0000000000..ee39dc8105 --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.handler.ee.ts @@ -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, + }), + }); + }, + ], +}; diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts new file mode 100644 index 0000000000..0d1536cffa --- /dev/null +++ b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ee.ts @@ -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 { + 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 { + 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; +export function clean(users: User[], options?: { includeRole: boolean }): Array>; + +export function clean( + users: User[] | User, + options?: { includeRole: boolean }, +): Array> | Partial { + return Array.isArray(users) + ? users.map((user) => pickUserSelectableProperties(user, options)) + : pickUserSelectableProperties(users, options); +} diff --git a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts b/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts deleted file mode 100644 index 064d0be471..0000000000 --- a/packages/cli/src/PublicApi/v1/handlers/users/users.service.ts +++ /dev/null @@ -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 { - return Container.get(RoleRepository).findWorkflowOwnerRoleOrFail(); -} diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 2049bde475..ee4cd38b9c 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -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, diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index cc55ba547b..cfcedcbad4 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -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'; diff --git a/packages/cli/src/PublicApi/v1/openapi.yml b/packages/cli/src/PublicApi/v1/openapi.yml index b37e9f1315..a8b46cb7fb 100644 --- a/packages/cli/src/PublicApi/v1/openapi.yml +++ b/packages/cli/src/PublicApi/v1/openapi.yml @@ -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 diff --git a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts index 1fe122e8f6..d57aa3071b 100644 --- a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts @@ -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(); +}; diff --git a/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml index b994cdc872..b05b3cb5d5 100644 --- a/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml +++ b/packages/cli/src/PublicApi/v1/shared/spec/parameters/_index.yml @@ -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' diff --git a/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml index 438fb1b59a..d98ad77f17 100644 --- a/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml +++ b/packages/cli/src/PublicApi/v1/shared/spec/schemas/_index.yml @@ -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' diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 821a788edc..ec00862f0c 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -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'; diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 7fc24df188..04c98cb2bb 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -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'; diff --git a/packages/cli/test/integration/publicApi/users.ee.test.ts b/packages/cli/test/integration/publicApi/users.ee.test.ts new file mode 100644 index 0000000000..069cf90e21 --- /dev/null +++ b/packages/cli/test/integration/publicApi/users.ee.test.ts @@ -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); + }); +});