diff --git a/playwright/e2e/assign-roles-and-districts.spec.ts b/playwright/e2e/assign-roles-and-districts.spec.ts new file mode 100644 index 00000000..39efd85c --- /dev/null +++ b/playwright/e2e/assign-roles-and-districts.spec.ts @@ -0,0 +1,307 @@ +import { expect, test, request, type APIRequestContext, type Page } from '@playwright/test'; +import { + loginPageAs as runtimeLoginPageAs, + switchRoleAndRelogin as runtimeSwitchRoleAndRelogin, + type WorkflowRoleCode, +} from './support/authRuntime'; +import { + ASSIGN_ROLES_PERMISSION_ID, + ROLE_ID, + assignDistrictsViaApi, + assignRoleViaApi, + clickAssignButton, + createSeedUser, + cleanupSeed, + ensureRolePermission, + expectAhDistrictErrorMessage, + expectAssignButtonDisabled, + expectAssignRolesNavLink, + expectAssignSuccessMessage, + getDistrictIdByCode, + getUserRoleAndDistrictsViaApi, + gotoAssignRolesAndDistricts, + revokeRolePermission, + selectDistrictFromDropdown, + selectRoleFromDropdown, + selectUserFromDropdown, + type SeedUser, +} from './support/manageUsersRuntime'; +import { + extensionRoleByCode as roleByCode, + getApiBaseUrl, + getDbPool, + getSingleUserLoginMode, + getSingleUserPassword, + getSingleUserRecordForDb, + getSingleUserUsername, + getTestDistrictCode, + setUserRoleById, +} from './support/extensionRuntime'; + +const logE2E = (message: string) => { + console.log(`[E2E] ${message}`); +}; + +type RoleCode = WorkflowRoleCode; + +let cachedSingleUserRecord: { id: number; sso_id: string } | null = null; +let testDistrictId: number | null = null; +let permissionGrantedBySuite = false; + +const loginPageAs = async ({ page, roleCode }: { page: Page; roleCode: RoleCode }): Promise => { + return runtimeLoginPageAs({ + page, + roleCode, + username: getSingleUserUsername(), + password: getSingleUserPassword(), + loginMode: getSingleUserLoginMode(), + apiBaseUrl: getApiBaseUrl(), + logE2E, + }); +}; + +const switchRoleAndRelogin = async ({ page, roleCode }: { page: Page; roleCode: RoleCode }) => { + return runtimeSwitchRoleAndRelogin({ + page, + roleCode, + roleByCode, + getSingleUserRecord: async () => { + if (!cachedSingleUserRecord) { + cachedSingleUserRecord = await getSingleUserRecordForDb(); + } + return cachedSingleUserRecord; + }, + setUserRoleById, + loginPageAs, + apiBaseUrl: getApiBaseUrl(), + logE2E, + }); +}; + +test.describe('Assign Roles and Districts', () => { + test.describe.configure({ timeout: 240000 }); + + let apiContext: APIRequestContext; + + test.beforeAll(async () => { + apiContext = await request.newContext(); + permissionGrantedBySuite = await ensureRolePermission({ + getDbPool, + roleId: roleByCode.SA, + permissionId: ASSIGN_ROLES_PERMISSION_ID, + logE2E, + }); + testDistrictId = await getDistrictIdByCode({ getDbPool, districtCode: getTestDistrictCode() }); + }); + + test.afterEach(async () => { + if (cachedSingleUserRecord) { + await setUserRoleById({ userId: cachedSingleUserRecord.id, roleId: roleByCode.SA }); + } + }); + + test.afterAll(async () => { + // Leave no residue: revoke the grant only when this suite added it, preserving + // environments where the permission was already configured. + if (permissionGrantedBySuite) { + await revokeRolePermission({ + getDbPool, + roleId: roleByCode.SA, + permissionId: ASSIGN_ROLES_PERMISSION_ID, + logE2E, + }); + permissionGrantedBySuite = false; + } + if (apiContext) { + await apiContext.dispose(); + } + }); + + test('shows the Assign Roles/Districts nav link only with the assign-roles permission', async ({ page }) => { + await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await page.goto('/home'); + await expectAssignRolesNavLink(page, { visible: true }); + + await revokeRolePermission({ + getDbPool, + roleId: roleByCode.SA, + permissionId: ASSIGN_ROLES_PERMISSION_ID, + logE2E, + }); + try { + await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await page.goto('/home'); + await expectAssignRolesNavLink(page, { visible: false }); + } finally { + await ensureRolePermission({ + getDbPool, + roleId: roleByCode.SA, + permissionId: ASSIGN_ROLES_PERMISSION_ID, + logE2E, + }); + } + + await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await page.goto('/home'); + await expectAssignRolesNavLink(page, { visible: true }); + }); + + test('shows empty states before selection and for a user with no role assigned', async ({ page }) => { + const seedUser: SeedUser = await createSeedUser({ getDbPool, roleId: null, logE2E }); + try { + await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await gotoAssignRolesAndDistricts({ page, logE2E }); + + // Before a user is selected: only the user selector is present. + await expect(page.getByRole('textbox', { name: 'Role' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Assign User Role/Districts' })).toHaveCount(0); + + await selectUserFromDropdown({ + page, + searchText: seedUser.searchToken, + optionTextContains: seedUser.searchToken, + logE2E, + }); + + // After selecting a user with no role/districts: selectors appear. The Role field defaults + // to a synthetic "No role in database yet" option (see getUserRoleObj), which is a truthy + // value, so the Assign button is already rendered here even though nothing has been + // explicitly chosen yet — this is current app behavior, not a bug this test asserts around. + await expect(page.getByRole('textbox', { name: 'Role' })).toHaveValue('No role in database yet'); + await expect(page.getByRole('textbox', { name: 'Select Districts' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Assign User Role/Districts' })).toBeVisible(); + } finally { + await cleanupSeed({ getDbPool, userId: seedUser.id, logE2E }); + } + }); + + test('assigns a role and districts end to end, then updates them', async ({ page }) => { + const seedUser: SeedUser = await createSeedUser({ getDbPool, roleId: null, logE2E }); + const testDistrictCode = getTestDistrictCode(); + try { + const token = await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await gotoAssignRolesAndDistricts({ page, logE2E }); + + await selectUserFromDropdown({ + page, + searchText: seedUser.searchToken, + optionTextContains: seedUser.searchToken, + awaitDistrictsForUserId: seedUser.id, + logE2E, + }); + await selectRoleFromDropdown({ page, roleDescription: 'Staff Agrologist', logE2E }); + await selectDistrictFromDropdown({ page, districtCode: testDistrictCode, logE2E }); + await clickAssignButton(page); + await expectAssignSuccessMessage(page); + + const afterFirstAssign = await getUserRoleAndDistrictsViaApi({ + apiContext, + token, + apiBaseUrl: getApiBaseUrl(), + userId: seedUser.id, + }); + expect(afterFirstAssign.roleId).toBe(ROLE_ID.STAFF_AGROLOGIST); + expect(afterFirstAssign.districtIds).toEqual([testDistrictId]); + + // Re-select the user (page state resets districts to the freshly-fetched value) and + // change the role to a different non-AH role with the same district, to exercise the + // update path (replaces existing user_districts rows rather than duplicating them). + await gotoAssignRolesAndDistricts({ page, logE2E }); + await selectUserFromDropdown({ + page, + searchText: seedUser.searchToken, + optionTextContains: seedUser.searchToken, + awaitDistrictsForUserId: seedUser.id, + logE2E, + }); + await selectRoleFromDropdown({ page, roleDescription: 'Staff Decision Maker', logE2E }); + await clickAssignButton(page); + await expectAssignSuccessMessage(page); + + const afterUpdate = await getUserRoleAndDistrictsViaApi({ + apiContext, + token, + apiBaseUrl: getApiBaseUrl(), + userId: seedUser.id, + }); + expect(afterUpdate.roleId).toBe(ROLE_ID.STAFF_DECISION_MAKER); + expect(afterUpdate.districtIds).toEqual([testDistrictId]); + } finally { + await cleanupSeed({ getDbPool, userId: seedUser.id, logE2E }); + } + }); + + test('prevents assigning districts to a Range Agreement Holder', async ({ page }) => { + const seedUser: SeedUser = await createSeedUser({ getDbPool, roleId: null, logE2E }); + const testDistrictCode = getTestDistrictCode(); + try { + await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await gotoAssignRolesAndDistricts({ page, logE2E }); + + await selectUserFromDropdown({ + page, + searchText: seedUser.searchToken, + optionTextContains: seedUser.searchToken, + awaitDistrictsForUserId: seedUser.id, + logE2E, + }); + await selectDistrictFromDropdown({ page, districtCode: testDistrictCode, logE2E }); + await selectRoleFromDropdown({ page, roleDescription: 'Range Agreement Holder', logE2E }); + + await expectAssignButtonDisabled(page); + await expectAhDistrictErrorMessage(page); + + // Document the current server-side behavior when the UI guard is bypassed: the API + // rejects the request but as an unhandled 500 (bare `throw` in UserController), not a + // clean 4xx. This test locks in today's actual behavior so a future fix is a visible, + // intentional change rather than a silent regression. + const token = await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await assignRoleViaApi({ + apiContext, + token, + apiBaseUrl: getApiBaseUrl(), + userId: seedUser.id, + roleId: ROLE_ID.AGREEMENT_HOLDER, + }); + const response = await assignDistrictsViaApi({ + apiContext, + token, + apiBaseUrl: getApiBaseUrl(), + userId: seedUser.id, + districtIds: testDistrictId ? [testDistrictId] : [], + }); + expect(response.status()).toBe(500); + const body = (await response.json()) as { error?: string; success?: boolean }; + expect(body.success).toBe(false); + } finally { + await cleanupSeed({ getDbPool, userId: seedUser.id, logE2E }); + } + }); + + test('shows an error message when the assign-role request fails', async ({ page }) => { + const seedUser: SeedUser = await createSeedUser({ getDbPool, roleId: null, logE2E }); + try { + await switchRoleAndRelogin({ page, roleCode: 'SA' }); + await gotoAssignRolesAndDistricts({ page, logE2E }); + + // Force the assignRole call to fail so we can assert the UI's error-state rendering + // deterministically, without depending on a real backend/data anomaly. + await page.route('**/v1/user/*/assignRole', (route) => + route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'boom' }) }), + ); + + await selectUserFromDropdown({ + page, + searchText: seedUser.searchToken, + optionTextContains: seedUser.searchToken, + logE2E, + }); + await selectRoleFromDropdown({ page, roleDescription: 'Staff Agrologist', logE2E }); + await clickAssignButton(page); + + await expect(page.getByText('Error assigning to account:', { exact: false })).toBeVisible(); + } finally { + await cleanupSeed({ getDbPool, userId: seedUser.id, logE2E }); + } + }); +}); diff --git a/playwright/e2e/support/manageUsersRuntime.ts b/playwright/e2e/support/manageUsersRuntime.ts new file mode 100644 index 00000000..3afef874 --- /dev/null +++ b/playwright/e2e/support/manageUsersRuntime.ts @@ -0,0 +1,353 @@ +import { randomInt } from 'crypto'; +import { expect, type APIRequestContext, type APIResponse, type Page } from '@playwright/test'; +import type { Pool } from 'pg'; + +export const ASSIGN_ROLES_PERMISSION_ID = 11; + +// Role ids per src/constants/variables.ts USER_ROLE +export const ROLE_ID = { + ADMIN: 1, + STAFF_DECISION_MAKER: 2, + STAFF_AGROLOGIST: 3, + AGREEMENT_HOLDER: 4, + EXTERNAL_AUDITOR: 5, +} as const; + +export type SeedUser = { + id: number; + username: string; + sso_id: string; + // Unique token also embedded in family_name. The "Select user" Autocomplete on the + // Assign Roles/Districts page filters options by full name (given + family name), not by + // sso_id, so tests must search using this token rather than sso_id/username. + searchToken: string; +}; + +type GetDbPool = () => Pool; +type LogE2E = (message: string) => void; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +// ensureRolePermission / revokeRolePermission intentionally mirror manageClientsRuntime.ts so +// both specs manage role_permissions the same idempotent way. Unlike the mirror, this copy +// reports whether it inserted the grant so callers can revoke suite-added residue in afterAll. +export const ensureRolePermission = async ({ + getDbPool, + roleId, + permissionId, + logE2E, +}: { + getDbPool: GetDbPool; + roleId: number; + permissionId: number; + logE2E: LogE2E; +}): Promise => { + const pool = getDbPool(); + try { + const existing = await pool.query( + 'SELECT id FROM role_permissions WHERE role_id = $1 AND permission_id = $2 LIMIT 1', + [roleId, permissionId], + ); + if ((existing.rowCount ?? 0) > 0) { + logE2E(`[E2E] role ${roleId} already carries permission ${permissionId}`); + return false; + } + await pool.query('INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)', [roleId, permissionId]); + logE2E(`[E2E] granted permission ${permissionId} to role ${roleId}`); + return true; + } finally { + await pool.end(); + } +}; + +export const revokeRolePermission = async ({ + getDbPool, + roleId, + permissionId, + logE2E, +}: { + getDbPool: GetDbPool; + roleId: number; + permissionId: number; + logE2E: LogE2E; +}): Promise => { + const pool = getDbPool(); + try { + const result = await pool.query('DELETE FROM role_permissions WHERE role_id = $1 AND permission_id = $2', [ + roleId, + permissionId, + ]); + const hadPermission = (result.rowCount ?? 0) > 0; + logE2E(`[E2E] revoked permission ${permissionId} from role ${roleId} (hadPermission=${hadPermission})`); + return hadPermission; + } finally { + await pool.end(); + } +}; + +// Seeds a user with no role assigned yet (role_id NULL), matching a freshly onboarded +// BCeID/IDIR account that hasn't been triaged by a Staff Admin. The Assign Roles/Districts +// page treats a null roleId as "No role in database yet" (see getUserRoleObj / getUserRole). +export const createSeedUser = async ({ + getDbPool, + roleId = null, + logE2E, +}: { + getDbPool: GetDbPool; + roleId?: number | null; + logE2E: LogE2E; +}): Promise => { + const suffix = randomInt(1_000_000); + const username = `e2e-auto-mu-user-${suffix}`; + const searchToken = `AutoMu${suffix}`; + const pool = getDbPool(); + try { + const result = await pool.query( + `INSERT INTO user_account (username, email, given_name, family_name, sso_id, role_id, active) + VALUES ($1, $2, $3, $4, $5, $6, true) + RETURNING id, username, sso_id`, + [username, `${username}@example.com`, 'E2e', searchToken, `bceid\\${username}`, roleId], + ); + const seedUser = { ...(result.rows[0] as Omit), searchToken }; + logE2E( + `[E2E] created seed manage-users user id=${seedUser.id} sso_id=${seedUser.sso_id} roleId=${roleId ?? 'null'}`, + ); + return seedUser; + } finally { + await pool.end(); + } +}; + +export const getDistrictIdByCode = async ({ + getDbPool, + districtCode, +}: { + getDbPool: GetDbPool; + districtCode: string; +}): Promise => { + const pool = getDbPool(); + try { + const result = await pool.query('SELECT id FROM ref_district WHERE code = $1', [districtCode]); + if (result.rowCount !== 1) { + throw new Error(`Could not find district by code='${districtCode}'`); + } + return result.rows[0].id; + } finally { + await pool.end(); + } +}; + +export const cleanupSeed = async ({ + getDbPool, + userId, + logE2E, +}: { + getDbPool: GetDbPool; + userId: number; + logE2E: LogE2E; +}): Promise => { + const pool = getDbPool(); + try { + await pool.query('DELETE FROM user_districts WHERE user_id = $1', [userId]); + await pool.query('DELETE FROM user_account WHERE id = $1', [userId]); + logE2E(`[E2E] cleaned up seed user ${userId}`); + } finally { + await pool.end(); + } +}; + +export const getUserRoleAndDistrictsViaApi = async ({ + apiContext, + token, + apiBaseUrl, + userId, +}: { + apiContext: APIRequestContext; + token: string; + apiBaseUrl: string; + userId: number; +}): Promise<{ roleId: number | null; districtIds: number[] }> => { + const userResponse = await apiContext.get(`${apiBaseUrl}/v1/user/${userId}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!userResponse.ok()) { + throw new Error(`Failed to load seeded user ${userId} (${userResponse.status()})`); + } + const user = (await userResponse.json()) as { roleId?: number | null }; + + const districtsResponse = await apiContext.get(`${apiBaseUrl}/v1/user/${userId}/districts`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!districtsResponse.ok()) { + // Agreement Holders are rejected outright by getAssociatedDistricts; treat as no districts. + return { roleId: user.roleId ?? null, districtIds: [] }; + } + const districts = (await districtsResponse.json()) as Array<{ id: number }>; + return { roleId: user.roleId ?? null, districtIds: districts.map((d) => d.id) }; +}; + +export const assignRoleViaApi = async ({ + apiContext, + token, + apiBaseUrl, + userId, + roleId, +}: { + apiContext: APIRequestContext; + token: string; + apiBaseUrl: string; + userId: number; + roleId: number; +}): Promise => { + const response = await apiContext.post(`${apiBaseUrl}/v1/user/${userId}/assignRole`, { + headers: { Authorization: `Bearer ${token}` }, + data: { roleId }, + }); + if (!response.ok()) { + throw new Error(`Failed to assign role ${roleId} to user ${userId} (${response.status()})`); + } +}; + +export const assignDistrictsViaApi = async ({ + apiContext, + token, + apiBaseUrl, + userId, + districtIds, +}: { + apiContext: APIRequestContext; + token: string; + apiBaseUrl: string; + userId: number; + districtIds: number[]; +}): Promise => { + return apiContext.post(`${apiBaseUrl}/v1/user/${userId}/assignDistricts`, { + headers: { Authorization: `Bearer ${token}` }, + data: { districts: districtIds.map((id) => ({ id })) }, + }); +}; + +export const gotoAssignRolesAndDistricts = async ({ page, logE2E }: { page: Page; logE2E: LogE2E }): Promise => { + await page.goto('/home'); + await page.getByRole('link', { name: 'Assign Roles/Districts' }).click(); + await expect(page).toHaveURL(/\/assign-roles-and-districts/); + await expect(page.getByText("Select the user who you'd like to edit", { exact: false })).toBeVisible(); + logE2E('[E2E] opened Assign Roles/Districts page'); +}; + +export const expectAssignRolesNavLink = async (page: Page, { visible }: { visible: boolean }): Promise => { + const link = page.getByRole('link', { name: 'Assign Roles/Districts' }); + if (visible) { + await expect(link).toBeVisible(); + } else { + await expect(link).toHaveCount(0); + } +}; + +const selectFromAutocomplete = async ({ + page, + inputLabel, + searchText, + optionTextContains, +}: { + page: Page; + inputLabel: string; + searchText: string; + optionTextContains: string; +}): Promise => { + const input = page.getByRole('textbox', { name: inputLabel }); + await input.click(); + await input.fill(searchText); + const option = page.getByRole('option', { name: new RegExp(escapeRegExp(optionTextContains)) }); + await expect(option.first()).toBeVisible({ timeout: 15000 }); + await option.first().click(); +}; + +export const selectUserFromDropdown = async ({ + page, + searchText, + optionTextContains, + awaitDistrictsForUserId, + logE2E, +}: { + page: Page; + searchText: string; + optionTextContains: string; + /** + * When set, waits for the page's pullRoleAndDistrict fetch + * (GET /v1/district/{userId}) to resolve before returning. That fetch overwrites + * the selected-districts state on resolve, so selecting districts or clicking + * Assign before it lands silently drops them. + */ + awaitDistrictsForUserId?: number; + logE2E: LogE2E; +}): Promise => { + const select = () => selectFromAutocomplete({ page, inputLabel: 'Select user', searchText, optionTextContains }); + if (awaitDistrictsForUserId === undefined) { + await select(); + } else { + const districtsUrl = `/v1/district/${awaitDistrictsForUserId}`; + await Promise.all([ + page.waitForResponse( + (response) => response.url().includes(districtsUrl) && response.request().method() === 'GET', + { timeout: 15000 }, + ), + select(), + ]); + } + logE2E(`[E2E] selected assign-roles user matching ${optionTextContains}`); +}; + +export const selectRoleFromDropdown = async ({ + page, + roleDescription, + logE2E, +}: { + page: Page; + roleDescription: string; + logE2E: LogE2E; +}): Promise => { + await selectFromAutocomplete({ + page, + inputLabel: 'Role', + searchText: roleDescription, + optionTextContains: roleDescription, + }); + logE2E(`[E2E] selected role ${roleDescription}`); +}; + +export const selectDistrictFromDropdown = async ({ + page, + districtCode, + logE2E, +}: { + page: Page; + districtCode: string; + logE2E: LogE2E; +}): Promise => { + await selectFromAutocomplete({ + page, + inputLabel: 'Select Districts', + searchText: districtCode, + optionTextContains: districtCode, + }); + logE2E(`[E2E] selected district ${districtCode}`); +}; + +export const clickAssignButton = async (page: Page): Promise => { + const button = page.getByRole('button', { name: 'Assign User Role/Districts' }); + await expect(button).toBeEnabled(); + await button.click(); +}; + +export const expectAssignButtonDisabled = async (page: Page): Promise => { + await expect(page.getByRole('button', { name: 'Assign User Role/Districts' })).toBeDisabled(); +}; + +export const expectAssignSuccessMessage = async (page: Page): Promise => { + await expect(page.getByText('Role and Districts assigned to account successfully', { exact: true })).toBeVisible(); +}; + +export const expectAhDistrictErrorMessage = async (page: Page): Promise => { + await expect(page.getByText('Range Agreement Holders cannot be assigned districts.', { exact: true })).toBeVisible(); +}; diff --git a/src/components/assignRolesAndDistrictsPage/index.tsx b/src/components/assignRolesAndDistrictsPage/index.tsx index a7ee01cb..62e61a1f 100644 --- a/src/components/assignRolesAndDistrictsPage/index.tsx +++ b/src/components/assignRolesAndDistrictsPage/index.tsx @@ -138,7 +138,7 @@ function AssignRolesAndDistrictsPage() { {usersFromData && ( <> 0 ? users : usersFromData} value={user} openOnFocus @@ -177,7 +177,7 @@ function AssignRolesAndDistrictsPage() { <> {/* Roles */} 0 ? districts : districtsFromData || []} value={selectedDistricts}