diff --git a/src/__tests__/userChangemakerPermissions.int.test.ts b/src/__tests__/userChangemakerPermissions.int.test.ts new file mode 100644 index 00000000..370fd4fc --- /dev/null +++ b/src/__tests__/userChangemakerPermissions.int.test.ts @@ -0,0 +1,126 @@ +import request from 'supertest'; +import { app } from '../app'; +import { + createChangemaker, + createOrUpdateUserChangemakerPermission, +} from '../database'; +import { expectTimestamp, loadTestUser } from '../test/utils'; +import { + mockJwt as authHeader, + mockJwtWithAdminRole as authHeaderWithAdminRole, +} from '../test/mockJwt'; +import { keycloakUserIdToString, Permission } from '../types'; + +describe('/users/changemakers/:changemakerId/permissions/:permission', () => { + describe('PUT /', () => { + it('returns 401 if the request lacks authentication', async () => { + const user = await loadTestUser(); + const changemaker = await createChangemaker({ + taxId: '11-1111111', + name: 'Example Inc.', + }); + await request(app) + .put( + `/users/${keycloakUserIdToString(user.keycloakUserId)}/changemakers/${changemaker.id}/permissions/${Permission.MANAGE}`, + ) + .send({}) + .expect(401); + }); + + it('returns 401 if the authenticated user lacks permission', async () => { + const user = await loadTestUser(); + const changemaker = await createChangemaker({ + taxId: '11-1111111', + name: 'Example Inc.', + }); + await request(app) + .put( + `/users/${keycloakUserIdToString(user.keycloakUserId)}/changemakers/${changemaker.id}/permissions/${Permission.MANAGE}`, + ) + .set(authHeader) + .send({}) + .expect(401); + }); + + it('returns 400 if the userId is not a valid keycloak user ID', async () => { + await request(app) + .put(`/users/notaguid/changemakers/1/permissions/${Permission.MANAGE}`) + .set(authHeaderWithAdminRole) + .send({}) + .expect(400); + }); + + it('returns 400 if the changemaker ID is not a valid ID', async () => { + const user = await loadTestUser(); + await request(app) + .put( + `/users/${keycloakUserIdToString(user.keycloakUserId)}/changemakers/notanId/permissions/${Permission.MANAGE}`, + ) + .set(authHeaderWithAdminRole) + .send({}) + .expect(400); + }); + + it('returns 400 if the permission is not a valid permission', async () => { + const user = await loadTestUser(); + await request(app) + .put( + `/users/${keycloakUserIdToString(user.keycloakUserId)}/changemakers/1/permissions/notAPermission`, + ) + .set(authHeaderWithAdminRole) + .send({}) + .expect(400); + }); + + it('creates and returns the new user changemaker permission when user has administrative credentials', async () => { + const user = await loadTestUser(); + const changemaker = await createChangemaker({ + taxId: '11-1111111', + name: 'Example Inc.', + }); + + const response = await request(app) + .put( + `/users/${keycloakUserIdToString(user.keycloakUserId)}/changemakers/${changemaker.id}/permissions/${Permission.EDIT}`, + ) + .set(authHeaderWithAdminRole) + .send({}) + .expect(201); + expect(response.body).toEqual({ + changemakerId: changemaker.id, + createdAt: expectTimestamp, + createdBy: user.keycloakUserId, + permission: Permission.EDIT, + userKeycloakUserId: user.keycloakUserId, + }); + }); + + it('creates and returns the new user changemaker permission when user has permission to manage the changemaker', async () => { + const user = await loadTestUser(); + const changemaker = await createChangemaker({ + taxId: '11-1111111', + name: 'Example Inc.', + }); + await createOrUpdateUserChangemakerPermission({ + userKeycloakUserId: user.keycloakUserId, + changemakerId: changemaker.id, + permission: Permission.MANAGE, + createdBy: user.keycloakUserId, + }); + const response = await request(app) + .put( + `/users/${keycloakUserIdToString(user.keycloakUserId)}/changemakers/${changemaker.id}/permissions/${Permission.EDIT}`, + ) + .set(authHeader) + .send({}) + .expect(201); + expect(response.body).toEqual({ + changemakerId: changemaker.id, + createdAt: expectTimestamp, + createdBy: user.keycloakUserId, + permission: Permission.EDIT, + userKeycloakUserId: user.keycloakUserId, + }); + }); + }); +}); diff --git a/src/handlers/userChangemakerPermissionsHandlers.ts b/src/handlers/userChangemakerPermissionsHandlers.ts new file mode 100644 index 00000000..ca595cef --- /dev/null +++ b/src/handlers/userChangemakerPermissionsHandlers.ts @@ -0,0 +1,92 @@ +import { createOrUpdateUserChangemakerPermission } from '../database'; +import { + isAuthContext, + isId, + isKeycloakUserId, + isPermission, + isTinyPgErrorWithQueryContext, + isWritableUserChangemakerPermission, +} from '../types'; +import { + DatabaseError, + FailedMiddlewareError, + InputValidationError, +} from '../errors'; +import type { Request, Response, NextFunction } from 'express'; + +const putUserChangemakerPermission = ( + req: Request, + res: Response, + next: NextFunction, +): void => { + if (!isAuthContext(req)) { + next(new FailedMiddlewareError('Unexpected lack of auth context.')); + return; + } + + const { userKeycloakUserId, changemakerId, permission } = req.params; + const createdBy = req.user.keycloakUserId; + + if (!isKeycloakUserId(userKeycloakUserId)) { + next( + new InputValidationError( + 'Invalid userKeycloakUserId parameter.', + isKeycloakUserId.errors ?? [], + ), + ); + return; + } + if (!isId(changemakerId)) { + next( + new InputValidationError( + 'Invalid changemakerId parameter.', + isId.errors ?? [], + ), + ); + return; + } + if (!isPermission(permission)) { + next( + new InputValidationError( + 'Invalid permission parameter.', + isPermission.errors ?? [], + ), + ); + return; + } + if (!isWritableUserChangemakerPermission(req.body)) { + next( + new InputValidationError( + 'Invalid request body.', + isWritableUserChangemakerPermission.errors ?? [], + ), + ); + return; + } + + (async () => { + const userChangemakerPermission = + await createOrUpdateUserChangemakerPermission({ + userKeycloakUserId, + changemakerId, + permission, + createdBy, + }); + res + .status(201) + .contentType('application/json') + .send(userChangemakerPermission); + })().catch((error: unknown) => { + if (isTinyPgErrorWithQueryContext(error)) { + next(new DatabaseError('Error creating item.', error)); + return; + } + next(error); + }); +}; + +const userChangemakerPermissionsHandlers = { + putUserChangemakerPermission, +}; + +export { userChangemakerPermissionsHandlers }; diff --git a/src/middleware/index.ts b/src/middleware/index.ts index a6af5525..4f2fbca9 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -4,3 +4,4 @@ export * from './errorHandler'; export * from './processJwt'; export * from './requireAdministratorRole'; export * from './requireAuthentication'; +export * from './requireChangemakerPermission'; diff --git a/src/middleware/requireChangemakerPermission.ts b/src/middleware/requireChangemakerPermission.ts new file mode 100644 index 00000000..28749290 --- /dev/null +++ b/src/middleware/requireChangemakerPermission.ts @@ -0,0 +1,38 @@ +import { Response, NextFunction } from 'express'; +import { InputValidationError, UnauthorizedError } from '../errors'; +import { isAuthContext, isId } from '../types'; +import type { Permission } from '../types'; +import type { Request } from 'express'; + +const requireChangemakerPermission = + (permission: Permission) => + (req: Request, res: Response, next: NextFunction) => { + if (!isAuthContext(req)) { + next(new UnauthorizedError('The request lacks an AuthContext.')); + return; + } + if (req.role?.isAdministrator === true) { + next(); + return; + } + const { changemakerId } = req.params; + if (!isId(changemakerId)) { + next( + new InputValidationError('Invalid changemakerId.', isId.errors ?? []), + ); + return; + } + const { user } = req; + const permissions = user.permissions.changemaker[changemakerId] ?? []; + if (!permissions.includes(permission)) { + next( + new UnauthorizedError( + 'Authenticated user does not have permission to perform this action.', + ), + ); + return; + } + next(); + }; + +export { requireChangemakerPermission }; diff --git a/src/routers/usersRouter.ts b/src/routers/usersRouter.ts index 742601f0..1a69aa2c 100644 --- a/src/routers/usersRouter.ts +++ b/src/routers/usersRouter.ts @@ -1,9 +1,19 @@ import express from 'express'; +import { userChangemakerPermissionsHandlers } from '../handlers/userChangemakerPermissionsHandlers'; import { usersHandlers } from '../handlers/usersHandlers'; -import { requireAuthentication } from '../middleware'; +import { + requireAuthentication, + requireChangemakerPermission, +} from '../middleware'; +import { Permission } from '../types'; const usersRouter = express.Router(); usersRouter.get('/', requireAuthentication, usersHandlers.getUsers); +usersRouter.put( + '/:userKeycloakUserId/changemakers/:changemakerId/permissions/:permission', + requireChangemakerPermission(Permission.MANAGE), + userChangemakerPermissionsHandlers.putUserChangemakerPermission, +); export { usersRouter };