Skip to content
Merged
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
10 changes: 9 additions & 1 deletion apps/backend/src/auth/ownership.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { SetMetadata, Type } from '@nestjs/common';
import { Role } from '../users/types';
import { User } from '../users/users.entity';

// Resolver function type to get the owner user ID for a given entity ID
// Should return the user IDs of the users who are authorized to call the
// endpoint that the decorator is attached to
// endpoint that the decorator is attached to. The current authenticated user
// is optionally provided so resolvers can branch on role when different roles
// need different ownership rules (e.g. PANTRY -> pantry rep, VOLUNTEER -> assigned volunteers).
// If the resolver returns null, it will be treated as if the user is not authorized
export type OwnerIdResolver = (params: {
entityId: number;
services: ServiceRegistry;
user?: User;
}) => Promise<number[] | null>;

// Registry of services that can be easily resolved
Expand All @@ -18,8 +22,12 @@ export interface ServiceRegistry {
}

// Configuration for ownership check
/**
*
*/
export interface OwnershipConfig {
idParam: string;
idSource?: 'params' | 'body';
resolver: OwnerIdResolver;
bypassRoles?: Role[];
}
Expand Down
33 changes: 31 additions & 2 deletions apps/backend/src/auth/ownership.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ import { Role } from '../users/types';
// Helper to create a mock execution context with specified user and params
// Creates the context to determine which user is making the request and what
// parameters are being passed in, such as the entity ID for ownership checks
function makeExecutionContext(user: User | null, params: Record<string, any>) {
function makeExecutionContext(
user: User | null,
params: Record<string, any>,
body: Record<string, any> = {},
) {
return {
switchToHttp: () => ({
getRequest: () => ({ user, params }),
getRequest: () => ({ user, params, body }),
}),
getHandler: () => jest.fn(),
} as any;
Expand Down Expand Up @@ -141,6 +145,31 @@ describe('OwnershipGuard', () => {
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});

it('extracts idParam from request body when idSource is "body"', async () => {
const config: OwnershipConfig = {
idParam: 'pantryId',
idSource: 'body',
resolver: async ({ entityId }) => {
expect(entityId).toBe(7);
return [dummyUser.id];
},
};
const guard = new OwnershipGuard(makeReflector(config), makeModuleRef());
const ctx = makeExecutionContext(dummyUser, {}, { pantryId: 7 });
await expect(guard.canActivate(ctx)).resolves.toBe(true);
});

it('throws ForbiddenException when body idParam is missing', async () => {
const config: OwnershipConfig = {
idParam: 'pantryId',
idSource: 'body',
resolver: async () => [dummyUser.id],
};
const guard = new OwnershipGuard(makeReflector(config), makeModuleRef());
const ctx = makeExecutionContext(dummyUser, {}, {});
await expect(guard.canActivate(ctx)).rejects.toThrow(ForbiddenException);
});

describe('OwnershipGuard bypassRoles', () => {
it('returns true when user role is in bypassRoles without calling resolver', async () => {
const resolver = jest.fn();
Expand Down
11 changes: 8 additions & 3 deletions apps/backend/src/auth/ownership.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,17 @@ export class OwnershipGuard implements CanActivate {
return true;
}

// Specified roles bypass ownership checks
// Specified bypass ownership checks for other roles
if (config.bypassRoles?.includes(user.role as Role)) {
return true;
}

// Get the id from the parameters
const entityId = Number(req.params[config.idParam]);
// Get the id from the configured source (URL params by default, request body opt-in)
const rawId =
config.idSource === 'body'
? req.body?.[config.idParam]
: req.params[config.idParam];
const entityId = Number(rawId);

if (isNaN(entityId)) {
throw new ForbiddenException(`Invalid ${config.idParam}`);
Expand All @@ -63,6 +67,7 @@ export class OwnershipGuard implements CanActivate {
const ownerIds = await config.resolver({
entityId,
services,
user,
});

if (
Expand Down
70 changes: 69 additions & 1 deletion apps/backend/src/foodRequests/request.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { RequestsService } from './request.service';
import { RequestsController } from './request.controller';
import {
RequestsController,
resolveRequestAuthorizedUserIds,
} from './request.controller';
import { Test, TestingModule } from '@nestjs/testing';
import { mock } from 'jest-mock-extended';
import { FoodRequest } from './request.entity';
Expand All @@ -16,6 +19,9 @@ import {
} from './dtos/matching.dto';
import { FoodManufacturer } from '../foodManufacturers/manufacturers.entity';
import { Pantry } from '../pantries/pantries.entity';
import { PantriesService } from '../pantries/pantries.service';
import { User } from '../users/users.entity';
import { Role } from '../users/types';

const mockRequestsService = mock<RequestsService>();

Expand Down Expand Up @@ -303,4 +309,66 @@ describe('RequestsController', () => {
expect(mockRequestsService.closeRequest).toHaveBeenCalledWith(requestId);
});
});

describe('resolveRequestAuthorizedUserIds', () => {
const mockResolverRequestsService = mock<RequestsService>();
const mockResolverPantriesService = mock<PantriesService>();

const services = {
get: <T>(cls: { name: string }): T => {
if (cls === RequestsService)
return mockResolverRequestsService as unknown as T;
if (cls === PantriesService)
return mockResolverPantriesService as unknown as T;
throw new Error(`unmocked service: ${cls.name}`);
},
};

const pantryRep: User = { id: 99, role: Role.PANTRY } as User;
const volunteer1: User = { id: 7, role: Role.VOLUNTEER } as User;
const volunteer2: User = { id: 8, role: Role.VOLUNTEER } as User;

const mockRequest = { requestId: 1, pantryId: 5 } as FoodRequest;
const mockPantry = {
pantryId: 5,
pantryUser: pantryRep,
volunteers: [volunteer1, volunteer2],
} as Pantry;

beforeEach(() => {
mockResolverRequestsService.findOne.mockReset();
mockResolverPantriesService.findOne.mockReset();
mockResolverRequestsService.findOne.mockResolvedValue(mockRequest);
mockResolverPantriesService.findOne.mockResolvedValue(mockPantry);
});

it('PANTRY: looks up request then pantry, returns pantry rep id', async () => {
const ids = await resolveRequestAuthorizedUserIds({
entityId: 1,
services,
user: pantryRep,
});

expect(mockResolverRequestsService.findOne).toHaveBeenCalledWith(1);
expect(mockResolverPantriesService.findOne).toHaveBeenCalledWith(
mockRequest.pantryId,
);

expect(ids).toEqual([pantryRep.id]);
});

it('VOLUNTEER: looks up request then pantry, returns volunteer ids', async () => {
const ids = await resolveRequestAuthorizedUserIds({
entityId: 1,
services,
user: volunteer1,
});

expect(mockResolverRequestsService.findOne).toHaveBeenCalledWith(1);
expect(mockResolverPantriesService.findOne).toHaveBeenCalledWith(
mockRequest.pantryId,
);
expect(ids).toEqual([volunteer1.id, volunteer2.id]);
});
});
});
76 changes: 74 additions & 2 deletions apps/backend/src/foodRequests/request.controller.ts
Copy link
Copy Markdown

@jiang-h-y jiang-h-y May 27, 2026

Choose a reason for hiding this comment

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

I'm struggling with testing these endpoints since I keep getting Unauthorized 401 errors if I try to access any endpoint except /api. What's the best way to troubleshoot this?

Edit: Figured it out, and endpoints look good

Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,44 @@ import {
MatchingItemsDto,
MatchingManufacturersDto,
} from './dtos/matching.dto';
import {
CheckOwnership,
OwnerIdResolver,
pipeNullable,
} from '../auth/ownership.decorator';
import { PantriesService } from '../pantries/pantries.service';
import { Pantry } from '../pantries/pantries.entity';
import { UpdateRequestDto } from './dtos/update-request.dto';

// PANTRY users may access requests belonging to their own pantry (matched by
// pantry representative id). All other non-admin callers (i.e. VOLUNTEER) must
// be in the pantry's assigned volunteers list. ADMIN bypasses in the guard.
export const resolveRequestAuthorizedUserIds: OwnerIdResolver = ({
entityId,
services,
user,
}) =>
pipeNullable(
() => services.get(RequestsService).findOne(entityId),
(request: FoodRequest) =>
services.get(PantriesService).findOne(request.pantryId),
(pantry: Pantry) =>
user?.role === Role.PANTRY
? [pantry.pantryUser.id]
: (pantry.volunteers ?? []).map((v) => v.id),
);

// For creating a request, the pantryId comes from the request body and the
// only authorized non-admin caller is the pantry representative.
export const resolveCreateRequestAuthorizedUserIds: OwnerIdResolver = ({
entityId,
services,
}) =>
pipeNullable(
() => services.get(PantriesService).findOne(entityId),
(pantry: Pantry) => [pantry.pantryUser.id],
);

@Controller('requests')
export class RequestsController {
constructor(private requestsService: RequestsService) {}
Expand All @@ -35,6 +71,10 @@ export class RequestsController {
return this.requestsService.getAll();
}

@CheckOwnership({
idParam: 'requestId',
resolver: resolveRequestAuthorizedUserIds,
})
@Roles(Role.PANTRY, Role.ADMIN, Role.VOLUNTEER)
@Get('/:requestId')
async getRequest(
Expand All @@ -43,6 +83,10 @@ export class RequestsController {
return this.requestsService.findOne(requestId);
}

@CheckOwnership({
idParam: 'requestId',
resolver: resolveRequestAuthorizedUserIds,
})
@Roles(Role.VOLUNTEER, Role.PANTRY, Role.ADMIN)
@Get('/:requestId/order-details')
async getAllOrderDetailsFromRequest(
Expand All @@ -51,15 +95,23 @@ export class RequestsController {
return this.requestsService.getOrderDetails(requestId);
}

@Roles(Role.VOLUNTEER)
@CheckOwnership({
idParam: 'requestId',
resolver: resolveRequestAuthorizedUserIds,
})
@Roles(Role.ADMIN, Role.VOLUNTEER)
@Get('/:requestId/matching-manufacturers')
async getMatchingManufacturers(
@Param('requestId', ParseIntPipe) requestId: number,
): Promise<MatchingManufacturersDto> {
return this.requestsService.getMatchingManufacturers(requestId);
}

@Roles(Role.VOLUNTEER)
@CheckOwnership({
idParam: 'requestId',
resolver: resolveRequestAuthorizedUserIds,
})
@Roles(Role.ADMIN, Role.VOLUNTEER)
@Get('/:requestId/matching-manufacturers/:manufacturerId/available-items')
async getAvailableItemsForManufacturer(
@Param('requestId', ParseIntPipe) requestId: number,
Expand All @@ -68,6 +120,12 @@ export class RequestsController {
return this.requestsService.getAvailableItems(requestId, manufacturerId);
}

@CheckOwnership({
idParam: 'pantryId',
idSource: 'body',
resolver: resolveCreateRequestAuthorizedUserIds,
})
@Roles(Role.ADMIN, Role.PANTRY)
@Post()
@ApiBody({
description: 'Details for creating a food request',
Comment thread
dburkhart07 marked this conversation as resolved.
Expand Down Expand Up @@ -105,6 +163,11 @@ export class RequestsController {
);
}

@Roles(Role.PANTRY)
@CheckOwnership({
idParam: 'requestId',
resolver: resolveRequestAuthorizedUserIds,
})
@Patch('/:requestId')
async updateRequest(
@Param('requestId', ParseIntPipe) requestId: number,
Expand All @@ -113,13 +176,22 @@ export class RequestsController {
await this.requestsService.update(requestId, body);
}

@Roles(Role.PANTRY)
@CheckOwnership({
idParam: 'requestId',
resolver: resolveRequestAuthorizedUserIds,
})
@Delete('/:requestId')
async deleteRequest(
@Param('requestId', ParseIntPipe) requestId: number,
): Promise<void> {
return this.requestsService.delete(requestId);
}

@CheckOwnership({
idParam: 'requestId',
resolver: resolveRequestAuthorizedUserIds,
})
@Roles(Role.VOLUNTEER)
@Patch('/:requestId/close')
async closeRequest(
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/pantries/pantries.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class PantriesService {

const pantry = await this.repo.findOne({
where: { pantryId },
relations: ['pantryUser'],
relations: ['pantryUser', 'volunteers'],
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does this affect the api return shape / should we account for it?

Copy link
Copy Markdown
Author

@dburkhart07 dburkhart07 May 28, 2026

Choose a reason for hiding this comment

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

it shouldnt. the frontend Pantry interface takes in volunteers optionally, and the Pantry entity has volunteers already as well. It does expose this on all endpoints that receive a Pantry, but none of them are using the volunteers attribute, only the resolver is so its not a huge deal.

});

if (!pantry) {
Expand Down
Loading