diff --git a/application/backend/prisma/schema.prisma b/application/backend/prisma/schema.prisma index f59e6e144..2118e797f 100644 --- a/application/backend/prisma/schema.prisma +++ b/application/backend/prisma/schema.prisma @@ -1,6 +1,7 @@ generator client { provider = "prisma-client-js" -} + previewFeatures = ["omitApi"] +} generator json { provider = "prisma-json-types-generator" diff --git a/application/backend/src/controllers/UsersController.test.ts b/application/backend/src/controllers/UsersController.test.ts index 60822eb58..6f4fb7fd7 100644 --- a/application/backend/src/controllers/UsersController.test.ts +++ b/application/backend/src/controllers/UsersController.test.ts @@ -76,6 +76,28 @@ describe('UsersController', () => { const body: GetAllUsersResponse = response.body expect(body.data).toBe(undefined) }) + + it('should not return user password', async () => { + const response = await request(app) + .get('/users') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetAllUsersResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0]).not.toHaveProperty('password') + }) + + it('should not return user emailHash', async () => { + const response = await request(app) + .get('/users') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetAllUsersResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0]).not.toHaveProperty('emailHash') + }) }) describe('GET /users/admin', () => { @@ -91,6 +113,28 @@ describe('UsersController', () => { expect(user.role).not.toEqual('Participant') }) }) + + it('should not return admin password', async () => { + const response = await request(app) + .get('/users') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetAllUsersResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0]).not.toHaveProperty('password') + }) + + it('should not return admin emailHash', async () => { + const response = await request(app) + .get('/users') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetAllUsersResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0]).not.toHaveProperty('emailHash') + }) }) describe('GET /users/:id', () => { @@ -118,6 +162,28 @@ describe('UsersController', () => { expect(response.body.message).toBe(`User with ID: ${userId} not found`) }) + + it('should not return user password', async () => { + const response = await request(app) + .get('/users') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetAllUsersResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0]).not.toHaveProperty('password') + }) + + it('should not return user emailHash', async () => { + const response = await request(app) + .get('/users') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(response.status).toBe(200) + + const body: GetAllUsersResponse = response.body + expect(body).toHaveProperty('data') + expect(body.data[0]).not.toHaveProperty('emailHash') + }) }) describe('POST /users', () => { @@ -583,6 +649,44 @@ describe('UsersController', () => { expect(res2.body.data).toHaveLength(1) expect(res2.body.data[0].id).toBe(OPERATOR_ADMIN_ID) }) + + it('should not return deleted admin password', async () => { + const res1 = await request(app) + .get('/users/admin/deleted') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(res1.ok).toBe(true) + expect(res1.body.data).toHaveLength(0) + await prisma.user.delete({ + where: { + id: OPERATOR_ADMIN_ID, + }, + }) + const res2 = await request(app) + .get('/users/admin/deleted') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(res2.ok).toBe(true) + expect(res2.body.data).toHaveLength(1) + expect(res2.body.data[0]).not.toHaveProperty('password') + }) + + it('should not return deleted admin emailHash', async () => { + const res1 = await request(app) + .get('/users/admin/deleted') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(res1.ok).toBe(true) + expect(res1.body.data).toHaveLength(0) + await prisma.user.delete({ + where: { + id: OPERATOR_ADMIN_ID, + }, + }) + const res2 = await request(app) + .get('/users/admin/deleted') + .set({ Authorization: `Bearer ${orgAdminToken}` }) + expect(res2.ok).toBe(true) + expect(res2.body.data).toHaveLength(1) + expect(res2.body.data[0]).not.toHaveProperty('emailHash') + }) }) describe('PATCH /users/{userId}/restore', () => { diff --git a/application/backend/src/controllers/UsersController.ts b/application/backend/src/controllers/UsersController.ts index 4c1af5456..733e55987 100644 --- a/application/backend/src/controllers/UsersController.ts +++ b/application/backend/src/controllers/UsersController.ts @@ -19,6 +19,7 @@ import { } from 'tsoa' import logger from 'common/src/logger' import type { + UserResponse, GetUserByIdResponse, GetAllUsersResponse, CreateUserRequest, @@ -28,7 +29,6 @@ import type { GeneratePasswordResetLinkRequest, ResetPasswordRequest, } from 'common/types/api/users' -import { User } from '@prisma/client' import prisma from '../PrismaClient' import { InternalErrorResponse, @@ -72,8 +72,10 @@ export class UsersController extends Controller { @Security('jwt', ['OrganisationAdmin']) @Response('401', 'Unauthorized') public async getAllUsers(): Promise { - const users: User[] = await this.userRepo.findMany({}) - const responseData = { data: users } + const users: UserResponse[] = await this.userRepo.findMany({ + omit: { password: true, emailHash: true }, + }) + const responseData: GetAllUsersResponse = { data: users } logger.info({ ...responseData }) return responseData } @@ -87,10 +89,11 @@ export class UsersController extends Controller { @Security('jwt', ['OrganisationAdmin', 'StudyAdmin']) @Response('401', 'Unauthorized') public async getAllAdminUsers(): Promise { - const users: User[] = await this.userRepo.findMany({ + const users: UserResponse[] = await this.userRepo.findMany({ where: { role: { in: ['OperatorAdmin', 'OrganisationAdmin', 'StudyAdmin'] } }, include: { adminOfStudies: { select: { id: true, name: true } } }, orderBy: { id: 'asc' }, + omit: { password: true, emailHash: true }, }) const responseData = { data: users } logger.info({ ...responseData }) @@ -114,8 +117,9 @@ export class UsersController extends Controller { ) { return { data: [] } } - const users: User[] = await this.userRepo.findMany({ + const users: UserResponse[] = await this.userRepo.findMany({ where: { role: { in: ['OperatorAdmin', 'OrganisationAdmin', 'StudyAdmin'] }, deleted: true }, + omit: { password: true, emailHash: true }, }) const responseData = { data: users } return responseData @@ -144,7 +148,7 @@ export class UsersController extends Controller { @Response('404', 'Not Found') @Response('401', 'Unauthorized') public async getUserById(@Path() userId: number): Promise { - const user: User | null = await this.userRepo.findUnique({ + const user: UserResponse | null = await this.userRepo.findUnique({ where: { id: userId }, include: { adminOfStudies: { select: { name: true, id: true } } }, }) diff --git a/application/backend/src/routes.ts b/application/backend/src/routes.ts index 0105ba233..9ad2428d0 100644 --- a/application/backend/src/routes.ts +++ b/application/backend/src/routes.ts @@ -94,20 +94,25 @@ const models: TsoaRoute.Models = { "type": {"dataType":"union","subSchemas":[{"dataType":"enum","enums":["OperatorAdmin"]},{"dataType":"enum","enums":["Participant"]},{"dataType":"enum","enums":["OrganisationAdmin"]},{"dataType":"enum","enums":["StudyAdmin"]}],"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "DefaultSelection_Prisma._36_UserPayload_": { + "Pick_User.Exclude_keyofUser.password-or-emailHash__": { "dataType": "refAlias", - "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"deleted":{"dataType":"boolean","required":true},"retriesRemaining":{"dataType":"double","required":true},"lockedUntil":{"dataType":"union","subSchemas":[{"dataType":"datetime"},{"dataType":"enum","enums":[null]}],"required":true},"agreedTermsAt":{"dataType":"union","subSchemas":[{"dataType":"datetime"},{"dataType":"enum","enums":[null]}],"required":true},"updatedAt":{"dataType":"datetime","required":true},"createdAt":{"dataType":"datetime","required":true},"role":{"ref":"_36_Enums.Role","required":true},"password":{"dataType":"string","required":true},"emailHash":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"email":{"dataType":"string","required":true},"lastName":{"dataType":"string","required":true},"middleName":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"firstName":{"dataType":"string","required":true},"id":{"dataType":"double","required":true}},"validators":{}}, + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"id":{"dataType":"double","required":true},"firstName":{"dataType":"string","required":true},"middleName":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"lastName":{"dataType":"string","required":true},"email":{"dataType":"string","required":true},"role":{"ref":"_36_Enums.Role","required":true},"createdAt":{"dataType":"datetime","required":true},"updatedAt":{"dataType":"datetime","required":true},"agreedTermsAt":{"dataType":"union","subSchemas":[{"dataType":"datetime"},{"dataType":"enum","enums":[null]}],"required":true},"lockedUntil":{"dataType":"union","subSchemas":[{"dataType":"datetime"},{"dataType":"enum","enums":[null]}],"required":true},"retriesRemaining":{"dataType":"double","required":true},"deleted":{"dataType":"boolean","required":true}},"validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - "User": { + "Omit_User.password-or-emailHash_": { "dataType": "refAlias", - "type": {"ref":"DefaultSelection_Prisma._36_UserPayload_","validators":{}}, + "type": {"ref":"Pick_User.Exclude_keyofUser.password-or-emailHash__","validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "UserResponse": { + "dataType": "refAlias", + "type": {"ref":"Omit_User.password-or-emailHash_","validators":{}}, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetAllUsersResponse": { "dataType": "refObject", "properties": { - "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"User"},"required":true}, + "data": {"dataType":"array","array":{"dataType":"refAlias","ref":"UserResponse"},"required":true}, }, "additionalProperties": false, }, @@ -124,7 +129,7 @@ const models: TsoaRoute.Models = { "GetUserByIdResponse": { "dataType": "refObject", "properties": { - "data": {"ref":"User","required":true}, + "data": {"ref":"UserResponse","required":true}, }, "additionalProperties": false, }, @@ -762,6 +767,16 @@ const models: TsoaRoute.Models = { "additionalProperties": false, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "DefaultSelection_Prisma._36_UserPayload_": { + "dataType": "refAlias", + "type": {"dataType":"nestedObjectLiteral","nestedProperties":{"deleted":{"dataType":"boolean","required":true},"retriesRemaining":{"dataType":"double","required":true},"lockedUntil":{"dataType":"union","subSchemas":[{"dataType":"datetime"},{"dataType":"enum","enums":[null]}],"required":true},"agreedTermsAt":{"dataType":"union","subSchemas":[{"dataType":"datetime"},{"dataType":"enum","enums":[null]}],"required":true},"updatedAt":{"dataType":"datetime","required":true},"createdAt":{"dataType":"datetime","required":true},"role":{"ref":"_36_Enums.Role","required":true},"password":{"dataType":"string","required":true},"emailHash":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"email":{"dataType":"string","required":true},"lastName":{"dataType":"string","required":true},"middleName":{"dataType":"union","subSchemas":[{"dataType":"string"},{"dataType":"enum","enums":[null]}],"required":true},"firstName":{"dataType":"string","required":true},"id":{"dataType":"double","required":true}},"validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa + "User": { + "dataType": "refAlias", + "type": {"ref":"DefaultSelection_Prisma._36_UserPayload_","validators":{}}, + }, + // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa "GetOrganisationUsersResponse": { "dataType": "refObject", "properties": { diff --git a/application/backend/swagger.json b/application/backend/swagger.json index 3b1cebc51..efb867772 100644 --- a/application/backend/swagger.json +++ b/application/backend/swagger.json @@ -120,85 +120,83 @@ "type": "object", "additionalProperties": false }, - "User": { - "description": "Model User", + "Pick_User.Exclude_keyofUser.password-or-emailHash__": { "properties": { - "deleted": { - "type": "boolean" - }, - "retriesRemaining": { + "id": { "type": "number", "format": "double" }, - "lockedUntil": { - "type": "string", - "format": "date-time", - "nullable": true + "firstName": { + "type": "string" }, - "agreedTermsAt": { + "middleName": { "type": "string", - "format": "date-time", "nullable": true }, - "updatedAt": { - "type": "string", - "format": "date-time" + "lastName": { + "type": "string" }, - "createdAt": { - "type": "string", - "format": "date-time" + "email": { + "type": "string" }, "role": { "$ref": "#/components/schemas/_36_Enums.Role" }, - "password": { - "type": "string" - }, - "emailHash": { + "createdAt": { "type": "string", - "nullable": true - }, - "email": { - "type": "string" + "format": "date-time" }, - "lastName": { - "type": "string" + "updatedAt": { + "type": "string", + "format": "date-time" }, - "middleName": { + "agreedTermsAt": { "type": "string", + "format": "date-time", "nullable": true }, - "firstName": { - "type": "string" + "lockedUntil": { + "type": "string", + "format": "date-time", + "nullable": true }, - "id": { + "retriesRemaining": { "type": "number", "format": "double" + }, + "deleted": { + "type": "boolean" } }, "required": [ - "deleted", - "retriesRemaining", - "lockedUntil", - "agreedTermsAt", - "updatedAt", - "createdAt", - "role", - "password", - "emailHash", - "email", - "lastName", - "middleName", + "id", "firstName", - "id" + "middleName", + "lastName", + "email", + "role", + "createdAt", + "updatedAt", + "agreedTermsAt", + "lockedUntil", + "retriesRemaining", + "deleted" ], - "type": "object" + "type": "object", + "description": "From T, pick a set of properties whose keys are in the union K" + }, + "Omit_User.password-or-emailHash_": { + "$ref": "#/components/schemas/Pick_User.Exclude_keyofUser.password-or-emailHash__", + "description": "Construct a type with the properties of T except for those in type K." + }, + "UserResponse": { + "$ref": "#/components/schemas/Omit_User.password-or-emailHash_" }, "GetAllUsersResponse": { "properties": { "data": { "items": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/UserResponse" }, "type": "array" } @@ -231,7 +229,7 @@ "GetUserByIdResponse": { "properties": { "data": { - "$ref": "#/components/schemas/User" + "$ref": "#/components/schemas/UserResponse" } }, "required": [ @@ -2234,6 +2232,80 @@ "type": "object", "additionalProperties": false }, + "User": { + "description": "Model User", + "properties": { + "deleted": { + "type": "boolean" + }, + "retriesRemaining": { + "type": "number", + "format": "double" + }, + "lockedUntil": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "agreedTermsAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "role": { + "$ref": "#/components/schemas/_36_Enums.Role" + }, + "password": { + "type": "string" + }, + "emailHash": { + "type": "string", + "nullable": true + }, + "email": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "middleName": { + "type": "string", + "nullable": true + }, + "firstName": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "deleted", + "retriesRemaining", + "lockedUntil", + "agreedTermsAt", + "updatedAt", + "createdAt", + "role", + "password", + "emailHash", + "email", + "lastName", + "middleName", + "firstName", + "id" + ], + "type": "object" + }, "GetOrganisationUsersResponse": { "properties": { "data": { diff --git a/application/common/types/api/users/getAllUsers.ts b/application/common/types/api/users/getAllUsers.ts index f242afa57..8bee5d3af 100644 --- a/application/common/types/api/users/getAllUsers.ts +++ b/application/common/types/api/users/getAllUsers.ts @@ -1,5 +1,7 @@ import { User } from '@prisma/client' +export type UserResponse = Omit + export interface GetAllUsersResponse { - data: User[] + data: UserResponse[] } diff --git a/application/common/types/api/users/getUserById.ts b/application/common/types/api/users/getUserById.ts index 702547a90..4f0a115fd 100644 --- a/application/common/types/api/users/getUserById.ts +++ b/application/common/types/api/users/getUserById.ts @@ -1,5 +1,5 @@ -import { User } from '@prisma/client' +import { UserResponse } from './getAllUsers' export interface GetUserByIdResponse { - data: User + data: UserResponse } diff --git a/application/common/types/api/users/index.ts b/application/common/types/api/users/index.ts index 7d6e9c57f..42ac32b4e 100644 --- a/application/common/types/api/users/index.ts +++ b/application/common/types/api/users/index.ts @@ -1,6 +1,6 @@ import type { GetUserByIdResponse } from './getUserById' import type { GetParticipantProfileResponse, FamilyMember } from './getParticipantProfile' -import type { GetAllUsersResponse } from './getAllUsers' +import type { UserResponse, GetAllUsersResponse } from './getAllUsers' import type { CreateUserRequest, CreateUserResponse } from './createUser' import type { UpdateUserRequest } from './updateUser' import type { UpdateProfileRequest } from './updateProfile' @@ -8,6 +8,7 @@ import type { UpdateUserRoleRequest } from './updateUserRole' import type { GeneratePasswordResetLinkRequest, ResetPasswordRequest } from './passwordReset' export { + UserResponse, GetUserByIdResponse, GetParticipantProfileResponse, FamilyMember,