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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- Funder collaborative endpoints now return `403` (or `404`) instead of `401` when an authenticated user lacks permission.
- `POST /opportunities` now returns `403` (or `404`) instead of `401` when an authenticated user lacks permission on the associated funder.
- `POST /proposals` now returns `403` (or `404`) instead of `422` when an authenticated user lacks permission on (or cannot view) the associated opportunity.
- `POST /changemakerFieldValueBatches` now returns `403` (or `404`) instead of `422` when an authenticated user lacks permission to reference (or cannot view) the specified source.
- `POST /changemakerFieldValues` now returns `403` when an authenticated user lacks permission to create field values for the specified changemaker, and `404` (instead of `409`) when the changemaker, base field, or batch does not exist.
- `POST /changemakerProposals` now returns `403` (instead of `422`) when an authenticated user lacks permission on the funder associated with the proposal, and `404` (instead of `422`) when the associated proposal cannot be viewed or does not exist.
- `POST /sources` now returns `403` (instead of `422`) when an authenticated user lacks permission to create a source for the specified funder, data provider, or changemaker, and `404` when that organization does not exist.
- `POST /proposalVersions` now returns `403` (instead of `422`) when an authenticated user lacks permission to edit the proposal or reference the source, and `404` (instead of `409`) when the proposal, source, application form, or application form field cannot be viewed or does not exist. A `409` is now returned only when the application form or field is not associated with the proposal.
- `POST /tasks/bulkUploads` now returns `403` (instead of `422`) when an authenticated user lacks permission to create proposals for the application form's opportunity or to reference the source, and `404` (instead of `422`) when the application form, opportunity, or source cannot be viewed or does not exist.
- `POST /permissionGrants` now returns `403` (instead of `401`) when an authenticated user lacks permission to manage permission grants on the specified context entity, and `404` when that context entity cannot be viewed or does not exist.
- `PUT /permissionGrants/:permissionGrantId` now returns `403` (instead of `401`) when an authenticated user lacks permission to manage permission grants on the specified context entity, and `404` when that context entity cannot be viewed or does not exist.
- `POST /applicationForms` now returns `403` (instead of `401`) when an authenticated user lacks permission on the associated opportunity, and `404` (instead of `422`) when that opportunity cannot be viewed or does not exist.
- `PATCH /applicationFormFields/:applicationFormFieldId` now returns `403` (instead of `401`) when an authenticated user lacks edit permission on the field's application form.

## 0.38.0 2026-06-12

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/applicationFormFields.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ describe('/applicationFormFields', () => {
.expect(401);
});

