diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5aec32..9c5e36b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 20.17.0 - run: | cd app yarn diff --git a/.tool-versions b/.tool-versions index e37237c..e763734 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,5 +1,5 @@ -nodejs 18.18.2 -yarn 1.22.4 +nodejs 20.17.0 +yarn 1.22.22 python 3.11.0 postgres 14.1 helm 3.10.2 diff --git a/app/.eslintrc.json b/app/.eslintrc.json index bffb357..957cd15 100644 --- a/app/.eslintrc.json +++ b/app/.eslintrc.json @@ -1,3 +1,3 @@ { - "extends": "next/core-web-vitals" + "extends": ["next/core-web-vitals"] } diff --git a/app/Dockerfile b/app/Dockerfile index 488ac67..85c0838 100644 --- a/app/Dockerfile +++ b/app/Dockerfile @@ -1,10 +1,10 @@ -FROM node:18.18.2-alpine as builder +FROM node:20.17.0-alpine as builder WORKDIR /opt/app COPY . . RUN yarn install --frozen-lockfile RUN yarn build -FROM node:18.18.2-alpine as runner +FROM node:20.17.0-alpine as runner WORKDIR /opt/app ENV NODE_ENV production diff --git a/app/__tests__/api/delete-realm.test.ts b/app/__tests__/api/delete-realm.test.ts index 8751305..6fe00d3 100644 --- a/app/__tests__/api/delete-realm.test.ts +++ b/app/__tests__/api/delete-realm.test.ts @@ -1,33 +1,29 @@ import { createMocks } from 'node-mocks-http'; import deleteHandler from '../../pages/api/realms/[id]'; -import githubResponseHandler from '../../pages/api/realms/pending'; import prisma from 'utils/prisma'; -import { CustomRealmProfiles } from '../fixtures'; -import { getServerSession } from 'next-auth'; -import { createCustomRealmPullRequest, mergePullRequest } from 'utils/github'; -import { EventEnum, StatusEnum } from 'validators/create-realm'; -import { removeUserAsRealmAdmin } from 'controllers/keycloak'; -import { sendDeletionCompleteEmail } from 'utils/mailer'; - -jest.mock('../../controllers/keycloak', () => { +import { CustomRealmProfiles, MockHttpRequest } from '../fixtures'; +import { manageCustomRealm, removeUserAsRealmAdmin } from 'controllers/keycloak'; +import { ssoTeamEmail } from 'utils/mailer'; +import { createMockSendEmail, mockAdminSession, mockSession } from './utils/mocks'; +import { createEvent } from 'utils/helpers'; +import { EventEnum } from 'validators/create-realm'; + +jest.mock('../../utils/helpers', () => { return { - removeUserAsRealmAdmin: jest.fn(), + ...jest.requireActual('../../utils/helpers'), + createEvent: jest.fn(), }; }); -jest.mock('../../utils/mailer', () => { - return { - sendUpdateEmail: jest.fn(), - sendDeleteEmail: jest.fn(), - sendDeletionCompleteEmail: jest.fn(() => Promise.resolve(true)), - }; -}); +jest.mock('utils/ches'); -jest.mock('../../utils/github', () => { +jest.mock('../../controllers/keycloak', () => { return { - createCustomRealmPullRequest: jest.fn(() => Promise.resolve({ data: { number: 1 } })), - mergePullRequest: jest.fn(() => Promise.resolve({ data: { merged: true } })), - deleteBranch: jest.fn(), + removeUserAsRealmAdmin: jest.fn(() => true), + createCustomRealm: jest.fn(() => true), + disableCustomRealm: jest.fn(() => true), + deleteCustomRealm: jest.fn(() => true), + manageCustomRealm: jest.fn(() => true), }; }); @@ -37,16 +33,6 @@ jest.mock('next/config', () => () => ({ }, })); -const mockSession = { - expires: new Date(Date.now() + 2 * 86400).toISOString(), - user: { - username: 'test', - family_name: 'test', - idir_username: 'test', - }, - status: 'authenticated', -}; - jest.mock('next-auth/next', () => { return { __esModule: true, @@ -61,19 +47,7 @@ jest.mock('../../pages/api/auth/[...nextauth]', () => { }; }); -const mockAdminSession = () => { - (getServerSession as jest.Mock).mockImplementation(() => { - return { - ...mockSession, - user: { - ...mockSession.user, - client_roles: ['sso-admin'], - }, - }; - }); -}; - -describe('Realm Delete Request', () => { +describe('Delete Realm Permissions', () => { beforeEach(() => { jest.clearAllMocks(); // Mock prisma find/update functions @@ -89,123 +63,79 @@ describe('Realm Delete Request', () => { }); it('Only allows admins to delete realms', async () => { - const { req, res } = createMocks({ + const { req, res }: MockHttpRequest = createMocks({ method: 'DELETE', query: { id: 1 }, }); await deleteHandler(req, res); expect(res.statusCode).toBe(401); - expect(createCustomRealmPullRequest).not.toHaveBeenCalled(); - expect(mergePullRequest).not.toHaveBeenCalled(); - mockAdminSession(); await deleteHandler(req, res); expect(res.statusCode).toBe(200); - expect(createCustomRealmPullRequest).toHaveBeenCalled(); - expect(mergePullRequest).toHaveBeenCalled(); - }); - - it('Updates the status, archived, and prNumber when deleted successfully', async () => { - const { req, res } = createMocks({ - method: 'DELETE', - query: { id: 1 }, - }); - mockAdminSession(); - await deleteHandler(req, res); - expect(res.statusCode).toBe(200); - - const rosterUpdateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0]; - expect(rosterUpdateArgs.data.archived).toBe(true); - expect(rosterUpdateArgs.data.prNumber).toBe(1); - expect(rosterUpdateArgs.data.status).toBe(StatusEnum.PRSUCCESS); - }); - - it('Updates the status to failed if the pr fails or merge fails and logs an event', async () => { - // PR Creation failure - const failureEvent = { - data: { - realmId: 1, - eventCode: EventEnum.REQUEST_DELETE_FAILED, - idirUserId: 'test', - }, - }; - const { req, res } = createMocks({ - method: 'DELETE', - query: { id: 1 }, - }); - (createCustomRealmPullRequest as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('Failed'))); - mockAdminSession(); - await deleteHandler(req, res); - expect(res.statusCode).toBe(500); - + expect(manageCustomRealm).toHaveBeenCalledTimes(1); let rosterUpdateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0]; - expect(rosterUpdateArgs.data.status).toBe(StatusEnum.PRFAILED); - expect(prisma.event.create).toHaveBeenCalledWith(failureEvent); - - // PR merge failure - (mergePullRequest as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('Failed'))); - await deleteHandler(req, res); - - expect(res.statusCode).toBe(500); - rosterUpdateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0]; - expect(rosterUpdateArgs.data.status).toBe(StatusEnum.PRFAILED); - expect(prisma.event.create).toHaveBeenCalledWith(failureEvent); + expect(rosterUpdateArgs.data.archived).toBe(true); + expect(rosterUpdateArgs.data.status).toBe('applied'); }); }); -describe('Github Actions Delete', () => { - const mockToken = 'secret'; +describe('Delete Realms', () => { beforeEach(() => { jest.clearAllMocks(); (prisma.roster.findUnique as jest.Mock).mockImplementation(() => { return Promise.resolve({ ...CustomRealmProfiles[0], id: 1, archived: true }); }); }); - const requestData = { - method: 'PUT' as 'PUT', - body: { - ids: [1], - action: 'tf_apply', - success: 'true', - }, - headers: { - Authorization: mockToken, - }, - }; - it('requires api token', async () => { - let { req, res } = createMocks(requestData); - await githubResponseHandler(req, res); - expect(res.statusCode).toBe(200); - - // Remove auth header - ({ req, res } = createMocks({ ...requestData, headers: { Authorization: 'empty' } })); - await githubResponseHandler(req, res); - expect(res.statusCode).toBe(401); - }); + it('successfully deletes realm in all environments and sends email', async () => { + mockAdminSession(); + const { req, res }: MockHttpRequest = createMocks({ + method: 'DELETE', + query: { id: 1 }, + }); - it('Removes technical contact and product owner from all envirionments', async () => { - const { req, res } = createMocks(requestData); - await githubResponseHandler(req, res); - expect(res.statusCode).toBe(200); + const emailList = createMockSendEmail(); - // Email only sent once - expect(sendDeletionCompleteEmail).toHaveBeenCalledTimes(1); + await deleteHandler(req, res); + expect(res.statusCode).toBe(200); + expect(manageCustomRealm).toHaveBeenCalledTimes(1); // PO email and technical contact email removed in each realm - ['dev', 'test', 'prod'].forEach((env) => { - expect(removeUserAsRealmAdmin).toHaveBeenCalledWith(['a@b.com', 'b@c.com'], env, 'realm 1'); + CustomRealmProfiles[0].environments.forEach((env) => { + expect(removeUserAsRealmAdmin).toHaveBeenCalledWith( + [CustomRealmProfiles[0].productOwnerEmail, CustomRealmProfiles[0].technicalContactEmail], + env, + CustomRealmProfiles[0].realm, + ); }); - // No extra calls expect(removeUserAsRealmAdmin).toHaveBeenCalledTimes(3); + expect(emailList.length).toBe(1); + expect(emailList[0].subject).toContain( + `Notification: Custom Realm ${CustomRealmProfiles[0].realm} has now been Deleted.`, + ); + expect(emailList[0].to).toEqual( + expect.arrayContaining([CustomRealmProfiles[0].productOwnerEmail, CustomRealmProfiles[0].technicalContactEmail]), + ); + expect(emailList[0].cc).toEqual(expect.arrayContaining([ssoTeamEmail])); }); - it('Only sends deletion complete email if all users removed successfully', async () => { - const { req, res } = createMocks(requestData); - (removeUserAsRealmAdmin as jest.Mock).mockImplementationOnce(() => Promise.reject(new Error('failure'))); - await githubResponseHandler(req, res); + it('does not send email if deleting realm in all environments fails', async () => { + mockAdminSession(); + + (manageCustomRealm as jest.Mock).mockImplementationOnce(() => Promise.reject('some error')); - expect(res.statusCode).toBe(500); - expect(sendDeletionCompleteEmail).not.toHaveBeenCalled(); + const { req, res }: MockHttpRequest = createMocks({ + method: 'DELETE', + query: { id: 1 }, + }); + + const emailList = createMockSendEmail(); + + await deleteHandler(req, res); + const createEventArgs = (createEvent as jest.Mock).mock.calls[0][0]; + expect(createEventArgs.eventCode).toBe(EventEnum.REQUEST_DELETE_FAILED); + expect(res.statusCode).toBe(422); + expect(manageCustomRealm).toHaveBeenCalledTimes(1); + expect(emailList.length).toBe(0); }); }); diff --git a/app/__tests__/api/restore-realm.test.ts b/app/__tests__/api/restore-realm.test.ts new file mode 100644 index 0000000..aecfa79 --- /dev/null +++ b/app/__tests__/api/restore-realm.test.ts @@ -0,0 +1,191 @@ +import { createMocks } from 'node-mocks-http'; +import handler from '../../pages/api/realms/[id]/restore'; +import prisma from 'utils/prisma'; +import { createEvent } from 'utils/helpers'; +import { EventEnum, StatusEnum } from 'validators/create-realm'; +import { ssoTeamEmail } from 'utils/mailer'; +import { manageCustomRealm } from 'controllers/keycloak'; +import { createMockSendEmail, mockAdminSession, mockSession, mockUserSession } from './utils/mocks'; +import { MockHttpRequest } from '__tests__/fixtures'; + +jest.mock('utils/ches'); + +jest.mock('../../utils/helpers', () => { + return { + ...jest.requireActual('../../utils/helpers'), + createEvent: jest.fn(), + }; +}); + +jest.mock('../../controllers/keycloak', () => { + return { + removeUserAsRealmAdmin: jest.fn(), + createCustomRealm: jest.fn(() => true), + manageCustomRealm: jest.fn(() => true), + deleteCustomRealm: jest.fn(() => true), + }; +}); + +jest.mock('../../utils/idir', () => { + return { + generateXML: jest.fn(), + makeSoapRequest: jest.fn(() => Promise.resolve({ response: null })), + getBceidAccounts: jest.fn(() => Promise.resolve([])), + }; +}); + +const ADMIN_FIRST_NAME = 'admin_firstname'; +const ADMIN_LAST_NAME = 'admin_firstname'; + +jest.mock('next-auth/next', () => { + return { + __esModule: true, + getServerSession: jest.fn(() => { + return mockSession; + }), + }; +}); + +jest.mock('../../pages/api/auth/[...nextauth]', () => { + return { + __esModule: true, + authOptions: {}, + }; +}); + +const PO_EMAIL = 'po@mail.com'; +const TECHNICAL_CONTACT_EMAIL = 'tc@mail.com'; +const realm = { + id: 2, + realm: 'realm', + productName: 'name', + purpose: 'purpose', + primaryEndUsers: ['livingInBC', 'businessInBC', 'govEmployees', 'details'], + environments: ['dev', 'test', 'prod'], + preferredAdminLoginMethod: 'idir', + productOwnerEmail: PO_EMAIL, + productOwnerIdirUserId: 'po', + technicalContactEmail: TECHNICAL_CONTACT_EMAIL, + technicalContactIdirUserId: 'd@e.com', + secondTechnicalContactIdirUserId: 'dmsd', + secondTechnicalContactEmail: 'a@b.com', + ministry: 'ministry', + branch: 'branch', + division: 'division', + approved: null, + status: 'applied', + archived: true, +}; + +describe('Restore Realm', () => { + beforeEach(() => { + jest.clearAllMocks(); + (prisma.roster.findUnique as jest.Mock).mockImplementation(() => Promise.resolve(realm)); + (prisma.roster.update as jest.Mock).mockImplementation(jest.fn()); + }); + + it('Only allows sso-admins to restore realms', async () => { + mockUserSession(); + const { req, res }: MockHttpRequest = createMocks({ method: 'POST' }); + + await handler(req, res); + + expect(res.statusCode).toBe(403); + + mockAdminSession(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + }); + + it('Only allows archived realms that are applied to be restored', async () => { + mockAdminSession(); + + const validStatuses = [StatusEnum.APPLIED]; + const invalidStatuses = [StatusEnum.APPLYFAILED, StatusEnum.PENDING]; + + const { req, res }: MockHttpRequest = createMocks({ method: 'POST' }); + + validStatuses.forEach(async (status) => { + (prisma.roster.findUnique as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ ...realm, archived: true, status }), + ); + await handler(req, res); + expect(res.statusCode).toBe(200); + }); + + validStatuses.forEach(async (status) => { + (prisma.roster.findUnique as jest.Mock).mockImplementationOnce(() => + // Reject since archived is false for valid status + Promise.resolve({ ...realm, archived: false, status }), + ); + await handler(req, res); + expect(res.statusCode).toBe(400); + }); + + invalidStatuses.forEach(async (status) => { + (prisma.roster.findUnique as jest.Mock).mockImplementationOnce(() => + Promise.resolve({ ...realm, archived: true, status }), + ); + await handler(req, res); + expect(res.statusCode).toBe(400); + }); + }); + + it('Logs a success event when successful', async () => { + mockAdminSession(); + const { req, res }: MockHttpRequest = createMocks({ method: 'POST' }); + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(createEvent).toHaveBeenCalledTimes(1); + + const createEventArgs = (createEvent as jest.Mock).mock.calls[0][0]; + expect(createEventArgs.eventCode).toBe(EventEnum.REQUEST_RESTORE_SUCCESS); + }); + + it('Logs a failure event when restore fails', async () => { + mockAdminSession(); + (manageCustomRealm as jest.Mock).mockImplementationOnce(() => Promise.reject()); + const { req, res }: MockHttpRequest = createMocks({ method: 'POST' }); + await handler(req, res); + + expect(res.statusCode).toBe(422); + expect(createEvent).toHaveBeenCalledTimes(1); + let createEventArgs = (createEvent as jest.Mock).mock.calls[0][0]; + expect(createEventArgs.eventCode).toBe(EventEnum.REQUEST_RESTORE_FAILED); + + expect(prisma.roster.update).toHaveBeenCalledTimes(1); + let updateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0]; + expect(updateArgs.data.status).toEqual(StatusEnum.APPLYFAILED); + }); + + it("sends an email to the the realm owners and cc's our team", async () => { + mockAdminSession(); + const { req, res }: MockHttpRequest = createMocks({ method: 'POST' }); + const emailList = createMockSendEmail(); + + await handler(req, res); + expect(manageCustomRealm).toHaveBeenCalledTimes(1); + expect(emailList.length).toBe(1); + expect(emailList[0].subject).toContain(`Notification: Realm ${realm.realm} Restoration Requested`); + expect(emailList[0].to).toEqual(expect.arrayContaining([realm.productOwnerEmail, realm.technicalContactEmail])); + expect(emailList[0].cc).toEqual(expect.arrayContaining([ssoTeamEmail])); + }); + + it('Updates the expected realm fields in the database', async () => { + mockAdminSession(); + const { req, res }: MockHttpRequest = createMocks({ method: 'POST' }); + await handler(req, res); + + expect(prisma.roster.update).toHaveBeenCalledTimes(1); + const updateArgs = (prisma.roster.update as jest.Mock).mock.calls[0][0]; + console.log('🚀 ~ it ~ updateArgs:', updateArgs); + expect(updateArgs.data).toEqual({ + lastUpdatedBy: 'test, test', + archived: false, + status: StatusEnum.APPLIED, + }); + }); +}); diff --git a/app/__tests__/api/update-realm.test.ts b/app/__tests__/api/update-realm.test.ts index 64caa53..a3553c2 100644 --- a/app/__tests__/api/update-realm.test.ts +++ b/app/__tests__/api/update-realm.test.ts @@ -1,20 +1,37 @@ import { createMocks } from 'node-mocks-http'; import handler from '../../pages/api/realms/[id]'; import prisma from 'utils/prisma'; -import { CustomRealmProfiles } from '../fixtures'; +import { CustomRealmProfiles, CustomRealms, MockHttpRequest } from '../fixtures'; import { getServerSession } from 'next-auth'; +import { manageCustomRealm } from 'controllers/keycloak'; +import { createEvent } from 'utils/helpers'; +import { EventEnum } from 'validators/create-realm'; +import { createMockSendEmail } from './utils/mocks'; +import { ssoTeamEmail } from 'utils/mailer'; -jest.mock('../../utils/mailer', () => { +jest.mock('../../utils/helpers', () => { return { - sendUpdateEmail: jest.fn(), + ...jest.requireActual('../../utils/helpers'), + createEvent: jest.fn(), }; }); -jest.mock('../../utils/github', () => { +jest.mock('utils/ches'); + +jest.mock('../../utils/idir', () => { + return { + generateXML: jest.fn(), + makeSoapRequest: jest.fn(() => Promise.resolve({ response: null })), + getBceidAccounts: jest.fn(() => Promise.resolve([])), + }; +}); + +jest.mock('../../controllers/keycloak.ts', () => { return { - createCustomRealmPullRequest: jest.fn(), - mergePullRequest: jest.fn(), - deleteBranch: jest.fn(), + createCustomRealm: jest.fn(() => true), + disableCustomRealm: jest.fn(() => true), + addUserAsRealmAdmin: jest.fn(() => true), + manageCustomRealm: jest.fn(() => true), }; }); @@ -79,7 +96,7 @@ describe('Profile Validations', () => { (prisma.roster.findUnique as jest.Mock).mockImplementation(() => { return Promise.resolve(CustomRealmProfiles[0]); }); - const { req, res } = createMocks({ + const { req, res }: MockHttpRequest = createMocks({ method: 'PUT', body: CustomRealmProfiles[0], }); @@ -105,7 +122,7 @@ describe('Profile Validations', () => { status: 'authenticated', }; }); - const { req, res } = createMocks({ + const { req, res }: MockHttpRequest = createMocks({ method: 'PUT', body: CustomRealmProfiles[0], }); @@ -132,7 +149,7 @@ describe('Profile Validations', () => { status: 'authenticated', }; }); - const { req, res } = createMocks({ + const { req, res }: MockHttpRequest = createMocks({ method: 'PUT', body: CustomRealmProfiles[0], }); @@ -143,4 +160,122 @@ describe('Profile Validations', () => { const updatedFields = Object.keys(profileUpdate.mock.calls[0][0].data); adminAllowedFields.forEach((field) => expect(updatedFields.includes(field)).toBeTruthy()); }); + + it('does not allow to update rejected realms', async () => { + (prisma.roster.findUnique as jest.Mock).mockImplementation(() => { + return Promise.resolve({ ...CustomRealmProfiles[0], approved: false }); + }); + const { req, res }: MockHttpRequest = createMocks({ + method: 'PUT', + query: { id: 1 }, + }); + await handler(req, res); + expect(res.statusCode).toBe(400); + }); +}); + +describe('approval and rejection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('calls kc admin api to create realm in all environments after approval', async () => { + (prisma.roster.findUnique as jest.Mock).mockImplementation(() => { + return Promise.resolve(CustomRealmProfiles[0]); + }); + + (prisma.roster.update as jest.Mock).mockImplementation(() => { + return Promise.resolve({ ...CustomRealms[0], approved: true }); + }); + (getServerSession as jest.Mock).mockImplementation(() => { + return { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { + username: 'test', + client_roles: ['sso-admin'], + }, + status: 'authenticated', + }; + }); + const { req, res }: MockHttpRequest = createMocks({ + method: 'PUT', + body: { ...CustomRealmProfiles[0], approved: true }, + query: { id: 1 }, + }); + + const emailList = createMockSendEmail(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(createEvent).toHaveBeenCalledTimes(3); + const createEventArgs0 = (createEvent as jest.Mock).mock.calls[0][0]; + expect(createEventArgs0.eventCode).toBe(EventEnum.REQUEST_APPROVE_SUCCESS); + const createEventArgs1 = (createEvent as jest.Mock).mock.calls[1][0]; + expect(createEventArgs1.eventCode).toBe(EventEnum.REQUEST_APPLY_SUCCESS); + const createEventArgs2 = (createEvent as jest.Mock).mock.calls[2][0]; + expect(createEventArgs2.eventCode).toBe(EventEnum.REQUEST_UPDATE_SUCCESS); + expect(manageCustomRealm).toHaveBeenCalledTimes(1); + expect(emailList.length).toBe(2); + expect(emailList[0].to).toEqual( + expect.arrayContaining([CustomRealms[0].productOwnerEmail, CustomRealms[0].technicalContactEmail]), + ); + expect(emailList[1].to).toEqual( + expect.arrayContaining([CustomRealms[0].productOwnerEmail, CustomRealms[0].technicalContactEmail]), + ); + expect(emailList[0].cc).toEqual(expect.arrayContaining([ssoTeamEmail])); + expect(emailList[1].cc).toEqual(expect.arrayContaining([ssoTeamEmail])); + expect(emailList[0].subject).toBe( + 'Important: Your request for Custom Realm realm 1 has been Approved (email 1 of 2)', + ); + expect(emailList[1].subject).toBe( + 'Important: Custom Realm realm 1 Created and Action Required for Realm Admin Configuration (email 2 of 2)', + ); + }); + + it('does not call kc admin api to create realm in all environments after rejection', async () => { + (prisma.roster.findUnique as jest.Mock).mockImplementation(() => { + return Promise.resolve(CustomRealmProfiles[0]); + }); + + (prisma.roster.update as jest.Mock).mockImplementation(() => { + return Promise.resolve({ ...CustomRealms[0], approved: false }); + }); + (getServerSession as jest.Mock).mockImplementation(() => { + return { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { + username: 'test', + client_roles: ['sso-admin'], + }, + status: 'authenticated', + }; + }); + const { req, res }: MockHttpRequest = createMocks({ + method: 'PUT', + body: { ...CustomRealmProfiles[0], approved: false }, + query: { id: 1 }, + }); + + const emailList = createMockSendEmail(); + + await handler(req, res); + + expect(res.statusCode).toBe(200); + expect(manageCustomRealm).not.toHaveBeenCalled(); + + expect(createEvent).toHaveBeenCalledTimes(2); + + const createEventArgs0 = (createEvent as jest.Mock).mock.calls[0][0]; + expect(createEventArgs0.eventCode).toBe(EventEnum.REQUEST_REJECT_SUCCESS); + + const createEventArgs1 = (createEvent as jest.Mock).mock.calls[1][0]; + expect(createEventArgs1.eventCode).toBe(EventEnum.REQUEST_UPDATE_SUCCESS); + + expect(emailList.length).toBe(1); + expect(emailList[0].subject).toContain('Important: Your request for Custom Realm realm 1 has been Declined'); + expect(emailList[0].to).toEqual( + expect.arrayContaining([CustomRealms[0].productOwnerEmail, CustomRealms[0].technicalContactEmail]), + ); + expect(emailList[0].cc).toEqual(expect.arrayContaining([ssoTeamEmail])); + }); }); diff --git a/app/__tests__/api/utils/mocks.ts b/app/__tests__/api/utils/mocks.ts new file mode 100644 index 0000000..3f26fac --- /dev/null +++ b/app/__tests__/api/utils/mocks.ts @@ -0,0 +1,44 @@ +import { sendEmail } from 'utils/ches'; +import { getServerSession } from 'next-auth'; + +const mockedSendEmail = sendEmail as jest.Mock; +export const createMockSendEmail = () => { + const result: any[] = []; + mockedSendEmail.mockImplementation((data: any) => { + result.push(data); + return Promise.resolve(null); + }); + + return result; +}; + +export const mockSession = { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { + username: 'test', + given_name: 'test', + family_name: 'test', + idir_username: 'test', + }, + status: 'authenticated', +}; + +export const mockUserSession = () => { + (getServerSession as jest.Mock).mockImplementation(() => { + return { + ...mockSession, + }; + }); +}; + +export const mockAdminSession = () => { + (getServerSession as jest.Mock).mockImplementation(() => { + return { + ...mockSession, + user: { + ...mockSession.user, + client_roles: ['sso-admin'], + }, + }; + }); +}; diff --git a/app/__tests__/custom-realm-dashboard.test.tsx b/app/__tests__/custom-realm-dashboard.test.tsx index ff68198..2a6d20a 100644 --- a/app/__tests__/custom-realm-dashboard.test.tsx +++ b/app/__tests__/custom-realm-dashboard.test.tsx @@ -1,17 +1,19 @@ import React from 'react'; -import { render, screen, within, waitFor } from '@testing-library/react'; +import { render, screen, within, waitFor, fireEvent } from '@testing-library/react'; import App from 'pages/_app'; import CustomRealmDashboard from 'pages/custom-realm-dashboard'; -import { updateRealmProfile } from 'services/realm'; +import { getRealmProfiles, updateRealmProfile } from 'services/realm'; import { getRealmEvents } from 'services/events'; import { CustomRealmFormData } from 'types/realm-profile'; import Router from 'next/router'; import { CustomRealms } from './fixtures'; +import { debug } from 'jest-preview'; jest.mock('services/realm', () => { return { deleteRealmRequest: jest.fn((realmInfo: CustomRealmFormData) => Promise.resolve([true, null])), updateRealmProfile: jest.fn((id: number, status: string) => Promise.resolve([true, null])), + getRealmProfiles: jest.fn((excludeArchived: boolean) => Promise.resolve([CustomRealms, null])), }; }); @@ -106,11 +108,10 @@ jest.mock('../pages/api/auth/[...nextauth]', () => { }); describe('Table', () => { - it('Loads in table data from serverside props', () => { - render(); - const table = screen.getByTestId('custom-realm-table'); - expect(within(table).getByText('realm 1')); - expect(within(table).getByText('realm 2')); + it('Loads in table data from serverside props', async () => { + render(); + await waitFor(() => screen.getByText('realm 1')); + await waitFor(() => screen.getByText('realm 2')); }); }); @@ -127,7 +128,8 @@ describe('Status update', () => { router={Router as any} />, ); - + await waitFor(() => screen.getByText('realm 1')); + fireEvent.click(screen.getByText('realm 1')); screen.getByText('Access Request').click(); await waitFor(() => screen.getByText('Approve Custom Realm', { selector: 'button' }).click()); await waitFor(() => screen.getByText('Are you sure you want to approve request 1?')); @@ -141,6 +143,8 @@ describe('Status update', () => { router={Router as any} />, ); + await waitFor(() => screen.getByText('realm 1')); + fireEvent.click(screen.getByText('realm 1')); screen.getByText('Access Request').click(); await waitFor(() => screen.getByText('Decline Custom Realm', { selector: 'button' }).click()); await waitFor(() => screen.getByText('Are you sure you want to decline request 1?')); @@ -154,6 +158,8 @@ describe('Status update', () => { router={Router as any} />, ); + await waitFor(() => screen.getByText('realm 1')); + fireEvent.click(screen.getByText('realm 1')); screen.getByText('Access Request').click(); await waitFor(() => screen.getByText('Approve Custom Realm', { selector: 'button' }).click()); await waitFor(() => screen.getByText('Are you sure you want to approve request 1?')); @@ -171,6 +177,8 @@ describe('Status update', () => { router={Router as any} />, ); + await waitFor(() => screen.getByText('realm 1')); + fireEvent.click(screen.getByText('realm 1')); screen.getByText('Access Request').click(); await waitFor(() => screen.getByText('Decline Custom Realm', { selector: 'button' }).click()); await waitFor(() => screen.getByText('Are you sure you want to decline request 1?')); @@ -188,23 +196,33 @@ describe('Status update', () => { router={Router as any} />, ); + await waitFor(() => screen.getByText('realm 1')); (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => Promise.resolve([null, { message: 'failure' }]), ); + + const table = screen.getByRole('table'); + const tbody = table.querySelector('tbody') as HTMLTableSectionElement; + fireEvent.click(screen.getByText('realm 1')); screen.getByText('Access Request').click(); await waitFor(() => screen.getByText('Approve Custom Realm', { selector: 'button' }).click()); await waitFor(() => screen.getByText('Are you sure you want to approve request 1?')); screen.getByText('Confirm', { selector: 'button' }).click(); - // Still pending - const firstRow = screen.getByTestId('custom-realm-row-1'); - within(firstRow).getByText('Pending'); + let firstRow: any; + await waitFor(() => { + expect(screen.queryByTestId('grid-svg')).not.toBeInTheDocument(); + }); + + firstRow = tbody.querySelector('tr') as HTMLTableRowElement; + within(firstRow).queryByText('Pending'); // Successful request (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => Promise.resolve([true, null])); - screen.getByText('Approve Custom Realm', { selector: 'button' }).click(); + await waitFor(() => screen.getByText('Approve Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to approve request 1?')); screen.getByText('Confirm', { selector: 'button' }).click(); - await waitFor(() => within(firstRow).getByText('Approved')); + await waitFor(() => screen.getByText('realm 1')); }); it('Updates status in table only when successfully declined', async () => { @@ -215,23 +233,35 @@ describe('Status update', () => { router={Router as any} />, ); + await waitFor(() => screen.getByText('realm 1')); (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => Promise.resolve([null, { message: 'failure' }]), ); + const table = screen.getByRole('table'); + const tbody = table.querySelector('tbody') as HTMLTableSectionElement; + fireEvent.click(screen.getByText('realm 1')); + screen.getByText('Access Request').click(); await waitFor(() => screen.getByText('Decline Custom Realm', { selector: 'button' }).click()); await waitFor(() => screen.getByText('Are you sure you want to decline request 1?')); - screen.getByText('Confirm', { selector: 'button' }).click(); + const confirmButton = screen.getByText('Confirm', { selector: 'button' }); + fireEvent.click(confirmButton); - // Still pending - const firstRow = screen.getByTestId('custom-realm-row-1'); - within(firstRow).getByText('Pending'); + let firstRow: any; + await waitFor(() => { + expect(screen.queryByTestId('grid-svg')).not.toBeInTheDocument(); + }); + + firstRow = tbody.querySelectorAll('tr') as any; + + within(firstRow[2]).queryByText('Pending'); // Successful request (updateRealmProfile as jest.MockedFunction).mockImplementationOnce(() => Promise.resolve([true, null])); - screen.getByText('Decline Custom Realm', { selector: 'button' }).click(); + await waitFor(() => screen.getByText('Decline Custom Realm', { selector: 'button' }).click()); + await waitFor(() => screen.getByText('Are you sure you want to decline request 1?')); screen.getByText('Confirm', { selector: 'button' }).click(); - await waitFor(() => within(firstRow).getByText('Declined')); + await waitFor(() => within(firstRow[2]).queryByText('Declined')); }); }); @@ -241,14 +271,16 @@ describe('Events table', () => { }); it('fetches correct events when selected row changes', async () => { - render(); + render(); + await waitFor(() => screen.getByText('realm 1')); + const row1 = screen.getByText('realm 1'); + fireEvent.click(row1); expect(getRealmEvents).toHaveBeenCalledTimes(1); // Called with correct realm id expect(getRealmEvents).toHaveBeenCalledWith('1'); - const table = screen.getByTestId('custom-realm-table'); - const row = within(table).getByText('realm 2'); - row.click(); + const row2 = screen.getByText('realm 2'); + fireEvent.click(row2); await waitFor(() => expect(getRealmEvents).toHaveBeenCalledTimes(2)); // Called with correct realm id @@ -256,18 +288,18 @@ describe('Events table', () => { }); it('displays events for the selected realm and updates when changing rows', async () => { - render(); - - const table = screen.getByTestId('custom-realm-table'); - const secondRealmRow = within(table).getByText('realm 2'); + render(); + await waitFor(() => screen.getByText('realm 1')); + const firstRealmRow = screen.getByText('realm 1'); + fireEvent.click(firstRealmRow); const eventTab = screen.getByText('Events'); - eventTab.click(); + fireEvent.click(eventTab); // Expect only realm 1 event to show await waitFor(() => screen.getByText('request-create-success')); expect(screen.queryByText('request-update-success')).toBeNull(); - - secondRealmRow.click(); + const secondRealmRow = screen.getByText('realm 2'); + fireEvent.click(secondRealmRow); await waitFor(() => screen.getByText('request-update-success')); expect(screen.queryByText('request-create-success')).toBeNull(); }); @@ -283,6 +315,8 @@ describe('Events table', () => { router={Router as any} />, ); + await waitFor(() => screen.getByText('realm 1')); + fireEvent.click(screen.getByText('realm 1')); await waitFor(() => screen.getByText('Network error when fetching realm events.')); }); }); diff --git a/app/__tests__/fixtures.ts b/app/__tests__/fixtures.ts index 902b646..03c881e 100644 --- a/app/__tests__/fixtures.ts +++ b/app/__tests__/fixtures.ts @@ -1,3 +1,4 @@ +import { NextApiRequest, NextApiResponse } from 'next/types'; import { CustomRealmFormData, RealmProfile } from 'types/realm-profile'; export const CustomRealms: CustomRealmFormData[] = [ @@ -23,6 +24,8 @@ export const CustomRealms: CustomRealmFormData[] = [ rcChannelOwnedBy: '', materialToSend: '', status: 'pending', + idps: [], + protocol: [], }, { id: 2, @@ -43,6 +46,30 @@ export const CustomRealms: CustomRealmFormData[] = [ division: 'division', approved: null, status: 'pending', + idps: [], + protocol: [], + }, + { + id: 3, + realm: 'realm 3', + productName: 'name', + purpose: 'purpose', + primaryEndUsers: ['livingInBC', 'businessInBC', 'govEmployees', 'details'], + environments: ['dev', 'test', 'prod'], + preferredAdminLoginMethod: 'idir', + productOwnerEmail: 'a@b.com', + productOwnerIdirUserId: 'po', + technicalContactEmail: 'b@c.com', + technicalContactIdirUserId: 'd@e.com', + secondTechnicalContactIdirUserId: 'dmsd', + secondTechnicalContactEmail: 'a@b.com', + ministry: 'ministry', + branch: 'branch', + division: 'division', + approved: false, + status: 'pending', + idps: [], + protocol: [], }, ]; @@ -60,3 +87,8 @@ export const CustomRealmProfiles: RealmProfile[] = CustomRealms.map((realm) => ( updatedAt: '', status: 'pending', })); + +export interface MockHttpRequest { + req: NextApiRequest; + res: NextApiResponse; +} diff --git a/app/__tests__/my-dashboard.test.tsx b/app/__tests__/my-dashboard.test.tsx index 75ff8bf..2db080b 100644 --- a/app/__tests__/my-dashboard.test.tsx +++ b/app/__tests__/my-dashboard.test.tsx @@ -1,26 +1,10 @@ import React from 'react'; -import { render, screen, within } from '@testing-library/react'; -import MyDashboard from 'pages/my-dashboard'; -import { CustomRealmFormData } from 'types/realm-profile'; -import { CustomRealmProfiles } from './fixtures'; -import { useSession } from 'next-auth/react'; +import { render, screen, within, fireEvent } from '@testing-library/react'; +import { CustomRealms } from './fixtures'; +import RealmLeftPanel from 'page-partials/my-dashboard/RealmLeftPanel'; +import noop from 'lodash.noop'; -const PRODUCT_OWNER_IDIR_USERID = 'po'; - -jest.mock('services/meta', () => { - return { - getBranches: jest.fn(() => Promise.resolve([[], null])), - getDivisions: jest.fn(() => Promise.resolve([[], null])), - getMinistries: jest.fn(() => Promise.resolve([[], null])), - }; -}); - -jest.mock('services/realm', () => { - return { - submitRealmRequest: jest.fn((realmInfo: CustomRealmFormData) => Promise.resolve([true, null])), - getRealmProfiles: jest.fn(() => Promise.resolve([CustomRealmProfiles, null])), - }; -}); +const editFunction = jest.fn(); jest.mock('next/router', () => ({ useRouter() { @@ -41,12 +25,12 @@ jest.mock('next/router', () => ({ })); // Mock authentication +const mockSession = { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { username: 'admin' }, +}; jest.mock('next-auth/react', () => { const originalModule = jest.requireActual('next-auth/react'); - const mockSession = { - expires: new Date(Date.now() + 2 * 86400).toISOString(), - user: { idir_username: 'tech lead' }, - }; return { __esModule: true, ...originalModule, @@ -56,149 +40,39 @@ jest.mock('next-auth/react', () => { }; }); -describe('Form Validation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - const getFormInputs = async (container: HTMLElement) => { - const realmNameInput = (await screen.findByLabelText('Custom Realm name', { exact: false })) as HTMLInputElement; - const productNameInput = (await screen.findByLabelText('Product Name', { exact: false })) as HTMLInputElement; - const ministryInput = (await screen.findByLabelText('Ministry', { exact: false })) as HTMLInputElement; - const divisionInput = (await screen.findByLabelText('Division', { exact: false })) as HTMLInputElement; - const branchInput = (await screen.findByLabelText('Branch', { exact: false })) as HTMLInputElement; - const realmPurposeInput = (await screen.findByLabelText('Purpose of Realm', { exact: false })) as HTMLInputElement; - const primaryEndUserInput = ( - await screen.findByText('Who are the primary end users of your project', { exact: false }) - ).closest('fieldset') as HTMLFieldSetElement; - //const poEmailInput = (await screen.findByLabelText("Product owner's email", { exact: false })) as HTMLInputElement; - const poEmailInput = container.querySelector('input.product-owner-email__input') as HTMLInputElement; - const poIdirInput = (await screen.findByLabelText("Product owner's IDIR", { exact: false })) as HTMLInputElement; - // const techContactEmailInput = (await screen.findByTestId('tech-contact-email', { - // exact: false, - // })) as HTMLInputElement; - const techContactEmailInput = container.querySelector('input.technical-contact-email__input') as HTMLInputElement; - const techContactIdirInput = (await screen.findByTestId('tech-contact-idir', { - exact: false, - })) as HTMLInputElement; - // const secondTechContactEmailInput = (await screen.findByLabelText("Secondary technical contact's email", { - // exact: false, - // })) as HTMLInputElement; - const secondTechContactEmailInput = container.querySelector( - 'input.secondary-contact-email__input', - ) as HTMLInputElement; - const secondTechContactIdirInput = (await screen.findByTestId('secondary-contact-idir', { - exact: false, - })) as HTMLInputElement; - return { - realmNameInput, - productNameInput, - ministryInput, - divisionInput, - branchInput, - realmPurposeInput, - primaryEndUserInput, - poEmailInput, - poIdirInput, - techContactEmailInput, - techContactIdirInput, - secondTechContactEmailInput, - secondTechContactIdirInput, - }; +jest.mock('next-auth/next', () => { + return { + __esModule: true, + getServerSession: jest.fn(() => { + return { data: mockSession, status: 'authenticated' }; + }), }; +}); - it('Enables/disables expected fields for a technical contact', async () => { - const { container } = render(); - const firstRow = (await screen.findByText('realm 1')).closest('tr') as HTMLElement; - const firstRowEditButton = within(firstRow).getByText('Edit'); - firstRowEditButton.click(); - - const inputs = await getFormInputs(container); - - expect(inputs.realmNameInput.disabled).toBe(true); - expect(inputs.productNameInput.disabled).toBe(true); - expect(inputs.ministryInput.disabled).toBe(false); - expect(inputs.divisionInput.disabled).toBe(false); - expect(inputs.branchInput.disabled).toBe(false); - expect(inputs.realmPurposeInput.disabled).toBe(true); - expect(inputs.primaryEndUserInput.disabled).toBe(true); - expect(inputs.poEmailInput!.disabled).toBe(true); - expect(inputs.poIdirInput.disabled).toBe(true); - expect(inputs.techContactEmailInput!.disabled).toBe(false); - expect(inputs.techContactIdirInput.disabled).toBe(true); - expect(inputs.secondTechContactEmailInput!.disabled).toBe(false); - expect(inputs.secondTechContactIdirInput.disabled).toBe(true); - - expect(screen.queryByTestId('rc-channel-input', { exact: false })).toBeNull(); - expect(screen.queryByTestId('rc-channel-owner-input', { exact: false })).toBeNull(); - expect(screen.queryByLabelText('Material To Send', { exact: false })).toBeNull(); - }); - - it('Enables/disables expected fields for a product owner', async () => { - (useSession as jest.Mock).mockImplementation(() => ({ - status: 'authenticated', - data: { - expires: new Date(Date.now() + 2 * 86400).toISOString(), - user: { idir_username: PRODUCT_OWNER_IDIR_USERID }, - }, - })); - const { container } = render(); - const firstRow = (await screen.findByText('realm 1')).closest('tr') as HTMLElement; - const firstRowEditButton = within(firstRow).getByText('Edit'); - firstRowEditButton.click(); - - const inputs = await getFormInputs(container); - - expect(inputs.realmNameInput.disabled).toBe(true); - expect(inputs.productNameInput.disabled).toBe(false); - expect(inputs.ministryInput.disabled).toBe(false); - expect(inputs.divisionInput.disabled).toBe(false); - expect(inputs.branchInput.disabled).toBe(false); - expect(inputs.realmPurposeInput.disabled).toBe(false); - expect(inputs.primaryEndUserInput.disabled).toBe(false); - expect(inputs.poEmailInput!.disabled).toBe(false); - expect(inputs.poIdirInput.disabled).toBe(true); - expect(inputs.techContactEmailInput!.disabled).toBe(false); - expect(inputs.techContactIdirInput.disabled).toBe(true); - expect(inputs.secondTechContactEmailInput!.disabled).toBe(false); - expect(inputs.secondTechContactIdirInput.disabled).toBe(true); +jest.mock('../pages/api/realms', () => { + return { + __esModule: true, + getAllRealms: jest.fn(() => Promise.resolve([CustomRealms, null])), + authOptions: {}, + }; +}); - expect(screen.queryByTestId('rc-channel-input', { exact: false })).toBeNull(); - expect(screen.queryByTestId('rc-channel-owner-input', { exact: false })).toBeNull(); - expect(screen.queryByLabelText('Material To Send', { exact: false })).toBeNull(); +describe('realm table', () => { + it('loads all realms', () => { + render(); }); - it('Enables/disables expected fields for an admin', async () => { - (useSession as jest.Mock).mockImplementation(() => ({ - status: 'authenticated', - data: { - expires: new Date(Date.now() + 2 * 86400).toISOString(), - user: { idir_username: PRODUCT_OWNER_IDIR_USERID, client_roles: 'sso-admin' }, - }, - })); - const { container } = render(); - const firstRow = (await screen.findByText('realm 1')).closest('tr') as HTMLElement; - const firstRowEditButton = within(firstRow).getByText('Edit'); - firstRowEditButton.click(); - - const inputs = await getFormInputs(container); - - expect(inputs.realmNameInput.disabled).toBe(true); - expect(inputs.productNameInput.disabled).toBe(false); - expect(inputs.ministryInput.disabled).toBe(false); - expect(inputs.divisionInput.disabled).toBe(false); - expect(inputs.branchInput.disabled).toBe(false); - expect(inputs.realmPurposeInput.disabled).toBe(false); - expect(inputs.primaryEndUserInput.disabled).toBe(false); - expect(inputs.poEmailInput!.disabled).toBe(false); - expect(inputs.poIdirInput.disabled).toBe(true); - expect(inputs.techContactEmailInput!.disabled).toBe(false); - expect(inputs.techContactIdirInput.disabled).toBe(true); - expect(inputs.secondTechContactEmailInput!.disabled).toBe(false); - expect(inputs.secondTechContactIdirInput.disabled).toBe(true); - - expect(screen.queryByTestId('rc-channel-input', { exact: false })).not.toBeNull(); - expect(screen.queryByTestId('rc-channel-owner-input', { exact: false })).not.toBeNull(); - expect(screen.queryByLabelText('Material To Send', { exact: false })).not.toBeNull(); + it('edit button disabled if realm is not approved', async () => { + render( + , + ); + const table = screen.getByRole('table'); + const thirdRow = table.querySelector('tbody tr:nth-child(3)') as HTMLTableRowElement; + expect(thirdRow).toBeInTheDocument(); + const actionCell = thirdRow.querySelector('td:nth-child(10)') as HTMLTableCellElement; + expect(actionCell).toBeInTheDocument(); + const editButton = within(actionCell).getByRole('img', { name: 'Edit' }); + fireEvent.click(editButton); + expect(editFunction).toHaveBeenCalledTimes(0); }); }); diff --git a/app/__tests__/realm-edit.test.tsx b/app/__tests__/realm-edit.test.tsx new file mode 100644 index 0000000..1a2200e --- /dev/null +++ b/app/__tests__/realm-edit.test.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import EditPage, { getServerSideProps } from 'pages/realm/[rid]'; +import { CustomRealmFormData } from 'types/realm-profile'; +import { CustomRealmProfiles } from './fixtures'; +import { useSession } from 'next-auth/react'; +import prisma from 'utils/prisma'; +import { getServerSession } from 'next-auth'; + +const PRODUCT_OWNER_IDIR_USERID = 'po'; + +jest.mock('services/meta', () => { + return { + getBranches: jest.fn(() => Promise.resolve([[], null])), + getDivisions: jest.fn(() => Promise.resolve([[], null])), + getMinistries: jest.fn(() => Promise.resolve([[], null])), + }; +}); + +jest.mock('services/realm', () => { + return { + submitRealmRequest: jest.fn((realmInfo: CustomRealmFormData) => Promise.resolve([true, null])), + getRealmProfiles: jest.fn(() => Promise.resolve([CustomRealmProfiles, null])), + }; +}); + +jest.mock('next/router', () => ({ + useRouter() { + return { + route: '/', + pathname: '', + query: '', + asPath: '', + push: jest.fn(() => Promise.resolve(true)), + events: { + on: jest.fn(), + off: jest.fn(), + }, + beforePopState: jest.fn(() => null), + prefetch: jest.fn(() => null), + }; + }, +})); + +// Mock authentication +jest.mock('next-auth/react', () => { + const originalModule = jest.requireActual('next-auth/react'); + const mockSession = { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { idir_username: 'tech lead' }, + }; + return { + __esModule: true, + ...originalModule, + useSession: jest.fn(() => { + return { data: mockSession, status: 'authenticated' }; // return type is [] in v3 but changed to {} in v4 + }), + }; +}); + +const MOCK_IDIR = 'idir'; + +jest.mock('next-auth', () => { + return { + __esModule: true, + default: jest.fn(() => {}), + getServerSession: jest.fn(() => { + return { + user: { + idir_username: MOCK_IDIR, + }, + }; + }), + }; +}); + +jest.mock('prisma', () => { + return { + __esModule: true, + default: jest.fn(() => {}), + roster: { + findFirst: jest.fn(), + }, + }; +}); + +const testRealm: CustomRealmFormData = { + realm: '', + purpose: '', + productName: '', + primaryEndUsers: [], + productOwnerEmail: '', + productOwnerIdirUserId: PRODUCT_OWNER_IDIR_USERID, + technicalContactEmail: '', + technicalContactIdirUserId: '', + secondTechnicalContactIdirUserId: '', + secondTechnicalContactEmail: '', +}; + +describe('Server Fetching', () => { + it('Requires non-admins to be a product owner, technical contact, or secondary contact to see the edit page', async () => { + await getServerSideProps({ params: { rid: 1 } } as any); + expect(prisma.roster.findFirst).toHaveBeenCalledTimes(1); + const prismaArgs = (prisma.roster.findFirst as jest.Mock).mock.calls[0][0]; + + // Checks the id + expect(prismaArgs.where.id).toBe(1); + + // Also requires IDIR match + expect(prismaArgs.where['OR']).toBeDefined(); + const technicalContactClause = prismaArgs.where['OR'].find((clause: any) => + Object.keys(clause).includes('technicalContactIdirUserId'), + ).technicalContactIdirUserId; + const secondaryTechnicalContactClause = prismaArgs.where['OR'].find((clause: any) => + Object.keys(clause).includes('secondTechnicalContactIdirUserId'), + ).secondTechnicalContactIdirUserId; + const productOwnerClause = prismaArgs.where['OR'].find((clause: any) => + Object.keys(clause).includes('productOwnerIdirUserId'), + ).productOwnerIdirUserId; + + expect(technicalContactClause.equals).toBe(MOCK_IDIR); + expect(secondaryTechnicalContactClause.equals).toBe(MOCK_IDIR); + expect(productOwnerClause.equals).toBe(MOCK_IDIR); + }); + + it('Allows admins to always see the edit page', async () => { + (getServerSession as jest.Mock).mockImplementation(() => ({ + user: { + idir_username: MOCK_IDIR, + client_roles: ['sso-admin'], + }, + })); + await getServerSideProps({ params: { rid: 1 } } as any); + expect(prisma.roster.findFirst).toHaveBeenCalledTimes(1); + const prismaArgs = (prisma.roster.findFirst as jest.Mock).mock.calls[0][0]; + expect(prismaArgs.where['OR']).not.toBeDefined(); + + // sso-admin only checks the id + expect(prismaArgs.where).toEqual({ id: 1 }); + }); +}); + +describe('Form Validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const getFormInputs = async (container: HTMLElement) => { + const realmNameInput = (await screen.findByLabelText('Custom Realm name', { exact: false })) as HTMLInputElement; + const productNameInput = (await screen.findByLabelText('Product Name', { exact: false })) as HTMLInputElement; + const ministryInput = (await screen.findByLabelText('Ministry', { exact: false })) as HTMLInputElement; + const divisionInput = (await screen.findByLabelText('Division', { exact: false })) as HTMLInputElement; + const branchInput = (await screen.findByLabelText('Branch', { exact: false })) as HTMLInputElement; + const realmPurposeInput = (await screen.findByLabelText('Purpose of Realm', { exact: false })) as HTMLInputElement; + const primaryEndUserInput = ( + await screen.findByText('Who are the primary end users of your project', { exact: false }) + ).closest('fieldset') as HTMLFieldSetElement; + //const poEmailInput = (await screen.findByLabelText("Product owner's email", { exact: false })) as HTMLInputElement; + const poEmailInput = container.querySelector('input.product-owner-email__input') as HTMLInputElement; + const poIdirInput = (await screen.findByLabelText("Product owner's IDIR", { exact: false })) as HTMLInputElement; + // const techContactEmailInput = (await screen.findByTestId('tech-contact-email', { + // exact: false, + // })) as HTMLInputElement; + const techContactEmailInput = container.querySelector('input.technical-contact-email__input') as HTMLInputElement; + const techContactIdirInput = (await screen.findByTestId('tech-contact-idir', { + exact: false, + })) as HTMLInputElement; + // const secondTechContactEmailInput = (await screen.findByLabelText("Secondary technical contact's email", { + // exact: false, + // })) as HTMLInputElement; + const secondTechContactEmailInput = container.querySelector( + 'input.secondary-contact-email__input', + ) as HTMLInputElement; + const secondTechContactIdirInput = (await screen.findByTestId('secondary-contact-idir', { + exact: false, + })) as HTMLInputElement; + return { + realmNameInput, + productNameInput, + ministryInput, + divisionInput, + branchInput, + realmPurposeInput, + primaryEndUserInput, + poEmailInput, + poIdirInput, + techContactEmailInput, + techContactIdirInput, + secondTechContactEmailInput, + secondTechContactIdirInput, + }; + }; + + it("Displays 'not found' when no realm can be retrieved", () => { + render(); + screen.getByText('Not Found'); + }); + + it('Enables/disables expected fields for a technical contact', async () => { + const { container } = render(); + + const inputs = await getFormInputs(container); + + expect(inputs.realmNameInput.disabled).toBe(true); + expect(inputs.productNameInput.disabled).toBe(true); + expect(inputs.ministryInput.disabled).toBe(false); + expect(inputs.divisionInput.disabled).toBe(false); + expect(inputs.branchInput.disabled).toBe(false); + expect(inputs.realmPurposeInput.disabled).toBe(true); + expect(inputs.primaryEndUserInput.disabled).toBe(true); + expect(inputs.poEmailInput!.disabled).toBe(true); + expect(inputs.poIdirInput.disabled).toBe(true); + expect(inputs.techContactEmailInput!.disabled).toBe(false); + expect(inputs.techContactIdirInput.disabled).toBe(true); + expect(inputs.secondTechContactEmailInput!.disabled).toBe(false); + expect(inputs.secondTechContactIdirInput.disabled).toBe(true); + + expect(screen.queryByTestId('rc-channel-input', { exact: false })).toBeNull(); + expect(screen.queryByTestId('rc-channel-owner-input', { exact: false })).toBeNull(); + expect(screen.queryByLabelText('SSO team notes', { exact: false })).toBeNull(); + }); + + it('Enables/disables expected fields for a product owner', async () => { + (useSession as jest.Mock).mockImplementation(() => ({ + status: 'authenticated', + data: { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { idir_username: PRODUCT_OWNER_IDIR_USERID }, + }, + })); + const { container } = render(); + + const inputs = await getFormInputs(container); + + expect(inputs.realmNameInput.disabled).toBe(true); + expect(inputs.productNameInput.disabled).toBe(false); + expect(inputs.ministryInput.disabled).toBe(false); + expect(inputs.divisionInput.disabled).toBe(false); + expect(inputs.branchInput.disabled).toBe(false); + expect(inputs.realmPurposeInput.disabled).toBe(false); + expect(inputs.primaryEndUserInput.disabled).toBe(false); + expect(inputs.poEmailInput!.disabled).toBe(false); + expect(inputs.poIdirInput.disabled).toBe(true); + expect(inputs.techContactEmailInput!.disabled).toBe(false); + expect(inputs.techContactIdirInput.disabled).toBe(true); + expect(inputs.secondTechContactEmailInput!.disabled).toBe(false); + expect(inputs.secondTechContactIdirInput.disabled).toBe(true); + + expect(screen.queryByTestId('rc-channel-input', { exact: false })).toBeNull(); + expect(screen.queryByTestId('rc-channel-owner-input', { exact: false })).toBeNull(); + expect(screen.queryByLabelText('SSO team notes', { exact: false })).toBeNull(); + }); + + it('Enables/disables expected fields for an admin', async () => { + (useSession as jest.Mock).mockImplementation(() => ({ + status: 'authenticated', + data: { + expires: new Date(Date.now() + 2 * 86400).toISOString(), + user: { idir_username: PRODUCT_OWNER_IDIR_USERID, client_roles: 'sso-admin' }, + }, + })); + const { container } = render(); + + const inputs = await getFormInputs(container); + + expect(inputs.realmNameInput.disabled).toBe(true); + expect(inputs.productNameInput.disabled).toBe(false); + expect(inputs.ministryInput.disabled).toBe(false); + expect(inputs.divisionInput.disabled).toBe(false); + expect(inputs.branchInput.disabled).toBe(false); + expect(inputs.realmPurposeInput.disabled).toBe(false); + expect(inputs.primaryEndUserInput.disabled).toBe(false); + expect(inputs.poEmailInput!.disabled).toBe(false); + expect(inputs.poIdirInput.disabled).toBe(true); + expect(inputs.techContactEmailInput!.disabled).toBe(false); + expect(inputs.techContactIdirInput.disabled).toBe(true); + expect(inputs.secondTechContactEmailInput!.disabled).toBe(false); + expect(inputs.secondTechContactIdirInput.disabled).toBe(true); + + expect(screen.queryByTestId('rc-channel-input', { exact: false })).not.toBeNull(); + expect(screen.queryByTestId('rc-channel-owner-input', { exact: false })).not.toBeNull(); + expect(screen.queryByLabelText('SSO team notes', { exact: false })).not.toBeNull(); + }); +}); diff --git a/app/components/ActionButton.tsx b/app/components/ActionButton.tsx new file mode 100644 index 0000000..5dafe4d --- /dev/null +++ b/app/components/ActionButton.tsx @@ -0,0 +1,54 @@ +import styled from 'styled-components'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +interface StyledActionButtonProps { + disabled?: boolean; + activeColor?: string; + isUnread?: boolean; +} + +export const ActionButton = styled(({ disabled, activeColor, isUnread, ...props }) => ( + +))` + cursor: ${(props) => (props.disabled ? 'not-allowed' : 'pointer')}; + ${(props) => + props.disabled ? `color: #CACACA;` : `color: inherit; &:hover { color: ${props.activeColor || '#000'}; }`} + ${(props) => (props.isUnread ? `color: #D8292F` : '')}; +`; + +interface Props { + id?: string; + title?: string; + defaultActiveColor?: string; + iconStyle?: any; + onClick?: Function; + disabled?: boolean; + icon?: JSX.Element; + size?: string; +} + +export default function Actionbutton({ + id, + title, + defaultActiveColor, + iconStyle = {}, + onClick, + disabled, + icon, + size = 'lg', +}: Props) { + return ( + + ); +} diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index 6fe6a81..7329a71 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -128,7 +128,7 @@ export default function GlobalModal({ setModalConfig, modalConfig }: Props) { )} {showConfirmButton && ( - )} diff --git a/app/components/RealmForm.tsx b/app/components/RealmForm.tsx index f9f78c3..25474db 100644 --- a/app/components/RealmForm.tsx +++ b/app/components/RealmForm.tsx @@ -1,6 +1,6 @@ import { CustomRealmFormData, PrimaryEndUser } from 'types/realm-profile'; import styled from 'styled-components'; -import React, { useState, ChangeEvent, useEffect } from 'react'; +import React, { useState, ChangeEvent, useEffect, Dispatch, SetStateAction } from 'react'; import { Grid as SpinnerGrid } from 'react-loader-spinner'; import Button from '@button-inc/bcgov-theme/Button'; import { ValidationError } from 'yup'; @@ -13,16 +13,17 @@ import kebabCase from 'lodash.kebabcase'; import AsyncSelect from 'react-select/async'; import { getIdirUserId, getIdirUsersByEmail } from 'services/azure'; import { realmTakenError } from 'pages/custom-realm-form'; +import debounce from 'lodash.debounce'; + const SForm = styled.form<{ collapse: boolean }>` display: grid; grid-template-columns: ${(props) => (props.collapse ? '1fr' : '1fr 1fr')}; column-gap: 2em; row-gap: 1em; - font-size: 1rem; + font-size: 16px; .error-message { color: red; - font-size: 0.8em; padding: 0; margin: 0; } @@ -58,8 +59,8 @@ const SForm = styled.form<{ collapse: boolean }>` fieldset { border: 0; legend { - font-size: 1em; - margin-bottom: 0; + font-size: 16px; + margin-bottom: 16px; } } @@ -71,8 +72,14 @@ const SForm = styled.form<{ collapse: boolean }>` .checkbox-wrapper, .radio-wrapper { input { + display: inline-block; + width: auto; + flex-grow: 0; margin-right: 0.5em; } + label { + display: inline-block; + } } .checkbox-wrapper.with-textarea { @@ -109,7 +116,6 @@ const SForm = styled.form<{ collapse: boolean }>` label { font-weight: 700; - font-size: 0.8rem; } input, @@ -166,7 +172,7 @@ const validateForm = (data: CustomRealmFormData, validationSchema: yup.AnyObject interface Props { formData: CustomRealmFormData; - setFormData: (data: CustomRealmFormData) => void; + setFormData: Dispatch> | Dispatch>; onSubmit: (data: CustomRealmFormData) => Promise; onCancel: () => void; isAdmin?: boolean; @@ -211,20 +217,22 @@ export default function RealmForm({ const [divisions, setDivisions] = useState([]); const [branches, setBranches] = useState([]); - const fuzzySearchIdirUsersByEmail = async (email: string) => { + const fuzzySearchIdirUsersByEmail = debounce((email: string, cb) => { if (email.length > 2) { - const [data] = await getIdirUsersByEmail(email); - const options = data?.map((u) => { - return { - value: u.id, - label: u.mail, - }; - }); - return new Promise((resolve) => { - resolve(options); + getIdirUsersByEmail(email).then(([data, err]) => { + if (err) return cb([]); + const options = data?.map((u) => { + return { + value: u.id, + label: u.mail, + }; + }); + cb(options); }); + } else { + cb([]); } - }; + }, 300); const handleFormSelectChange = async (e: any, selectorName: string, dependentInput: string) => { let idirUserId: string | null = ''; @@ -360,8 +368,9 @@ export default function RealmForm({ list="ministry-list" id="ministry" name="ministry" - onBlur={handleFormInputChange} disabled={!schemaFields.includes('ministry')} + value={formData.ministry} + onChange={handleFormInputChange} /> @@ -379,8 +388,9 @@ export default function RealmForm({ list="division-list" id="division" name="division" - onBlur={handleFormInputChange} + onChange={handleFormInputChange} disabled={!schemaFields.includes('division')} + value={formData.division} /> @@ -398,8 +408,9 @@ export default function RealmForm({ list="branch-list" id="branch" name="branch" - onBlur={handleFormInputChange} + onChange={handleFormInputChange} disabled={!schemaFields.includes('branch')} + value={formData.branch} /> @@ -638,14 +649,14 @@ export default function RealmForm({ {schemaFields.includes('materialToSend') && (
- +