diff --git a/backend/package-lock.json b/backend/package-lock.json index 9c25102..4a48a51 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "@types/aws-serverless-express": "^3.3.10", "@types/cookie-parser": "^1.4.10", "@types/jest": "^30.0.0", + "@types/multer": "^2.0.0", "@types/node": "^25.0.9", "aws-sdk": "^2.1693.0", "esbuild": "^0.27.2", @@ -205,6 +206,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2304,6 +2306,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz", "integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2336,6 +2339,7 @@ "integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2419,6 +2423,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -3192,6 +3197,7 @@ "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -3286,6 +3292,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", + "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "25.0.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.9.tgz", @@ -4000,6 +4016,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4026,6 +4043,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4492,6 +4510,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4712,6 +4731,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4759,13 +4779,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6549,6 +6571,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -8131,6 +8154,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -8522,7 +8546,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-directory": { "version": "2.1.1", @@ -8727,6 +8752,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9759,6 +9785,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9995,6 +10022,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10218,6 +10246,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/backend/package.json b/backend/package.json index c33d7fc..2e99e47 100644 --- a/backend/package.json +++ b/backend/package.json @@ -39,6 +39,7 @@ "@types/aws-serverless-express": "^3.3.10", "@types/cookie-parser": "^1.4.10", "@types/jest": "^30.0.0", + "@types/multer": "^2.0.0", "@types/node": "^25.0.9", "aws-sdk": "^2.1693.0", "esbuild": "^0.27.2", diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 10841e3..7eca97a 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -1,478 +1,477 @@ -import { Test, TestingModule } from "@nestjs/testing"; -import { AuthService } from "../auth.service"; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthService } from '../auth.service'; +import { User } from '../../../../middle-layer/types/User'; +import { UserStatus } from '../../../../middle-layer/types/UserStatus'; import { - Logger, - UnauthorizedException, + BadRequestException, + ConflictException, InternalServerErrorException, -} from "@nestjs/common"; -import { describe, it, expect, beforeEach, vi, beforeAll } from "vitest"; - -vi.mock('../../guards/auth.guard', () => ({ - VerifyUserGuard: vi.fn(class MockVerifyUserGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), - VerifyAdminRoleGuard: vi.fn(class MockVerifyAdminRoleGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), -})); - -const mockAdminCreateUser = vi.fn(); -const mockAdminSetUserPassword = vi.fn(); -const mockInitiateAuth = vi.fn(); -const mockGetUser = vi.fn(); -const mockRespondToAuthChallenge = vi.fn(); -const mockAdminAddUserToGroup = vi.fn(); -const mockAdminDeleteUser = vi.fn(); -const mockCognitoPromise = vi.fn(); + UnauthorizedException, +} from '@nestjs/common'; +import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; -const mockDynamoGet = vi.fn(); -const mockDynamoPut = vi.fn(); -const mockDynamoUpdate = vi.fn(); -const mockDynamoScan = vi.fn(); +// ─── Cognito mocks ──────────────────────────────────────────────────────────── +const mockCognitoPromise = vi.fn(); +const mockAdminCreateUser = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminSetUserPassword = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminAddUserToGroup = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminRemoveUserFromGroup = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockAdminDeleteUser = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockInitiateAuth = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockGetUser = vi.fn(() => ({ promise: mockCognitoPromise })); +const mockRespondToAuthChallenge = vi.fn(() => ({ promise: mockCognitoPromise })); + +// ─── DynamoDB mocks ─────────────────────────────────────────────────────────── const mockDynamoPromise = vi.fn(); +const mockDynamoGet = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoPut = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoUpdate = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoScan = vi.fn(() => ({ promise: mockDynamoPromise })); +const mockDynamoDelete = vi.fn(() => ({ promise: mockDynamoPromise })); +// ─── AWS SDK mock ───────────────────────────────────────────────────────────── vi.mock('aws-sdk', () => { - return { - CognitoIdentityServiceProvider: vi.fn(function() { - return { - adminCreateUser: mockAdminCreateUser, - adminSetUserPassword: mockAdminSetUserPassword, - initiateAuth: mockInitiateAuth, - getUser: mockGetUser, - respondToAuthChallenge: mockRespondToAuthChallenge, - adminAddUserToGroup: mockAdminAddUserToGroup, - adminDeleteUser: mockAdminDeleteUser, - }; - }), - DynamoDB: { - DocumentClient: vi.fn(function() { - return { - get: mockDynamoGet, - put: mockDynamoPut, - update: mockDynamoUpdate, - scan: mockDynamoScan, - }; - }) - }, - SES: vi.fn(function() { - return {}; - }), + const cognitoFactory = vi.fn(function () { + return { + adminCreateUser: mockAdminCreateUser, + adminSetUserPassword: mockAdminSetUserPassword, + adminAddUserToGroup: mockAdminAddUserToGroup, + adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, + adminDeleteUser: mockAdminDeleteUser, + initiateAuth: mockInitiateAuth, + getUser: mockGetUser, + respondToAuthChallenge: mockRespondToAuthChallenge, + }; + }); + + const documentClientFactory = vi.fn(function () { + return { + get: mockDynamoGet, + put: mockDynamoPut, + update: mockDynamoUpdate, + scan: mockDynamoScan, + delete: mockDynamoDelete, + }; + }); + + const awsMock = { + CognitoIdentityServiceProvider: cognitoFactory, + DynamoDB: { DocumentClient: documentClientFactory }, }; + + return { ...awsMock, default: awsMock }; }); -describe("AuthService", () => { +// ─── Test suite ─────────────────────────────────────────────────────────────── +describe('AuthService', () => { let service: AuthService; beforeAll(() => { - process.env.COGNITO_USER_POOL_ID = "test-user-pool-id"; - process.env.COGNITO_CLIENT_ID = "test-client-id"; - process.env.COGNITO_CLIENT_SECRET = "test-client-secret"; - process.env.DYNAMODB_USER_TABLE_NAME = "test-users-table"; - process.env.FISH_EYE_LENS = "sha256"; + process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + process.env.COGNITO_CLIENT_ID = 'test-client-id'; + process.env.COGNITO_CLIENT_SECRET = 'test-client-secret'; + process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; + process.env.FISH_EYE_LENS = 'sha256'; }); beforeEach(async () => { vi.clearAllMocks(); + // Re-attach after clearAllMocks mockAdminCreateUser.mockReturnValue({ promise: mockCognitoPromise }); mockAdminSetUserPassword.mockReturnValue({ promise: mockCognitoPromise }); + mockAdminAddUserToGroup.mockReturnValue({ promise: mockCognitoPromise }); + mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockCognitoPromise }); + mockAdminDeleteUser.mockReturnValue({ promise: mockCognitoPromise }); mockInitiateAuth.mockReturnValue({ promise: mockCognitoPromise }); mockGetUser.mockReturnValue({ promise: mockCognitoPromise }); mockRespondToAuthChallenge.mockReturnValue({ promise: mockCognitoPromise }); - mockAdminAddUserToGroup.mockReturnValue({ promise: mockCognitoPromise }); - mockAdminDeleteUser.mockReturnValue({ promise: mockCognitoPromise }); mockDynamoGet.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoPut.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoUpdate.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoScan.mockReturnValue({ promise: mockDynamoPromise }); + mockDynamoDelete.mockReturnValue({ promise: mockDynamoPromise }); + + mockCognitoPromise.mockResolvedValue({}); + mockDynamoPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ providers: [AuthService], }).compile(); service = module.get(AuthService); - - mockCognitoPromise.mockResolvedValue({}); - mockDynamoPromise.mockResolvedValue({}); }); - describe("register", () => { - it("should successfully register a user", async () => { - // adminCreateUser returns a user with sub attribute - mockCognitoPromise.mockResolvedValueOnce({ - User: { - Attributes: [{ Name: "sub", Value: "test-sub-123" }], - }, - }); - // adminSetUserPassword - mockCognitoPromise.mockResolvedValueOnce({}); - // adminAddUserToGroup - mockCognitoPromise.mockResolvedValueOnce({}); - // DynamoDB put - mockDynamoPromise.mockResolvedValueOnce({}); + // ── register ──────────────────────────────────────────────────────────────── + + describe('register', () => { + it('should successfully register a user', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'test-sub-123' }] } }) // adminCreateUser + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockResolvedValueOnce({}); // adminAddUserToGroup - // register now takes (email, password, firstName, lastName) - await service.register("c4c@example.com", "Pass123!", "John", "Doe"); + mockDynamoPromise.mockResolvedValueOnce({}); // DynamoDB put + + await service.register('c4c@example.com', 'Pass123!', 'John', 'Doe'); expect(mockAdminCreateUser).toHaveBeenCalledWith({ - UserPoolId: "test-user-pool-id", - Username: "c4c@example.com", + UserPoolId: 'test-pool-id', + Username: 'c4c@example.com', UserAttributes: [ - { Name: "email", Value: "c4c@example.com" }, - { Name: "email_verified", Value: "true" }, + { Name: 'email', Value: 'c4c@example.com' }, + { Name: 'email_verified', Value: 'true' }, ], - MessageAction: "SUPPRESS", + MessageAction: 'SUPPRESS', }); expect(mockAdminSetUserPassword).toHaveBeenCalledWith({ - UserPoolId: "test-user-pool-id", - Username: "c4c@example.com", - Password: "Pass123!", + UserPoolId: 'test-pool-id', + Username: 'c4c@example.com', + Password: 'Pass123!', Permanent: true, }); expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: "Inactive", - UserPoolId: "test-user-pool-id", - Username: "c4c@example.com", + GroupName: 'Inactive', + UserPoolId: 'test-pool-id', + Username: 'c4c@example.com', }); expect(mockDynamoPut).toHaveBeenCalledWith( expect.objectContaining({ - TableName: "test-users-table", + TableName: 'test-users-table', Item: expect.objectContaining({ - email: "c4c@example.com", - position: "Inactive", - firstName: "John", - lastName: "Doe", + email: 'c4c@example.com', + position: UserStatus.Inactive, + firstName: 'John', + lastName: 'Doe', }), }) ); }); - it("should deny someone from making an account when email is already in use", async () => { + it('should throw ConflictException when email already exists', async () => { mockCognitoPromise.mockRejectedValueOnce({ - code: "UsernameExistsException", - message: "User already exists", + code: 'UsernameExistsException', + message: 'User already exists', }); await expect( - service.register("existing@example.com", "Pass123!", "John", "Doe") - ).rejects.toThrow("An account with this email already exists"); + service.register('existing@example.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow('An account with this email already exists'); }); - }); - describe("login", () => { - it("should successfully login existing user", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "id-token", - AccessToken: "access-token", - RefreshToken: "refresh-token", - }, - }), - }); + it('should throw BadRequestException for invalid email', async () => { + await expect( + service.register('not-an-email', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow('Valid email address is required'); + }); - const mockGetUserFn = () => ({ - promise: () => - Promise.resolve({ - UserAttributes: [ - { Name: "email", Value: "c4c@example.com" }, - { Name: "sub", Value: "test-sub-123" }, - ], - }), - }); + it('should throw BadRequestException when password is too short', async () => { + await expect( + service.register('test@test.com', 'short', 'John', 'Doe') + ).rejects.toThrow('Password must be at least 8 characters long'); + }); - // DynamoDB get returns existing user keyed by email - const mockGetFn = () => ({ - promise: () => - Promise.resolve({ - Item: { - email: "c4c@example.com", - position: "Inactive", - firstName: "John", - lastName: "Doe", - }, - }), - }); + it('should throw InternalServerErrorException when COGNITO_USER_POOL_ID is missing', async () => { + const original = process.env.COGNITO_USER_POOL_ID; + delete process.env.COGNITO_USER_POOL_ID; - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - (service["cognito"] as any).getUser = mockGetUserFn; - (service["dynamoDb"] as any).get = mockGetFn; + const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); + const svc = module.get(AuthService); - const result = await service.login("c4c@example.com", "Pass123!"); + await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); + process.env.COGNITO_USER_POOL_ID = original; + }); - expect(result.access_token).toBe("access-token"); - expect(result.user).toEqual({ - email: "c4c@example.com", - position: "Inactive", - firstName: "John", - lastName: "Doe", - }); - expect(result.message).toBe("Login Successful!"); - }); - - it("should handle NEW_PASSWORD_REQUIRED challenge", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.resolve({ - ChallengeName: "NEW_PASSWORD_REQUIRED", - Session: "session-123", - ChallengeParameters: { - requiredAttributes: '["email"]', - }, - }), - }); + it('should throw InternalServerErrorException when DYNAMODB_USER_TABLE_NAME is missing', async () => { + const original = process.env.DYNAMODB_USER_TABLE_NAME; + delete process.env.DYNAMODB_USER_TABLE_NAME; - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; + const module = await Test.createTestingModule({ providers: [AuthService] }).compile(); + const svc = module.get(AuthService); - const result = await service.login("c4c@example.com", "newPassword"); + await expect(svc.register('test@test.com', 'Pass123!', 'John', 'Doe')).rejects.toThrow('Server configuration error'); + process.env.DYNAMODB_USER_TABLE_NAME = original; + }); - expect(result.challenge).toBe("NEW_PASSWORD_REQUIRED"); - expect(result.session).toBe("session-123"); - expect(result.requiredAttributes).toEqual(["email"]); - // username is no longer returned in challenge response - expect(result.access_token).toBeUndefined(); - expect(result.user).toEqual({}); - }); - - it("should create new DynamoDB user if not exists", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "id-token", - AccessToken: "access-token", - RefreshToken: "refresh-token", - }, - }), - }); + it('should rollback Cognito user if adminSetUserPassword fails', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'sub-123' }] } }) // adminCreateUser + .mockRejectedValueOnce({ code: 'InvalidPasswordException', message: 'Bad password' }) // setPassword fails + .mockResolvedValueOnce({}); // rollback delete - const mockGetUserFn = () => ({ - promise: () => - Promise.resolve({ - UserAttributes: [ - { Name: "email", Value: "c4c@gmail.com" }, - { Name: "sub", Value: "test-sub-456" }, - ], - }), - }); + await expect( + service.register('test@test.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow(); - // DynamoDB get returns nothing (user doesn't exist yet) - const mockGetFn = () => ({ - promise: () => Promise.resolve({}), - }); + expect(mockAdminDeleteUser).toHaveBeenCalledWith( + expect.objectContaining({ Username: 'test@test.com', UserPoolId: 'test-pool-id' }) + ); + }); - const mockPutFn = () => ({ - promise: () => Promise.resolve({}), - }); + it('should rollback Cognito user if adminAddUserToGroup fails', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'sub-123' }] } }) // adminCreateUser + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Group not found' }) // group fails + .mockResolvedValueOnce({}); // rollback delete + + await expect( + service.register('test@test.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow("User group 'Inactive' does not exist in the system"); + + expect(mockAdminDeleteUser).toHaveBeenCalled(); + }); + + it('should rollback Cognito user if DynamoDB put fails', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ User: { Attributes: [{ Name: 'sub', Value: 'sub-123' }] } }) + .mockResolvedValueOnce({}) // adminSetUserPassword + .mockResolvedValueOnce({}) // adminAddUserToGroup + .mockResolvedValueOnce({}); // rollback delete + + mockDynamoPromise.mockRejectedValueOnce(new Error('DB error')); - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - (service["cognito"] as any).getUser = mockGetUserFn; - (service["dynamoDb"] as any).get = mockGetFn; - (service["dynamoDb"] as any).put = mockPutFn; + await expect( + service.register('test@test.com', 'Pass123!', 'John', 'Doe') + ).rejects.toThrow('Failed to save user data to database'); + + expect(mockAdminDeleteUser).toHaveBeenCalled(); + }); + }); - const result = await service.login("c4c@gmail.com", "Pass123!"); + // ── login ──────────────────────────────────────────────────────────────────── - expect(result.access_token).toBe("access-token"); - expect(result.user).toEqual({ - email: "c4c@gmail.com", - position: "Inactive", - firstName: "", - lastName: "", + describe('login', () => { + it('should successfully login and return tokens and user', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ + AuthenticationResult: { + IdToken: 'id-token', + AccessToken: 'access-token', + RefreshToken: 'refresh-token', + }, + }) + .mockResolvedValueOnce({ + UserAttributes: [ + { Name: 'email', Value: 'c4c@example.com' }, + { Name: 'sub', Value: 'test-sub-123' }, + ], + }); + + mockDynamoPromise.mockResolvedValueOnce({ + Item: { email: 'c4c@example.com', position: UserStatus.Inactive, firstName: 'John', lastName: 'Doe' }, }); - expect(result.message).toBe("Login Successful!"); + + const result = await service.login('c4c@example.com', 'Pass123!'); + + expect(result.access_token).toBe('access-token'); + expect(result.refreshToken).toBe('refresh-token'); + expect(result.idToken).toBe('id-token'); + expect(result.user).toEqual({ email: 'c4c@example.com', position: UserStatus.Inactive, firstName: 'John', lastName: 'Doe' }); + expect(result.message).toBe('Login Successful!'); }); - it("should handle NotAuthorizedException", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.reject({ - code: "NotAuthorizedException", - message: "Incorrect username or password", - }), + it('should return NEW_PASSWORD_REQUIRED challenge', async () => { + mockCognitoPromise.mockResolvedValueOnce({ + ChallengeName: 'NEW_PASSWORD_REQUIRED', + Session: 'session-123', + ChallengeParameters: { requiredAttributes: '["email"]' }, }); - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - await expect(service.login("c4c@example.com", "wrongpassword")).rejects.toThrow( - UnauthorizedException - ); + const result = await service.login('c4c@example.com', 'Pass123!'); + + expect(result.challenge).toBe('NEW_PASSWORD_REQUIRED'); + expect(result.session).toBe('session-123'); + expect(result.requiredAttributes).toEqual(['email']); + expect(result.access_token).toBeUndefined(); }); - it("should handle missing client credentials", async () => { - delete process.env.COGNITO_CLIENT_ID; - delete process.env.COGNITO_CLIENT_SECRET; + it('should create new DynamoDB user if not found after login', async () => { + mockCognitoPromise + .mockResolvedValueOnce({ + AuthenticationResult: { + IdToken: 'id-token', + AccessToken: 'access-token', + RefreshToken: 'refresh-token', + }, + }) + .mockResolvedValueOnce({ + UserAttributes: [ + { Name: 'email', Value: 'new@example.com' }, + { Name: 'sub', Value: 'new-sub-456' }, + ], + }); - await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( - "Cognito Client ID or Secret is not defined." - ); + mockDynamoPromise + .mockResolvedValueOnce({}) // get - user not found + .mockResolvedValueOnce({}); // put - create new user + + const result = await service.login('new@example.com', 'Pass123!'); - process.env.COGNITO_CLIENT_ID = "test-client-id"; - process.env.COGNITO_CLIENT_SECRET = "test-client-secret"; + expect(result.user).toEqual({ email: 'new@example.com', position: UserStatus.Inactive, firstName: '', lastName: '' }); + expect(mockDynamoPut).toHaveBeenCalled(); }); - it("should handle missing tokens in response", async () => { - mockCognitoPromise.mockResolvedValueOnce({ - AuthenticationResult: {}, - }); + it('should throw BadRequestException when email is empty', async () => { + await expect(service.login('', 'Pass123!')).rejects.toThrow('Email is required'); + await expect(service.login(' ', 'Pass123!')).rejects.toThrow('Email is required'); + }); - await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( - "Authentication failed: Missing IdToken or AccessToken" - ); + it('should throw BadRequestException when password is empty', async () => { + await expect(service.login('test@test.com', '')).rejects.toThrow('Password is required'); }); - it("should handle generic Cognito errors", async () => { - const mockInitiateAuthFn = () => ({ - promise: () => - Promise.reject({ - code: "SomeAwsError", - message: "AWS error occurred", - }), - }); + it('should throw UnauthorizedException for NotAuthorizedException', async () => { + mockCognitoPromise.mockRejectedValueOnce({ code: 'NotAuthorizedException', message: 'Bad credentials' }); + await expect(service.login('test@test.com', 'wrongpass')).rejects.toThrow(UnauthorizedException); + }); - (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( - InternalServerErrorException - ); + it('should throw InternalServerErrorException for other Cognito errors', async () => { + mockCognitoPromise.mockRejectedValueOnce({ code: 'SomeAwsError', message: 'AWS error' }); + await expect(service.login('test@test.com', 'Pass123!')).rejects.toThrow(InternalServerErrorException); }); - }); - describe("setNewPassword", () => { - it("should successfully set new password", async () => { - const mockRespondFn = () => ({ - promise: () => - Promise.resolve({ - AuthenticationResult: { - IdToken: "new-id-token", - }, - }), - }); + it('should throw when client credentials are missing', async () => { + const origId = process.env.COGNITO_CLIENT_ID; + const origSecret = process.env.COGNITO_CLIENT_SECRET; + delete process.env.COGNITO_CLIENT_ID; + delete process.env.COGNITO_CLIENT_SECRET; - (service["cognito"] as any).respondToAuthChallenge = mockRespondFn; + await expect(service.login('test@test.com', 'Pass123!')).rejects.toThrow('Cognito Client ID or Secret is not defined.'); - // setNewPassword no longer takes a separate username — just (newPassword, session, email) - const result = await service.setNewPassword( - "NewPass123!", - "session123", - "c4c@example.com" - ); + process.env.COGNITO_CLIENT_ID = origId; + process.env.COGNITO_CLIENT_SECRET = origSecret; + }); - expect(result.access_token).toBe("new-id-token"); + it('should throw when AuthenticationResult is missing tokens', async () => { + mockCognitoPromise.mockResolvedValueOnce({ AuthenticationResult: {} }); + await expect(service.login('test@test.com', 'Pass123!')).rejects.toThrow('Authentication failed: Missing IdToken or AccessToken'); }); + }); - it("should handle failed password setting", async () => { + // ── setNewPassword ─────────────────────────────────────────────────────────── + + describe('setNewPassword', () => { + it('should successfully set a new password', async () => { mockCognitoPromise.mockResolvedValueOnce({ - AuthenticationResult: {}, + AuthenticationResult: { IdToken: 'new-id-token' }, }); - await expect( - service.setNewPassword("NewPass123!", "s123", "c4c@example.com") - ).rejects.toThrow("Failed to set new password"); + const result = await service.setNewPassword('NewPass123!', 'session123', 'c4c@example.com'); + expect(result.access_token).toBe('new-id-token'); + expect(mockRespondToAuthChallenge).toHaveBeenCalled(); }); - it("should handle Cognito errors", async () => { - const mockRespondFn = () => ({ - promise: () => Promise.reject(new Error("Cognito Error")), + it('should include email in challenge responses', async () => { + mockCognitoPromise.mockResolvedValueOnce({ + AuthenticationResult: { IdToken: 'new-id-token' }, }); - (service["cognito"] as any).respondToAuthChallenge = mockRespondFn; + await service.setNewPassword('NewPass123!', 'session123', 'c4c@example.com'); - await expect( - service.setNewPassword("NewPass123!", "s123", "c4c@example.com") - ).rejects.toThrow("Cognito Error"); + expect(mockRespondToAuthChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + ChallengeResponses: expect.objectContaining({ email: 'c4c@example.com' }), + }) + ); + }); + + it('should throw BadRequestException when newPassword is empty', async () => { + await expect(service.setNewPassword('', 'session', 'c4c@example.com')).rejects.toThrow('New password is required'); + }); + + it('should throw BadRequestException when session is empty', async () => { + await expect(service.setNewPassword('NewPass123!', '', 'c4c@example.com')).rejects.toThrow('Session is required'); + }); + + it('should throw when AuthenticationResult is missing from response', async () => { + mockCognitoPromise.mockResolvedValueOnce({}); + await expect(service.setNewPassword('NewPass123!', 'session', 'c4c@example.com')).rejects.toThrow('Failed to set new password'); + }); + + it('should throw when respondToAuthChallenge fails', async () => { + mockCognitoPromise.mockRejectedValueOnce(new Error('Challenge failed')); + await expect(service.setNewPassword('NewPass123!', 'session', 'c4c@example.com')).rejects.toThrow('Challenge failed'); }); }); - describe("updateProfile", () => { - it("should successfully update user profile", async () => { + // ── updateProfile ──────────────────────────────────────────────────────────── + + describe('updateProfile', () => { + it('should successfully update user profile', async () => { mockDynamoPromise.mockResolvedValueOnce({}); - // updateProfile now takes (email, position_or_role) — no username - await service.updateProfile("c4c@example.com", "Software Developer"); + await expect(service.updateProfile('c4c@example.com', 'Software Developer')).resolves.toBeUndefined(); expect(mockDynamoUpdate).toHaveBeenCalledWith( expect.objectContaining({ - Key: { email: "c4c@example.com" }, - UpdateExpression: "SET email = :email, position_or_role = :position_or_role", - ExpressionAttributeValues: { - ":email": "c4c@example.com", - ":position_or_role": "Software Developer", - }, + TableName: 'test-users-table', + Key: { email: 'c4c@example.com' }, + ExpressionAttributeValues: expect.objectContaining({ + ':email': 'c4c@example.com', + ':position_or_role': 'Software Developer', + }), }) ); }); - it("should handle DynamoDB update errors", async () => { - const mockUpdateFn = vi.fn().mockReturnValue({ - promise: vi.fn().mockRejectedValue(new Error("DB error")), - }); + it('should throw BadRequestException when email is empty', async () => { + await expect(service.updateProfile('', 'role')).rejects.toThrow('Email is required'); + await expect(service.updateProfile(' ', 'role')).rejects.toThrow('Email is required'); + }); - (service['dynamoDb'] as any).update = mockUpdateFn; + it('should throw BadRequestException when position_or_role is empty', async () => { + await expect(service.updateProfile('c4c@example.com', '')).rejects.toThrow('Position or role is required'); + await expect(service.updateProfile('c4c@example.com', ' ')).rejects.toThrow('Position or role is required'); + }); - await expect( - service.updateProfile("c4c@example.com", "Active") - ).rejects.toThrow("DB error"); + it('should throw when DynamoDB update fails', async () => { + mockDynamoPromise.mockRejectedValueOnce(new Error('DB error')); + await expect(service.updateProfile('c4c@example.com', 'role')).rejects.toThrow('DB error'); }); }); - describe("validateSession", () => { - it("should successfully validate a session", async () => { - // Cognito getUser returns email in attributes + // ── validateSession ────────────────────────────────────────────────────────── + + describe('validateSession', () => { + it('should successfully validate a session and return user', async () => { mockCognitoPromise.mockResolvedValueOnce({ - Username: "c4c@example.com", - UserAttributes: [{ Name: "email", Value: "c4c@example.com" }], + Username: 'c4c@example.com', + UserAttributes: [{ Name: 'email', Value: 'c4c@example.com' }], }); - // DynamoDB get returns user keyed by email mockDynamoPromise.mockResolvedValueOnce({ - Item: { email: "c4c@example.com", position: "Active", firstName: "John", lastName: "Doe" }, + Item: { email: 'c4c@example.com', position: UserStatus.Employee, firstName: 'John', lastName: 'Doe' }, }); - const result = await service.validateSession("valid-token"); + const result = await service.validateSession('valid-access-token'); - expect(result).toEqual({ - email: "c4c@example.com", - position: "Active", - firstName: "John", - lastName: "Doe", - }); + expect(result).toEqual({ email: 'c4c@example.com', position: UserStatus.Employee, firstName: 'John', lastName: 'Doe' }); + expect(mockGetUser).toHaveBeenCalledWith({ AccessToken: 'valid-access-token' }); }); - it("should reject missing access token", async () => { - // Empty string still calls Cognito which then throws, caught as UnauthorizedException - mockCognitoPromise.mockRejectedValueOnce({ - code: "NotAuthorizedException", - message: "Invalid token", - }); - - await expect( - service.validateSession("") - ).rejects.toThrow(UnauthorizedException); + it('should throw UnauthorizedException when token is expired', async () => { + mockCognitoPromise.mockRejectedValueOnce({ code: 'NotAuthorizedException', message: 'Token expired' }); + await expect(service.validateSession('expired-token')).rejects.toThrow('Session expired or invalid'); }); - it("should handle NotAuthorizedException", async () => { - mockCognitoPromise.mockRejectedValueOnce({ - code: "NotAuthorizedException", - message: "Invalid token", + it('should throw UnauthorizedException when user not found in DynamoDB', async () => { + mockCognitoPromise.mockResolvedValueOnce({ + Username: 'ghost@test.com', + UserAttributes: [{ Name: 'email', Value: 'ghost@test.com' }], }); + mockDynamoPromise.mockResolvedValueOnce({}); // no Item - await expect( - service.validateSession("invalid-token") - ).rejects.toThrow(UnauthorizedException); + await expect(service.validateSession('some-token')).rejects.toThrow('Failed to validate session'); }); - it("should handle InvalidParameterException", async () => { - mockCognitoPromise.mockRejectedValueOnce({ - code: "InvalidParameterException", - message: "Invalid token format", - }); - - await expect( - service.validateSession("bad-format") - ).rejects.toThrow(UnauthorizedException); + it('should throw UnauthorizedException for any other error', async () => { + mockCognitoPromise.mockRejectedValueOnce(new Error('Unknown error')); + await expect(service.validateSession('bad-token')).rejects.toThrow('Failed to validate session'); }); }); }); \ No newline at end of file diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index 38e4fea..7441145 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -3,112 +3,117 @@ import { UserController } from '../user.controller'; import { UserService } from '../user.service'; import { User } from '../../../../middle-layer/types/User'; import { UserStatus } from '../../../../middle-layer/types/UserStatus'; -import * as AWS from 'aws-sdk'; import { VerifyUserGuard, VerifyAdminRoleGuard, VerifyAdminOrEmployeeRoleGuard } from '../../guards/auth.guard'; import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest'; -const mockScan = vi.fn(); -const mockGet = vi.fn(); -const mockUpdate = vi.fn(); -const mockPut = vi.fn(); -const mockDelete = vi.fn(); +// ─── Mock function declarations ─────────────────────────────────────────────── const mockPromise = vi.fn(); -const mockAdminAddUserToGroup = vi.fn(); -const mockAdminRemoveUserFromGroup = vi.fn(); -const mockAdminDeleteUser = vi.fn(); -const mockSendEmail = vi.fn(); +const mockScan = vi.fn(() => ({ promise: mockPromise })); +const mockGet = vi.fn(() => ({ promise: mockPromise })); +const mockUpdate = vi.fn(() => ({ promise: mockPromise })); +const mockPut = vi.fn(() => ({ promise: mockPromise })); +const mockDelete = vi.fn(() => ({ promise: mockPromise })); +const mockAdminAddUserToGroup = vi.fn(() => ({ promise: mockPromise })); +const mockAdminRemoveUserFromGroup = vi.fn(() => ({ promise: mockPromise })); +const mockAdminDeleteUser = vi.fn(() => ({ promise: mockPromise })); +const mockSendEmail = vi.fn(() => ({ promise: mockPromise })); +const mockS3Upload = vi.fn(() => ({ promise: mockPromise })); + +// ─── AWS SDK mock ───────────────────────────────────────────────────────────── vi.mock('aws-sdk', () => { - return { - default: { - CognitoIdentityServiceProvider: vi.fn(function() { - return { - adminAddUserToGroup: mockAdminAddUserToGroup, - adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, - adminDeleteUser: mockAdminDeleteUser, - }; - }), - DynamoDB: { - DocumentClient: vi.fn(function() { - return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; - }) - }, - SES: vi.fn(function() { - return { sendEmail: mockSendEmail }; - }) - }, - CognitoIdentityServiceProvider: vi.fn(function() { - return { - adminAddUserToGroup: mockAdminAddUserToGroup, - adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, - adminDeleteUser: mockAdminDeleteUser, - }; - }), - DynamoDB: { - DocumentClient: vi.fn(function() { - return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; - }) - }, - SES: vi.fn(function() { - return { sendEmail: mockSendEmail }; - }) + const cognitoFactory = vi.fn(function () { + return { + adminAddUserToGroup: mockAdminAddUserToGroup, + adminRemoveUserFromGroup: mockAdminRemoveUserFromGroup, + adminDeleteUser: mockAdminDeleteUser, + }; + }); + + const documentClientFactory = vi.fn(function () { + return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; + }); + + const sesFactory = vi.fn(function () { + return { sendEmail: mockSendEmail }; + }); + + const s3Factory = vi.fn(function () { + return { upload: mockS3Upload }; + }); + + const awsMock = { + CognitoIdentityServiceProvider: cognitoFactory, + DynamoDB: { DocumentClient: documentClientFactory }, + SES: sesFactory, + S3: s3Factory, }; + + return { ...awsMock, default: awsMock }; }); +// ─── Auth guard mock ────────────────────────────────────────────────────────── vi.mock('../../guards/auth.guard', () => ({ - VerifyUserGuard: vi.fn(class MockVerifyUserGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), - VerifyAdminRoleGuard: vi.fn(class MockVerifyAdminRoleGuard { - canActivate = vi.fn().mockResolvedValue(true); - }), - VerifyAdminOrEmployeeRoleGuard: vi.fn(class MockVerifyAdminOrEmployeeRoleGuard { - canActivate = vi.fn().mockResolvedValue(true); - }) + VerifyUserGuard: vi.fn(class { canActivate = vi.fn().mockResolvedValue(true); }), + VerifyAdminRoleGuard: vi.fn(class { canActivate = vi.fn().mockResolvedValue(true); }), + VerifyAdminOrEmployeeRoleGuard: vi.fn(class { canActivate = vi.fn().mockResolvedValue(true); }), })); -// Mock database now keyed by email since that's the partition key +// ─── Mock database (email is now the partition key) ─────────────────────────── const mockDatabase = { users: [ - { email: 'admin1@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'One' }, - { email: 'admin2@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'Two' }, - { email: 'emp1@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'One' }, - { email: 'emp2@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Two' }, - { email: 'emp3@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Three' }, - { email: 'inactive1@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'One' }, - { email: 'inactive2@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Two' }, + { email: 'admin1@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'One' }, + { email: 'admin2@example.com', position: UserStatus.Admin, firstName: 'Admin', lastName: 'Two' }, + { email: 'emp1@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'One' }, + { email: 'emp2@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Two' }, + { email: 'emp3@example.com', position: UserStatus.Employee, firstName: 'Emp', lastName: 'Three' }, + { email: 'inactive1@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'One' }, + { email: 'inactive2@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Two' }, { email: 'inactive3@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Three' }, - { email: 'inactive4@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Four' }, + { email: 'inactive4@example.com', position: UserStatus.Inactive, firstName: 'Inactive', lastName: 'Four' }, ] as User[], - scan: (params: any) => { - let filteredUsers = [...mockDatabase.users]; - if (params.FilterExpression) { - if (params.FilterExpression.includes('(:inactive)')) { - filteredUsers = filteredUsers.filter(u => u.position === 'Inactive'); - } else if (params.FilterExpression.includes('(:admin, :employee)')) { - filteredUsers = filteredUsers.filter(u => u.position === 'Admin' || u.position === 'Employee'); - } + scan(params: any) { + let users = [...this.users]; + if (params.FilterExpression?.includes('(:inactive)')) { + users = users.filter(u => u.position === UserStatus.Inactive); + } else if (params.FilterExpression?.includes('(:admin, :employee)')) { + users = users.filter(u => u.position === UserStatus.Admin || u.position === UserStatus.Employee); } - return { Items: filteredUsers }; + return { Items: users }; }, - // Now looks up by email instead of userId - get: (params: any) => { - const email = params.Key.email; - const user = mockDatabase.users.find(u => u.email === email); + get(params: any) { + const user = this.users.find(u => u.email === params.Key.email); return user ? { Item: user } : {}; - } + }, }; +// ─── Helpers ────────────────────────────────────────────────────────────────── +const createMockFile = (overrides?: Partial): Express.Multer.File => ({ + fieldname: 'profilePic', + originalname: 'test-image.jpg', + encoding: '7bit', + mimetype: 'image/jpeg', + size: 1024 * 1024, + buffer: Buffer.from('fake-image-data'), + destination: '', + filename: '', + path: '', + stream: null as any, + ...overrides, +}); + +// ─── Test suite ─────────────────────────────────────────────────────────────── describe('UserController', () => { let controller: UserController; let userService: UserService; beforeAll(() => { process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; - process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; + process.env.PROFILE_PICTURE_BUCKET = 'test-profile-pics-bucket'; }); beforeEach(async () => { @@ -116,13 +121,15 @@ describe('UserController', () => { mockScan.mockReturnValue({ promise: mockPromise }); mockGet.mockReturnValue({ promise: mockPromise }); - mockDelete.mockReturnValue({ promise: mockPromise }); mockUpdate.mockReturnValue({ promise: mockPromise }); mockPut.mockReturnValue({ promise: mockPromise }); + mockDelete.mockReturnValue({ promise: mockPromise }); mockAdminAddUserToGroup.mockReturnValue({ promise: mockPromise }); mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockPromise }); mockAdminDeleteUser.mockReturnValue({ promise: mockPromise }); mockSendEmail.mockReturnValue({ promise: mockPromise }); + mockS3Upload.mockReturnValue({ promise: mockPromise }); + mockPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ @@ -130,34 +137,174 @@ describe('UserController', () => { providers: [UserService], }).compile(); - controller = module.get(UserController); + controller = module.get(UserController); userService = module.get(UserService); }); - it('should get all users from mock database', async () => { - mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); + // ── uploadProfilePic ──────────────────────────────────────────────────────── + + describe('uploadProfilePic', () => { + it('should successfully upload profile picture', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const file = createMockFile(); + // key format: firstName-lastName-firstThreeOfEmail-profilepic.ext + const s3Url = 'https://test-profile-pics-bucket.s3.amazonaws.com/Emp-One-emp-profilepic.jpg'; + + mockPromise + .mockResolvedValueOnce({ Location: s3Url, Key: 'Emp-One-emp-profilepic.jpg', Bucket: 'test-profile-pics-bucket' }) + .mockResolvedValueOnce({ Attributes: { ...user, profilePicUrl: s3Url } }); + + const result = await userService.uploadProfilePic(user, file); + + expect(result).toBe(s3Url); + expect(mockS3Upload).toHaveBeenCalledWith({ + Bucket: 'test-profile-pics-bucket', + Key: 'Emp-One-emp-profilepic.jpg', + Body: file.buffer, + ContentType: 'image/jpeg', + }); + expect(mockUpdate).toHaveBeenCalledWith({ + TableName: 'test-users-table', + Key: { email: 'emp1@example.com' }, + UpdateExpression: 'SET profilePicUrl = :url', + ExpressionAttributeValues: { ':url': s3Url }, + ReturnValues: 'ALL_NEW', + }); + }); - const result = await userService.getAllUsers(); + it('should generate correct filename for non-jpg extensions', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const file = createMockFile({ originalname: 'test.png', mimetype: 'image/png' }); + const s3Url = 'https://test-profile-pics-bucket.s3.amazonaws.com/Emp-One-emp-profilepic.png'; + + mockPromise + .mockResolvedValueOnce({ Location: s3Url, Key: 'Emp-One-emp-profilepic.png' }) + .mockResolvedValueOnce({ Attributes: { ...user, profilePicUrl: s3Url } }); + + await userService.uploadProfilePic(user, file); + + expect(mockS3Upload).toHaveBeenCalledWith( + expect.objectContaining({ Key: 'Emp-One-emp-profilepic.png' }) + ); + }); + + it('should throw BadRequestException when user object is invalid', async () => { + const file = createMockFile(); + await expect(userService.uploadProfilePic(null as any, file)).rejects.toThrow('Valid user object is required'); + await expect(userService.uploadProfilePic({ email: '' } as any, file)).rejects.toThrow('Valid user object is required'); + }); + + it('should throw BadRequestException when file is invalid', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + await expect(userService.uploadProfilePic(user, null as any)).rejects.toThrow('Valid image file is required'); + await expect(userService.uploadProfilePic(user, { buffer: null } as any)).rejects.toThrow('Valid image file is required'); + }); + + it('should throw BadRequestException for invalid file type', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + await expect(userService.uploadProfilePic(user, createMockFile({ mimetype: 'application/pdf' }))).rejects.toThrow('Invalid file type'); + }); + + it('should throw BadRequestException for file too large', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + await expect(userService.uploadProfilePic(user, createMockFile({ size: 10 * 1024 * 1024 }))).rejects.toThrow('File too large'); + }); + + it('should accept all allowed image types', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const s3Url = 'https://test.com/image.jpg'; + for (const mimetype of ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']) { + vi.clearAllMocks(); + mockS3Upload.mockReturnValue({ promise: mockPromise }); + mockUpdate.mockReturnValue({ promise: mockPromise }); + mockPromise + .mockResolvedValueOnce({ Location: s3Url, Key: 'key', Bucket: 'test-profile-pics-bucket' }) + .mockResolvedValueOnce({ Attributes: { ...user, profilePicUrl: s3Url } }); + + const result = await userService.uploadProfilePic(user, createMockFile({ mimetype })); + expect(result).toBe(s3Url); + } + }); + + it('should handle S3 NoSuchBucket error', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise.mockRejectedValueOnce({ code: 'NoSuchBucket', message: 'Bucket does not exist' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Storage bucket not found'); + }); + + it('should handle S3 AccessDenied error', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise.mockRejectedValueOnce({ code: 'AccessDenied', message: 'Access denied' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Insufficient permissions to upload file'); + }); + + it('should handle DynamoDB update failure after S3 upload succeeds', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise + .mockResolvedValueOnce({ Location: 'https://test.com/img.jpg', Key: 'key' }) + .mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Database table not found'); + }); + + it('should handle DynamoDB ValidationException', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise + .mockResolvedValueOnce({ Location: 'https://test.com/img.jpg', Key: 'key' }) + .mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid parameters' }); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Invalid update parameters'); + }); + + it('should throw InternalServerErrorException when DynamoDB returns no Attributes', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + mockPromise + .mockResolvedValueOnce({ Location: 'https://test.com/img.jpg', Key: 'key' }) + .mockResolvedValueOnce({}); + await expect(userService.uploadProfilePic(user, createMockFile())).rejects.toThrow('Failed to retrieve updated user data'); + }); + + it('should throw InternalServerErrorException when bucket env var is not set', async () => { + const original = process.env.PROFILE_PICTURE_BUCKET; + delete process.env.PROFILE_PICTURE_BUCKET; + + const module = await Test.createTestingModule({ providers: [UserService] }).compile(); + const svc = module.get(UserService); + + await expect(svc.uploadProfilePic(mockDatabase.users[2], createMockFile())).rejects.toThrow('Server configuration error'); + process.env.PROFILE_PICTURE_BUCKET = original; + }); + }); + + // ── getAllUsers ────────────────────────────────────────────────────────────── + + it('should get all users', async () => { + mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); + const result = await userService.getAllUsers(); expect(result).toHaveLength(9); expect(mockScan).toHaveBeenCalledWith({ TableName: 'test-users-table' }); }); - // getUserById is now getUserByEmail in the service - it('should get user by email from mock database', async () => { - mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { email: 'admin1@example.com' } })); + it('should handle ResourceNotFoundException when getting all users', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); + await expect(userService.getAllUsers()).rejects.toThrow('Database table not found'); + }); - const result = await userService.getUserByEmail('admin1@example.com'); + it('should handle generic DynamoDB errors when getting all users', async () => { + mockPromise.mockRejectedValueOnce({ code: 'UnknownError', message: 'Unknown error' }); + await expect(userService.getAllUsers()).rejects.toThrow('Could not retrieve users'); + }); + // ── getUserByEmail ─────────────────────────────────────────────────────────── + + it('should get user by email', async () => { + mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { email: 'admin1@example.com' } })); + const result = await userService.getUserByEmail('admin1@example.com'); expect(result.email).toBe('admin1@example.com'); - expect(result.position).toBe('Admin'); - expect(mockGet).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Key: { email: 'admin1@example.com' } - }); + expect(result.position).toBe(UserStatus.Admin); + expect(mockGet).toHaveBeenCalledWith({ TableName: 'test-users-table', Key: { email: 'admin1@example.com' } }); }); - it('should throw BadRequestException when email is invalid', async () => { + it('should throw BadRequestException for invalid email', async () => { await expect(userService.getUserByEmail('')).rejects.toThrow('Valid user email is required'); await expect(userService.getUserByEmail(null as any)).rejects.toThrow('Valid user email is required'); await expect(userService.getUserByEmail(' ')).rejects.toThrow('Valid user email is required'); @@ -165,392 +312,261 @@ describe('UserController', () => { it('should throw NotFoundException when user does not exist', async () => { mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { email: 'nonexistent@example.com' } })); - await expect(userService.getUserByEmail('nonexistent@example.com')).rejects.toThrow("User 'nonexistent@example.com' does not exist"); - expect(mockGet).toHaveBeenCalled(); }); - it('should handle errors when getting all users', async () => { - const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(awsError); - - await expect(userService.getAllUsers()).rejects.toThrow('Database table not found'); - expect(mockScan).toHaveBeenCalled(); - }); - - it('should handle generic DynamoDB errors when getting all users', async () => { - const awsError = { code: 'UnknownError', message: 'Unknown DynamoDB error' }; - mockPromise.mockRejectedValueOnce(awsError); - - await expect(userService.getAllUsers()).rejects.toThrow('Could not retrieve users'); - expect(mockScan).toHaveBeenCalled(); - }); - - it('should handle errors when getting user by email', async () => { - const awsError = { code: 'ValidationException', message: 'Invalid request' }; - mockPromise.mockRejectedValueOnce(awsError); - + it('should handle ValidationException when getting user by email', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid request' }); await expect(userService.getUserByEmail('user@example.com')).rejects.toThrow('Invalid request: Invalid request'); - expect(mockGet).toHaveBeenCalled(); }); it('should handle ResourceNotFoundException when getting user by email', async () => { - const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(awsError); - + mockPromise.mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); await expect(userService.getUserByEmail('user@example.com')).rejects.toThrow('Database table not found'); - expect(mockGet).toHaveBeenCalled(); }); - it('should get all inactive users from mock database', async () => { + // ── getAllInactiveUsers ────────────────────────────────────────────────────── + + it('should get all inactive users', async () => { const scanParams = { TableName: 'test-users-table', FilterExpression: '#pos IN (:inactive)', ExpressionAttributeNames: { '#pos': 'position' }, - ExpressionAttributeValues: { ':inactive': 'Inactive' } + ExpressionAttributeValues: { ':inactive': 'Inactive' }, }; - mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); - const result = await userService.getAllInactiveUsers(); - expect(result).toHaveLength(4); - expect(result.every((u: User) => u.position === 'Inactive')).toBe(true); + expect(result.every((u: User) => u.position === UserStatus.Inactive)).toBe(true); expect(result.map((u: User) => u.email).sort()).toEqual([ - 'inactive1@example.com', - 'inactive2@example.com', - 'inactive3@example.com', - 'inactive4@example.com' + 'inactive1@example.com', 'inactive2@example.com', 'inactive3@example.com', 'inactive4@example.com', ]); expect(mockScan).toHaveBeenCalledWith(scanParams); }); - it('should handle errors when getting inactive users', async () => { - const awsError = { code: 'ValidationException', message: 'Invalid filter' }; - mockPromise.mockRejectedValueOnce(awsError); - + it('should handle ValidationException when getting inactive users', async () => { + mockPromise.mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid filter' }); await expect(userService.getAllInactiveUsers()).rejects.toThrow('Invalid filter expression'); - expect(mockScan).toHaveBeenCalled(); }); - it('should get all active users from mock database', async () => { + // ── getAllActiveUsers ──────────────────────────────────────────────────────── + + it('should get all active users', async () => { const scanParams = { TableName: 'test-users-table', FilterExpression: '#pos IN (:admin, :employee)', ExpressionAttributeNames: { '#pos': 'position' }, - ExpressionAttributeValues: { ':admin': 'Admin', ':employee': 'Employee' } + ExpressionAttributeValues: { ':admin': 'Admin', ':employee': 'Employee' }, }; - mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); - const result = await userService.getAllActiveUsers(); - expect(result).toHaveLength(5); - expect(result.every((u: User) => u.position === 'Admin' || u.position === 'Employee')).toBe(true); - expect(result.filter((u: User) => u.position === 'Admin')).toHaveLength(2); - expect(result.filter((u: User) => u.position === 'Employee')).toHaveLength(3); + expect(result.filter((u: User) => u.position === UserStatus.Admin)).toHaveLength(2); + expect(result.filter((u: User) => u.position === UserStatus.Employee)).toHaveLength(3); expect(mockScan).toHaveBeenCalledWith(scanParams); }); it('should throw NotFoundException when no active users found', async () => { mockPromise.mockResolvedValueOnce({ Items: undefined }); - await expect(userService.getAllActiveUsers()).rejects.toThrow('No active users found.'); - expect(mockScan).toHaveBeenCalled(); }); it('should handle ProvisionedThroughputExceededException', async () => { - const awsError = { code: 'ProvisionedThroughputExceededException', message: 'Throughput exceeded' }; - mockPromise.mockRejectedValueOnce(awsError); - + mockPromise.mockRejectedValueOnce({ code: 'ProvisionedThroughputExceededException', message: 'Throughput exceeded' }); await expect(userService.getAllActiveUsers()).rejects.toThrow('Database is temporarily unavailable, please try again'); - expect(mockScan).toHaveBeenCalled(); }); - // ======================================== - // Tests for addUserToGroup (Change Role) - // ======================================== + // ── addUserToGroup ─────────────────────────────────────────────────────────── - it('should successfully change user role from Inactive to Employee', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should change role from Inactive to Employee and send email', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); // DynamoDB get - mockPromise.mockResolvedValueOnce({}); // Cognito remove from old group - mockPromise.mockResolvedValueOnce({}); // Cognito add to new group - mockPromise.mockResolvedValueOnce({ MessageId: 'test' }); // SES sendEmail - mockPromise.mockResolvedValueOnce({ - Attributes: { ...user, position: UserStatus.Employee } - }); // DynamoDB update + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ MessageId: 'test-id' }) + .mockResolvedValueOnce({ Attributes: { ...user, position: UserStatus.Employee } }); const result = await userService.addUserToGroup(user, UserStatus.Employee, admin); - expect(result.position).toBe(UserStatus.Employee); - expect(mockGet).toHaveBeenCalled(); - // Cognito calls now use email as Username - expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: 'Employee', - UserPoolId: 'test-pool-id', - Username: 'inactive1@example.com' - }); + expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ GroupName: 'Employee', UserPoolId: 'test-pool-id', Username: 'inactive1@example.com' }); expect(mockSendEmail).toHaveBeenCalled(); expect(mockUpdate).toHaveBeenCalled(); }); - it('should successfully promote Employee to Admin', async () => { - const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + it('should promote Employee to Admin', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); - mockPromise.mockResolvedValueOnce({}); // Remove from Employee - mockPromise.mockResolvedValueOnce({}); // Add to Admin - mockPromise.mockResolvedValueOnce({ Attributes: { ...user, position: UserStatus.Admin } }); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ Attributes: { ...user, position: UserStatus.Admin } }); const result = await userService.addUserToGroup(user, UserStatus.Admin, admin); - expect(result.position).toBe(UserStatus.Admin); - expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledWith({ - GroupName: 'Employee', - UserPoolId: 'test-pool-id', - Username: 'emp1@example.com' - }); - expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ - GroupName: 'Admin', - UserPoolId: 'test-pool-id', - Username: 'emp1@example.com' - }); + expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledWith({ GroupName: 'Employee', UserPoolId: 'test-pool-id', Username: 'emp1@example.com' }); + expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ GroupName: 'Admin', UserPoolId: 'test-pool-id', Username: 'emp1@example.com' }); }); it('should return user unchanged if already in requested group', async () => { - const user = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - const requestedBy = mockDatabase.users.find(u => u.email === 'admin2@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; + const admin = mockDatabase.users.find(u => u.email === 'admin2@example.com')!; mockPromise.mockResolvedValueOnce({ Item: user }); - const result = await userService.addUserToGroup(user, UserStatus.Admin, requestedBy); - + const result = await userService.addUserToGroup(user, UserStatus.Admin, admin); expect(result.position).toBe(UserStatus.Admin); expect(mockAdminAddUserToGroup).not.toHaveBeenCalled(); }); - it('should throw BadRequestException when user object is invalid', async () => { + it('should throw BadRequestException for invalid user in addUserToGroup', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.addUserToGroup(null as any, UserStatus.Employee, admin) - ).rejects.toThrow('Valid user object is required'); - - await expect( - userService.addUserToGroup({ email: '' } as any, UserStatus.Employee, admin) - ).rejects.toThrow('Valid user object is required'); + await expect(userService.addUserToGroup(null as any, UserStatus.Employee, admin)).rejects.toThrow('Valid user object is required'); + await expect(userService.addUserToGroup({ email: '' } as any, UserStatus.Employee, admin)).rejects.toThrow('Valid user object is required'); }); - it('should throw BadRequestException when group name is invalid', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should throw BadRequestException for invalid group name', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.addUserToGroup(user, '' as any, admin) - ).rejects.toThrow('Group name is required'); - - await expect( - userService.addUserToGroup(user, 'InvalidGroup' as any, admin) - ).rejects.toThrow('Invalid group name'); + await expect(userService.addUserToGroup(user, '' as any, admin)).rejects.toThrow('Group name is required'); + await expect(userService.addUserToGroup(user, 'InvalidGroup' as any, admin)).rejects.toThrow('Invalid group name'); }); - it('should throw UnauthorizedException when non-admin tries to change role', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should throw UnauthorizedException when non-admin changes role', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const employee = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; - - await expect( - userService.addUserToGroup(user, UserStatus.Employee, employee) - ).rejects.toThrow('Only administrators can modify user groups'); + await expect(userService.addUserToGroup(user, UserStatus.Employee, employee)).rejects.toThrow('Only administrators can modify user groups'); }); - it('should throw BadRequestException when admin tries to demote themselves', async () => { + it('should throw BadRequestException when admin demotes themselves', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: admin }); - - await expect( - userService.addUserToGroup(admin, UserStatus.Employee, admin) - ).rejects.toThrow('Administrators cannot demote themselves'); + await expect(userService.addUserToGroup(admin, UserStatus.Employee, admin)).rejects.toThrow('Administrators cannot demote themselves'); }); - it('should throw NotFoundException when user does not exist', async () => { - const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com') as User; + it('should throw NotFoundException when user does not exist in addUserToGroup', async () => { + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; const fakeUser: User = { email: 'fake@test.com', position: UserStatus.Inactive, firstName: '', lastName: '' }; - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.addUserToGroup(fakeUser, UserStatus.Employee, admin) - ).rejects.toThrow("User 'fake@test.com' does not exist"); + await expect(userService.addUserToGroup(fakeUser, UserStatus.Employee, admin)).rejects.toThrow("User 'fake@test.com' does not exist"); }); - it('should handle Cognito UserNotFoundException', async () => { - const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; + it('should handle Cognito UserNotFoundException in addUserToGroup', async () => { + const user = mockDatabase.users.find(u => u.email === 'inactive1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); - mockPromise.mockResolvedValueOnce({}); // Remove from old group - const cognitoError = { code: 'UserNotFoundException', message: 'User not found in Cognito' }; - mockPromise.mockRejectedValueOnce(cognitoError); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce({ code: 'UserNotFoundException', message: 'User not found in Cognito' }); - await expect( - userService.addUserToGroup(user, UserStatus.Employee, admin) - ).rejects.toThrow('not found in authentication system'); + await expect(userService.addUserToGroup(user, UserStatus.Employee, admin)).rejects.toThrow('not found in authentication system'); }); it('should rollback Cognito change if DynamoDB update fails', async () => { - const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: user }); - mockPromise.mockResolvedValueOnce({}); // Remove from old group - mockPromise.mockResolvedValueOnce({}); // Add to new group - const dynamoError = { code: 'ValidationException', message: 'Invalid update' }; - mockPromise.mockRejectedValueOnce(dynamoError); - mockPromise.mockResolvedValueOnce({}); // Rollback: remove from new group - mockPromise.mockResolvedValueOnce({}); // Rollback: add back to old group - - await expect( - userService.addUserToGroup(user, UserStatus.Admin, admin) - ).rejects.toThrow('Invalid update parameters'); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockRejectedValueOnce({ code: 'ValidationException', message: 'Invalid update' }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + await expect(userService.addUserToGroup(user, UserStatus.Admin, admin)).rejects.toThrow('Invalid update parameters'); expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledTimes(2); expect(mockAdminAddUserToGroup).toHaveBeenCalledTimes(2); }); - // ======================================== - // Tests for deleteUser - // ======================================== + // ── deleteUser ─────────────────────────────────────────────────────────────── it('should successfully delete a user', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); // DynamoDB get - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); // DynamoDB delete - mockPromise.mockResolvedValueOnce({}); // Cognito delete - - const result = await userService.deleteUser(userToDelete, admin); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({ Attributes: user }) + .mockResolvedValueOnce({}); + const result = await userService.deleteUser(user, admin); expect(result.email).toBe('emp1@example.com'); - expect(mockGet).toHaveBeenCalled(); - // DynamoDB key is now email - expect(mockDelete).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Key: { email: 'emp1@example.com' }, - ReturnValues: 'ALL_OLD' - }); - // Cognito Username is now email - expect(mockAdminDeleteUser).toHaveBeenCalledWith({ - UserPoolId: 'test-pool-id', - Username: 'emp1@example.com' - }); + expect(mockDelete).toHaveBeenCalledWith({ TableName: 'test-users-table', Key: { email: 'emp1@example.com' }, ReturnValues: 'ALL_OLD' }); + expect(mockAdminDeleteUser).toHaveBeenCalledWith({ UserPoolId: 'test-pool-id', Username: 'emp1@example.com' }); }); - it('should throw BadRequestException when user object is invalid', async () => { + it('should throw BadRequestException for invalid user in deleteUser', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.deleteUser(null as any, admin) - ).rejects.toThrow('Valid user object is required'); - - await expect( - userService.deleteUser({ email: '' } as any, admin) - ).rejects.toThrow('Valid user object is required'); + await expect(userService.deleteUser(null as any, admin)).rejects.toThrow('Valid user object is required'); + await expect(userService.deleteUser({ email: '' } as any, admin)).rejects.toThrow('Valid user object is required'); }); - it('should throw BadRequestException when requestedBy is invalid', async () => { + it('should throw BadRequestException for invalid requestedBy', async () => { const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; - - await expect( - userService.deleteUser(user, null as any) - ).rejects.toThrow('Valid requesting user is required'); + await expect(userService.deleteUser(user, null as any)).rejects.toThrow('Valid requesting user is required'); }); - it('should throw UnauthorizedException when non-admin tries to delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp2@example.com')!; + it('should throw UnauthorizedException when non-admin deletes user', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp2@example.com')!; const employee = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; - - await expect( - userService.deleteUser(userToDelete, employee) - ).rejects.toThrow('Only administrators can delete users'); + await expect(userService.deleteUser(user, employee)).rejects.toThrow('Only administrators can delete users'); }); - it('should throw BadRequestException when admin tries to delete themselves', async () => { + it('should throw BadRequestException when admin deletes themselves', async () => { const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - - await expect( - userService.deleteUser(admin, admin) - ).rejects.toThrow('Administrators cannot delete their own account'); + await expect(userService.deleteUser(admin, admin)).rejects.toThrow('Administrators cannot delete their own account'); }); it('should throw NotFoundException when user to delete does not exist', async () => { - const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com') as User; + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; const fakeUser: User = { email: 'fake@test.com', position: UserStatus.Employee, firstName: '', lastName: '' }; - mockPromise.mockResolvedValueOnce({}); - - await expect( - userService.deleteUser(fakeUser, admin) - ).rejects.toThrow("User 'fake@test.com' does not exist"); + await expect(userService.deleteUser(fakeUser, admin)).rejects.toThrow("User 'fake@test.com' does not exist"); }); - it('should handle Cognito UserNotFoundException during delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + it('should handle Cognito UserNotFoundException during delete and rollback', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - const cognitoError = { code: 'UserNotFoundException', message: 'User not found' }; - mockPromise.mockRejectedValueOnce(cognitoError); - mockPromise.mockResolvedValueOnce({}); // Rollback restore - - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('not found in authentication system'); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({ Attributes: user }) + .mockRejectedValueOnce({ code: 'UserNotFoundException', message: 'User not found' }) + .mockResolvedValueOnce({}); - expect(mockPut).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Item: userToDelete - }); + await expect(userService.deleteUser(user, admin)).rejects.toThrow('not found in authentication system'); + expect(mockPut).toHaveBeenCalledWith({ TableName: 'test-users-table', Item: user }); }); it('should rollback DynamoDB delete if Cognito delete fails', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - const cognitoError = { code: 'InternalError', message: 'Cognito internal error' }; - mockPromise.mockRejectedValueOnce(cognitoError); - mockPromise.mockResolvedValueOnce({}); // Rollback + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockResolvedValueOnce({ Attributes: user }) + .mockRejectedValueOnce({ code: 'InternalError', message: 'Cognito internal error' }) + .mockResolvedValueOnce({}); - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('Failed to delete user from authentication system'); - - expect(mockPut).toHaveBeenCalledWith({ - TableName: 'test-users-table', - Item: userToDelete - }); + await expect(userService.deleteUser(user, admin)).rejects.toThrow('Failed to delete user from authentication system'); + expect(mockPut).toHaveBeenCalledWith({ TableName: 'test-users-table', Item: user }); }); - it('should handle DynamoDB delete failure', async () => { - const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + it('should handle DynamoDB delete failure without calling Cognito', async () => { + const user = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - const dynamoError = { code: 'ResourceNotFoundException', message: 'Table not found' }; - mockPromise.mockRejectedValueOnce(dynamoError); - - await expect( - userService.deleteUser(userToDelete, admin) - ).rejects.toThrow('Failed to delete user from database'); + mockPromise + .mockResolvedValueOnce({ Item: user }) + .mockRejectedValueOnce({ code: 'ResourceNotFoundException', message: 'Table not found' }); + await expect(userService.deleteUser(user, admin)).rejects.toThrow('Failed to delete user from database'); expect(mockAdminDeleteUser).not.toHaveBeenCalled(); }); }); \ No newline at end of file diff --git a/backend/src/user/types/user.types.ts b/backend/src/user/types/user.types.ts index dcc903f..b067502 100644 --- a/backend/src/user/types/user.types.ts +++ b/backend/src/user/types/user.types.ts @@ -3,6 +3,11 @@ import { UserStatus } from '../../../../middle-layer/types/UserStatus'; import { User } from '../../types/User'; export class ChangeRoleBody { - user!: User + user!: User; groupName!: UserStatus; +} + +export class UploadProfilePicBody{ + user! : User ; + file! : Express.Multer.File; } \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index d599d81..e433896 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,10 +1,11 @@ -import { Controller, Get, Delete, Body, Param, UseGuards, Req, Post } from "@nestjs/common"; +import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req, Post, UseInterceptors, UploadedFile, BadRequestException } from "@nestjs/common"; import { UserService } from "./user.service"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; import { VerifyAdminRoleGuard, VerifyUserGuard, VerifyAdminOrEmployeeRoleGuard } from "../guards/auth.guard"; -import { ApiResponse, ApiParam , ApiBearerAuth} from "@nestjs/swagger"; -import { ChangeRoleBody } from "./types/user.types"; +import { ApiResponse, ApiParam , ApiBearerAuth, ApiOperation, ApiConsumes, ApiBody} from "@nestjs/swagger"; +import { ChangeRoleBody, UploadProfilePicBody } from "./types/user.types"; +import { FileInterceptor } from "@nestjs/platform-express"; @Controller("user") export class UserController { @@ -231,4 +232,93 @@ export class UserController { async getUserById(@Param('email') email: string): Promise { return await this.userService.getUserByEmail(email); } + + @Post('upload-pfp') +@ApiOperation({ + summary: 'Upload profile picture', + description: 'Uploads a profile picture for a user to S3 and updates the user record in DynamoDB with the image URL. Returns the S3 URL of the uploaded image.' +}) +@ApiConsumes('multipart/form-data') +@ApiBody({ + description: 'Profile picture upload with user information', + schema: { + type: 'object', + required: ['profilePic', 'user'], + properties: { + profilePic: { + type: 'string', + format: 'binary', + description: 'Image file (jpg, jpeg, png, gif, webp). Max size: 5MB' + }, + user: { + type: 'string', + description: 'User object as JSON string containing userId, position, and email', + example: '{"firstName":"john", "lastName":"doe","position":"Employee","email":"john@example.com"}' + } + } + } +}) +@ApiResponse({ + status: 201, + description: 'Profile picture uploaded successfully. Returns the S3 URL of the uploaded image.', + schema: { + type: 'string', + example: 'https://bcan-pics.s3.amazonaws.com/user-123-profilepic.jpg', + description: 'Full S3 URL where the profile picture is stored' + } +}) +@ApiResponse({ + status: 400, + description: 'Bad Request - Invalid file type, file too large, invalid user data format, missing required fields, or JSON parse error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 400 }, + message: { + type: 'string', + example: 'Invalid file type. Allowed types: image/jpeg, image/jpg, image/png, image/gif, image/webp' + }, + error: { type: 'string', example: 'Bad Request' } + } + } +}) +@ApiResponse({ + status: 401, + description: 'Unauthorized - Missing or invalid authentication token' +}) +@ApiResponse({ + status: 403, + description: 'Forbidden - User does not have permission to upload profile pictures' +}) +@ApiResponse({ + status: 500, + description: 'Internal Server Error - S3 upload failed, DynamoDB update failed, or server configuration error', + schema: { + type: 'object', + properties: { + statusCode: { type: 'number', example: 500 }, + message: { + type: 'string', + example: 'Failed to upload profile picture' + }, + error: { type: 'string', example: 'Internal Server Error' } + } + } +}) +@UseGuards(VerifyAdminOrEmployeeRoleGuard) +@ApiBearerAuth() +@UseInterceptors(FileInterceptor('profilePic')) +async uploadProfilePic( + @UploadedFile() file: Express.Multer.File, + @Body('user') userJson: string, +): Promise { + try { + // Parse the JSON string to User object + const user: User = JSON.parse(userJson); + + return await this.userService.uploadProfilePic(user, file); + } catch (error) { + throw new BadRequestException('Invalid user data format'); + } +} } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 893ce5f..db04a5f 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -22,7 +22,126 @@ export class UserService { private readonly logger = new Logger(UserService.name); private dynamoDb = new AWS.DynamoDB.DocumentClient(); private ses = new AWS.SES({ region: process.env.AWS_REGION }); + private s3 = new AWS.S3(); + private profilePicBucket : string = process.env.PROFILE_PICTURE_BUCKET!; +async uploadProfilePic(user: User, pic: Express.Multer.File): Promise { + const tableName = process.env.DYNAMODB_USER_TABLE_NAME; + + // 1. Validate all inputs + this.validateUploadInputs(user, pic, tableName); + + // 2. Generate filename: firstName-lastName-profilepic.ext + const fileExtension = pic.originalname.split('.').pop()?.toLowerCase() || 'jpg'; + const key = `${user.firstName}-${user.lastName}-${user.email.slice(0,3)}-profilepic.${fileExtension}`; + + this.logger.log(`Uploading profile picture for user ${user.firstName} ${user.lastName} with key: ${key}`); + + try { + // 3. Upload to S3 + const uploadParams: AWS.S3.PutObjectRequest = { + Bucket: this.profilePicBucket, + Key: key, + Body: pic.buffer, + ContentType: pic.mimetype, + }; + + const uploadResult = await this.s3.upload(uploadParams).promise(); + this.logger.log(`✓ Profile picture uploaded to S3: ${uploadResult.Location}`); + + // 4. Update user's profile picture URL in DynamoDB + const updateParams = { + TableName: tableName!, + Key: { email: user.email }, + UpdateExpression: "SET profilePicUrl = :url", + ExpressionAttributeValues: { + ":url": uploadResult.Location, + }, + ReturnValues: "ALL_NEW" as const, + }; + + const updateResult = await this.dynamoDb.update(updateParams).promise(); + + if (!updateResult.Attributes) { + this.logger.error(`DynamoDB update did not return updated attributes for ${user.email}`); + throw new InternalServerErrorException("Failed to retrieve updated user data"); + } + + this.logger.log(`✅ Profile picture uploaded successfully for user ${user.email}`); + return updateResult.Attributes.profilePicUrl; + + } catch (error: any) { + this.logger.error(`Failed to upload profile picture for ${user.email}:`, error); + + // Handle S3 errors + if (error.code === 'NoSuchBucket') { + this.logger.error(`S3 bucket does not exist: ${this.profilePicBucket}`); + throw new InternalServerErrorException('Storage bucket not found'); + } else if (error.code === 'AccessDenied') { + this.logger.error('Access denied to S3 bucket'); + throw new InternalServerErrorException('Insufficient permissions to upload file'); + } + + // Handle DynamoDB errors + if (error.code === 'ResourceNotFoundException') { + this.logger.error('DynamoDB table does not exist'); + throw new InternalServerErrorException('Database table not found'); + } else if (error.code === 'ValidationException') { + this.logger.error(`Invalid DynamoDB update parameters`); + throw new BadRequestException(`Invalid update parameters`); + } + + if (error instanceof HttpException) { + throw error; + } + + this.logger.error(`Failed to upload profile pic error: ${error}`) + throw new InternalServerErrorException('Failed to upload profile picture'); + } +} + +// Validation helper method for profile picture uploads +private validateUploadInputs(user: User, pic: Express.Multer.File, tableName: string | undefined): void { + // Validate environment variables + if (!this.profilePicBucket) { + this.logger.error("Profile Picture Bucket is not defined in environment variables"); + throw new InternalServerErrorException("Server configuration error"); + } + + if (!user || !user.firstName || !user.lastName || !user.email) { + this.logger.error("Invalid user object provided for upload"); + throw new BadRequestException("Valid user object is required"); + } + + if (!tableName) { + this.logger.error("DynamoDB User Table Name is not defined in environment variables"); + throw new InternalServerErrorException("Server configuration error"); + } + + // Validate file exists + if (!pic || !pic.buffer) { + this.logger.error("Invalid file provided for upload"); + throw new BadRequestException("Valid image file is required"); + } + + // Validate file type + const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedMimeTypes.includes(pic.mimetype)) { + this.logger.error(`Invalid file type: ${pic.mimetype}`); + throw new BadRequestException( + `Invalid file type. Allowed types: ${allowedMimeTypes.join(', ')}` + ); + } + + // Validate file size (5MB max) + const maxSizeInBytes = 5 * 1024 * 1024; + if (pic.size > maxSizeInBytes) { + this.logger.error(`File too large: ${pic.size} bytes`); + throw new BadRequestException( + `File too large. Maximum size: ${maxSizeInBytes / (1024 * 1024)}MB` + ); + } +} // purpose statement: deletes user from database; only admin can delete users // use case: employee is no longer with BCAN async deleteUser(user: User, requestedBy: User): Promise {