Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
import {
CognitoIdentityProviderClient,
AdminCreateUserCommand,
AdminAddUserToGroupCommand,
AdminRemoveUserFromGroupCommand,
} from '@aws-sdk/client-cognito-identity-provider';

import CognitoAuthConfig from './aws-exports';
Expand Down Expand Up @@ -70,4 +72,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 () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

we generally havent been testing for the sad cases in the controller, since primarily the service will handle bad input logic. controller tests for us are primarily just to make sure that the service is called once, and with the right parameters.

cuz of that, can we delete this test and the one below?

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
116 changes: 116 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 @@ -730,4 +734,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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

in our dummy data, user id 7 is a volunteer i believe (from line 553). can we just use that for all of our promote checks here instead of finding all volunteers?

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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

can we make sure hat the volunteer had pantries to begin with before, to show that the call removes them?


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({

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 above comment about using id instead

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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

same thing here, user id 1 should be an admin

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 () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

nit: can we rephrase this to "when user is not a volunteer" since thats really what we want to test (any other role will work though).

const pantryUser = await testDataSource.getRepository(User).findOne({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

same thing here with this search, can we just use id instead? user id 10 should be a pantry

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 () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

can delete this, the above test covers our cases

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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

same here

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);
});
});
});
36 changes: 34 additions & 2 deletions apps/backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Between, In, Repository } from 'typeorm';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Between, DataSource, In, Repository } from 'typeorm';
import { User } from './users.entity';
import { PendingApplication, Role } from './types';
import { validateId } from '../utils/validation.utils';
Expand Down Expand Up @@ -44,6 +44,8 @@ export class UsersService {
private pantryRepo: Repository<Pantry>,
@InjectRepository(FoodManufacturer)
private fmRepo: Repository<FoodManufacturer>,
@InjectDataSource()
private dataSource: DataSource,
private authService: AuthService,
private emailsService: EmailsService,
@Inject(forwardRef(() => PantriesService))
Expand Down Expand Up @@ -298,4 +300,34 @@ export class UsersService {
throw new BadRequestException(`Unsupported role: ${user.role}`);
}
}

async promoteVolunteerToAdmin(userId: number): Promise<void> {
validateId(userId, 'User');

const user = await this.repo.findOne({
where: { id: userId },
relations: ['pantries'],
});

if (!user) {
throw new NotFoundException(`User ${userId} not found`);
}

if (user.role !== Role.VOLUNTEER) {
throw new BadRequestException(
`User ${userId} is not a volunteer. Current role: ${user.role}`,
);
}

await this.dataSource.transaction(async (transactionManager) => {
const userRepo = transactionManager.getRepository(User);

user.role = Role.ADMIN;
user.pantries = [];
await userRepo.save(user);

await this.authService.addUserToGroup(user.email, 'admin');
await this.authService.removeUserFromGroup(user.email, 'volunteer');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

if this removeUserFromGroup call fails, then our database will be inconsistent with Cognito (we will have an admin user in Cognito, but this will rollback and give us a volunteer in the database. For this second call, can we do a try catch and, in the catch, run await this.authService.removeUserFromGroup(user.email, 'admin'); so that we remove the just added admin, and then throw an error so that everything rolls back? can we also add a specific test for this?

});
}
}
4 changes: 4 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,10 @@ export class ApiClient {
.then((response) => response.data);
}

public async promoteVolunteerToAdmin(userId: number): Promise<void> {
await this.axiosInstance.patch(`/api/users/${userId}/promote-volunteer`);
}

public async getFoodRequest(requestId: number): Promise<FoodRequest> {
return this.axiosInstance
.get(`/api/requests/${requestId}`)
Expand Down
Loading
Loading