From 882785214c3a57d2376a09dcbefef037f523eef2 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Tue, 7 Jul 2026 10:16:52 -0400 Subject: [PATCH 1/9] feat: add @ocom-verification coverage for staff role management (#292) - verification-shared: 12 shared Gherkin scenarios for staff role creation, listing, viewing, updates, permission enforcement, and error cases (duplicates, insufficient privileges, validation); staff-role/staff-user seed data, page objects, and test actors - acceptance-api: staff-role step definitions and GraphQL glue covering backend behavior, persistence, and error handling (27/27) - acceptance-ui: staff portal role management step definitions with component wrappers (23/23) - e2e-tests: end-to-end staff role scenarios combining UI actions with API verification (24/24) - serenity-framework: isChecked support and first-match locator handling in page adapters - e2e-tests: fix worktree env leak in test-environment.ts where local-settings.e2e.json values overrode worktree-scoped ports and OIDC hosts in spawned app servers (test:e2e:worktree now passes) - security: bump brace-expansion@5 to 5.0.7 and webpack-dev-server to ^5.2.6 via overrides; add .snyk ignore for SNYK-JS-BRACEEXPANSION-17706650 on minimatch@3 paths (only fixed in 5.0.7, which has an incompatible API; Docusaurus tooling only) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .snyk | 5 + apps/ui-community/mock-oidc.users.json | 6 +- apps/ui-staff/mock-oidc.users.json | 15 +- .../src/dom/asset-loader-hooks.ts | 12 + .../src/pages/adapters/dom-adapter.ts | 18 + .../src/pages/adapters/playwright-adapter.ts | 10 +- .../src/pages/page-adapter.ts | 6 + .../tests/file-user-store.test.ts | 14 +- .../tests/portal-discovery.test.ts | 6 +- .../acceptance-api/cucumber.js | 1 + .../staff-role/step-definitions/index.ts | 2 + .../staff-role-management.steps.ts | 268 ++++++++++ .../staff-role/support/staff-role-graphql.ts | 127 +++++ .../step-definitions/staff-landing.steps.ts | 14 + .../src/cucumber-lifecycle-hooks.ts | 2 + .../src/mock-application-services.ts | 32 +- .../src/shared/abilities/actor-auth.ts | 35 ++ .../src/shared/abilities/graphql-client.ts | 8 +- .../shared/graphql/staff-role-operations.ts | 119 +++++ .../src/step-definitions/index.ts | 1 + .../acceptance-api/src/world.ts | 2 +- .../acceptance-ui/cucumber.js | 1 + .../acceptance-ui/package.json | 1 + .../staff-role/step-definitions/index.ts | 1 + .../staff-role-management.steps.tsx | 458 ++++++++++++++++++ .../create-staff-landing.steps.ts | 3 + .../src/shared/ocom-component-wrapper.ts | 28 +- .../src/step-definitions/index.ts | 1 + .../acceptance-ui/tsconfig.json | 11 +- .../ocom-verification/e2e-tests/cucumber.js | 1 + .../staff-role/step-definitions/index.ts | 1 + .../staff-role-management.steps.ts | 246 ++++++++++ .../support/staff-portal-session.ts | 65 +++ .../step-definitions/staff-landing.steps.ts | 3 + .../shared/environment/test-environment.ts | 54 ++- .../e2e-tests/src/step-definitions/index.ts | 1 + .../verification-shared/src/pages/index.ts | 2 + .../src/pages/staff-role-form.page.ts | 107 ++++ .../src/pages/staff-roles-list.page.ts | 58 +++ .../staff/staff-role-management.feature | 97 ++++ .../src/test-data/index.ts | 15 +- .../src/test-data/seed/index.ts | 2 + .../src/test-data/seed/seed.ts | 14 +- .../src/test-data/seed/staff-roles.ts | 191 ++++++++ .../src/test-data/seed/staff-users.ts | 50 ++ .../src/test-data/test-actors.ts | 23 + pnpm-lock.yaml | 43 +- pnpm-workspace.yaml | 9 +- 48 files changed, 2117 insertions(+), 72 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/support/staff-portal-session.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts create mode 100644 packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature create mode 100644 packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts create mode 100644 packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts diff --git a/.snyk b/.snyk index 78207903f..4ed19f4a5 100644 --- a/.snyk +++ b/.snyk @@ -126,3 +126,8 @@ ignore: reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.' expires: '2026-07-18T00:00:00.000Z' created: '2026-06-18T00:00:00.000Z' + 'SNYK-JS-BRACEEXPANSION-17706650': + - '* > minimatch@3.1.5 > brace-expansion': + reason: 'Transitive via Docusaurus tooling (serve-handler, rimraf>glob) pinned to minimatch@3, which requires the brace-expansion v1/v2 callable API; the fixed 5.0.7 exports a named function and is not drop-in. Overridden to 2.0.3 (latest compatible). ReDoS on build/dev-server glob patterns from repository-controlled input only.' + expires: '2026-08-07T00:00:00.000Z' + created: '2026-07-07T00:00:00.000Z' diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index ade0b4f42..f5518cba9 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "test@example.com", "sub": "00000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "test@example.com", "given_name": "Test", @@ -15,7 +15,7 @@ "username": "john.doe@example.com", "sub": "00000000-0000-4000-8000-000000000002", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "john.doe@example.com", "given_name": "John", @@ -27,7 +27,7 @@ "username": "owner@test.example", "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee01", "password": "password", - "oidcConfigName": "end-user", + "oidcConfigName": "end-user", "claims": { "email": "owner@test.example", "given_name": "Test", diff --git a/apps/ui-staff/mock-oidc.users.json b/apps/ui-staff/mock-oidc.users.json index 6b635dfb4..8907eee6b 100644 --- a/apps/ui-staff/mock-oidc.users.json +++ b/apps/ui-staff/mock-oidc.users.json @@ -3,7 +3,7 @@ "username": "staff@ownercommunity.onmicrosoft.com", "sub": "10000000-0000-4000-8000-000000000001", "password": "password", - "oidcConfigName": "staff-user", + "oidcConfigName": "staff-user", "claims": { "email": "staff@ownercommunity.onmicrosoft.com", "given_name": "Staff", @@ -11,5 +11,18 @@ "tid": "test-staff-tenant-id", "roles": ["Staff.CaseManager"] } + }, + { + "username": "tech.admin@ownercommunity.onmicrosoft.com", + "sub": "10000000-0000-4000-8000-000000000002", + "password": "password", + "oidcConfigName": "staff-user", + "claims": { + "email": "tech.admin@ownercommunity.onmicrosoft.com", + "given_name": "Tech", + "family_name": "Admin", + "tid": "test-staff-tenant-id", + "roles": ["Staff.TechAdmin"] + } } ] diff --git a/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts index f6652fb22..23c53a5d0 100644 --- a/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts +++ b/packages/cellix/serenity-framework/src/dom/asset-loader-hooks.ts @@ -50,5 +50,17 @@ export async function resolve(specifier: string, context: AssetLoaderResolveCont } } + // Redirect @apollo/client bare and directory subpath imports to their ESM + // entry points. The package's legacy `main` fields point at CJS bundles + // whose named exports Node cannot statically detect. + if (specifier === '@apollo/client' || (specifier.startsWith('@apollo/client/') && !/\.[cm]?js$/.test(specifier))) { + const redirected = specifier === '@apollo/client' ? '@apollo/client/index.js' : `${specifier}/index.js`; + try { + return await nextResolve(redirected, context); + } catch { + // fall through to default + } + } + return nextResolve(specifier, context); } diff --git a/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts index e4f533bcd..823702626 100644 --- a/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/dom-adapter.ts @@ -68,6 +68,10 @@ export class DomElementHandle implements ElementHandle { if (this.element) { const element = this.element; act(() => { + // Fire the full pointer sequence: some widgets (e.g. antd Select) + // open on mousedown rather than click. + fireEvent.mouseDown(element); + fireEvent.mouseUp(element); fireEvent.click(element); }); } @@ -94,6 +98,20 @@ export class DomElementHandle implements ElementHandle { return Promise.resolve(this.element?.getAttribute(name) ?? null); } + inputValue(): Promise { + if (this.element instanceof HTMLInputElement || this.element instanceof HTMLTextAreaElement || this.element instanceof HTMLSelectElement) { + return Promise.resolve(this.element.value); + } + return Promise.resolve(null); + } + + isChecked(): Promise { + if (this.element instanceof HTMLInputElement) { + return Promise.resolve(this.element.checked); + } + return Promise.resolve(false); + } + isVisible(): Promise { return Promise.resolve(this.element !== null); } diff --git a/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts index 8c4563fa6..b84d8c6a4 100644 --- a/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts @@ -30,6 +30,14 @@ export class PlaywrightElementHandle implements ElementHandle { return this.locator.getAttribute(name); } + inputValue(): Promise { + return this.locator.inputValue(); + } + + isChecked(): Promise { + return this.locator.isChecked(); + } + isVisible(): Promise { return this.locator.isVisible(); } @@ -81,7 +89,7 @@ export class PlaywrightPageAdapter implements PageAdapter { } locator(selector: string): ElementHandle { - return new PlaywrightElementHandle(this.page.locator(selector)); + return new PlaywrightElementHandle(this.page.locator(selector).first()); } async locatorAll(selector: string): Promise { diff --git a/packages/cellix/serenity-framework/src/pages/page-adapter.ts b/packages/cellix/serenity-framework/src/pages/page-adapter.ts index de24f5a37..e99e573eb 100644 --- a/packages/cellix/serenity-framework/src/pages/page-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/page-adapter.ts @@ -21,6 +21,12 @@ export interface ElementHandle { /** Read an element attribute, or `null` when the attribute is missing. */ getAttribute(name: string): Promise; + /** Read the current value of an input-like control, or `null` when unavailable. */ + inputValue(): Promise; + + /** Return whether a checkbox-like control is currently checked. */ + isChecked(): Promise; + /** Return whether the element is currently visible to the adapter runtime. */ isVisible(): Promise; diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts index 04772575f..f6fdee1ca 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/file-user-store.test.ts @@ -126,9 +126,7 @@ describe('file user store oidcConfigName filtering', () => { it('users without oidcConfigName are visible to all scoped stores', async () => { if (!tmp) throw new Error('tmp not created'); - writeUsers(tmp, 'mock-oidc.users.json', [ - { username: 'shared@example.com', sub: 'sub-shared' }, - ]); + writeUsers(tmp, 'mock-oidc.users.json', [{ username: 'shared@example.com', sub: 'sub-shared' }]); const endStore = createFileUserStore(tmp, 'end-user'); const staffStore = createFileUserStore(tmp, 'staff-user'); @@ -191,15 +189,7 @@ describe('file user store oidcConfigName filtering', () => { expect(users).toHaveLength(1); expect(users[0]?.username).toBe('good@example.com'); expect(warnSpy.length).toBeGreaterThan(0); - expect( - warnSpy.some((args) => - (args as unknown[]).some( - (arg) => - typeof arg === 'string' && - arg.includes('"oidcConfigName" must be a string'), - ), - ), - ).toBe(true); + expect(warnSpy.some((args) => (args as unknown[]).some((arg) => typeof arg === 'string' && arg.includes('"oidcConfigName" must be a string')))).toBe(true); } finally { console.warn = origWarn; } diff --git a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts index d364915c8..565ece8ac 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/tests/portal-discovery.test.ts @@ -569,11 +569,7 @@ describe('discoverPortalConfigs', () => { envVars: { clientId: 'VITE_APP_PORTAL_STAFF_CLIENTID', redirectUri: 'VITE_APP_PORTAL_STAFF_REDIRECT' }, }, ]); - writeEnv( - tmp, - 'ui-portal/.env', - 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n', - ); + writeEnv(tmp, 'ui-portal/.env', 'VITE_APP_PORTAL_END_CLIENTID=end-cid\nVITE_APP_PORTAL_END_REDIRECT=https://portal/end/cb\nVITE_APP_PORTAL_STAFF_CLIENTID=staff-cid\nVITE_APP_PORTAL_STAFF_REDIRECT=https://portal/staff/cb\n'); const portals = discoverPortalConfigs(tmp); expect(portals).toHaveLength(2); diff --git a/packages/ocom-verification/acceptance-api/cucumber.js b/packages/ocom-verification/acceptance-api/cucumber.js index b5fb90c59..3c059af85 100644 --- a/packages/ocom-verification/acceptance-api/cucumber.js +++ b/packages/ocom-verification/acceptance-api/cucumber.js @@ -2,6 +2,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], + tags: 'not @ui-only and not @e2e-only and not @skip-api', import: ['src/world.ts', 'src/step-definitions/index.ts'], format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-api.json', 'html:./reports/cucumber-report-api.html'], formatOptions: { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts new file mode 100644 index 000000000..1012eafed --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/index.ts @@ -0,0 +1,2 @@ +// Staff role management context step definitions +import './staff-role-management.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts new file mode 100644 index 000000000..28fd0fd64 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -0,0 +1,268 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { actorCalled } from '@serenity-js/core'; +import { setActorToken } from '../../../shared/abilities/actor-auth.ts'; +import { + assignStaffRole, + createStaffRole, + findStaffRoleByName, + getStaffRoleById, + listStaffRoles, + listStaffUsers, + type MutationStatus, + STAFF_ROLE_PERMISSION_GROUP_BY_KEY, + type StaffRolePermissionGroupName, + updateStaffRole, +} from '../support/staff-role-graphql.ts'; + +const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin']; + +interface StaffRoleScenarioState { + lastActorName: string; + lastStatus: MutationStatus | undefined; + lastError: string | undefined; + listedRoleNames: string[] | undefined; + viewedRole: { roleName: string; enterpriseAppRole: string } | undefined; + baselineRoleCount: number | undefined; +} + +const state: StaffRoleScenarioState = { + lastActorName: 'Alice', + lastStatus: undefined, + lastError: undefined, + listedRoleNames: undefined, + viewedRole: undefined, + baselineRoleCount: undefined, +}; + +function resetState(actorName: string): void { + state.lastActorName = actorName; + state.lastStatus = undefined; + state.lastError = undefined; + state.listedRoleNames = undefined; + state.viewedRole = undefined; + state.baselineRoleCount = undefined; +} + +function recordOutcome(status: MutationStatus): void { + state.lastStatus = status; + state.lastError = status.success ? undefined : (status.errorMessage ?? 'Unknown error'); +} + +function currentActor(actorName?: string) { + const resolved = actorName ? ActorName.resolve(actorName, { defaultName: state.lastActorName }) : state.lastActorName; + state.lastActorName = resolved; + return actorCalled(resolved); +} + +async function requireRoleByName(actorName: string, roleName: string) { + const actor = currentActor(actorName); + const role = await findStaffRoleByName(actor, roleName); + if (!role) { + throw new Error(`Staff role "${roleName}" was not found`); + } + return role; +} + +Given('{word} is not authenticated', (actorName: string) => { + resetState(actorName); + setActorToken(actorName, null); + actorCalled(actorName); +}); + +Given('a staff role named {string} exists', async (roleName: string) => { + const actor = currentActor(); + const existing = await findStaffRoleByName(actor, roleName); + if (!existing) { + const { status } = await createStaffRole(actor, { roleName, enterpriseAppRole: 'Staff.CaseManager' }); + if (!status.success) { + throw new Error(`Could not set up staff role "${roleName}": ${status.errorMessage}`); + } + } +}); + +When('{word} views the staff roles list', async (actorName: string) => { + const actor = currentActor(actorName); + const roles = await listStaffRoles(actor); + state.listedRoleNames = roles.map((role) => role.roleName); +}); + +When('{word} attempts to view the staff roles list', async (actorName: string) => { + const actor = currentActor(actorName); + try { + const roles = await listStaffRoles(actor); + state.listedRoleNames = roles.map((role) => role.roleName); + state.lastError = undefined; + } catch (error) { + state.lastError = error instanceof Error ? error.message : String(error); + } +}); + +When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { + const role = await requireRoleByName(actorName, roleName); + const details = await getStaffRoleById(currentActor(), role.id); + if (!details) { + throw new Error(`Staff role "${roleName}" (${role.id}) could not be retrieved by id`); + } + state.viewedRole = { roleName: details.roleName, enterpriseAppRole: details.enterpriseAppRole }; +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { + const actor = currentActor(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash<{ roleName: string; enterpriseAppRole?: string }>(); + recordOutcome((await createStaffRole(actor, { roleName: details.roleName, enterpriseAppRole: details.enterpriseAppRole })).status); +}); + +When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { + const actor = currentActor(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash<{ roleName?: string; enterpriseAppRole?: string }>(); + state.baselineRoleCount = (await listStaffRoles(actor)).length; + recordOutcome((await createStaffRole(actor, { roleName: details.roleName ?? '', enterpriseAppRole: details.enterpriseAppRole })).status); +}); + +When('{word} creates a staff role named {string} with permissions:', async (actorName: string, roleName: string, dataTable: DataTable) => { + const actor = currentActor(actorName); + const flatPermissions = GherkinDataTable.from(dataTable).rowsHash>(); + const permissions = Object.fromEntries(Object.entries(flatPermissions).map(([key, value]) => [key, value === 'true'])); + recordOutcome((await createStaffRole(actor, { roleName, enterpriseAppRole: 'Staff.CaseManager', permissions })).status); +}); + +When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { + const role = await requireRoleByName(actorName, currentName); + recordOutcome((await updateStaffRole(currentActor(), { id: role.id, roleName: newName, enterpriseAppRole: role.enterpriseAppRole })).status); +}); + +When('{word} grants the permission {string} to the staff role {string}', async (actorName: string, permissionKey: string, roleName: string) => { + const role = await requireRoleByName(actorName, roleName); + recordOutcome( + ( + await updateStaffRole(currentActor(), { + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + permissions: { [permissionKey]: true }, + }) + ).status, + ); +}); + +When('{word} assigns the staff role {string} to the staff user {string}', async (actorName: string, roleName: string, staffUserDisplayName: string) => { + const role = await requireRoleByName(actorName, roleName); + const actor = currentActor(); + const staffUsers = await listStaffUsers(actor); + const staffUser = staffUsers.find((user) => user.displayName === staffUserDisplayName); + if (!staffUser) { + throw new Error(`Staff user "${staffUserDisplayName}" was not found`); + } + recordOutcome((await assignStaffRole(actor, staffUser.id, role.id)).status); +}); + +Then('the staff roles list should include the default staff roles', () => { + const listed = state.listedRoleNames; + if (!listed) { + throw new Error('No staff roles list was retrieved. Did the actor view the staff roles list first?'); + } + const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); + if (missing.length > 0) { + throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); + } +}); + +Then('the staff roles list should include {string}', async (roleName: string) => { + const roles = await listStaffRoles(currentActor()); + const names = roles.map((role) => role.roleName); + if (!names.includes(roleName)) { + throw new Error(`Expected staff roles list to include "${roleName}", but got: [${names.join(', ')}]`); + } +}); + +Then('she should see the staff role name {string}', (expectedName: string) => { + if (state.viewedRole?.roleName !== expectedName) { + throw new Error(`Expected staff role name "${expectedName}", but got "${state.viewedRole?.roleName}"`); + } +}); + +Then('she should see the enterprise app role {string}', (expectedEnterpriseAppRole: string) => { + if (state.viewedRole?.enterpriseAppRole !== expectedEnterpriseAppRole) { + throw new Error(`Expected enterprise app role "${expectedEnterpriseAppRole}", but got "${state.viewedRole?.enterpriseAppRole}"`); + } +}); + +Then('the staff role should be created successfully', () => { + if (state.lastStatus?.success !== true) { + throw new Error(`Expected staff role creation to succeed, but it failed: ${state.lastStatus?.errorMessage ?? 'no mutation was performed'}`); + } +}); + +Then('the staff role should be updated successfully', () => { + if (state.lastStatus?.success !== true) { + throw new Error(`Expected staff role update to succeed, but it failed: ${state.lastStatus?.errorMessage ?? 'no mutation was performed'}`); + } +}); + +Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { + const role = await findStaffRoleByName(currentActor(), roleName); + if (!role) { + throw new Error(`Staff role "${roleName}" was not found`); + } + const group: StaffRolePermissionGroupName | undefined = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[permissionKey]; + if (!group) { + throw new Error(`Unknown staff role permission "${permissionKey}"`); + } + if (role.permissions[group]?.[permissionKey] !== true) { + throw new Error(`Expected staff role "${roleName}" to have permission "${permissionKey}" granted, but it is not`); + } +}); + +Then('the staff user {string} should have the staff role {string}', async (staffUserDisplayName: string, roleName: string) => { + const staffUsers = await listStaffUsers(currentActor()); + const staffUser = staffUsers.find((user) => user.displayName === staffUserDisplayName); + if (!staffUser) { + throw new Error(`Staff user "${staffUserDisplayName}" was not found`); + } + if (staffUser.role?.roleName !== roleName) { + throw new Error(`Expected staff user "${staffUserDisplayName}" to have role "${roleName}", but got "${staffUser.role?.roleName ?? 'none'}"`); + } +}); + +Then('she should see a staff role error containing {string}', (expectedFragment: string) => { + if (!state.lastError) { + throw new Error(`Expected a staff role error containing "${expectedFragment}", but no error was captured`); + } + if (!state.lastError.toLowerCase().includes(expectedFragment.toLowerCase())) { + throw new Error(`Expected staff role error to contain "${expectedFragment}", but got: "${state.lastError}"`); + } +}); + +Then('she should see a staff role validation error for {string}', (fieldName: string) => { + if (!state.lastError) { + throw new Error(`Expected a validation error for "${fieldName}", but no error was captured`); + } + const isFieldMentioned = state.lastError.toLowerCase().includes(fieldName.toLowerCase()); + const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|too short|too long|wrong.*type/i.test(state.lastError); + if (!isFieldMentioned && !isValidationPattern) { + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${state.lastError}"`); + } +}); + +Then('no additional staff role should be created', async () => { + if (state.baselineRoleCount === undefined) { + throw new Error('No baseline staff role count was captured. Did the actor attempt to create a staff role first?'); + } + const roles = await listStaffRoles(currentActor()); + if (roles.length !== state.baselineRoleCount) { + throw new Error(`Expected staff role count to remain ${state.baselineRoleCount}, but got ${roles.length}`); + } +}); + +Then('the staff roles request should be rejected as unauthorized', () => { + if (!state.lastError) { + throw new Error('Expected the staff roles request to be rejected, but it succeeded'); + } + if (!state.lastError.toLowerCase().includes('unauthorized')) { + throw new Error(`Expected an unauthorized error, but got: "${state.lastError}"`); + } +}); + +export { resetState as resetStaffRoleScenarioState }; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts new file mode 100644 index 000000000..f57148bd9 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts @@ -0,0 +1,127 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import type { Actor } from '@serenity-js/core'; +import { STAFF_ROLE_BY_ID_QUERY, STAFF_ROLE_CREATE_MUTATION, STAFF_ROLE_UPDATE_MUTATION, STAFF_ROLES_QUERY, STAFF_USER_ASSIGN_ROLE_MUTATION, STAFF_USERS_QUERY } from '../../../shared/graphql/staff-role-operations.ts'; + +export type StaffRolePermissionGroupName = 'communityPermissions' | 'financePermissions' | 'staffRolePermissions' | 'techAdminPermissions' | 'userPermissions'; + +interface StaffRoleResult { + id: string; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + permissions: Record>; +} + +interface StaffUserResult { + id: string; + displayName: string; + externalId: string; + role: { id: string; roleName: string } | null; +} + +export interface MutationStatus { + success: boolean; + errorMessage?: string | null; +} + +/** Maps flat permission command keys to their GraphQL permission group. */ +export const STAFF_ROLE_PERMISSION_GROUP_BY_KEY: Record = { + canManageCommunities: 'communityPermissions', + canManageStaffRolesAndPermissions: 'communityPermissions', + canManageAllCommunities: 'communityPermissions', + canDeleteCommunities: 'communityPermissions', + canChangeCommunityOwner: 'communityPermissions', + canReIndexSearchCollections: 'communityPermissions', + canManageFinance: 'financePermissions', + canViewGLBatchSummaries: 'financePermissions', + canViewFinanceConfigs: 'financePermissions', + canCreateFinanceConfigs: 'financePermissions', + canViewRoles: 'staffRolePermissions', + canAddRole: 'staffRolePermissions', + canEditRole: 'staffRolePermissions', + canRemoveRole: 'staffRolePermissions', + canManageTechAdmin: 'techAdminPermissions', + canViewDatabaseExplorer: 'techAdminPermissions', + canViewBlobExplorer: 'techAdminPermissions', + canViewQueueDashboard: 'techAdminPermissions', + canSendQueueMessages: 'techAdminPermissions', + canManageUsers: 'userPermissions', + canAssignStaffRoles: 'userPermissions', + canViewStaffUsers: 'userPermissions', +}; + +/** Convert flat `key -> boolean` pairs into the grouped StaffRole permissions input shape. */ +function toPermissionsInput(flatPermissions: Record): Record> { + const input: Record> = {}; + for (const [key, value] of Object.entries(flatPermissions)) { + const group = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[key]; + if (!group) { + throw new Error(`Unknown staff role permission "${key}"`); + } + input[group] = { ...input[group], [key]: value }; + } + return input; +} + +export async function listStaffRoles(actor: Actor): Promise { + const response = await GraphQLClient.as(actor).execute(STAFF_ROLES_QUERY); + return response.data['staffRoles'] as StaffRoleResult[]; +} + +export async function getStaffRoleById(actor: Actor, id: string): Promise { + const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_BY_ID_QUERY, { id }); + return (response.data['staffRoleById'] as StaffRoleResult | null) ?? null; +} + +export async function findStaffRoleByName(actor: Actor, roleName: string): Promise { + const roles = await listStaffRoles(actor); + return roles.find((role) => role.roleName === roleName); +} + +interface StaffRoleCreateRequest { + roleName: string; + enterpriseAppRole?: string | undefined; + permissions?: Record | undefined; +} + +export async function createStaffRole(actor: Actor, request: StaffRoleCreateRequest): Promise<{ status: MutationStatus; staffRole: StaffRoleResult | null }> { + const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_CREATE_MUTATION, { + input: { + roleName: request.roleName, + enterpriseAppRole: request.enterpriseAppRole ?? null, + permissions: request.permissions ? toPermissionsInput(request.permissions) : null, + }, + }); + return response.data['staffRoleCreate'] as { status: MutationStatus; staffRole: StaffRoleResult | null }; +} + +interface StaffRoleUpdateRequest { + id: string; + roleName: string; + enterpriseAppRole: string; + permissions?: Record | undefined; +} + +export async function updateStaffRole(actor: Actor, request: StaffRoleUpdateRequest): Promise<{ status: MutationStatus; staffRole: StaffRoleResult | null }> { + const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_UPDATE_MUTATION, { + input: { + id: request.id, + roleName: request.roleName, + enterpriseAppRole: request.enterpriseAppRole, + permissions: request.permissions ? toPermissionsInput(request.permissions) : null, + }, + }); + return response.data['staffRoleUpdate'] as { status: MutationStatus; staffRole: StaffRoleResult | null }; +} + +export async function listStaffUsers(actor: Actor): Promise { + const response = await GraphQLClient.as(actor).execute(STAFF_USERS_QUERY); + return response.data['staffUsers'] as StaffUserResult[]; +} + +export async function assignStaffRole(actor: Actor, staffUserId: string, roleId: string): Promise<{ status: MutationStatus; staffUser: StaffUserResult | null }> { + const response = await GraphQLClient.as(actor).execute(STAFF_USER_ASSIGN_ROLE_MUTATION, { + input: { staffUserId, roleId }, + }); + return response.data['staffUserAssignRole'] as { status: MutationStatus; staffUser: StaffUserResult | null }; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts index 475769e42..840e108e5 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -1,6 +1,8 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; +import { setActorToken, staffTokenFor } from '../../../shared/abilities/actor-auth.ts'; +import { resetStaffRoleScenarioState } from '../../staff-role/step-definitions/staff-role-management.steps.ts'; type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; @@ -31,12 +33,22 @@ const normalizeRole = (roleName: string): StaffBusinessRole => { const roleForActor = (actorName: string): StaffBusinessRole => actorRoles.get(actorName) ?? 'case manager'; +/** Shared test actor whose token carries the enterprise app roles for each staff business role. */ +const staffActorByBusinessRole: Record = { + finance: actors.StaffUser.name, + 'tech admin': actors.TechAdminStaff.name, + 'service line owner': actors.StaffUser.name, + 'case manager': actors.CaseManagerStaff.name, +}; + const resolveFinanceWorkspaceRoute = (role: StaffBusinessRole): string => (role === 'finance' || role === 'tech admin' ? '/staff/finance' : '/unauthorized'); Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); + resetStaffRoleScenarioState(actorName); + setActorToken(actorName, staffTokenFor(staffActorByBusinessRole['case manager'])); await actor.attemptsTo(notes().set('targetRoute', '')); }); @@ -45,6 +57,8 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); + resetStaffRoleScenarioState(actorName); + setActorToken(actorName, staffTokenFor(staffActorByBusinessRole[role])); await actor.attemptsTo(notes().set('targetRoute', '')); }); diff --git a/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts index 8def59785..3e66f8b97 100644 --- a/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts +++ b/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts @@ -3,6 +3,7 @@ import { getTimeout } from '@cellix/serenity-framework/settings'; import type { IWorld } from '@cucumber/cucumber'; import { isAgent } from 'std-env'; import { infrastructure } from './infrastructure.ts'; +import { clearActorTokens } from './shared/abilities/actor-auth.ts'; import type { CellixApiWorld } from './world.ts'; let printedSuiteHeader = false; @@ -18,6 +19,7 @@ export function registerLifecycleHooks(): void { console.log(' - Community context\n'); } + clearActorTokens(); await world.init(); }, after: async (world) => { diff --git a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts index 308365adb..3a2fedc8b 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -4,10 +4,11 @@ import type { ApiContextSpec } from '@ocom/context-spec'; import { Persistence } from '@ocom/persistence'; import type { ServiceApolloServer } from '@ocom/service-apollo-server'; import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBlobsRequest, UploadTextBlobRequest } from '@ocom/service-blob-storage'; -import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { ServiceMongoose } from '@ocom/service-mongoose'; +import type { EndUserUpdatePayload, QueueStorageOperations } from '@ocom/service-queue-storage'; import type { TokenValidation, TokenValidationResult } from '@ocom/service-token-validation'; -import { actors } from '@ocom-verification/verification-shared/test-data'; +import { actors, getActor } from '@ocom-verification/verification-shared/test-data'; +import { STAFF_TOKEN_PREFIX } from './shared/abilities/actor-auth.ts'; interface RecordedCommunityCreationMessage { communityId: string; @@ -22,7 +23,22 @@ const communityCreationMessages: RecordedCommunityCreationMessage[] = []; function createMockTokenValidation(): TokenValidation { return { - verifyJwt: (_token: string): Promise | null> => { + verifyJwt: (token: string): Promise | null> => { + // Staff tokens (e.g. "staff:TechAdminStaff") resolve to a StaffPortal principal + // whose enterprise app roles come from the shared test actor definition. + if (token.startsWith(STAFF_TOKEN_PREFIX)) { + const staffActor = getActor(token.slice(STAFF_TOKEN_PREFIX.length)); + return Promise.resolve({ + verifiedJwt: { + given_name: staffActor.givenName, + family_name: staffActor.familyName, + email: staffActor.email, + sub: staffActor.externalId, + roles: staffActor.roles ?? [], + } as unknown as ClaimsType, + openIdConfigKey: 'StaffPortal', + }); + } const actor = actors.CommunityOwner; return Promise.resolve({ verifiedJwt: { @@ -133,11 +149,7 @@ export function createMockApplicationServicesFactory(serviceMongoose: ServiceMon queueStorageService, }; - const mockApplicationServicesFactory = buildApplicationServicesFactory(apiContextSpec); - - return { - forRequest: (_rawAuthHeader, hints) => { - return mockApplicationServicesFactory.forRequest('Bearer test-token', hints); - }, - }; + // Pass the raw auth header through so scenarios can act as differently + // privileged (or unauthenticated) principals via per-actor test tokens. + return buildApplicationServicesFactory(apiContextSpec); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts new file mode 100644 index 000000000..cfe1afb82 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts @@ -0,0 +1,35 @@ +/** + * Per-actor authentication registry for the API acceptance suite. + * + * Step definitions register a test token per Serenity actor name; the GraphQL + * client ability resolves the token lazily on every request, so a single cast + * supports scenarios with differently privileged (or unauthenticated) actors. + */ + +const tokensByActorName = new Map(); + +/** Default token: resolves to the CommunityOwner AccountPortal principal in the mock token validation. */ +const DEFAULT_TEST_AUTH_TOKEN = 'Bearer test-token'; + +/** Prefix for tokens that resolve to a staff principal in the mock token validation. */ +export const STAFF_TOKEN_PREFIX = 'staff:'; + +/** Build the token for a staff test actor (e.g. `staff:TechAdminStaff`). */ +export function staffTokenFor(actorName: string): string { + return `${STAFF_TOKEN_PREFIX}${actorName}`; +} + +/** Register the auth token used by the given Serenity actor. Pass `null` for an unauthenticated actor. */ +export function setActorToken(actorName: string, token: string | null): void { + tokensByActorName.set(actorName, token); +} + +/** Resolve the auth token for an actor; defaults to the CommunityOwner token when unregistered. */ +export function getActorToken(actorName: string): string | null { + return tokensByActorName.has(actorName) ? (tokensByActorName.get(actorName) ?? null) : DEFAULT_TEST_AUTH_TOKEN; +} + +/** Clear all registered actor tokens (called between scenarios). */ +export function clearActorTokens(): void { + tokensByActorName.clear(); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts index 6dd954aea..e3d0e6e31 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts @@ -1,10 +1,12 @@ import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { getActorToken } from './actor-auth.ts'; -export function createGraphQLClientAbility(apiUrl: string): GraphQLClient { +export function createGraphQLClientAbility(apiUrl: string, actorName: string): GraphQLClient { return new GraphQLClient({ apiUrl, - headers: { - Authorization: 'Bearer test-token', + headers: () => { + const token = getActorToken(actorName); + return token === null ? {} : { Authorization: token }; }, }); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts new file mode 100644 index 000000000..47c00e520 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts @@ -0,0 +1,119 @@ +const STAFF_ROLE_FIELDS = ` + id + roleName + enterpriseAppRole + isDefault + permissions { + communityPermissions { + canManageCommunities + canManageStaffRolesAndPermissions + canManageAllCommunities + canDeleteCommunities + canChangeCommunityOwner + canReIndexSearchCollections + } + financePermissions { + canManageFinance + canViewGLBatchSummaries + canViewFinanceConfigs + canCreateFinanceConfigs + } + staffRolePermissions { + canViewRoles + canAddRole + canEditRole + canRemoveRole + } + techAdminPermissions { + canManageTechAdmin + canViewDatabaseExplorer + canViewBlobExplorer + canViewQueueDashboard + canSendQueueMessages + } + userPermissions { + canManageUsers + canAssignStaffRoles + canViewStaffUsers + } + } + createdAt + updatedAt +`; + +export const STAFF_ROLES_QUERY = ` + query StaffRoles { + staffRoles { + ${STAFF_ROLE_FIELDS} + } + } +`; + +export const STAFF_ROLE_BY_ID_QUERY = ` + query StaffRoleById($id: ObjectID!) { + staffRoleById(id: $id) { + ${STAFF_ROLE_FIELDS} + } + } +`; + +export const STAFF_ROLE_CREATE_MUTATION = ` + mutation StaffRoleCreate($input: StaffRoleCreateInput!) { + staffRoleCreate(input: $input) { + status { + success + errorMessage + } + staffRole { + ${STAFF_ROLE_FIELDS} + } + } + } +`; + +export const STAFF_ROLE_UPDATE_MUTATION = ` + mutation StaffRoleUpdate($input: StaffRoleUpdateInput!) { + staffRoleUpdate(input: $input) { + status { + success + errorMessage + } + staffRole { + ${STAFF_ROLE_FIELDS} + } + } + } +`; + +export const STAFF_USERS_QUERY = ` + query StaffUsers { + staffUsers { + id + displayName + externalId + role { + id + roleName + } + } + } +`; + +export const STAFF_USER_ASSIGN_ROLE_MUTATION = ` + mutation StaffUserAssignRole($input: StaffUserAssignRoleInput!) { + staffUserAssignRole(input: $input) { + status { + success + errorMessage + } + staffUser { + id + displayName + role { + id + roleName + } + } + } + } +`; diff --git a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts index cba25d6cc..dfa3041fb 100644 --- a/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/step-definitions/index.ts @@ -6,3 +6,4 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; +import '../contexts/staff-role/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index a19acf271..e814c0e6b 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -16,7 +16,7 @@ export const CellixApiWorld = registerManagedSerenityWorld({ createCast: (state) => new SerenityCast({ useNotepad: true, - abilities: [() => createGraphQLClientAbility(graphqlUrl(state)), () => createCommunityAbility()], + abilities: [(actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), () => createCommunityAbility()], }), }); diff --git a/packages/ocom-verification/acceptance-ui/cucumber.js b/packages/ocom-verification/acceptance-ui/cucumber.js index 90638142e..23810568a 100644 --- a/packages/ocom-verification/acceptance-ui/cucumber.js +++ b/packages/ocom-verification/acceptance-ui/cucumber.js @@ -2,6 +2,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], + tags: 'not @api-only and not @e2e-only and not @skip-ui', import: ['src/world.ts', 'src/step-definitions/index.ts'], format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report-ui.json', 'html:./reports/cucumber-report-ui.html'], formatOptions: { diff --git a/packages/ocom-verification/acceptance-ui/package.json b/packages/ocom-verification/acceptance-ui/package.json index d0943ce3d..b6dc79d0f 100644 --- a/packages/ocom-verification/acceptance-ui/package.json +++ b/packages/ocom-verification/acceptance-ui/package.json @@ -23,6 +23,7 @@ "react": "^19.1.0", "react-dom": "^19.1.0", "react-oidc-context": "^3.3.0", + "react-router-dom": "catalog:", "std-env": "^4.0.0" }, "devDependencies": { diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts new file mode 100644 index 000000000..eaa3bb526 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-role-management.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx new file mode 100644 index 000000000..367d94e65 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx @@ -0,0 +1,458 @@ +import type { MockedResponse } from '@apollo/client/testing'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { StaffRoleByIdDocument, StaffRoleCreateDocument, StaffRolesListDocument, StaffRoleUpdateDocument } from '../../../../../../ocom/ui-staff-route-user-management/src/generated.tsx'; +import { StaffRolesPage } from '../../../../../../ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx'; +import type { StaffAuth } from '../../../../../../ocom/ui-staff-shared/src/staff-route-shell.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { StaffUiNotes } from '../../staff/abilities/staff-types.ts'; + +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin']; + +interface MockPermissions { + __typename: 'StaffRolePermissions'; + communityPermissions: { + __typename: 'StaffRoleCommunityPermissions'; + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageAllCommunities: boolean; + canDeleteCommunities: boolean; + canChangeCommunityOwner: boolean; + canReIndexSearchCollections: boolean; + }; + userPermissions: { + __typename: 'StaffRoleUserPermissions'; + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + }; + staffRolePermissions: { + __typename: 'StaffRoleRolePermissions'; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; + }; + financePermissions: { + __typename: 'StaffRoleFinancePermissions'; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; + }; + techAdminPermissions: { + __typename: 'StaffRoleTechAdminPermissions'; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; + }; +} + +interface MockStaffRole { + id: string; + roleName: string; + enterpriseAppRole: string; + createdAt: string; + updatedAt: string; + permissions: MockPermissions; +} + +const emptyPermissions = (): MockPermissions => ({ + __typename: 'StaffRolePermissions', + communityPermissions: { + __typename: 'StaffRoleCommunityPermissions', + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + userPermissions: { + __typename: 'StaffRoleUserPermissions', + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }, + staffRolePermissions: { + __typename: 'StaffRoleRolePermissions', + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + financePermissions: { + __typename: 'StaffRoleFinancePermissions', + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + __typename: 'StaffRoleTechAdminPermissions', + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, +}); + +const staffAuthByRole: Record = { + 'tech admin': { + name: 'Tech Admin Staff', + enterpriseAppRole: 'Staff.TechAdmin', + permissions: { + canManageStaffRolesAndPermissions: true, + canManageTechAdmin: true, + canViewRoles: true, + canAddRole: true, + canEditRole: true, + canRemoveRole: true, + canManageUsers: true, + canAssignStaffRoles: true, + canViewStaffUsers: true, + }, + }, + 'service line owner': { name: 'Service Line Owner Staff', enterpriseAppRole: 'Staff.ServiceLineOwner', permissions: {} }, + finance: { name: 'Finance Staff', enterpriseAppRole: 'Staff.Finance', permissions: { canManageFinance: true } }, + 'case manager': { name: 'Case Manager Staff', enterpriseAppRole: 'Staff.CaseManager', permissions: {} }, +}; + +// Mock backend state shared by the dynamic Apollo mocks below. +let uiRoles: MockStaffRole[] = []; +let baselineRoleCount = 0; +let currentAuthRole: StaffBusinessRole = 'tech admin'; +let lastMutation: { success: boolean; errorMessage?: string | null } | undefined; +let currentPath = '/'; +let idCounter = 0; + +const nextRoleId = (): string => { + idCounter += 1; + return idCounter.toString(16).padStart(24, '0'); +}; + +const addRole = (roleName: string, enterpriseAppRole: string): MockStaffRole => { + const now = new Date().toISOString(); + const role: MockStaffRole = { id: nextRoleId(), roleName, enterpriseAppRole, createdAt: now, updatedAt: now, permissions: emptyPermissions() }; + uiRoles.push(role); + return role; +}; + +/** + * Resets the mocked staff-role backend for a new scenario and records which + * business role the acting staff user holds. Called from the shared staff + * authentication Givens in the staff context. + */ +export function resetStaffRoleUiState(businessRole: StaffBusinessRole): void { + uiRoles = []; + baselineRoleCount = 0; + lastMutation = undefined; + currentPath = '/'; + currentAuthRole = businessRole; + for (const roleName of DEFAULT_STAFF_ROLE_NAMES) { + addRole(roleName, `Staff.${roleName.replace('Default ', '').replaceAll(' ', '')}`); + } +} + +const toListFields = (role: MockStaffRole) => ({ + __typename: 'StaffRole' as const, + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + createdAt: role.createdAt, + updatedAt: role.updatedAt, +}); + +const toEditFields = (role: MockStaffRole) => ({ + __typename: 'StaffRole' as const, + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + permissions: role.permissions, +}); + +interface StaffRoleMutationInput { + id?: string; + roleName?: string; + enterpriseAppRole?: string | null; +} + +const buildMocks = (): MockedResponse[] => [ + { + request: { query: StaffRolesListDocument }, + maxUsageCount: Number.POSITIVE_INFINITY, + result: () => ({ data: { staffRoles: uiRoles.map(toListFields) } }), + }, + { + request: { query: StaffRoleByIdDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { id?: string }) => { + const role = uiRoles.find((candidate) => candidate.id === variables.id); + return { data: { staffRoleById: role ? toEditFields(role) : null } }; + }, + }, + { + request: { query: StaffRoleCreateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: StaffRoleMutationInput }) => { + const roleName = variables.input?.roleName ?? ''; + if (uiRoles.some((candidate) => candidate.roleName === roleName)) { + lastMutation = { success: false, errorMessage: `Staff role with name ${roleName} already exists` }; + return { + data: { + staffRoleCreate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, + staffRole: null, + }, + }, + }; + } + const role = addRole(roleName, variables.input?.enterpriseAppRole ?? ''); + lastMutation = { success: true }; + return { + data: { + staffRoleCreate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + staffRole: toListFields(role), + }, + }, + }; + }, + }, + { + request: { query: StaffRoleUpdateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: StaffRoleMutationInput }) => { + const role = uiRoles.find((candidate) => candidate.id === variables.input?.id); + if (!role) { + lastMutation = { success: false, errorMessage: 'Staff role not found' }; + return { + data: { + staffRoleUpdate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, + staffRole: null, + }, + }, + }; + } + role.roleName = variables.input?.roleName ?? role.roleName; + role.enterpriseAppRole = variables.input?.enterpriseAppRole ?? role.enterpriseAppRole; + role.updatedAt = new Date().toISOString(); + lastMutation = { success: true }; + return { + data: { + staffRoleUpdate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + staffRole: toEditFields(role), + }, + }, + }; + }, + }, +]; + +const LocationProbe: React.FC = () => { + const location = useLocation(); + currentPath = location.pathname; + return null; +}; + +async function flushUi(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +async function waitUntilUi(check: () => Promise, message: string, attempts = 200): Promise { + for (let attempt = 0; attempt < attempts; attempt++) { + if (await check()) { + return; + } + await flushUi(); + } + throw new Error(message); +} + +const listPageFor = (actor: Actor): StaffRolesListPage => new StaffRolesListPage(new DomPageAdapter(RenderInDom.as(actor).container)); +const formPageFor = (actor: Actor): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +// antd messages portal to document.body, outside the rendered container. +const feedbackPage = (): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(document.body)); + +async function renderStaffRolesScreen(actorName: string): Promise { + const actor = actorCalled(actorName); + await actor.attemptsTo( + Render.component(React.createElement(React.Fragment, null, React.createElement(LocationProbe), React.createElement(StaffRolesPage)), { + wrapper: wrapOcomComponent({ mocks: buildMocks(), initialEntries: ['/'], staffAuth: staffAuthByRole[currentAuthRole] }), + }), + ); + await flushUi(); + return actor; +} + +async function openRolesListAndWait(actorName: string): Promise { + const actor = await renderStaffRolesScreen(actorName); + const listPage = listPageFor(actor); + await waitUntilUi(async () => (await listPage.listedRoleNames()).length > 0, 'Expected the staff roles list to render rows'); + return actor; +} + +async function openEditFormForRole(actorName: string, roleName: string): Promise { + const actor = await openRolesListAndWait(actorName); + const listPage = listPageFor(actor); + await listPage.clickEditForRole(roleName); + const formPage = formPageFor(actor); + await waitUntilUi(async () => (await formPage.roleNameValue()) === roleName, `Expected the edit form to load the staff role "${roleName}"`); + return actor; +} + +interface StaffRoleFormInput { + roleName?: string; + enterpriseAppRole?: string; +} + +async function submitCreateForm(actorName: string, dataTable: DataTable): Promise { + const { roleName = '', enterpriseAppRole } = GherkinDataTable.from(dataTable).rowsHash(); + baselineRoleCount = uiRoles.length; + const actor = await openRolesListAndWait(actorName); + const listPage = listPageFor(actor); + await listPage.clickCreateRole(); + const formPage = formPageFor(actor); + await waitUntilUi(() => formPage.roleNameInput.isVisible(), 'Expected the create staff role form to render'); + if (roleName) { + await formPage.fillRoleName(roleName); + } + if (enterpriseAppRole) { + await formPage.selectEnterpriseAppRole(enterpriseAppRole); + } + await formPage.clickSubmit(); + await flushUi(); +} + +Given('a staff role named {string} exists', (roleName: string) => { + if (!uiRoles.some((candidate) => candidate.roleName === roleName)) { + addRole(roleName, 'Staff.CaseManager'); + } +}); + +When('{word} views the staff roles list', async (actorName: string) => { + await openRolesListAndWait(actorName); +}); + +When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { + await openEditFormForRole(actorName, roleName); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { + await submitCreateForm(actorName, dataTable); +}); + +When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { + await submitCreateForm(actorName, dataTable); +}); + +When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { + const actor = await openEditFormForRole(actorName, currentName); + const formPage = formPageFor(actor); + await formPage.fillRoleName(newName); + await formPage.clickSubmit(); + await flushUi(); +}); + +When('{word} opens the staff roles screen', async (actorName: string) => { + const actor = await renderStaffRolesScreen(actorName); + await flushUi(); + await actor.attemptsTo(notes().set('targetRoute', currentPath)); +}); + +Then('the staff roles list should include the default staff roles', async () => { + const listPage = listPageFor(actorInTheSpotlight()); + const listed = await listPage.listedRoleNames(); + const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); + if (missing.length > 0) { + throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); + } +}); + +Then('{word} should see the staff role name {string}', async (_actorName: string, expectedName: string) => { + const formPage = formPageFor(actorInTheSpotlight()); + const value = await formPage.roleNameValue(); + if (value !== expectedName) { + throw new Error(`Expected the staff role form to show role name "${expectedName}", but got "${value}"`); + } +}); + +Then('{word} should see the enterprise app role {string}', async (_actorName: string, expectedRole: string) => { + const formPage = formPageFor(actorInTheSpotlight()); + const value = await formPage.selectedEnterpriseAppRole(); + if (value !== expectedRole) { + throw new Error(`Expected the staff role form to show enterprise app role "${expectedRole}", but got "${value}"`); + } +}); + +Then('the staff role should be created successfully', async () => { + await waitUntilUi(() => feedbackPage().successFeedback.isVisible(), 'Expected a success message after creating the staff role'); + if (lastMutation?.success !== true || uiRoles.length !== baselineRoleCount + 1) { + throw new Error('Expected the staff role create mutation to succeed and persist exactly one new role'); + } +}); + +Then('the staff role should be updated successfully', async () => { + await waitUntilUi(() => feedbackPage().successFeedback.isVisible(), 'Expected a success message after updating the staff role'); + if (lastMutation?.success !== true) { + throw new Error('Expected the staff role update mutation to succeed'); + } +}); + +Then('the staff roles list should include {string}', async (roleName: string) => { + const listPage = listPageFor(actorInTheSpotlight()); + await waitUntilUi(() => listPage.hasRoleNamed(roleName), `Expected the staff roles list to include "${roleName}"`); +}); + +Then('{word} should see a staff role error containing {string}', async (_actorName: string, expectedFragment: string) => { + await waitUntilUi(async () => { + const errorFeedback = feedbackPage().errorFeedback; + if (!(await errorFeedback.isVisible())) { + return false; + } + const text = (await errorFeedback.textContent()) ?? ''; + return text.includes(expectedFragment); + }, `Expected a staff role error message containing "${expectedFragment}"`); +}); + +Then('{word} should see a staff role validation error for {string}', async (_actorName: string, fieldName: string) => { + const formPage = formPageFor(actorInTheSpotlight()); + const expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); + await waitUntilUi(async () => { + if (!(await formPage.firstValidationError.isVisible())) { + return false; + } + const text = (await formPage.firstValidationError.textContent()) ?? ''; + return expectedPattern.test(text); + }, `Expected a validation error for "${fieldName}"`); +}); + +Then('no additional staff role should be created', () => { + if (uiRoles.length !== baselineRoleCount) { + throw new Error(`Expected no additional staff role to be created, but the role count changed from ${baselineRoleCount} to ${uiRoles.length}`); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts index f50c14577..d1949a6c1 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts @@ -1,6 +1,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; +import { resetStaffRoleUiState } from '../../staff-role/step-definitions/staff-role-management.steps.tsx'; import type { StaffUiNotes } from '../abilities/staff-types.ts'; import { StaffTargetRoute } from '../questions/staff-target-route.ts'; import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; @@ -36,6 +37,7 @@ Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); + resetStaffRoleUiState('case manager'); await actor.attemptsTo(notes().set('targetRoute', '')); }); @@ -44,6 +46,7 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); + resetStaffRoleUiState(role); await actor.attemptsTo(notes().set('targetRoute', '')); }); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts index 10b34268b..c4f1ffb9c 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/ocom-component-wrapper.ts @@ -2,12 +2,36 @@ import { MockedProvider, type MockedResponse } from '@apollo/client/testing'; import { HelmetProvider } from '@dr.pogodin/react-helmet'; import { App, ConfigProvider } from 'antd'; import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { type StaffAuth, StaffAuthContext } from '../../../../ocom/ui-staff-shared/src/staff-route-shell.tsx'; interface OcomComponentWrapperOptions { mocks?: MockedResponse[]; + /** When provided, wraps children in a MemoryRouter with these initial entries. */ + initialEntries?: string[]; + /** When provided, wraps children in a StaffAuthContext provider. */ + staffAuth?: StaffAuth; } export function wrapOcomComponent(options?: OcomComponentWrapperOptions) { - return (children: React.ReactElement): React.ReactElement => - React.createElement(HelmetProvider, null, React.createElement(ConfigProvider, null, React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, children)))); + return (children: React.ReactElement): React.ReactElement => { + let content: React.ReactElement = children; + if (options?.initialEntries) { + content = React.createElement(MemoryRouter, { initialEntries: options.initialEntries }, content); + } + if (options?.staffAuth) { + content = React.createElement(StaffAuthContext.Provider, { value: options.staffAuth }, content); + } + return React.createElement( + HelmetProvider, + null, + React.createElement( + ConfigProvider, + // Keep antd popups (Select dropdowns, tooltips) inside the rendered + // container so scoped DOM page adapters can reach them. + { getPopupContainer: (trigger?: HTMLElement) => trigger?.parentElement ?? document.body }, + React.createElement(App, null, React.createElement(MockedProvider, { mocks: options?.mocks ?? [] }, content)), + ), + ); + }; } diff --git a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts index 585f6f459..dd9eee6f7 100644 --- a/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/step-definitions/index.ts @@ -5,4 +5,5 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; +import '../contexts/staff-role/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index 5966e2dba..f592b3280 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -13,5 +13,14 @@ "rootDir": "../..", "outDir": "./dist" }, - "include": ["src/**/*.ts", "src/**/*.tsx", "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", + "../../ocom/ui-community-route-root/src/**/*.tsx", + "../../ocom/ui-staff-route-root/src/**/*.tsx", + "../../ocom/ui-staff-route-user-management/src/**/*.tsx", + "../../ocom/ui-staff-shared/src/**/*.tsx", + "../../ocom/ui-shared/src/**/*.tsx" + ] } diff --git a/packages/ocom-verification/e2e-tests/cucumber.js b/packages/ocom-verification/e2e-tests/cucumber.js index 38c29160d..c963d09f8 100644 --- a/packages/ocom-verification/e2e-tests/cucumber.js +++ b/packages/ocom-verification/e2e-tests/cucumber.js @@ -2,6 +2,7 @@ import { isAgent } from 'std-env'; export default { paths: ['../verification-shared/src/scenarios/**/*.feature'], + tags: 'not @api-only and not @ui-only and not @skip-e2e', import: ['src/world.ts', 'src/step-definitions/index.ts'], format: [...(isAgent ? ['@cellix/serenity-framework/formatters/agent'] : ['progress-bar']), 'json:./reports/cucumber-report.json', 'html:./reports/cucumber-report.html'], formatOptions: { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts new file mode 100644 index 000000000..d69e05efd --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/index.ts @@ -0,0 +1 @@ +import './staff-role-management.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts new file mode 100644 index 000000000..d17d29ffc --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -0,0 +1,246 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { AfterAll, type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; +import { actorCalled, notes } from '@serenity-js/core'; +import type { Page } from 'playwright'; +import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; +import { closeStaffPortalSessions, type StaffPortalBusinessRole, staffPortalPageFor } from '../support/staff-portal-session.ts'; + +const ROLES_LIST_PATH = '/staff/user-management/staff-roles'; +const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin']; +const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +interface StaffRoleE2EState { + actorName: string; + role: StaffPortalBusinessRole; + baselineRoleCount: number | undefined; + listedRoleNames: string[] | undefined; +} + +let state: StaffRoleE2EState = { + actorName: 'Alice', + role: 'case manager', + baselineRoleCount: undefined, + listedRoleNames: undefined, +}; + +/** + * Reset the per-scenario staff-role E2E state. Called from the shared + * `authenticated staff user` Givens in the staff context so both the + * staff-landing and staff-role features stay in sync. + */ +export function resetStaffRoleE2EState(actorName: string, role: StaffPortalBusinessRole): void { + state = { actorName, role, baselineRoleCount: undefined, listedRoleNames: undefined }; +} + +const waitUntil = async (condition: () => Promise, failureMessage: string, timeoutMs = 20_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(failureMessage); +}; + +const activePage = (): Promise => staffPortalPageFor(state.role); + +const listPageOn = (page: Page): StaffRolesListPage => new StaffRolesListPage(new PlaywrightPageAdapter(page)); + +const formPageOn = (page: Page): StaffRoleFormPage => new StaffRoleFormPage(new PlaywrightPageAdapter(page)); + +const isAtRolesList = (url: URL): boolean => url.pathname.endsWith('/staff-roles'); + +/** Navigate to the staff roles list and wait for the table rows to render. */ +const openRolesList = async (page: Page): Promise => { + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + const listPage = listPageOn(page); + await waitUntil(async () => (await listPage.listedRoleNames()).length > 0, 'Timed out waiting for the staff roles list to render'); + return listPage; +}; + +/** Open the create form from the roles list, recording the baseline role count. */ +const openCreateForm = async (page: Page): Promise => { + const listPage = await openRolesList(page); + state.baselineRoleCount = (await listPage.listedRoleNames()).length; + await listPage.clickCreateRole(); + await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); + const formPage = formPageOn(page); + await formPage.roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); + return formPage; +}; + +/** Open the edit form for a role and wait for its values to load. */ +const openEditFormFor = async (page: Page, roleName: string): Promise => { + const listPage = await openRolesList(page); + await listPage.clickEditForRole(roleName); + await page.waitForURL((url) => url.pathname.includes('/staff-roles/edit/'), { timeout: 20_000 }); + const formPage = formPageOn(page); + await waitUntil(async () => (await formPage.roleNameValue()) === roleName, `Timed out waiting for the edit form of staff role "${roleName}" to load`); + return formPage; +}; + +const fillCreateForm = async (formPage: StaffRoleFormPage, fields: Record): Promise => { + await formPage.fillRoleName(fields['roleName'] ?? ''); + const enterpriseAppRole = fields['enterpriseAppRole']; + if (enterpriseAppRole) { + await formPage.selectEnterpriseAppRole(enterpriseAppRole); + } +}; + +const submitCreateForm = async (page: Page, dataTable: DataTable): Promise => { + const formPage = await openCreateForm(page); + await fillCreateForm(formPage, dataTable.rowsHash()); + await formPage.clickSubmit(); +}; + +const waitForReturnToRolesList = async (page: Page, failureMessage: string): Promise => { + await page.waitForURL(isAtRolesList, { timeout: 20_000 }).catch(() => { + throw new Error(`${failureMessage} (still on ${new URL(page.url()).pathname})`); + }); +}; + +Given('a staff role named {string} exists', async (roleName: string) => { + const page = await activePage(); + const listPage = await openRolesList(page); + if (await listPage.hasRoleNamed(roleName)) { + return; + } + const formPage = await openCreateForm(page); + await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); + await formPage.clickSubmit(); + await waitForReturnToRolesList(page, `Creating the prerequisite staff role "${roleName}" did not return to the roles list`); + await waitUntil(async () => await listPageOn(page).hasRoleNamed(roleName), `Prerequisite staff role "${roleName}" did not appear in the roles list`); +}); + +When('{word} views the staff roles list', async (actorName: string) => { + state.actorName = actorName; + const page = await activePage(); + const listPage = await openRolesList(page); + state.listedRoleNames = await listPage.listedRoleNames(); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { + state.actorName = actorName; + await submitCreateForm(await activePage(), dataTable); +}); + +When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { + state.actorName = actorName; + await submitCreateForm(await activePage(), dataTable); +}); + +When('{word} creates a staff role named {string} with permissions:', async (actorName: string, roleName: string, dataTable: DataTable) => { + state.actorName = actorName; + const page = await activePage(); + const formPage = await openCreateForm(page); + await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); + for (const [permissionKey, value] of Object.entries(dataTable.rowsHash())) { + if (value === 'true') { + await formPage.grantPermission(permissionKey); + } + } + await formPage.clickSubmit(); +}); + +When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { + state.actorName = actorName; + const page = await activePage(); + const formPage = await openEditFormFor(page, currentName); + await formPage.fillRoleName(newName); + await formPage.clickSubmit(); +}); + +When('{word} grants the permission {string} to the staff role {string}', async (actorName: string, permissionKey: string, roleName: string) => { + state.actorName = actorName; + const page = await activePage(); + const formPage = await openEditFormFor(page, roleName); + await formPage.grantPermission(permissionKey); + await formPage.clickSubmit(); +}); + +When('{word} opens the staff roles screen', async (actorName: string) => { + state.actorName = actorName; + const page = await activePage(); + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + await page.waitForURL((url) => url.pathname === '/unauthorized' || isAtRolesList(url), { timeout: 20_000 }); + const currentPath = new URL(page.url()).pathname; + await actorCalled(actorName).attemptsTo(notes().set('currentPath', currentPath)); +}); + +Then('the staff roles list should include the default staff roles', () => { + const listed = state.listedRoleNames; + if (!listed) { + throw new Error('No staff roles list was retrieved. Did the actor view the staff roles list first?'); + } + const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); + if (missing.length > 0) { + throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); + } +}); + +Then('the staff role should be created successfully', async () => { + const page = await activePage(); + await waitForReturnToRolesList(page, 'Expected the staff role to be created and the app to return to the roles list'); +}); + +Then('the staff role should be updated successfully', async () => { + const page = await activePage(); + await waitForReturnToRolesList(page, 'Expected the staff role to be updated and the app to return to the roles list'); +}); + +Then('the staff roles list should include {string}', async (roleName: string) => { + const page = await activePage(); + const listPage = await openRolesList(page); + await waitUntil(async () => await listPage.hasRoleNamed(roleName), `Expected staff roles list to include "${roleName}", but got: [${(await listPage.listedRoleNames()).join(', ')}]`); +}); + +Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { + const page = await activePage(); + const formPage = await openEditFormFor(page, roleName); + if (!(await formPage.isPermissionGranted(permissionKey))) { + throw new Error(`Expected staff role "${roleName}" to have the permission "${permissionKey}" granted`); + } +}); + +Then('{word} should see a staff role error containing {string}', async (_actorName: string, expectedFragment: string) => { + const page = await activePage(); + const formPage = formPageOn(page); + await waitUntil(async () => { + if (!(await formPage.errorFeedback.isVisible())) { + return false; + } + const text = (await formPage.errorFeedback.textContent()) ?? ''; + return text.includes(expectedFragment); + }, `Expected a staff role error message containing "${expectedFragment}"`); +}); + +Then('{word} should see a staff role validation error for {string}', async (_actorName: string, fieldName: string) => { + const page = await activePage(); + const formPage = formPageOn(page); + const expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); + await waitUntil(async () => { + if (!(await formPage.firstValidationError.isVisible())) { + return false; + } + const text = (await formPage.firstValidationError.textContent()) ?? ''; + return expectedPattern.test(text); + }, `Expected a validation error for "${fieldName}"`); +}); + +Then('no additional staff role should be created', async () => { + if (state.baselineRoleCount === undefined) { + throw new Error('No baseline staff role count was recorded. Did the actor attempt to create a staff role first?'); + } + const page = await activePage(); + const listPage = await openRolesList(page); + const currentCount = (await listPage.listedRoleNames()).length; + if (currentCount !== state.baselineRoleCount) { + throw new Error(`Expected no additional staff role to be created, but the role count changed from ${state.baselineRoleCount} to ${currentCount}`); + } +}); + +AfterAll(async () => { + await closeStaffPortalSessions(); +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/support/staff-portal-session.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/support/staff-portal-session.ts new file mode 100644 index 000000000..d85236839 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/support/staff-portal-session.ts @@ -0,0 +1,65 @@ +import type { BrowserContext, Page } from 'playwright'; +import { infrastructure } from '../../../infrastructure.ts'; +import { performOAuth2Login } from '../../../shared/abilities/oauth2-login.ts'; + +/** Business roles supported by the staff portal E2E login flow. */ +export type StaffPortalBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +interface StaffPortalCredentials { + email: string; + password: string; +} + +/** + * Mock OIDC users defined in `apps/ui-staff/mock-oidc.users.json`. The subs of + * these users match the seeded staff users from + * `@ocom-verification/verification-shared` test data, so the API resolves the + * matching seeded staff role for each login. + */ +const credentialsByRole: Record = { + 'tech admin': { email: 'tech.admin@ownercommunity.onmicrosoft.com', password: 'password' }, + 'case manager': { email: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, + finance: { email: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, + 'service line owner': { email: 'staff@ownercommunity.onmicrosoft.com', password: 'password' }, +}; + +interface StaffPortalSession { + context: BrowserContext; + page: Page; + authenticated: boolean; +} + +const sessions = new Map(); + +/** + * Return an authenticated staff-portal page for the given business role. + * + * Browser contexts are reused across scenarios (one per mock OIDC user), so the + * OIDC session survives the per-scenario database reseed and only the first + * scenario per user pays the login cost. The login flow only runs once per + * session — re-running it would navigate the page away from whatever screen + * the current step left it on. + */ +export async function staffPortalPageFor(role: StaffPortalBusinessRole): Promise { + const credentials = credentialsByRole[role]; + let session = sessions.get(credentials.email); + if (!session) { + const context = await infrastructure.newPortalContext('staff'); + const page = await context.newPage(); + session = { context, page, authenticated: false }; + sessions.set(credentials.email, session); + } + if (!session.authenticated) { + await performOAuth2Login(session.page, credentials, '/auth-redirect'); + session.authenticated = true; + } + return session.page; +} + +/** Close every staff-portal browser context opened by the suite. */ +export async function closeStaffPortalSessions(): Promise { + for (const session of sessions.values()) { + await session.context.close().catch(() => undefined); + } + sessions.clear(); +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts index 8ea22e414..4476647b2 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -1,6 +1,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; +import { resetStaffRoleE2EState } from '../../staff-role/step-definitions/staff-role-management.steps.ts'; import type { StaffE2ENotes } from '../abilities/staff-types.ts'; import { StaffCurrentPath } from '../questions/staff-current-path.ts'; import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; @@ -36,6 +37,7 @@ Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); + resetStaffRoleE2EState(actorName, 'case manager'); await actor.attemptsTo(notes().set('currentPath', '')); }); @@ -44,6 +46,7 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); + resetStaffRoleE2EState(actorName, role); await actor.attemptsTo(notes().set('currentPath', '')); }); diff --git a/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts index 56c41feb2..5caa651a0 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/environment/test-environment.ts @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getAzuritePorts, getMongoPort } from '@ocom-verification/verification-shared/environment'; import { getPortlessPath } from './resolve-portless.ts'; let proxyInitialized = false; @@ -86,10 +87,61 @@ function loadApiLocalSettings(filePath: string): void { }; for (const [key, value] of Object.entries(parsed.Values ?? {})) { - process.env[key] ??= String(value); + // The committed settings assume the default (non-worktree) local ports and + // hostnames. Patch worktree-scoped values before they land in process.env, + // because they leak into spawned app servers whose own worktree overrides + // use `??=` and therefore never win over inherited environment variables. + process.env[key] ??= patchWorktreeSetting(key, String(value)); } } +function patchWorktreeSetting(key: string, value: string): string { + const worktreeName = process.env['WORKTREE_NAME']; + if (!worktreeName) return value; + + switch (key) { + case 'COSMOSDB_CONNECTION_STRING': + return withPort(value, getMongoPort()); + // Disable the Node.js inspector: its fixed port would collide with the + // primary worktree's API dev server (mirrors start-dev.mjs worktree mode). + case 'languageWorkers__node__arguments': + return ''; + case 'AzureWebJobsStorage': + case 'AZURE_STORAGE_CONNECTION_STRING': + return worktreeAzuriteConnectionString(); + case 'ACCOUNT_PORTAL_OIDC_ENDPOINT': + case 'ACCOUNT_PORTAL_OIDC_ISSUER': + case 'STAFF_PORTAL_OIDC_ENDPOINT': + case 'STAFF_PORTAL_OIDC_ISSUER': { + const url = new URL(value); + url.hostname = applyWorktreeSuffix(url.hostname, worktreeName); + return url.toString().replace(/\/$/, value.endsWith('/') ? '/' : ''); + } + default: + return value; + } +} + +function withPort(connectionString: string, port: number): string { + const url = new URL(connectionString); + url.port = String(port); + return url.toString(); +} + +function worktreeAzuriteConnectionString(): string { + const { blob, queue, table } = getAzuritePorts(); + const account = 'devstoreaccount1'; + const key = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw=='; + return [ + 'DefaultEndpointsProtocol=http', + `AccountName=${account}`, + `AccountKey=${key}`, + `BlobEndpoint=http://127.0.0.1:${blob}/${account}`, + `QueueEndpoint=http://127.0.0.1:${queue}/${account}`, + `TableEndpoint=http://127.0.0.1:${table}/${account}`, + ].join(';'); +} + interface ResolvePortlessHostnamesOptions { keys: Record; env?: NodeJS.ProcessEnv; diff --git a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts index 585f6f459..dd9eee6f7 100644 --- a/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/step-definitions/index.ts @@ -5,4 +5,5 @@ import '../contexts/community/step-definitions/index.ts'; import '../contexts/staff/step-definitions/index.ts'; +import '../contexts/staff-role/step-definitions/index.ts'; import '../contexts/authentication/step-definitions/index.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 63aa501c8..92c1e1c6b 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,2 +1,4 @@ export { CommunityPage } from './community.page.ts'; export { HomePage } from './home.page.ts'; +export { STAFF_ROLE_PERMISSION_LABELS, StaffRoleFormPage } from './staff-role-form.page.ts'; +export { StaffRolesListPage } from './staff-roles-list.page.ts'; diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts new file mode 100644 index 000000000..f72d1270a --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-role-form.page.ts @@ -0,0 +1,107 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Maps staff role permission command keys (as used in shared scenarios and the + * GraphQL inputs) to the checkbox labels rendered by the create/edit form. + */ +export const STAFF_ROLE_PERMISSION_LABELS: Record = { + canManageCommunities: 'Can Manage Communities', + canManageStaffRolesAndPermissions: 'Can Manage Staff Roles and Permissions', + canManageAllCommunities: 'Can Manage All Communities', + canDeleteCommunities: 'Can Delete Communities', + canChangeCommunityOwner: 'Can Change Community Owner', + canReIndexSearchCollections: 'Can Reindex Search Collections', + canManageUsers: 'Can Manage Users', + canAssignStaffRoles: 'Can Assign Staff Roles', + canViewStaffUsers: 'Can View Staff Users', + canViewRoles: 'Can View Staff Roles', + canAddRole: 'Can Add Staff Role', + canEditRole: 'Can Edit Staff Role', + canRemoveRole: 'Can Remove Staff Role', + canManageFinance: 'Can Manage Finance', + canViewGLBatchSummaries: 'Can View GL Batch Summaries', + canViewFinanceConfigs: 'Can View Finance Configs', + canCreateFinanceConfigs: 'Can Create Finance Configs', + canManageTechAdmin: 'Can Manage Tech Admin', + canViewDatabaseExplorer: 'Can View Database Explorer', + canViewBlobExplorer: 'Can View Blob Explorer', + canViewQueueDashboard: 'Can View Queue Dashboard', + canSendQueueMessages: 'Can Send Queue Messages', +}; + +/** + * Page object for the staff role create/edit form. + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffRoleFormPage extends AdapterBackedPageObject { + get roleNameInput(): ElementHandle { + return this.adapter.getByLabel('Role Name'); + } + + get enterpriseAppRoleSelect(): ElementHandle { + return this.adapter.getByRole('combobox'); + } + + get submitButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Create Role|Update Role/i }); + } + + get firstValidationError(): ElementHandle { + return this.adapter.locator('.ant-form-item-explain-error'); + } + + get errorFeedback(): ElementHandle { + return this.adapter.locator('.ant-message-error, [role="alert"]'); + } + + get successFeedback(): ElementHandle { + return this.adapter.locator('.ant-message-success'); + } + + async fillRoleName(value: string): Promise { + await this.roleNameInput.fill(value); + } + + /** Current value of the role name input. */ + async roleNameValue(): Promise { + return (await this.roleNameInput.inputValue()) ?? ''; + } + + /** Currently selected enterprise app role (antd Select selection). */ + async selectedEnterpriseAppRole(): Promise { + const selection = this.adapter.locator('.ant-select-selection-item, .ant-select-content'); + return (await selection.getAttribute('title')) ?? (await selection.textContent())?.trim() ?? ''; + } + + /** Open the enterprise app role dropdown and pick the option with the given value. */ + async selectEnterpriseAppRole(value: string): Promise { + await this.enterpriseAppRoleSelect.click(); + const option = this.adapter.locator(`.ant-select-item-option[title="${value}"]`); + await option.waitFor({ state: 'visible' }); + await option.click(); + } + + /** Check a permission checkbox by its command key (e.g. `canViewRoles`). */ + async grantPermission(permissionKey: string): Promise { + await this.permissionCheckbox(permissionKey).check(); + } + + /** Return whether a permission checkbox is currently checked. */ + async isPermissionGranted(permissionKey: string): Promise { + return await this.permissionCheckbox(permissionKey).isChecked(); + } + + private permissionCheckbox(permissionKey: string): ElementHandle { + const label = STAFF_ROLE_PERMISSION_LABELS[permissionKey]; + if (!label) { + throw new Error(`Unknown staff role permission "${permissionKey}"`); + } + return this.adapter.getByLabel(label); + } + + async clickSubmit(): Promise { + await this.submitButton.click(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts new file mode 100644 index 000000000..3891bc3b0 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts @@ -0,0 +1,58 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page object for the staff roles list screen (`/staff/user-management/roles`). + * + * Works against both the DOM adapter (component acceptance tests) and the + * Playwright adapter (browser E2E tests). + */ +export class StaffRolesListPage extends AdapterBackedPageObject { + get heading(): ElementHandle { + return this.adapter.getByText(/Staff Roles \(/); + } + + get createRoleButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Create Staff Role/i }); + } + + async clickCreateRole(): Promise { + await this.createRoleButton.click(); + } + + /** Role names currently rendered in the table body. */ + async listedRoleNames(): Promise { + const cells = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row td:first-child'); + const names: string[] = []; + for (const cell of cells) { + const text = await cell.textContent(); + if (text) { + names.push(text.trim()); + } + } + return names; + } + + async hasRoleNamed(roleName: string): Promise { + const names = await this.listedRoleNames(); + return names.includes(roleName); + } + + /** Click the row-level Edit action for the given role. */ + async clickEditForRole(roleName: string): Promise { + const rows = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row'); + for (const row of rows) { + const text = await row.textContent(); + if (text?.includes(roleName)) { + const buttons = await row.querySelectorAll('button, a'); + for (const button of buttons) { + const label = await button.textContent(); + if (label?.trim() === 'Edit') { + await button.click(); + return; + } + } + } + } + throw new Error(`No Edit action found for staff role "${roleName}"`); + } +} diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature new file mode 100644 index 000000000..930869813 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature @@ -0,0 +1,97 @@ +Feature: Staff role management + + As a staff administrator + I want to create, view, update, and assign staff roles with granular permissions + So that staff members only have access to the operations their role allows + + Scenario: View the list of staff roles + Given Alice is an authenticated "tech admin" staff user + When Alice views the staff roles list + Then the staff roles list should include the default staff roles + + @skip-e2e + Scenario: View staff role details + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Seeded Case Manager" exists + When Alice views the details of the staff role "Seeded Case Manager" + Then she should see the staff role name "Seeded Case Manager" + And she should see the enterprise app role "Staff.CaseManager" + + Scenario: Create a staff role with basic details + Given Alice is an authenticated "tech admin" staff user + When Alice creates a staff role with: + | roleName | Support Agent | + | enterpriseAppRole | Staff.CaseManager | + Then the staff role should be created successfully + And the staff roles list should include "Support Agent" + + @skip-ui + Scenario: Create a staff role with granted permissions + Given Alice is an authenticated "tech admin" staff user + When Alice creates a staff role named "Audit Reviewer" with permissions: + | canViewRoles | true | + | canViewStaffUsers | true | + Then the staff role should be created successfully + And the staff role "Audit Reviewer" should have the permission "canViewRoles" granted + And the staff role "Audit Reviewer" should have the permission "canViewStaffUsers" granted + + Scenario: Rename an existing staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Field Coordinator" exists + When Alice renames the staff role "Field Coordinator" to "Regional Coordinator" + Then the staff role should be updated successfully + And the staff roles list should include "Regional Coordinator" + + @skip-ui + Scenario: Update permissions on an existing staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Intake Specialist" exists + When Alice grants the permission "canAssignStaffRoles" to the staff role "Intake Specialist" + Then the staff role should be updated successfully + And the staff role "Intake Specialist" should have the permission "canAssignStaffRoles" granted + + @api-only + Scenario: Assign a staff role to a staff user + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Seeded Tech Admin" exists + When Alice assigns the staff role "Seeded Tech Admin" to the staff user "Staff User" + Then the staff user "Staff User" should have the staff role "Seeded Tech Admin" + + @validation + Scenario: Cannot create a duplicate staff role + Given Alice is an authenticated "tech admin" staff user + And a staff role named "Billing Clerk" exists + When Alice attempts to create a staff role with: + | roleName | Billing Clerk | + | enterpriseAppRole | Staff.CaseManager | + Then she should see a staff role error containing "already exists" + And no additional staff role should be created + + @validation + Scenario: Cannot create a staff role without a name + Given Alice is an authenticated "tech admin" staff user + When Alice attempts to create a staff role with: + | roleName | | + Then she should see a staff role validation error for "roleName" + And no additional staff role should be created + + @api-only + Scenario: Case manager cannot create a role for a higher privileged enterprise app role + Given Alice is an authenticated "case manager" staff user + When Alice attempts to create a staff role with: + | roleName | Elevated Role | + | enterpriseAppRole | Staff.TechAdmin | + Then she should see a staff role error containing "do not have permission" + And no additional staff role should be created + + @api-only + Scenario: Unauthenticated users cannot view staff roles + Given Alice is not authenticated + When Alice attempts to view the staff roles list + Then the staff roles request should be rejected as unauthorized + + @skip-api + Scenario: Staff user without role permissions cannot open the roles screen + Given Alice is an authenticated "case manager" staff user + When Alice opens the staff roles screen + Then Alice should be directed to "/unauthorized" diff --git a/packages/ocom-verification/verification-shared/src/test-data/index.ts b/packages/ocom-verification/verification-shared/src/test-data/index.ts index 150c45cc0..696dd0876 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts @@ -1,4 +1,17 @@ -export { END_USER_IDS, type EndUserSeedDocument, endUsers, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed/index.ts'; +export { + END_USER_IDS, + type EndUserSeedDocument, + endUsers, + type MongoDBSeedContext, + type MongoDBSeedDataFunction, + STAFF_ROLE_IDS, + STAFF_USER_IDS, + type StaffRoleSeedDocument, + type StaffUserSeedDocument, + seedDatabase, + staffRoles, + staffUsers, +} from './seed/index.ts'; export { actors, defaultActor, diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts index 2151ccda7..2ea6031fc 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts @@ -1,2 +1,4 @@ export { END_USER_IDS, type EndUserSeedDocument, endUsers } from './end-users.ts'; export { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed.ts'; +export { STAFF_ROLE_IDS, type StaffRoleSeedDocument, staffRoles } from './staff-roles.ts'; +export { STAFF_USER_IDS, type StaffUserSeedDocument, staffUsers } from './staff-users.ts'; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts index bed6ce13a..3845a3a33 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/seed.ts @@ -1,5 +1,7 @@ import { type Document, MongoClient, ObjectId } from 'mongodb'; import { endUsers } from './end-users.ts'; +import { staffRoles } from './staff-roles.ts'; +import { staffUsers } from './staff-users.ts'; export interface MongoDBSeedContext { connectionString: string; @@ -36,7 +38,17 @@ export async function seedDatabase(context: MongoDBSeedContext): Promise { ...user, _id: toObjectId(user._id), })); - await upsertSeedDocuments(client, context.dbName, 'users', users); + const roles = staffRoles.map((role) => ({ + ...role, + _id: toObjectId(role._id), + })); + const staff = staffUsers.map((user) => ({ + ...user, + _id: toObjectId(user._id), + role: toObjectId(user.role), + })); + await upsertSeedDocuments(client, context.dbName, 'users', [...users, ...staff]); + await upsertSeedDocuments(client, context.dbName, 'roles', roles); } finally { await client.close(); } diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts new file mode 100644 index 000000000..ac0bacd0f --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts @@ -0,0 +1,191 @@ +interface StaffRolePermissionsSeedDocument { + servicePermissions: { canManageServices: boolean }; + serviceTicketPermissions: { + canCreateTickets: boolean; + canManageTickets: boolean; + canAssignTickets: boolean; + canUpdateTickets: boolean; + canWorkOnTickets: boolean; + }; + violationTicketPermissions: { + canCreateTickets: boolean; + canManageTickets: boolean; + canAssignTickets: boolean; + canUpdateTickets: boolean; + canWorkOnTickets: boolean; + }; + communityPermissions: { + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageAllCommunities: boolean; + canDeleteCommunities: boolean; + canChangeCommunityOwner: boolean; + canReIndexSearchCollections: boolean; + }; + financePermissions: { + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; + }; + techAdminPermissions: { + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; + }; + userPermissions: { + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + }; + staffRolePermissions: { + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; + }; + propertyPermissions: { + canManageProperties: boolean; + canEditOwnProperty: boolean; + }; +} + +export interface StaffRoleSeedDocument { + _id: string; + roleType: 'staff-user-role'; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + permissions: StaffRolePermissionsSeedDocument; + schemaVersion: string; + createdAt: Date; + updatedAt: Date; +} + +export const STAFF_ROLE_IDS = { + seededTechAdmin: 'b00000000000000000000001', + seededCaseManager: 'b00000000000000000000002', +} as const; + +function buildPermissions(overrides: Partial): StaffRolePermissionsSeedDocument { + const allFalseTickets = { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canUpdateTickets: false, + canWorkOnTickets: false, + }; + return { + servicePermissions: { canManageServices: false }, + serviceTicketPermissions: { ...allFalseTickets }, + violationTicketPermissions: { ...allFalseTickets }, + communityPermissions: { + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + financePermissions: { + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, + userPermissions: { + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }, + staffRolePermissions: { + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + ...overrides, + }; +} + +const seedTimestamp = new Date('2024-01-01T00:00:00Z'); + +/** + * Staff roles seeded for verification suites. + * + * The seeded tech admin role grants `canManageStaffRolesAndPermissions`, which the + * staff passport requires before staff-role aggregates accept mutations. + */ +export const staffRoles: StaffRoleSeedDocument[] = [ + { + _id: STAFF_ROLE_IDS.seededTechAdmin, + roleType: 'staff-user-role', + roleName: 'Seeded Tech Admin', + enterpriseAppRole: 'Staff.TechAdmin', + isDefault: false, + permissions: buildPermissions({ + communityPermissions: { + canManageCommunities: true, + canManageStaffRolesAndPermissions: true, + canManageAllCommunities: true, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + techAdminPermissions: { + canManageTechAdmin: true, + canViewDatabaseExplorer: true, + canViewBlobExplorer: true, + canViewQueueDashboard: true, + canSendQueueMessages: true, + }, + userPermissions: { + canManageUsers: true, + canAssignStaffRoles: true, + canViewStaffUsers: true, + }, + staffRolePermissions: { + canViewRoles: true, + canAddRole: true, + canEditRole: true, + canRemoveRole: true, + }, + }), + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, + { + _id: STAFF_ROLE_IDS.seededCaseManager, + roleType: 'staff-user-role', + roleName: 'Seeded Case Manager', + enterpriseAppRole: 'Staff.CaseManager', + isDefault: false, + permissions: buildPermissions({ + communityPermissions: { + canManageCommunities: true, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + }), + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, +]; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts new file mode 100644 index 000000000..03d4eca3a --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-users.ts @@ -0,0 +1,50 @@ +import { actors, type TestActor } from '../test-actors.ts'; +import { STAFF_ROLE_IDS } from './staff-roles.ts'; + +export interface StaffUserSeedDocument { + _id: string; + userType: 'staff-user'; + externalId: string; + displayName: string; + firstName: string; + lastName: string; + email: string; + role: string; + accessBlocked: boolean; + tags: string[]; + activityLog: never[]; + schemaVersion: string; + createdAt: Date; + updatedAt: Date; +} + +export const STAFF_USER_IDS = { + staffUser: 'c00000000000000000000001', + techAdminStaff: 'c00000000000000000000002', + caseManagerStaff: 'c00000000000000000000003', +} as const; + +export const staffUsers: StaffUserSeedDocument[] = [ + createStaffUserSeedDocument(STAFF_USER_IDS.staffUser, actors.StaffUser, STAFF_ROLE_IDS.seededCaseManager), + createStaffUserSeedDocument(STAFF_USER_IDS.techAdminStaff, actors.TechAdminStaff, STAFF_ROLE_IDS.seededTechAdmin), + createStaffUserSeedDocument(STAFF_USER_IDS.caseManagerStaff, actors.CaseManagerStaff, STAFF_ROLE_IDS.seededCaseManager), +]; + +function createStaffUserSeedDocument(id: string, actor: TestActor, roleId: string): StaffUserSeedDocument { + return { + _id: id, + userType: 'staff-user', + externalId: actor.externalId, + displayName: `${actor.givenName} ${actor.familyName}`.trim(), + firstName: actor.givenName, + lastName: actor.familyName, + email: actor.email, + role: roleId, + accessBlocked: false, + tags: [], + activityLog: [], + schemaVersion: '1.0.0', + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-01T00:00:00Z'), + }; +} diff --git a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts index 32517bbc6..3f822e4b1 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/test-actors.ts @@ -4,6 +4,8 @@ export interface TestActor { email: string; givenName: string; familyName: string; + /** Entra enterprise app roles claim included in the actor's JWT (staff actors only). */ + roles?: string[]; } const communityOwner: TestActor = { @@ -36,12 +38,33 @@ const staffUser: TestActor = { email: 'staff@sharethrift.onmicrosoft.com', givenName: 'Staff', familyName: 'User', + roles: ['Staff.CaseManager'], +}; + +const techAdminStaff: TestActor = { + name: 'TechAdminStaff', + externalId: '10000000-0000-4000-8000-000000000002', + email: 'tech.admin@sharethrift.onmicrosoft.com', + givenName: 'Tech', + familyName: 'Admin', + roles: ['Staff.TechAdmin'], +}; + +const caseManagerStaff: TestActor = { + name: 'CaseManagerStaff', + externalId: '10000000-0000-4000-8000-000000000003', + email: 'case.manager@sharethrift.onmicrosoft.com', + givenName: 'Case', + familyName: 'Manager', + roles: ['Staff.CaseManager'], }; export const actors = { CommunityOwner: communityOwner, CommunityMember: communityMember, StaffUser: staffUser, + TechAdminStaff: techAdminStaff, + CaseManagerStaff: caseManagerStaff, Guest: guest, } as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf48ef53c..75bc149d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,9 +129,10 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - brace-expansion@1.1.12: 1.1.13 - brace-expansion@5.0.4: 5.0.6 - brace-expansion@5.0.5: 5.0.6 + brace-expansion@1: 2.0.3 + brace-expansion@5.0.4: 5.0.7 + brace-expansion@5.0.5: 5.0.7 + brace-expansion@5.0.6: 5.0.7 diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -152,7 +153,7 @@ overrides: node-forge@<1.3.2: '>=1.3.2' picomatch: ^4.0.4 webpack: ^5.105.4 - webpack-dev-server: ^5.2.5 + webpack-dev-server: ^5.2.6 express-rate-limit: 8.5.1 '@azure/ms-rest-js>uuid': ^3.4.0 azurite>uuid: ^3.4.0 @@ -1367,6 +1368,9 @@ importers: react-oidc-context: specifier: ^3.3.0 version: 3.3.0(oidc-client-ts@3.4.1)(react@19.2.0) + react-router-dom: + specifier: 'catalog:' + version: 7.15.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) std-env: specifier: ^4.0.0 version: 4.0.0 @@ -7818,14 +7822,11 @@ packages: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} braces@3.0.3: @@ -8258,9 +8259,6 @@ packages: compute-scroll-into-view@3.1.1: resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - concurrently@9.2.1: resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} engines: {node: '>=18'} @@ -13749,8 +13747,8 @@ packages: webpack: optional: true - webpack-dev-server@5.2.5: - resolution: {integrity: sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==} + webpack-dev-server@5.2.6: + resolution: {integrity: sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==} engines: {node: '>= 18.12.0'} hasBin: true peerDependencies: @@ -16269,7 +16267,7 @@ snapshots: update-notifier: 6.0.2 webpack: 5.105.4(esbuild@0.28.1) webpack-bundle-analyzer: 4.10.2 - webpack-dev-server: 5.2.5(webpack@5.105.4(esbuild@0.28.1)) + webpack-dev-server: 5.2.6(webpack@5.105.4(esbuild@0.28.1)) webpack-merge: 6.0.1 transitivePeerDependencies: - '@parcel/css' @@ -20606,16 +20604,11 @@ snapshots: widest-line: 4.0.1 wrap-ansi: 8.1.0 - brace-expansion@1.1.13: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.6: + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 @@ -21072,8 +21065,6 @@ snapshots: compute-scroll-into-view@3.1.1: {} - concat-map@0.0.1: {} - concurrently@9.2.1: dependencies: chalk: 4.1.2 @@ -24189,11 +24180,11 @@ snapshots: minimatch@10.2.4: dependencies: - brace-expansion: 5.0.6 + brace-expansion: 5.0.7 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 2.0.3 minimatch@9.0.9: dependencies: @@ -27517,7 +27508,7 @@ snapshots: optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) - webpack-dev-server@5.2.5(webpack@5.105.4(esbuild@0.28.1)): + webpack-dev-server@5.2.6(webpack@5.105.4(esbuild@0.28.1)): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a5e8c4715..123f77ad6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -80,9 +80,10 @@ overrides: jiti: 2.6.1 rollup: ^4.59.0 '@ant-design/pro-layout>path-to-regexp': ^8.4.0 - 'brace-expansion@1.1.12': 1.1.13 - 'brace-expansion@5.0.4': 5.0.6 - 'brace-expansion@5.0.5': 5.0.6 + 'brace-expansion@1': 2.0.3 + 'brace-expansion@5.0.4': 5.0.7 + 'brace-expansion@5.0.5': 5.0.7 + 'brace-expansion@5.0.6': 5.0.7 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 @@ -103,7 +104,7 @@ overrides: node-forge@<1.3.2: '>=1.3.2' picomatch: ^4.0.4 webpack: ^5.105.4 - webpack-dev-server: ^5.2.5 + webpack-dev-server: ^5.2.6 express-rate-limit: 8.5.1 '@azure/ms-rest-js>uuid': '^3.4.0' 'azurite>uuid': '^3.4.0' From 3b2105f63c168f016c029f8985f73b148ccd94d1 Mon Sep 17 00:00:00 2001 From: Copilot Bot Date: Wed, 1 Jul 2026 14:33:54 -0400 Subject: [PATCH 2/9] fix(#289): disable pnpm verify-deps-before-run to prevent concurrent install conflicts in CI pnpm 11 defaults to verify-deps-before-run=install, causing each concurrent turbo task to trigger pnpm install when workspace state is stale. Parallel workers SIGINT each other, killing tasks like @ocom/service-queue-storage#gen. CI already runs 'pnpm install --frozen-lockfile' before turbo tasks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .npmrc | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..fc360f8f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,4 @@ +# Disable pnpm 11's auto-install on script runs. CI explicitly runs +# `pnpm install --frozen-lockfile` before turbo tasks; concurrent auto-installs +# from parallel turbo workers kill each other with SIGINT. +verify-deps-before-run=false From ad04839508b53dde0ef386fa6fd0576615c15594 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Tue, 7 Jul 2026 13:52:49 -0400 Subject: [PATCH 3/9] fix: address PR #297 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serenity-framework: align PlaywrightElementHandle inputValue()/isChecked() with the DomElementHandle contract — resolve null/false for unsupported elements instead of throwing, so shared page objects behave identically across adapters; document the contract on the ElementHandle interface - acceptance-ui: make the TS project typecheckable (tsc --noEmit passes) while keeping the include globs tsx needs for the react-jsx runtime transform: exclude UI stories/tests, disable exactOptionalPropertyTypes to match how the included UI packages typecheck themselves, and add a plain-css ambient module declaration for side-effect stylesheet imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../serenity-framework/src/dom/css-types.d.ts | 3 +++ .../src/pages/adapters/playwright-adapter.ts | 18 ++++++++++++++---- .../src/pages/page-adapter.ts | 14 ++++++++++++-- .../acceptance-ui/tsconfig.json | 8 +++++++- 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 packages/cellix/serenity-framework/src/dom/css-types.d.ts diff --git a/packages/cellix/serenity-framework/src/dom/css-types.d.ts b/packages/cellix/serenity-framework/src/dom/css-types.d.ts new file mode 100644 index 000000000..fa5505519 --- /dev/null +++ b/packages/cellix/serenity-framework/src/dom/css-types.d.ts @@ -0,0 +1,3 @@ +// Side-effect stylesheet imports (handled by the asset loader at runtime). +// No exports: plain .css files can only be imported for their side effects. +declare module '*.css' {} diff --git a/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts index b84d8c6a4..282b6fb85 100644 --- a/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/adapters/playwright-adapter.ts @@ -30,12 +30,22 @@ export class PlaywrightElementHandle implements ElementHandle { return this.locator.getAttribute(name); } - inputValue(): Promise { - return this.locator.inputValue(); + async inputValue(): Promise { + try { + return await this.locator.inputValue(); + } catch { + // Match DomElementHandle: non-input elements yield null instead of throwing. + return null; + } } - isChecked(): Promise { - return this.locator.isChecked(); + async isChecked(): Promise { + try { + return await this.locator.isChecked(); + } catch { + // Match DomElementHandle: non-checkbox elements yield false instead of throwing. + return false; + } } isVisible(): Promise { diff --git a/packages/cellix/serenity-framework/src/pages/page-adapter.ts b/packages/cellix/serenity-framework/src/pages/page-adapter.ts index e99e573eb..41de0f3cb 100644 --- a/packages/cellix/serenity-framework/src/pages/page-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/page-adapter.ts @@ -21,10 +21,20 @@ export interface ElementHandle { /** Read an element attribute, or `null` when the attribute is missing. */ getAttribute(name: string): Promise; - /** Read the current value of an input-like control, or `null` when unavailable. */ + /** + * Read the current value of an input-like control. + * + * Adapters must resolve to `null` (rather than throw) when the element is not + * an input-like control, so page objects behave identically across adapters. + */ inputValue(): Promise; - /** Return whether a checkbox-like control is currently checked. */ + /** + * Return whether a checkbox-like control is currently checked. + * + * Adapters must resolve to `false` (rather than throw) when the element is not + * a checkbox-like control, so page objects behave identically across adapters. + */ isChecked(): Promise; /** Return whether the element is currently visible to the adapter runtime. */ diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index f592b3280..3dc0da163 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -6,6 +6,10 @@ "composite": false, "incremental": false, "skipLibCheck": true, + // The UI packages compiled into this project (via the include globs below, + // which tsx also uses to apply the react-jsx transform at runtime) typecheck + // themselves with exactOptionalPropertyTypes disabled. + "exactOptionalPropertyTypes": false, "allowImportingTsExtensions": true, "rewriteRelativeImportExtensions": true, "noEmit": true, @@ -17,10 +21,12 @@ "src/**/*.ts", "src/**/*.tsx", "../../cellix/serenity-framework/src/dom/css-module-types.d.ts", + "../../cellix/serenity-framework/src/dom/css-types.d.ts", "../../ocom/ui-community-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-user-management/src/**/*.tsx", "../../ocom/ui-staff-shared/src/**/*.tsx", "../../ocom/ui-shared/src/**/*.tsx" - ] + ], + "exclude": ["../../ocom/**/*.stories.tsx", "../../ocom/**/*.test.ts", "../../ocom/**/*.test.tsx"] } From 3c69201015d437c776a65f2992c29fa4386e7ca9 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Wed, 8 Jul 2026 14:42:21 -0400 Subject: [PATCH 4/9] test: cover staffRolePermissions enforcement in role management UI Adds 4 @ui-only scenarios exercising canViewRoles, canAddRole and canEditRole as the deciding flags for the roles screen, create and edit affordances, and the edit-screen unauthorized redirect. canRemoveRole has no enforcement surface yet (role deletion is not implemented). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../staff-role-management.steps.tsx | 46 ++++++++++++++++++- .../src/pages/staff-roles-list.page.ts | 15 ++++++ .../staff/staff-role-management.feature | 32 +++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx index 367d94e65..19250f93a 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx @@ -134,6 +134,8 @@ const staffAuthByRole: Record = { let uiRoles: MockStaffRole[] = []; let baselineRoleCount = 0; let currentAuthRole: StaffBusinessRole = 'tech admin'; +// When set, overrides the business-role auth with fine-grained permissions. +let currentCustomAuth: StaffAuth | undefined; let lastMutation: { success: boolean; errorMessage?: string | null } | undefined; let currentPath = '/'; let idCounter = 0; @@ -161,6 +163,7 @@ export function resetStaffRoleUiState(businessRole: StaffBusinessRole): void { lastMutation = undefined; currentPath = '/'; currentAuthRole = businessRole; + currentCustomAuth = undefined; for (const roleName of DEFAULT_STAFF_ROLE_NAMES) { addRole(roleName, `Staff.${roleName.replace('Default ', '').replaceAll(' ', '')}`); } @@ -298,11 +301,11 @@ const formPageFor = (actor: Actor): StaffRoleFormPage => new StaffRoleFormPage(n // antd messages portal to document.body, outside the rendered container. const feedbackPage = (): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(document.body)); -async function renderStaffRolesScreen(actorName: string): Promise { +async function renderStaffRolesScreen(actorName: string, initialEntries: string[] = ['/']): Promise { const actor = actorCalled(actorName); await actor.attemptsTo( Render.component(React.createElement(React.Fragment, null, React.createElement(LocationProbe), React.createElement(StaffRolesPage)), { - wrapper: wrapOcomComponent({ mocks: buildMocks(), initialEntries: ['/'], staffAuth: staffAuthByRole[currentAuthRole] }), + wrapper: wrapOcomComponent({ mocks: buildMocks(), initialEntries, staffAuth: currentCustomAuth ?? staffAuthByRole[currentAuthRole] }), }), ); await flushUi(); @@ -348,6 +351,21 @@ async function submitCreateForm(actorName: string, dataTable: DataTable): Promis await flushUi(); } +const STAFF_ROLE_PERMISSION_FLAGS = ['canViewRoles', 'canAddRole', 'canEditRole', 'canRemoveRole'] as const; +type StaffRolePermissionFlag = (typeof STAFF_ROLE_PERMISSION_FLAGS)[number]; + +Given('{word} is an authenticated staff user with only the {string} role permission', (_actorName: string, permissionFlag: string) => { + if (!STAFF_ROLE_PERMISSION_FLAGS.includes(permissionFlag as StaffRolePermissionFlag)) { + throw new Error(`Unsupported staff role permission "${permissionFlag}". Expected one of: ${STAFF_ROLE_PERMISSION_FLAGS.join(', ')}`); + } + resetStaffRoleUiState('case manager'); + currentCustomAuth = { + name: 'Scoped Staff', + enterpriseAppRole: 'Staff.CaseManager', + permissions: { [permissionFlag]: true }, + }; +}); + Given('a staff role named {string} exists', (roleName: string) => { if (!uiRoles.some((candidate) => candidate.roleName === roleName)) { addRole(roleName, 'Staff.CaseManager'); @@ -384,6 +402,16 @@ When('{word} opens the staff roles screen', async (actorName: string) => { await actor.attemptsTo(notes().set('targetRoute', currentPath)); }); +When('{word} opens the edit screen for the staff role {string}', async (actorName: string, roleName: string) => { + const role = uiRoles.find((candidate) => candidate.roleName === roleName); + if (!role) { + throw new Error(`Staff role "${roleName}" is not present in the mocked backend`); + } + const actor = await renderStaffRolesScreen(actorName, [`/edit/${role.id}`]); + await flushUi(); + await actor.attemptsTo(notes().set('targetRoute', currentPath)); +}); + Then('the staff roles list should include the default staff roles', async () => { const listPage = listPageFor(actorInTheSpotlight()); const listed = await listPage.listedRoleNames(); @@ -451,6 +479,20 @@ Then('{word} should see a staff role validation error for {string}', async (_act }, `Expected a validation error for "${fieldName}"`); }); +Then('{word} should not see an option to create a staff role', async (_actorName: string) => { + const listPage = listPageFor(actorInTheSpotlight()); + if (await listPage.createRoleButton.isVisible()) { + throw new Error('Expected the create staff role action to be hidden, but it is visible'); + } +}); + +Then('{word} should not see an option to edit a staff role', async (_actorName: string) => { + const listPage = listPageFor(actorInTheSpotlight()); + if (await listPage.hasAnyEditAction()) { + throw new Error('Expected no edit action to be rendered for any staff role, but at least one is visible'); + } +}); + Then('no additional staff role should be created', () => { if (uiRoles.length !== baselineRoleCount) { throw new Error(`Expected no additional staff role to be created, but the role count changed from ${baselineRoleCount} to ${uiRoles.length}`); diff --git a/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts b/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts index 3891bc3b0..8550de0da 100644 --- a/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts +++ b/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts @@ -37,6 +37,21 @@ export class StaffRolesListPage extends AdapterBackedPageObject { return names.includes(roleName); } + /** Whether any row-level Edit action is rendered in the roles table. */ + async hasAnyEditAction(): Promise { + const rows = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row'); + for (const row of rows) { + const buttons = await row.querySelectorAll('button, a'); + for (const button of buttons) { + const label = await button.textContent(); + if (label?.trim() === 'Edit') { + return true; + } + } + } + return false; + } + /** Click the row-level Edit action for the given role. */ async clickEditForRole(roleName: string): Promise { const rows = await this.adapter.locatorAll('.ant-table-tbody tr.ant-table-row'); diff --git a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature index 930869813..c24a4abfc 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature @@ -95,3 +95,35 @@ Feature: Staff role management Given Alice is an authenticated "case manager" staff user When Alice opens the staff roles screen Then Alice should be directed to "/unauthorized" + + @ui-only + Scenario: Staff user with only the view-roles permission can see but not manage roles + Given Alice is an authenticated staff user with only the "canViewRoles" role permission + When Alice views the staff roles list + Then the staff roles list should include the default staff roles + And she should not see an option to create a staff role + And she should not see an option to edit a staff role + + @ui-only + Scenario: Staff user with only the add-role permission can create a staff role + Given Alice is an authenticated staff user with only the "canAddRole" role permission + When Alice creates a staff role with: + | roleName | Junior Auditor | + | enterpriseAppRole | Staff.CaseManager | + Then the staff role should be created successfully + And the staff roles list should include "Junior Auditor" + + @ui-only + Scenario: Staff user with only the edit-role permission can rename a staff role + Given Alice is an authenticated staff user with only the "canEditRole" role permission + And a staff role named "Legacy Coordinator" exists + When Alice renames the staff role "Legacy Coordinator" to "Modern Coordinator" + Then the staff role should be updated successfully + And the staff roles list should include "Modern Coordinator" + + @ui-only + Scenario: Staff user without the edit-role permission cannot open the edit screen + Given Alice is an authenticated staff user with only the "canViewRoles" role permission + And a staff role named "Restricted Role" exists + When Alice opens the edit screen for the staff role "Restricted Role" + Then Alice should be directed to "/unauthorized" From e43f79871e19e665754f7cc72b92981a5509af04 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Wed, 8 Jul 2026 15:14:26 -0400 Subject: [PATCH 5/9] fix(#289): move verify-deps-before-run fix to pnpm-workspace.yaml pnpm 11 only reads auth/registry settings from .npmrc; all other settings must live in pnpm-workspace.yaml (camelCase). The .npmrc added in 3b2105f6 was silently ignored, so parallel turbo tasks still spawned concurrent 'pnpm install' runs that SIGINT each other (runDepsStatusCheck in the CI stack trace). verifyDepsBeforeRun: false now actually resolves (pnpm config get confirms). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .npmrc | 4 ---- pnpm-workspace.yaml | 5 +++++ 2 files changed, 5 insertions(+), 4 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index fc360f8f1..000000000 --- a/.npmrc +++ /dev/null @@ -1,4 +0,0 @@ -# Disable pnpm 11's auto-install on script runs. CI explicitly runs -# `pnpm install --frozen-lockfile` before turbo tasks; concurrent auto-installs -# from parallel turbo workers kill each other with SIGINT. -verify-deps-before-run=false diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 168859512..c36d4f76a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,11 @@ packages: engineStrict: true +# Disable pnpm 11's auto-install on script runs. CI explicitly runs +# `pnpm install --frozen-lockfile` before turbo tasks; concurrent auto-installs +# from parallel turbo workers kill each other with SIGINT (#289). +verifyDepsBeforeRun: false + catalog: '@apollo/server': 5.5.0 '@azure/functions': 4.11.0 From 2e5c40b9a71985077c453b9e94d31e1a21b466d7 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Thu, 9 Jul 2026 15:33:52 -0400 Subject: [PATCH 6/9] Align snyk remediation with team-broadcast pnpm-workspace.yaml/.snyk shape Adopts the team-wide fix shape (blanket body-parser 2.3.0 / js-yaml 4.3.0 overrides, drop protobufjs@7.5.4 pin, .snyk ignore for propagator-jaeger) with two verified deviations: - Keep 'js-yaml@3.14.2': 3.15.0 pin: the blanket 4.3.0 override alone breaks the docs build (gray-matter calls yaml.safeLoad, removed in js-yaml 4). 3.15.0 fixes SNYK-JS-JSYAML-17900054 on the 3.x line. - Restore a narrowed SNYK-JS-JSYAML-17342520 ignore: that older advisory is fixed only in 4.x, which gray-matter cannot use; 3.x has no fixed release. Verified: snyk:test exit 0 (0 vulnerable paths), docs build succeeds, acceptance-api 27/27, acceptance-ui 27/27, e2e worktree 24/24, audit and format:check pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .snyk | 11 +++---- pnpm-lock.yaml | 73 ++++++--------------------------------------- pnpm-workspace.yaml | 8 ++--- 3 files changed, 17 insertions(+), 75 deletions(-) diff --git a/.snyk b/.snyk index 1e83d06a5..8037ac1d3 100644 --- a/.snyk +++ b/.snyk @@ -155,12 +155,8 @@ ignore: expires: '2026-09-16T00:00:00.000Z' created: '2026-06-16T00:00:00.000Z' 'SNYK-JS-JSYAML-17342520': - - '* > js-yaml@3.14.2': - reason: 'js-yaml 3.x→4.x is a breaking major change for all transitive consumers (gray-matter/Docusaurus chain). js-yaml@4.1.1 patched to 4.2.0 via pnpm override; 3.x path has no safe upgrade.' - expires: '2026-09-16T00:00:00.000Z' - created: '2026-06-16T00:00:00.000Z' - '* > js-yaml': - reason: 'Transitive in Docusaurus build tooling (gray-matter pins js-yaml@3.x; fix is in 4.2.0, a major bump blocked by gray-matter). Build-time parsing of repository-controlled input only.' + reason: 'Transitive via gray-matter (Docusaurus), which pins js-yaml 3.x and calls yaml.safeLoad — removed in js-yaml 4, so the 4.x-only fix breaks the docs build. 3.x is pinned to 3.15.0 (fixes SNYK-JS-JSYAML-17900054); this older advisory has no 3.x fix. Build-time parsing of repository-controlled input only.' expires: '2026-09-18T00:00:00.000Z' created: '2026-06-18T00:00:00.000Z' 'SNYK-JS-OPENTELEMETRYCORE-17373280': @@ -173,3 +169,8 @@ ignore: reason: 'Transitive in Docusaurus serve-handler for local static docs serving. The fixed brace-expansion 5.x line requires minimatch 10, whose named-export API is incompatible with serve-handler 6.1.7.' expires: '2026-09-30T00:00:00.000Z' created: '2026-06-30T00:00:00.000Z' + 'SNYK-JS-OPENTELEMETRYPROPAGATORJAEGER-17901201': + - '* > @opentelemetry/propagator-jaeger@<=1.30.1': + reason: 'The transitive dependency of @opentelemetry does not have a fixed upgrade path. Accepted temporarily until upgrade paths are made available.' + expires: '2026-07-18T00:00:00.000Z' + created: '2026-06-18T00:00:00.000Z' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1088b185c..9c1b08443 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,7 @@ overrides: '@vitest/browser-playwright': 4.1.8 happy-dom: 20.10.1 axios: 1.16.0 + body-parser: 2.3.0 esbuild: 0.28.1 follow-redirects: ^1.16.0 vite: 8.0.16 @@ -136,7 +137,6 @@ overrides: diff@4.0.2: 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 - protobufjs@7.5.4: 7.6.5 serve-handler>minimatch: 3.1.5 serialize-javascript@6.0.2: 7.0.5 serialize-javascript@7.0.4: 7.0.5 @@ -166,12 +166,8 @@ overrides: fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - js-yaml@4.1.1: 4.3.0 - js-yaml@4.2.0: 4.3.0 + js-yaml: 4.3.0 js-yaml@3.14.2: 3.15.0 - body-parser@1.20.5: 1.20.6 - body-parser@2.2.2: 2.3.0 - '@opentelemetry/propagator-jaeger@1.30.1': 2.9.0 shell-quote@<1.8.4: 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 @@ -5316,12 +5312,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.9.0': - resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.57.2': resolution: {integrity: sha512-eovEy10n3umjKJl2Ey6TLzikPE+W4cUQ4gCwgGP1RqzTGtgDra0WjIqdy29ohiUKfvmbiL3MndZww58xfIvyFw==} engines: {node: '>=14'} @@ -5454,9 +5444,9 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.9.0': - resolution: {integrity: sha512-4mYGty27rYvSM0jtp1ZUOqd3LfVRCYg9H5G9OFzSx5HViYToU21MFhWfco7x1HwXr7ER8yGOiCIHZUwjPksc0Q==} - engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/propagator-jaeger@1.30.1': + resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} + engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -7886,10 +7876,6 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} - body-parser@1.20.6: - resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -9749,10 +9735,6 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -12141,10 +12123,6 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - raw-body@3.0.2: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} @@ -17895,11 +17873,6 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.38.0 - '@opentelemetry/exporter-logs-otlp-grpc@0.57.2(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 @@ -18104,10 +18077,10 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@2.9.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0)': dependencies: @@ -18192,7 +18165,7 @@ snapshots: '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 2.9.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) semver: 7.7.4 @@ -20647,23 +20620,6 @@ snapshots: bn.js@5.2.3: {} - body-parser@1.20.6: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.15.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -22043,7 +21999,7 @@ snapshots: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.6 + body-parser: 2.3.0 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 @@ -22889,10 +22845,6 @@ snapshots: hyperdyperid@1.2.0: {} - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -25691,13 +25643,6 @@ snapshots: range-parser@1.2.1: {} - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - raw-body@3.0.2: dependencies: bytes: 3.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6bce39e4f..d72187af9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -79,6 +79,7 @@ overrides: '@vitest/browser-playwright': 4.1.8 happy-dom: 20.10.1 axios: 1.16.0 + body-parser: 2.3.0 esbuild: 'catalog:' follow-redirects: ^1.16.0 vite: "catalog:" @@ -89,7 +90,6 @@ overrides: 'diff@4.0.2': 4.0.4 '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 - 'protobufjs@7.5.4': 7.6.5 'serve-handler>minimatch': 3.1.5 'serialize-javascript@6.0.2': 7.0.5 'serialize-javascript@7.0.4': 7.0.5 @@ -119,12 +119,8 @@ overrides: fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - 'js-yaml@4.1.1': 4.3.0 - 'js-yaml@4.2.0': 4.3.0 + js-yaml: 4.3.0 'js-yaml@3.14.2': 3.15.0 - 'body-parser@1.20.5': 1.20.6 - 'body-parser@2.2.2': 2.3.0 - '@opentelemetry/propagator-jaeger@1.30.1': 2.9.0 'shell-quote@<1.8.4': 1.8.4 '@opentelemetry/exporter-prometheus@0.57.2': 0.217.0 '@opentelemetry/core@2.7.1': 2.8.0 From 46828a51a447c27de434f4f4d4c92bd4f5a788a7 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Fri, 10 Jul 2026 14:52:10 -0400 Subject: [PATCH 7/9] refactor(#292): extract staff-role step logic into Screenplay tasks/questions/notes/abilities Address PR #297 review feedback: - acceptance-api: replace contexts/staff-role/support/staff-role-graphql.ts with shared abilities (create/update/assign staff role) registered in the cast, plus tasks/, questions/, and notes/staff-role-notes.ts following the contexts/community reference implementation - acceptance-ui: move Apollo mock backend into abilities/, decompose steps into tasks/questions/notes; steps stay thin glue - e2e-tests: relocate staff-portal-session to shared/abilities, decompose steps into tasks/questions/notes; business role now travels via actor notes - verification-shared: export DEFAULT_STAFF_ROLE_NAMES to remove triplication Behavior preserved: all shared scenarios pass (27 api, 27 ui, 24 e2e). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../staff-role/notes/staff-role-notes.ts | 19 + .../staff-role/questions/staff-role-named.ts | 24 + .../questions/staff-role-outcome.ts | 50 ++ .../questions/staff-role-permission.ts | 36 ++ .../staff-role/questions/staff-roles-list.ts | 23 + .../staff-role/questions/staff-user-named.ts | 29 ++ .../staff-role/questions/viewed-staff-role.ts | 66 +++ .../staff-role-management.steps.ts | 266 ++++------ .../staff-role/support/staff-role-graphql.ts | 127 ----- .../tasks/assign-staff-role-to-user.ts | 39 ++ .../staff-role/tasks/create-staff-role.ts | 24 + .../tasks/ensure-staff-role-exists.ts | 35 ++ .../tasks/grant-staff-role-permission.ts | 41 ++ .../staff-role/tasks/rename-staff-role.ts | 40 ++ .../tasks/view-staff-role-details.ts | 40 ++ .../staff-role/tasks/view-staff-roles-list.ts | 29 ++ .../step-definitions/staff-landing.steps.ts | 3 - .../src/shared/abilities/assign-staff-role.ts | 51 ++ .../src/shared/abilities/create-staff-role.ts | 65 +++ .../src/shared/abilities/index.ts | 3 + .../src/shared/abilities/update-staff-role.ts | 58 +++ .../shared/graphql/staff-role-operations.ts | 65 +++ .../acceptance-api/src/world.ts | 5 +- .../abilities/mock-staff-role-backend.ts | 311 ++++++++++++ .../staff-role/notes/staff-role-notes.ts | 4 + .../questions/staff-role-outcome.ts | 19 + .../staff-role/questions/staff-role-screen.ts | 76 +++ .../staff-role-management.steps.tsx | 479 +++--------------- .../staff-role/tasks/create-staff-role.ts | 53 ++ .../tasks/open-staff-role-screens.ts | 37 ++ .../staff-role/tasks/staff-roles-screen.ts | 86 ++++ .../create-staff-landing.steps.ts | 2 +- .../staff-role/notes/staff-role-notes.ts | 5 + .../staff-role/questions/staff-role-screen.ts | 87 ++++ .../staff-role-management.steps.ts | 242 ++------- .../staff-role/tasks/staff-roles-screen.ts | 183 +++++++ .../contexts/staff/abilities/staff-types.ts | 4 + .../step-definitions/staff-landing.steps.ts | 7 +- .../abilities}/staff-portal-session.ts | 4 +- .../src/test-data/index.ts | 1 + .../src/test-data/seed/index.ts | 2 +- .../src/test-data/seed/staff-roles.ts | 7 + 42 files changed, 1844 insertions(+), 903 deletions(-) create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts delete mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts create mode 100644 packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts create mode 100644 packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts rename packages/ocom-verification/e2e-tests/src/{contexts/staff-role/support => shared/abilities}/staff-portal-session.ts (94%) diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts new file mode 100644 index 000000000..12805f0fb --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/notes/staff-role-notes.ts @@ -0,0 +1,19 @@ +/** Input details used when creating a staff role through the API. */ +export interface StaffRoleDetails { + roleName: string; + enterpriseAppRole?: string | undefined; + permissions?: Record | undefined; +} + +/** Scenario-local staff-role state shared between tasks and questions via actor notes. */ +export interface StaffRoleNotes { + lastStaffRoleStatus: string; + lastStaffRoleId: string; + lastStaffRoleName: string; + lastStaffRoleError: string; + listedStaffRoleNames: string[]; + viewedStaffRoleId: string; + viewedStaffRoleName: string; + viewedStaffRoleEnterpriseAppRole: string; + baselineStaffRoleCount: number; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts new file mode 100644 index 000000000..f72484bba --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-named.ts @@ -0,0 +1,24 @@ +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import type { StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; +import { StaffRolesList } from './staff-roles-list.ts'; + +/** + * Question that finds a staff role by its role name through the API. + * Answers `undefined` when no staff role with that name exists. + */ +export class StaffRoleNamed extends Question> { + static called(roleName: string): StaffRoleNamed { + return new StaffRoleNamed(roleName); + } + + private constructor(private readonly roleName: string) { + super(`the staff role named "${roleName}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const roles = await actor.answer(StaffRolesList.displayed()); + return roles.find((role) => role.roleName === this.roleName); + } + + override toString = () => `the staff role named "${this.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts new file mode 100644 index 000000000..e4af374d5 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-outcome.ts @@ -0,0 +1,50 @@ +import { notes, Question } from '@serenity-js/core'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; + +/** Question that reads the status of the last staff-role mutation from actor notes. */ +export const StaffRoleStatus = { + of: () => + Question.about('the staff role mutation status', async (actor) => { + try { + return await actor.answer(notes().get('lastStaffRoleStatus')); + } catch { + return undefined; + } + }), +} as const; + +/** Question that reads the error captured for the last staff-role action from actor notes. */ +export const StaffRoleError = { + captured: () => + Question.about('the captured staff role error', async (actor) => { + try { + return await actor.answer(notes().get('lastStaffRoleError')); + } catch { + return undefined; + } + }), +} as const; + +/** Question that reads the staff role names recorded when the actor viewed the list. */ +export const ListedStaffRoleNames = { + recorded: () => + Question.about('the recorded staff role names', async (actor) => { + try { + return await actor.answer(notes().get('listedStaffRoleNames')); + } catch { + return undefined; + } + }), +} as const; + +/** Question that reads the staff role count captured before a create attempt. */ +export const BaselineStaffRoleCount = { + recorded: () => + Question.about('the baseline staff role count', async (actor) => { + try { + return await actor.answer(notes().get('baselineStaffRoleCount')); + } catch { + return undefined; + } + }), +} as const; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts new file mode 100644 index 000000000..d2b80a0b1 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-role-permission.ts @@ -0,0 +1,36 @@ +import { type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_ROLE_PERMISSION_GROUP_BY_KEY } from '../../../shared/graphql/staff-role-operations.ts'; +import { StaffRoleNamed } from './staff-role-named.ts'; + +/** + * Question that answers whether a named staff role has a permission granted, + * read live from the API. + */ +export class StaffRolePermission extends Question> { + static granted(roleName: string, permissionKey: string): StaffRolePermission { + return new StaffRolePermission(roleName, permissionKey); + } + + private constructor( + private readonly roleName: string, + private readonly permissionKey: string, + ) { + super(`whether the staff role "${roleName}" has the permission "${permissionKey}" granted`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const group = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[this.permissionKey]; + if (!group) { + throw new Error(`Unknown staff role permission "${this.permissionKey}"`); + } + + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + return role.permissions[group]?.[this.permissionKey] === true; + } + + override toString = () => `whether the staff role "${this.roleName}" has the permission "${this.permissionKey}" granted`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts new file mode 100644 index 000000000..9ed4ac26d --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-roles-list.ts @@ -0,0 +1,23 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_ROLES_QUERY, type StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; + +/** + * Question that reads the staff roles currently visible to the actor from the API. + */ +export class StaffRolesList extends Question> { + constructor() { + super('the staff roles list'); + } + + static displayed(): StaffRolesList { + return new StaffRolesList(); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_ROLES_QUERY); + return response.data['staffRoles'] as StaffRoleResult[]; + } + + override toString = () => 'the staff roles list'; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts new file mode 100644 index 000000000..e10d2755d --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/staff-user-named.ts @@ -0,0 +1,29 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_USERS_QUERY, type StaffUserResult } from '../../../shared/graphql/staff-role-operations.ts'; + +/** + * Question that finds a staff user by display name through the API. + * Fails with a diagnostic error when the staff user does not exist. + */ +export class StaffUserNamed extends Question> { + static called(displayName: string): StaffUserNamed { + return new StaffUserNamed(displayName); + } + + private constructor(private readonly displayName: string) { + super(`the staff user "${displayName}"`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_USERS_QUERY); + const staffUsers = response.data['staffUsers'] as StaffUserResult[]; + const staffUser = staffUsers.find((user) => user.displayName === this.displayName); + if (!staffUser) { + throw new Error(`Staff user "${this.displayName}" was not found`); + } + return staffUser; + } + + override toString = () => `the staff user "${this.displayName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts new file mode 100644 index 000000000..0b83f73f0 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/questions/viewed-staff-role.ts @@ -0,0 +1,66 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import { STAFF_ROLE_BY_ID_QUERY, type StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; + +type ViewedField = 'roleName' | 'enterpriseAppRole'; + +const noteKeyByField: Record = { + roleName: 'viewedStaffRoleName', + enterpriseAppRole: 'viewedStaffRoleEnterpriseAppRole', +}; + +/** + * Question that reads a field of the staff role the actor last viewed. + * Prefers a live API read by the viewed staff role id, falling back to actor notes. + */ +export class ViewedStaffRole extends Question> { + static roleName(): ViewedStaffRole { + return new ViewedStaffRole('roleName'); + } + + static enterpriseAppRole(): ViewedStaffRole { + return new ViewedStaffRole('enterpriseAppRole'); + } + + private constructor(private readonly field: ViewedField) { + super(`the viewed staff role ${field}`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const viewedRoleId = await this.readNote(actor, 'viewedStaffRoleId'); + const apiValue = await this.readFieldFromApi(actor, viewedRoleId); + if (apiValue) { + return apiValue; + } + + const notedValue = await this.readNote(actor, noteKeyByField[this.field]); + if (!notedValue) { + throw new Error(`No viewed staff role ${this.field} found in the system or actor notes. Did the actor view a staff role first?`); + } + return notedValue; + } + + override toString = () => `the viewed staff role ${this.field}`; + + private async readFieldFromApi(actor: AnswersQuestions & UsesAbilities, staffRoleId?: string): Promise { + if (!staffRoleId) { + return undefined; + } + try { + const response = await GraphQLClient.as(actor as unknown as Actor).execute(STAFF_ROLE_BY_ID_QUERY, { id: staffRoleId }); + const staffRole = response.data['staffRoleById'] as StaffRoleResult | null; + return staffRole?.[this.field] ? String(staffRole[this.field]) : undefined; + } catch { + return undefined; + } + } + + private async readNote(actor: AnswersQuestions & UsesAbilities, key: keyof StaffRoleNotes): Promise { + try { + return await actor.answer(notes>().get(key)); + } catch { + return undefined; + } + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts index 28fd0fd64..61dd39c63 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -1,165 +1,99 @@ -import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; -import { actorCalled } from '@serenity-js/core'; +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; import { setActorToken } from '../../../shared/abilities/actor-auth.ts'; -import { - assignStaffRole, - createStaffRole, - findStaffRoleByName, - getStaffRoleById, - listStaffRoles, - listStaffUsers, - type MutationStatus, - STAFF_ROLE_PERMISSION_GROUP_BY_KEY, - type StaffRolePermissionGroupName, - updateStaffRole, -} from '../support/staff-role-graphql.ts'; - -const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin']; - -interface StaffRoleScenarioState { - lastActorName: string; - lastStatus: MutationStatus | undefined; - lastError: string | undefined; - listedRoleNames: string[] | undefined; - viewedRole: { roleName: string; enterpriseAppRole: string } | undefined; - baselineRoleCount: number | undefined; -} - -const state: StaffRoleScenarioState = { - lastActorName: 'Alice', - lastStatus: undefined, - lastError: undefined, - listedRoleNames: undefined, - viewedRole: undefined, - baselineRoleCount: undefined, -}; - -function resetState(actorName: string): void { - state.lastActorName = actorName; - state.lastStatus = undefined; - state.lastError = undefined; - state.listedRoleNames = undefined; - state.viewedRole = undefined; - state.baselineRoleCount = undefined; -} - -function recordOutcome(status: MutationStatus): void { - state.lastStatus = status; - state.lastError = status.success ? undefined : (status.errorMessage ?? 'Unknown error'); -} - -function currentActor(actorName?: string) { - const resolved = actorName ? ActorName.resolve(actorName, { defaultName: state.lastActorName }) : state.lastActorName; - state.lastActorName = resolved; - return actorCalled(resolved); -} - -async function requireRoleByName(actorName: string, roleName: string) { - const actor = currentActor(actorName); - const role = await findStaffRoleByName(actor, roleName); - if (!role) { - throw new Error(`Staff role "${roleName}" was not found`); - } - return role; -} +import type { StaffRoleDetails, StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { BaselineStaffRoleCount, ListedStaffRoleNames, StaffRoleError, StaffRoleStatus } from '../questions/staff-role-outcome.ts'; +import { StaffRolePermission } from '../questions/staff-role-permission.ts'; +import { StaffRolesList } from '../questions/staff-roles-list.ts'; +import { StaffUserNamed } from '../questions/staff-user-named.ts'; +import { ViewedStaffRole } from '../questions/viewed-staff-role.ts'; +import { AssignStaffRoleToUser } from '../tasks/assign-staff-role-to-user.ts'; +import { CreateStaffRole } from '../tasks/create-staff-role.ts'; +import { EnsureStaffRoleExists } from '../tasks/ensure-staff-role-exists.ts'; +import { GrantStaffRolePermission } from '../tasks/grant-staff-role-permission.ts'; +import { RenameStaffRole } from '../tasks/rename-staff-role.ts'; +import { ViewStaffRoleDetails } from '../tasks/view-staff-role-details.ts'; +import { ViewStaffRolesList } from '../tasks/view-staff-roles-list.ts'; + +const errorMessageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error)); + +const clearStaffRoleOutcomeNotes = (actor: ReturnType) => + actor.attemptsTo( + notes().set('lastStaffRoleStatus', undefined as unknown as string), + notes().set('lastStaffRoleError', undefined as unknown as string), + notes().set('lastStaffRoleId', undefined as unknown as string), + notes().set('lastStaffRoleName', undefined as unknown as string), + ); Given('{word} is not authenticated', (actorName: string) => { - resetState(actorName); setActorToken(actorName, null); actorCalled(actorName); }); Given('a staff role named {string} exists', async (roleName: string) => { - const actor = currentActor(); - const existing = await findStaffRoleByName(actor, roleName); - if (!existing) { - const { status } = await createStaffRole(actor, { roleName, enterpriseAppRole: 'Staff.CaseManager' }); - if (!status.success) { - throw new Error(`Could not set up staff role "${roleName}": ${status.errorMessage}`); - } - } + await actorInTheSpotlight().attemptsTo(EnsureStaffRoleExists.named(roleName)); }); When('{word} views the staff roles list', async (actorName: string) => { - const actor = currentActor(actorName); - const roles = await listStaffRoles(actor); - state.listedRoleNames = roles.map((role) => role.roleName); + await actorCalled(actorName).attemptsTo(ViewStaffRolesList.displayed()); }); When('{word} attempts to view the staff roles list', async (actorName: string) => { - const actor = currentActor(actorName); + const actor = actorCalled(actorName); + await clearStaffRoleOutcomeNotes(actor); try { - const roles = await listStaffRoles(actor); - state.listedRoleNames = roles.map((role) => role.roleName); - state.lastError = undefined; + await actor.attemptsTo(ViewStaffRolesList.displayed()); } catch (error) { - state.lastError = error instanceof Error ? error.message : String(error); + await actor.attemptsTo(notes().set('lastStaffRoleError', errorMessageOf(error))); } }); When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { - const role = await requireRoleByName(actorName, roleName); - const details = await getStaffRoleById(currentActor(), role.id); - if (!details) { - throw new Error(`Staff role "${roleName}" (${role.id}) could not be retrieved by id`); - } - state.viewedRole = { roleName: details.roleName, enterpriseAppRole: details.enterpriseAppRole }; + await actorCalled(actorName).attemptsTo(ViewStaffRoleDetails.of(roleName)); }); When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { - const actor = currentActor(actorName); - const details = GherkinDataTable.from(dataTable).rowsHash<{ roleName: string; enterpriseAppRole?: string }>(); - recordOutcome((await createStaffRole(actor, { roleName: details.roleName, enterpriseAppRole: details.enterpriseAppRole })).status); + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(CreateStaffRole.with(details)); }); When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { - const actor = currentActor(actorName); - const details = GherkinDataTable.from(dataTable).rowsHash<{ roleName?: string; enterpriseAppRole?: string }>(); - state.baselineRoleCount = (await listStaffRoles(actor)).length; - recordOutcome((await createStaffRole(actor, { roleName: details.roleName ?? '', enterpriseAppRole: details.enterpriseAppRole })).status); + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash>(); + + await clearStaffRoleOutcomeNotes(actor); + const baselineRoles = await actor.answer(StaffRolesList.displayed()); + await actor.attemptsTo(notes().set('baselineStaffRoleCount', baselineRoles.length)); + + try { + await actor.attemptsTo(CreateStaffRole.with({ roleName: details.roleName ?? '', enterpriseAppRole: details.enterpriseAppRole })); + } catch (error) { + await actor.attemptsTo(notes().set('lastStaffRoleError', errorMessageOf(error))); + } }); When('{word} creates a staff role named {string} with permissions:', async (actorName: string, roleName: string, dataTable: DataTable) => { - const actor = currentActor(actorName); const flatPermissions = GherkinDataTable.from(dataTable).rowsHash>(); const permissions = Object.fromEntries(Object.entries(flatPermissions).map(([key, value]) => [key, value === 'true'])); - recordOutcome((await createStaffRole(actor, { roleName, enterpriseAppRole: 'Staff.CaseManager', permissions })).status); + await actorCalled(actorName).attemptsTo(CreateStaffRole.with({ roleName, enterpriseAppRole: 'Staff.CaseManager', permissions })); }); When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { - const role = await requireRoleByName(actorName, currentName); - recordOutcome((await updateStaffRole(currentActor(), { id: role.id, roleName: newName, enterpriseAppRole: role.enterpriseAppRole })).status); + await actorCalled(actorName).attemptsTo(RenameStaffRole.from(currentName).to(newName)); }); When('{word} grants the permission {string} to the staff role {string}', async (actorName: string, permissionKey: string, roleName: string) => { - const role = await requireRoleByName(actorName, roleName); - recordOutcome( - ( - await updateStaffRole(currentActor(), { - id: role.id, - roleName: role.roleName, - enterpriseAppRole: role.enterpriseAppRole, - permissions: { [permissionKey]: true }, - }) - ).status, - ); + await actorCalled(actorName).attemptsTo(GrantStaffRolePermission.grant(permissionKey).to(roleName)); }); When('{word} assigns the staff role {string} to the staff user {string}', async (actorName: string, roleName: string, staffUserDisplayName: string) => { - const role = await requireRoleByName(actorName, roleName); - const actor = currentActor(); - const staffUsers = await listStaffUsers(actor); - const staffUser = staffUsers.find((user) => user.displayName === staffUserDisplayName); - if (!staffUser) { - throw new Error(`Staff user "${staffUserDisplayName}" was not found`); - } - recordOutcome((await assignStaffRole(actor, staffUser.id, role.id)).status); + await actorCalled(actorName).attemptsTo(AssignStaffRoleToUser.assign(roleName).to(staffUserDisplayName)); }); -Then('the staff roles list should include the default staff roles', () => { - const listed = state.listedRoleNames; +Then('the staff roles list should include the default staff roles', async () => { + const listed = await actorInTheSpotlight().answer(ListedStaffRoleNames.recorded()); if (!listed) { throw new Error('No staff roles list was retrieved. Did the actor view the staff roles list first?'); } @@ -170,99 +104,103 @@ Then('the staff roles list should include the default staff roles', () => { }); Then('the staff roles list should include {string}', async (roleName: string) => { - const roles = await listStaffRoles(currentActor()); + const roles = await actorInTheSpotlight().answer(StaffRolesList.displayed()); const names = roles.map((role) => role.roleName); if (!names.includes(roleName)) { throw new Error(`Expected staff roles list to include "${roleName}", but got: [${names.join(', ')}]`); } }); -Then('she should see the staff role name {string}', (expectedName: string) => { - if (state.viewedRole?.roleName !== expectedName) { - throw new Error(`Expected staff role name "${expectedName}", but got "${state.viewedRole?.roleName}"`); +Then('she should see the staff role name {string}', async (expectedName: string) => { + const actualName = await actorInTheSpotlight().answer(ViewedStaffRole.roleName()); + if (actualName !== expectedName) { + throw new Error(`Expected staff role name "${expectedName}", but got "${actualName}"`); } }); -Then('she should see the enterprise app role {string}', (expectedEnterpriseAppRole: string) => { - if (state.viewedRole?.enterpriseAppRole !== expectedEnterpriseAppRole) { - throw new Error(`Expected enterprise app role "${expectedEnterpriseAppRole}", but got "${state.viewedRole?.enterpriseAppRole}"`); +Then('she should see the enterprise app role {string}', async (expectedEnterpriseAppRole: string) => { + const actualEnterpriseAppRole = await actorInTheSpotlight().answer(ViewedStaffRole.enterpriseAppRole()); + if (actualEnterpriseAppRole !== expectedEnterpriseAppRole) { + throw new Error(`Expected enterprise app role "${expectedEnterpriseAppRole}", but got "${actualEnterpriseAppRole}"`); } }); -Then('the staff role should be created successfully', () => { - if (state.lastStatus?.success !== true) { - throw new Error(`Expected staff role creation to succeed, but it failed: ${state.lastStatus?.errorMessage ?? 'no mutation was performed'}`); +Then('the staff role should be created successfully', async () => { + const status = await actorInTheSpotlight().answer(StaffRoleStatus.of()); + if (status !== 'SUCCESS') { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + throw new Error(`Expected staff role creation to succeed, but it failed: ${capturedError ?? 'no mutation was performed'}`); } }); -Then('the staff role should be updated successfully', () => { - if (state.lastStatus?.success !== true) { - throw new Error(`Expected staff role update to succeed, but it failed: ${state.lastStatus?.errorMessage ?? 'no mutation was performed'}`); +Then('the staff role should be updated successfully', async () => { + const status = await actorInTheSpotlight().answer(StaffRoleStatus.of()); + if (status !== 'SUCCESS') { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + throw new Error(`Expected staff role update to succeed, but it failed: ${capturedError ?? 'no mutation was performed'}`); } }); Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { - const role = await findStaffRoleByName(currentActor(), roleName); - if (!role) { - throw new Error(`Staff role "${roleName}" was not found`); - } - const group: StaffRolePermissionGroupName | undefined = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[permissionKey]; - if (!group) { - throw new Error(`Unknown staff role permission "${permissionKey}"`); - } - if (role.permissions[group]?.[permissionKey] !== true) { + const granted = await actorInTheSpotlight().answer(StaffRolePermission.granted(roleName, permissionKey)); + if (!granted) { throw new Error(`Expected staff role "${roleName}" to have permission "${permissionKey}" granted, but it is not`); } }); Then('the staff user {string} should have the staff role {string}', async (staffUserDisplayName: string, roleName: string) => { - const staffUsers = await listStaffUsers(currentActor()); - const staffUser = staffUsers.find((user) => user.displayName === staffUserDisplayName); - if (!staffUser) { - throw new Error(`Staff user "${staffUserDisplayName}" was not found`); - } + const staffUser = await actorInTheSpotlight().answer(StaffUserNamed.called(staffUserDisplayName)); if (staffUser.role?.roleName !== roleName) { throw new Error(`Expected staff user "${staffUserDisplayName}" to have role "${roleName}", but got "${staffUser.role?.roleName ?? 'none'}"`); } }); -Then('she should see a staff role error containing {string}', (expectedFragment: string) => { - if (!state.lastError) { +Then('she should see a staff role error containing {string}', async (expectedFragment: string) => { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + if (!capturedError) { throw new Error(`Expected a staff role error containing "${expectedFragment}", but no error was captured`); } - if (!state.lastError.toLowerCase().includes(expectedFragment.toLowerCase())) { - throw new Error(`Expected staff role error to contain "${expectedFragment}", but got: "${state.lastError}"`); + if (!capturedError.toLowerCase().includes(expectedFragment.toLowerCase())) { + throw new Error(`Expected staff role error to contain "${expectedFragment}", but got: "${capturedError}"`); } }); -Then('she should see a staff role validation error for {string}', (fieldName: string) => { - if (!state.lastError) { +Then('she should see a staff role validation error for {string}', async (fieldName: string) => { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + if (!capturedError) { throw new Error(`Expected a validation error for "${fieldName}", but no error was captured`); } - const isFieldMentioned = state.lastError.toLowerCase().includes(fieldName.toLowerCase()); - const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|too short|too long|wrong.*type/i.test(state.lastError); + const isFieldMentioned = capturedError.toLowerCase().includes(fieldName.toLowerCase()); + const isValidationPattern = /cannot be empty|required|missing|invalid|must not be empty|too short|too long|wrong.*type/i.test(capturedError); if (!isFieldMentioned && !isValidationPattern) { - throw new Error(`Expected a validation error related to "${fieldName}", but got: "${state.lastError}"`); + throw new Error(`Expected a validation error related to "${fieldName}", but got: "${capturedError}"`); } }); Then('no additional staff role should be created', async () => { - if (state.baselineRoleCount === undefined) { + const actor = actorInTheSpotlight(); + const baselineCount = await actor.answer(BaselineStaffRoleCount.recorded()); + if (baselineCount === undefined) { throw new Error('No baseline staff role count was captured. Did the actor attempt to create a staff role first?'); } - const roles = await listStaffRoles(currentActor()); - if (roles.length !== state.baselineRoleCount) { - throw new Error(`Expected staff role count to remain ${state.baselineRoleCount}, but got ${roles.length}`); + + const createdStatus = await actor.answer(StaffRoleStatus.of()); + if (createdStatus === 'SUCCESS') { + throw new Error('Expected staff role creation to be blocked, but the mutation reported success'); + } + + const roles = await actor.answer(StaffRolesList.displayed()); + if (roles.length !== baselineCount) { + throw new Error(`Expected staff role count to remain ${baselineCount}, but got ${roles.length}`); } }); -Then('the staff roles request should be rejected as unauthorized', () => { - if (!state.lastError) { +Then('the staff roles request should be rejected as unauthorized', async () => { + const capturedError = await actorInTheSpotlight().answer(StaffRoleError.captured()); + if (!capturedError) { throw new Error('Expected the staff roles request to be rejected, but it succeeded'); } - if (!state.lastError.toLowerCase().includes('unauthorized')) { - throw new Error(`Expected an unauthorized error, but got: "${state.lastError}"`); + if (!capturedError.toLowerCase().includes('unauthorized')) { + throw new Error(`Expected an unauthorized error, but got: "${capturedError}"`); } }); - -export { resetState as resetStaffRoleScenarioState }; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts deleted file mode 100644 index f57148bd9..000000000 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/support/staff-role-graphql.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; -import type { Actor } from '@serenity-js/core'; -import { STAFF_ROLE_BY_ID_QUERY, STAFF_ROLE_CREATE_MUTATION, STAFF_ROLE_UPDATE_MUTATION, STAFF_ROLES_QUERY, STAFF_USER_ASSIGN_ROLE_MUTATION, STAFF_USERS_QUERY } from '../../../shared/graphql/staff-role-operations.ts'; - -export type StaffRolePermissionGroupName = 'communityPermissions' | 'financePermissions' | 'staffRolePermissions' | 'techAdminPermissions' | 'userPermissions'; - -interface StaffRoleResult { - id: string; - roleName: string; - enterpriseAppRole: string; - isDefault: boolean; - permissions: Record>; -} - -interface StaffUserResult { - id: string; - displayName: string; - externalId: string; - role: { id: string; roleName: string } | null; -} - -export interface MutationStatus { - success: boolean; - errorMessage?: string | null; -} - -/** Maps flat permission command keys to their GraphQL permission group. */ -export const STAFF_ROLE_PERMISSION_GROUP_BY_KEY: Record = { - canManageCommunities: 'communityPermissions', - canManageStaffRolesAndPermissions: 'communityPermissions', - canManageAllCommunities: 'communityPermissions', - canDeleteCommunities: 'communityPermissions', - canChangeCommunityOwner: 'communityPermissions', - canReIndexSearchCollections: 'communityPermissions', - canManageFinance: 'financePermissions', - canViewGLBatchSummaries: 'financePermissions', - canViewFinanceConfigs: 'financePermissions', - canCreateFinanceConfigs: 'financePermissions', - canViewRoles: 'staffRolePermissions', - canAddRole: 'staffRolePermissions', - canEditRole: 'staffRolePermissions', - canRemoveRole: 'staffRolePermissions', - canManageTechAdmin: 'techAdminPermissions', - canViewDatabaseExplorer: 'techAdminPermissions', - canViewBlobExplorer: 'techAdminPermissions', - canViewQueueDashboard: 'techAdminPermissions', - canSendQueueMessages: 'techAdminPermissions', - canManageUsers: 'userPermissions', - canAssignStaffRoles: 'userPermissions', - canViewStaffUsers: 'userPermissions', -}; - -/** Convert flat `key -> boolean` pairs into the grouped StaffRole permissions input shape. */ -function toPermissionsInput(flatPermissions: Record): Record> { - const input: Record> = {}; - for (const [key, value] of Object.entries(flatPermissions)) { - const group = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[key]; - if (!group) { - throw new Error(`Unknown staff role permission "${key}"`); - } - input[group] = { ...input[group], [key]: value }; - } - return input; -} - -export async function listStaffRoles(actor: Actor): Promise { - const response = await GraphQLClient.as(actor).execute(STAFF_ROLES_QUERY); - return response.data['staffRoles'] as StaffRoleResult[]; -} - -export async function getStaffRoleById(actor: Actor, id: string): Promise { - const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_BY_ID_QUERY, { id }); - return (response.data['staffRoleById'] as StaffRoleResult | null) ?? null; -} - -export async function findStaffRoleByName(actor: Actor, roleName: string): Promise { - const roles = await listStaffRoles(actor); - return roles.find((role) => role.roleName === roleName); -} - -interface StaffRoleCreateRequest { - roleName: string; - enterpriseAppRole?: string | undefined; - permissions?: Record | undefined; -} - -export async function createStaffRole(actor: Actor, request: StaffRoleCreateRequest): Promise<{ status: MutationStatus; staffRole: StaffRoleResult | null }> { - const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_CREATE_MUTATION, { - input: { - roleName: request.roleName, - enterpriseAppRole: request.enterpriseAppRole ?? null, - permissions: request.permissions ? toPermissionsInput(request.permissions) : null, - }, - }); - return response.data['staffRoleCreate'] as { status: MutationStatus; staffRole: StaffRoleResult | null }; -} - -interface StaffRoleUpdateRequest { - id: string; - roleName: string; - enterpriseAppRole: string; - permissions?: Record | undefined; -} - -export async function updateStaffRole(actor: Actor, request: StaffRoleUpdateRequest): Promise<{ status: MutationStatus; staffRole: StaffRoleResult | null }> { - const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_UPDATE_MUTATION, { - input: { - id: request.id, - roleName: request.roleName, - enterpriseAppRole: request.enterpriseAppRole, - permissions: request.permissions ? toPermissionsInput(request.permissions) : null, - }, - }); - return response.data['staffRoleUpdate'] as { status: MutationStatus; staffRole: StaffRoleResult | null }; -} - -export async function listStaffUsers(actor: Actor): Promise { - const response = await GraphQLClient.as(actor).execute(STAFF_USERS_QUERY); - return response.data['staffUsers'] as StaffUserResult[]; -} - -export async function assignStaffRole(actor: Actor, staffUserId: string, roleId: string): Promise<{ status: MutationStatus; staffUser: StaffUserResult | null }> { - const response = await GraphQLClient.as(actor).execute(STAFF_USER_ASSIGN_ROLE_MUTATION, { - input: { staffUserId, roleId }, - }); - return response.data['staffUserAssignRole'] as { status: MutationStatus; staffUser: StaffUserResult | null }; -} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts new file mode 100644 index 000000000..5ee6a38b3 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/assign-staff-role-to-user.ts @@ -0,0 +1,39 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { AssignStaffRole as AssignStaffRoleAbility } from '../../../shared/abilities/assign-staff-role.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; +import { StaffUserNamed } from '../questions/staff-user-named.ts'; + +/** + * Task that assigns a staff role to a staff user through the API and records + * the outcome in actor notes. + */ +export class AssignStaffRoleToUser extends Task { + static assign(roleName: string) { + return { + to: (staffUserDisplayName: string) => new AssignStaffRoleToUser(roleName, staffUserDisplayName), + }; + } + + private constructor( + private readonly roleName: string, + private readonly staffUserDisplayName: string, + ) { + super(`assigns the staff role "${roleName}" to the staff user "${staffUserDisplayName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + const staffUser = await actor.answer(StaffUserNamed.called(this.staffUserDisplayName)); + + await AssignStaffRoleAbility.as(actor).performAs(actor, { staffUserId: staffUser.id, roleId: role.id }); + + await actor.attemptsTo(notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `assigns the staff role "${this.roleName}" to the staff user "${this.staffUserDisplayName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts new file mode 100644 index 000000000..8eac0fbdc --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/create-staff-role.ts @@ -0,0 +1,24 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { CreateStaffRole as CreateStaffRoleAbility } from '../../../shared/abilities/create-staff-role.ts'; +import type { StaffRoleDetails, StaffRoleNotes } from '../notes/staff-role-notes.ts'; + +/** + * Task that creates a staff role through the API and records the outcome in actor notes. + */ +export class CreateStaffRole extends Task { + static with(details: StaffRoleDetails) { + return new CreateStaffRole(details); + } + + private constructor(private readonly details: StaffRoleDetails) { + super(`creates a staff role named "${details.roleName}"`); + } + + async performAs(actor: Actor): Promise { + const staffRole = await CreateStaffRoleAbility.as(actor).performAs(actor, this.details); + + await actor.attemptsTo(notes().set('lastStaffRoleId', staffRole.id), notes().set('lastStaffRoleName', staffRole.roleName), notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `creates a staff role named "${this.details.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts new file mode 100644 index 000000000..1fef7d564 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts @@ -0,0 +1,35 @@ +import { type Actor, Task } from '@serenity-js/core'; +import { CreateStaffRole as CreateStaffRoleAbility } from '../../../shared/abilities/create-staff-role.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +/** + * Given-glue task that makes sure a staff role with the given name exists, + * creating it through the API when absent. + */ +export class EnsureStaffRoleExists extends Task { + static named(roleName: string) { + return new EnsureStaffRoleExists(roleName); + } + + private constructor(private readonly roleName: string) { + super(`makes sure a staff role named "${roleName}" exists`); + } + + async performAs(actor: Actor): Promise { + const existing = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (existing) { + return; + } + + try { + await CreateStaffRoleAbility.as(actor).performAs(actor, { roleName: this.roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Could not set up staff role "${this.roleName}": ${message}`); + } + } + + override toString = () => `makes sure a staff role named "${this.roleName}" exists`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts new file mode 100644 index 000000000..c2837c14f --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/grant-staff-role-permission.ts @@ -0,0 +1,41 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { UpdateStaffRole as UpdateStaffRoleAbility } from '../../../shared/abilities/update-staff-role.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +/** + * Task that grants a single permission to an existing staff role through the + * API and records the outcome in actor notes. + */ +export class GrantStaffRolePermission extends Task { + static grant(permissionKey: string) { + return { + to: (roleName: string) => new GrantStaffRolePermission(permissionKey, roleName), + }; + } + + private constructor( + private readonly permissionKey: string, + private readonly roleName: string, + ) { + super(`grants the permission "${permissionKey}" to the staff role "${roleName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + const updated = await UpdateStaffRoleAbility.as(actor).performAs(actor, { + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + permissions: { [this.permissionKey]: true }, + }); + + await actor.attemptsTo(notes().set('lastStaffRoleId', updated.id), notes().set('lastStaffRoleName', updated.roleName), notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `grants the permission "${this.permissionKey}" to the staff role "${this.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts new file mode 100644 index 000000000..2d8ceb0fc --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/rename-staff-role.ts @@ -0,0 +1,40 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { UpdateStaffRole as UpdateStaffRoleAbility } from '../../../shared/abilities/update-staff-role.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +/** + * Task that renames an existing staff role through the API and records the + * outcome in actor notes. + */ +export class RenameStaffRole extends Task { + static from(currentName: string) { + return { + to: (newName: string) => new RenameStaffRole(currentName, newName), + }; + } + + private constructor( + private readonly currentName: string, + private readonly newName: string, + ) { + super(`renames the staff role "${currentName}" to "${newName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.currentName)); + if (!role) { + throw new Error(`Staff role "${this.currentName}" was not found`); + } + + const updated = await UpdateStaffRoleAbility.as(actor).performAs(actor, { + id: role.id, + roleName: this.newName, + enterpriseAppRole: role.enterpriseAppRole, + }); + + await actor.attemptsTo(notes().set('lastStaffRoleId', updated.id), notes().set('lastStaffRoleName', updated.roleName), notes().set('lastStaffRoleStatus', 'SUCCESS')); + } + + override toString = () => `renames the staff role "${this.currentName}" to "${this.newName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts new file mode 100644 index 000000000..ff1110273 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-role-details.ts @@ -0,0 +1,40 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { STAFF_ROLE_BY_ID_QUERY, type StaffRoleResult } from '../../../shared/graphql/staff-role-operations.ts'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRoleNamed } from '../questions/staff-role-named.ts'; + +/** + * Task that retrieves the details of a named staff role by id through the API + * and records the viewed fields in actor notes. + */ +export class ViewStaffRoleDetails extends Task { + static of(roleName: string) { + return new ViewStaffRoleDetails(roleName); + } + + private constructor(private readonly roleName: string) { + super(`views the details of the staff role "${roleName}"`); + } + + async performAs(actor: Actor): Promise { + const role = await actor.answer(StaffRoleNamed.called(this.roleName)); + if (!role) { + throw new Error(`Staff role "${this.roleName}" was not found`); + } + + const response = await GraphQLClient.as(actor).execute(STAFF_ROLE_BY_ID_QUERY, { id: role.id }); + const details = response.data['staffRoleById'] as StaffRoleResult | null; + if (!details) { + throw new Error(`Staff role "${this.roleName}" (${role.id}) could not be retrieved by id`); + } + + await actor.attemptsTo( + notes().set('viewedStaffRoleId', details.id), + notes().set('viewedStaffRoleName', details.roleName), + notes().set('viewedStaffRoleEnterpriseAppRole', details.enterpriseAppRole), + ); + } + + override toString = () => `views the details of the staff role "${this.roleName}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts new file mode 100644 index 000000000..38e61a820 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/tasks/view-staff-roles-list.ts @@ -0,0 +1,29 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffRoleNotes } from '../notes/staff-role-notes.ts'; +import { StaffRolesList } from '../questions/staff-roles-list.ts'; + +/** + * Task that reads the staff roles list from the API and records the visible + * role names in actor notes. + */ +export class ViewStaffRolesList extends Task { + static displayed() { + return new ViewStaffRolesList(); + } + + private constructor() { + super('views the staff roles list'); + } + + async performAs(actor: Actor): Promise { + const roles = await actor.answer(StaffRolesList.displayed()); + await actor.attemptsTo( + notes().set( + 'listedStaffRoleNames', + roles.map((role) => role.roleName), + ), + ); + } + + override toString = () => 'views the staff roles list'; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts index 840e108e5..4b69b5577 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -2,7 +2,6 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; import { setActorToken, staffTokenFor } from '../../../shared/abilities/actor-auth.ts'; -import { resetStaffRoleScenarioState } from '../../staff-role/step-definitions/staff-role-management.steps.ts'; type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; @@ -47,7 +46,6 @@ Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); - resetStaffRoleScenarioState(actorName); setActorToken(actorName, staffTokenFor(staffActorByBusinessRole['case manager'])); await actor.attemptsTo(notes().set('targetRoute', '')); }); @@ -57,7 +55,6 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); - resetStaffRoleScenarioState(actorName); setActorToken(actorName, staffTokenFor(staffActorByBusinessRole[role])); await actor.attemptsTo(notes().set('targetRoute', '')); }); diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts new file mode 100644 index 000000000..470be20ed --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/assign-staff-role.ts @@ -0,0 +1,51 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { type MutationStatus, STAFF_USER_ASSIGN_ROLE_MUTATION, type StaffUserResult } from '../graphql/staff-role-operations.ts'; + +/** Assignment details accepted by the API staff-user role assignment flow. */ +interface AssignStaffRoleDetails { + staffUserId: string; + roleId: string; +} + +/** Handler that assigns a staff role to a staff user for an API actor. */ +type AssignStaffRoleHandler = (actor: Actor, details: AssignStaffRoleDetails) => Promise; + +/** + * Serenity ability that assigns staff roles to staff users through the API + * verification server. Throws when the mutation reports a failure. + */ +export class AssignStaffRole extends Ability { + constructor(private readonly handler: AssignStaffRoleHandler) { + super(); + } + + static using(handler: AssignStaffRoleHandler): AssignStaffRole { + return new AssignStaffRole(handler); + } + + async performAs(actor: Actor, details: AssignStaffRoleDetails): Promise { + return await this.handler(actor, details); + } +} + +export function assignStaffRoleAbility(): AssignStaffRole { + return AssignStaffRole.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(STAFF_USER_ASSIGN_ROLE_MUTATION, { + input: { staffUserId: details.staffUserId, roleId: details.roleId }, + }); + + const mutationResult = response.data['staffUserAssignRole'] as { status: MutationStatus; staffUser: StaffUserResult | null } | undefined; + if (mutationResult?.status?.success !== true) { + throw new Error(String(mutationResult?.status?.errorMessage ?? 'Failed to assign staff role')); + } + + const staffUser = mutationResult.staffUser; + if (!staffUser) { + throw new Error('API staffUserAssignRole reported success but returned no staff user'); + } + + return staffUser; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts new file mode 100644 index 000000000..92fd52bd1 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/create-staff-role.ts @@ -0,0 +1,65 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { type MutationStatus, STAFF_ROLE_BY_ID_QUERY, STAFF_ROLE_CREATE_MUTATION, type StaffRoleResult, toPermissionsInput } from '../graphql/staff-role-operations.ts'; + +/** Staff role details accepted by the API creation flow. */ +interface CreateStaffRoleDetails { + roleName: string; + enterpriseAppRole?: string | undefined; + permissions?: Record | undefined; +} + +/** Handler that performs staff role creation for an API actor. */ +type CreateStaffRoleHandler = (actor: Actor, details: CreateStaffRoleDetails) => Promise; + +/** + * Serenity ability that creates staff roles through the API verification server. + * Throws when the mutation reports a validation or authorization failure. + */ +export class CreateStaffRole extends Ability { + constructor(private readonly handler: CreateStaffRoleHandler) { + super(); + } + + static using(handler: CreateStaffRoleHandler): CreateStaffRole { + return new CreateStaffRole(handler); + } + + async performAs(actor: Actor, details: CreateStaffRoleDetails): Promise { + return await this.handler(actor, details); + } +} + +export function createStaffRoleAbility(): CreateStaffRole { + return CreateStaffRole.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(STAFF_ROLE_CREATE_MUTATION, { + input: { + roleName: details.roleName, + enterpriseAppRole: details.enterpriseAppRole ?? null, + permissions: details.permissions ? toPermissionsInput(details.permissions) : null, + }, + }); + + const mutationResult = response.data['staffRoleCreate'] as { status: MutationStatus; staffRole: StaffRoleResult | null } | undefined; + if (mutationResult?.status?.success !== true) { + throw new Error(String(mutationResult?.status?.errorMessage ?? 'Failed to create staff role')); + } + + const staffRoleId = mutationResult.staffRole?.id; + if (!staffRoleId) { + throw new Error('API staffRoleCreate reported success but returned no staff role id'); + } + + const persistedResponse = await graphql.execute(STAFF_ROLE_BY_ID_QUERY, { id: staffRoleId }); + const persistedRole = persistedResponse.data['staffRoleById'] as StaffRoleResult | null; + if (!persistedRole) { + throw new Error(`Staff role ${staffRoleId} was not found on re-query; API backend did not persist the staff role`); + } + if (persistedRole.roleName !== details.roleName) { + throw new Error(`Re-queried staff role name "${persistedRole.roleName}" does not match created name "${details.roleName}"`); + } + + return persistedRole; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts index 2ed5ecf3a..41d8d8913 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -1,2 +1,5 @@ +export { assignStaffRoleAbility } from './assign-staff-role.ts'; export { createCommunityAbility } from './create-community.ts'; +export { createStaffRoleAbility } from './create-staff-role.ts'; export { createGraphQLClientAbility } from './graphql-client.ts'; +export { updateStaffRoleAbility } from './update-staff-role.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts new file mode 100644 index 000000000..3e7c9ab89 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-staff-role.ts @@ -0,0 +1,58 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { type MutationStatus, STAFF_ROLE_UPDATE_MUTATION, type StaffRoleResult, toPermissionsInput } from '../graphql/staff-role-operations.ts'; + +/** Staff role details accepted by the API update flow. */ +interface UpdateStaffRoleDetails { + id: string; + roleName: string; + enterpriseAppRole: string; + permissions?: Record | undefined; +} + +/** Handler that performs a staff role update for an API actor. */ +type UpdateStaffRoleHandler = (actor: Actor, details: UpdateStaffRoleDetails) => Promise; + +/** + * Serenity ability that updates staff roles through the API verification server. + * Throws when the mutation reports a validation or authorization failure. + */ +export class UpdateStaffRole extends Ability { + constructor(private readonly handler: UpdateStaffRoleHandler) { + super(); + } + + static using(handler: UpdateStaffRoleHandler): UpdateStaffRole { + return new UpdateStaffRole(handler); + } + + async performAs(actor: Actor, details: UpdateStaffRoleDetails): Promise { + return await this.handler(actor, details); + } +} + +export function updateStaffRoleAbility(): UpdateStaffRole { + return UpdateStaffRole.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(STAFF_ROLE_UPDATE_MUTATION, { + input: { + id: details.id, + roleName: details.roleName, + enterpriseAppRole: details.enterpriseAppRole, + permissions: details.permissions ? toPermissionsInput(details.permissions) : null, + }, + }); + + const mutationResult = response.data['staffRoleUpdate'] as { status: MutationStatus; staffRole: StaffRoleResult | null } | undefined; + if (mutationResult?.status?.success !== true) { + throw new Error(String(mutationResult?.status?.errorMessage ?? 'Failed to update staff role')); + } + + const staffRole = mutationResult.staffRole; + if (!staffRole) { + throw new Error('API staffRoleUpdate reported success but returned no staff role'); + } + + return staffRole; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts index 47c00e520..38e66c807 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts @@ -1,3 +1,68 @@ +/** GraphQL permission groups exposed on the StaffRole type. */ +type StaffRolePermissionGroupName = 'communityPermissions' | 'financePermissions' | 'staffRolePermissions' | 'techAdminPermissions' | 'userPermissions'; + +/** Staff role shape returned by the staff-role queries and mutations. */ +export interface StaffRoleResult { + id: string; + roleName: string; + enterpriseAppRole: string; + isDefault: boolean; + permissions: Record>; +} + +/** Staff user shape returned by the staff-user queries and mutations. */ +export interface StaffUserResult { + id: string; + displayName: string; + externalId: string; + role: { id: string; roleName: string } | null; +} + +/** Status block returned by staff-role and staff-user mutations. */ +export interface MutationStatus { + success: boolean; + errorMessage?: string | null; +} + +/** Maps flat permission command keys to their GraphQL permission group. */ +export const STAFF_ROLE_PERMISSION_GROUP_BY_KEY: Record = { + canManageCommunities: 'communityPermissions', + canManageStaffRolesAndPermissions: 'communityPermissions', + canManageAllCommunities: 'communityPermissions', + canDeleteCommunities: 'communityPermissions', + canChangeCommunityOwner: 'communityPermissions', + canReIndexSearchCollections: 'communityPermissions', + canManageFinance: 'financePermissions', + canViewGLBatchSummaries: 'financePermissions', + canViewFinanceConfigs: 'financePermissions', + canCreateFinanceConfigs: 'financePermissions', + canViewRoles: 'staffRolePermissions', + canAddRole: 'staffRolePermissions', + canEditRole: 'staffRolePermissions', + canRemoveRole: 'staffRolePermissions', + canManageTechAdmin: 'techAdminPermissions', + canViewDatabaseExplorer: 'techAdminPermissions', + canViewBlobExplorer: 'techAdminPermissions', + canViewQueueDashboard: 'techAdminPermissions', + canSendQueueMessages: 'techAdminPermissions', + canManageUsers: 'userPermissions', + canAssignStaffRoles: 'userPermissions', + canViewStaffUsers: 'userPermissions', +}; + +/** Convert flat `key -> boolean` pairs into the grouped StaffRole permissions input shape. */ +export function toPermissionsInput(flatPermissions: Record): Record> { + const input: Record> = {}; + for (const [key, value] of Object.entries(flatPermissions)) { + const group = STAFF_ROLE_PERMISSION_GROUP_BY_KEY[key]; + if (!group) { + throw new Error(`Unknown staff role permission "${key}"`); + } + input[group] = { ...input[group], [key]: value }; + } + return input; +} + const STAFF_ROLE_FIELDS = ` id roleName diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index e814c0e6b..f2a1db5e4 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -3,8 +3,11 @@ import type { ApiInfrastructureState } from '@cellix/serenity-framework/infrastr import { SerenityCast } from '@cellix/serenity-framework/serenity'; import { registerLifecycleHooks } from './cucumber-lifecycle-hooks.ts'; import { infrastructure } from './infrastructure.ts'; +import { assignStaffRoleAbility } from './shared/abilities/assign-staff-role.ts'; import { createCommunityAbility } from './shared/abilities/create-community.ts'; +import { createStaffRoleAbility } from './shared/abilities/create-staff-role.ts'; import { createGraphQLClientAbility } from './shared/abilities/graphql-client.ts'; +import { updateStaffRoleAbility } from './shared/abilities/update-staff-role.ts'; export const CellixApiWorld = registerManagedSerenityWorld({ infrastructure, @@ -16,7 +19,7 @@ export const CellixApiWorld = registerManagedSerenityWorld({ createCast: (state) => new SerenityCast({ useNotepad: true, - abilities: [(actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), () => createCommunityAbility()], + abilities: [(actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), () => createCommunityAbility(), () => createStaffRoleAbility(), () => updateStaffRoleAbility(), () => assignStaffRoleAbility()], }), }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts new file mode 100644 index 000000000..5fd6205b3 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/abilities/mock-staff-role-backend.ts @@ -0,0 +1,311 @@ +import type { MockedResponse } from '@apollo/client/testing'; +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { StaffRoleByIdDocument, StaffRoleCreateDocument, StaffRolesListDocument, StaffRoleUpdateDocument } from '../../../../../../ocom/ui-staff-route-user-management/src/generated.tsx'; +import type { StaffAuth } from '../../../../../../ocom/ui-staff-shared/src/staff-route-shell.tsx'; + +/** Business roles supported by the staff portal UI acceptance flows. */ +type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; + +const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +interface MockPermissions { + __typename: 'StaffRolePermissions'; + communityPermissions: { + __typename: 'StaffRoleCommunityPermissions'; + canManageCommunities: boolean; + canManageStaffRolesAndPermissions: boolean; + canManageAllCommunities: boolean; + canDeleteCommunities: boolean; + canChangeCommunityOwner: boolean; + canReIndexSearchCollections: boolean; + }; + userPermissions: { + __typename: 'StaffRoleUserPermissions'; + canManageUsers: boolean; + canAssignStaffRoles: boolean; + canViewStaffUsers: boolean; + }; + staffRolePermissions: { + __typename: 'StaffRoleRolePermissions'; + canViewRoles: boolean; + canAddRole: boolean; + canEditRole: boolean; + canRemoveRole: boolean; + }; + financePermissions: { + __typename: 'StaffRoleFinancePermissions'; + canManageFinance: boolean; + canViewGLBatchSummaries: boolean; + canViewFinanceConfigs: boolean; + canCreateFinanceConfigs: boolean; + }; + techAdminPermissions: { + __typename: 'StaffRoleTechAdminPermissions'; + canManageTechAdmin: boolean; + canViewDatabaseExplorer: boolean; + canViewBlobExplorer: boolean; + canViewQueueDashboard: boolean; + canSendQueueMessages: boolean; + }; +} + +interface MockStaffRole { + id: string; + roleName: string; + enterpriseAppRole: string; + createdAt: string; + updatedAt: string; + permissions: MockPermissions; +} + +/** Outcome of the last mocked staff-role mutation. */ +export interface MockMutationResult { + success: boolean; + errorMessage?: string | null; +} + +const emptyPermissions = (): MockPermissions => ({ + __typename: 'StaffRolePermissions', + communityPermissions: { + __typename: 'StaffRoleCommunityPermissions', + canManageCommunities: false, + canManageStaffRolesAndPermissions: false, + canManageAllCommunities: false, + canDeleteCommunities: false, + canChangeCommunityOwner: false, + canReIndexSearchCollections: false, + }, + userPermissions: { + __typename: 'StaffRoleUserPermissions', + canManageUsers: false, + canAssignStaffRoles: false, + canViewStaffUsers: false, + }, + staffRolePermissions: { + __typename: 'StaffRoleRolePermissions', + canViewRoles: false, + canAddRole: false, + canEditRole: false, + canRemoveRole: false, + }, + financePermissions: { + __typename: 'StaffRoleFinancePermissions', + canManageFinance: false, + canViewGLBatchSummaries: false, + canViewFinanceConfigs: false, + canCreateFinanceConfigs: false, + }, + techAdminPermissions: { + __typename: 'StaffRoleTechAdminPermissions', + canManageTechAdmin: false, + canViewDatabaseExplorer: false, + canViewBlobExplorer: false, + canViewQueueDashboard: false, + canSendQueueMessages: false, + }, +}); + +const staffAuthByRole: Record = { + 'tech admin': { + name: 'Tech Admin Staff', + enterpriseAppRole: 'Staff.TechAdmin', + permissions: { + canManageStaffRolesAndPermissions: true, + canManageTechAdmin: true, + canViewRoles: true, + canAddRole: true, + canEditRole: true, + canRemoveRole: true, + canManageUsers: true, + canAssignStaffRoles: true, + canViewStaffUsers: true, + }, + }, + 'service line owner': { name: 'Service Line Owner Staff', enterpriseAppRole: 'Staff.ServiceLineOwner', permissions: {} }, + finance: { name: 'Finance Staff', enterpriseAppRole: 'Staff.Finance', permissions: { canManageFinance: true } }, + 'case manager': { name: 'Case Manager Staff', enterpriseAppRole: 'Staff.CaseManager', permissions: {} }, +}; + +// Mock backend state shared by the dynamic Apollo mocks below. +let uiRoles: MockStaffRole[] = []; +let currentAuthRole: StaffBusinessRole = 'tech admin'; +// When set, overrides the business-role auth with fine-grained permissions. +let currentCustomAuth: StaffAuth | undefined; +let lastMutation: MockMutationResult | undefined; +let currentPath = '/'; +let idCounter = 0; + +const nextRoleId = (): string => { + idCounter += 1; + return idCounter.toString(16).padStart(24, '0'); +}; + +const addRole = (roleName: string, enterpriseAppRole: string): MockStaffRole => { + const now = new Date().toISOString(); + const role: MockStaffRole = { id: nextRoleId(), roleName, enterpriseAppRole, createdAt: now, updatedAt: now, permissions: emptyPermissions() }; + uiRoles.push(role); + return role; +}; + +/** + * Resets the mocked staff-role backend for a new scenario and records which + * business role the acting staff user holds. Called from the shared staff + * authentication Givens in the staff context. + */ +export function resetStaffRoleUiState(businessRole: StaffBusinessRole): void { + uiRoles = []; + lastMutation = undefined; + currentPath = '/'; + currentAuthRole = businessRole; + currentCustomAuth = undefined; + for (const roleName of DEFAULT_STAFF_ROLE_NAMES) { + addRole(roleName, `Staff.${roleName.replace('Default ', '').replaceAll(' ', '')}`); + } +} + +/** Overrides the business-role auth with a custom fine-grained StaffAuth. */ +export function setScopedStaffAuth(staffAuth: StaffAuth): void { + currentCustomAuth = staffAuth; +} + +/** The StaffAuth the rendered staff-role screens should observe. */ +export function currentStaffAuth(): StaffAuth { + return currentCustomAuth ?? staffAuthByRole[currentAuthRole]; +} + +/** Adds a staff role to the mocked backend when it is not present yet. */ +export function ensureMockStaffRole(roleName: string, enterpriseAppRole: string = DEFAULT_ENTERPRISE_APP_ROLE): void { + if (!uiRoles.some((candidate) => candidate.roleName === roleName)) { + addRole(roleName, enterpriseAppRole); + } +} + +/** Finds a staff role in the mocked backend by role name. */ +export function findMockStaffRoleByName(roleName: string): { id: string; roleName: string } | undefined { + return uiRoles.find((candidate) => candidate.roleName === roleName); +} + +/** Number of staff roles currently held by the mocked backend. */ +export function mockStaffRoleCount(): number { + return uiRoles.length; +} + +/** Outcome of the last mocked staff-role mutation, if any was performed. */ +export function lastStaffRoleMutation(): MockMutationResult | undefined { + return lastMutation; +} + +/** Records the router path the rendered screen navigated to. */ +export function recordCurrentMockPath(path: string): void { + currentPath = path; +} + +/** The router path last recorded by the rendered screen. */ +export function currentMockPath(): string { + return currentPath; +} + +const toListFields = (role: MockStaffRole) => ({ + __typename: 'StaffRole' as const, + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + createdAt: role.createdAt, + updatedAt: role.updatedAt, +}); + +const toEditFields = (role: MockStaffRole) => ({ + __typename: 'StaffRole' as const, + id: role.id, + roleName: role.roleName, + enterpriseAppRole: role.enterpriseAppRole, + permissions: role.permissions, +}); + +interface StaffRoleMutationInput { + id?: string; + roleName?: string; + enterpriseAppRole?: string | null; +} + +/** Builds the dynamic Apollo mocks backing the staff-role screens. */ +export const buildStaffRoleMocks = (): MockedResponse[] => [ + { + request: { query: StaffRolesListDocument }, + maxUsageCount: Number.POSITIVE_INFINITY, + result: () => ({ data: { staffRoles: uiRoles.map(toListFields) } }), + }, + { + request: { query: StaffRoleByIdDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { id?: string }) => { + const role = uiRoles.find((candidate) => candidate.id === variables.id); + return { data: { staffRoleById: role ? toEditFields(role) : null } }; + }, + }, + { + request: { query: StaffRoleCreateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: StaffRoleMutationInput }) => { + const roleName = variables.input?.roleName ?? ''; + if (uiRoles.some((candidate) => candidate.roleName === roleName)) { + lastMutation = { success: false, errorMessage: `Staff role with name ${roleName} already exists` }; + return { + data: { + staffRoleCreate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, + staffRole: null, + }, + }, + }; + } + const role = addRole(roleName, variables.input?.enterpriseAppRole ?? ''); + lastMutation = { success: true }; + return { + data: { + staffRoleCreate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + staffRole: toListFields(role), + }, + }, + }; + }, + }, + { + request: { query: StaffRoleUpdateDocument }, + variableMatcher: () => true, + maxUsageCount: Number.POSITIVE_INFINITY, + result: (variables: { input?: StaffRoleMutationInput }) => { + const role = uiRoles.find((candidate) => candidate.id === variables.input?.id); + if (!role) { + lastMutation = { success: false, errorMessage: 'Staff role not found' }; + return { + data: { + staffRoleUpdate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, + staffRole: null, + }, + }, + }; + } + role.roleName = variables.input?.roleName ?? role.roleName; + role.enterpriseAppRole = variables.input?.enterpriseAppRole ?? role.enterpriseAppRole; + role.updatedAt = new Date().toISOString(); + lastMutation = { success: true }; + return { + data: { + staffRoleUpdate: { + __typename: 'StaffRoleMutationResult' as const, + status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, + staffRole: toEditFields(role), + }, + }, + }; + }, + }, +]; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts new file mode 100644 index 000000000..d34359742 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/notes/staff-role-notes.ts @@ -0,0 +1,4 @@ +/** Scenario-local staff-role UI state shared between tasks and questions via actor notes. */ +export interface StaffRoleUiNotes { + baselineStaffRoleCount: number; +} diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts new file mode 100644 index 000000000..0e39e16da --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-outcome.ts @@ -0,0 +1,19 @@ +import { notes, Question } from '@serenity-js/core'; +import { lastStaffRoleMutation, type MockMutationResult, mockStaffRoleCount } from '../abilities/mock-staff-role-backend.ts'; +import type { StaffRoleUiNotes } from '../notes/staff-role-notes.ts'; + +/** Question that reads the outcome of the last mocked staff-role mutation. */ +export const LastStaffRoleMutation = (): Question> => Question.about('the last staff role mutation outcome', async () => lastStaffRoleMutation()); + +/** Question that reads the current staff role count from the mocked backend. */ +export const MockedStaffRoleCount = (): Question> => Question.about('the mocked staff role count', async () => mockStaffRoleCount()); + +/** Question that reads the staff role count captured before a create attempt. */ +export const BaselineStaffRoleCount = () => + Question.about('the baseline staff role count', async (actor) => { + try { + return await actor.answer(notes().get('baselineStaffRoleCount')); + } catch { + return undefined; + } + }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts new file mode 100644 index 000000000..2f62fa0a0 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/questions/staff-role-screen.ts @@ -0,0 +1,76 @@ +import { Question } from '@serenity-js/core'; +import { feedbackPage, formPageFor, listPageFor, waitUntilUi } from '../tasks/staff-roles-screen.ts'; + +/** Question that reads the staff role names visible in the rendered list. */ +export const ListedStaffRoleNames = () => Question.about('the listed staff role names', async (actor) => await listPageFor(actor).listedRoleNames()); + +/** Question that waits for a staff role to appear in the rendered list. */ +export const StaffRolesListIncludes = (roleName: string) => + Question.about(`whether the staff roles list includes "${roleName}"`, async (actor) => { + const listPage = listPageFor(actor); + try { + await waitUntilUi(() => listPage.hasRoleNamed(roleName), `Expected the staff roles list to include "${roleName}"`); + return true; + } catch { + return false; + } + }); + +/** Question that reads the role name shown by the staff role form. */ +export const FormRoleNameValue = () => Question.about('the staff role form role name', async (actor) => await formPageFor(actor).roleNameValue()); + +/** Question that reads the enterprise app role selected in the staff role form. */ +export const FormEnterpriseAppRole = () => Question.about('the staff role form enterprise app role', async (actor) => await formPageFor(actor).selectedEnterpriseAppRole()); + +/** Question that waits for a success message after a staff-role mutation. */ +export const SuccessFeedbackVisible = () => + Question.about('whether a staff role success message is visible', async () => { + try { + await waitUntilUi(() => feedbackPage().successFeedback.isVisible(), 'Expected a success message'); + return true; + } catch { + return false; + } + }); + +/** Question that waits for an error message containing the given fragment. */ +export const ErrorFeedbackContaining = (expectedFragment: string) => + Question.about(`whether a staff role error containing "${expectedFragment}" is visible`, async () => { + try { + await waitUntilUi(async () => { + const errorFeedback = feedbackPage().errorFeedback; + if (!(await errorFeedback.isVisible())) { + return false; + } + const text = (await errorFeedback.textContent()) ?? ''; + return text.includes(expectedFragment); + }, `Expected a staff role error message containing "${expectedFragment}"`); + return true; + } catch { + return false; + } + }); + +/** Question that waits for a form validation error matching the given pattern. */ +export const ValidationErrorMatching = (expectedPattern: RegExp) => + Question.about(`whether a validation error matching ${expectedPattern} is visible`, async (actor) => { + const formPage = formPageFor(actor); + try { + await waitUntilUi(async () => { + if (!(await formPage.firstValidationError.isVisible())) { + return false; + } + const text = (await formPage.firstValidationError.textContent()) ?? ''; + return expectedPattern.test(text); + }, `Expected a validation error matching ${expectedPattern}`); + return true; + } catch { + return false; + } + }); + +/** Question that answers whether the create staff role action is visible. */ +export const CreateRoleActionVisible = () => Question.about('whether the create staff role action is visible', async (actor) => await listPageFor(actor).createRoleButton.isVisible()); + +/** Question that answers whether any staff role edit action is visible. */ +export const AnyEditActionVisible = () => Question.about('whether any staff role edit action is visible', async (actor) => await listPageFor(actor).hasAnyEditAction()); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx index 19250f93a..14744c8a1 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx @@ -1,420 +1,76 @@ -import type { MockedResponse } from '@apollo/client/testing'; import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; -import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; -import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; -import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; -import { act } from '@testing-library/react'; -import React from 'react'; -import { useLocation } from 'react-router-dom'; -import { StaffRoleByIdDocument, StaffRoleCreateDocument, StaffRolesListDocument, StaffRoleUpdateDocument } from '../../../../../../ocom/ui-staff-route-user-management/src/generated.tsx'; -import { StaffRolesPage } from '../../../../../../ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx'; -import type { StaffAuth } from '../../../../../../ocom/ui-staff-shared/src/staff-route-shell.tsx'; -import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; -import type { StaffUiNotes } from '../../staff/abilities/staff-types.ts'; - -type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; - -const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin']; - -interface MockPermissions { - __typename: 'StaffRolePermissions'; - communityPermissions: { - __typename: 'StaffRoleCommunityPermissions'; - canManageCommunities: boolean; - canManageStaffRolesAndPermissions: boolean; - canManageAllCommunities: boolean; - canDeleteCommunities: boolean; - canChangeCommunityOwner: boolean; - canReIndexSearchCollections: boolean; - }; - userPermissions: { - __typename: 'StaffRoleUserPermissions'; - canManageUsers: boolean; - canAssignStaffRoles: boolean; - canViewStaffUsers: boolean; - }; - staffRolePermissions: { - __typename: 'StaffRoleRolePermissions'; - canViewRoles: boolean; - canAddRole: boolean; - canEditRole: boolean; - canRemoveRole: boolean; - }; - financePermissions: { - __typename: 'StaffRoleFinancePermissions'; - canManageFinance: boolean; - canViewGLBatchSummaries: boolean; - canViewFinanceConfigs: boolean; - canCreateFinanceConfigs: boolean; - }; - techAdminPermissions: { - __typename: 'StaffRoleTechAdminPermissions'; - canManageTechAdmin: boolean; - canViewDatabaseExplorer: boolean; - canViewBlobExplorer: boolean; - canViewQueueDashboard: boolean; - canSendQueueMessages: boolean; - }; -} - -interface MockStaffRole { - id: string; - roleName: string; - enterpriseAppRole: string; - createdAt: string; - updatedAt: string; - permissions: MockPermissions; -} - -const emptyPermissions = (): MockPermissions => ({ - __typename: 'StaffRolePermissions', - communityPermissions: { - __typename: 'StaffRoleCommunityPermissions', - canManageCommunities: false, - canManageStaffRolesAndPermissions: false, - canManageAllCommunities: false, - canDeleteCommunities: false, - canChangeCommunityOwner: false, - canReIndexSearchCollections: false, - }, - userPermissions: { - __typename: 'StaffRoleUserPermissions', - canManageUsers: false, - canAssignStaffRoles: false, - canViewStaffUsers: false, - }, - staffRolePermissions: { - __typename: 'StaffRoleRolePermissions', - canViewRoles: false, - canAddRole: false, - canEditRole: false, - canRemoveRole: false, - }, - financePermissions: { - __typename: 'StaffRoleFinancePermissions', - canManageFinance: false, - canViewGLBatchSummaries: false, - canViewFinanceConfigs: false, - canCreateFinanceConfigs: false, - }, - techAdminPermissions: { - __typename: 'StaffRoleTechAdminPermissions', - canManageTechAdmin: false, - canViewDatabaseExplorer: false, - canViewBlobExplorer: false, - canViewQueueDashboard: false, - canSendQueueMessages: false, - }, -}); - -const staffAuthByRole: Record = { - 'tech admin': { - name: 'Tech Admin Staff', - enterpriseAppRole: 'Staff.TechAdmin', - permissions: { - canManageStaffRolesAndPermissions: true, - canManageTechAdmin: true, - canViewRoles: true, - canAddRole: true, - canEditRole: true, - canRemoveRole: true, - canManageUsers: true, - canAssignStaffRoles: true, - canViewStaffUsers: true, - }, - }, - 'service line owner': { name: 'Service Line Owner Staff', enterpriseAppRole: 'Staff.ServiceLineOwner', permissions: {} }, - finance: { name: 'Finance Staff', enterpriseAppRole: 'Staff.Finance', permissions: { canManageFinance: true } }, - 'case manager': { name: 'Case Manager Staff', enterpriseAppRole: 'Staff.CaseManager', permissions: {} }, -}; - -// Mock backend state shared by the dynamic Apollo mocks below. -let uiRoles: MockStaffRole[] = []; -let baselineRoleCount = 0; -let currentAuthRole: StaffBusinessRole = 'tech admin'; -// When set, overrides the business-role auth with fine-grained permissions. -let currentCustomAuth: StaffAuth | undefined; -let lastMutation: { success: boolean; errorMessage?: string | null } | undefined; -let currentPath = '/'; -let idCounter = 0; - -const nextRoleId = (): string => { - idCounter += 1; - return idCounter.toString(16).padStart(24, '0'); -}; - -const addRole = (roleName: string, enterpriseAppRole: string): MockStaffRole => { - const now = new Date().toISOString(); - const role: MockStaffRole = { id: nextRoleId(), roleName, enterpriseAppRole, createdAt: now, updatedAt: now, permissions: emptyPermissions() }; - uiRoles.push(role); - return role; -}; - -/** - * Resets the mocked staff-role backend for a new scenario and records which - * business role the acting staff user holds. Called from the shared staff - * authentication Givens in the staff context. - */ -export function resetStaffRoleUiState(businessRole: StaffBusinessRole): void { - uiRoles = []; - baselineRoleCount = 0; - lastMutation = undefined; - currentPath = '/'; - currentAuthRole = businessRole; - currentCustomAuth = undefined; - for (const roleName of DEFAULT_STAFF_ROLE_NAMES) { - addRole(roleName, `Staff.${roleName.replace('Default ', '').replaceAll(' ', '')}`); - } -} - -const toListFields = (role: MockStaffRole) => ({ - __typename: 'StaffRole' as const, - id: role.id, - roleName: role.roleName, - enterpriseAppRole: role.enterpriseAppRole, - createdAt: role.createdAt, - updatedAt: role.updatedAt, -}); - -const toEditFields = (role: MockStaffRole) => ({ - __typename: 'StaffRole' as const, - id: role.id, - roleName: role.roleName, - enterpriseAppRole: role.enterpriseAppRole, - permissions: role.permissions, -}); - -interface StaffRoleMutationInput { - id?: string; - roleName?: string; - enterpriseAppRole?: string | null; -} - -const buildMocks = (): MockedResponse[] => [ - { - request: { query: StaffRolesListDocument }, - maxUsageCount: Number.POSITIVE_INFINITY, - result: () => ({ data: { staffRoles: uiRoles.map(toListFields) } }), - }, - { - request: { query: StaffRoleByIdDocument }, - variableMatcher: () => true, - maxUsageCount: Number.POSITIVE_INFINITY, - result: (variables: { id?: string }) => { - const role = uiRoles.find((candidate) => candidate.id === variables.id); - return { data: { staffRoleById: role ? toEditFields(role) : null } }; - }, - }, - { - request: { query: StaffRoleCreateDocument }, - variableMatcher: () => true, - maxUsageCount: Number.POSITIVE_INFINITY, - result: (variables: { input?: StaffRoleMutationInput }) => { - const roleName = variables.input?.roleName ?? ''; - if (uiRoles.some((candidate) => candidate.roleName === roleName)) { - lastMutation = { success: false, errorMessage: `Staff role with name ${roleName} already exists` }; - return { - data: { - staffRoleCreate: { - __typename: 'StaffRoleMutationResult' as const, - status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, - staffRole: null, - }, - }, - }; - } - const role = addRole(roleName, variables.input?.enterpriseAppRole ?? ''); - lastMutation = { success: true }; - return { - data: { - staffRoleCreate: { - __typename: 'StaffRoleMutationResult' as const, - status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, - staffRole: toListFields(role), - }, - }, - }; - }, - }, - { - request: { query: StaffRoleUpdateDocument }, - variableMatcher: () => true, - maxUsageCount: Number.POSITIVE_INFINITY, - result: (variables: { input?: StaffRoleMutationInput }) => { - const role = uiRoles.find((candidate) => candidate.id === variables.input?.id); - if (!role) { - lastMutation = { success: false, errorMessage: 'Staff role not found' }; - return { - data: { - staffRoleUpdate: { - __typename: 'StaffRoleMutationResult' as const, - status: { __typename: 'MutationStatus' as const, success: false, errorMessage: lastMutation.errorMessage }, - staffRole: null, - }, - }, - }; - } - role.roleName = variables.input?.roleName ?? role.roleName; - role.enterpriseAppRole = variables.input?.enterpriseAppRole ?? role.enterpriseAppRole; - role.updatedAt = new Date().toISOString(); - lastMutation = { success: true }; - return { - data: { - staffRoleUpdate: { - __typename: 'StaffRoleMutationResult' as const, - status: { __typename: 'MutationStatus' as const, success: true, errorMessage: null }, - staffRole: toEditFields(role), - }, - }, - }; - }, - }, -]; - -const LocationProbe: React.FC = () => { - const location = useLocation(); - currentPath = location.pathname; - return null; -}; - -async function flushUi(): Promise { - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 0)); - }); -} - -async function waitUntilUi(check: () => Promise, message: string, attempts = 200): Promise { - for (let attempt = 0; attempt < attempts; attempt++) { - if (await check()) { - return; - } - await flushUi(); - } - throw new Error(message); -} - -const listPageFor = (actor: Actor): StaffRolesListPage => new StaffRolesListPage(new DomPageAdapter(RenderInDom.as(actor).container)); -const formPageFor = (actor: Actor): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(RenderInDom.as(actor).container)); - -// antd messages portal to document.body, outside the rendered container. -const feedbackPage = (): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(document.body)); - -async function renderStaffRolesScreen(actorName: string, initialEntries: string[] = ['/']): Promise { - const actor = actorCalled(actorName); - await actor.attemptsTo( - Render.component(React.createElement(React.Fragment, null, React.createElement(LocationProbe), React.createElement(StaffRolesPage)), { - wrapper: wrapOcomComponent({ mocks: buildMocks(), initialEntries, staffAuth: currentCustomAuth ?? staffAuthByRole[currentAuthRole] }), - }), - ); - await flushUi(); - return actor; -} - -async function openRolesListAndWait(actorName: string): Promise { - const actor = await renderStaffRolesScreen(actorName); - const listPage = listPageFor(actor); - await waitUntilUi(async () => (await listPage.listedRoleNames()).length > 0, 'Expected the staff roles list to render rows'); - return actor; -} - -async function openEditFormForRole(actorName: string, roleName: string): Promise { - const actor = await openRolesListAndWait(actorName); - const listPage = listPageFor(actor); - await listPage.clickEditForRole(roleName); - const formPage = formPageFor(actor); - await waitUntilUi(async () => (await formPage.roleNameValue()) === roleName, `Expected the edit form to load the staff role "${roleName}"`); - return actor; -} - -interface StaffRoleFormInput { - roleName?: string; - enterpriseAppRole?: string; -} - -async function submitCreateForm(actorName: string, dataTable: DataTable): Promise { - const { roleName = '', enterpriseAppRole } = GherkinDataTable.from(dataTable).rowsHash(); - baselineRoleCount = uiRoles.length; - const actor = await openRolesListAndWait(actorName); - const listPage = listPageFor(actor); - await listPage.clickCreateRole(); - const formPage = formPageFor(actor); - await waitUntilUi(() => formPage.roleNameInput.isVisible(), 'Expected the create staff role form to render'); - if (roleName) { - await formPage.fillRoleName(roleName); - } - if (enterpriseAppRole) { - await formPage.selectEnterpriseAppRole(enterpriseAppRole); - } - await formPage.clickSubmit(); - await flushUi(); -} +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; +import { ensureMockStaffRole, resetStaffRoleUiState, setScopedStaffAuth } from '../abilities/mock-staff-role-backend.ts'; +import { BaselineStaffRoleCount, LastStaffRoleMutation, MockedStaffRoleCount } from '../questions/staff-role-outcome.ts'; +import { + AnyEditActionVisible, + CreateRoleActionVisible, + ErrorFeedbackContaining, + FormEnterpriseAppRole, + FormRoleNameValue, + ListedStaffRoleNames, + StaffRolesListIncludes, + SuccessFeedbackVisible, + ValidationErrorMatching, +} from '../questions/staff-role-screen.ts'; +import { CreateStaffRoleViaForm, RenameStaffRoleViaForm, type StaffRoleFormInput } from '../tasks/create-staff-role.ts'; +import { OpenStaffRoleEditScreenRecordingRoute, OpenStaffRolesScreenRecordingRoute } from '../tasks/open-staff-role-screens.ts'; +import { OpenEditFormForRole, OpenStaffRolesList } from '../tasks/staff-roles-screen.ts'; const STAFF_ROLE_PERMISSION_FLAGS = ['canViewRoles', 'canAddRole', 'canEditRole', 'canRemoveRole'] as const; type StaffRolePermissionFlag = (typeof STAFF_ROLE_PERMISSION_FLAGS)[number]; -Given('{word} is an authenticated staff user with only the {string} role permission', (_actorName: string, permissionFlag: string) => { +Given('{word} is an authenticated staff user with only the {string} role permission', (actorName: string, permissionFlag: string) => { if (!STAFF_ROLE_PERMISSION_FLAGS.includes(permissionFlag as StaffRolePermissionFlag)) { throw new Error(`Unsupported staff role permission "${permissionFlag}". Expected one of: ${STAFF_ROLE_PERMISSION_FLAGS.join(', ')}`); } resetStaffRoleUiState('case manager'); - currentCustomAuth = { + setScopedStaffAuth({ name: 'Scoped Staff', enterpriseAppRole: 'Staff.CaseManager', permissions: { [permissionFlag]: true }, - }; + }); + actorCalled(actorName); }); Given('a staff role named {string} exists', (roleName: string) => { - if (!uiRoles.some((candidate) => candidate.roleName === roleName)) { - addRole(roleName, 'Staff.CaseManager'); - } + ensureMockStaffRole(roleName); }); When('{word} views the staff roles list', async (actorName: string) => { - await openRolesListAndWait(actorName); + await actorCalled(actorName).attemptsTo(OpenStaffRolesList()); }); When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { - await openEditFormForRole(actorName, roleName); + await actorCalled(actorName).attemptsTo(OpenEditFormForRole(roleName)); }); When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { - await submitCreateForm(actorName, dataTable); + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(details)); }); When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { - await submitCreateForm(actorName, dataTable); + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(details)); }); When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { - const actor = await openEditFormForRole(actorName, currentName); - const formPage = formPageFor(actor); - await formPage.fillRoleName(newName); - await formPage.clickSubmit(); - await flushUi(); + await actorCalled(actorName).attemptsTo(RenameStaffRoleViaForm(currentName, newName)); }); When('{word} opens the staff roles screen', async (actorName: string) => { - const actor = await renderStaffRolesScreen(actorName); - await flushUi(); - await actor.attemptsTo(notes().set('targetRoute', currentPath)); + await actorCalled(actorName).attemptsTo(OpenStaffRolesScreenRecordingRoute()); }); When('{word} opens the edit screen for the staff role {string}', async (actorName: string, roleName: string) => { - const role = uiRoles.find((candidate) => candidate.roleName === roleName); - if (!role) { - throw new Error(`Staff role "${roleName}" is not present in the mocked backend`); - } - const actor = await renderStaffRolesScreen(actorName, [`/edit/${role.id}`]); - await flushUi(); - await actor.attemptsTo(notes().set('targetRoute', currentPath)); + await actorCalled(actorName).attemptsTo(OpenStaffRoleEditScreenRecordingRoute(roleName)); }); Then('the staff roles list should include the default staff roles', async () => { - const listPage = listPageFor(actorInTheSpotlight()); - const listed = await listPage.listedRoleNames(); + const listed = await actorInTheSpotlight().answer(ListedStaffRoleNames()); const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); if (missing.length > 0) { throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); @@ -422,79 +78,82 @@ Then('the staff roles list should include the default staff roles', async () => }); Then('{word} should see the staff role name {string}', async (_actorName: string, expectedName: string) => { - const formPage = formPageFor(actorInTheSpotlight()); - const value = await formPage.roleNameValue(); + const value = await actorInTheSpotlight().answer(FormRoleNameValue()); if (value !== expectedName) { throw new Error(`Expected the staff role form to show role name "${expectedName}", but got "${value}"`); } }); Then('{word} should see the enterprise app role {string}', async (_actorName: string, expectedRole: string) => { - const formPage = formPageFor(actorInTheSpotlight()); - const value = await formPage.selectedEnterpriseAppRole(); + const value = await actorInTheSpotlight().answer(FormEnterpriseAppRole()); if (value !== expectedRole) { throw new Error(`Expected the staff role form to show enterprise app role "${expectedRole}", but got "${value}"`); } }); Then('the staff role should be created successfully', async () => { - await waitUntilUi(() => feedbackPage().successFeedback.isVisible(), 'Expected a success message after creating the staff role'); - if (lastMutation?.success !== true || uiRoles.length !== baselineRoleCount + 1) { + const actor = actorInTheSpotlight(); + if (!(await actor.answer(SuccessFeedbackVisible()))) { + throw new Error('Expected a success message after creating the staff role'); + } + const mutation = await actor.answer(LastStaffRoleMutation()); + const baselineCount = await actor.answer(BaselineStaffRoleCount()); + const currentCount = await actor.answer(MockedStaffRoleCount()); + if (mutation?.success !== true || baselineCount === undefined || currentCount !== baselineCount + 1) { throw new Error('Expected the staff role create mutation to succeed and persist exactly one new role'); } }); Then('the staff role should be updated successfully', async () => { - await waitUntilUi(() => feedbackPage().successFeedback.isVisible(), 'Expected a success message after updating the staff role'); - if (lastMutation?.success !== true) { + const actor = actorInTheSpotlight(); + if (!(await actor.answer(SuccessFeedbackVisible()))) { + throw new Error('Expected a success message after updating the staff role'); + } + const mutation = await actor.answer(LastStaffRoleMutation()); + if (mutation?.success !== true) { throw new Error('Expected the staff role update mutation to succeed'); } }); Then('the staff roles list should include {string}', async (roleName: string) => { - const listPage = listPageFor(actorInTheSpotlight()); - await waitUntilUi(() => listPage.hasRoleNamed(roleName), `Expected the staff roles list to include "${roleName}"`); + if (!(await actorInTheSpotlight().answer(StaffRolesListIncludes(roleName)))) { + throw new Error(`Expected the staff roles list to include "${roleName}"`); + } }); Then('{word} should see a staff role error containing {string}', async (_actorName: string, expectedFragment: string) => { - await waitUntilUi(async () => { - const errorFeedback = feedbackPage().errorFeedback; - if (!(await errorFeedback.isVisible())) { - return false; - } - const text = (await errorFeedback.textContent()) ?? ''; - return text.includes(expectedFragment); - }, `Expected a staff role error message containing "${expectedFragment}"`); + if (!(await actorInTheSpotlight().answer(ErrorFeedbackContaining(expectedFragment)))) { + throw new Error(`Expected a staff role error message containing "${expectedFragment}"`); + } }); Then('{word} should see a staff role validation error for {string}', async (_actorName: string, fieldName: string) => { - const formPage = formPageFor(actorInTheSpotlight()); const expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); - await waitUntilUi(async () => { - if (!(await formPage.firstValidationError.isVisible())) { - return false; - } - const text = (await formPage.firstValidationError.textContent()) ?? ''; - return expectedPattern.test(text); - }, `Expected a validation error for "${fieldName}"`); + if (!(await actorInTheSpotlight().answer(ValidationErrorMatching(expectedPattern)))) { + throw new Error(`Expected a validation error for "${fieldName}"`); + } }); Then('{word} should not see an option to create a staff role', async (_actorName: string) => { - const listPage = listPageFor(actorInTheSpotlight()); - if (await listPage.createRoleButton.isVisible()) { + if (await actorInTheSpotlight().answer(CreateRoleActionVisible())) { throw new Error('Expected the create staff role action to be hidden, but it is visible'); } }); Then('{word} should not see an option to edit a staff role', async (_actorName: string) => { - const listPage = listPageFor(actorInTheSpotlight()); - if (await listPage.hasAnyEditAction()) { + if (await actorInTheSpotlight().answer(AnyEditActionVisible())) { throw new Error('Expected no edit action to be rendered for any staff role, but at least one is visible'); } }); -Then('no additional staff role should be created', () => { - if (uiRoles.length !== baselineRoleCount) { - throw new Error(`Expected no additional staff role to be created, but the role count changed from ${baselineRoleCount} to ${uiRoles.length}`); +Then('no additional staff role should be created', async () => { + const actor = actorInTheSpotlight(); + const baselineCount = await actor.answer(BaselineStaffRoleCount()); + if (baselineCount === undefined) { + throw new Error('No baseline staff role count was recorded. Did the actor attempt to create a staff role first?'); + } + const currentCount = await actor.answer(MockedStaffRoleCount()); + if (currentCount !== baselineCount) { + throw new Error(`Expected no additional staff role to be created, but the role count changed from ${baselineCount} to ${currentCount}`); } }); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts new file mode 100644 index 000000000..0736faf3a --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/create-staff-role.ts @@ -0,0 +1,53 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { mockStaffRoleCount } from '../abilities/mock-staff-role-backend.ts'; +import type { StaffRoleUiNotes } from '../notes/staff-role-notes.ts'; +import { flushUi, formPageFor, listPageFor, OpenEditFormForRole, OpenStaffRolesList, waitUntilUi } from './staff-roles-screen.ts'; + +/** Details captured from the create staff role form Gherkin table. */ +export interface StaffRoleFormInput { + roleName?: string; + enterpriseAppRole?: string; +} + +/** + * Task that opens the create staff role form, fills it, and submits it, + * recording the baseline role count in actor notes for negative-path checks. + */ +export const CreateStaffRoleViaForm = (details: StaffRoleFormInput): Task => + Task.where( + `#actor creates a staff role named "${details.roleName ?? ''}"`, + new TaskStep('#actor fills and submits the create staff role form', async (actor) => { + await actor.attemptsTo(notes().set('baselineStaffRoleCount', mockStaffRoleCount())); + await actor.attemptsTo(OpenStaffRolesList()); + + const listPage = listPageFor(actor); + await listPage.clickCreateRole(); + + const formPage = formPageFor(actor); + await waitUntilUi(() => formPage.roleNameInput.isVisible(), 'Expected the create staff role form to render'); + if (details.roleName) { + await formPage.fillRoleName(details.roleName); + } + if (details.enterpriseAppRole) { + await formPage.selectEnterpriseAppRole(details.enterpriseAppRole); + } + await formPage.clickSubmit(); + await flushUi(); + }), + ); + +/** + * Task that renames a staff role through the edit form. + */ +export const RenameStaffRoleViaForm = (currentName: string, newName: string): Task => + Task.where( + `#actor renames the staff role "${currentName}" to "${newName}"`, + new TaskStep('#actor updates the role name and submits the edit form', async (actor) => { + await actor.attemptsTo(OpenEditFormForRole(currentName)); + const formPage = formPageFor(actor); + await formPage.fillRoleName(newName); + await formPage.clickSubmit(); + await flushUi(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts new file mode 100644 index 000000000..2d2661c97 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/open-staff-role-screens.ts @@ -0,0 +1,37 @@ +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { StaffUiNotes } from '../../staff/abilities/staff-types.ts'; +import { currentMockPath, findMockStaffRoleByName } from '../abilities/mock-staff-role-backend.ts'; +import { flushUi, RenderStaffRolesScreen } from './staff-roles-screen.ts'; + +/** + * Task that opens the staff roles screen and records the route the router + * settled on, so authorization redirects can be asserted. + */ +export const OpenStaffRolesScreenRecordingRoute = (): Task => + Task.where( + '#actor opens the staff roles screen', + new TaskStep('#actor renders the staff roles screen and records the route', async (actor) => { + await actor.attemptsTo(RenderStaffRolesScreen()); + await flushUi(); + await actor.attemptsTo(notes().set('targetRoute', currentMockPath())); + }), + ); + +/** + * Task that opens the edit screen for a staff role directly by route and + * records the route the router settled on. + */ +export const OpenStaffRoleEditScreenRecordingRoute = (roleName: string): Task => + Task.where( + `#actor opens the edit screen for the staff role "${roleName}"`, + new TaskStep('#actor renders the edit screen and records the route', async (actor) => { + const role = findMockStaffRoleByName(roleName); + if (!role) { + throw new Error(`Staff role "${roleName}" is not present in the mocked backend`); + } + await actor.attemptsTo(RenderStaffRolesScreen([`/edit/${role.id}`])); + await flushUi(); + await actor.attemptsTo(notes().set('targetRoute', currentMockPath())); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts new file mode 100644 index 000000000..0dfe5e97f --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts @@ -0,0 +1,86 @@ +import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-dom'; +import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; +import { TaskStep } from '@cellix/serenity-framework/serenity'; +import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import { act } from '@testing-library/react'; +import React from 'react'; +import { useLocation } from 'react-router-dom'; +import { StaffRolesPage } from '../../../../../../ocom/ui-staff-route-user-management/src/pages/staff-roles.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import { buildStaffRoleMocks, currentStaffAuth, recordCurrentMockPath } from '../abilities/mock-staff-role-backend.ts'; + +/** Let pending React work (async form handlers, Apollo mocks) settle. */ +export async function flushUi(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +/** Poll a DOM condition, flushing React work between attempts. */ +export async function waitUntilUi(check: () => Promise, message: string, attempts = 200): Promise { + for (let attempt = 0; attempt < attempts; attempt++) { + if (await check()) { + return; + } + await flushUi(); + } + throw new Error(message); +} + +/** Staff roles list page object scoped to the actor's rendered container. */ +export const listPageFor = (actor: Actor): StaffRolesListPage => new StaffRolesListPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +/** Staff role form page object scoped to the actor's rendered container. */ +export const formPageFor = (actor: Actor): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(RenderInDom.as(actor).container)); + +/** antd messages portal to document.body, outside the rendered container. */ +export const feedbackPage = (): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(document.body)); + +/** Reports the router path the rendered screen navigated to. */ +const LocationProbe: React.FC = () => { + const location = useLocation(); + recordCurrentMockPath(location.pathname); + return null; +}; + +/** + * Task that renders the staff roles screen at the given router entries with + * the mocked staff-role backend and the current staff auth. + */ +export const RenderStaffRolesScreen = (initialEntries: string[] = ['/']): Task => + Task.where( + '#actor opens the staff roles screen', + new TaskStep('#actor renders the staff roles page', async (actor) => { + await actor.attemptsTo( + Render.component(React.createElement(React.Fragment, null, React.createElement(LocationProbe), React.createElement(StaffRolesPage)), { + wrapper: wrapOcomComponent({ mocks: buildStaffRoleMocks(), initialEntries, staffAuth: currentStaffAuth() }), + }), + ); + await flushUi(); + }), + ); + +/** Task that renders the staff roles list and waits for its rows to appear. */ +export const OpenStaffRolesList = (): Task => + Task.where( + '#actor views the staff roles list', + new TaskStep('#actor waits for the staff roles list rows', async (actor) => { + await actor.attemptsTo(RenderStaffRolesScreen()); + const listPage = listPageFor(actor); + await waitUntilUi(async () => (await listPage.listedRoleNames()).length > 0, 'Expected the staff roles list to render rows'); + }), + ); + +/** Task that opens the edit form for a staff role and waits for it to load. */ +export const OpenEditFormForRole = (roleName: string): Task => + Task.where( + `#actor opens the edit form for the staff role "${roleName}"`, + new TaskStep(`#actor navigates to the edit form of "${roleName}"`, async (actor) => { + await actor.attemptsTo(OpenStaffRolesList()); + const listPage = listPageFor(actor); + await listPage.clickEditForRole(roleName); + const formPage = formPageFor(actor); + await waitUntilUi(async () => (await formPage.roleNameValue()) === roleName, `Expected the edit form to load the staff role "${roleName}"`); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts index d1949a6c1..a410e81df 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff/step-definitions/create-staff-landing.steps.ts @@ -1,7 +1,7 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; -import { resetStaffRoleUiState } from '../../staff-role/step-definitions/staff-role-management.steps.tsx'; +import { resetStaffRoleUiState } from '../../staff-role/abilities/mock-staff-role-backend.ts'; import type { StaffUiNotes } from '../abilities/staff-types.ts'; import { StaffTargetRoute } from '../questions/staff-target-route.ts'; import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts new file mode 100644 index 000000000..5bf97d7e3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/notes/staff-role-notes.ts @@ -0,0 +1,5 @@ +/** Scenario-local staff-role E2E state shared between tasks and questions via actor notes. */ +export interface StaffRoleE2ENotes { + listedStaffRoleNames: string[]; + baselineStaffRoleCount: number; +} diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts new file mode 100644 index 000000000..52e1e5278 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts @@ -0,0 +1,87 @@ +import { type Actor, notes, Question } from '@serenity-js/core'; +import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.ts'; +import { formPageOn, openEditFormFor, openRolesList, staffPortalPageOf, waitForReturnToRolesList, waitUntil } from '../tasks/staff-roles-screen.ts'; + +/** Staff role names recorded when the actor last viewed the roles list. */ +export const RecordedStaffRoleNames = () => + Question.about('the staff role names recorded from the roles list', async (actor) => { + try { + return await (actor as unknown as Actor).answer(notes().get('listedStaffRoleNames')); + } catch { + throw new Error('No staff roles list was retrieved. Did the actor view the staff roles list first?'); + } + }); + +/** Waits until the live roles list includes the given role name. */ +export const StaffRolesListIncludes = (roleName: string) => + Question.about(`whether the staff roles list includes "${roleName}"`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const listPage = await openRolesList(page); + await waitUntil(async () => await listPage.hasRoleNamed(roleName), `Expected staff roles list to include "${roleName}", but got: [${(await listPage.listedRoleNames()).join(', ')}]`); + return true; + }); + +/** Waits for the app to navigate back to the staff roles list after a mutation. */ +export const ReturnedToStaffRolesList = (failureMessage: string) => + Question.about('whether the app returned to the staff roles list', async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + await waitForReturnToRolesList(page, failureMessage); + return true; + }); + +/** Whether the edit form for a role shows the given permission as granted. */ +export const StaffRolePermissionGranted = (roleName: string, permissionKey: string) => + Question.about(`whether the staff role "${roleName}" has the permission "${permissionKey}" granted`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const formPage = await openEditFormFor(page, roleName); + return await formPage.isPermissionGranted(permissionKey); + }); + +/** Waits for an error feedback message containing the given fragment. */ +export const StaffRoleErrorContaining = (expectedFragment: string) => + Question.about(`whether a staff role error containing "${expectedFragment}" is shown`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const formPage = formPageOn(page); + await waitUntil(async () => { + if (!(await formPage.errorFeedback.isVisible())) { + return false; + } + const text = (await formPage.errorFeedback.textContent()) ?? ''; + return text.includes(expectedFragment); + }, `Expected a staff role error message containing "${expectedFragment}"`); + return true; + }); + +/** Waits for a validation error matching the given field name. */ +export const StaffRoleValidationErrorFor = (fieldName: string) => + Question.about(`whether a staff role validation error for "${fieldName}" is shown`, async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const formPage = formPageOn(page); + const expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); + await waitUntil(async () => { + if (!(await formPage.firstValidationError.isVisible())) { + return false; + } + const text = (await formPage.firstValidationError.textContent()) ?? ''; + return expectedPattern.test(text); + }, `Expected a validation error for "${fieldName}"`); + return true; + }); + +/** Current number of staff roles shown in the live roles list. */ +export const CurrentStaffRoleCount = () => + Question.about('the current staff role count', async (actor) => { + const page = await staffPortalPageOf(actor as unknown as Actor); + const listPage = await openRolesList(page); + return (await listPage.listedRoleNames()).length; + }); + +/** Baseline staff role count recorded before an attempted creation. */ +export const BaselineStaffRoleCount = () => + Question.about('the baseline staff role count', async (actor) => { + try { + return await (actor as unknown as Actor).answer(notes().get('baselineStaffRoleCount')); + } catch { + throw new Error('No baseline staff role count was recorded. Did the actor attempt to create a staff role first?'); + } + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts index d17d29ffc..33b29d0bd 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -1,179 +1,68 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; import { AfterAll, type DataTable, Given, Then, When } from '@cucumber/cucumber'; -import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; -import { actorCalled, notes } from '@serenity-js/core'; -import type { Page } from 'playwright'; -import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; -import { closeStaffPortalSessions, type StaffPortalBusinessRole, staffPortalPageFor } from '../support/staff-portal-session.ts'; - -const ROLES_LIST_PATH = '/staff/user-management/staff-roles'; -const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin']; -const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; - -interface StaffRoleE2EState { - actorName: string; - role: StaffPortalBusinessRole; - baselineRoleCount: number | undefined; - listedRoleNames: string[] | undefined; -} - -let state: StaffRoleE2EState = { - actorName: 'Alice', - role: 'case manager', - baselineRoleCount: undefined, - listedRoleNames: undefined, -}; - -/** - * Reset the per-scenario staff-role E2E state. Called from the shared - * `authenticated staff user` Givens in the staff context so both the - * staff-landing and staff-role features stay in sync. - */ -export function resetStaffRoleE2EState(actorName: string, role: StaffPortalBusinessRole): void { - state = { actorName, role, baselineRoleCount: undefined, listedRoleNames: undefined }; -} - -const waitUntil = async (condition: () => Promise, failureMessage: string, timeoutMs = 20_000): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await condition()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error(failureMessage); -}; - -const activePage = (): Promise => staffPortalPageFor(state.role); - -const listPageOn = (page: Page): StaffRolesListPage => new StaffRolesListPage(new PlaywrightPageAdapter(page)); - -const formPageOn = (page: Page): StaffRoleFormPage => new StaffRoleFormPage(new PlaywrightPageAdapter(page)); - -const isAtRolesList = (url: URL): boolean => url.pathname.endsWith('/staff-roles'); - -/** Navigate to the staff roles list and wait for the table rows to render. */ -const openRolesList = async (page: Page): Promise => { - await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); - const listPage = listPageOn(page); - await waitUntil(async () => (await listPage.listedRoleNames()).length > 0, 'Timed out waiting for the staff roles list to render'); - return listPage; -}; - -/** Open the create form from the roles list, recording the baseline role count. */ -const openCreateForm = async (page: Page): Promise => { - const listPage = await openRolesList(page); - state.baselineRoleCount = (await listPage.listedRoleNames()).length; - await listPage.clickCreateRole(); - await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); - const formPage = formPageOn(page); - await formPage.roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); - return formPage; -}; - -/** Open the edit form for a role and wait for its values to load. */ -const openEditFormFor = async (page: Page, roleName: string): Promise => { - const listPage = await openRolesList(page); - await listPage.clickEditForRole(roleName); - await page.waitForURL((url) => url.pathname.includes('/staff-roles/edit/'), { timeout: 20_000 }); - const formPage = formPageOn(page); - await waitUntil(async () => (await formPage.roleNameValue()) === roleName, `Timed out waiting for the edit form of staff role "${roleName}" to load`); - return formPage; -}; - -const fillCreateForm = async (formPage: StaffRoleFormPage, fields: Record): Promise => { - await formPage.fillRoleName(fields['roleName'] ?? ''); - const enterpriseAppRole = fields['enterpriseAppRole']; - if (enterpriseAppRole) { - await formPage.selectEnterpriseAppRole(enterpriseAppRole); - } -}; - -const submitCreateForm = async (page: Page, dataTable: DataTable): Promise => { - const formPage = await openCreateForm(page); - await fillCreateForm(formPage, dataTable.rowsHash()); - await formPage.clickSubmit(); -}; - -const waitForReturnToRolesList = async (page: Page, failureMessage: string): Promise => { - await page.waitForURL(isAtRolesList, { timeout: 20_000 }).catch(() => { - throw new Error(`${failureMessage} (still on ${new URL(page.url()).pathname})`); - }); -}; +import { DEFAULT_STAFF_ROLE_NAMES } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight } from '@serenity-js/core'; +import { closeStaffPortalSessions } from '../../../shared/abilities/staff-portal-session.ts'; +import { + BaselineStaffRoleCount, + CurrentStaffRoleCount, + RecordedStaffRoleNames, + ReturnedToStaffRolesList, + StaffRoleErrorContaining, + StaffRolePermissionGranted, + StaffRolesListIncludes, + StaffRoleValidationErrorFor, +} from '../questions/staff-role-screen.ts'; +import { + CreateStaffRoleViaForm, + CreateStaffRoleWithPermissions, + EnsureStaffRoleExists, + GrantStaffRolePermissionViaForm, + OpenStaffRolesScreenRecordingPath, + RenameStaffRoleViaForm, + ViewStaffRolesList, +} from '../tasks/staff-roles-screen.ts'; Given('a staff role named {string} exists', async (roleName: string) => { - const page = await activePage(); - const listPage = await openRolesList(page); - if (await listPage.hasRoleNamed(roleName)) { - return; - } - const formPage = await openCreateForm(page); - await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); - await formPage.clickSubmit(); - await waitForReturnToRolesList(page, `Creating the prerequisite staff role "${roleName}" did not return to the roles list`); - await waitUntil(async () => await listPageOn(page).hasRoleNamed(roleName), `Prerequisite staff role "${roleName}" did not appear in the roles list`); + await actorInTheSpotlight().attemptsTo(EnsureStaffRoleExists(roleName)); }); When('{word} views the staff roles list', async (actorName: string) => { - state.actorName = actorName; - const page = await activePage(); - const listPage = await openRolesList(page); - state.listedRoleNames = await listPage.listedRoleNames(); + await actorCalled(actorName).attemptsTo(ViewStaffRolesList()); }); When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { - state.actorName = actorName; - await submitCreateForm(await activePage(), dataTable); + const fields = GherkinDataTable.from(dataTable).rowsHash>(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(fields)); }); When('{word} attempts to create a staff role with:', async (actorName: string, dataTable: DataTable) => { - state.actorName = actorName; - await submitCreateForm(await activePage(), dataTable); + const fields = GherkinDataTable.from(dataTable).rowsHash>(); + await actorCalled(actorName).attemptsTo(CreateStaffRoleViaForm(fields)); }); When('{word} creates a staff role named {string} with permissions:', async (actorName: string, roleName: string, dataTable: DataTable) => { - state.actorName = actorName; - const page = await activePage(); - const formPage = await openCreateForm(page); - await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); - for (const [permissionKey, value] of Object.entries(dataTable.rowsHash())) { - if (value === 'true') { - await formPage.grantPermission(permissionKey); - } - } - await formPage.clickSubmit(); + const grants = GherkinDataTable.from(dataTable).rowsHash>(); + const permissionKeys = Object.entries(grants) + .filter(([, value]) => value === 'true') + .map(([permissionKey]) => permissionKey); + await actorCalled(actorName).attemptsTo(CreateStaffRoleWithPermissions(roleName, permissionKeys)); }); When('{word} renames the staff role {string} to {string}', async (actorName: string, currentName: string, newName: string) => { - state.actorName = actorName; - const page = await activePage(); - const formPage = await openEditFormFor(page, currentName); - await formPage.fillRoleName(newName); - await formPage.clickSubmit(); + await actorCalled(actorName).attemptsTo(RenameStaffRoleViaForm(currentName, newName)); }); When('{word} grants the permission {string} to the staff role {string}', async (actorName: string, permissionKey: string, roleName: string) => { - state.actorName = actorName; - const page = await activePage(); - const formPage = await openEditFormFor(page, roleName); - await formPage.grantPermission(permissionKey); - await formPage.clickSubmit(); + await actorCalled(actorName).attemptsTo(GrantStaffRolePermissionViaForm(permissionKey, roleName)); }); When('{word} opens the staff roles screen', async (actorName: string) => { - state.actorName = actorName; - const page = await activePage(); - await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); - await page.waitForURL((url) => url.pathname === '/unauthorized' || isAtRolesList(url), { timeout: 20_000 }); - const currentPath = new URL(page.url()).pathname; - await actorCalled(actorName).attemptsTo(notes().set('currentPath', currentPath)); -}); - -Then('the staff roles list should include the default staff roles', () => { - const listed = state.listedRoleNames; - if (!listed) { - throw new Error('No staff roles list was retrieved. Did the actor view the staff roles list first?'); - } + await actorCalled(actorName).attemptsTo(OpenStaffRolesScreenRecordingPath()); +}); + +Then('the staff roles list should include the default staff roles', async () => { + const listed = await actorInTheSpotlight().answer(RecordedStaffRoleNames()); const missing = DEFAULT_STAFF_ROLE_NAMES.filter((name) => !listed.includes(name)); if (missing.length > 0) { throw new Error(`Expected default staff roles [${missing.join(', ')}] to be listed, but got: [${listed.join(', ')}]`); @@ -181,63 +70,38 @@ Then('the staff roles list should include the default staff roles', () => { }); Then('the staff role should be created successfully', async () => { - const page = await activePage(); - await waitForReturnToRolesList(page, 'Expected the staff role to be created and the app to return to the roles list'); + await actorInTheSpotlight().answer(ReturnedToStaffRolesList('Expected the staff role to be created and the app to return to the roles list')); }); Then('the staff role should be updated successfully', async () => { - const page = await activePage(); - await waitForReturnToRolesList(page, 'Expected the staff role to be updated and the app to return to the roles list'); + await actorInTheSpotlight().answer(ReturnedToStaffRolesList('Expected the staff role to be updated and the app to return to the roles list')); }); Then('the staff roles list should include {string}', async (roleName: string) => { - const page = await activePage(); - const listPage = await openRolesList(page); - await waitUntil(async () => await listPage.hasRoleNamed(roleName), `Expected staff roles list to include "${roleName}", but got: [${(await listPage.listedRoleNames()).join(', ')}]`); + await actorInTheSpotlight().answer(StaffRolesListIncludes(roleName)); }); Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { - const page = await activePage(); - const formPage = await openEditFormFor(page, roleName); - if (!(await formPage.isPermissionGranted(permissionKey))) { + const granted = await actorInTheSpotlight().answer(StaffRolePermissionGranted(roleName, permissionKey)); + if (!granted) { throw new Error(`Expected staff role "${roleName}" to have the permission "${permissionKey}" granted`); } }); Then('{word} should see a staff role error containing {string}', async (_actorName: string, expectedFragment: string) => { - const page = await activePage(); - const formPage = formPageOn(page); - await waitUntil(async () => { - if (!(await formPage.errorFeedback.isVisible())) { - return false; - } - const text = (await formPage.errorFeedback.textContent()) ?? ''; - return text.includes(expectedFragment); - }, `Expected a staff role error message containing "${expectedFragment}"`); + await actorInTheSpotlight().answer(StaffRoleErrorContaining(expectedFragment)); }); Then('{word} should see a staff role validation error for {string}', async (_actorName: string, fieldName: string) => { - const page = await activePage(); - const formPage = formPageOn(page); - const expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); - await waitUntil(async () => { - if (!(await formPage.firstValidationError.isVisible())) { - return false; - } - const text = (await formPage.firstValidationError.textContent()) ?? ''; - return expectedPattern.test(text); - }, `Expected a validation error for "${fieldName}"`); + await actorInTheSpotlight().answer(StaffRoleValidationErrorFor(fieldName)); }); Then('no additional staff role should be created', async () => { - if (state.baselineRoleCount === undefined) { - throw new Error('No baseline staff role count was recorded. Did the actor attempt to create a staff role first?'); - } - const page = await activePage(); - const listPage = await openRolesList(page); - const currentCount = (await listPage.listedRoleNames()).length; - if (currentCount !== state.baselineRoleCount) { - throw new Error(`Expected no additional staff role to be created, but the role count changed from ${state.baselineRoleCount} to ${currentCount}`); + const actor = actorInTheSpotlight(); + const baseline = await actor.answer(BaselineStaffRoleCount()); + const currentCount = await actor.answer(CurrentStaffRoleCount()); + if (currentCount !== baseline) { + throw new Error(`Expected no additional staff role to be created, but the role count changed from ${baseline} to ${currentCount}`); } }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts new file mode 100644 index 000000000..6800380f7 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts @@ -0,0 +1,183 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { Page } from 'playwright'; +import { type StaffPortalBusinessRole, staffPortalPageFor } from '../../../shared/abilities/staff-portal-session.ts'; +import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; +import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.ts'; + +const ROLES_LIST_PATH = '/staff/user-management/staff-roles'; +const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +/** Poll a browser condition until it holds or the timeout elapses. */ +export const waitUntil = async (condition: () => Promise, failureMessage: string, timeoutMs = 20_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(failureMessage); +}; + +/** Resolve the authenticated staff-portal page for the actor's business role. */ +export const staffPortalPageOf = async (actor: Actor): Promise => { + let role: StaffPortalBusinessRole = 'case manager'; + try { + role = (await actor.answer(notes().get('businessRole'))) ?? 'case manager'; + } catch { + // No business role noted yet; fall back to the default staff user. + } + return await staffPortalPageFor(role); +}; + +const listPageOn = (page: Page): StaffRolesListPage => new StaffRolesListPage(new PlaywrightPageAdapter(page)); + +export const formPageOn = (page: Page): StaffRoleFormPage => new StaffRoleFormPage(new PlaywrightPageAdapter(page)); + +const isAtRolesList = (url: URL): boolean => url.pathname.endsWith('/staff-roles'); + +/** Navigate to the staff roles list and wait for the table rows to render. */ +export const openRolesList = async (page: Page): Promise => { + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + const listPage = listPageOn(page); + await waitUntil(async () => (await listPage.listedRoleNames()).length > 0, 'Timed out waiting for the staff roles list to render'); + return listPage; +}; + +/** Open the create form from the roles list. */ +const openCreateForm = async (page: Page): Promise => { + const listPage = await openRolesList(page); + await listPage.clickCreateRole(); + await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); + const formPage = formPageOn(page); + await formPage.roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); + return formPage; +}; + +/** Open the edit form for a role and wait for its values to load. */ +export const openEditFormFor = async (page: Page, roleName: string): Promise => { + const listPage = await openRolesList(page); + await listPage.clickEditForRole(roleName); + await page.waitForURL((url) => url.pathname.includes('/staff-roles/edit/'), { timeout: 20_000 }); + const formPage = formPageOn(page); + await waitUntil(async () => (await formPage.roleNameValue()) === roleName, `Timed out waiting for the edit form of staff role "${roleName}" to load`); + return formPage; +}; + +const fillCreateForm = async (formPage: StaffRoleFormPage, fields: Record): Promise => { + await formPage.fillRoleName(fields['roleName'] ?? ''); + const enterpriseAppRole = fields['enterpriseAppRole']; + if (enterpriseAppRole) { + await formPage.selectEnterpriseAppRole(enterpriseAppRole); + } +}; + +/** Wait for the app to navigate back to the staff roles list. */ +export const waitForReturnToRolesList = async (page: Page, failureMessage: string): Promise => { + await page.waitForURL(isAtRolesList, { timeout: 20_000 }).catch(() => { + throw new Error(`${failureMessage} (still on ${new URL(page.url()).pathname})`); + }); +}; + +/** + * Task that opens the staff roles list in the browser and records the listed + * role names in actor notes. + */ +export const ViewStaffRolesList = () => + Interaction.where(the`#actor views the staff roles list`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const listPage = await openRolesList(page); + await actor.attemptsTo(notes().set('listedStaffRoleNames', await listPage.listedRoleNames())); + }); + +/** + * Given-glue task that makes sure a staff role exists, creating it through the + * staff portal UI when absent. + */ +export const EnsureStaffRoleExists = (roleName: string) => + Interaction.where(the`#actor makes sure a staff role named "${roleName}" exists`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const listPage = await openRolesList(page); + if (await listPage.hasRoleNamed(roleName)) { + return; + } + const formPage = await openCreateForm(page); + await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); + await formPage.clickSubmit(); + await waitForReturnToRolesList(page, `Creating the prerequisite staff role "${roleName}" did not return to the roles list`); + await waitUntil(async () => await listPageOn(page).hasRoleNamed(roleName), `Prerequisite staff role "${roleName}" did not appear in the roles list`); + }); + +/** + * Task that opens the create staff role form, fills it, and submits it, + * recording the baseline role count in actor notes for negative-path checks. + */ +export const CreateStaffRoleViaForm = (fields: Record) => + Interaction.where(the`#actor creates a staff role named "${fields['roleName'] ?? ''}" via the staff portal`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const listPage = await openRolesList(page); + await actor.attemptsTo(notes().set('baselineStaffRoleCount', (await listPage.listedRoleNames()).length)); + await listPage.clickCreateRole(); + await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); + const formPage = formPageOn(page); + await formPage.roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); + await fillCreateForm(formPage, fields); + await formPage.clickSubmit(); + }); + +/** + * Task that creates a staff role and grants the given permissions before submitting. + */ +export const CreateStaffRoleWithPermissions = (roleName: string, permissionKeys: string[]) => + Interaction.where(the`#actor creates a staff role named "${roleName}" with permissions via the staff portal`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const formPage = await openCreateForm(page); + await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); + for (const permissionKey of permissionKeys) { + await formPage.grantPermission(permissionKey); + } + await formPage.clickSubmit(); + }); + +/** + * Task that renames a staff role through the edit form. + */ +export const RenameStaffRoleViaForm = (currentName: string, newName: string) => + Interaction.where(the`#actor renames the staff role "${currentName}" to "${newName}" via the staff portal`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const formPage = await openEditFormFor(page, currentName); + await formPage.fillRoleName(newName); + await formPage.clickSubmit(); + }); + +/** + * Task that grants a permission to a staff role through the edit form. + */ +export const GrantStaffRolePermissionViaForm = (permissionKey: string, roleName: string) => + Interaction.where(the`#actor grants the permission "${permissionKey}" to the staff role "${roleName}" via the staff portal`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const formPage = await openEditFormFor(page, roleName); + await formPage.grantPermission(permissionKey); + await formPage.clickSubmit(); + }); + +/** + * Task that navigates to the staff roles screen and records where the router + * settled, so authorization redirects can be asserted. + */ +export const OpenStaffRolesScreenRecordingPath = () => + Interaction.where(the`#actor opens the staff roles screen`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + await page.waitForURL((url) => url.pathname === '/unauthorized' || isAtRolesList(url), { timeout: 20_000 }); + await actor.attemptsTo(notes().set('currentPath', new URL(page.url()).pathname)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts index e7dab9ca2..4a3707340 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/abilities/staff-types.ts @@ -1,3 +1,7 @@ +import type { StaffPortalBusinessRole } from '../../../shared/abilities/staff-portal-session.ts'; + export interface StaffE2ENotes { currentPath: string; + /** Business role of the mock OIDC staff user the scenario authenticated as. */ + businessRole: StaffPortalBusinessRole; } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts index 4476647b2..9b2ab5c1c 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff/step-definitions/staff-landing.steps.ts @@ -1,7 +1,6 @@ import { Given, Then, When } from '@cucumber/cucumber'; import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; -import { resetStaffRoleE2EState } from '../../staff-role/step-definitions/staff-role-management.steps.ts'; import type { StaffE2ENotes } from '../abilities/staff-types.ts'; import { StaffCurrentPath } from '../questions/staff-current-path.ts'; import { OpenStaffLanding } from '../tasks/open-staff-landing.ts'; @@ -37,8 +36,7 @@ Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(actorName, 'case manager'); - resetStaffRoleE2EState(actorName, 'case manager'); - await actor.attemptsTo(notes().set('currentPath', '')); + await actor.attemptsTo(notes().set('currentPath', ''), notes().set('businessRole', 'case manager')); }); Given('{word} is an authenticated {string} staff user', async (actorName: string, roleName: string) => { @@ -46,8 +44,7 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); - resetStaffRoleE2EState(actorName, role); - await actor.attemptsTo(notes().set('currentPath', '')); + await actor.attemptsTo(notes().set('currentPath', ''), notes().set('businessRole', role)); }); When('{word} enters the staff operations workspace', async (actorName: string) => { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/support/staff-portal-session.ts b/packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts similarity index 94% rename from packages/ocom-verification/e2e-tests/src/contexts/staff-role/support/staff-portal-session.ts rename to packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts index d85236839..b1e379b9b 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/support/staff-portal-session.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts @@ -1,6 +1,6 @@ import type { BrowserContext, Page } from 'playwright'; -import { infrastructure } from '../../../infrastructure.ts'; -import { performOAuth2Login } from '../../../shared/abilities/oauth2-login.ts'; +import { infrastructure } from '../../infrastructure.ts'; +import { performOAuth2Login } from './oauth2-login.ts'; /** Business roles supported by the staff portal E2E login flow. */ export type StaffPortalBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; diff --git a/packages/ocom-verification/verification-shared/src/test-data/index.ts b/packages/ocom-verification/verification-shared/src/test-data/index.ts index 696dd0876..b4a279858 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts @@ -1,4 +1,5 @@ export { + DEFAULT_STAFF_ROLE_NAMES, END_USER_IDS, type EndUserSeedDocument, endUsers, diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts index 2ea6031fc..f6d65410b 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts @@ -1,4 +1,4 @@ export { END_USER_IDS, type EndUserSeedDocument, endUsers } from './end-users.ts'; export { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed.ts'; -export { STAFF_ROLE_IDS, type StaffRoleSeedDocument, staffRoles } from './staff-roles.ts'; +export { DEFAULT_STAFF_ROLE_NAMES, STAFF_ROLE_IDS, type StaffRoleSeedDocument, staffRoles } from './staff-roles.ts'; export { STAFF_USER_IDS, type StaffUserSeedDocument, staffUsers } from './staff-users.ts'; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts index ac0bacd0f..78e2a13c2 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts @@ -69,6 +69,13 @@ export const STAFF_ROLE_IDS = { seededCaseManager: 'b00000000000000000000002', } as const; +/** + * Names of the default staff roles the domain creates on startup + * (see `@ocom/domain` staff-role aggregate default-role factories). + * Verification suites assert these appear in the staff roles list. + */ +export const DEFAULT_STAFF_ROLE_NAMES = ['Default Case Manager', 'Default Service Line Owner', 'Default Finance', 'Default Tech Admin'] as const; + function buildPermissions(overrides: Partial): StaffRolePermissionsSeedDocument { const allFalseTickets = { canCreateTickets: false, From 7b7e320daedccdbde61659bd93a6d0f3f8187255 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Fri, 10 Jul 2026 15:37:57 -0400 Subject: [PATCH 8/9] fix(#292): accept UsesAbilities in acceptance-ui staff-role page factories Question.about callbacks receive AnswersQuestions & UsesAbilities, not Actor, so listPageFor/formPageFor taking Actor broke tsc --noEmit in acceptance-ui. RenderInDom.as only requires UsesAbilities, so narrow the helpers instead of casting at each call site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/contexts/staff-role/tasks/staff-roles-screen.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts index 0dfe5e97f..eaa99db41 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/tasks/staff-roles-screen.ts @@ -2,7 +2,7 @@ import { Render, RenderInDom } from '@cellix/serenity-framework/dom/render-in-do import { DomPageAdapter } from '@cellix/serenity-framework/pages/dom'; import { TaskStep } from '@cellix/serenity-framework/serenity'; import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Task } from '@serenity-js/core'; +import { type Actor, Task, type UsesAbilities } from '@serenity-js/core'; import { act } from '@testing-library/react'; import React from 'react'; import { useLocation } from 'react-router-dom'; @@ -29,10 +29,10 @@ export async function waitUntilUi(check: () => Promise, message: string } /** Staff roles list page object scoped to the actor's rendered container. */ -export const listPageFor = (actor: Actor): StaffRolesListPage => new StaffRolesListPage(new DomPageAdapter(RenderInDom.as(actor).container)); +export const listPageFor = (actor: UsesAbilities): StaffRolesListPage => new StaffRolesListPage(new DomPageAdapter(RenderInDom.as(actor).container)); /** Staff role form page object scoped to the actor's rendered container. */ -export const formPageFor = (actor: Actor): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(RenderInDom.as(actor).container)); +export const formPageFor = (actor: UsesAbilities): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(RenderInDom.as(actor).container)); /** antd messages portal to document.body, outside the rendered container. */ export const feedbackPage = (): StaffRoleFormPage => new StaffRoleFormPage(new DomPageAdapter(document.body)); From 3d889fc159f02610952436cf2c3a6a07361eb591 Mon Sep 17 00:00:00 2001 From: Henry Casper Date: Tue, 14 Jul 2026 12:38:50 -0400 Subject: [PATCH 9/9] refactor(#292): split e2e staff-role tasks into per-task files composed of Interactions Address PR #297 follow-up review comment: tasks/staff-roles-screen.ts held all seven tasks plus helpers in one file. Now aligned with the acceptance-api staff-role tasks/ layout, and introduces Serenity Interactions (https://serenity-js.org/handbook/design/screenplay-pattern/#interactions) as reusable command objects composed by tasks via Task.where: - abilities/staff-portal-page.ts: page-access helpers shared by interactions and questions - interactions/: OpenStaffRolesList, OpenCreateStaffRoleForm, OpenEditStaffRoleForm, FillStaffRoleForm, GrantFormPermission, SubmitStaffRoleForm, RecordListedStaffRoleNames, RecordBaselineStaffRoleCount - tasks/: one file per task (create, ensure-exists, rename, grant-permission, view-list, open-screen), duplicated open/fill/submit logic removed No behavior change: task names, step definitions, and questions surface are unchanged; e2e suite passes 24/24. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../staff-role/abilities/staff-portal-page.ts | 64 ++++++ .../interactions/fill-staff-role-form.ts | 30 +++ .../open-create-staff-role-form.ts | 14 ++ .../interactions/open-edit-staff-role-form.ts | 12 ++ .../interactions/open-staff-roles-list.ts | 12 ++ .../interactions/record-staff-role-notes.ts | 27 +++ .../interactions/submit-staff-role-form.ts | 11 ++ .../staff-role/questions/staff-role-screen.ts | 2 +- .../staff-role-management.steps.ts | 15 +- .../staff-role/tasks/create-staff-role.ts | 35 ++++ .../tasks/ensure-staff-role-exists.ts | 24 +++ .../tasks/grant-staff-role-permission.ts | 10 + .../tasks/open-staff-roles-screen.ts | 16 ++ .../staff-role/tasks/rename-staff-role.ts | 10 + .../staff-role/tasks/staff-roles-screen.ts | 183 ------------------ .../staff-role/tasks/view-staff-roles-list.ts | 9 + 16 files changed, 281 insertions(+), 193 deletions(-) create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts delete mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts create mode 100644 packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts new file mode 100644 index 000000000..08cd1ca12 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/abilities/staff-portal-page.ts @@ -0,0 +1,64 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; +import { type AnswersQuestions, notes } from '@serenity-js/core'; +import type { Page } from 'playwright'; +import { type StaffPortalBusinessRole, staffPortalPageFor } from '../../../shared/abilities/staff-portal-session.ts'; +import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; + +export const ROLES_LIST_PATH = '/staff/user-management/staff-roles'; + +/** Poll a browser condition until it holds or the timeout elapses. */ +export const waitUntil = async (condition: () => Promise, failureMessage: string, timeoutMs = 20_000): Promise => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await condition()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(failureMessage); +}; + +/** Resolve the authenticated staff-portal page for the actor's business role. */ +export const staffPortalPageOf = async (actor: AnswersQuestions): Promise => { + let role: StaffPortalBusinessRole = 'case manager'; + try { + role = (await actor.answer(notes().get('businessRole'))) ?? 'case manager'; + } catch { + // No business role noted yet; fall back to the default staff user. + } + return await staffPortalPageFor(role); +}; + +/** Staff roles list page object bound to the given browser page. */ +export const listPageOn = (page: Page): StaffRolesListPage => new StaffRolesListPage(new PlaywrightPageAdapter(page)); + +/** Staff role form page object bound to the given browser page. */ +export const formPageOn = (page: Page): StaffRoleFormPage => new StaffRoleFormPage(new PlaywrightPageAdapter(page)); + +export const isAtRolesList = (url: URL): boolean => url.pathname.endsWith('/staff-roles'); + +/** Navigate to the staff roles list and wait for the table rows to render. */ +export const openRolesList = async (page: Page): Promise => { + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + const listPage = listPageOn(page); + await waitUntil(async () => (await listPage.listedRoleNames()).length > 0, 'Timed out waiting for the staff roles list to render'); + return listPage; +}; + +/** Open the edit form for a role and wait for its values to load. */ +export const openEditFormFor = async (page: Page, roleName: string): Promise => { + const listPage = await openRolesList(page); + await listPage.clickEditForRole(roleName); + await page.waitForURL((url) => url.pathname.includes('/staff-roles/edit/'), { timeout: 20_000 }); + const formPage = formPageOn(page); + await waitUntil(async () => (await formPage.roleNameValue()) === roleName, `Timed out waiting for the edit form of staff role "${roleName}" to load`); + return formPage; +}; + +/** Wait for the app to navigate back to the staff roles list. */ +export const waitForReturnToRolesList = async (page: Page, failureMessage: string): Promise => { + await page.waitForURL(isAtRolesList, { timeout: 20_000 }).catch(() => { + throw new Error(`${failureMessage} (still on ${new URL(page.url()).pathname})`); + }); +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts new file mode 100644 index 000000000..8e92518a4 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/fill-staff-role-form.ts @@ -0,0 +1,30 @@ +import { Interaction, the } from '@serenity-js/core'; +import { formPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that fills the staff role form fields currently on + * screen. Only the provided fields are touched, so it works for both the + * create form and the edit form. + */ +export const FillStaffRoleForm = (fields: Record) => + Interaction.where(the`#actor fills the staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + const formPage = formPageOn(page); + if (fields['roleName'] !== undefined) { + await formPage.fillRoleName(fields['roleName']); + } + const enterpriseAppRole = fields['enterpriseAppRole']; + if (enterpriseAppRole) { + await formPage.selectEnterpriseAppRole(enterpriseAppRole); + } + }); + +/** + * Low-level interaction that grants a single permission on the staff role + * form currently on screen. + */ +export const GrantFormPermission = (permissionKey: string) => + Interaction.where(the`#actor grants the "${permissionKey}" permission on the staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + await formPageOn(page).grantPermission(permissionKey); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts new file mode 100644 index 000000000..17c9f60d2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-create-staff-role-form.ts @@ -0,0 +1,14 @@ +import { Interaction, the } from '@serenity-js/core'; +import { formPageOn, listPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that opens the create staff role form from the roles + * list and waits for the form to be ready. Expects the roles list to be open. + */ +export const OpenCreateStaffRoleForm = () => + Interaction.where(the`#actor opens the create staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + await listPageOn(page).clickCreateRole(); + await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); + await formPageOn(page).roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts new file mode 100644 index 000000000..fb9324983 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-edit-staff-role-form.ts @@ -0,0 +1,12 @@ +import { Interaction, the } from '@serenity-js/core'; +import { openEditFormFor, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that opens the edit form for a staff role from the + * roles list and waits for its values to load. + */ +export const OpenEditStaffRoleForm = (roleName: string) => + Interaction.where(the`#actor opens the edit form for the staff role "${roleName}"`, async (actor) => { + const page = await staffPortalPageOf(actor); + await openEditFormFor(page, roleName); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts new file mode 100644 index 000000000..1c1142f18 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/open-staff-roles-list.ts @@ -0,0 +1,12 @@ +import { Interaction, the } from '@serenity-js/core'; +import { openRolesList, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that navigates the actor's staff portal to the staff + * roles list and waits for the table rows to render. + */ +export const OpenStaffRolesList = () => + Interaction.where(the`#actor opens the staff roles list`, async (actor) => { + const page = await staffPortalPageOf(actor); + await openRolesList(page); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts new file mode 100644 index 000000000..3f3f12804 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/record-staff-role-notes.ts @@ -0,0 +1,27 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { listPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; +import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.ts'; + +/** + * Low-level interaction that reads the currently listed staff role names and + * records them in actor notes for later assertions. Expects the roles list to + * be open (see `OpenStaffRolesList`). + */ +export const RecordListedStaffRoleNames = () => + Interaction.where(the`#actor records the listed staff role names`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + await actor.attemptsTo(notes().set('listedStaffRoleNames', await listPageOn(page).listedRoleNames())); + }); + +/** + * Low-level interaction that records the current staff role count in actor + * notes as the baseline for negative-path assertions. Expects the roles list + * to be open (see `OpenStaffRolesList`). + */ +export const RecordBaselineStaffRoleCount = () => + Interaction.where(the`#actor records the baseline staff role count`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + await actor.attemptsTo(notes().set('baselineStaffRoleCount', (await listPageOn(page).listedRoleNames()).length)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts new file mode 100644 index 000000000..5c1f14b70 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/interactions/submit-staff-role-form.ts @@ -0,0 +1,11 @@ +import { Interaction, the } from '@serenity-js/core'; +import { formPageOn, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Low-level interaction that submits the staff role form currently on screen. + */ +export const SubmitStaffRoleForm = () => + Interaction.where(the`#actor submits the staff role form`, async (actor) => { + const page = await staffPortalPageOf(actor); + await formPageOn(page).clickSubmit(); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts index 52e1e5278..3b179f93f 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/questions/staff-role-screen.ts @@ -1,6 +1,6 @@ import { type Actor, notes, Question } from '@serenity-js/core'; +import { formPageOn, openEditFormFor, openRolesList, staffPortalPageOf, waitForReturnToRolesList, waitUntil } from '../abilities/staff-portal-page.ts'; import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.ts'; -import { formPageOn, openEditFormFor, openRolesList, staffPortalPageOf, waitForReturnToRolesList, waitUntil } from '../tasks/staff-roles-screen.ts'; /** Staff role names recorded when the actor last viewed the roles list. */ export const RecordedStaffRoleNames = () => diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts index 33b29d0bd..57bc833d3 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -13,15 +13,12 @@ import { StaffRolesListIncludes, StaffRoleValidationErrorFor, } from '../questions/staff-role-screen.ts'; -import { - CreateStaffRoleViaForm, - CreateStaffRoleWithPermissions, - EnsureStaffRoleExists, - GrantStaffRolePermissionViaForm, - OpenStaffRolesScreenRecordingPath, - RenameStaffRoleViaForm, - ViewStaffRolesList, -} from '../tasks/staff-roles-screen.ts'; +import { CreateStaffRoleViaForm, CreateStaffRoleWithPermissions } from '../tasks/create-staff-role.ts'; +import { EnsureStaffRoleExists } from '../tasks/ensure-staff-role-exists.ts'; +import { GrantStaffRolePermissionViaForm } from '../tasks/grant-staff-role-permission.ts'; +import { OpenStaffRolesScreenRecordingPath } from '../tasks/open-staff-roles-screen.ts'; +import { RenameStaffRoleViaForm } from '../tasks/rename-staff-role.ts'; +import { ViewStaffRolesList } from '../tasks/view-staff-roles-list.ts'; Given('a staff role named {string} exists', async (roleName: string) => { await actorInTheSpotlight().attemptsTo(EnsureStaffRoleExists(roleName)); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts new file mode 100644 index 000000000..1b38a84e4 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/create-staff-role.ts @@ -0,0 +1,35 @@ +import { Task, the } from '@serenity-js/core'; +import { FillStaffRoleForm, GrantFormPermission } from '../interactions/fill-staff-role-form.ts'; +import { OpenCreateStaffRoleForm } from '../interactions/open-create-staff-role-form.ts'; +import { OpenStaffRolesList } from '../interactions/open-staff-roles-list.ts'; +import { RecordBaselineStaffRoleCount } from '../interactions/record-staff-role-notes.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; + +export const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; + +/** + * Task that opens the create staff role form, fills it, and submits it, + * recording the baseline role count in actor notes for negative-path checks. + */ +export const CreateStaffRoleViaForm = (fields: Record) => + Task.where( + the`#actor creates a staff role named "${fields['roleName'] ?? ''}" via the staff portal`, + OpenStaffRolesList(), + RecordBaselineStaffRoleCount(), + OpenCreateStaffRoleForm(), + FillStaffRoleForm(fields), + SubmitStaffRoleForm(), + ); + +/** + * Task that creates a staff role and grants the given permissions before submitting. + */ +export const CreateStaffRoleWithPermissions = (roleName: string, permissionKeys: string[]) => + Task.where( + the`#actor creates a staff role named "${roleName}" with permissions via the staff portal`, + OpenStaffRolesList(), + OpenCreateStaffRoleForm(), + FillStaffRoleForm({ roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }), + ...permissionKeys.map((permissionKey) => GrantFormPermission(permissionKey)), + SubmitStaffRoleForm(), + ); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts new file mode 100644 index 000000000..6e1e79b0f --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/ensure-staff-role-exists.ts @@ -0,0 +1,24 @@ +import { type Actor, Interaction, the } from '@serenity-js/core'; +import { listPageOn, openRolesList, staffPortalPageOf, waitForReturnToRolesList, waitUntil } from '../abilities/staff-portal-page.ts'; +import { FillStaffRoleForm } from '../interactions/fill-staff-role-form.ts'; +import { OpenCreateStaffRoleForm } from '../interactions/open-create-staff-role-form.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; +import { DEFAULT_ENTERPRISE_APP_ROLE } from './create-staff-role.ts'; + +/** + * Given-glue task that makes sure a staff role exists, creating it through the + * staff portal UI when absent. Conditional, so it is modelled as a single + * interaction that composes the create-form interactions only when needed. + */ +export const EnsureStaffRoleExists = (roleName: string) => + Interaction.where(the`#actor makes sure a staff role named "${roleName}" exists`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + const listPage = await openRolesList(page); + if (await listPage.hasRoleNamed(roleName)) { + return; + } + await actor.attemptsTo(OpenCreateStaffRoleForm(), FillStaffRoleForm({ roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }), SubmitStaffRoleForm()); + await waitForReturnToRolesList(page, `Creating the prerequisite staff role "${roleName}" did not return to the roles list`); + await waitUntil(async () => await listPageOn(page).hasRoleNamed(roleName), `Prerequisite staff role "${roleName}" did not appear in the roles list`); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts new file mode 100644 index 000000000..54ec76c5b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/grant-staff-role-permission.ts @@ -0,0 +1,10 @@ +import { Task, the } from '@serenity-js/core'; +import { GrantFormPermission } from '../interactions/fill-staff-role-form.ts'; +import { OpenEditStaffRoleForm } from '../interactions/open-edit-staff-role-form.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; + +/** + * Task that grants a permission to a staff role through the edit form. + */ +export const GrantStaffRolePermissionViaForm = (permissionKey: string, roleName: string) => + Task.where(the`#actor grants the permission "${permissionKey}" to the staff role "${roleName}" via the staff portal`, OpenEditStaffRoleForm(roleName), GrantFormPermission(permissionKey), SubmitStaffRoleForm()); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts new file mode 100644 index 000000000..28621b68b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/open-staff-roles-screen.ts @@ -0,0 +1,16 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; +import { isAtRolesList, ROLES_LIST_PATH, staffPortalPageOf } from '../abilities/staff-portal-page.ts'; + +/** + * Task that navigates to the staff roles screen and records where the router + * settled, so authorization redirects can be asserted. + */ +export const OpenStaffRolesScreenRecordingPath = () => + Interaction.where(the`#actor opens the staff roles screen`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await staffPortalPageOf(actor); + await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); + await page.waitForURL((url) => url.pathname === '/unauthorized' || isAtRolesList(url), { timeout: 20_000 }); + await actor.attemptsTo(notes().set('currentPath', new URL(page.url()).pathname)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts new file mode 100644 index 000000000..4399e2c27 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/rename-staff-role.ts @@ -0,0 +1,10 @@ +import { Task, the } from '@serenity-js/core'; +import { FillStaffRoleForm } from '../interactions/fill-staff-role-form.ts'; +import { OpenEditStaffRoleForm } from '../interactions/open-edit-staff-role-form.ts'; +import { SubmitStaffRoleForm } from '../interactions/submit-staff-role-form.ts'; + +/** + * Task that renames a staff role through the edit form. + */ +export const RenameStaffRoleViaForm = (currentName: string, newName: string) => + Task.where(the`#actor renames the staff role "${currentName}" to "${newName}" via the staff portal`, OpenEditStaffRoleForm(currentName), FillStaffRoleForm({ roleName: newName }), SubmitStaffRoleForm()); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts deleted file mode 100644 index 6800380f7..000000000 --- a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/staff-roles-screen.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { StaffRoleFormPage, StaffRolesListPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; -import type { Page } from 'playwright'; -import { type StaffPortalBusinessRole, staffPortalPageFor } from '../../../shared/abilities/staff-portal-session.ts'; -import type { StaffE2ENotes } from '../../staff/abilities/staff-types.ts'; -import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.ts'; - -const ROLES_LIST_PATH = '/staff/user-management/staff-roles'; -const DEFAULT_ENTERPRISE_APP_ROLE = 'Staff.CaseManager'; - -/** Poll a browser condition until it holds or the timeout elapses. */ -export const waitUntil = async (condition: () => Promise, failureMessage: string, timeoutMs = 20_000): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (await condition()) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error(failureMessage); -}; - -/** Resolve the authenticated staff-portal page for the actor's business role. */ -export const staffPortalPageOf = async (actor: Actor): Promise => { - let role: StaffPortalBusinessRole = 'case manager'; - try { - role = (await actor.answer(notes().get('businessRole'))) ?? 'case manager'; - } catch { - // No business role noted yet; fall back to the default staff user. - } - return await staffPortalPageFor(role); -}; - -const listPageOn = (page: Page): StaffRolesListPage => new StaffRolesListPage(new PlaywrightPageAdapter(page)); - -export const formPageOn = (page: Page): StaffRoleFormPage => new StaffRoleFormPage(new PlaywrightPageAdapter(page)); - -const isAtRolesList = (url: URL): boolean => url.pathname.endsWith('/staff-roles'); - -/** Navigate to the staff roles list and wait for the table rows to render. */ -export const openRolesList = async (page: Page): Promise => { - await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); - const listPage = listPageOn(page); - await waitUntil(async () => (await listPage.listedRoleNames()).length > 0, 'Timed out waiting for the staff roles list to render'); - return listPage; -}; - -/** Open the create form from the roles list. */ -const openCreateForm = async (page: Page): Promise => { - const listPage = await openRolesList(page); - await listPage.clickCreateRole(); - await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); - const formPage = formPageOn(page); - await formPage.roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); - return formPage; -}; - -/** Open the edit form for a role and wait for its values to load. */ -export const openEditFormFor = async (page: Page, roleName: string): Promise => { - const listPage = await openRolesList(page); - await listPage.clickEditForRole(roleName); - await page.waitForURL((url) => url.pathname.includes('/staff-roles/edit/'), { timeout: 20_000 }); - const formPage = formPageOn(page); - await waitUntil(async () => (await formPage.roleNameValue()) === roleName, `Timed out waiting for the edit form of staff role "${roleName}" to load`); - return formPage; -}; - -const fillCreateForm = async (formPage: StaffRoleFormPage, fields: Record): Promise => { - await formPage.fillRoleName(fields['roleName'] ?? ''); - const enterpriseAppRole = fields['enterpriseAppRole']; - if (enterpriseAppRole) { - await formPage.selectEnterpriseAppRole(enterpriseAppRole); - } -}; - -/** Wait for the app to navigate back to the staff roles list. */ -export const waitForReturnToRolesList = async (page: Page, failureMessage: string): Promise => { - await page.waitForURL(isAtRolesList, { timeout: 20_000 }).catch(() => { - throw new Error(`${failureMessage} (still on ${new URL(page.url()).pathname})`); - }); -}; - -/** - * Task that opens the staff roles list in the browser and records the listed - * role names in actor notes. - */ -export const ViewStaffRolesList = () => - Interaction.where(the`#actor views the staff roles list`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const page = await staffPortalPageOf(actor); - const listPage = await openRolesList(page); - await actor.attemptsTo(notes().set('listedStaffRoleNames', await listPage.listedRoleNames())); - }); - -/** - * Given-glue task that makes sure a staff role exists, creating it through the - * staff portal UI when absent. - */ -export const EnsureStaffRoleExists = (roleName: string) => - Interaction.where(the`#actor makes sure a staff role named "${roleName}" exists`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const page = await staffPortalPageOf(actor); - const listPage = await openRolesList(page); - if (await listPage.hasRoleNamed(roleName)) { - return; - } - const formPage = await openCreateForm(page); - await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); - await formPage.clickSubmit(); - await waitForReturnToRolesList(page, `Creating the prerequisite staff role "${roleName}" did not return to the roles list`); - await waitUntil(async () => await listPageOn(page).hasRoleNamed(roleName), `Prerequisite staff role "${roleName}" did not appear in the roles list`); - }); - -/** - * Task that opens the create staff role form, fills it, and submits it, - * recording the baseline role count in actor notes for negative-path checks. - */ -export const CreateStaffRoleViaForm = (fields: Record) => - Interaction.where(the`#actor creates a staff role named "${fields['roleName'] ?? ''}" via the staff portal`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const page = await staffPortalPageOf(actor); - const listPage = await openRolesList(page); - await actor.attemptsTo(notes().set('baselineStaffRoleCount', (await listPage.listedRoleNames()).length)); - await listPage.clickCreateRole(); - await page.waitForURL((url) => url.pathname.endsWith('/staff-roles/create'), { timeout: 20_000 }); - const formPage = formPageOn(page); - await formPage.roleNameInput.waitFor({ state: 'visible', timeout: 20_000 }); - await fillCreateForm(formPage, fields); - await formPage.clickSubmit(); - }); - -/** - * Task that creates a staff role and grants the given permissions before submitting. - */ -export const CreateStaffRoleWithPermissions = (roleName: string, permissionKeys: string[]) => - Interaction.where(the`#actor creates a staff role named "${roleName}" with permissions via the staff portal`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const page = await staffPortalPageOf(actor); - const formPage = await openCreateForm(page); - await fillCreateForm(formPage, { roleName, enterpriseAppRole: DEFAULT_ENTERPRISE_APP_ROLE }); - for (const permissionKey of permissionKeys) { - await formPage.grantPermission(permissionKey); - } - await formPage.clickSubmit(); - }); - -/** - * Task that renames a staff role through the edit form. - */ -export const RenameStaffRoleViaForm = (currentName: string, newName: string) => - Interaction.where(the`#actor renames the staff role "${currentName}" to "${newName}" via the staff portal`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const page = await staffPortalPageOf(actor); - const formPage = await openEditFormFor(page, currentName); - await formPage.fillRoleName(newName); - await formPage.clickSubmit(); - }); - -/** - * Task that grants a permission to a staff role through the edit form. - */ -export const GrantStaffRolePermissionViaForm = (permissionKey: string, roleName: string) => - Interaction.where(the`#actor grants the permission "${permissionKey}" to the staff role "${roleName}" via the staff portal`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const page = await staffPortalPageOf(actor); - const formPage = await openEditFormFor(page, roleName); - await formPage.grantPermission(permissionKey); - await formPage.clickSubmit(); - }); - -/** - * Task that navigates to the staff roles screen and records where the router - * settled, so authorization redirects can be asserted. - */ -export const OpenStaffRolesScreenRecordingPath = () => - Interaction.where(the`#actor opens the staff roles screen`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const page = await staffPortalPageOf(actor); - await page.goto(ROLES_LIST_PATH, { waitUntil: 'networkidle' }); - await page.waitForURL((url) => url.pathname === '/unauthorized' || isAtRolesList(url), { timeout: 20_000 }); - await actor.attemptsTo(notes().set('currentPath', new URL(page.url()).pathname)); - }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts new file mode 100644 index 000000000..85bc50e6e --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/tasks/view-staff-roles-list.ts @@ -0,0 +1,9 @@ +import { Task, the } from '@serenity-js/core'; +import { OpenStaffRolesList } from '../interactions/open-staff-roles-list.ts'; +import { RecordListedStaffRoleNames } from '../interactions/record-staff-role-notes.ts'; + +/** + * Task that opens the staff roles list in the browser and records the listed + * role names in actor notes. + */ +export const ViewStaffRolesList = () => Task.where(the`#actor views the staff roles list`, OpenStaffRolesList(), RecordListedStaffRoleNames());