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/apps/ui-staff/mock-oidc.users.json b/apps/ui-staff/mock-oidc.users.json index 1c753ea7c..8907eee6b 100644 --- a/apps/ui-staff/mock-oidc.users.json +++ b/apps/ui-staff/mock-oidc.users.json @@ -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/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/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..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,6 +30,24 @@ export class PlaywrightElementHandle implements ElementHandle { return this.locator.getAttribute(name); } + async inputValue(): Promise { + try { + return await this.locator.inputValue(); + } catch { + // Match DomElementHandle: non-input elements yield null instead of throwing. + return null; + } + } + + async isChecked(): Promise { + try { + return await this.locator.isChecked(); + } catch { + // Match DomElementHandle: non-checkbox elements yield false instead of throwing. + return false; + } + } + isVisible(): Promise { return this.locator.isVisible(); } @@ -81,7 +99,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..41de0f3cb 100644 --- a/packages/cellix/serenity-framework/src/pages/page-adapter.ts +++ b/packages/cellix/serenity-framework/src/pages/page-adapter.ts @@ -21,6 +21,22 @@ 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. + * + * 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. + * + * 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. */ isVisible(): Promise; 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/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/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..61dd39c63 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -0,0 +1,206 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +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 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) => { + setActorToken(actorName, null); + actorCalled(actorName); +}); + +Given('a staff role named {string} exists', async (roleName: string) => { + await actorInTheSpotlight().attemptsTo(EnsureStaffRoleExists.named(roleName)); +}); + +When('{word} views the staff roles list', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(ViewStaffRolesList.displayed()); +}); + +When('{word} attempts to view the staff roles list', async (actorName: string) => { + const actor = actorCalled(actorName); + await clearStaffRoleOutcomeNotes(actor); + try { + await actor.attemptsTo(ViewStaffRolesList.displayed()); + } catch (error) { + await actor.attemptsTo(notes().set('lastStaffRoleError', errorMessageOf(error))); + } +}); + +When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(ViewStaffRoleDetails.of(roleName)); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: DataTable) => { + 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 = 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 flatPermissions = GherkinDataTable.from(dataTable).rowsHash>(); + const permissions = Object.fromEntries(Object.entries(flatPermissions).map(([key, value]) => [key, value === 'true'])); + 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) => { + 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) => { + 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) => { + await actorCalled(actorName).attemptsTo(AssignStaffRoleToUser.assign(roleName).to(staffUserDisplayName)); +}); + +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?'); + } + 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 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}', 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}', 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', 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', 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 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 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}', 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 (!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}', 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 = 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: "${capturedError}"`); + } +}); + +Then('no additional staff role should be created', async () => { + 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 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', 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 (!capturedError.toLowerCase().includes('unauthorized')) { + throw new Error(`Expected an unauthorized error, but got: "${capturedError}"`); + } +}); 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 475769e42..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 @@ -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 { setActorToken, staffTokenFor } from '../../../shared/abilities/actor-auth.ts'; type StaffBusinessRole = 'finance' | 'tech admin' | 'service line owner' | 'case manager'; @@ -31,12 +32,21 @@ 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'); + setActorToken(actorName, staffTokenFor(staffActorByBusinessRole['case manager'])); await actor.attemptsTo(notes().set('targetRoute', '')); }); @@ -45,6 +55,7 @@ Given('{word} is an authenticated {string} staff user', async (actorName: string const role = normalizeRole(roleName); const actor = actorCalled(actorName); actorRoles.set(actorName, role); + 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 7b162160a..3a2fedc8b 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -7,7 +7,8 @@ import type { BlobAddress, BlobStorageOperations, ClientUploadOperations, ListBl 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/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/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/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 new file mode 100644 index 000000000..38e66c807 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/staff-role-operations.ts @@ -0,0 +1,184 @@ +/** 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 + 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..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: [() => createGraphQLClientAbility(graphqlUrl(state)), () => createCommunityAbility()], + abilities: [(actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), () => createCommunityAbility(), () => createStaffRoleAbility(), () => updateStaffRoleAbility(), () => assignStaffRoleAbility()], }), }); 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 9cb328852..7bdbe792b 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/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/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..14744c8a1 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/staff-role/step-definitions/staff-role-management.steps.tsx @@ -0,0 +1,159 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +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) => { + 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'); + setScopedStaffAuth({ + name: 'Scoped Staff', + enterpriseAppRole: 'Staff.CaseManager', + permissions: { [permissionFlag]: true }, + }); + actorCalled(actorName); +}); + +Given('a staff role named {string} exists', (roleName: string) => { + ensureMockStaffRole(roleName); +}); + +When('{word} views the staff roles list', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(OpenStaffRolesList()); +}); + +When('{word} views the details of the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(OpenEditFormForRole(roleName)); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: 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) => { + 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) => { + await actorCalled(actorName).attemptsTo(RenameStaffRoleViaForm(currentName, newName)); +}); + +When('{word} opens the staff roles screen', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(OpenStaffRolesScreenRecordingRoute()); +}); + +When('{word} opens the edit screen for the staff role {string}', async (actorName: string, roleName: string) => { + await actorCalled(actorName).attemptsTo(OpenStaffRoleEditScreenRecordingRoute(roleName)); +}); + +Then('the staff roles list should include the default staff roles', async () => { + 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(', ')}]`); + } +}); + +Then('{word} should see the staff role name {string}', async (_actorName: string, expectedName: string) => { + 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 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 () => { + 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 () => { + 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) => { + 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) => { + 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 expectedPattern = fieldName === 'roleName' ? /role name/i : new RegExp(fieldName, 'i'); + 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) => { + 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) => { + 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', 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..eaa99db41 --- /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, type UsesAbilities } 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: 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: 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)); + +/** 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 f50c14577..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,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/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'; @@ -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..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, @@ -13,5 +17,16 @@ "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", + "../../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"] } 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/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/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..3b179f93f --- /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 { formPageOn, openEditFormFor, openRolesList, staffPortalPageOf, waitForReturnToRolesList, waitUntil } from '../abilities/staff-portal-page.ts'; +import type { StaffRoleE2ENotes } from '../notes/staff-role-notes.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/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..57bc833d3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/staff-role/step-definitions/staff-role-management.steps.ts @@ -0,0 +1,107 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { AfterAll, type DataTable, Given, Then, When } from '@cucumber/cucumber'; +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 } 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)); +}); + +When('{word} views the staff roles list', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(ViewStaffRolesList()); +}); + +When('{word} creates a staff role with:', async (actorName: string, dataTable: 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) => { + 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) => { + 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) => { + 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) => { + await actorCalled(actorName).attemptsTo(GrantStaffRolePermissionViaForm(permissionKey, roleName)); +}); + +When('{word} opens the staff roles screen', async (actorName: string) => { + 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(', ')}]`); + } +}); + +Then('the staff role should be created successfully', async () => { + 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 () => { + 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) => { + await actorInTheSpotlight().answer(StaffRolesListIncludes(roleName)); +}); + +Then('the staff role {string} should have the permission {string} granted', async (roleName: string, permissionKey: string) => { + 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) => { + await actorInTheSpotlight().answer(StaffRoleErrorContaining(expectedFragment)); +}); + +Then('{word} should see a staff role validation error for {string}', async (_actorName: string, fieldName: string) => { + await actorInTheSpotlight().answer(StaffRoleValidationErrorFor(fieldName)); +}); + +Then('no additional staff role should be created', async () => { + 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}`); + } +}); + +AfterAll(async () => { + await closeStaffPortalSessions(); +}); 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/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()); 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 8ea22e414..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 @@ -36,7 +36,7 @@ Given('{word} is an authenticated staff user', async (actorName: string) => { lastActorName = actorName; const actor = actorCalled(actorName); actorRoles.set(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) => { @@ -44,7 +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); - 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/shared/abilities/staff-portal-session.ts b/packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts new file mode 100644 index 000000000..b1e379b9b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/abilities/staff-portal-session.ts @@ -0,0 +1,65 @@ +import type { BrowserContext, Page } from 'playwright'; +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'; + +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/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..8550de0da --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/staff-roles-list.page.ts @@ -0,0 +1,73 @@ +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); + } + + /** 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'); + 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..c24a4abfc --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/staff/staff-role-management.feature @@ -0,0 +1,129 @@ +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" + + @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" 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..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,18 @@ -export { END_USER_IDS, type EndUserSeedDocument, endUsers, type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed/index.ts'; +export { + DEFAULT_STAFF_ROLE_NAMES, + 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..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,2 +1,4 @@ export { END_USER_IDS, type EndUserSeedDocument, endUsers } from './end-users.ts'; export { type MongoDBSeedContext, type MongoDBSeedDataFunction, seedDatabase } from './seed.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/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..78e2a13c2 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/staff-roles.ts @@ -0,0 +1,198 @@ +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; + +/** + * 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, + 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 7fb196c84..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.3 serve-handler>minimatch: 3.1.5 serialize-javascript@6.0.2: 7.0.5 serialize-javascript@7.0.4: 7.0.5 @@ -161,12 +161,13 @@ overrides: playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 - protobufjs: 7.6.3 + protobufjs: 7.6.5 ip-address: ^10.1.1 fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - js-yaml@4.1.1: 4.2.0 + js-yaml: 4.3.0 + js-yaml@3.14.2: 3.15.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 @@ -1416,6 +1417,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 @@ -6175,9 +6179,6 @@ packages: '@protobufjs/inquire@1.1.1': resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -7875,12 +7876,8 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} - body-parser@1.20.5: - resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - body-parser@2.2.2: - resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} bonjour-service@1.3.0: @@ -8379,6 +8376,10 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + continuation-local-storage@3.2.1: resolution: {integrity: sha512-jx44cconVqkCEEyLSKWwkvUXwO561jXMa3LPjTPsm5QR22PA0/mhe33FT4Xb5y74JDvt/Cq+5lm8S8rskLv9ZA==} @@ -9734,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'} @@ -9746,6 +9743,10 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + icss-utils@5.1.0: resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} engines: {node: ^10 || ^12 || >= 14} @@ -10200,12 +10201,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsbi@4.3.2: @@ -12048,8 +12049,8 @@ packages: proto-list@1.2.4: resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} - protobufjs@7.6.3: - resolution: {integrity: sha512-+k0vdJKNdW+Vu+dYe8tZA/VvQb6XKNWexC6URwBFXxNnjLJz9nQJCemGyNgRAWD+B7+nGNc9qMPGwcD7s4nzUw==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} proxy-addr@2.0.7: @@ -12122,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'} @@ -13436,9 +13433,9 @@ packages: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} - type-is@2.0.1: - resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} - engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} @@ -14445,7 +14442,7 @@ snapshots: '@apollo/utils.withrequired': 3.0.0 '@graphql-tools/schema': 10.0.30(graphql@16.12.0) async-retry: 1.3.3 - body-parser: 2.2.2(supports-color@8.1.1) + body-parser: 2.3.0 content-type: 1.0.5 cors: 2.8.5 finalhandler: 2.1.1(supports-color@8.1.1) @@ -16487,7 +16484,7 @@ snapshots: '@types/react-router-config': 5.0.11 combine-promises: 1.2.0 fs-extra: 11.3.2 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -16933,7 +16930,7 @@ snapshots: '@docusaurus/utils-common': 3.10.1(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) fs-extra: 11.3.2 joi: 17.13.4 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 tslib: 2.8.1 transitivePeerDependencies: @@ -16958,7 +16955,7 @@ snapshots: globby: 11.1.0 gray-matter: 4.0.3 jiti: 2.6.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 micromatch: 4.0.8 p-queue: 6.6.2 @@ -17534,7 +17531,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 jose: 5.10.0 - js-yaml: 4.2.0 + js-yaml: 4.3.0 lodash: 4.18.1 scuid: 1.1.0 tslib: 2.8.1 @@ -17631,7 +17628,7 @@ snapshots: dependencies: lodash.camelcase: 4.3.0 long: 5.3.2 - protobufjs: 7.6.3 + protobufjs: 7.6.5 yargs: 17.7.2 '@hapi/hoek@9.3.0': {} @@ -18073,7 +18070,7 @@ snapshots: '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.0) - protobufjs: 7.6.3 + protobufjs: 7.6.5 '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.0)': dependencies: @@ -18646,8 +18643,6 @@ snapshots: '@protobufjs/inquire@1.1.1': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -20625,34 +20620,17 @@ snapshots: bn.js@5.2.3: {} - body-parser@1.20.5: - 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.2.2(supports-color@8.1.1): + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 + content-type: 2.0.0 debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.3 on-finished: 2.4.1 qs: 6.15.2 raw-body: 3.0.2 - type-is: 2.0.1 + type-is: 2.1.0 transitivePeerDependencies: - supports-color @@ -21192,6 +21170,8 @@ snapshots: content-type@1.0.5: {} + content-type@2.0.0: {} + continuation-local-storage@3.2.1: dependencies: async-listener: 0.6.10 @@ -21237,7 +21217,7 @@ snapshots: cosmiconfig@8.3.6(typescript@6.0.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -22019,7 +21999,7 @@ snapshots: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 1.20.5 + body-parser: 2.3.0 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 @@ -22526,7 +22506,7 @@ snapshots: gray-matter@4.0.3: dependencies: - js-yaml: 3.14.2 + js-yaml: 3.15.0 kind-of: 6.0.3 section-matter: 1.0.0 strip-bom-string: 1.0.0 @@ -22865,15 +22845,15 @@ snapshots: hyperdyperid@1.2.0: {} - iconv-lite@0.4.24: + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.6.3: + iconv-lite@0.7.0: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.0: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -23283,12 +23263,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.0: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -23416,7 +23396,7 @@ snapshots: fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 minimist: 1.2.8 oxc-resolver: 11.14.0(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) picocolors: 1.1.1 @@ -25585,7 +25565,7 @@ snapshots: proto-list@1.2.4: {} - protobufjs@7.6.3: + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -25593,7 +25573,6 @@ snapshots: '@protobufjs/eventemitter': 1.1.1 '@protobufjs/fetch': 1.1.1 '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 @@ -25664,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 @@ -27162,9 +27134,9 @@ snapshots: media-typer: 0.3.0 mime-types: 2.1.35 - type-is@2.0.1: + type-is@2.1.0: dependencies: - content-type: 1.0.5 + content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b48bb6c08..d72187af9 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 @@ -74,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:" @@ -84,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.3 'serve-handler>minimatch': 3.1.5 'serialize-javascript@6.0.2': 7.0.5 'serialize-javascript@7.0.4': 7.0.5 @@ -109,12 +114,13 @@ overrides: playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 - protobufjs: 7.6.3 + protobufjs: 7.6.5 ip-address: ^10.1.1 fast-uri: ^4.0.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 - 'js-yaml@4.1.1': 4.2.0 + js-yaml: 4.3.0 + 'js-yaml@3.14.2': 3.15.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 @@ -146,3 +152,4 @@ patchedDependencies: minimumReleaseAgeExclude: - fast-uri@3.1.3 || 4.0.1 + - body-parser@1.20.6