it('returns 401 for user without funder EDIT permission', async () => {
it('returns 403 for a user who can view the field but lacks funder EDIT permission', async () => {
const db = getDatabase();
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser);
Expand Down Expand Up @@ -521,7 +521,7 @@ describe('/applicationFormFields', () => {
.send({
label: 'New Label',
})
.expect(401);
.expect(403);
});

it('successfully updates a field when granted edit at the application form level', async () => {
Expand Down
10 changes: 5 additions & 5 deletions src/__tests__/applicationForms.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ describe('/applicationForms', () => {
.expect(400);
});

it(`returns 401 unauthorized if the user does not have edit permission on the associated opportunity's funder`, async () => {
it(`returns 403 forbidden if the user can view the opportunity but does not have edit permission on the associated opportunity's funder`, async () => {
const db = getDatabase();
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser);
Expand Down Expand Up @@ -936,7 +936,7 @@ describe('/applicationForms', () => {
name: null,
fields: [],
})
.expect(401);
.expect(403);
const after = await loadTableMetrics(db, 'application_forms');
expect(before.count).toEqual(1);
expect(after.count).toEqual(1);
Expand Down Expand Up @@ -1094,7 +1094,7 @@ describe('/applicationForms', () => {
});
});

it('returns 422 conflict when a non-existent opportunity id is provided', async () => {
it('returns 404 not found when a non-existent opportunity id is provided', async () => {
const result = await request(app)
.post('/applicationForms')
.type('application/json')
Expand All @@ -1104,9 +1104,9 @@ describe('/applicationForms', () => {
name: null,
fields: [],
})
.expect(422);
.expect(404);
expect(result.body).toMatchObject({
name: 'UnprocessableEntityError',
name: 'NotFoundError',
details: expectArray(),
});
});
Expand Down
58 changes: 48 additions & 10 deletions src/__tests__/bulkUploadTasks.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
expectNumber,
expectObject,
expectObjectContaining,
expectString,
expectTimestamp,
} from '../test/asymettricMatchers';
import {
Expand Down Expand Up @@ -675,7 +676,7 @@ describe('/tasks/bulkUploads', () => {
expect(after.count).toEqual(1);
});

it('returns 422 unprocessable entity when the user lacks reference permission on the source', async () => {
it('returns 403 forbidden when the user can view the source but lacks reference permission', async () => {
const db = getDatabase();
const systemSource = await loadSystemSource(db, null);
const systemUser = await loadSystemUser(db, null);
Expand All @@ -702,6 +703,14 @@ describe('/tasks/bulkUploads', () => {
scope: [PermissionGrantEntityType.PROPOSAL],
verbs: [PermissionGrantVerb.CREATE],
});
await createPermissionGrant(db, systemUserAuthContext, {
granteeType: PermissionGrantGranteeType.USER,
granteeUserKeycloakUserId: testUser.keycloakUserId,
contextEntityType: PermissionGrantEntityType.SOURCE,
sourceId: systemSource.id,
scope: [PermissionGrantEntityType.SOURCE],
verbs: [PermissionGrantVerb.VIEW],
});
const proposalsDataFile = await createTestFile(db, testUserAuthContext);

const opportunity = await createTestOpportunity(
Expand Down Expand Up @@ -732,15 +741,15 @@ describe('/tasks/bulkUploads', () => {
.type('application/json')
.set(authHeader)
.send(testData)
.expect(422);
.expect(403);
const after = await loadTableMetrics(db, 'bulk_upload_tasks');

expect(before.count).toEqual(0);
expect(result.body).toEqual({
details: [{ name: 'UnprocessableEntityError' }],
details: [{ name: 'ForbiddenError' }],
message:
'You do not have permission to reference the specified source.',
name: 'UnprocessableEntityError',
'Authenticated user does not have permission to reference the specified source.',
name: 'ForbiddenError',
});
expect(after.count).toEqual(0);
});
Expand Down Expand Up @@ -822,7 +831,7 @@ describe('/tasks/bulkUploads', () => {
.expect(201);
});

it('returns 422 unprocessable entity when the user does not have create proposal permission for the associated opportunity', async () => {
it('returns 403 forbidden when the user can view the application form but does not have create proposal permission for the associated opportunity', async () => {
const db = getDatabase();
const systemSource = await loadSystemSource(db, null);
const systemUser = await loadSystemUser(db, null);
Expand Down Expand Up @@ -879,19 +888,48 @@ describe('/tasks/bulkUploads', () => {
.type('application/json')
.set(authHeader)
.send(testData)
.expect(422);
.expect(403);
const after = await loadTableMetrics(db, 'bulk_upload_tasks');

expect(before.count).toEqual(0);
expect(result.body).toEqual({
details: [{ name: 'UnprocessableEntityError' }],
details: [{ name: 'ForbiddenError' }],
message:
'You do not have permission to create proposals for this opportunity.',
name: 'UnprocessableEntityError',
'Authenticated user does not have permission to create proposals for the specified opportunity.',
name: 'ForbiddenError',
});
expect(after.count).toEqual(0);
});

it('returns 404 not found when the application form cannot be viewed or does not exist', async () => {
const db = getDatabase();
const systemSource = await loadSystemSource(db, null);
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser);
const proposalsDataFile = await createTestFile(db, testUserAuthContext);

const testData: WritableBulkUploadTask = {
sourceId: systemSource.id,
applicationFormId: 999999,
proposalsDataFileId: proposalsDataFile.id,
attachmentsArchiveFileId: null,
};
const before = await loadTableMetrics(db, 'bulk_upload_tasks');
const result = await request(app)
.post('/tasks/bulkUploads/')
.type('application/json')
.set(authHeader)
.send(testData)
.expect(404);
const after = await loadTableMetrics(db, 'bulk_upload_tasks');

expect(result.body).toMatchObject({
name: 'NotFoundError',
message: expectString(),
});
expect(after.count).toEqual(before.count);
});

it('returns 400 bad request when no proposalDataFileId is provided', async () => {
const db = getDatabase();
const systemSource = await loadSystemSource(db, null);
Expand Down
22 changes: 16 additions & 6 deletions src/__tests__/changemakerFieldValueBatches.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,21 @@ describe('POST /changemakerFieldValueBatches', () => {
.expect(401);
});

it('Returns 422 when the user lacks reference permission on the source', async () => {
it('Returns 403 when the user can view the source but lacks reference permission', async () => {
const db = getDatabase();
const systemUser = await loadSystemUser(db, null);
const systemUserAuthContext = getAuthContext(systemUser);
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser);
const source = await createTestSource(db, testUserAuthContext);
await createPermissionGrant(db, systemUserAuthContext, {
granteeType: PermissionGrantGranteeType.USER,
granteeUserKeycloakUserId: testUser.keycloakUserId,
contextEntityType: PermissionGrantEntityType.SOURCE,
sourceId: source.id,
scope: [PermissionGrantEntityType.SOURCE],
verbs: [PermissionGrantVerb.VIEW],
});

const result = await request(app)
.post('/changemakerFieldValueBatches')
Expand All @@ -150,10 +160,10 @@ describe('POST /changemakerFieldValueBatches', () => {
sourceId: source.id,
notes: 'Test notes',
})
.expect(422);
.expect(403);

expect(result.body).toMatchObject({
name: 'UnprocessableEntityError',
name: 'ForbiddenError',
});
});

Expand Down Expand Up @@ -192,7 +202,7 @@ describe('POST /changemakerFieldValueBatches', () => {
});
});

it('Returns 422 when source does not exist', async () => {
it('Returns 404 when source does not exist', async () => {
const result = await request(app)
.post('/changemakerFieldValueBatches')
.type('application/json')
Expand All @@ -201,10 +211,10 @@ describe('POST /changemakerFieldValueBatches', () => {
sourceId: 999999,
notes: 'Test notes',
})
.expect(422);
.expect(404);

expect(result.body).toMatchObject({
name: 'UnprocessableEntityError',
name: 'NotFoundError',
});
});
});
Expand Down
33 changes: 17 additions & 16 deletions src/__tests__/changemakerFieldValues.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
expectArrayContaining,
expectNumber,
expectObjectContaining,
expectString,
expectTimestamp,
} from '../test/asymettricMatchers';
import {
Expand Down Expand Up @@ -288,7 +289,7 @@ describe('POST /changemakerFieldValues', () => {
.expect(401);
});

it('Returns 422 when user does not have create permission on changemakerFieldValue', async () => {
it('Returns 403 when user can view the changemaker but lacks create permission on changemakerFieldValue', async () => {
const db = getDatabase();
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser, true);
Expand Down Expand Up @@ -323,12 +324,12 @@ describe('POST /changemakerFieldValues', () => {
value: 'Test value',
goodAsOf: '2024-01-01T00:00:00Z',
})
.expect(422);
.expect(403);

expect(result.body).toMatchObject({
name: 'UnprocessableEntityError',
name: 'ForbiddenError',
message:
'You do not have permission to create field values for this changemaker.',
'Authenticated user does not have permission to create field values for the specified changemaker.',
});
});

Expand Down Expand Up @@ -389,7 +390,7 @@ describe('POST /changemakerFieldValues', () => {
});
});

it('Returns 409 when base field does not exist', async () => {
it('Returns 404 when base field does not exist', async () => {
const db = getDatabase();
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser, true);
Expand Down Expand Up @@ -420,11 +421,11 @@ describe('POST /changemakerFieldValues', () => {
value: 'Test value',
goodAsOf: '2024-01-01T00:00:00Z',
})
.expect(409);
.expect(404);

expect(result.body).toMatchObject({
name: 'InputConflictError',
message: 'The base field does not exist.',
name: 'NotFoundError',
message: expectString(),
});
});

Expand Down Expand Up @@ -513,7 +514,7 @@ describe('POST /changemakerFieldValues', () => {
});
});

it('Returns 409 when batch does not exist', async () => {
it('Returns 404 when batch does not exist', async () => {
const db = getDatabase();
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser, true);
Expand All @@ -533,15 +534,15 @@ describe('POST /changemakerFieldValues', () => {
value: 'Test value',
goodAsOf: '2024-01-01T00:00:00Z',
})
.expect(409);
.expect(404);

expect(result.body).toMatchObject({
name: 'InputConflictError',
message: 'The batch does not exist.',
name: 'NotFoundError',
message: expectString(),
});
});

it('Returns 409 when changemaker does not exist', async () => {
it('Returns 404 when changemaker does not exist', async () => {
const db = getDatabase();
const testUser = await loadTestUser(db);
const testUserAuthContext = getAuthContext(testUser, true);
Expand Down Expand Up @@ -574,11 +575,11 @@ describe('POST /changemakerFieldValues', () => {
value: 'Test value',
goodAsOf: '2024-01-01T00:00:00Z',
})
.expect(409);
.expect(404);

expect(result.body).toMatchObject({
name: 'InputConflictError',
message: 'The changemaker does not exist.',
name: 'NotFoundError',
message: expectString(),
});
});
});
Expand Down
Loading
Loading