diff --git a/backend/src/auth/__test__/auth.service.spec.ts b/backend/src/auth/__test__/auth.service.spec.ts index 9b51cdcc..10841e3e 100644 --- a/backend/src/auth/__test__/auth.service.spec.ts +++ b/backend/src/auth/__test__/auth.service.spec.ts @@ -16,7 +16,6 @@ vi.mock('../../guards/auth.guard', () => ({ }), })); -// Create mock functions for Cognito operations - at module level for test access const mockAdminCreateUser = vi.fn(); const mockAdminSetUserPassword = vi.fn(); const mockInitiateAuth = vi.fn(); @@ -26,14 +25,12 @@ const mockAdminAddUserToGroup = vi.fn(); const mockAdminDeleteUser = vi.fn(); const mockCognitoPromise = vi.fn(); -// Create mock functions for DynamoDB operations - at module level for test access const mockDynamoGet = vi.fn(); const mockDynamoPut = vi.fn(); const mockDynamoUpdate = vi.fn(); const mockDynamoScan = vi.fn(); const mockDynamoPromise = vi.fn(); -// Mock AWS SDK - reference module-level mocks vi.mock('aws-sdk', () => { return { CognitoIdentityServiceProvider: vi.fn(function() { @@ -66,7 +63,6 @@ vi.mock('aws-sdk', () => { describe("AuthService", () => { let service: AuthService; - // Set up environment variables for testing beforeAll(() => { process.env.COGNITO_USER_POOL_ID = "test-user-pool-id"; process.env.COGNITO_CLIENT_ID = "test-client-id"; @@ -76,10 +72,8 @@ describe("AuthService", () => { }); beforeEach(async () => { - // Clear all mocks before each test vi.clearAllMocks(); - // Setup Cognito mocks to return chainable objects with .promise() mockAdminCreateUser.mockReturnValue({ promise: mockCognitoPromise }); mockAdminSetUserPassword.mockReturnValue({ promise: mockCognitoPromise }); mockInitiateAuth.mockReturnValue({ promise: mockCognitoPromise }); @@ -88,7 +82,6 @@ describe("AuthService", () => { mockAdminAddUserToGroup.mockReturnValue({ promise: mockCognitoPromise }); mockAdminDeleteUser.mockReturnValue({ promise: mockCognitoPromise }); - // Setup DynamoDB mocks to return chainable objects with .promise() mockDynamoGet.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoPut.mockReturnValue({ promise: mockDynamoPromise }); mockDynamoUpdate.mockReturnValue({ promise: mockDynamoPromise }); @@ -100,38 +93,31 @@ describe("AuthService", () => { service = module.get(AuthService); - // Reset mock promises to default resolved state mockCognitoPromise.mockResolvedValue({}); mockDynamoPromise.mockResolvedValue({}); }); describe("register", () => { - // 1. Set up mock responses for Cognito adminCreateUser and adminSetUserPassword - // 2. Set up mock response for DynamoDB put operation - // 3. Call service.register with test data - // 4. Assert that Cognito methods were called with correct parameters - // 5. Assert that DynamoDB put was called with correct user data it("should successfully register a user", async () => { - // Ensure scan returns no items (email not in use) - mockDynamoPromise.mockResolvedValueOnce({ Items: [] }); - // Ensure get returns no items (username not in use) - mockDynamoPromise.mockResolvedValueOnce({}); - - // Cognito promise chain - // adminCreateUser().promise() - mockCognitoPromise.mockResolvedValueOnce({}); - // adminSetUserPassword().promise() + // adminCreateUser returns a user with sub attribute + mockCognitoPromise.mockResolvedValueOnce({ + User: { + Attributes: [{ Name: "sub", Value: "test-sub-123" }], + }, + }); + // adminSetUserPassword mockCognitoPromise.mockResolvedValueOnce({}); - // adminAddUserToGroup().promise() - needs to be mocked here + // adminAddUserToGroup mockCognitoPromise.mockResolvedValueOnce({}); - // DynamoDB put().promise() + // DynamoDB put mockDynamoPromise.mockResolvedValueOnce({}); - await service.register("c4c", "Pass123!", "c4c@example.com"); + // register now takes (email, password, firstName, lastName) + await service.register("c4c@example.com", "Pass123!", "John", "Doe"); expect(mockAdminCreateUser).toHaveBeenCalledWith({ UserPoolId: "test-user-pool-id", - Username: "c4c", + Username: "c4c@example.com", UserAttributes: [ { Name: "email", Value: "c4c@example.com" }, { Name: "email_verified", Value: "true" }, @@ -141,7 +127,7 @@ describe("AuthService", () => { expect(mockAdminSetUserPassword).toHaveBeenCalledWith({ UserPoolId: "test-user-pool-id", - Username: "c4c", + Username: "c4c@example.com", Password: "Pass123!", Permanent: true, }); @@ -149,81 +135,88 @@ describe("AuthService", () => { expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ GroupName: "Inactive", UserPoolId: "test-user-pool-id", - Username: "c4c", + Username: "c4c@example.com", }); expect(mockDynamoPut).toHaveBeenCalledWith( expect.objectContaining({ TableName: "test-users-table", Item: expect.objectContaining({ - userId: "c4c", email: "c4c@example.com", position: "Inactive", + firstName: "John", + lastName: "Doe", }), }) ); }); - it("should deny someone from making an email when it is already in use", async () => {}); + it("should deny someone from making an account when email is already in use", async () => { + mockCognitoPromise.mockRejectedValueOnce({ + 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"); + }); }); describe("login", () => { - // 1. Mock successful Cognito initiateAuth response with tokens - // 2. Mock Cognito getUser response with user attributes - // 3. Mock DynamoDB get to return existing user - // 4. Call service.login and verify returned token and user data it("should successfully login existing user", async () => { - // Mock Cognito initiateAuth - const mockInitiateAuth = () => ({ + const mockInitiateAuthFn = () => ({ promise: () => Promise.resolve({ AuthenticationResult: { IdToken: "id-token", AccessToken: "access-token", + RefreshToken: "refresh-token", }, }), }); - // Mock Cognito getUser - const mockGetUser = () => ({ + const mockGetUserFn = () => ({ promise: () => Promise.resolve({ - UserAttributes: [{ Name: "email", Value: "c4c@example.com" }], + UserAttributes: [ + { Name: "email", Value: "c4c@example.com" }, + { Name: "sub", Value: "test-sub-123" }, + ], }), }); - // Mock DynamoDB get to return existing user - const mockGet = () => ({ + // DynamoDB get returns existing user keyed by email + const mockGetFn = () => ({ promise: () => Promise.resolve({ - Item: { userId: "c4c", - email: "c4c@example.com", - position: "Inactive", }, + Item: { + email: "c4c@example.com", + position: "Inactive", + firstName: "John", + lastName: "Doe", + }, }), }); - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - (service["cognito"] as any).getUser = mockGetUser; - (service["dynamoDb"] as any).get = mockGet; + (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; + (service["cognito"] as any).getUser = mockGetUserFn; + (service["dynamoDb"] as any).get = mockGetFn; - // Call the login method - const result = await service.login("c4c", "Pass123!"); + const result = await service.login("c4c@example.com", "Pass123!"); - // Verify the results expect(result.access_token).toBe("access-token"); expect(result.user).toEqual({ - userId: "c4c", email: "c4c@example.com", position: "Inactive", + firstName: "John", + lastName: "Doe", }); expect(result.message).toBe("Login Successful!"); }); - // 1. Mock Cognito initiateAuth to return NEW_PASSWORD_REQUIRED challenge - // 2. Call service.login and verify challenge response structure it("should handle NEW_PASSWORD_REQUIRED challenge", async () => { - // Mock Cognito initiateAuth - const mockInitiateAuth = () => ({ + const mockInitiateAuthFn = () => ({ promise: () => Promise.resolve({ ChallengeName: "NEW_PASSWORD_REQUIRED", @@ -234,73 +227,68 @@ describe("AuthService", () => { }), }); - // Replace the cognito method with mock - (service["cognito"] as any).initiateAuth = mockInitiateAuth; + (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; - // Call login and expect challenge response - const result = await service.login("c4c", "newPassword"); + const result = await service.login("c4c@example.com", "newPassword"); - // Verify the challenge response expect(result.challenge).toBe("NEW_PASSWORD_REQUIRED"); expect(result.session).toBe("session-123"); expect(result.requiredAttributes).toEqual(["email"]); - expect(result.username).toBe("c4c"); + // 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 mockInitiateAuth = () => ({ + const mockInitiateAuthFn = () => ({ promise: () => Promise.resolve({ AuthenticationResult: { IdToken: "id-token", AccessToken: "access-token", + RefreshToken: "refresh-token", }, }), }); - // Mock getUser - const mockGetUser = () => ({ + const mockGetUserFn = () => ({ promise: () => Promise.resolve({ - UserAttributes: [{ Name: "email", Value: "c4c@gmail.com" }], + UserAttributes: [ + { Name: "email", Value: "c4c@gmail.com" }, + { Name: "sub", Value: "test-sub-456" }, + ], }), }); - // Mock DynamoDB - const mockGet = () => ({ + // DynamoDB get returns nothing (user doesn't exist yet) + const mockGetFn = () => ({ promise: () => Promise.resolve({}), }); - // Mock DynamoDB put - const mockPut = () => ({ + const mockPutFn = () => ({ promise: () => Promise.resolve({}), }); - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - (service["cognito"] as any).getUser = mockGetUser; - (service["dynamoDb"] as any).get = mockGet; - (service["dynamoDb"] as any).put = mockPut; + (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; + (service["cognito"] as any).getUser = mockGetUserFn; + (service["dynamoDb"] as any).get = mockGetFn; + (service["dynamoDb"] as any).put = mockPutFn; - const result = await service.login("c4c", "Pass123!"); + const result = await service.login("c4c@gmail.com", "Pass123!"); expect(result.access_token).toBe("access-token"); expect(result.user).toEqual({ - userId: "c4c", email: "c4c@gmail.com", position: "Inactive", + firstName: "", + lastName: "", }); expect(result.message).toBe("Login Successful!"); - - expect(result.user.userId).toBe("c4c"); - expect(result.user.email).toBe("c4c@gmail.com"); }); - // 1. Mock Cognito to throw NotAuthorizedException - // 2. Verify UnauthorizedException is thrown by service it("should handle NotAuthorizedException", async () => { - const mockInitiateAuth = () => ({ + const mockInitiateAuthFn = () => ({ promise: () => Promise.reject({ code: "NotAuthorizedException", @@ -308,19 +296,17 @@ describe("AuthService", () => { }), }); - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - await expect(service.login("c4c", "wrongpassword")).rejects.toThrow( + (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; + await expect(service.login("c4c@example.com", "wrongpassword")).rejects.toThrow( UnauthorizedException ); }); - // 1. Remove environment variables - // 2. Expect service to throw configuration error it("should handle missing client credentials", async () => { delete process.env.COGNITO_CLIENT_ID; delete process.env.COGNITO_CLIENT_SECRET; - await expect(service.login("john", "123")).rejects.toThrow( + await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( "Cognito Client ID or Secret is not defined." ); @@ -328,20 +314,18 @@ describe("AuthService", () => { process.env.COGNITO_CLIENT_SECRET = "test-client-secret"; }); - // 1. Mock Cognito to return response without required tokens - // 2. Verify appropriate error is thrown it("should handle missing tokens in response", async () => { mockCognitoPromise.mockResolvedValueOnce({ AuthenticationResult: {}, }); - await expect(service.login("c4c", "c4c@gmail.com")).rejects.toThrow( + await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( "Authentication failed: Missing IdToken or AccessToken" ); }); - it("handle generic Cognito errors with descriptive message", async () => { - const mockInitiateAuth = () => ({ + it("should handle generic Cognito errors", async () => { + const mockInitiateAuthFn = () => ({ promise: () => Promise.reject({ code: "SomeAwsError", @@ -349,20 +333,16 @@ describe("AuthService", () => { }), }); - (service["cognito"] as any).initiateAuth = mockInitiateAuth; - await expect(service.login("testuser", "Pass123!")).rejects.toThrow( + (service["cognito"] as any).initiateAuth = mockInitiateAuthFn; + await expect(service.login("c4c@example.com", "Pass123!")).rejects.toThrow( InternalServerErrorException ); }); }); describe("setNewPassword", () => { - // 1. Mock successful respondToAuthChallenge response - // 2. Call service.setNewPassword with test data - // 3. Verify correct parameters passed to Cognito - // 4. Verify returned access token it("should successfully set new password", async () => { - const mockRespondToAuthChallenge = () => ({ + const mockRespondFn = () => ({ promise: () => Promise.resolve({ AuthenticationResult: { @@ -371,65 +351,52 @@ describe("AuthService", () => { }), }); - (service["cognito"] as any).respondToAuthChallenge = - mockRespondToAuthChallenge; + (service["cognito"] as any).respondToAuthChallenge = mockRespondFn; + // setNewPassword no longer takes a separate username — just (newPassword, session, email) const result = await service.setNewPassword( "NewPass123!", "session123", - "c4c", - "c4c@gmail.com" + "c4c@example.com" ); expect(result.access_token).toBe("new-id-token"); }); - // 1. Mock Cognito to return response without IdToken - // 2. Verify error is thrown it("should handle failed password setting", async () => { mockCognitoPromise.mockResolvedValueOnce({ AuthenticationResult: {}, }); await expect( - service.setNewPassword("NewPass123!", "s123", "c4c") + service.setNewPassword("NewPass123!", "s123", "c4c@example.com") ).rejects.toThrow("Failed to set new password"); }); - // 1. Mock Cognito to throw error - // 2. Verify error handling it("should handle Cognito errors", async () => { - const mockRespondToAuthChallenge = () => ({ + const mockRespondFn = () => ({ promise: () => Promise.reject(new Error("Cognito Error")), }); - (service["cognito"] as any).respondToAuthChallenge = - mockRespondToAuthChallenge; + (service["cognito"] as any).respondToAuthChallenge = mockRespondFn; await expect( - service.setNewPassword("NewPass123!", "s123", "c4c") + service.setNewPassword("NewPass123!", "s123", "c4c@example.com") ).rejects.toThrow("Cognito Error"); }); }); describe("updateProfile", () => { - // 1. Mock successful DynamoDB update - // 2. Call service.updateProfile with test data - // 3. Verify correct UpdateExpression and parameters it("should successfully update user profile", async () => { mockDynamoPromise.mockResolvedValueOnce({}); - await service.updateProfile( - "c4c", - "c4c@example.com", - "Software Developer" - ); + // updateProfile now takes (email, position_or_role) — no username + await service.updateProfile("c4c@example.com", "Software Developer"); expect(mockDynamoUpdate).toHaveBeenCalledWith( expect.objectContaining({ - Key: { userId: "c4c" }, - UpdateExpression: - "SET email = :email, position_or_role = :position_or_role", + 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", @@ -438,44 +405,49 @@ describe("AuthService", () => { ); }); - // 1. Mock DynamoDB to throw error - // 2. Verify error handling - it("handle DynamoDB update errors", async () => { - const mockUpdate = vi.fn().mockReturnValue({ - promise: vi.fn().mockRejectedValue(new Error("DB error")) + it("should handle DynamoDB update errors", async () => { + const mockUpdateFn = vi.fn().mockReturnValue({ + promise: vi.fn().mockRejectedValue(new Error("DB error")), }); - - (service['dynamoDb'] as any).update = mockUpdate; + + (service['dynamoDb'] as any).update = mockUpdateFn; await expect( - service.updateProfile("c4c", "c4c@example.com", "Active") + service.updateProfile("c4c@example.com", "Active") ).rejects.toThrow("DB error"); }); }); describe("validateSession", () => { it("should successfully validate a session", async () => { - // Mock Cognito getUser response + // Cognito getUser returns email in attributes mockCognitoPromise.mockResolvedValueOnce({ - Username: "c4c", + Username: "c4c@example.com", UserAttributes: [{ Name: "email", Value: "c4c@example.com" }], }); - // Mock DynamoDB get response + // DynamoDB get returns user keyed by email mockDynamoPromise.mockResolvedValueOnce({ - Item: { userId: "c4c", email: "c4c@example.com", position: "Active" }, + Item: { email: "c4c@example.com", position: "Active", firstName: "John", lastName: "Doe" }, }); const result = await service.validateSession("valid-token"); expect(result).toEqual({ - userId: "c4c", email: "c4c@example.com", position: "Active", + firstName: "John", + lastName: "Doe", }); }); 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); diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index b5ab53e6..b2404528 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -77,7 +77,7 @@ export class AuthController { async register( @Body() body: RegisterBody ): Promise<{ message: string }> { - await this.authService.register(body.username, body.password, body.email); + await this.authService.register(body.email, body.password,body.firstName,body.lastName); return { message: 'User registered successfully' }; } @@ -110,10 +110,9 @@ export class AuthController { session?: string; challenge?: string; requiredAttributes?: string[]; - username?: string; position?: string; }> { - const result = await this.authService.login(body.username, body.password); + const result = await this.authService.login(body.email, body.password); // Set cookie with access token if (result.access_token) { @@ -125,7 +124,32 @@ export class AuthController { path: '/', // Cookie available on all routes }); } + + if (result.refreshToken) { + console.log("refresh token set") + response.cookie('refresh_token', result.refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days (match your Cognito refresh token expiry) + path: '/auth/refresh', // more restrictive path than access token + }); +} + + if (result.idToken) { + response.cookie('id_token', result.idToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 3600000, // 1 hour, same expiry as access token + path: '/', + }); + } + + + delete result.idToken; delete result.access_token; + delete result.refreshToken; return result } @@ -156,12 +180,12 @@ export class AuthController { async setNewPassword( @Body() body: SetPasswordBody ): Promise<{ message: string }> { - await this.authService.setNewPassword(body.newPassword, body.session, body.username, body.email); + await this.authService.setNewPassword(body.newPassword, body.session, body.email); return { message: 'Password has been set successfully' }; } /** - * Update user profile for username, email, and position_or_role + * Update user profile for email, and position_or_role */ @Post('update-profile') @UseGuards(VerifyUserGuard) @@ -186,7 +210,7 @@ export class AuthController { async updateProfile( @Body() body: UpdateProfileBody ): Promise<{ message: string }> { - await this.authService.updateProfile(body.username, body.email, body.position_or_role); + await this.authService.updateProfile(body.email, body.position_or_role); return { message: 'Profile has been updated' }; } diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 6ea9da23..4b9f2d03 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -23,9 +23,9 @@ export class AuthService { private dynamoDb; private computeHatch( - username: string, + email: string, clientId: string, - clientSecret: string + clientSecret: string, ): string { const hatch = process.env.FISH_EYE_LENS; if (!hatch) { @@ -33,302 +33,291 @@ export class AuthService { } return crypto .createHmac(hatch, clientSecret) - .update(username + clientId) + .update(email + clientId) .digest("base64"); } + constructor() { + try { + this.logger.log("Starting AuthService constructor..."); + this.logger.log("AWS module:", typeof AWS); + this.logger.log( + "AWS.CognitoIdentityServiceProvider:", + typeof AWS.CognitoIdentityServiceProvider, + ); -constructor() { - try { - this.logger.log('Starting AuthService constructor...'); - this.logger.log('AWS module:', typeof AWS); - this.logger.log('AWS.CognitoIdentityServiceProvider:', typeof AWS.CognitoIdentityServiceProvider); - - this.cognito = new AWS.CognitoIdentityServiceProvider(); - this.logger.log('Cognito initialized successfully'); - - this.dynamoDb = new AWS.DynamoDB.DocumentClient(); - this.logger.log('DynamoDB initialized successfully'); - - this.logger.log('AuthService constructor completed'); - } catch (error) { - this.logger.error('FATAL: AuthService constructor failed:', error); - throw error; - } -} - - - // purpose statement: registers an user into cognito and dynamodb - // use case: new employee is joining - async register( - username: string, - password: string, - email: string -): Promise { - const userPoolId = process.env.COGNITO_USER_POOL_ID; - const tableName = process.env.DYNAMODB_USER_TABLE_NAME; - - // Validate environment variables - if (!userPoolId) { - this.logger.error("Cognito User Pool ID is not defined in environment variables."); - throw new InternalServerErrorException("Server configuration error"); - } - - // Validate environment variables - if (!tableName) { - this.logger.error("DynamoDB User Table Name is not defined in environment variables."); - throw new InternalServerErrorException("Server configuration error"); - } - - // Validate input parameters for username, password, and email - if (!username || username.trim().length === 0) { - this.logger.error("Registration failed: Username is required"); - throw new BadRequestException("Username is required"); - } + this.cognito = new AWS.CognitoIdentityServiceProvider(); + this.logger.log("Cognito initialized successfully"); - if (!password || password.length < 8) { - this.logger.error("Registration failed: Password must be at least 8 characters long"); - throw new BadRequestException("Password must be at least 8 characters long"); - } + this.dynamoDb = new AWS.DynamoDB.DocumentClient(); + this.logger.log("DynamoDB initialized successfully"); - if (!email || !this.isValidEmail(email)) { - this.logger.error("Registration failed: Valid email address is required"); - throw new BadRequestException("Valid email address is required"); + this.logger.log("AuthService constructor completed"); + } catch (error) { + this.logger.error("FATAL: AuthService constructor failed:", error); + throw error; + } } - this.logger.log(`Starting registration for username: ${username}, email: ${email}`); - - try { - // Step 1: Check if email already exists in DynamoDB - this.logger.log(`Checking if email ${email} is already in use...`); - - const emailCheckParams = { - TableName: tableName, - FilterExpression: "#email = :email", - ExpressionAttributeNames: { - "#email": "email", - }, - ExpressionAttributeValues: { - ":email": email, - }, - }; - - const emailCheckResult = await this.dynamoDb.scan(emailCheckParams).promise(); - - if (emailCheckResult.Items && emailCheckResult.Items.length > 0) { - this.logger.error(`Registration failed: Email ${email} already exists`); - throw new ConflictException("An account with this email already exists"); + // purpose statement: registers an user into cognito and dynamodb + // use case: new employee is joining + async register( + email: string, + password: string, + firstName: string, + lastName: string, + ): Promise { + const userPoolId = process.env.COGNITO_USER_POOL_ID; + const tableName = process.env.DYNAMODB_USER_TABLE_NAME; + + if (!userPoolId) { + this.logger.error( + "Cognito User Pool ID is not defined in environment variables.", + ); + throw new InternalServerErrorException("Server configuration error"); } - // Step 2: Check if username already exists in DynamoDB - this.logger.log(`Checking if username ${username} is already in use...`); - - const usernameCheckParams = { - TableName: tableName, - Key: { userId: username }, - }; + if (!tableName) { + this.logger.error( + "DynamoDB User Table Name is not defined in environment variables.", + ); + throw new InternalServerErrorException("Server configuration error"); + } - const usernameCheckResult = await this.dynamoDb.get(usernameCheckParams).promise(); + if (!email || !this.isValidEmail(email)) { + this.logger.error("Registration failed: Valid email address is required"); + throw new BadRequestException("Valid email address is required"); + } - if (usernameCheckResult.Item) { - this.logger.error(`Registration failed: Username ${username} already exists`); - throw new ConflictException("This username is already taken"); + if (!password || password.length < 8) { + this.logger.error( + "Registration failed: Password must be at least 8 characters long", + ); + throw new BadRequestException( + "Password must be at least 8 characters long", + ); } - this.logger.log(`Email and username are unique. Proceeding with Cognito user creation...`); + this.logger.log(`Starting registration for email: ${email}`); - // Step 3: Create user in Cognito - let cognitoUserCreated = false; - try { - await this.cognito.adminCreateUser({ - UserPoolId: userPoolId, - Username: username, - UserAttributes: [ - { Name: "email", Value: email }, - { Name: "email_verified", Value: "true" }, - ], - MessageAction: "SUPPRESS", - }).promise(); - - cognitoUserCreated = true; - this.logger.log(`✓ Cognito user created successfully for ${username}`); - - } catch (cognitoError: any) { - this.logger.error(`Cognito user creation failed for ${username}:`, cognitoError); - - // Handle specific Cognito errors - if (cognitoError.code === 'UsernameExistsException') { - throw new ConflictException("Username already exists in authentication system"); - } else if (cognitoError.code === 'InvalidPasswordException') { - throw new BadRequestException("Password does not meet security requirements"); - } else if (cognitoError.code === 'InvalidParameterException') { - throw new BadRequestException(`Invalid registration parameters: ${cognitoError.message}`); - } else { - throw new InternalServerErrorException("Failed to create user account"); - } - } + // Step 1: Create user in Cognito using email as the username + let cognitoSub: string | undefined; + let cognitoUserCreated = false; - // Step 4: Set user password - try { - await this.cognito.adminSetUserPassword({ - UserPoolId: userPoolId, - Username: username, - Password: password, - Permanent: true, - }).promise(); + try { + const createUserResponse = await this.cognito + .adminCreateUser({ + UserPoolId: userPoolId, + Username: email, + UserAttributes: [ + { Name: "email", Value: email }, + { Name: "email_verified", Value: "true" }, + ], + MessageAction: "SUPPRESS", + }) + .promise(); - this.logger.log(`✓ Password set successfully for ${username}`); + // Extract the sub from the created user's attributes + cognitoSub = createUserResponse.User?.Attributes?.find( + (attr) => attr.Name === "sub", + )?.Value; - } catch (passwordError: any) { - this.logger.error(`Failed to set password for ${username}:`, passwordError); + if (!cognitoSub) { + throw new InternalServerErrorException( + "Failed to retrieve user ID after creation", + ); + } + cognitoUserCreated = true; + this.logger.log( + `✓ Cognito user created successfully for ${email}, sub: ${cognitoSub}`, + ); + } catch (cognitoError: any) { + if (cognitoError instanceof HttpException) throw cognitoError; + this.logger.error( + `Cognito user creation failed for ${email}:`, + cognitoError, + ); - // Rollback: Delete Cognito user if password setting fails - if (cognitoUserCreated) { - this.logger.warn(`Rolling back: Deleting Cognito user ${username}...`); - try { - await this.cognito.adminDeleteUser({ - UserPoolId: userPoolId, - Username: username, - }).promise(); - this.logger.log(`Rollback successful: Cognito user ${username} deleted`); - } catch (rollbackError) { - this.logger.error(`Rollback failed: Could not delete Cognito user ${username}`, rollbackError); + if (cognitoError.code === "UsernameExistsException") { + throw new ConflictException( + "An account with this email already exists", + ); + } else if (cognitoError.code === "InvalidPasswordException") { + throw new BadRequestException( + "Password does not meet security requirements", + ); + } else if (cognitoError.code === "InvalidParameterException") { + throw new BadRequestException( + `Invalid registration parameters: ${cognitoError.message}`, + ); + } else { + throw new InternalServerErrorException( + "Failed to create user account", + ); } } - if (passwordError.code === 'InvalidPasswordException') { - throw new BadRequestException("Password does not meet requirements: must be at least 8 characters with uppercase, lowercase, and numbers"); - } - throw new InternalServerErrorException("Failed to set user password"); - } - - // Step 5: Add user to Inactive group - try { - await this.cognito.adminAddUserToGroup({ - GroupName: "Inactive", - UserPoolId: userPoolId, - Username: username, - }).promise(); - - this.logger.log(`✓ User ${username} added to Inactive group`); + // Step 2: Set user password + try { + await this.cognito + .adminSetUserPassword({ + UserPoolId: userPoolId, + Username: email, + Password: password, + Permanent: true, + }) + .promise(); - } catch (groupError: any) { - this.logger.error(`Failed to add ${username} to Inactive group:`, groupError); + this.logger.log(`✓ Password set successfully for ${email}`); + } catch (passwordError: any) { + this.logger.error( + `Failed to set password for ${email}:`, + passwordError, + ); - // Rollback: Delete Cognito user - this.logger.warn(`Rolling back: Deleting Cognito user ${username}...`); - try { - await this.cognito.adminDeleteUser({ - UserPoolId: userPoolId, - Username: username, - }).promise(); - this.logger.log(`Rollback successful: Cognito user ${username} deleted`); - } catch (rollbackError) { - this.logger.error(`Rollback failed: Could not delete Cognito user ${username}`, rollbackError); - } + if (cognitoUserCreated) { + await this.rollbackCognitoUser(userPoolId, email); + } - if (groupError.code === 'ResourceNotFoundException') { - throw new InternalServerErrorException("User group 'Inactive' does not exist in the system"); + if (passwordError.code === "InvalidPasswordException") { + throw new BadRequestException( + "Password does not meet requirements: must be at least 8 characters with uppercase, lowercase, and numbers", + ); + } + throw new InternalServerErrorException("Failed to set user password"); } - throw new InternalServerErrorException("Failed to assign user group"); - } - // Step 6: Save user to DynamoDB - const user: User = { - userId: username, - position: UserStatus.Inactive, - email: email, - }; + // Step 3: Add user to Inactive group + try { + await this.cognito + .adminAddUserToGroup({ + GroupName: "Inactive", + UserPoolId: userPoolId, + Username: email, + }) + .promise(); - try { - await this.dynamoDb.put({ - TableName: tableName, - Item: user, - }).promise(); + this.logger.log(`✓ User ${email} added to Inactive group`); + } catch (groupError: any) { + this.logger.error( + `Failed to add ${email} to Inactive group:`, + groupError, + ); + await this.rollbackCognitoUser(userPoolId, email); - this.logger.log(`✓ User ${username} saved to DynamoDB successfully`); + if (groupError.code === "ResourceNotFoundException") { + throw new InternalServerErrorException( + "User group 'Inactive' does not exist in the system", + ); + } + throw new InternalServerErrorException("Failed to assign user group"); + } - } catch (dynamoError: any) { - this.logger.error(`Failed to save ${username} to DynamoDB:`, dynamoError); + // Step 4: Save user to DynamoDB using email as the key + const user: User = { + position: UserStatus.Inactive, + email: email, + firstName: firstName, + lastName: lastName, + }; - // Rollback: Delete Cognito user - this.logger.warn(`Rolling back: Deleting Cognito user ${username}...`); try { - await this.cognito.adminDeleteUser({ - UserPoolId: userPoolId, - Username: username, - }).promise(); - this.logger.log(`Rollback successful: Cognito user ${username} deleted`); - } catch (rollbackError) { - this.logger.error(`Rollback failed: Could not delete Cognito user ${username}`, rollbackError); - // Critical: User exists in Cognito but not in DynamoDB - this.logger.error(`CRITICAL: User ${username} exists in Cognito but not in DynamoDB - manual cleanup required`); + await this.dynamoDb + .put({ + TableName: tableName, + Item: user, + }) + .promise(); + + this.logger.log(`✓ User ${email} saved to DynamoDB successfully`); + } catch (dynamoError: any) { + this.logger.error(`Failed to save ${email} to DynamoDB:`, dynamoError); + await this.rollbackCognitoUser(userPoolId, email); + throw new InternalServerErrorException( + "Failed to save user data to database", + ); } - throw new InternalServerErrorException("Failed to save user data to database"); - } + this.logger.log(`✅ Registration completed successfully for ${email}`); + } catch (error) { + if (error instanceof HttpException) throw error; - this.logger.log(`✅ Registration completed successfully for ${username}`); + if (error instanceof Error) { + this.logger.error( + `Unexpected error during registration for ${email}:`, + error.stack, + ); + } else { + this.logger.error( + `Unknown error during registration for ${email}:`, + error, + ); + } - } catch (error) { - // Re-throw HTTP exceptions (validation errors, conflicts, etc.) - if (error instanceof HttpException) { - throw error; + throw new InternalServerErrorException("Internal Server Error"); } + } - // Handle unexpected errors - if (error instanceof Error) { - this.logger.error(`Unexpected error during registration for ${username}:`, error.stack); - throw new InternalServerErrorException( - `Internal Server Error` + // Helper to avoid repeating rollback logic + private async rollbackCognitoUser( + userPoolId: string, + email: string, + ): Promise { + this.logger.warn(`Rolling back: Deleting Cognito user ${email}...`); + try { + await this.cognito + .adminDeleteUser({ + UserPoolId: userPoolId, + Username: email, + }) + .promise(); + this.logger.log(`Rollback successful: Cognito user ${email} deleted`); + } catch (rollbackError) { + this.logger.error( + `Rollback failed: Could not delete Cognito user ${email}`, + rollbackError, + ); + this.logger.error( + `CRITICAL: User ${email} exists in Cognito but not in DynamoDB - manual cleanup required`, ); - } else { - this.logger.error(`Unknown error during registration for ${username}:`, error); } - - throw new InternalServerErrorException( - "Internal Server Error" - ); } -} - -// Helper method for email validation -private isValidEmail(email: string): boolean { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); -} - + // Helper method for email validation + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } // purpose statement: logs in an user via cognito and retrieves user data from dynamodb // use case: employee is trying to access the app, needs to have an account already + async login( - username: string, - password: string + email: string, + password: string, ): Promise<{ access_token?: string; + refreshToken?: string; user: User; session?: string; challenge?: string; requiredAttributes?: string[]; - username?: string; message?: string; + idToken?: string; }> { const clientId = process.env.COGNITO_CLIENT_ID; const clientSecret = process.env.COGNITO_CLIENT_SECRET; - // Validate environment variables if (!clientId || !clientSecret) { this.logger.error("Cognito Client ID or Secret is not defined."); throw new Error("Cognito Client ID or Secret is not defined."); } - // Validate input parameters for username and password - if (!username || username.trim().length === 0) { - this.logger.error("Login failed: Username is required"); - throw new BadRequestException("Username is required"); + if (!email || email.trim().length === 0) { + this.logger.error("Login failed: Email is required"); + throw new BadRequestException("Email is required"); } if (!password || password.length === 0) { @@ -336,14 +325,14 @@ private isValidEmail(email: string): boolean { throw new BadRequestException("Password is required"); } - const hatch = this.computeHatch(username, clientId, clientSecret); + // Cognito uses email as the USERNAME when pool is configured with username-attributes: email + const hatch = this.computeHatch(email, clientId, clientSecret); - // Todo, change constants of AUTH_FLOW types & other constants in repo const authParams = { AuthFlow: "USER_PASSWORD_AUTH", ClientId: clientId, AuthParameters: { - USERNAME: username, + USERNAME: email, PASSWORD: password, SECRET_HASH: hatch, }, @@ -353,20 +342,19 @@ private isValidEmail(email: string): boolean { const response = await this.cognito.initiateAuth(authParams).promise(); this.logger.debug( - `Cognito Response: ${JSON.stringify(response, null, 2)}` + `Cognito Response: ${JSON.stringify(response, null, 2)}`, ); if (response.ChallengeName === "NEW_PASSWORD_REQUIRED") { this.logger.warn(`ChallengeName: ${response.ChallengeName}`); const requiredAttributes = JSON.parse( - response.ChallengeParameters?.requiredAttributes || "[]" + response.ChallengeParameters?.requiredAttributes || "[]", ); return { challenge: "NEW_PASSWORD_REQUIRED", session: response.Session, requiredAttributes, - username, user: {} as User, }; } @@ -377,61 +365,61 @@ private isValidEmail(email: string): boolean { !response.AuthenticationResult.AccessToken ) { this.logger.error( - "Authentication failed: Missing IdToken or AccessToken" + "Authentication failed: Missing IdToken or AccessToken", ); throw new Error( - "Authentication failed: Missing IdToken or AccessToken" + "Authentication failed: Missing IdToken or AccessToken", ); } - // User Identity Information - const idToken = response.AuthenticationResult.IdToken; - // Grants access to resources const accessToken = response.AuthenticationResult.AccessToken; + const refreshToken = response.AuthenticationResult.RefreshToken; + const idToken = response.AuthenticationResult.IdToken; if (!accessToken) { throw new Error("Access token is undefined."); - } + } const getUserResponse = await this.cognito .getUser({ AccessToken: accessToken }) .promise(); - let email: string | undefined; + // Pull the Cognito sub (unique user ID) to use as DynamoDB key + let sub: string | undefined; + let resolvedEmail: string | undefined; for (const attribute of getUserResponse.UserAttributes) { - if (attribute.Name === "email") { - email = attribute.Value; - break; - } + if (attribute.Name === "sub") sub = attribute.Value; + if (attribute.Name === "email") resolvedEmail = attribute.Value; } - // Fundamental attribute check (email must exist between Cognito and Dynamo) - if (!email) { + if (!resolvedEmail) { throw new Error("Failed to retrieve user email from Cognito."); } - const tableName = process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE"; + if (!sub) { + throw new Error("Failed to retrieve user sub from Cognito."); + } - this.logger.debug("user response..?" + tableName); + const tableName = process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE"; + // Use sub as the DynamoDB key instead of username const params = { TableName: tableName, Key: { - userId: username, + email: email, }, }; - // Grab table reference for in-app use const userResult = await this.dynamoDb.get(params).promise(); let user = userResult.Item as User; - // Investigage this further it doesnt really make sense if (!user) { const newUser: User = { - userId: username, - email: email, + email: resolvedEmail, position: UserStatus.Inactive, + firstName: "", + lastName: "", }; await this.dynamoDb @@ -444,38 +432,45 @@ private isValidEmail(email: string): boolean { user = newUser; } - return { access_token: accessToken, user, message: "Login Successful!" }; + return { + access_token: accessToken, + user, + refreshToken, + idToken, + message: "Login Successful!", + }; } catch (error: unknown) { - /* Login Failures */ const cognitoError = error as AwsCognitoError; if (cognitoError.code) { switch (cognitoError.code) { case "NotAuthorizedException": this.logger.error(`Login failed: ${cognitoError.message}`); - throw new UnauthorizedException("Incorrect username or password."); + throw new UnauthorizedException("Incorrect email or password."); default: this.logger.error( `Login failed: ${cognitoError.message}`, - cognitoError.stack + cognitoError.stack, ); throw new InternalServerErrorException( - "An error occurred during login." + "An error occurred during login.", ); } } else if (error instanceof BadRequestException) { throw error; } else if (error instanceof Error) { - // Handle non-AWS errors this.logger.error("Login failed", error.stack); throw new InternalServerErrorException( - error.message || "Login failed." + error.message || "Login failed.", ); } - // Handle unknown errors - this.logger.error(`Login failed for user ${username} with unknown error type`, error); + + this.logger.error( + `Login failed for user ${email} with unknown error type`, + error, + ); throw new InternalServerErrorException( - "An unknown error occurred during login." + "An unknown error occurred during login.", ); } } @@ -485,8 +480,7 @@ private isValidEmail(email: string): boolean { async setNewPassword( newPassword: string, session: string, - username: string, - email?: string + email: string, ): Promise<{ access_token: string }> { const clientId = process.env.COGNITO_CLIENT_ID; const clientSecret = process.env.COGNITO_CLIENT_SECRET; @@ -507,15 +501,12 @@ private isValidEmail(email: string): boolean { throw new BadRequestException("Session is required"); } - if (!username || username.trim().length === 0) { - this.logger.error("Set New Password failed: Username is required"); - throw new BadRequestException("Username is required"); - } + - const hatch = this.computeHatch(username, clientId, clientSecret); + const hatch = this.computeHatch(email, clientId, clientSecret); const challengeResponses: any = { - USERNAME: username, + USERNAME: email, NEW_PASSWORD: newPassword, SECRET_HASH: hatch, }; @@ -536,7 +527,7 @@ private isValidEmail(email: string): boolean { const response = await this.cognito .respondToAuthChallenge(params) .promise(); - this.logger.log("Responded to auth challenge for new password"); + this.logger.log("Responded to auth challenge for new password"); if ( !response.AuthenticationResult || @@ -546,7 +537,7 @@ private isValidEmail(email: string): boolean { } const token = response.AuthenticationResult.IdToken; - this.logger.log(`New password set successfully for user ${username}`); + this.logger.log(`New password set successfully for user ${email}`); return { access_token: token }; } catch (error: unknown) { if (error instanceof Error) { @@ -559,18 +550,10 @@ private isValidEmail(email: string): boolean { // purpose statement: updates user profile info in dynamodb // use case: employee is updating their profile information - async updateProfile( - username: string, - email: string, - position_or_role: string - ) { + async updateProfile(email: string, position_or_role: string) { // Validate input parameters for username, email, and position_or_role - if (!username || username.trim().length === 0) { - this.logger.error("Update Profile failed: Username is required"); - throw new BadRequestException("Username is required"); - } - if (!email || email.trim().length === 0) { + if (!email || email.trim().length === 0 || !this.isValidEmail(email)) { this.logger.error("Update Profile failed: Email is required"); throw new BadRequestException("Email is required"); } @@ -579,12 +562,12 @@ private isValidEmail(email: string): boolean { this.logger.error("Update Profile failed: Position or role is required"); throw new BadRequestException("Position or role is required"); } - this.logger.log(`Updating profile for user ${username}`); + this.logger.log(`Updating profile for user ${email}`); const tableName = process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE"; const params = { TableName: tableName, - Key: { userId: username }, + Key: { email: email }, // Update both fields in one go: UpdateExpression: "SET email = :email, position_or_role = :position_or_role", @@ -598,7 +581,7 @@ private isValidEmail(email: string): boolean { try { await this.dynamoDb.update(params).promise(); - this.logger.log(`User ${username} updated user profile.`); + this.logger.log(`User ${email} updated user profile.`); } catch (error: unknown) { if (error instanceof Error) { this.logger.error("Updating the profile failed", error.stack); @@ -610,55 +593,59 @@ private isValidEmail(email: string): boolean { // Add this to auth.service.ts -// purpose statement: validates a user's session token via cognito and retrieves user data from dynamodb -// use case: employee is accessing the app with an existing session token -async validateSession(accessToken: string): Promise { - try { - // Use Cognito's getUser method to validate the token - const getUserResponse = await this.cognito - .getUser({ AccessToken: accessToken }) - .promise(); - - const username = getUserResponse.Username; - let email: string | undefined; - - // Extract email from user attributes - for (const attribute of getUserResponse.UserAttributes) { - if (attribute.Name === 'email') { - this.logger.log(`Extracted email from user attributes: ${attribute.Value}`); - email = attribute.Value; - break; + // purpose statement: validates a user's session token via cognito and retrieves user data from dynamodb + // use case: employee is accessing the app with an existing session token + async validateSession(accessToken: string): Promise { + try { + const getUserResponse = await this.cognito + .getUser({ AccessToken: accessToken }) + .promise(); + + let email: string | undefined; + + // Extract email from user attributes + for (const attribute of getUserResponse.UserAttributes) { + if (attribute.Name === "email") { + email = attribute.Value; + break; + } } - } - // Get additional user info from DynamoDB - const tableName = process.env.DYNAMODB_USER_TABLE_NAME || 'TABLE_FAILURE'; - const params = { - TableName: tableName, - Key: { - userId: username, - }, - }; + if (!email) { + this.logger.error( + "Failed to extract email from Cognito user attributes", + ); + throw new Error("Failed to retrieve user email from token"); + } - const userResult = await this.dynamoDb.get(params).promise(); - const user = userResult.Item; + // Get user from DynamoDB using email as the partition key + const tableName = process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE"; + const params = { + TableName: tableName, + Key: { + email: email, + }, + }; - if (!user) { - this.logger.error(`User not found in database for username: ${username}`); - throw new Error('User not found in database'); - } + const userResult = await this.dynamoDb.get(params).promise(); + const user = userResult.Item; + + if (!user) { + this.logger.error(`User not found in database for email: ${email}`); + throw new Error("User not found in database"); + } + + this.logger.log(`Session validated successfully for user ${email}`); + return user; + } catch (error: unknown) { + this.logger.error("Session validation failed", error); + + const cognitoError = error as AwsCognitoError; + if (cognitoError.code === "NotAuthorizedException") { + throw new UnauthorizedException("Session expired or invalid"); + } - this.logger.log(`Session validated successfully for user ${username}`); - return user; - } catch (error: unknown) { - this.logger.error('Session validation failed', error); - - const cognitoError = error as AwsCognitoError; - if (cognitoError.code === 'NotAuthorizedException') { - throw new UnauthorizedException('Session expired or invalid'); + throw new UnauthorizedException("Failed to validate session"); } - - throw new UnauthorizedException('Failed to validate session'); } } -} \ No newline at end of file diff --git a/backend/src/auth/types/auth.types.ts b/backend/src/auth/types/auth.types.ts index c434818c..09d9d885 100644 --- a/backend/src/auth/types/auth.types.ts +++ b/backend/src/auth/types/auth.types.ts @@ -1,24 +1,23 @@ export class RegisterBody { - username!: string; password!: string; email!: string; + firstName!: string; + lastName!: string; } export class LoginBody { - username!: string; + email!: string; password!: string; } export class SetPasswordBody { newPassword!: string; session!: string; - username!: string; - email?: string; + email!: string; } export class UpdateProfileBody { - username!: string; email!: string; position_or_role!: string; } \ No newline at end of file diff --git a/backend/src/grant/__test__/grant.service.spec.ts b/backend/src/grant/__test__/grant.service.spec.ts index ec5a8543..c90990aa 100644 --- a/backend/src/grant/__test__/grant.service.spec.ts +++ b/backend/src/grant/__test__/grant.service.spec.ts @@ -721,7 +721,6 @@ describe('Notification helpers', () => { expect(notificationServiceMock.createNotification).toHaveBeenCalledTimes(6); expect(notificationServiceMock.createNotification).toHaveBeenCalledWith( expect.objectContaining({ - userId: 'user123', notificationId: expect.stringContaining('-app'), message: expect.stringContaining('Application due in'), alertTime: expect.any(String), diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index e95ed2fd..ac294ff5 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -494,10 +494,10 @@ export class GrantService { } // Creates notifications for a grant's application and report deadlines - private async createGrantNotifications(grant: Grant, userId: string) { + private async createGrantNotifications(grant: Grant, email: string) { const { grantId, organization, application_deadline, report_deadlines } = grant; this.logger.log( - `Creating notifications for grant ${grantId} (${organization}) for user ${userId}`, + `Creating notifications for grant ${grantId} (${organization}) for user ${email}`, ); // Application deadline notifications @@ -513,7 +513,7 @@ export class GrantService { const message = `Application due in ${this.daysUntil(alertTime, application_deadline)} days for ${organization}`; const notification: Notification = { notificationId: `${grantId}-app`, - userId, + userEmail: email, message, alertTime: alertTime as TDateISO, sent: false, @@ -540,7 +540,7 @@ export class GrantService { const message = `Report due in ${this.daysUntil(alertTime, reportDeadline)} days for ${organization}`; const notification: Notification = { notificationId: `${grantId}-report`, - userId, + userEmail: email, message, alertTime: alertTime as TDateISO, sent: false, @@ -555,7 +555,7 @@ export class GrantService { } this.logger.log( - `Finished creating notifications for grant ${grantId} (${organization}) for user ${userId}`, + `Finished creating notifications for grant ${grantId} (${organization}) for user ${email}`, ); } diff --git a/backend/src/guards/auth.guard.ts b/backend/src/guards/auth.guard.ts index 1c907612..67ade10f 100644 --- a/backend/src/guards/auth.guard.ts +++ b/backend/src/guards/auth.guard.ts @@ -44,53 +44,79 @@ export class VerifyUserGuard implements CanActivate { } } +@Injectable() @Injectable() export class VerifyAdminRoleGuard implements CanActivate { private verifier: any; + private idVerifier: any; private readonly logger: Logger; + constructor() { const userPoolId = process.env.COGNITO_USER_POOL_ID; this.logger = new Logger(VerifyAdminRoleGuard.name); - if (userPoolId) { - this.verifier = CognitoJwtVerifier.create({ - userPoolId, - tokenUse: "access", - clientId: process.env.COGNITO_CLIENT_ID, - }); - } else { - throw new Error( - "[AUTH] USER POOL ID is not defined in environment variables" - ); + + if (!userPoolId) { + throw new Error("[AUTH] USER POOL ID is not defined in environment variables"); } + + this.verifier = CognitoJwtVerifier.create({ + userPoolId, + tokenUse: "access", + clientId: process.env.COGNITO_CLIENT_ID, + }); + + this.idVerifier = CognitoJwtVerifier.create({ + userPoolId, + tokenUse: "id", + clientId: process.env.COGNITO_CLIENT_ID, + }); } + async canActivate(context: ExecutionContext): Promise { try { const request = context.switchToHttp().getRequest(); const accessToken = request.cookies["access_token"]; - if (!accessToken) { + const idToken = request.cookies["id_token"]; + + if (!accessToken) { this.logger.error("No access token found in cookies"); return false; } - const result = await this.verifier.verify(accessToken); + + if (!idToken) { + this.logger.error("No ID token found in cookies"); + return false; + } + + const [result, idResult] = await Promise.all([ + this.verifier.verify(accessToken), + this.idVerifier.verify(idToken), + ]); + const groups = result['cognito:groups'] || []; - + const email = idResult['email']; + + if (!email) { + this.logger.error("No email found in ID token claims"); + return false; + } + // Attach user info to request for use in controllers request.user = { - userId: result['username'] || result['cognito:username'], - email: result['email'], + email, position: groups.includes('Admin') ? 'Admin' : (groups.includes('Employee') ? 'Employee' : 'Inactive') }; - - console.log("User groups from token:", groups); + + this.logger.log(`User groups from token: ${groups}`); + if (!groups.includes('Admin')) { - console.warn("Access denied: User is not an Admin"); + this.logger.warn("Access denied: User is not an Admin"); return false; - } else { - return true; } + return true; } catch (error) { - console.error("Token verification failed:", error); // Debug log + this.logger.error("Token verification failed:", error); return false; } } diff --git a/backend/src/notifications/__test__/notification.service.test.ts b/backend/src/notifications/__test__/notification.service.test.ts index 4880a2bc..7fa88164 100644 --- a/backend/src/notifications/__test__/notification.service.test.ts +++ b/backend/src/notifications/__test__/notification.service.test.ts @@ -3,7 +3,6 @@ import { Notification } from '../../../../middle-layer/types/Notification'; import { NotificationController } from '../notification.controller'; import { NotificationService } from '../notification.service'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { servicesVersion } from 'typescript'; import { TDateISO } from '../../utils/date'; import { NotFoundException, BadRequestException, InternalServerErrorException } from '@nestjs/common'; @@ -16,11 +15,10 @@ vi.mock('../../guards/auth.guard', () => ({ }), })); -// Create mock functions that we can reference const mockPromise = vi.fn(); const mockScan = vi.fn(); const mockGet = vi.fn(); -const mockSend = vi.fn(); // for SES +const mockSend = vi.fn(); const mockPut = vi.fn(); const mockDelete = vi.fn(); const mockQuery = vi.fn(); @@ -33,16 +31,14 @@ const mockDocumentClient = { put: mockPut, query: mockQuery, update: mockUpdate, - delete : mockDelete, + delete: mockDelete, }; - const mockSES = { send: mockSend, - sendEmail : mockSendEmail + sendEmail: mockSendEmail }; -// Mock AWS SDK - Note the structure here vi.mock('aws-sdk', () => ({ DynamoDB: { DocumentClient: vi.fn(function() { @@ -57,35 +53,29 @@ vi.mock('aws-sdk', () => ({ describe('NotificationController', () => { let controller: NotificationController; let notificationService: NotificationService; - let mockNotification_id1_user1 : Notification; - let mockNotification_id1_user2 : Notification; + let mockNotification_id1_user1: Notification; + let mockNotification_id1_user2: Notification; let mockNotification_id2_user1: Notification; let mockNotification_id2_user2: Notification; beforeEach(async () => { - // Clear all mocks before each test vi.clearAllMocks(); - // Setup DynamoDB mocks to return chainable objects with .promise() mockScan.mockReturnValue({ promise: mockPromise }); mockGet.mockReturnValue({ promise: mockPromise }); mockDelete.mockReturnValue({ promise: mockPromise }); mockUpdate.mockReturnValue({ promise: mockPromise }); mockPut.mockReturnValue({ promise: mockPromise }); mockQuery.mockReturnValue({ promise: mockPromise }); - - // Setup SES mocks to return chainable objects with .promise() mockSendEmail.mockReturnValue({ promise: mockPromise }); mockSend.mockReturnValue({ promise: mockPromise }); - // Reset environment variables const originalEnv = process.env; process.env = { ...originalEnv }; - process.env.NOTIFICATION_EMAIL_SENDER = 'kummer.j@northeastern.edu'; + process.env.NOTIFICATION_EMAIL_SENDER = 'kummer.j@northeastern.edu'; process.env.DYNAMODB_NOTIFICATION_TABLE_NAME = 'BCANNotifications'; process.env.COGNITO_USER_POOL_ID = "test-user-pool-id"; - const module: TestingModule = await Test.createTestingModule({ controllers: [NotificationController], providers: [NotificationService], @@ -94,19 +84,15 @@ describe('NotificationController', () => { controller = module.get(NotificationController); notificationService = module.get(NotificationService); - // Reset promise mock to default resolved state - mockPromise.mockResolvedValue({}); - mockPromise.mockResolvedValue({ MessageId: 'test-message-id-123', - ResponseMetadata: { - RequestId: 'test-request-id' - } + ResponseMetadata: { RequestId: 'test-request-id' } }); + // All notifications now use userEmail instead of userId mockNotification_id1_user1 = { notificationId: '1', - userId: 'user-1', + userEmail: 'user1@example.com', message: 'New Grant Created 🎉 ', alertTime: '2024-01-15T10:30:00.000Z', sent: false @@ -114,67 +100,62 @@ describe('NotificationController', () => { mockNotification_id1_user2 = { notificationId: '1', - userId: 'user-2', + userEmail: 'user2@example.com', message: 'New Grant Created', alertTime: '2025-01-15T10:30:00.000Z', sent: false } as Notification; - mockNotification_id2_user1= { + mockNotification_id2_user1 = { notificationId: '2', - userId: 'user-1', + userEmail: 'user1@example.com', message: 'New Grant Created', alertTime: '2025-01-15T10:30:00.000Z', sent: false } as Notification; - mockNotification_id2_user2= { + mockNotification_id2_user2 = { notificationId: '2', - userId: 'user-2', + userEmail: 'user2@example.com', message: 'New Grant Created', alertTime: '2025-01-15T10:30:00.000Z', sent: false } as Notification; mockPut.mockReturnValue({ promise: mockPromise }); - mockPromise.mockResolvedValue({}); + mockPromise.mockResolvedValue({}); }); - it('getNOtificationByUserId mock query called with correct parameters', async () => { - // Arrange - Setup query mock to return our test data + it('getNotificationByUserEmail mock query called with correct parameters', async () => { const mockQueryResponse = { - Items: [mockNotification_id1_user1, mockNotification_id1_user2, mockNotification_id2_user1, mockNotification_id2_user2] + Items: [mockNotification_id1_user1, mockNotification_id1_user2, mockNotification_id2_user1, mockNotification_id2_user2] }; - + mockQuery.mockReturnValue({ promise: vi.fn().mockResolvedValue(mockQueryResponse) }); - // Act - const result = await notificationService.getNotificationByUserId('user-1'); + const result = await notificationService.getNotificationByUserEmail('user1@example.com'); - //Assert + // Index and key should now use userEmail expect(mockQuery).toHaveBeenCalledWith({ TableName: 'BCANNotifications', - IndexName: 'userId-alertTime-index', - KeyConditionExpression: 'userId = :userId', + IndexName: 'userEmail-alertTime-index', + KeyConditionExpression: 'userEmail = :userEmail', ExpressionAttributeValues: { - ':userId': 'user-1', + ':userEmail': 'user1@example.com', }, ScanIndexForward: false }); - }); - describe('getNotificationByNotification', () => { + describe('getNotificationByNotificationId', () => { it('should throw NotFoundException when notification does not exist', async () => { - mockQuery.mockReturnValue({ + mockQuery.mockReturnValue({ promise: vi.fn().mockResolvedValueOnce({ Items: null }) - }) + }); await expect(notificationService.getNotificationByNotificationId('nonexistent-id')).rejects.toThrow(NotFoundException); - }); - it('should throw InternalServerErrorException when DynamoDB query fails', async () => { mockPromise.mockRejectedValueOnce(new Error('DynamoDB query failed')); @@ -183,210 +164,153 @@ describe('NotificationController', () => { }); it('should send email successfully with valid parameters', async () => { - // Arrange const to = 'user@example.com'; const subject = 'Test Notification'; const body = 'This is a test notification email.'; - // Act const result = await notificationService.sendEmailNotification(to, subject, body); - // Assert expect(mockSendEmail).toHaveBeenCalledWith({ Source: 'kummer.j@northeastern.edu', - Destination: { - ToAddresses: ['user@example.com'], - }, + Destination: { ToAddresses: ['user@example.com'] }, Message: { Subject: { Charset: 'UTF-8', Data: 'Test Notification' }, - Body: { - Text: { Charset: 'UTF-8', Data: 'This is a test notification email.' }, - }, + Body: { Text: { Charset: 'UTF-8', Data: 'This is a test notification email.' } }, }, }); - + expect(mockPromise).toHaveBeenCalled(); }); it('should use fallback email when NOTIFICATION_EMAIL_SENDER is not set', async () => { - // Arrange delete process.env.NOTIFICATION_EMAIL_SENDER; - - const to = 'user@example.com'; - const subject = 'Test Subject'; - const body = 'Test Body'; - // Act - await notificationService.sendEmailNotification(to, subject, body); + await notificationService.sendEmailNotification('user@example.com', 'Test Subject', 'Test Body'); - // Assert expect(mockSendEmail).toHaveBeenCalledWith({ Source: 'u&@nveR1ified-failure@dont-send.com', - Destination: { - ToAddresses: ['user@example.com'], - }, + Destination: { ToAddresses: ['user@example.com'] }, Message: { Subject: { Charset: 'UTF-8', Data: 'Test Subject' }, - Body: { - Text: { Charset: 'UTF-8', Data: 'Test Body' }, - }, + Body: { Text: { Charset: 'UTF-8', Data: 'Test Body' } }, }, }); }); it('should handle special characters in email content', async () => { - // Arrange const to = 'user@example.com'; const subject = 'Émoji Test: 🎉 Special chars: àáâãäå'; const body = 'Body with special chars: ñóôõö and symbols: €£¥ and emojis: 🚀📧'; - // Act await notificationService.sendEmailNotification(to, subject, body); - // Assert expect(mockSendEmail).toHaveBeenCalledWith({ Source: 'kummer.j@northeastern.edu', - Destination: { - ToAddresses: ['user@example.com'], - }, + Destination: { ToAddresses: ['user@example.com'] }, Message: { Subject: { Charset: 'UTF-8', Data: subject }, - Body: { - Text: { Charset: 'UTF-8', Data: body }, - }, + Body: { Text: { Charset: 'UTF-8', Data: body } }, }, }); }); it('should throw error when SES sendEmail fails', async () => { - // Arrange - const sesError = new Error('SES service unavailable'); - mockPromise.mockRejectedValue(sesError); + mockPromise.mockRejectedValue(new Error('SES service unavailable')); - // Act & Assert await expect(notificationService.sendEmailNotification( - 'user@example.com', - 'Test Subject', - 'Test Body' + 'user@example.com', 'Test Subject', 'Test Body' )).rejects.toThrow(InternalServerErrorException); expect(mockSendEmail).toHaveBeenCalled(); }); it('should send an email that is an empty string', async () => { - // Arrange - const to = 'user@example.com'; - const subject = ''; - const body = ''; + await notificationService.sendEmailNotification('user@example.com', '', ''); - // Act - await notificationService.sendEmailNotification(to, subject, body); - - // Assert expect(mockSendEmail).toHaveBeenCalledWith({ Source: 'kummer.j@northeastern.edu', - Destination: { - ToAddresses: ['user@example.com'], - }, + Destination: { ToAddresses: ['user@example.com'] }, Message: { Subject: { Charset: 'UTF-8', Data: '' }, - Body: { - Text: { Charset: 'UTF-8', Data: '' }, - }, + Body: { Text: { Charset: 'UTF-8', Data: '' } }, }, }); }); it('should send very long email content', async () => { - // Arrange - const to = 'user@example.com'; - const subject = 'A'.repeat(1000); // Very long subject - const body = 'B'.repeat(10000); // Very long body + const subject = 'A'.repeat(1000); + const body = 'B'.repeat(10000); - // Act - await notificationService.sendEmailNotification(to, subject, body); + await notificationService.sendEmailNotification('user@example.com', subject, body); - // Assert expect(mockSendEmail).toHaveBeenCalledWith({ Source: 'kummer.j@northeastern.edu', - Destination: { - ToAddresses: ['user@example.com'], - }, + Destination: { ToAddresses: ['user@example.com'] }, Message: { Subject: { Charset: 'UTF-8', Data: subject }, - Body: { - Text: { Charset: 'UTF-8', Data: body }, - }, + Body: { Text: { Charset: 'UTF-8', Data: body } }, }, }); }); - - - it('should throw error when notifications is null', async () => { - // Arrange - Setup query mock to return no items - const mockQueryResponse = { - Items: [] // Empty array instead of null - }; - + it('should return empty array when no notifications found', async () => { + const mockQueryResponse = { Items: [] }; mockQuery.mockReturnValue({ promise: vi.fn().mockResolvedValue(mockQueryResponse) }); - // Act & Assert - const result = await notificationService.getNotificationByUserId('nonexistent-user'); + const result = await notificationService.getNotificationByUserEmail('nonexistent-user@example.com'); expect(result).toEqual([]); }); - it('should throw InternalServerError when DynamoDB query fails', async() => { + it('should throw InternalServerError when DynamoDB query fails', async () => { mockPromise.mockRejectedValueOnce(new Error('DynamoDB connection failed')); - await expect(notificationService.getCurrentNotificationsByUserId('user-1')).rejects.toThrow(InternalServerErrorException); - }) + await expect(notificationService.getCurrentNotificationsByEmail('user1@example.com')).rejects.toThrow(InternalServerErrorException); + }); it('should create notification with valid data in the set table', async () => { const mockNotification = { notificationId: '123', - userId : 'user-456', - message : 'Test notification', - alertTime : '2024-01-15T10:30:00.000Z', + userEmail: 'user@example.com', + message: 'Test notification', + alertTime: '2024-01-15T10:30:00.000Z', sent: false } as Notification; + const result = await notificationService.createNotification(mockNotification); + + // Service spreads the notification object so userEmail stays as userEmail expect(mockPut).toHaveBeenCalledWith({ - TableName : 'BCANNotifications', - Item : { - notificationId: '123', - userId : 'user-456', - message : 'Test notification', - alertTime : '2024-01-15T10:30:00.000Z', - sent: false + TableName: 'BCANNotifications', + Item: { + notificationId: '123', + userEmail: 'user@example.com', + message: 'Test notification', + alertTime: '2024-01-15T10:30:00.000Z', + sent: false }, }); expect(result).toEqual(mockNotification); - }); it('should create notification with fallback table name when environment variable is not set', async () => { - // Arrange - explicitly delete the environment variable delete process.env.DYNAMODB_NOTIFICATION_TABLE_NAME; - + const mockNotification = { notificationId: '123', - userId: 'user-456', + userEmail: 'user@example.com', message: 'Test notification', alertTime: '2024-01-15T10:30:00.000Z', sent: false } as Notification; - // Act const result = await notificationService.createNotification(mockNotification); expect(result).toEqual(mockNotification); - // Assert expect(mockPut).toHaveBeenCalledWith({ TableName: 'TABLE_FAILURE', Item: { notificationId: '123', - userId: 'user-456', + userEmail: 'user@example.com', message: 'Test notification', alertTime: '2024-01-15T10:30:00.000Z', sent: false @@ -394,10 +318,10 @@ describe('NotificationController', () => { }); }); - it('should throw BadRequestException when userId is missing', async () => { + it('should throw BadRequestException when userEmail is missing', async () => { const invalidNotification = { notificationId: '123', - userId: '', + userEmail: '', message: 'Test', alertTime: '2024-01-15T10:30:00.000Z', sent: false @@ -409,7 +333,7 @@ describe('NotificationController', () => { it('should throw BadRequestException when notificationId is missing', async () => { const invalidNotification = { notificationId: '', - userId: 'user-123', + userEmail: 'user@example.com', message: 'Test', alertTime: '2024-01-15T10:30:00.000Z', sent: false @@ -421,7 +345,7 @@ describe('NotificationController', () => { it('should throw BadRequestException for invalid alertTime', async () => { const invalidNotification = { notificationId: '123', - userId: 'user-456', + userEmail: 'user@example.com', message: 'Test', alertTime: 'not-a-valid-date' as any, sent: false @@ -430,10 +354,10 @@ describe('NotificationController', () => { await expect(notificationService.createNotification(invalidNotification)).rejects.toThrow(BadRequestException); }); - it('should throw InternalServerErrorException when DynamoDB fails', async () => { + it('should throw InternalServerErrorException when DynamoDB fails on create', async () => { const validNotification = { notificationId: '123', - userId: 'user-456', + userEmail: 'user@example.com', message: 'Test', alertTime: '2024-01-15T10:30:00.000Z', sent: false @@ -445,75 +369,57 @@ describe('NotificationController', () => { }); it('should update a notification successfully with multiple fields', async () => { - // Arrange const notificationId = 'notif-123'; const updates = { message: 'Updated message', alertTime: '2025-01-01T00:00:00.000Z' as unknown as TDateISO }; - - + const mockUpdateResponse = { Attributes: { message: 'Updated message', alertTime: '2025-01-01T00:00:00.000Z', }, }; - + mockUpdate.mockReturnValue({ promise: mockPromise }); mockPromise.mockResolvedValue(mockUpdateResponse); - + const result = await notificationService.updateNotification(notificationId, updates); - + expect(mockUpdate).toHaveBeenCalledWith({ TableName: 'BCANNotifications', Key: { notificationId }, UpdateExpression: 'SET #message = :message, #alertTime = :alertTime', - ExpressionAttributeNames: { - '#message': 'message', - '#alertTime': 'alertTime', - }, + ExpressionAttributeNames: { '#message': 'message', '#alertTime': 'alertTime' }, ExpressionAttributeValues: { ':message': 'Updated message', ':alertTime': '2025-01-01T00:00:00.000Z', }, ReturnValues: 'UPDATED_NEW', }); - + expect(result).toEqual(JSON.stringify(mockUpdateResponse)); }); it('should throw error when DynamoDB update fails', async () => { - // Arrange - const notificationId = 'notif-fail'; - const updates = { message: 'Failure test' }; - const mockError = new Error('DynamoDB update failed'); + mockPromise.mockRejectedValueOnce(new Error('DynamoDB update failed')); - mockPromise.mockRejectedValueOnce(mockError); - - // Act & Assert - await expect(notificationService.updateNotification(notificationId, updates)) + await expect(notificationService.updateNotification('notif-fail', { message: 'Failure test' })) .rejects.toThrow(InternalServerErrorException); expect(mockUpdate).toHaveBeenCalled(); }); it('should correctly update a single field', async () => { - // Arrange - const notificationId = 'notif-single'; - const updates = { message: 'Single field update' }; const mockUpdateResponse = { Attributes: { message: 'Single field update' } }; - - // Make sure mockPromise resolves with the response mockPromise.mockResolvedValueOnce(mockUpdateResponse); - // Act - const result = await notificationService.updateNotification(notificationId, updates); + const result = await notificationService.updateNotification('notif-single', { message: 'Single field update' }); - // Assert expect(mockUpdate).toHaveBeenCalledWith({ TableName: 'BCANNotifications', - Key: { notificationId }, + Key: { notificationId: 'notif-single' }, UpdateExpression: 'SET #message = :message', ExpressionAttributeNames: { '#message': 'message' }, ExpressionAttributeValues: { ':message': 'Single field update' }, @@ -523,57 +429,48 @@ describe('NotificationController', () => { expect(result).toEqual(JSON.stringify(mockUpdateResponse)); }); - - - - describe('deleteNotification', () => { it('should successfully delete a notification given a valid id', async () => { - mockPromise.mockResolvedValueOnce({}) - - const result = await notificationService.deleteNotification('0') + mockPromise.mockResolvedValueOnce({}); - expect(mockDelete).toHaveBeenCalledTimes(1) + const result = await notificationService.deleteNotification('0'); + expect(mockDelete).toHaveBeenCalledTimes(1); expect(mockDelete).toHaveBeenCalledWith({ TableName: 'BCANNotifications', - Key: { - notificationId: '0', - }, + Key: { notificationId: '0' }, ConditionExpression: 'attribute_exists(notificationId)' - }) + }); - expect(result).toEqual('Notification with id 0 successfully deleted') - }) + expect(result).toEqual('Notification with id 0 successfully deleted'); + }); it('uses the fallback table when the environment variable is not set', async () => { - delete process.env.DYNAMODB_NOTIFICATION_TABLE_NAME - mockPromise.mockResolvedValueOnce({}) + delete process.env.DYNAMODB_NOTIFICATION_TABLE_NAME; + mockPromise.mockResolvedValueOnce({}); - const result = await notificationService.deleteNotification('0') + await notificationService.deleteNotification('0'); expect(mockDelete).toHaveBeenCalledWith({ TableName: 'TABLE_FAILURE', - Key: { - notificationId: '0', - }, + Key: { notificationId: '0' }, ConditionExpression: 'attribute_exists(notificationId)' - }) - }) + }); + }); it('throws NotFoundException when the given notification id does not exist', async () => { mockPromise.mockRejectedValueOnce({ - code: 'ConditionalCheckFailedException', - message: 'The item does not exist' + code: 'ConditionalCheckFailedException', + message: 'The item does not exist' }); await expect(notificationService.deleteNotification('999')).rejects.toThrow(NotFoundException); - }) + }); it('throws InternalServerErrorException when DynamoDB fails unexpectedly', async () => { mockPromise.mockRejectedValueOnce(new Error('DynamoDB service unavailable')); await expect(notificationService.deleteNotification('123')).rejects.toThrow(InternalServerErrorException); - }) - }) + }); + }); }); \ No newline at end of file diff --git a/backend/src/notifications/notification.controller.ts b/backend/src/notifications/notification.controller.ts index 74c4b7ed..a3c5be5d 100644 --- a/backend/src/notifications/notification.controller.ts +++ b/backend/src/notifications/notification.controller.ts @@ -78,7 +78,7 @@ export class NotificationController { } /** - * gets the current notifications for user based on the user id + * gets the current notifications for user based on the email */ @ApiResponse({ status: 200, @@ -96,11 +96,11 @@ export class NotificationController { status: 500, description: "Internal Server Error" }) - @Get('/user/:userId/current') + @Get('/user/:email/current') @UseGuards(VerifyUserGuard) @ApiBearerAuth() - async findCurrentByUser(@Param('userId') userId: string) { - return await this.notificationService.getCurrentNotificationsByUserId(userId); + async findCurrentByUser(@Param('email') email: string) { + return await this.notificationService.getCurrentNotificationsByEmail(email); } /** @@ -122,12 +122,11 @@ export class NotificationController { status: 500, description: "Internal Server Error" }) - @Get('/user/:userId') + @Get('/user/:email') @UseGuards(VerifyUserGuard) @ApiBearerAuth() - async findByUser(@Param('userId') userId: string) { - console.log("HERE") - return await this.notificationService.getNotificationByUserId(userId); + async findByUser(@Param('email') email: string) { + return await this.notificationService.getNotificationByUserEmail(email); } /** diff --git a/backend/src/notifications/notification.service.ts b/backend/src/notifications/notification.service.ts index 05480674..392c93e0 100644 --- a/backend/src/notifications/notification.service.ts +++ b/backend/src/notifications/notification.service.ts @@ -13,12 +13,12 @@ export class NotificationService { // Function to create a notification in DynamoDB for a specific user // Should this have a check to prevent duplicate notifications? async createNotification(notification: Notification): Promise { - this.logger.log(`Starting notification creation for userId: ${notification.userId}`); + this.logger.log(`Starting notification creation for user: ${notification.userEmail}`); // validate required fields - if (!notification.userId || !notification.notificationId) { + if (!notification.userEmail || !notification.notificationId) { this.logger.error('Missing required fields in notification'); - throw new BadRequestException('userId and notificationId are required'); + throw new BadRequestException('user and notificationId are required'); } // validate and parse alertTime @@ -42,20 +42,20 @@ export class NotificationService { this.logger.log(`Notification created successfully with Id: ${notification.notificationId}`); return notification; } catch (error) { - this.logger.error(`Failed to create notification for userId ${notification.userId}:`, error); + this.logger.error(`Failed to create notification for userEmail ${notification.userEmail}:`, error); throw new InternalServerErrorException('Failed to create notification'); } } // Function that retreives all current notifications for a user - async getCurrentNotificationsByUserId(userId: string): Promise { - this.logger.log(`Fetching current notifications for userID: ${userId}`); + async getCurrentNotificationsByEmail(userEmail: string): Promise { + this.logger.log(`Fetching current notifications for userEmail: ${userEmail}`); - try {const notifactions = await this.getNotificationByUserId(userId); + try {const notifactions = await this.getNotificationByUserEmail(userEmail); const currentTime = new Date(); - this.logger.log(`Found current notifications for userID ${userId}`); + this.logger.log(`Found current notifications for userEmail ${userEmail}`); return notifactions.filter(notification => new Date(notification.alertTime) <= currentTime); } catch (error) { this.logger.error("Failed to notifications by user id error: " + error) @@ -64,15 +64,15 @@ export class NotificationService { } - // Function that returns array of notifications by user id (sorted by most recent notifications first) - async getNotificationByUserId(userId: string): Promise { + // Function that returns array of notifications by user email (sorted by most recent notifications first) + async getNotificationByUserEmail(email: string): Promise { // KeyConditionExpression specifies the query condition // ExpressionAttributeValues specifies the actual value of the key // IndexName specifies our Global Secondary Index, which was created in the BCANNotifs table to - // allow for querying by userId, as it is not a primary/partition key + // allow for querying by userEmail, as it is not a primary/partition key const notificationTableName = process.env.DYNAMODB_NOTIFICATION_TABLE_NAME; - this.logger.log(`Fetching notifications for userId: ${userId} from table: ${notificationTableName}`); + this.logger.log(`Fetching notifications for userEmail: ${email} from table: ${notificationTableName}`); if (!notificationTableName) { this.logger.error('DYNAMODB_NOTIFICATION_TABLE_NAME is not defined in environment variables'); @@ -80,10 +80,10 @@ export class NotificationService { } const params = { TableName: notificationTableName, - IndexName: 'userId-alertTime-index', - KeyConditionExpression: 'userId = :userId', + IndexName: 'userEmail-alertTime-index', + KeyConditionExpression: 'userEmail = :userEmail', ExpressionAttributeValues: { - ':userId': userId, + ':userEmail': email, }, ScanIndexForward: false // sort in descending order }; @@ -93,16 +93,16 @@ export class NotificationService { const data = await this.dynamoDb.query(params).promise(); - // This is never hit, because no present userId throws an error + // This is never hit, because no present userEmail throws an error if (!data || !data.Items || data.Items.length == 0) { - this.logger.warn(`No notifications found for userId: ${userId}`); + this.logger.warn(`No notifications found for user : ${email}`); return [] as Notification[]; } - this.logger.log(`Retrieved ${data.Items.length} notifications for userId ${userId}`); + this.logger.log(`Retrieved ${data.Items.length} notifications for user ${email}`); return data.Items as Notification[]; } catch (error) { - this.logger.error(`Error retrieving notifications for userId: ${userId}`, error as string); + this.logger.error(`Error retrieving notifications for user : ${email}`, error as string); throw new InternalServerErrorException('Failed to retrieve notifications.'); } } diff --git a/backend/src/notifications/types/notification.types.ts b/backend/src/notifications/types/notification.types.ts index 9b8a1269..86aa185b 100644 --- a/backend/src/notifications/types/notification.types.ts +++ b/backend/src/notifications/types/notification.types.ts @@ -2,7 +2,7 @@ import { TDateISO } from "../../utils/date"; export class NotificationBody { notificationId!: string; - userId!: string; + userEmail!: string; message!: string; alertTime!: TDateISO; sent!: boolean; diff --git a/backend/src/user/__test__/user.service.spec.ts b/backend/src/user/__test__/user.service.spec.ts index 1d713e55..38e4feaf 100644 --- a/backend/src/user/__test__/user.service.spec.ts +++ b/backend/src/user/__test__/user.service.spec.ts @@ -3,13 +3,10 @@ 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'; -// Create mock functions at module level (BEFORE mock) const mockScan = vi.fn(); const mockGet = vi.fn(); const mockUpdate = vi.fn(); @@ -17,15 +14,11 @@ const mockPut = vi.fn(); const mockDelete = vi.fn(); const mockPromise = vi.fn(); -// Mock Cognito functions const mockAdminAddUserToGroup = vi.fn(); const mockAdminRemoveUserFromGroup = vi.fn(); const mockAdminDeleteUser = vi.fn(); - -// Mock SES functions const mockSendEmail = vi.fn(); -// Mock AWS SDK ONCE with proper structure for import * as AWS vi.mock('aws-sdk', () => { return { default: { @@ -38,19 +31,11 @@ vi.mock('aws-sdk', () => { }), DynamoDB: { DocumentClient: vi.fn(function() { - return { - scan: mockScan, - get: mockGet, - update: mockUpdate, - put: mockPut, - delete: mockDelete, - }; + return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; }) }, SES: vi.fn(function() { - return { - sendEmail: mockSendEmail, - }; + return { sendEmail: mockSendEmail }; }) }, CognitoIdentityServiceProvider: vi.fn(function() { @@ -62,24 +47,15 @@ vi.mock('aws-sdk', () => { }), DynamoDB: { DocumentClient: vi.fn(function() { - return { - scan: mockScan, - get: mockGet, - update: mockUpdate, - put: mockPut, - delete: mockDelete, - }; + return { scan: mockScan, get: mockGet, update: mockUpdate, put: mockPut, delete: mockDelete }; }) }, SES: vi.fn(function() { - return { - sendEmail: mockSendEmail, - }; + return { sendEmail: mockSendEmail }; }) }; }); -// ✅ Mock the auth guards vi.mock('../../guards/auth.guard', () => ({ VerifyUserGuard: vi.fn(class MockVerifyUserGuard { canActivate = vi.fn().mockResolvedValue(true); @@ -92,44 +68,36 @@ vi.mock('../../guards/auth.guard', () => ({ }) })); -// 🗄️ Mock Database with test data -// This simulates a DynamoDB table with realistic test data -// Contains: 2 Admins, 3 Employees, 4 Inactive users (9 total) +// Mock database now keyed by email since that's the partition key const mockDatabase = { users: [ - { userId: 'admin1', email: 'admin1@example.com', position: UserStatus.Admin }, - { userId: 'admin2', email: 'admin2@example.com', position: UserStatus.Admin }, - { userId: 'emp1', email: 'emp1@example.com', position: UserStatus.Employee }, - { userId: 'emp2', email: 'emp2@example.com', position: UserStatus.Employee }, - { userId: 'emp3', email: 'emp3@example.com', position: UserStatus.Employee }, - { userId: 'inactive1', email: 'inactive1@example.com', position: UserStatus.Inactive }, - { userId: 'inactive2', email: 'inactive2@example.com', position: UserStatus.Inactive }, - { userId: 'inactive3', email: 'inactive3@example.com', position: UserStatus.Inactive }, - { userId: 'inactive4', email: 'inactive4@example.com', position: UserStatus.Inactive }, + { 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' }, ] as User[], - - // Helper function to simulate DynamoDB scan with FilterExpression + scan: (params: any) => { let filteredUsers = [...mockDatabase.users]; - if (params.FilterExpression) { - // Handle FilterExpression for inactive users: #pos IN (:inactive) if (params.FilterExpression.includes('(:inactive)')) { filteredUsers = filteredUsers.filter(u => u.position === 'Inactive'); - } - // Handle FilterExpression for active users: #pos IN (:admin, :employee) - else if (params.FilterExpression.includes('(:admin, :employee)')) { + } else if (params.FilterExpression.includes('(:admin, :employee)')) { filteredUsers = filteredUsers.filter(u => u.position === 'Admin' || u.position === 'Employee'); } } - return { Items: filteredUsers }; }, - - // Helper function to simulate DynamoDB get operation + + // Now looks up by email instead of userId get: (params: any) => { - const userId = params.Key.userId; - const user = mockDatabase.users.find(u => u.userId === userId); + const email = params.Key.email; + const user = mockDatabase.users.find(u => u.email === email); return user ? { Item: user } : {}; } }; @@ -139,31 +107,22 @@ describe('UserController', () => { let userService: UserService; beforeAll(() => { - // Set up environment variables process.env.DYNAMODB_USER_TABLE_NAME = 'test-users-table'; process.env.COGNITO_USER_POOL_ID = 'test-pool-id'; }); beforeEach(async () => { - // Clear all mocks before each test vi.clearAllMocks(); - // Setup DynamoDB mocks to return chainable objects with .promise() mockScan.mockReturnValue({ promise: mockPromise }); mockGet.mockReturnValue({ promise: mockPromise }); mockDelete.mockReturnValue({ promise: mockPromise }); mockUpdate.mockReturnValue({ promise: mockPromise }); mockPut.mockReturnValue({ promise: mockPromise }); - - // Setup Cognito mocks to return chainable objects with .promise() mockAdminAddUserToGroup.mockReturnValue({ promise: mockPromise }); mockAdminRemoveUserFromGroup.mockReturnValue({ promise: mockPromise }); mockAdminDeleteUser.mockReturnValue({ promise: mockPromise }); - - // Setup SES mocks to return chainable objects with .promise() mockSendEmail.mockReturnValue({ promise: mockPromise }); - - // Reset promise mocks to default resolved state mockPromise.mockResolvedValue({}); const module: TestingModule = await Test.createTestingModule({ @@ -176,48 +135,42 @@ describe('UserController', () => { }); it('should get all users from mock database', async () => { - // Setup the mock response using our mock database mockPromise.mockResolvedValueOnce(mockDatabase.scan({ TableName: 'test-users-table' })); const result = await userService.getAllUsers(); - - expect(result).toHaveLength(9); // All 9 users in mock database - expect(mockScan).toHaveBeenCalledWith({ - TableName: 'test-users-table' - }); + + expect(result).toHaveLength(9); + expect(mockScan).toHaveBeenCalledWith({ TableName: 'test-users-table' }); }); - it('should get user by id from mock database', async () => { - // Setup the mock response using our mock database - mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { userId: 'admin1' } })); + // 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' } })); + + const result = await userService.getUserByEmail('admin1@example.com'); - const result = await userService.getUserById('admin1'); - - expect(result.userId).toBe('admin1'); - expect(result.position).toBe('Admin'); expect(result.email).toBe('admin1@example.com'); + expect(result.position).toBe('Admin'); expect(mockGet).toHaveBeenCalledWith({ TableName: 'test-users-table', - Key: { userId: 'admin1' } + Key: { email: 'admin1@example.com' } }); }); - it('should throw BadRequestException when userId is invalid', async () => { - await expect(userService.getUserById('')).rejects.toThrow('Valid user ID is required'); - await expect(userService.getUserById(null as any)).rejects.toThrow('Valid user ID is required'); - await expect(userService.getUserById(' ')).rejects.toThrow('Valid user ID is required'); + it('should throw BadRequestException when email is invalid', 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'); }); - it('should throw NotFoundException when user does not exist in mock database', async () => { - // Mock empty response (user not found) using mock database - mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { userId: 'nonexistent' } })); + it('should throw NotFoundException when user does not exist', async () => { + mockPromise.mockResolvedValueOnce(mockDatabase.get({ Key: { email: 'nonexistent@example.com' } })); - await expect(userService.getUserById('nonexistent')).rejects.toThrow("User 'nonexistent' does not exist"); + 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 () => { - // Mock an error with AWS error structure const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; mockPromise.mockRejectedValueOnce(awsError); @@ -226,7 +179,6 @@ describe('UserController', () => { }); it('should handle generic DynamoDB errors when getting all users', async () => { - // Mock a generic error const awsError = { code: 'UnknownError', message: 'Unknown DynamoDB error' }; mockPromise.mockRejectedValueOnce(awsError); @@ -234,41 +186,42 @@ describe('UserController', () => { expect(mockScan).toHaveBeenCalled(); }); - it('should handle errors when getting user by id', async () => { - // Mock an AWS error with specific error code + it('should handle errors when getting user by email', async () => { const awsError = { code: 'ValidationException', message: 'Invalid request' }; mockPromise.mockRejectedValueOnce(awsError); - await expect(userService.getUserById('1')).rejects.toThrow('Invalid request: 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 id', async () => { - // Mock a ResourceNotFoundException + it('should handle ResourceNotFoundException when getting user by email', async () => { const awsError = { code: 'ResourceNotFoundException', message: 'Table not found' }; mockPromise.mockRejectedValueOnce(awsError); - await expect(userService.getUserById('1')).rejects.toThrow('Database 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 () => { - // Setup the mock response using our mock database with filter const scanParams = { TableName: 'test-users-table', FilterExpression: '#pos IN (:inactive)', ExpressionAttributeNames: { '#pos': 'position' }, ExpressionAttributeValues: { ':inactive': 'Inactive' } }; - + mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); const result = await userService.getAllInactiveUsers(); - - // Should return exactly 4 inactive users from mock database + expect(result).toHaveLength(4); - expect(result.every(u => u.position === 'Inactive')).toBe(true); - expect(result.map(u => u.userId).sort()).toEqual(['inactive1', 'inactive2', 'inactive3', 'inactive4']); + expect(result.every((u: User) => u.position === 'Inactive')).toBe(true); + expect(result.map((u: User) => u.email).sort()).toEqual([ + 'inactive1@example.com', + 'inactive2@example.com', + 'inactive3@example.com', + 'inactive4@example.com' + ]); expect(mockScan).toHaveBeenCalledWith(scanParams); }); @@ -281,32 +234,25 @@ describe('UserController', () => { }); it('should get all active users from mock database', async () => { - // Setup the mock response using our mock database with filter const scanParams = { TableName: 'test-users-table', FilterExpression: '#pos IN (:admin, :employee)', ExpressionAttributeNames: { '#pos': 'position' }, ExpressionAttributeValues: { ':admin': 'Admin', ':employee': 'Employee' } }; - + mockPromise.mockResolvedValueOnce(mockDatabase.scan(scanParams)); const result = await userService.getAllActiveUsers(); - - // Should return exactly 5 active users (2 admins + 3 employees) from mock database + expect(result).toHaveLength(5); - expect(result.every(u => u.position === 'Admin' || u.position === 'Employee')).toBe(true); - - const admins = result.filter(u => u.position === 'Admin'); - const employees = result.filter(u => u.position === 'Employee'); - expect(admins).toHaveLength(2); - expect(employees).toHaveLength(3); - + 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(mockScan).toHaveBeenCalledWith(scanParams); }); it('should throw NotFoundException when no active users found', async () => { - // Mock empty response mockPromise.mockResolvedValueOnce({ Items: undefined }); await expect(userService.getAllActiveUsers()).rejects.toThrow('No active users found.'); @@ -326,188 +272,154 @@ describe('UserController', () => { // ======================================== it('should successfully change user role from Inactive to Employee', async () => { - const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get to verify user exists - mockPromise.mockResolvedValueOnce({ Item: user }); - - // Mock Cognito remove from old group (no-op for Inactive) - mockPromise.mockResolvedValueOnce({}); - - // Mock Cognito add to new group - mockPromise.mockResolvedValueOnce({}); - - // Mock SES sendEmail (verification email for Inactive -> Employee) - mockPromise.mockResolvedValueOnce({ MessageId: 'test-message-id' }); - - // Mock DynamoDB update + 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 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' + Username: 'inactive1@example.com' }); - expect(mockSendEmail).toHaveBeenCalled(); // Verify email was sent + expect(mockSendEmail).toHaveBeenCalled(); expect(mockUpdate).toHaveBeenCalled(); }); it('should successfully promote Employee to Admin', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get + 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 }); - - // Mock Cognito remove from Employee group - mockPromise.mockResolvedValueOnce({}); - - // Mock Cognito add to Admin group - mockPromise.mockResolvedValueOnce({}); - - // Mock DynamoDB update - mockPromise.mockResolvedValueOnce({ - Attributes: { ...user, position: UserStatus.Admin } - }); + mockPromise.mockResolvedValueOnce({}); // Remove from Employee + mockPromise.mockResolvedValueOnce({}); // Add to Admin + mockPromise.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' + Username: 'emp1@example.com' }); expect(mockAdminAddUserToGroup).toHaveBeenCalledWith({ GroupName: 'Admin', UserPoolId: 'test-pool-id', - Username: 'emp1' + Username: 'emp1@example.com' }); }); it('should return user unchanged if already in requested group', async () => { - const user = mockDatabase.users.find(u => u.userId === 'admin1')!; - const requestedBy = mockDatabase.users.find(u => u.userId === 'admin2')!; - - // Mock DynamoDB get - user already Admin + const user = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; + const requestedBy = mockDatabase.users.find(u => u.email === 'admin2@example.com')!; + mockPromise.mockResolvedValueOnce({ Item: user }); const result = await userService.addUserToGroup(user, UserStatus.Admin, requestedBy); - + expect(result.position).toBe(UserStatus.Admin); - // Should not call Cognito if already in group expect(mockAdminAddUserToGroup).not.toHaveBeenCalled(); }); it('should throw BadRequestException when user object is invalid', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - + 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({ userId: '' } as any, UserStatus.Employee, admin) + 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.userId === 'inactive1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - + 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'); }); it('should throw UnauthorizedException when non-admin tries to change role', async () => { - const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; - const employee = mockDatabase.users.find(u => u.userId === 'emp1')!; - + 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'); }); it('should throw BadRequestException when admin tries to demote themselves', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get + 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'); }); it('should throw NotFoundException when user does not exist', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - const fakeUser: User = { userId: 'nonexistent', email: 'fake@test.com', position: UserStatus.Inactive }; - - // Mock DynamoDB get - user not found + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com') as User; + 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 'nonexistent' does not exist"); + ).rejects.toThrow("User 'fake@test.com' does not exist"); }); it('should handle Cognito UserNotFoundException', async () => { - const user = mockDatabase.users.find(u => u.userId === 'inactive1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get + 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 }); - - // Mock Cognito remove from old group (no-op for Inactive, but still called) - mockPromise.mockResolvedValueOnce({}); - - // Mock Cognito add to new group - this should fail + mockPromise.mockResolvedValueOnce({}); // Remove from old group const cognitoError = { code: 'UserNotFoundException', message: 'User not found in Cognito' }; mockPromise.mockRejectedValueOnce(cognitoError); - + 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.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get + 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 }); - - // Mock Cognito operations succeed - mockPromise.mockResolvedValueOnce({}); // Remove from old group - mockPromise.mockResolvedValueOnce({}); // Add to new group - - // Mock DynamoDB update fails + mockPromise.mockResolvedValueOnce({}); // Remove from old group + mockPromise.mockResolvedValueOnce({}); // Add to new group const dynamoError = { code: 'ValidationException', message: 'Invalid update' }; mockPromise.mockRejectedValueOnce(dynamoError); - - // Mock rollback operations - mockPromise.mockResolvedValueOnce({}); // Remove from new group - mockPromise.mockResolvedValueOnce({}); // Add back to old group - + 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'); - - // Verify rollback was attempted - expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledTimes(2); // Once for change, once for rollback - expect(mockAdminAddUserToGroup).toHaveBeenCalledTimes(2); // Once for change, once for rollback + + expect(mockAdminRemoveUserFromGroup).toHaveBeenCalledTimes(2); + expect(mockAdminAddUserToGroup).toHaveBeenCalledTimes(2); }); // ======================================== @@ -515,106 +427,92 @@ describe('UserController', () => { // ======================================== it('should successfully delete a user', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get to verify user exists - mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete - mockPromise.mockResolvedValueOnce({ - Attributes: userToDelete - }); - - // Mock Cognito delete - mockPromise.mockResolvedValueOnce({}); + const userToDelete = 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); - - expect(result.userId).toBe('emp1'); + + expect(result.email).toBe('emp1@example.com'); expect(mockGet).toHaveBeenCalled(); + // DynamoDB key is now email expect(mockDelete).toHaveBeenCalledWith({ TableName: 'test-users-table', - Key: { userId: 'emp1' }, + Key: { email: 'emp1@example.com' }, ReturnValues: 'ALL_OLD' }); + // Cognito Username is now email expect(mockAdminDeleteUser).toHaveBeenCalledWith({ UserPoolId: 'test-pool-id', - Username: 'emp1' + Username: 'emp1@example.com' }); }); it('should throw BadRequestException when user object is invalid', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - + 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({ userId: '' } as any, admin) + userService.deleteUser({ email: '' } as any, admin) ).rejects.toThrow('Valid user object is required'); }); it('should throw BadRequestException when requestedBy is invalid', async () => { - const user = mockDatabase.users.find(u => u.userId === 'emp1')!; - + 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'); }); it('should throw UnauthorizedException when non-admin tries to delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp2')!; - const employee = mockDatabase.users.find(u => u.userId === 'emp1')!; - + const userToDelete = 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'); }); it('should throw BadRequestException when admin tries to delete themselves', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - + 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'); }); it('should throw NotFoundException when user to delete does not exist', async () => { - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - const fakeUser: User = { userId: 'nonexistent', email: 'fake@test.com', position: UserStatus.Employee }; - - // Mock DynamoDB get - user not found + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com') as User; + const fakeUser: User = { email: 'fake@test.com', position: UserStatus.Employee, firstName: '', lastName: '' }; + mockPromise.mockResolvedValueOnce({}); - + await expect( userService.deleteUser(fakeUser, admin) - ).rejects.toThrow("User 'nonexistent' does not exist"); + ).rejects.toThrow("User 'fake@test.com' does not exist"); }); it('should handle Cognito UserNotFoundException during delete', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get + const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; + mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete succeeds mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - - // Mock Cognito delete fails const cognitoError = { code: 'UserNotFoundException', message: 'User not found' }; mockPromise.mockRejectedValueOnce(cognitoError); - - // Mock rollback (restore to DynamoDB) - mockPromise.mockResolvedValueOnce({}); - + mockPromise.mockResolvedValueOnce({}); // Rollback restore + await expect( userService.deleteUser(userToDelete, admin) ).rejects.toThrow('not found in authentication system'); - - // Verify rollback was attempted + expect(mockPut).toHaveBeenCalledWith({ TableName: 'test-users-table', Item: userToDelete @@ -622,27 +520,19 @@ describe('UserController', () => { }); it('should rollback DynamoDB delete if Cognito delete fails', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get + const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; + mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete succeeds mockPromise.mockResolvedValueOnce({ Attributes: userToDelete }); - - // Mock Cognito delete fails with generic error const cognitoError = { code: 'InternalError', message: 'Cognito internal error' }; mockPromise.mockRejectedValueOnce(cognitoError); - - // Mock rollback succeeds - mockPromise.mockResolvedValueOnce({}); - + mockPromise.mockResolvedValueOnce({}); // Rollback + await expect( userService.deleteUser(userToDelete, admin) ).rejects.toThrow('Failed to delete user from authentication system'); - - // Verify rollback was attempted + expect(mockPut).toHaveBeenCalledWith({ TableName: 'test-users-table', Item: userToDelete @@ -650,21 +540,17 @@ describe('UserController', () => { }); it('should handle DynamoDB delete failure', async () => { - const userToDelete = mockDatabase.users.find(u => u.userId === 'emp1')!; - const admin = mockDatabase.users.find(u => u.userId === 'admin1')!; - - // Mock DynamoDB get + const userToDelete = mockDatabase.users.find(u => u.email === 'emp1@example.com')!; + const admin = mockDatabase.users.find(u => u.email === 'admin1@example.com')!; + mockPromise.mockResolvedValueOnce({ Item: userToDelete }); - - // Mock DynamoDB delete fails 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'); - - // Cognito delete should not be called if DynamoDB fails + 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 8cd99c2c..dcc903fb 100644 --- a/backend/src/user/types/user.types.ts +++ b/backend/src/user/types/user.types.ts @@ -1,11 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { UserStatus } from '../../../../middle-layer/types/UserStatus'; +import { User } from '../../types/User'; export class ChangeRoleBody { - user!: { - userId: string, - position: UserStatus, - email: string - }; + user!: User groupName!: UserStatus; } \ No newline at end of file diff --git a/backend/src/user/user.controller.ts b/backend/src/user/user.controller.ts index f32729a7..d599d812 100644 --- a/backend/src/user/user.controller.ts +++ b/backend/src/user/user.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Patch, Delete, Body, Param, UseGuards, Req } from "@nestjs/common"; +import { Controller, Get, Delete, Body, Param, UseGuards, Req, Post } from "@nestjs/common"; import { UserService } from "./user.service"; import { User } from "../../../middle-layer/types/User"; import { UserStatus } from "../../../middle-layer/types/UserStatus"; @@ -101,7 +101,7 @@ export class UserController { /** * Change a user's role (make sure guard is on this route) */ - @Patch("change-role") + @Post("change-role") @ApiResponse({ status : 200, description : "User role changed successfully" @@ -150,9 +150,9 @@ export class UserController { /** * Delete a user */ - @Delete("delete-user/:userId") + @Delete("delete-user/:email") @ApiParam({ - name: 'userId', + name: 'email', description: 'ID of the user to delete', required: true, type: String @@ -184,25 +184,25 @@ export class UserController { @UseGuards(VerifyAdminRoleGuard) @ApiBearerAuth() async deleteUser( - @Param('userId') userId: string, + @Param('email') email: string, @Req() req: any ): Promise { // Get the requesting admin from the authenticated session (attached by guard) const requestedBy: User = req.user; // Fetch the user to delete from the database - const userToDelete: User = await this.userService.getUserById(userId); + const userToDelete: User = await this.userService.getUserByEmail(email); return await this.userService.deleteUser(userToDelete, requestedBy); } /** - * Get user by ID + * Get user by email */ - @Get(":id") + @Get(":email") @ApiParam({ - name: 'id', - description: 'User ID to retrieve', + name: 'email', + description: 'User email to retrieve', required: true, type: String }) @@ -228,7 +228,7 @@ export class UserController { }) @UseGuards(VerifyAdminOrEmployeeRoleGuard) @ApiBearerAuth() - async getUserById(@Param('id') userId: string): Promise { - return await this.userService.getUserById(userId); + async getUserById(@Param('email') email: string): Promise { + return await this.userService.getUserByEmail(email); } } diff --git a/backend/src/user/user.service.ts b/backend/src/user/user.service.ts index 34105d87..893ce5f8 100644 --- a/backend/src/user/user.service.ts +++ b/backend/src/user/user.service.ts @@ -41,29 +41,29 @@ export class UserService { } // 2. Validate input - if (!user || !user.userId) { + if (!user || !user.email) { this.logger.error("Invalid user object provided for deletion"); throw new BadRequestException("Valid user object is required"); } - if (!requestedBy || !requestedBy.userId) { + if (!requestedBy || !requestedBy.email) { this.logger.error("Invalid requesting user object provided for deletion"); throw new BadRequestException("Valid requesting user is required"); } - const username = user.userId; // now access userID after validating + const email = user.email; // now access userEmail after validating // 3. Authorization check if (requestedBy.position !== UserStatus.Admin) { this.logger.warn( - `Unauthorized deletion attempt: ${requestedBy.userId} tried to delete ${username}` + `Unauthorized deletion attempt: ${requestedBy.email} tried to delete ${email}` ); throw new UnauthorizedException("Only administrators can delete users"); } // 4. Prevent self-deletion - if (requestedBy.userId === username) { - this.logger.warn(`Administrator ${requestedBy.userId} attempted to delete their own account`); + if (requestedBy.email === email) { + this.logger.warn(`Administrator ${requestedBy.email} attempted to delete their own account`); throw new BadRequestException("Administrators cannot delete their own account"); } @@ -72,14 +72,14 @@ export class UserService { try { const params = { TableName: tableName, - Key: { userId: username }, + Key: { email: email }, }; const result = await this.dynamoDb.get(params).promise(); if (!result.Item) { - this.logger.warn(`User ${username} not found in database`); - throw new NotFoundException(`User '${username}' does not exist`); + this.logger.warn(`User ${email} not found in database`); + throw new NotFoundException(`User '${email}' does not exist`); } userToDelete = result.Item as User; @@ -87,7 +87,7 @@ export class UserService { if (error instanceof HttpException) { throw error; } - this.logger.error(`Error checking user existence: ${username}`, error); + this.logger.error(`Error checking user existence: ${email}`, error); throw new InternalServerErrorException("Failed to verify user existence"); } @@ -96,23 +96,23 @@ export class UserService { try { const deleteParams = { TableName: tableName, - Key: { userId: username }, + Key: { email: email }, ReturnValues: "ALL_OLD" as const, }; const deleteResult = await this.dynamoDb.delete(deleteParams).promise(); if (!deleteResult.Attributes) { - this.logger.error(`DynamoDB delete did not return deleted attributes for ${username}`); + this.logger.error(`DynamoDB delete did not return deleted attributes for ${email}`); throw new InternalServerErrorException( "Failed to delete user from database" ); } dynamoDeleted = true; - this.logger.log(`✓ User ${username} deleted from DynamoDB`); + this.logger.log(`✓ User ${email} deleted from DynamoDB`); } catch (error: any) { - this.logger.error(`Failed to delete ${username} from DynamoDB:`, error); + this.logger.error(`Failed to delete ${email} from DynamoDB:`, error); if (error instanceof HttpException) { throw error; @@ -128,21 +128,21 @@ export class UserService { await this.cognito .adminDeleteUser({ UserPoolId: userPoolId, - Username: username, + Username: email, }) .promise(); - this.logger.log(`✓ User ${username} deleted from Cognito`); + this.logger.log(`✓ User ${email} deleted from Cognito`); } catch (cognitoError: any) { this.logger.error( - `Failed to delete ${username} from Cognito:`, + `Failed to delete ${email} from Cognito:`, cognitoError ); // Rollback: Restore user in DynamoDB if (dynamoDeleted) { this.logger.warn( - `Attempting rollback: restoring ${username} to DynamoDB...` + `Attempting rollback: restoring ${email} to DynamoDB...` ); try { @@ -153,23 +153,23 @@ export class UserService { }) .promise(); - this.logger.log(`✓ Rollback successful: User ${username} restored`); + this.logger.log(`✓ Rollback successful: User ${email} restored`); } catch (rollbackError) { this.logger.error( - `Rollback failed: Could not restore ${username}`, + `Rollback failed: Could not restore ${email}`, rollbackError ); this.logger.error( - `CRITICAL: User ${username} deleted from DynamoDB but not from Cognito - manual sync required` + `CRITICAL: User ${email} deleted from DynamoDB but not from Cognito - manual sync required` ); } } // Handle specific Cognito errors if (cognitoError.code === "UserNotFoundException") { - this.logger.error(`User not found in Cognito: ${username}`); + this.logger.error(`User not found in Cognito: ${email}`); throw new NotFoundException( - `User '${username}' not found in authentication system` + `User '${email}' not found in authentication system` ); } else if (cognitoError.code === "InvalidParameterException") { this.logger.error(`Invalid Cognito parameters`); @@ -188,7 +188,7 @@ export class UserService { } this.logger.log( - `✅ User ${username} deleted successfully by ${requestedBy.userId}` + `✅ User ${email} deleted successfully by ${requestedBy.email}` ); return userToDelete; @@ -254,12 +254,12 @@ async addUserToGroup( } // 2. Validate input FIRST before accessing any properties - if (!user || !user.userId) { + if (!user || !user.email) { this.logger.error("Invalid user object provided for role change"); throw new BadRequestException("Valid user object is required"); } - if (!requestedBy || !requestedBy.userId) { + if (!requestedBy || !requestedBy.email) { this.logger.error("Invalid requesting user object provided for role change"); throw new BadRequestException("Valid requesting user is required"); } @@ -270,7 +270,7 @@ async addUserToGroup( } // Now safe to access user properties - const username = user.userId; + const email = user.email; const previousGroup = user.position; // Store the old group for rollback // Validate group name is a valid UserStatus @@ -285,7 +285,7 @@ async addUserToGroup( // 3. Authorization check if (requestedBy.position !== UserStatus.Admin) { this.logger.warn( - `Unauthorized access attempt: ${requestedBy.userId} tried to add ${username} to ${groupName}` + `Unauthorized access attempt: ${requestedBy.email} tried to add ${email} to ${groupName}` ); throw new UnauthorizedException( "Only administrators can modify user groups" @@ -296,31 +296,31 @@ async addUserToGroup( try { const userCheckParams = { TableName: tableName, - Key: { userId: username }, + Key: { email: email }, }; const existingUser = await this.dynamoDb.get(userCheckParams).promise(); if (!existingUser.Item) { - this.logger.warn(`User ${username} not found in database`); - throw new NotFoundException(`User '${username}' does not exist`); + this.logger.warn(`User ${email} not found in database`); + throw new NotFoundException(`User '${email}' does not exist`); } // 5. Check if user is already in the requested group const currentUser = existingUser.Item as User; if (currentUser.position === groupName) { - this.logger.log(`User ${username} is already in group ${groupName}`); + this.logger.log(`User ${email} is already in group ${groupName}`); return currentUser; // No change needed } // 6. Prevent self-demotion for admins if ( - requestedBy.userId === username && + requestedBy.email === email && requestedBy.position === UserStatus.Admin && groupName !== UserStatus.Admin ) { this.logger.warn( - `Administrator ${requestedBy.userId} attempted to demote themselves` + `Administrator ${requestedBy.email} attempted to demote themselves` ); throw new BadRequestException( "Administrators cannot demote themselves" @@ -331,7 +331,7 @@ async addUserToGroup( if (error instanceof HttpException) { throw error; } - this.logger.error(`Error checking user existence: ${username}`, error); + this.logger.error(`Error checking user existence: ${email}`, error); // Handle specific AWS DynamoDB errors if (error.code === 'ResourceNotFoundException') { @@ -353,17 +353,17 @@ async addUserToGroup( .adminRemoveUserFromGroup({ GroupName: previousGroup as string, UserPoolId: userPoolId, - Username: username, + Username: email, }) .promise(); this.logger.log( - `✓ User ${username} removed from Cognito group ${previousGroup}` + `✓ User ${email} removed from Cognito group ${previousGroup}` ); } catch (removeError: any) { // Log but don't fail if user wasn't in the old group this.logger.warn( - `Could not remove ${username} from old group ${previousGroup}: ${removeError.message}` + `Could not remove ${email} from old group ${previousGroup}: ${removeError.message}` ); } } @@ -373,11 +373,11 @@ async addUserToGroup( .adminAddUserToGroup({ GroupName: groupName as string, UserPoolId: userPoolId, - Username: username, + Username: email, }) .promise(); - this.logger.log(`✓ User ${username} added to Cognito group ${groupName}`); + this.logger.log(`✓ User ${email} added to Cognito group ${groupName}`); // Send verification email if moving from Inactive to employee group if ( @@ -391,27 +391,27 @@ async addUserToGroup( ); } catch (emailError) { this.logger.error( - `Failed to send verification email to ${username}:`, + `Failed to send verification email to ${email}:`, emailError ); } } else { this.logger.log( - `No verification email sent to ${username}. Previous group: ${previousGroup}, New group: ${groupName}` + `No verification email sent to ${email}. Previous group: ${previousGroup}, New group: ${groupName}` ); } } catch (cognitoError: any) { this.logger.error( - `Failed to add ${username} to Cognito group ${groupName}:`, + `Failed to add ${email} to Cognito group ${groupName}:`, cognitoError ); // Handle specific Cognito errors if (cognitoError.code === "UserNotFoundException") { - this.logger.error(`User not found in Cognito: ${username}`); + this.logger.error(`User not found in Cognito: ${email}`); throw new NotFoundException( - `User '${username}' not found in authentication system` + `User '${email}' not found in authentication system` ); } else if (cognitoError.code === "ResourceNotFoundException") { this.logger.error(`Group not found in Cognito: ${groupName}`); @@ -446,7 +446,7 @@ async addUserToGroup( // 9. Update user's position in DynamoDB const params = { TableName: tableName, - Key: { userId: username }, + Key: { email: email }, UpdateExpression: "SET #position = :position", ExpressionAttributeNames: { "#position": "position", // Add this to handle reserved keyword @@ -461,7 +461,7 @@ async addUserToGroup( if (!result.Attributes) { this.logger.error( - `DynamoDB update did not return updated attributes for ${username}` + `DynamoDB update did not return updated attributes for ${email}` ); throw new InternalServerErrorException( "Failed to retrieve updated user data" @@ -469,19 +469,19 @@ async addUserToGroup( } this.logger.log( - `✅ User ${username} successfully moved from ${previousGroup} to ${groupName} by ${requestedBy.userId}` + `✅ User ${email} successfully moved from ${previousGroup} to ${groupName} by ${requestedBy.firstName} ${requestedBy.lastName}` ); return result.Attributes as User; } catch (dynamoError: any) { this.logger.error( - `Failed to update ${username} in DynamoDB:`, + `Failed to update ${email} in DynamoDB:`, dynamoError ); // Attempt rollback: revert Cognito group change this.logger.warn( - `Attempting rollback: reverting Cognito group for ${username} back to ${previousGroup}...` + `Attempting rollback: reverting Cognito group for ${email} back to ${previousGroup}...` ); try { @@ -490,7 +490,7 @@ async addUserToGroup( .adminRemoveUserFromGroup({ GroupName: groupName as string, UserPoolId: userPoolId, - Username: username, + Username: email, }) .promise(); @@ -500,28 +500,28 @@ async addUserToGroup( .adminAddUserToGroup({ GroupName: previousGroup as string, UserPoolId: userPoolId, - Username: username, + Username: email, }) .promise(); this.logger.log( - `✓ Rollback successful: User ${username} restored to group ${previousGroup}` + `✓ Rollback successful: User ${email} restored to group ${previousGroup}` ); } } catch (rollbackError: any) { this.logger.error( - `Rollback failed: Could not restore ${username} to group ${previousGroup}`, + `Rollback failed: Could not restore ${email} to group ${previousGroup}`, rollbackError ); this.logger.error( - `CRITICAL: User ${username} group updated in Cognito to ${groupName} but not in DynamoDB - manual sync required` + `CRITICAL: User ${email} group updated in Cognito to ${groupName} but not in DynamoDB - manual sync required` ); } // Handle specific DynamoDB errors if (dynamoError.code === "ConditionalCheckFailedException") { this.logger.error( - `Conditional check failed while updating user ${username} in DynamoDB` + `Conditional check failed while updating user ${email} in DynamoDB` ); throw new ConflictException( "User data was modified by another process" @@ -543,34 +543,34 @@ async addUserToGroup( } } - // purpose statement: retrieves user by their userId + // purpose statement: retrieves user by their email // use case: not actually sure right now, maybe is there is an option for admin to click on a specific user to see details? - async getUserById(userId: string): Promise { + async getUserByEmail(email: string): Promise { // Validate input - if (!userId || typeof userId !== 'string' || userId.trim() === '') { - this.logger.error(`Invalid userId provided: ${userId}`); - throw new BadRequestException("Valid user ID is required"); + if (!email || typeof email !== 'string' || email.trim() === '') { + this.logger.error(`Invalid user email provided: ${email}`); + throw new BadRequestException("Valid user email is required"); } const params = { TableName: process.env.DYNAMODB_USER_TABLE_NAME || "TABLE_FAILURE", Key: { - userId, + email: email, }, }; try { - this.logger.log(`Fetching user ${userId} from DynamoDB...`); + this.logger.log(`Fetching user ${email} from DynamoDB...`); const data = await this.dynamoDb.get(params).promise(); // Check if user exists if (!data.Item) { - this.logger.warn(`User ${userId} not found in database`); - throw new NotFoundException(`User '${userId}' does not exist`); + this.logger.warn(`User ${email} not found in database`); + throw new NotFoundException(`User '${email}' does not exist`); } - this.logger.log(`✅ Successfully retrieved user ${userId}`); + this.logger.log(`✅ Successfully retrieved user ${email}`); return data.Item as User; } catch (error: any) { // Re-throw known exceptions @@ -578,7 +578,7 @@ async addUserToGroup( throw error; } - this.logger.error(`Failed to retrieve user ${userId} from DynamoDB:`, error); + this.logger.error(`Failed to retrieve user ${email} from DynamoDB:`, error); // Handle specific AWS errors if (error.code === 'ResourceNotFoundException') { @@ -614,10 +614,10 @@ async addUserToGroup( const result = await this.dynamoDb.scan(params).promise(); const users: User[] = (result.Items || []).map((item) => ({ - userId: item.userId, // Assign name to userId position: item.position as UserStatus, email: item.email, - name: item.userId, // Keep name as name + firstName: item.firstName, + lastName: item.lastName })); this.logger.log(`✅ Successfully retrieved ${users.length} inactive users`); @@ -669,10 +669,10 @@ async getAllActiveUsers(): Promise { throw new NotFoundException("No active users found."); } const users: User[] = (result.Items || []).map((item) => ({ - userId: item.userId, // Assign name to userId position: item.position as UserStatus, email: item.email, - name: item.userId, // Keep name as name + firstName: item.firstName, + lastName: item.lastName })); this.logger.debug(`Fetched ${users.length} active users.`); diff --git a/frontend/src/ForgotPassword.tsx b/frontend/src/ForgotPassword.tsx new file mode 100644 index 00000000..799102fa --- /dev/null +++ b/frontend/src/ForgotPassword.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import logo from "./images/logo.svg"; + +/** + * Forgot Password page - allows users to request a password reset email + */ +const ForgotPassword = () => { + const [email, setEmail] = useState(""); + const [submitted, setSubmitted] = useState(false); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + // TODO: Add API call to backend when endpoint is ready + // For now, just show success message + console.log("Password reset requested for:", email); + setSubmitted(true); + }; + + return ( +
+ {/* Left side: Forgot Password form */} +
+
+

