Skip to content
Open
45 changes: 44 additions & 1 deletion apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
import {
CognitoIdentityProviderClient,
AdminCreateUserCommand,
AdminAddUserToGroupCommand,
AdminRemoveUserFromGroupCommand,
} from '@aws-sdk/client-cognito-identity-provider';

import CognitoAuthConfig from './aws-exports';
import { SignUpDto } from './dtos/sign-up.dto';
import { createHmac } from 'crypto';
import { Role } from '../users/types';
import { validateEnv } from '../utils/validation.utils';

@Injectable()
Expand Down Expand Up @@ -44,7 +47,8 @@ export class AuthService {
firstName,
lastName,
email,
}: Omit<SignUpDto, 'password' | 'phone'>): Promise<string> {
role,
}: Omit<SignUpDto, 'password' | 'phone'> & { role: Role }): Promise<string> {
const createUserCommand = new AdminCreateUserCommand({
UserPoolId: CognitoAuthConfig.userPoolId,
Username: email,
Expand All @@ -61,6 +65,10 @@ export class AuthService {
const sub = response.User?.Attributes?.find(
(attr) => attr.Name === 'sub',
)?.Value;

// Add user to the appropriate Cognito group based on their role
await this.addUserToGroup(email, role);

return sub ?? '';
} catch (error) {
if (error instanceof Error && error.name == 'UsernameExistsException') {
Expand All @@ -70,4 +78,39 @@ export class AuthService {
}
}
}

async addUserToGroup(username: string, groupName: string): Promise<void> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see slack comments

const command = new AdminAddUserToGroupCommand({
UserPoolId: CognitoAuthConfig.userPoolId,
Username: username,
GroupName: groupName,
});

try {
await this.providerClient.send(command);
} catch (error) {
throw new InternalServerErrorException(
`Failed to add user to group ${groupName}`,
);
}
}

async removeUserFromGroup(
username: string,
groupName: string,
): Promise<void> {
const command = new AdminRemoveUserFromGroupCommand({
UserPoolId: CognitoAuthConfig.userPoolId,
Username: username,
GroupName: groupName,
});

try {
await this.providerClient.send(command);
} catch (error) {
throw new InternalServerErrorException(
`Failed to remove user from group ${groupName}`,
);
}
}
}
5 changes: 5 additions & 0 deletions apps/backend/src/foodRequests/request.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { FoodRequest } from './request.entity';
import { RequestsService } from './request.service';
import { Pantry } from '../pantries/pantries.entity';
Expand Down Expand Up @@ -66,6 +67,10 @@ describe('RequestsService', () => {
provide: EmailsService,
useValue: mockEmailsService,
},
{
provide: DataSource,
useValue: testDataSource,
},
],
}).compile();

Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/pantries/pantries.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PantriesService } from './pantries.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { In } from 'typeorm';
import { DataSource, In } from 'typeorm';
import { Pantry } from './pantries.entity';
import {
BadRequestException,
Expand Down Expand Up @@ -145,6 +145,10 @@ describe('PantriesService', () => {
provide: getRepositoryToken(FoodManufacturer),
useValue: testDataSource.getRepository(FoodManufacturer),
},
{
provide: DataSource,
useValue: testDataSource,
},
],
}).compile();

Expand Down
35 changes: 34 additions & 1 deletion apps/backend/src/users/users.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { userSchemaDto } from './dtos/userSchema.dto';
import { Test, TestingModule } from '@nestjs/testing';
import { mock } from 'jest-mock-extended';
import { UpdateUserInfoDto } from './dtos/update-user-info.dto';
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { AuthenticatedRequest } from '../auth/authenticated-request';

const mockUserService = mock<UsersService>();
Expand All @@ -31,6 +31,7 @@ describe('UsersController', () => {
mockUserService.create.mockReset();
mockUserService.getUserDashboardStats.mockReset();
mockUserService.getRecentPendingApplications.mockReset();
mockUserService.promoteVolunteerToAdmin.mockReset();

const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
Expand Down Expand Up @@ -211,4 +212,36 @@ describe('UsersController', () => {
expect(result).toEqual([]);
});
});

describe('PATCH /:id/promote-volunteer', () => {
it('should promote volunteer to admin successfully', async () => {
mockUserService.promoteVolunteerToAdmin.mockResolvedValueOnce(undefined);

await controller.promoteToAdmin(1);

expect(mockUserService.promoteVolunteerToAdmin).toHaveBeenCalledWith(1);
});

it('should throw NotFoundException from service when user not found', async () => {
Comment thread
jxuistrying marked this conversation as resolved.
mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce(
new NotFoundException('User 999 not found'),
);

await expect(controller.promoteToAdmin(999)).rejects.toThrow(
new NotFoundException('User 999 not found'),
);
});

it('should throw BadRequestException from service when user is not a volunteer', async () => {
mockUserService.promoteVolunteerToAdmin.mockRejectedValueOnce(
new BadRequestException(
'User 1 is not a volunteer. Current role: admin',
),
);

await expect(controller.promoteToAdmin(1)).rejects.toThrow(
BadRequestException,
);
});
});
});
6 changes: 6 additions & 0 deletions apps/backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export class UsersController {
return this.usersService.update(id, dto);
}

@Patch('/:id/promote-volunteer')
@Roles(Role.ADMIN)
async promoteToAdmin(@Param('id', ParseIntPipe) id: number): Promise<void> {
await this.usersService.promoteVolunteerToAdmin(id);
}

