diff --git a/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda.ts b/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda.ts index 022efd1ee..da1e541af 100644 --- a/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda.ts +++ b/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda.ts @@ -55,7 +55,7 @@ export const handler: Handler = async ( } }; -const listLaboratoryUsers = ( +export const listLaboratoryUsers = ( organizationId?: string, laboratoryId?: string, userId?: string, diff --git a/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda.ts b/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda.ts index 39abb4812..8a061b838 100644 --- a/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda.ts +++ b/packages/back-end/src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda.ts @@ -29,7 +29,7 @@ export const handler: Handler = async ( } }; -const listLaboratoryUsers = ( +export const listLaboratoryUsers = ( organizationId?: string, laboratoryId?: string, userId?: string, diff --git a/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/add-laboratory-user.lambda.test.ts b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/add-laboratory-user.lambda.test.ts new file mode 100644 index 000000000..8ec559670 --- /dev/null +++ b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/add-laboratory-user.lambda.test.ts @@ -0,0 +1,189 @@ +import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda'; +import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; + +import { handler } from '../../../../../../src/app/controllers/easy-genomics/laboratory/user/add-laboratory-user.lambda'; + +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/organization-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/platform-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/user-service'); +jest.mock('../../../../../../src/app/utils/auth-utils'); + +import { LaboratoryService } from '../../../../../../src/app/services/easy-genomics/laboratory-service'; +import { LaboratoryUserService } from '../../../../../../src/app/services/easy-genomics/laboratory-user-service'; +import { OrganizationUserService } from '../../../../../../src/app/services/easy-genomics/organization-user-service'; +import { PlatformUserService } from '../../../../../../src/app/services/easy-genomics/platform-user-service'; +import { UserService } from '../../../../../../src/app/services/easy-genomics/user-service'; +import { + validateLaboratoryManagerAccess, + validateOrganizationAdminAccess, +} from '../../../../../../src/app/utils/auth-utils'; + +describe('add-laboratory-user.lambda', () => { + let mockLabService: jest.MockedClass; + let mockLabUserService: jest.MockedClass; + let mockOrgUserService: jest.MockedClass; + let mockPlatformUserService: jest.MockedClass; + let mockUserService: jest.MockedClass; + let mockValidateOrgAdmin: jest.MockedFunction; + let mockValidateLabManager: jest.MockedFunction; + + const createEvent = (body: any, overrides: Partial = {}) => + ({ + body: JSON.stringify(body), + isBase64Encoded: false, + httpMethod: 'POST', + path: '/laboratory/user/add', + headers: {}, + requestContext: { + authorizer: { + claims: { + email: 'admin@example.com', + 'cognito:username': 'admin-user', + }, + }, + }, + resource: '', + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + multiValueHeaders: {}, + ...overrides, + }) as any; + + const createContext = (): Context => + ({ + functionName: 'add-laboratory-user', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:region:acct:function:add-laboratory-user', + memoryLimitInMB: '128', + awsRequestId: 'req-id', + logGroupName: '/aws/lambda/add-laboratory-user', + logStreamName: '2026/03/11/[$LATEST]test', + identity: undefined, + clientContext: undefined, + callbackWaitsForEmptyEventLoop: true, + getRemainingTimeInMillis: () => 30000, + done: jest.fn(), + fail: jest.fn(), + succeed: jest.fn(), + }) as any; + + const baseRequest = { + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + UserId: 'user-1', + LabManager: true, + LabTechnician: false, + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockLabService = LaboratoryService as jest.MockedClass; + mockLabUserService = LaboratoryUserService as jest.MockedClass; + mockOrgUserService = OrganizationUserService as jest.MockedClass; + mockPlatformUserService = PlatformUserService as jest.MockedClass; + mockUserService = UserService as jest.MockedClass; + mockValidateOrgAdmin = validateOrganizationAdminAccess as any; + mockValidateLabManager = validateLaboratoryManagerAccess as any; + + mockValidateOrgAdmin.mockReturnValue(true); + mockValidateLabManager.mockReturnValue(false); + }); + + it('adds existing user to laboratory when caller is org admin and mapping does not exist', async () => { + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + (mockOrgUserService.prototype.get as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + UserId: 'user-1', + }); + (mockLabUserService.prototype.get as jest.Mock).mockRejectedValueOnce( + // simulate LaboratoryUserNotFoundError, which should be swallowed + new (class extends Error {})(), + ); + (mockPlatformUserService.prototype.addExistingUserToLaboratory as jest.Mock).mockResolvedValue(true); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.Status).toBe('Success'); + expect(mockPlatformUserService.prototype.addExistingUserToLaboratory).toHaveBeenCalled(); + }); + + it('rejects invalid request body', async () => { + const result = await handler(createEvent({}), createContext(), () => {}); + + expect(result.statusCode).toBe(400); + expect(mockPlatformUserService.prototype.addExistingUserToLaboratory).not.toHaveBeenCalled(); + }); + + it('denies access when caller is neither org admin nor lab manager', async () => { + mockValidateOrgAdmin.mockReturnValue(false); + mockValidateLabManager.mockReturnValue(false); + + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(403); + }); + + it('returns error when user already has laboratory access mapping', async () => { + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + (mockOrgUserService.prototype.get as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + UserId: 'user-1', + }); + (mockLabUserService.prototype.get as jest.Mock).mockResolvedValue({ + LaboratoryId: 'lab-1', + UserId: 'user-1', + }); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(409); + }); + + it('maps ConditionalCheckFailedException from platformUserService to LaboratoryUserAlreadyExistsError', async () => { + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + (mockOrgUserService.prototype.get as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + UserId: 'user-1', + }); + (mockLabUserService.prototype.get as jest.Mock).mockRejectedValueOnce(new (class extends Error {})()); + (mockPlatformUserService.prototype.addExistingUserToLaboratory as jest.Mock).mockRejectedValue( + new ConditionalCheckFailedException({}), + ); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(409); + }); +}); diff --git a/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/edit-laboratory-user.lambda.test.ts b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/edit-laboratory-user.lambda.test.ts new file mode 100644 index 000000000..a0775289d --- /dev/null +++ b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/edit-laboratory-user.lambda.test.ts @@ -0,0 +1,142 @@ +import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda'; + +import { handler } from '../../../../../../src/app/controllers/easy-genomics/laboratory/user/edit-laboratory-user.lambda'; + +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/platform-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/user-service'); +jest.mock('../../../../../../src/app/utils/auth-utils'); + +import { LaboratoryService } from '../../../../../../src/app/services/easy-genomics/laboratory-service'; +import { LaboratoryUserService } from '../../../../../../src/app/services/easy-genomics/laboratory-user-service'; +import { PlatformUserService } from '../../../../../../src/app/services/easy-genomics/platform-user-service'; +import { UserService } from '../../../../../../src/app/services/easy-genomics/user-service'; +import { + validateLaboratoryManagerAccess, + validateOrganizationAdminAccess, +} from '../../../../../../src/app/utils/auth-utils'; + +describe('edit-laboratory-user.lambda', () => { + let mockLabService: jest.MockedClass; + let mockLabUserService: jest.MockedClass; + let mockPlatformUserService: jest.MockedClass; + let mockUserService: jest.MockedClass; + let mockValidateOrgAdmin: jest.MockedFunction; + let mockValidateLabManager: jest.MockedFunction; + + const createEvent = (body: any, overrides: Partial = {}) => + ({ + body: JSON.stringify(body), + isBase64Encoded: false, + httpMethod: 'PUT', + path: '/laboratory/user/edit', + headers: {}, + requestContext: { + authorizer: { + claims: { + email: 'admin@example.com', + 'cognito:username': 'admin-user', + }, + }, + }, + resource: '', + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + multiValueHeaders: {}, + ...overrides, + }) as any; + + const createContext = (): Context => + ({ + functionName: 'edit-laboratory-user', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:region:acct:function:edit-laboratory-user', + memoryLimitInMB: '128', + awsRequestId: 'req-id', + logGroupName: '/aws/lambda/edit-laboratory-user', + logStreamName: '2026/03/11/[$LATEST]test', + identity: undefined, + clientContext: undefined, + callbackWaitsForEmptyEventLoop: true, + getRemainingTimeInMillis: () => 30000, + done: jest.fn(), + fail: jest.fn(), + succeed: jest.fn(), + }) as any; + + const baseRequest = { + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + UserId: 'user-1', + LabManager: false, + LabTechnician: true, + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockLabService = LaboratoryService as jest.MockedClass; + mockLabUserService = LaboratoryUserService as jest.MockedClass; + mockPlatformUserService = PlatformUserService as jest.MockedClass; + mockUserService = UserService as jest.MockedClass; + mockValidateOrgAdmin = validateOrganizationAdminAccess as any; + mockValidateLabManager = validateLaboratoryManagerAccess as any; + + mockValidateOrgAdmin.mockReturnValue(true); + mockValidateLabManager.mockReturnValue(false); + }); + + it('edits existing laboratory user mapping when caller has access', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockResolvedValue({ + LaboratoryId: 'lab-1', + UserId: 'user-1', + LabManager: true, + LabTechnician: false, + }); + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + (mockPlatformUserService.prototype.editExistingUserAccessToLaboratory as jest.Mock).mockResolvedValue(true); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.Status).toBe('Success'); + expect(mockPlatformUserService.prototype.editExistingUserAccessToLaboratory).toHaveBeenCalled(); + }); + + it('rejects invalid request body', async () => { + const result = await handler(createEvent({}), createContext(), () => {}); + + expect(result.statusCode).toBe(400); + expect(mockPlatformUserService.prototype.editExistingUserAccessToLaboratory).not.toHaveBeenCalled(); + }); + + it('denies access when caller is neither org admin nor lab manager', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockResolvedValue({ + LaboratoryId: 'lab-1', + UserId: 'user-1', + }); + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + + mockValidateOrgAdmin.mockReturnValue(false); + mockValidateLabManager.mockReturnValue(false); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(403); + }); +}); diff --git a/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda.test.ts b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda.test.ts new file mode 100644 index 000000000..51372c253 --- /dev/null +++ b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda.test.ts @@ -0,0 +1,182 @@ +import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda'; + +import { + handler, + listLaboratoryUsers, +} from '../../../../../../src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users-details.lambda'; + +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/user-service'); + +import { LaboratoryUserService } from '../../../../../../src/app/services/easy-genomics/laboratory-user-service'; +import { UserService } from '../../../../../../src/app/services/easy-genomics/user-service'; + +describe('list-laboratory-users-details.lambda', () => { + let mockLabUserService: jest.MockedClass; + let mockUserService: jest.MockedClass; + + const createEvent = ( + query: Record, + overrides: Partial = {}, + ) => + ({ + body: null, + isBase64Encoded: false, + httpMethod: 'GET', + path: '/laboratory/user/list-details', + headers: {}, + requestContext: { + authorizer: { + claims: { + email: 'admin@example.com', + }, + }, + }, + resource: '', + queryStringParameters: query as any, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + multiValueHeaders: {}, + ...overrides, + }) as any; + + const createContext = (): Context => + ({ + functionName: 'list-laboratory-users-details', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:region:acct:function:list-laboratory-users-details', + memoryLimitInMB: '128', + awsRequestId: 'req-id', + logGroupName: '/aws/lambda/list-laboratory-users-details', + logStreamName: '2026/03/11/[$LATEST]test', + identity: undefined, + clientContext: undefined, + callbackWaitsForEmptyEventLoop: true, + getRemainingTimeInMillis: () => 30000, + done: jest.fn(), + fail: jest.fn(), + succeed: jest.fn(), + }) as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockLabUserService = LaboratoryUserService as jest.MockedClass; + mockUserService = UserService as jest.MockedClass; + }); + + it('returns empty list when no laboratory users are found', async () => { + (mockLabUserService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue([]); + + const result = await handler(createEvent({ laboratoryId: 'lab-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toEqual([]); + }); + + it('joins laboratory users with user details for organizationId query', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1', LabManager: true, LabTechnician: false }, + ]); + (mockUserService.prototype.listUsers as jest.Mock).mockResolvedValue([ + { + UserId: 'user-1', + Email: 'user@example.com', + PreferredName: 'P', + FirstName: 'First', + LastName: 'Last', + }, + ]); + + const result = await handler(createEvent({ organizationId: 'org-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toHaveLength(1); + expect(body[0].UserEmail).toBe('user@example.com'); + expect(body[0].LabManager).toBe(true); + }); + + it('ignores laboratory users without matching user records', async () => { + (mockLabUserService.prototype.queryByUserId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1', LabManager: true, LabTechnician: false }, + ]); + (mockUserService.prototype.listUsers as jest.Mock).mockResolvedValue([]); + + const result = await handler(createEvent({ userId: 'user-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toEqual([]); + }); + + it('returns 400 when query combination is invalid', async () => { + const result = await handler( + createEvent({ organizationId: 'org-1', laboratoryId: 'lab-1' }), + createContext(), + () => {}, + ); + + expect(result.statusCode).toBe(400); + }); + + describe('listLaboratoryUsers helper', () => { + it('queries by organizationId only', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const res = await listLaboratoryUsers('org-1', undefined, undefined); + expect(res).toHaveLength(1); + expect(mockLabUserService.prototype.queryByOrganizationId).toHaveBeenCalledWith('org-1'); + }); + + it('queries by laboratoryId only', async () => { + (mockLabUserService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const res = await listLaboratoryUsers(undefined, 'lab-1', undefined); + expect(res).toHaveLength(1); + expect(mockLabUserService.prototype.queryByLaboratoryId).toHaveBeenCalledWith('lab-1'); + }); + + it('queries by userId only', async () => { + (mockLabUserService.prototype.queryByUserId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const res = await listLaboratoryUsers(undefined, undefined, 'user-1'); + expect(res).toHaveLength(1); + expect(mockLabUserService.prototype.queryByUserId).toHaveBeenCalledWith('user-1'); + }); + + it('throws InvalidRequestError for invalid combinations', async () => { + await expect(listLaboratoryUsers(undefined, undefined, undefined)).rejects.toThrow(); + await expect(listLaboratoryUsers('org-1', 'lab-1', undefined)).rejects.toThrow(); + await expect(listLaboratoryUsers('org-1', undefined, 'user-1')).rejects.toThrow(); + }); + }); + + describe('handler failure cases', () => { + it('returns 500 when laboratory user query fails', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockRejectedValue(new Error('db failure')); + + const result = await handler(createEvent({ organizationId: 'org-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(500); + }); + + it('returns 500 when userService.listUsers fails', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1', LabManager: true, LabTechnician: false }, + ]); + (mockUserService.prototype.listUsers as jest.Mock).mockRejectedValue(new Error('user lookup failure')); + + const result = await handler(createEvent({ organizationId: 'org-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(500); + }); + }); +}); diff --git a/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda.test.ts b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda.test.ts new file mode 100644 index 000000000..adf6def2d --- /dev/null +++ b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda.test.ts @@ -0,0 +1,168 @@ +import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda'; + +import { + handler, + listLaboratoryUsers, +} from '../../../../../../src/app/controllers/easy-genomics/laboratory/user/list-laboratory-users.lambda'; + +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-user-service'); + +import { LaboratoryUserService } from '../../../../../../src/app/services/easy-genomics/laboratory-user-service'; + +describe('list-laboratory-users.lambda', () => { + let mockLabUserService: jest.MockedClass; + + const createEvent = ( + query: Record, + overrides: Partial = {}, + ) => + ({ + body: null, + isBase64Encoded: false, + httpMethod: 'GET', + path: '/laboratory/user/list', + headers: {}, + requestContext: { + authorizer: { + claims: { + email: 'admin@example.com', + }, + }, + }, + resource: '', + queryStringParameters: query as any, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + multiValueHeaders: {}, + ...overrides, + }) as any; + + const createContext = (): Context => + ({ + functionName: 'list-laboratory-users', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:region:acct:function:list-laboratory-users', + memoryLimitInMB: '128', + awsRequestId: 'req-id', + logGroupName: '/aws/lambda/list-laboratory-users', + logStreamName: '2026/03/11/[$LATEST]test', + identity: undefined, + clientContext: undefined, + callbackWaitsForEmptyEventLoop: true, + getRemainingTimeInMillis: () => 30000, + done: jest.fn(), + fail: jest.fn(), + succeed: jest.fn(), + }) as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockLabUserService = LaboratoryUserService as jest.MockedClass; + }); + + it('lists users by organizationId', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const result = await handler(createEvent({ organizationId: 'org-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toHaveLength(1); + expect(mockLabUserService.prototype.queryByOrganizationId).toHaveBeenCalledWith('org-1'); + }); + + it('lists users by laboratoryId', async () => { + (mockLabUserService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const result = await handler(createEvent({ laboratoryId: 'lab-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toHaveLength(1); + expect(mockLabUserService.prototype.queryByLaboratoryId).toHaveBeenCalledWith('lab-1'); + }); + + it('lists users by userId', async () => { + (mockLabUserService.prototype.queryByUserId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const result = await handler(createEvent({ userId: 'user-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toHaveLength(1); + expect(mockLabUserService.prototype.queryByUserId).toHaveBeenCalledWith('user-1'); + }); + + it('returns 400 when query combination is invalid', async () => { + const result = await handler( + createEvent({ organizationId: 'org-1', laboratoryId: 'lab-1' }), + createContext(), + () => {}, + ); + + expect(result.statusCode).toBe(400); + }); + + describe('listLaboratoryUsers helper', () => { + it('queries by organizationId only', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const res = await listLaboratoryUsers('org-1', undefined, undefined); + expect(res).toHaveLength(1); + expect(mockLabUserService.prototype.queryByOrganizationId).toHaveBeenCalledWith('org-1'); + }); + + it('queries by laboratoryId only', async () => { + (mockLabUserService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const res = await listLaboratoryUsers(undefined, 'lab-1', undefined); + expect(res).toHaveLength(1); + expect(mockLabUserService.prototype.queryByLaboratoryId).toHaveBeenCalledWith('lab-1'); + }); + + it('queries by userId only', async () => { + (mockLabUserService.prototype.queryByUserId as jest.Mock).mockResolvedValue([ + { OrganizationId: 'org-1', LaboratoryId: 'lab-1', UserId: 'user-1' }, + ]); + + const res = await listLaboratoryUsers(undefined, undefined, 'user-1'); + expect(res).toHaveLength(1); + expect(mockLabUserService.prototype.queryByUserId).toHaveBeenCalledWith('user-1'); + }); + + it('throws InvalidRequestError for invalid argument combinations', async () => { + await expect(listLaboratoryUsers(undefined, undefined, undefined)).rejects.toThrow(); + await expect(listLaboratoryUsers('org-1', 'lab-1', undefined)).rejects.toThrow(); + await expect(listLaboratoryUsers('org-1', undefined, 'user-1')).rejects.toThrow(); + }); + }); + + describe('handler failure cases', () => { + it('returns 500 when underlying query throws unexpected error', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockRejectedValue(new Error('db failure')); + + const result = await handler(createEvent({ organizationId: 'org-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(500); + }); + + it('returns 500 when listLaboratoryUsers returns undefined', async () => { + (mockLabUserService.prototype.queryByOrganizationId as jest.Mock).mockResolvedValue(undefined as any); + + const result = await handler(createEvent({ organizationId: 'org-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(500); + }); + }); +}); diff --git a/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/remove-laboratory-user.lambda.test.ts b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/remove-laboratory-user.lambda.test.ts new file mode 100644 index 000000000..ae3eeedb1 --- /dev/null +++ b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/remove-laboratory-user.lambda.test.ts @@ -0,0 +1,160 @@ +import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda'; +import { LaboratoryUserNotFoundError } from '@easy-genomics/shared-lib/src/app/utils/HttpError'; + +import { handler } from '../../../../../../src/app/controllers/easy-genomics/laboratory/user/remove-laboratory-user.lambda'; + +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/platform-user-service'); +jest.mock('../../../../../../src/app/services/easy-genomics/user-service'); +jest.mock('../../../../../../src/app/utils/auth-utils'); + +import { LaboratoryService } from '../../../../../../src/app/services/easy-genomics/laboratory-service'; +import { LaboratoryUserService } from '../../../../../../src/app/services/easy-genomics/laboratory-user-service'; +import { PlatformUserService } from '../../../../../../src/app/services/easy-genomics/platform-user-service'; +import { UserService } from '../../../../../../src/app/services/easy-genomics/user-service'; +import { + validateLaboratoryManagerAccess, + validateOrganizationAdminAccess, +} from '../../../../../../src/app/utils/auth-utils'; + +describe('remove-laboratory-user.lambda', () => { + let mockLabService: jest.MockedClass; + let mockLabUserService: jest.MockedClass; + let mockPlatformUserService: jest.MockedClass; + let mockUserService: jest.MockedClass; + let mockValidateOrgAdmin: jest.MockedFunction; + let mockValidateLabManager: jest.MockedFunction; + + const createEvent = (body: any, overrides: Partial = {}) => + ({ + body: JSON.stringify(body), + isBase64Encoded: false, + httpMethod: 'POST', + path: '/laboratory/user/remove', + headers: {}, + requestContext: { + authorizer: { + claims: { + email: 'admin@example.com', + 'cognito:username': 'admin-user', + }, + }, + }, + resource: '', + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + multiValueHeaders: {}, + ...overrides, + }) as any; + + const createContext = (): Context => + ({ + functionName: 'remove-laboratory-user', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:region:acct:function:remove-laboratory-user', + memoryLimitInMB: '128', + awsRequestId: 'req-id', + logGroupName: '/aws/lambda/remove-laboratory-user', + logStreamName: '2026/03/11/[$LATEST]test', + identity: undefined, + clientContext: undefined, + callbackWaitsForEmptyEventLoop: true, + getRemainingTimeInMillis: () => 30000, + done: jest.fn(), + fail: jest.fn(), + succeed: jest.fn(), + }) as any; + + const baseRequest = { + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + UserId: 'user-1', + LabManager: false, + LabTechnician: true, + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockLabService = LaboratoryService as jest.MockedClass; + mockLabUserService = LaboratoryUserService as jest.MockedClass; + mockPlatformUserService = PlatformUserService as jest.MockedClass; + mockUserService = UserService as jest.MockedClass; + mockValidateOrgAdmin = validateOrganizationAdminAccess as any; + mockValidateLabManager = validateLaboratoryManagerAccess as any; + + mockValidateOrgAdmin.mockReturnValue(true); + mockValidateLabManager.mockReturnValue(false); + }); + + it('removes existing laboratory user mapping when caller has access', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockResolvedValue({ + LaboratoryId: 'lab-1', + UserId: 'user-1', + LabManager: false, + LabTechnician: true, + }); + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + (mockPlatformUserService.prototype.removeExistingUserFromLaboratory as jest.Mock).mockResolvedValue(true); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.Status).toBe('Success'); + expect(mockPlatformUserService.prototype.removeExistingUserFromLaboratory).toHaveBeenCalled(); + }); + + it('rejects invalid request body', async () => { + const result = await handler(createEvent({}), createContext(), () => {}); + + expect(result.statusCode).toBe(400); + expect(mockPlatformUserService.prototype.removeExistingUserFromLaboratory).not.toHaveBeenCalled(); + }); + + it('denies access when caller is neither org admin nor lab manager', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockResolvedValue({ + LaboratoryId: 'lab-1', + UserId: 'user-1', + }); + (mockLabService.prototype.queryByLaboratoryId as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + }); + (mockUserService.prototype.get as jest.Mock).mockResolvedValue({ + UserId: 'user-1', + }); + + mockValidateOrgAdmin.mockReturnValue(false); + mockValidateLabManager.mockReturnValue(false); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(403); + }); + + it('returns non-200 when body cannot be parsed as JSON', async () => { + const badEvent = createEvent(baseRequest); + (badEvent as any).body = '{invalid-json'; + + const result = await handler(badEvent, createContext(), () => {}); + + expect(result.statusCode).not.toBe(200); + }); + + it('returns 500 when unexpected error occurs in downstream service', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockRejectedValue(new Error('downstream failure')); + + const result = await handler(createEvent(baseRequest), createContext(), () => {}); + + expect(result.statusCode).toBe(500); + }); +}); diff --git a/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/request-laboratory-user.lambda.test.ts b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/request-laboratory-user.lambda.test.ts new file mode 100644 index 000000000..e951bf8de --- /dev/null +++ b/packages/back-end/test/app/controllers/easy-genomics/laboratory/user/request-laboratory-user.lambda.test.ts @@ -0,0 +1,110 @@ +import { APIGatewayProxyWithCognitoAuthorizerEvent, Context } from 'aws-lambda'; +import { LaboratoryUserNotFoundError } from '@easy-genomics/shared-lib/src/app/utils/HttpError'; + +import { handler } from '../../../../../../src/app/controllers/easy-genomics/laboratory/user/request-laboratory-user.lambda'; + +jest.mock('../../../../../../src/app/services/easy-genomics/laboratory-user-service'); + +import { LaboratoryUserService } from '../../../../../../src/app/services/easy-genomics/laboratory-user-service'; + +describe('request-laboratory-user.lambda', () => { + let mockLabUserService: jest.MockedClass; + + const createEvent = (body: any, overrides: Partial = {}) => + ({ + body: JSON.stringify(body), + isBase64Encoded: false, + httpMethod: 'POST', + path: '/laboratory/user/request', + headers: {}, + requestContext: { + authorizer: { + claims: { + email: 'admin@example.com', + }, + }, + }, + resource: '', + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + multiValueHeaders: {}, + ...overrides, + }) as any; + + const createContext = (): Context => + ({ + functionName: 'request-laboratory-user', + functionVersion: '$LATEST', + invokedFunctionArn: 'arn:aws:lambda:region:acct:function:request-laboratory-user', + memoryLimitInMB: '128', + awsRequestId: 'req-id', + logGroupName: '/aws/lambda/request-laboratory-user', + logStreamName: '2026/03/11/[$LATEST]test', + identity: undefined, + clientContext: undefined, + callbackWaitsForEmptyEventLoop: true, + getRemainingTimeInMillis: () => 30000, + done: jest.fn(), + fail: jest.fn(), + succeed: jest.fn(), + }) as any; + + beforeEach(() => { + jest.clearAllMocks(); + mockLabUserService = LaboratoryUserService as jest.MockedClass; + }); + + it('returns laboratory user when request is valid', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockResolvedValue({ + OrganizationId: 'org-1', + LaboratoryId: 'lab-1', + UserId: 'user-1', + }); + + const result = await handler(createEvent({ LaboratoryId: 'lab-1', UserId: 'user-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body.UserId).toBe('user-1'); + }); + + it('rejects invalid request body', async () => { + const result = await handler(createEvent({}), createContext(), () => {}); + + expect(result.statusCode).toBe(400); + expect(mockLabUserService.prototype.get).not.toHaveBeenCalled(); + }); + + it('returns 404 when laboratory user is not found', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockRejectedValue( + new LaboratoryUserNotFoundError('User not found'), + ); + + const result = await handler( + createEvent({ LaboratoryId: 'lab-1', UserId: 'missing-user' }), + createContext(), + () => {}, + ); + + expect(result.statusCode).toBe(404); + }); + + it('returns non-200 when body cannot be parsed as JSON', async () => { + const badEvent = createEvent({} as any); + (badEvent as any).body = '{invalid-json'; + + const result = await handler(badEvent, createContext(), () => {}); + + expect(result.statusCode).not.toBe(200); + }); + + it('returns 500 when an unexpected error occurs in service', async () => { + (mockLabUserService.prototype.get as jest.Mock).mockRejectedValue(new Error('unexpected failure')); + + const result = await handler(createEvent({ LaboratoryId: 'lab-1', UserId: 'user-1' }), createContext(), () => {}); + + expect(result.statusCode).toBe(500); + }); +});