Forgot Password

+
+ + {!submitted ? ( +
+ {/* Email Input */} +
+ + setEmail(e.target.value)} + placeholder="Enter your email address" + className="w-full rounded-xl border border-grey-600 bg-white py-3 px-4 text-base text-black placeholder:text-grey-600 focus:outline-none focus:ring-2 focus:ring-primary-900 focus:border-transparent" + /> +
+ + {/* Send Email Button */} + + + {/* Back to Login Link */} +
+ Remembered your password?{" "} + +
+
+ ) : ( + // Success message after submission +
+
+ If an account exists with this email, you'll receive password reset instructions. +
+ +
+ )} +
+ + {/* Right side: Logo */} +
+
+ BCAN Logo +
+
+
+ ); +}; + +export default ForgotPassword; \ No newline at end of file diff --git a/frontend/src/Login.tsx b/frontend/src/Login.tsx index 6a856636..453df9f3 100644 --- a/frontend/src/Login.tsx +++ b/frontend/src/Login.tsx @@ -9,8 +9,10 @@ import "./external/bcanSatchel/mutators"; * Registered users can log in here */ const Login = observer(() => { - const [username, setUsername] = useState(""); + const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); + const [showPassword, setShowPassword] = useState(false); const [failure, setFailure] = useState(false); const navigate = useNavigate(); const { login } = useAuthContext(); @@ -18,100 +20,130 @@ const Login = observer(() => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const success = await login(username, password); + const success = await login(email, password); if (success) { - navigate("/grant-info"); + navigate("/main/all-grants"); } else { setFailure(true); } }; return ( -
+
{/*/ Left side: Registration form */} -
-
-

Welcome back!

-

- Enter your credentials to access your account. -

+
+
+

Log in

-
-