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
30 changes: 29 additions & 1 deletion apps/backend/src/pantries/pantries.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { User } from '../users/users.entity';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto';
import { RequestsService } from '../foodRequests/request.service';
import { Role } from '../users/types';
import { FoodRequest } from '../foodRequests/request.entity';

const mockPantriesService = mock<PantriesService>();
Expand Down Expand Up @@ -296,7 +297,7 @@ describe('PantriesController', () => {
});

describe('PATCH /:pantryId/application', () => {
const req = { user: { id: 1 } };
const req = { user: { id: 1, role: Role.PANTRY } };

it('should update a pantry application', async () => {
const pantryId = 1;
Expand All @@ -323,6 +324,7 @@ describe('PantriesController', () => {
pantryId,
mockUpdateData,
1,
Role.PANTRY,
);
});

Expand All @@ -346,6 +348,32 @@ describe('PantriesController', () => {
999,
mockUpdateData,
1,
Role.PANTRY,
);
});

it('should allow admin to update any pantry application', async () => {
const adminReq = { user: { id: 2, role: Role.ADMIN } };
const pantryId = 1;

const mockUpdateData: UpdatePantryApplicationDto = {
secondaryContactFirstName: 'Admin Updated',
};

mockPantriesService.updatePantryApplication.mockResolvedValue(mockPantry);

const result = await controller.updatePantryApplication(
adminReq as AuthenticatedRequest,
pantryId,
mockUpdateData,
);

expect(result).toEqual(mockPantry);
expect(mockPantriesService.updatePantryApplication).toHaveBeenCalledWith(
pantryId,
mockUpdateData,
2,
Role.ADMIN,
);
});
});
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/src/pantries/pantries.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ export class PantriesController {
idParam: 'pantryId',
resolver: resolvePantryAuthorizedUserIds,
})
@Roles(Role.PANTRY)
@Roles(Role.PANTRY, Role.ADMIN)
@Patch('/:pantryId/application')
async updatePantryApplication(
@Req() req: AuthenticatedRequest,
Expand All @@ -404,6 +404,7 @@ export class PantriesController {
pantryId,
pantryData,
req.user.id,
req.user.role,
);
}

Expand Down
35 changes: 35 additions & 0 deletions apps/backend/src/pantries/pantries.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { UpdatePantryApplicationDto } from './dtos/update-pantry-application.dto
import { EmailsService } from '../emails/email.service';
import { mock } from 'jest-mock-extended';
import { emailTemplates, SSF_PARTNER_EMAIL } from '../emails/emailTemplates';
import { Role } from '../users/types';

jest.setTimeout(60000);

Expand Down Expand Up @@ -483,6 +484,40 @@ describe('PantriesService', () => {
),
);
});

it('allows admin to update any approved pantry (bypassing ownership check)', async () => {
const dto: UpdatePantryApplicationDto = {
secondaryContactFirstName: 'AdminUpdated',
itemsInStock: 'Admin updated items',
};

// User 1 is not the owner of pantry 1 (owner is user 10), but as admin should be able to edit
const adminUserId = 1;
const updatedPantry = await service.updatePantryApplication(
1,
dto,
adminUserId,
Role.ADMIN,
);

expect(updatedPantry.secondaryContactFirstName).toBe('AdminUpdated');
expect(updatedPantry.itemsInStock).toBe('Admin updated items');
});

it('still throws ForbiddenException for non-admin non-owner users', async () => {
const dto: UpdatePantryApplicationDto = {
itemsInStock: 'Should not work',
};

// User 999 is not the owner and not an admin
await expect(
service.updatePantryApplication(1, dto, 999, Role.PANTRY),
).rejects.toThrow(
new ForbiddenException(
'User 999 is not allowed to edit application for Pantry 1',
),
);
});
});

describe('getPantryStats (single pantry)', () => {
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/pantries/pantries.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ export class PantriesService {
pantryId: number,
pantryData: UpdatePantryApplicationDto,
currentUserId: number,
currentUserRole?: Role,
): Promise<Pantry> {
validateId(pantryId, 'Pantry');
validateId(currentUserId, 'User');
Expand All @@ -435,7 +436,10 @@ export class PantriesService {
throw new NotFoundException(`Pantry ${pantryId} not found`);
}

if (pantry.pantryUser.id !== currentUserId) {
if (
currentUserRole !== Role.ADMIN &&
pantry.pantryUser.id !== currentUserId
) {
throw new ForbiddenException(
`User ${currentUserId} is not allowed to edit application for Pantry ${pantryId}`,
);
Expand Down
31 changes: 25 additions & 6 deletions apps/frontend/src/components/forms/editablePantryApplication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,20 +254,31 @@ function validateRequired(form: FormState): boolean {
interface EditablePantryApplicationProps {
isEditing: boolean;
onEditingChange: (v: boolean) => void;
pantryId?: number;
initialApplication?: PantryWithUser;
}

const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
isEditing,
onEditingChange,
pantryId: propPantryId,
initialApplication,
}) => {
const [application, setApplication] = useState<PantryWithUser | null>(null);
const [application, setApplication] = useState<PantryWithUser | null>(
initialApplication ?? null,
);
const [alertState, setAlertMessage] = useAlert();
const [isSaving, setIsSaving] = useState(false);
const [form, setForm] = useState<FormState | null>(null);
const [isLoading, setIsLoading] = useState(!initialApplication);
const [form, setForm] = useState<FormState | null>(
initialApplication ? buildFormState(initialApplication) : null,
);

const fetchApplication = useCallback(async () => {
try {
const pantryId = await ApiClient.getCurrentUserPantryId();
setIsLoading(true);
const pantryId =
propPantryId ?? (await ApiClient.getCurrentUserPantryId());
if (pantryId) {
const data = await ApiClient.getPantry(pantryId);
setApplication(data);
Expand All @@ -278,12 +289,16 @@ const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
'Could not load application details. Please try again later.',
AlertStatus.ERROR,
);
} finally {
setIsLoading(false);
}
}, []);
}, [propPantryId]);

useEffect(() => {
fetchApplication();
}, [fetchApplication]);
if (!initialApplication) {
fetchApplication();
}
}, [fetchApplication, initialApplication]);

useEffect(() => {
if (!isEditing && application) setForm(buildFormState(application));
Expand Down Expand Up @@ -429,6 +444,10 @@ const EditablePantryApplication: React.FC<EditablePantryApplicationProps> = ({
}
};

if (isLoading) {
return null;
}

if (!application || !form) {
return (
<Center py={16}>
Expand Down
Loading
Loading