// Keeping these two as functionality seems useful
@Post('/')
async createUser(@Body() createUserDto: userSchemaDto): Promise<User> {
Expand Down
118 changes: 118 additions & 0 deletions apps/backend/src/users/users.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ jest.setTimeout(60000);

const mockAuthService = {
adminCreateUser: jest.fn().mockResolvedValue('mock-sub'),
addUserToGroup: jest.fn().mockResolvedValue(undefined),
removeUserFromGroup: jest.fn().mockResolvedValue(undefined),
};
const mockEmailsService = mock<EmailsService>();

Expand Down Expand Up @@ -125,6 +127,8 @@ describe('UsersService', () => {

beforeEach(async () => {
mockAuthService.adminCreateUser.mockClear();
mockAuthService.addUserToGroup.mockClear();
mockAuthService.removeUserFromGroup.mockClear();
mockEmailsService.sendEmails.mockClear();
await testDataSource.runMigrations();
});
Expand Down Expand Up @@ -167,6 +171,7 @@ describe('UsersService', () => {
firstName: createUserDto.firstName,
lastName: createUserDto.lastName,
email: createUserDto.email,
role: createUserDto.role,
});
expect(result.id).toBeDefined();
expect(result.userCognitoSub).toBe('mock-sub');
Expand Down Expand Up @@ -246,6 +251,7 @@ describe('UsersService', () => {
firstName: createUserDto.firstName,
lastName: createUserDto.lastName,
email: createUserDto.email,
role: createUserDto.role,
});
expect(mockEmailsService.sendEmails).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -730,4 +736,116 @@ describe('UsersService', () => {
expect(types).toContain('food_manufacturer');
});
});

describe('promoteVolunteerToAdmin', () => {
it('should promote volunteer to admin successfully', async () => {
const userRepo = testDataSource.getRepository(User);
const volunteers = await userRepo.find({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.VOLUNTEER },
});
expect(volunteers.length).toBeGreaterThan(0);
const volunteer = volunteers[0];

await service.promoteVolunteerToAdmin(volunteer.id);

const updatedUser = await userRepo.findOne({
where: { id: volunteer.id },
});
expect(updatedUser!.role).toBe(Role.ADMIN);
});

it('should clear volunteer pantry assignments after promotion', async () => {
const volunteer = await testDataSource.getRepository(User).findOne({
where: { role: Role.VOLUNTEER },
relations: ['pantries'],
});
expect(volunteer).toBeDefined();
Comment thread
jxuistrying marked this conversation as resolved.

await service.promoteVolunteerToAdmin(volunteer!.id);

const assignments = await testDataSource.query(
`SELECT * FROM volunteer_assignments WHERE volunteer_id = $1`,
[volunteer!.id],
);
expect(assignments).toHaveLength(0);
});

it('should call Cognito addUserToGroup and removeUserFromGroup', async () => {
const volunteer = await testDataSource.getRepository(User).findOne({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.VOLUNTEER },
});
expect(volunteer).toBeDefined();

await service.promoteVolunteerToAdmin(volunteer!.id);

expect(mockAuthService.addUserToGroup).toHaveBeenCalledWith(
volunteer!.email,
'admin',
);
expect(mockAuthService.removeUserFromGroup).toHaveBeenCalledWith(
volunteer!.email,
'volunteer',
);
});

it('should throw NotFoundException when user does not exist', async () => {
await expect(service.promoteVolunteerToAdmin(99999)).rejects.toThrow(
NotFoundException,
);
});

it('should throw BadRequestException when user is already admin', async () => {
const admin = await testDataSource.getRepository(User).findOne({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.ADMIN },
});
expect(admin).toBeDefined();

await expect(service.promoteVolunteerToAdmin(admin!.id)).rejects.toThrow(
BadRequestException,
);
});

it('should throw BadRequestException when user is pantry', async () => {
Comment thread
jxuistrying marked this conversation as resolved.
const pantryUser = await testDataSource.getRepository(User).findOne({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.PANTRY },
});
expect(pantryUser).toBeDefined();

await expect(
service.promoteVolunteerToAdmin(pantryUser!.id),
).rejects.toThrow(BadRequestException);
});

it('should throw BadRequestException when user is food manufacturer', async () => {
Comment thread
jxuistrying marked this conversation as resolved.
const fmUser = await testDataSource.getRepository(User).findOne({
where: { role: Role.FOODMANUFACTURER },
});
expect(fmUser).toBeDefined();

await expect(service.promoteVolunteerToAdmin(fmUser!.id)).rejects.toThrow(
BadRequestException,
);
});

it('should rollback if Cognito fails', async () => {
const userRepo = testDataSource.getRepository(User);
const volunteer = await userRepo.findOne({
Comment thread
jxuistrying marked this conversation as resolved.
where: { role: Role.VOLUNTEER },
});
expect(volunteer).toBeDefined();

mockAuthService.addUserToGroup.mockRejectedValueOnce(
new InternalServerErrorException('Cognito error'),
);

await expect(
service.promoteVolunteerToAdmin(volunteer!.id),
).rejects.toThrow(InternalServerErrorException);

const userAfter = await userRepo.findOne({
where: { id: volunteer!.id },
});
expect(userAfter!.role).toBe(Role.VOLUNTEER);
});
});
});
Loading
Loading