diff --git a/.snyk b/.snyk index 8037ac1d3..07371951e 100644 --- a/.snyk +++ b/.snyk @@ -162,7 +162,7 @@ ignore: 'SNYK-JS-OPENTELEMETRYCORE-17373280': - '* > @opentelemetry/core': reason: 'The fix requires migrating the Azure Functions telemetry stack from OpenTelemetry 1.x to 2.8.0 or newer. The current Azure exporter and 0.57.x SDK packages require the 1.x API family; a forced major override is type-compatible at build time but leaves mixed SDK internals. Accepted temporarily while the existing OTEL 2.x migration is completed.' - expires: '2026-07-18T00:00:00.000Z' + expires: '2027-01-18T00:00:00.000Z' created: '2026-06-18T00:00:00.000Z' 'SNYK-JS-BRACEEXPANSION-17706650': - '* > brace-expansion@1.1.13': @@ -172,5 +172,5 @@ ignore: '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' + expires: '2027-01-18T00:00:00.000Z' created: '2026-06-18T00:00:00.000Z' diff --git a/apps/ui-community/mock-oidc.users.json b/apps/ui-community/mock-oidc.users.json index f5518cba9..8724234f1 100644 --- a/apps/ui-community/mock-oidc.users.json +++ b/apps/ui-community/mock-oidc.users.json @@ -34,5 +34,17 @@ "family_name": "Owner", "tid": "test-tenant-id" } + }, + { + "username": "member@test.example", + "sub": "aaaaaaaa-bbbb-1ccc-9ddd-eeeeeeeeee02", + "password": "password", + "oidcConfigName": "end-user", + "claims": { + "email": "member@test.example", + "given_name": "Test", + "family_name": "Member", + "tid": "test-tenant-id" + } } ] diff --git a/packages/cellix/server-oauth2-mock-seedwork/src/login-handlers.ts b/packages/cellix/server-oauth2-mock-seedwork/src/login-handlers.ts index 288dc0313..445e04c44 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/src/login-handlers.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/src/login-handlers.ts @@ -17,6 +17,15 @@ interface LoginSessionStoreEntry { nonce?: string; } +interface CredentialRequestBody { + username?: unknown; + password?: unknown; + nonce?: unknown; + email?: unknown; + given_name?: unknown; + family_name?: unknown; +} + interface TtlStore { get(key: string): T | undefined; set(key: string, value: T): void; @@ -105,12 +114,12 @@ export function createLoginHandlers(deps: LoginHandlerDeps): { res.status(404).send('Login not available'); return; } - const { username, password } = req.body; + const { username, password, nonce: rawBodyNonce } = (req.body ?? {}) as CredentialRequestBody; if (typeof username !== 'string' || typeof password !== 'string') { res.status(400).json({ error: 'username and password are required' }); return; } - const nonceFromBody = typeof req.body.nonce === 'string' ? req.body.nonce : undefined; + const nonceFromBody = typeof rawBodyNonce === 'string' ? rawBodyNonce : undefined; const { nonce: rawQueryNonce } = req.query as Record; const nonceFromQuery = typeof rawQueryNonce === 'string' ? rawQueryNonce : undefined; let loginNonceUsed: string | undefined; @@ -246,15 +255,15 @@ export function createLoginHandlers(deps: LoginHandlerDeps): { res.status(404).send('Signup not available'); return; } - const { username, password } = req.body; + const { username, password, email: rawEmail, given_name: rawGivenName, family_name: rawFamilyName, nonce: rawNonce } = (req.body ?? {}) as CredentialRequestBody; if (typeof username !== 'string' || typeof password !== 'string') { res.status(400).json({ error: 'username and password are required' }); return; } - const email = typeof req.body.email === 'string' ? req.body.email : undefined; - const given_name = typeof req.body.given_name === 'string' ? req.body.given_name : undefined; - const family_name = typeof req.body.family_name === 'string' ? req.body.family_name : undefined; - const signupNonce = typeof req.body.nonce === 'string' ? req.body.nonce : ''; + const email = typeof rawEmail === 'string' ? rawEmail : undefined; + const given_name = typeof rawGivenName === 'string' ? rawGivenName : undefined; + const family_name = typeof rawFamilyName === 'string' ? rawFamilyName : undefined; + const signupNonce = typeof rawNonce === 'string' ? rawNonce : ''; const signupSession = loginSessionStore.get(signupNonce); const redirect = signupSession?.redirectUri ?? primaryRedirectUri; const state = signupSession?.state ?? undefined; @@ -295,7 +304,17 @@ export function createLoginHandlers(deps: LoginHandlerDeps): { res .status(200) .setHeader('Content-Type', 'text/html; charset=utf-8') - .send(buildSignupHtml({ issuerBaseUrl, nonce: signupNonce, username, email, given_name, family_name, error: 'A user with that username already exists. Please choose a different username.' })); + .send( + buildSignupHtml({ + issuerBaseUrl, + nonce: signupNonce, + username, + ...(email === undefined ? {} : { email }), + ...(given_name === undefined ? {} : { given_name }), + ...(family_name === undefined ? {} : { family_name }), + error: 'A user with that username already exists. Please choose a different username.', + }), + ); return; } res.status(409).json({ error: 'user_exists', error_description: message }); diff --git a/packages/cellix/server-oauth2-mock-seedwork/src/router.test.ts b/packages/cellix/server-oauth2-mock-seedwork/src/router.test.ts index a71828ee0..f33621632 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/src/router.test.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/src/router.test.ts @@ -126,6 +126,16 @@ describe('buildOidcRouter', () => { expect(json).toHaveProperty('error', 'Invalid redirect_uri'); }); + it.each(['/token', '/login', '/signup'])('POST %s rejects an unparsed request body', async (path) => { + const res = await fetch(`http://127.0.0.1:${port}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: 'not parsed', + }); + + expect(res.status).toBe(400); + }); + it('POST /signup persists user and rejects duplicate username', async () => { const signupUrl = `http://127.0.0.1:${port}/signup`; const alicePassword = createPassword('alice-password'); diff --git a/packages/cellix/server-oauth2-mock-seedwork/src/router.ts b/packages/cellix/server-oauth2-mock-seedwork/src/router.ts index be411d876..bdcb66bf6 100644 --- a/packages/cellix/server-oauth2-mock-seedwork/src/router.ts +++ b/packages/cellix/server-oauth2-mock-seedwork/src/router.ts @@ -132,7 +132,7 @@ export async function buildOidcRouter(issuerBaseUrl: string, config: MockOAuth2P }); router.post('/token', async (req, res) => { - const { grant_type, tid, code } = req.body as { + const { grant_type, tid, code } = (req.body ?? {}) as { grant_type?: string; refresh_token?: string; tid?: string; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts index 5882baf68..6ccbc2f2d 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/notes/community-notes.ts @@ -2,9 +2,18 @@ export interface CommunityDetails { name: string; } +/** Settings fields accepted by community update flows (all optional). */ +export interface CommunitySettingsDetails { + name?: string; + domain?: string; + whiteLabelDomain?: string; + handle?: string; +} + export interface CommunityNotes { lastCommunityStatus: string; lastCommunityName: string; lastCommunityId: string; lastValidationError: string; + lastViewedCommunityName: string; } diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-field.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-field.ts new file mode 100644 index 000000000..10c435167 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/community-field.ts @@ -0,0 +1,43 @@ +import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import { QueryCommunity } from '../../../shared/abilities/query-community.ts'; +import type { CommunityNotes } from '../notes/community-notes.ts'; + +type CommunityFieldName = 'name' | 'domain' | 'whiteLabelDomain' | 'handle'; + +/** + * Question that reads a single settings field of the community the scenario is + * acting on (the community noted as `lastCommunityId`), straight from the API. + */ +export class CommunityField extends Question> { + static named(fieldName: CommunityFieldName): CommunityField { + return new CommunityField(fieldName); + } + + private constructor(private readonly fieldName: CommunityFieldName) { + super(`community ${fieldName}`); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + const communityId = await this.readNote(actor, 'lastCommunityId'); + if (!communityId) { + throw new Error(`No community id found in actor notes; cannot read the community ${this.fieldName}. Did the actor act on a community first?`); + } + + const community = await QueryCommunity.as(actor).byId(actor, communityId); + if (!community) { + throw new Error(`Community "${communityId}" was not found through the API`); + } + + return String(community[this.fieldName] ?? ''); + } + + override toString = () => `community ${this.fieldName}`; + + private async readNote(actor: AnswersQuestions & UsesAbilities, key: keyof CommunityNotes): Promise { + try { + return await actor.answer(notes>().get(key)); + } catch { + return undefined; + } + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/questions/viewed-community-name.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/viewed-community-name.ts new file mode 100644 index 000000000..744e26fbd --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/questions/viewed-community-name.ts @@ -0,0 +1,32 @@ +import { type AnswersQuestions, notes, Question, type UsesAbilities } from '@serenity-js/core'; +import type { CommunityNotes } from '../notes/community-notes.ts'; + +/** + * Question that reads the community name observed by the actor's most recent + * view flow (recorded in `lastViewedCommunityName`). + */ +export class ViewedCommunityName extends Question> { + static displayed(): ViewedCommunityName { + return new ViewedCommunityName(); + } + + private constructor() { + super('viewed community name'); + } + + override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise { + let viewedName: string | undefined; + try { + viewedName = await actor.answer(notes().get('lastViewedCommunityName')); + } catch { + viewedName = undefined; + } + + if (!viewedName) { + throw new Error('No viewed community name found in actor notes. Did the actor view a community first?'); + } + return viewedName; + } + + override toString = () => 'viewed community name'; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts index af8dcb394..c2c469519 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/create-community.steps.ts @@ -1,24 +1,22 @@ import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; -import { actors } from '@ocom-verification/verification-shared/test-data'; import { actorCalled, notes } from '@serenity-js/core'; import { getRecordedCommunityCreationMessages, resetRecordedQueueMessages } from '../../../mock-application-services.ts'; import type { CommunityDetails, CommunityNotes } from '../notes/community-notes.ts'; import { CommunityName } from '../questions/community-name.ts'; import { CommunityStatus } from '../questions/community-status.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; - -let lastActorName = actors.CommunityOwner.name; +import { getLastActorName, setLastActorName } from './last-actor.ts'; Given('{word} is an authenticated community owner', (actorName: string) => { - lastActorName = actorName; + setLastActorName(actorName); resetRecordedQueueMessages(); actorCalled(actorName); }); When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { - lastActorName = actorName; + setLastActorName(actorName); const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash(); @@ -26,11 +24,23 @@ When('{word} creates a community with:', async (actorName: string, dataTable: Da }); When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { - lastActorName = actorName; - const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash(); + await attemptCommunityCreation(actorName, details); +}); + +When('{word} attempts to create a community with a name of {int} characters', async (actorName: string, nameLength: number) => { + await attemptCommunityCreation(actorName, { name: 'A'.repeat(nameLength) }); +}); + +async function attemptCommunityCreation(actorName: string, details: CommunityDetails): Promise { + setLastActorName(actorName); + const actor = actorCalled(actorName); - await actor.attemptsTo(notes().set('lastCommunityId', undefined as unknown as string), notes().set('lastValidationError', undefined as unknown as string)); + await actor.attemptsTo( + notes().set('lastCommunityId', undefined as unknown as string), + notes().set('lastCommunityStatus', undefined as unknown as string), + notes().set('lastValidationError', undefined as unknown as string), + ); try { await actor.attemptsTo(CreateCommunity.with(details)); @@ -38,10 +48,10 @@ When('{word} attempts to create a community with:', async (actorName: string, da const errorMessage = error instanceof Error ? error.message : String(error); await actor.attemptsTo(notes().set('lastValidationError', errorMessage)); } -}); +} Then('the community should be created successfully', async () => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); const status = await actor.answer(CommunityStatus.of()); if (status !== 'SUCCESS') { @@ -50,7 +60,7 @@ Then('the community should be created successfully', async () => { }); Then('the community name should be {string}', async (expectedName: string) => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); const actualName = await actor.answer(CommunityName.displayed()); if (actualName !== expectedName) { @@ -59,7 +69,7 @@ Then('the community name should be {string}', async (expectedName: string) => { }); Then('a community creation queue message should be recorded', async () => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); const communityId = await actor.answer(notes().get('lastCommunityId')); const communityName = await actor.answer(notes().get('lastCommunityName')); @@ -79,7 +89,7 @@ Then('a community creation queue message should be recorded', async () => { }); Then('{word} should see a community error for {string}', async (actorName: string, fieldName: string) => { - const resolvedActorName = ActorName.resolve(actorName, { defaultName: lastActorName }); + const resolvedActorName = ActorName.resolve(actorName, { defaultName: getLastActorName() }); const actor = actorCalled(resolvedActorName); let storedError: string | undefined; @@ -99,14 +109,14 @@ Then('{word} should see a community error for {string}', async (actorName: strin throw new Error(`Expected a validation error related to "${fieldName}", but got: "${storedError}"`); } - let communityId: string | undefined; + let status: string | undefined; try { - communityId = await actor.answer(notes().get('lastCommunityId')); + status = await actor.answer(notes().get('lastCommunityStatus')); } catch { // expected } - if (communityId) { - throw new Error(`Expected community creation to be blocked by "${fieldName}" validation, but a community was created with id: ${communityId}`); + if (status === 'SUCCESS') { + throw new Error(`Expected the community action to be blocked by "${fieldName}" validation, but it succeeded`); } return; @@ -116,7 +126,7 @@ Then('{word} should see a community error for {string}', async (actorName: strin }); Then('no community should be created', async () => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); let hasValidationError = false; try { diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts index c04c54d61..5cac6d2a0 100644 --- a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/index.ts @@ -1,2 +1,3 @@ // Community context step definitions import './create-community.steps.ts'; +import './update-community-settings.steps.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/last-actor.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/last-actor.ts new file mode 100644 index 000000000..319a91771 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/last-actor.ts @@ -0,0 +1,16 @@ +import { actors } from '@ocom-verification/verification-shared/test-data'; + +/** + * Tracks the most recent actor a community step acted as, so follow-up + * `Then` steps that omit the actor name resolve to the correct actor across + * the community step-definition files. + */ +let lastActorName = actors.CommunityOwner.name; + +export function setLastActorName(actorName: string): void { + lastActorName = actorName; +} + +export function getLastActorName(): string { + return lastActorName; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-community-settings.steps.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-community-settings.steps.ts new file mode 100644 index 000000000..3195ef39a --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/step-definitions/update-community-settings.steps.ts @@ -0,0 +1,199 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { COMMUNITY_IDS, MEMBER_IDS, SEEDED_COMMUNITY } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import { endUserTokenFor, setActorHints, setActorToken, staffTokenFor } from '../../../shared/abilities/actor-auth.ts'; +import { QueryCommunity } from '../../../shared/abilities/query-community.ts'; +import type { CommunityNotes, CommunitySettingsDetails } from '../notes/community-notes.ts'; +import { CommunityField } from '../questions/community-field.ts'; +import { CommunityStatus } from '../questions/community-status.ts'; +import { ViewedCommunityName } from '../questions/viewed-community-name.ts'; +import { UpdateCommunitySettings } from '../tasks/update-community-settings.ts'; +import { ViewCommunityDetails } from '../tasks/view-community-details.ts'; +import { ViewCurrentCommunity } from '../tasks/view-current-community.ts'; +import { getLastActorName, setLastActorName } from './last-actor.ts'; + +async function noteSeededCommunityAsTarget(actorName: string): Promise { + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('lastCommunityId', SEEDED_COMMUNITY.id)); +} + +Given('{word} is an authenticated admin of the seeded community', async (actorName: string) => { + setLastActorName(actorName); + // The CommunityOwner principal (default token) acting through their admin membership. + setActorHints(actorName, { memberId: MEMBER_IDS.seededAdminMember, communityId: COMMUNITY_IDS.seededCommunity }); + await noteSeededCommunityAsTarget(actorName); +}); + +Given('{word} is an authenticated member of the seeded community without settings permissions', async (actorName: string) => { + setLastActorName(actorName); + setActorToken(actorName, endUserTokenFor('CommunityMember')); + setActorHints(actorName, { memberId: MEMBER_IDS.seededPlainMember, communityId: COMMUNITY_IDS.seededCommunity }); + await noteSeededCommunityAsTarget(actorName); +}); + +Given('{word} is an authenticated admin of the other community', async (actorName: string) => { + setLastActorName(actorName); + // An admin of the *other* community browsing their own community portal. + setActorToken(actorName, endUserTokenFor('CommunityMember')); + setActorHints(actorName, { memberId: MEMBER_IDS.otherCommunityAdminMember, communityId: COMMUNITY_IDS.otherCommunity }); + await noteSeededCommunityAsTarget(actorName); +}); + +Given('{word} is a staff user who can manage all communities', async (actorName: string) => { + setLastActorName(actorName); + setActorToken(actorName, staffTokenFor('TechAdminStaff')); + await noteSeededCommunityAsTarget(actorName); +}); + +Given('{word} is a staff user who cannot manage all communities', async (actorName: string) => { + setLastActorName(actorName); + setActorToken(actorName, staffTokenFor('CaseManagerStaff')); + await noteSeededCommunityAsTarget(actorName); +}); + +Given('{word} is an unauthenticated guest', async (actorName: string) => { + setLastActorName(actorName); + setActorToken(actorName, null); + await noteSeededCommunityAsTarget(actorName); +}); + +When('{word} views the current community', async (actorName: string) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + + await actor.attemptsTo(ViewCurrentCommunity.now()); +}); + +When('{word} views the details of the seeded community', async (actorName: string) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + + await actor.attemptsTo(ViewCommunityDetails.ofSeededCommunity()); +}); + +When('{word} updates the community settings with:', async (actorName: string, dataTable: DataTable) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + + await actor.attemptsTo(UpdateCommunitySettings.with(details)); +}); + +When('{word} attempts to update the community settings with:', async (actorName: string, dataTable: DataTable) => { + const details = GherkinDataTable.from(dataTable).rowsHash(); + await attemptCommunitySettingsUpdate(actorName, details); +}); + +When('{word} attempts to update the community settings with a name of {int} characters', async (actorName: string, nameLength: number) => { + await attemptCommunitySettingsUpdate(actorName, { name: 'A'.repeat(nameLength) }); +}); + +async function attemptCommunitySettingsUpdate(actorName: string, details: CommunitySettingsDetails): Promise { + setLastActorName(actorName); + const actor = actorCalled(actorName); + + await actor.attemptsTo(notes().set('lastCommunityStatus', undefined as unknown as string), notes().set('lastValidationError', undefined as unknown as string)); + + try { + await actor.attemptsTo(UpdateCommunitySettings.with(details)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await actor.attemptsTo(notes().set('lastValidationError', errorMessage)); + } +} + +Then('the community settings update should succeed', async () => { + const actor = actorCalled(getLastActorName()); + const status = await actor.answer(CommunityStatus.of()); + + if (status !== 'SUCCESS') { + throw new Error(`Expected community update status "SUCCESS" but got "${status}"`); + } +}); + +Then('the community white label domain should be {string}', async (expectedValue: string) => { + await verifyCommunityField('whiteLabelDomain', expectedValue); +}); + +Then('the community domain should be {string}', async (expectedValue: string) => { + await verifyCommunityField('domain', expectedValue); +}); + +Then('the community handle should be {string}', async (expectedValue: string) => { + await verifyCommunityField('handle', expectedValue); +}); + +async function verifyCommunityField(fieldName: 'domain' | 'whiteLabelDomain' | 'handle', expectedValue: string): Promise { + const actor = actorCalled(getLastActorName()); + const actualValue = await actor.answer(CommunityField.named(fieldName)); + + if (actualValue !== expectedValue) { + throw new Error(`Expected community ${fieldName} "${expectedValue}" but got "${actualValue}"`); + } +} + +Then('{word} should see the community name {string}', async (actorName: string, expectedName: string) => { + const resolvedActorName = ActorName.resolve(actorName, { defaultName: getLastActorName() }); + const actor = actorCalled(resolvedActorName); + const viewedName = await actor.answer(ViewedCommunityName.displayed()); + + if (viewedName !== expectedName) { + throw new Error(`Expected the viewed community name "${expectedName}" but got "${viewedName}"`); + } +}); + +Then('{word} should see a community error containing {string}', async (actorName: string, expectedFragment: string) => { + const resolvedActorName = ActorName.resolve(actorName, { defaultName: getLastActorName() }); + const actor = actorCalled(resolvedActorName); + + let storedError: string | undefined; + try { + storedError = await actor.answer(notes().get('lastValidationError')); + } catch { + // No error in notes + } + + if (!storedError) { + throw new Error(`Expected a community error containing "${expectedFragment}" but none was captured`); + } + + if (!storedError.toLowerCase().includes(expectedFragment.toLowerCase())) { + throw new Error(`Expected a community error containing "${expectedFragment}" but got: "${storedError}"`); + } +}); + +Then('the community update should be rejected as unauthorized', async () => { + const actor = actorCalled(getLastActorName()); + + let storedError: string | undefined; + try { + storedError = await actor.answer(notes().get('lastValidationError')); + } catch { + // No error in notes + } + + if (!storedError) { + throw new Error('Expected the community update to be rejected as unauthorized, but no error was captured'); + } + + if (!/unauthorized/i.test(storedError)) { + throw new Error(`Expected an unauthorized rejection but got: "${storedError}"`); + } +}); + +Then('the community should not be modified', async () => { + // Verify through an independently authenticated actor so the assertion works + // even when the scenario actor is unauthenticated or under-privileged. + const verifier = actorCalled('CommunityBaselineVerifier'); + const community = await QueryCommunity.as(verifier).byId(verifier, SEEDED_COMMUNITY.id); + + if (!community) { + throw new Error(`The seeded community "${SEEDED_COMMUNITY.id}" was not found through the API`); + } + + if (community.name !== SEEDED_COMMUNITY.name) { + throw new Error(`Expected the seeded community to keep its name "${SEEDED_COMMUNITY.name}", but it is now "${community.name}"`); + } +}); diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-community-settings.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-community-settings.ts new file mode 100644 index 000000000..287f16591 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/update-community-settings.ts @@ -0,0 +1,60 @@ +import { SEEDED_COMMUNITY } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { QueryCommunity } from '../../../shared/abilities/query-community.ts'; +import { UpdateCommunity as UpdateCommunityAbility } from '../../../shared/abilities/update-community.ts'; +import type { CommunityNotes, CommunitySettingsDetails } from '../notes/community-notes.ts'; + +/** + * Task that updates the settings of the community the scenario is acting on + * (the community noted as `lastCommunityId`, defaulting to the seeded community). + */ +export class UpdateCommunitySettings extends Task { + static with(details: CommunitySettingsDetails) { + return new UpdateCommunitySettings(details); + } + + private constructor(private readonly details: CommunitySettingsDetails) { + super(`updates the community settings (${Object.keys(details).join(', ')})`); + } + + async performAs(actor: Actor): Promise { + const targetCommunityId = (await readNote(actor, 'lastCommunityId')) ?? SEEDED_COMMUNITY.id; + + const community = await UpdateCommunityAbility.as(actor).performAs(actor, { + id: targetCommunityId, + ...this.details, + }); + + if (community.id !== targetCommunityId) { + throw new Error(`Community update returned "${community.id}" instead of the requested community "${targetCommunityId}"`); + } + + const persistedCommunity = await QueryCommunity.as(actor).byId(actor, targetCommunityId); + if (!persistedCommunity) { + throw new Error(`Community "${targetCommunityId}" was not found after the settings update`); + } + + for (const [fieldName, expectedValue] of Object.entries(this.details) as Array<[keyof CommunitySettingsDetails, string]>) { + const actualValue = String(persistedCommunity[fieldName] ?? ''); + if (actualValue !== expectedValue) { + throw new Error(`Community "${targetCommunityId}" did not persist ${fieldName}: expected "${expectedValue}" but got "${actualValue}"`); + } + } + + await actor.attemptsTo( + notes().set('lastCommunityId', targetCommunityId), + notes().set('lastCommunityName', persistedCommunity.name), + notes().set('lastCommunityStatus', 'SUCCESS'), + ); + } + + override toString = () => `updates the community settings (${Object.keys(this.details).join(', ')})`; +} + +async function readNote(actor: Actor, key: keyof CommunityNotes): Promise { + try { + return (await actor.answer(notes().get(key))) || undefined; + } catch { + return undefined; + } +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/view-community-details.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/view-community-details.ts new file mode 100644 index 000000000..ef275234f --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/view-community-details.ts @@ -0,0 +1,33 @@ +import { SEEDED_COMMUNITY } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import { QueryCommunity } from '../../../shared/abilities/query-community.ts'; +import type { CommunityNotes } from '../notes/community-notes.ts'; + +/** + * Task that fetches a community by id through the API and records the + * viewed details in actor notes for follow-up assertions. + */ +export class ViewCommunityDetails extends Task { + static ofSeededCommunity() { + return new ViewCommunityDetails(SEEDED_COMMUNITY.id); + } + + static ofCommunityWithId(communityId: string) { + return new ViewCommunityDetails(communityId); + } + + private constructor(private readonly communityId: string) { + super(`views the details of community "${communityId}"`); + } + + async performAs(actor: Actor): Promise { + const community = await QueryCommunity.as(actor).byId(actor, this.communityId); + if (!community) { + throw new Error(`Community "${this.communityId}" was not found through the API`); + } + + await actor.attemptsTo(notes().set('lastCommunityId', community.id), notes().set('lastViewedCommunityName', community.name)); + } + + override toString = () => `views the details of community "${this.communityId}"`; +} diff --git a/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/view-current-community.ts b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/view-current-community.ts new file mode 100644 index 000000000..c74bd4ee8 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/contexts/community/tasks/view-current-community.ts @@ -0,0 +1,29 @@ +import { type Actor, notes, Task } from '@serenity-js/core'; +import { QueryCommunity } from '../../../shared/abilities/query-community.ts'; +import type { CommunityNotes } from '../notes/community-notes.ts'; + +/** + * Task that fetches the current community (resolved from the actor's + * principal hints, like the community portal does) and records the viewed + * details in actor notes for follow-up assertions. + */ +export class ViewCurrentCommunity extends Task { + static now() { + return new ViewCurrentCommunity(); + } + + private constructor() { + super('views the current community'); + } + + async performAs(actor: Actor): Promise { + const community = await QueryCommunity.as(actor).current(actor); + if (!community) { + throw new Error('No current community was resolved for the actor. Are the community principal hints registered?'); + } + + await actor.attemptsTo(notes().set('lastCommunityId', community.id), notes().set('lastViewedCommunityName', community.name)); + } + + override toString = () => 'views the current community'; +} 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 3e66f8b97..227e3944f 100644 --- a/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts +++ b/packages/ocom-verification/acceptance-api/src/cucumber-lifecycle-hooks.ts @@ -1,8 +1,10 @@ import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber'; import { getTimeout } from '@cellix/serenity-framework/settings'; import type { IWorld } from '@cucumber/cucumber'; +import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; import { isAgent } from 'std-env'; import { infrastructure } from './infrastructure.ts'; +import { mongoDbName, testMongoServer } from './servers/test-mongo-server.ts'; import { clearActorTokens } from './shared/abilities/actor-auth.ts'; import type { CellixApiWorld } from './world.ts'; @@ -20,6 +22,11 @@ export function registerLifecycleHooks(): void { } clearActorTokens(); + // Restore the seeded fixtures so update scenarios always start from a + // known baseline regardless of what previous scenarios modified. + if (testMongoServer.isRunning()) { + await seedDatabase({ connectionString: testMongoServer.getConnectionString(), dbName: mongoDbName }); + } 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 3a2fedc8b..552f15d2c 100644 --- a/packages/ocom-verification/acceptance-api/src/mock-application-services.ts +++ b/packages/ocom-verification/acceptance-api/src/mock-application-services.ts @@ -8,7 +8,7 @@ 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, getActor } from '@ocom-verification/verification-shared/test-data'; -import { STAFF_TOKEN_PREFIX } from './shared/abilities/actor-auth.ts'; +import { END_USER_TOKEN_PREFIX, STAFF_TOKEN_PREFIX } from './shared/abilities/actor-auth.ts'; interface RecordedCommunityCreationMessage { communityId: string; @@ -39,7 +39,9 @@ function createMockTokenValidation(): TokenValidation { openIdConfigKey: 'StaffPortal', }); } - const actor = actors.CommunityOwner; + // End-user tokens (e.g. "enduser:CommunityMember") resolve to a specific + // AccountPortal principal; any other token resolves to the CommunityOwner. + const actor = token.startsWith(END_USER_TOKEN_PREFIX) ? getActor(token.slice(END_USER_TOKEN_PREFIX.length)) : actors.CommunityOwner; return Promise.resolve({ verifiedJwt: { given_name: actor.givenName, @@ -149,7 +151,11 @@ export function createMockApplicationServicesFactory(serviceMongoose: ServiceMon queueStorageService, }; - // Pass the raw auth header through so scenarios can act as differently - // privileged (or unauthenticated) principals via per-actor test tokens. - return buildApplicationServicesFactory(apiContextSpec); + const mockApplicationServicesFactory = buildApplicationServicesFactory(apiContextSpec); + + return { + forRequest: (rawAuthHeader, hints) => { + return mockApplicationServicesFactory.forRequest(rawAuthHeader, hints); + }, + }; } diff --git a/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts b/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts index 5c3ce3677..6ee5b6155 100644 --- a/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts +++ b/packages/ocom-verification/acceptance-api/src/servers/api-graphql-test-server.ts @@ -20,7 +20,13 @@ class ApiGraphQLTestServer implements TestServer { validationRules: [depthLimit(10)], context: async ({ req }) => { this.applicationServicesFactory ??= createMockApplicationServicesFactory(mongooseTestServer.getService()); - const applicationServices = await this.applicationServicesFactory.forRequest(req.headers.authorization ?? undefined); + // Mirror the production graphql-handler: principal hints travel as + // x-member-id / x-community-id headers alongside the auth token. + const headerValue = (value: string | string[] | undefined): string | undefined => (Array.isArray(value) ? value[0] : value); + const applicationServices = await this.applicationServicesFactory.forRequest(req.headers.authorization ?? undefined, { + memberId: headerValue(req.headers['x-member-id']), + communityId: headerValue(req.headers['x-community-id']), + }); if (!applicationServices) { throw new Error('ApplicationServicesFactory required for test server'); } 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 index cfe1afb82..e9fb969b2 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/actor-auth.ts @@ -1,24 +1,41 @@ /** * 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. + * Step definitions register a test token (and optional principal hints) per + * Serenity actor name; the GraphQL client ability resolves them lazily on + * every request, so a single cast supports scenarios with differently + * privileged (or unauthenticated) actors. */ const tokensByActorName = new Map(); +/** Principal hints forwarded as `x-member-id` / `x-community-id` headers. */ +interface ActorPrincipalHints { + memberId?: string; + communityId?: string; +} + +const hintsByActorName = new Map(); + /** Default token: resolves to the CommunityOwner AccountPortal principal in the mock token validation. */ -const DEFAULT_TEST_AUTH_TOKEN = 'Bearer test-token'; +const DEFAULT_TEST_AUTH_TOKEN = 'test-auth-token'; /** Prefix for tokens that resolve to a staff principal in the mock token validation. */ export const STAFF_TOKEN_PREFIX = 'staff:'; +/** Prefix for tokens that resolve to a specific end-user AccountPortal principal in the mock token validation. */ +export const END_USER_TOKEN_PREFIX = 'enduser:'; + /** Build the token for a staff test actor (e.g. `staff:TechAdminStaff`). */ export function staffTokenFor(actorName: string): string { return `${STAFF_TOKEN_PREFIX}${actorName}`; } +/** Build the token for an end-user test actor (e.g. `enduser:CommunityMember`). */ +export function endUserTokenFor(actorName: string): string { + return `${END_USER_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); @@ -29,7 +46,18 @@ 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). */ +/** Register the principal hints (member/community) sent by the given Serenity actor. */ +export function setActorHints(actorName: string, hints: ActorPrincipalHints): void { + hintsByActorName.set(actorName, hints); +} + +/** Resolve the principal hints for an actor, if any were registered. */ +export function getActorHints(actorName: string): ActorPrincipalHints | undefined { + return hintsByActorName.get(actorName); +} + +/** Clear all registered actor tokens and hints (called between scenarios). */ export function clearActorTokens(): void { tokensByActorName.clear(); + hintsByActorName.clear(); } diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/graphql-client.ts index e3d0e6e31..65f71d077 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,12 +1,17 @@ import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; -import { getActorToken } from './actor-auth.ts'; +import { getActorHints, getActorToken } from './actor-auth.ts'; export function createGraphQLClientAbility(apiUrl: string, actorName: string): GraphQLClient { return new GraphQLClient({ apiUrl, headers: () => { const token = getActorToken(actorName); - return token === null ? {} : { Authorization: token }; + const hints = getActorHints(actorName); + return { + ...(token === null ? {} : { Authorization: token }), + ...(hints?.memberId ? { 'x-member-id': hints.memberId } : {}), + ...(hints?.communityId ? { 'x-community-id': hints.communityId } : {}), + }; }, }); } 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 41d8d8913..f69b501dd 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/index.ts @@ -2,4 +2,6 @@ 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 { type CommunityReadResult, QueryCommunity, queryCommunityAbility } from './query-community.ts'; +export { UpdateCommunity, type UpdateCommunityDetails, type UpdateCommunityResult, updateCommunityAbility } from './update-community.ts'; export { updateStaffRoleAbility } from './update-staff-role.ts'; diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/query-community.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/query-community.ts new file mode 100644 index 000000000..732654d3b --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/query-community.ts @@ -0,0 +1,53 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type UsesAbilities } from '@serenity-js/core'; +import { CURRENT_COMMUNITY_QUERY, GET_COMMUNITY_QUERY } from '../graphql/community-operations.ts'; + +/** Community fields returned by the API read flows. */ +export interface CommunityReadResult { + id: string; + name: string; + domain?: string | null; + whiteLabelDomain?: string | null; + handle?: string | null; +} + +/** + * Serenity ability that reads communities through the API verification server. + */ +export class QueryCommunity extends Ability { + // biome-ignore lint/complexity/noUselessConstructor: widens Serenity's protected Ability constructor so the world cast can instantiate this ability + constructor() { + super(); + } + + /** Fetch a community by id. Returns `null` when the community does not exist. */ + async byId(actor: UsesAbilities, id: string): Promise { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(GET_COMMUNITY_QUERY, { id }); + return toCommunityReadResult(response.data['communityById'] as Record | null | undefined); + } + + /** Fetch the current community resolved from the actor's principal hints. */ + async current(actor: UsesAbilities): Promise { + const graphql = GraphQLClient.as(actor); + const response = await graphql.execute(CURRENT_COMMUNITY_QUERY); + return toCommunityReadResult(response.data['currentCommunity'] as Record | null | undefined); + } +} + +function toCommunityReadResult(community: Record | null | undefined): CommunityReadResult | null { + if (!community) { + return null; + } + return { + id: String(community['id'] ?? ''), + name: String(community['name'] ?? ''), + domain: (community['domain'] as string | null | undefined) ?? null, + whiteLabelDomain: (community['whiteLabelDomain'] as string | null | undefined) ?? null, + handle: (community['handle'] as string | null | undefined) ?? null, + }; +} + +export function queryCommunityAbility(): QueryCommunity { + return new QueryCommunity(); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/abilities/update-community.ts b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-community.ts new file mode 100644 index 000000000..f7994a666 --- /dev/null +++ b/packages/ocom-verification/acceptance-api/src/shared/abilities/update-community.ts @@ -0,0 +1,72 @@ +import { GraphQLClient } from '@cellix/serenity-framework/clients/graphql'; +import { Ability, type Actor } from '@serenity-js/core'; +import { COMMUNITY_UPDATE_SETTINGS_MUTATION } from '../graphql/community-operations.ts'; + +/** Community settings fields accepted by the API update flow. */ +export interface UpdateCommunityDetails { + id: string; + name?: string; + domain?: string; + whiteLabelDomain?: string; + handle?: string; +} + +/** Community fields returned by the API update flow. */ +export interface UpdateCommunityResult { + id: string; + name: string; + domain?: string | null; + whiteLabelDomain?: string | null; + handle?: string | null; +} + +/** Handler that performs a community settings update for an API actor. */ +type UpdateCommunityHandler = (actor: Actor, details: UpdateCommunityDetails) => Promise; + +/** + * Serenity ability that updates community settings through the API verification server. + */ +export class UpdateCommunity extends Ability { + constructor(private readonly handler: UpdateCommunityHandler) { + super(); + } + + static using(handler: UpdateCommunityHandler): UpdateCommunity { + return new UpdateCommunity(handler); + } + + async performAs(actor: Actor, details: UpdateCommunityDetails): Promise { + return await this.handler(actor, details); + } +} + +export function updateCommunityAbility(): UpdateCommunity { + return UpdateCommunity.using(async (actor, details) => { + const graphql = GraphQLClient.as(actor); + const { id, ...fields } = details; + const response = await graphql.execute(COMMUNITY_UPDATE_SETTINGS_MUTATION, { + input: { id, ...fields }, + }); + + const mutationResult = response.data['communityUpdateSettings'] as Record; + const status = mutationResult?.['status'] as Record | undefined; + const community = mutationResult?.['community'] as Record | undefined; + + if (status?.['success'] !== true) { + throw new Error(String(status?.['errorMessage'] ?? 'Failed to update community settings')); + } + + const communityId = String(community?.['id'] ?? ''); + if (!communityId) { + throw new Error('API communityUpdateSettings returned a community without an id'); + } + + return { + id: communityId, + name: String(community?.['name'] ?? ''), + domain: (community?.['domain'] as string | null | undefined) ?? null, + whiteLabelDomain: (community?.['whiteLabelDomain'] as string | null | undefined) ?? null, + handle: (community?.['handle'] as string | null | undefined) ?? null, + }; + }); +} diff --git a/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts b/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts index f239508d5..591550089 100644 --- a/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts +++ b/packages/ocom-verification/acceptance-api/src/shared/graphql/community-operations.ts @@ -13,11 +13,44 @@ export const COMMUNITY_CREATE_MUTATION = ` } `; +export const COMMUNITY_UPDATE_SETTINGS_MUTATION = ` + mutation CommunityUpdateSettings($input: CommunityUpdateSettingsInput!) { + communityUpdateSettings(input: $input) { + status { + success + errorMessage + } + community { + id + name + domain + whiteLabelDomain + handle + } + } + } +`; + export const GET_COMMUNITY_QUERY = ` query CommunityById($id: ObjectID!) { communityById(id: $id) { id name + domain + whiteLabelDomain + handle + } + } +`; + +export const CURRENT_COMMUNITY_QUERY = ` + query CurrentCommunity { + currentCommunity { + id + name + domain + whiteLabelDomain + handle } } `; diff --git a/packages/ocom-verification/acceptance-api/src/world.ts b/packages/ocom-verification/acceptance-api/src/world.ts index f2a1db5e4..2be8330bf 100644 --- a/packages/ocom-verification/acceptance-api/src/world.ts +++ b/packages/ocom-verification/acceptance-api/src/world.ts @@ -7,6 +7,8 @@ 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 { queryCommunityAbility } from './shared/abilities/query-community.ts'; +import { updateCommunityAbility } from './shared/abilities/update-community.ts'; import { updateStaffRoleAbility } from './shared/abilities/update-staff-role.ts'; export const CellixApiWorld = registerManagedSerenityWorld({ @@ -19,7 +21,15 @@ export const CellixApiWorld = registerManagedSerenityWorld({ createCast: (state) => new SerenityCast({ useNotepad: true, - abilities: [(actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), () => createCommunityAbility(), () => createStaffRoleAbility(), () => updateStaffRoleAbility(), () => assignStaffRoleAbility()], + abilities: [ + (actor) => createGraphQLClientAbility(graphqlUrl(state), actor.name), + () => createCommunityAbility(), + () => updateCommunityAbility(), + () => queryCommunityAbility(), + () => createStaffRoleAbility(), + () => updateStaffRoleAbility(), + () => assignStaffRoleAbility(), + ], }), }); diff --git a/packages/ocom-verification/acceptance-ui/cucumber.js b/packages/ocom-verification/acceptance-ui/cucumber.js index 23810568a..d536703d4 100644 --- a/packages/ocom-verification/acceptance-ui/cucumber.js +++ b/packages/ocom-verification/acceptance-ui/cucumber.js @@ -9,4 +9,5 @@ export default { snippetInterface: 'async-await', }, parallel: 1, + tags: 'not @api-only and not @e2e-only and not @skip-ui', }; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts index 8bc8f9b45..e40fcb751 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/notes/community-notes.ts @@ -2,4 +2,8 @@ export interface CommunityUiNotes { communityName: string; formSubmitted: boolean; communityCreationQueued: boolean; + settingsSaved: boolean; + submittedWhiteLabelDomain: string; + submittedDomain: string; + submittedHandle: string; } diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-settings.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-settings.ts new file mode 100644 index 000000000..46a54c3c0 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/questions/community-settings.ts @@ -0,0 +1,10 @@ +import { notes, Question } from '@serenity-js/core'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; + +export const CommunitySettingsSavedFlag = () => Question.about('whether the community settings form was saved', (actor) => actor.answer(notes().get('settingsSaved'))); + +export const SubmittedWhiteLabelDomain = () => Question.about('the submitted white label domain', (actor) => actor.answer(notes().get('submittedWhiteLabelDomain'))); + +export const SubmittedDomain = () => Question.about('the submitted domain', (actor) => actor.answer(notes().get('submittedDomain'))); + +export const SubmittedHandle = () => Question.about('the submitted handle', (actor) => actor.answer(notes().get('submittedHandle'))); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts index bc657d814..09b5b04c8 100644 --- a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/index.ts @@ -1,2 +1,3 @@ // Community context step definitions import './create-community.steps.tsx'; +import './update-community-settings.steps.tsx'; diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/update-community-settings.steps.tsx b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/update-community-settings.steps.tsx new file mode 100644 index 000000000..1d2140dc6 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/step-definitions/update-community-settings.steps.tsx @@ -0,0 +1,132 @@ +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { Render } from '@cellix/serenity-framework/dom/render-in-dom'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { SEEDED_COMMUNITY } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, actorInTheSpotlight, notes } from '@serenity-js/core'; +import { SettingsGeneral } from '../../../../../../ocom/ui-community-route-admin/src/components/settings-general.tsx'; +import { wrapOcomComponent } from '../../../shared/ocom-component-wrapper.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; +import { CommunityName } from '../questions/community-name.ts'; +import { CommunitySettingsSavedFlag, SubmittedDomain, SubmittedHandle, SubmittedWhiteLabelDomain } from '../questions/community-settings.ts'; +import { type CommunitySettingsFormDetails, UpdateCommunitySettings } from '../tasks/update-community-settings.ts'; +import { ViewCommunitySettings } from '../tasks/view-community-settings.ts'; + +interface SubmittedSettingsValues { + name?: string | null | undefined; + whiteLabelDomain?: string | null | undefined; + domain?: string | null | undefined; + handle?: string | null | undefined; +} + +// antd's onFinish does not await onSave, so submitted values are captured +// synchronously here and copied into actor notes by the awaited When steps. +const submittedSettingsByActor = new Map(); + +Given('{word} is an authenticated admin of the seeded community', async (actorName: string) => { + const actor = actorCalled(actorName); + submittedSettingsByActor.delete(actorName); + + const onSave = (values: SubmittedSettingsValues): Promise => { + submittedSettingsByActor.set(actorName, values); + return Promise.resolve(); + }; + + const seededCommunityData = { + id: SEEDED_COMMUNITY.id, + name: SEEDED_COMMUNITY.name, + domain: null, + whiteLabelDomain: null, + handle: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + await actor.attemptsTo( + notes().set('settingsSaved', false), + notes().set('communityName', ''), + notes().set('submittedWhiteLabelDomain', ''), + notes().set('submittedDomain', ''), + notes().set('submittedHandle', ''), + Render.component( + , + { wrapper: wrapOcomComponent() }, + ), + ); +}); + +When('{word} views the current community', async (actorName: string) => { + await actorCalled(actorName).attemptsTo(ViewCommunitySettings()); +}); + +async function recordSubmittedSettings(actorName: string): Promise { + const actor = actorCalled(actorName); + const submitted = submittedSettingsByActor.get(actorName); + if (!submitted) { + return; + } + + await actor.attemptsTo( + notes().set('settingsSaved', true), + notes().set('communityName', submitted.name ?? ''), + notes().set('submittedWhiteLabelDomain', submitted.whiteLabelDomain ?? ''), + notes().set('submittedDomain', submitted.domain ?? ''), + notes().set('submittedHandle', submitted.handle ?? ''), + ); +} + +When('{word} updates the community settings with:', async (actorName: string, dataTable: DataTable) => { + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(UpdateCommunitySettings(details)); + await recordSubmittedSettings(actorName); +}); + +When('{word} attempts to update the community settings with:', async (actorName: string, dataTable: DataTable) => { + const details = GherkinDataTable.from(dataTable).rowsHash(); + await actorCalled(actorName).attemptsTo(UpdateCommunitySettings(details)); + await recordSubmittedSettings(actorName); +}); + +Then('the community settings update should succeed', async () => { + const saved = await actorInTheSpotlight().answer(CommunitySettingsSavedFlag()); + if (!saved) { + throw new Error('Expected the community settings form to be saved'); + } +}); + +Then('{word} should see the community name {string}', async (_actorName: string, expectedName: string) => { + const name = await actorInTheSpotlight().answer(CommunityName()); + if (name !== expectedName) { + throw new Error(`Expected the displayed community name "${expectedName}" but got "${name}"`); + } +}); + +Then('the community white label domain should be {string}', async (expectedValue: string) => { + const value = await actorInTheSpotlight().answer(SubmittedWhiteLabelDomain()); + if (value !== expectedValue) { + throw new Error(`Expected the saved white label domain "${expectedValue}" but got "${value}"`); + } +}); + +Then('the community domain should be {string}', async (expectedValue: string) => { + const value = await actorInTheSpotlight().answer(SubmittedDomain()); + if (value !== expectedValue) { + throw new Error(`Expected the saved domain "${expectedValue}" but got "${value}"`); + } +}); + +Then('the community handle should be {string}', async (expectedValue: string) => { + const value = await actorInTheSpotlight().answer(SubmittedHandle()); + if (value !== expectedValue) { + throw new Error(`Expected the saved handle "${expectedValue}" but got "${value}"`); + } +}); + +Then('the community should not be modified', async () => { + const saved = await actorInTheSpotlight().answer(CommunitySettingsSavedFlag()); + if (saved) { + throw new Error('Expected the community settings form not to be saved, but it was'); + } +}); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-community-settings.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-community-settings.ts new file mode 100644 index 000000000..b9c51f0a8 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/update-community-settings.ts @@ -0,0 +1,45 @@ +import { 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 { CommunitySettingsPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Task } from '@serenity-js/core'; +import type { AcceptanceUiCommunitySettingsPage } from '../../../shared/page-contracts.ts'; + +/** Settings fields that can be edited on the community settings form. */ +export interface CommunitySettingsFormDetails { + name?: string; + whiteLabelDomain?: string; + domain?: string; + handle?: string; +} + +/** Let the form's async `onFinish`/`onSave` handlers settle before assertions run. */ +async function flushPendingReactWork(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +export const UpdateCommunitySettings = (details: CommunitySettingsFormDetails): Task => + Task.where( + `#actor updates the community settings (${Object.keys(details).join(', ')})`, + new TaskStep('#actor fills the community settings form and saves', async (actor) => { + const page: AcceptanceUiCommunitySettingsPage = new CommunitySettingsPage(new DomPageAdapter(RenderInDom.as(actor).container)); + + if (details.name !== undefined) { + await page.fillName(details.name); + } + if (details.whiteLabelDomain !== undefined) { + await page.fillWhiteLabelDomain(details.whiteLabelDomain); + } + if (details.domain !== undefined) { + await page.fillDomain(details.domain); + } + if (details.handle !== undefined) { + await page.fillHandle(details.handle); + } + + await page.clickSave(); + + await flushPendingReactWork(); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/view-community-settings.ts b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/view-community-settings.ts new file mode 100644 index 000000000..c35e69b94 --- /dev/null +++ b/packages/ocom-verification/acceptance-ui/src/contexts/community/tasks/view-community-settings.ts @@ -0,0 +1,22 @@ +import { 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 { CommunitySettingsPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, notes, Task } from '@serenity-js/core'; +import type { AcceptanceUiCommunitySettingsPage } from '../../../shared/page-contracts.ts'; +import type { CommunityUiNotes } from '../notes/community-notes.ts'; + +/** + * Reads the community name shown on the rendered settings form and records it + * in actor notes so `Then` steps can assert on the displayed value. + */ +export const ViewCommunitySettings = (): Task => + Task.where( + '#actor views the community settings', + new TaskStep('#actor reads the displayed community name', async (actor) => { + const page: AcceptanceUiCommunitySettingsPage = new CommunitySettingsPage(new DomPageAdapter(RenderInDom.as(actor).container)); + const displayedName = (await page.nameInput.inputValue()) ?? ''; + + await actor.attemptsTo(notes().set('communityName', displayedName)); + }), + ); diff --git a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts index b7512789f..4efc73211 100644 --- a/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts +++ b/packages/ocom-verification/acceptance-ui/src/shared/page-contracts.ts @@ -1,5 +1,7 @@ -import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, CommunitySettingsPage, HomePage } from '@ocom-verification/verification-shared/pages'; export type AcceptanceUiHomePage = Pick; export type AcceptanceUiCommunityPage = Pick; + +export type AcceptanceUiCommunitySettingsPage = Pick; diff --git a/packages/ocom-verification/acceptance-ui/tsconfig.json b/packages/ocom-verification/acceptance-ui/tsconfig.json index 3dc0da163..e0fb133f0 100644 --- a/packages/ocom-verification/acceptance-ui/tsconfig.json +++ b/packages/ocom-verification/acceptance-ui/tsconfig.json @@ -23,6 +23,7 @@ "../../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-community-route-admin/src/**/*.tsx", "../../ocom/ui-staff-route-root/src/**/*.tsx", "../../ocom/ui-staff-route-user-management/src/**/*.tsx", "../../ocom/ui-staff-shared/src/**/*.tsx", diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-portal-page.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-portal-page.ts new file mode 100644 index 000000000..c0f984263 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/abilities/community-portal-page.ts @@ -0,0 +1,53 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { CommunitySettingsPage } from '@ocom-verification/verification-shared/pages'; +import { COMMUNITY_IDS, MEMBER_IDS } from '@ocom-verification/verification-shared/test-data'; +import { type AnswersQuestions, notes } from '@serenity-js/core'; +import type { Page } from 'playwright'; +import { type CommunityPortalUser, communityPortalPageFor } from '../../../shared/abilities/community-portal-session.ts'; +import type { E2ECommunitySettingsPage } from '../../../shared/page-contracts.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; + +/** Poll a browser condition until it holds or the timeout elapses. */ +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 community-portal page for the actor's portal user. */ +export const communityPortalPageOf = async (actor: AnswersQuestions): Promise => { + let user: CommunityPortalUser = 'owner'; + try { + user = (await actor.answer(notes().get('portalUser'))) ?? 'owner'; + } catch { + // No portal user noted yet; fall back to the community owner. + } + return await communityPortalPageFor(user); +}; + +/** Resolve the admin settings route for the actor's noted membership. */ +export const communitySettingsPathOf = async (actor: AnswersQuestions): Promise => { + let memberId: string | undefined; + try { + memberId = await actor.answer(notes().get('memberId')); + } catch { + // No member noted yet; fall back to the seeded admin member. + } + return `/community/${COMMUNITY_IDS.seededCommunity}/admin/${memberId || MEMBER_IDS.seededAdminMember}/settings`; +}; + +/** Community settings page object bound to the given browser page. */ +export const settingsPageOn = (page: Page): E2ECommunitySettingsPage => new CommunitySettingsPage(new PlaywrightPageAdapter(page)); + +/** Navigate to the community admin settings screen and wait for the form to load. */ +export const openCommunitySettings = async (page: Page, path: string): Promise => { + await page.goto(path, { waitUntil: 'networkidle' }); + const settingsPage = settingsPageOn(page); + await waitUntil(async () => Boolean(await settingsPage.nameInput.inputValue()), 'Timed out waiting for the community settings form to load'); + return settingsPage; +}; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-community-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-community-form.ts new file mode 100644 index 000000000..5a9111dfd --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-community-form.ts @@ -0,0 +1,17 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { CommunityPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, the } from '@serenity-js/core'; +import type { E2ECommunityPage } from '../../../shared/page-contracts.ts'; + +/** + * Low-level interaction that fills the community name on the create-community + * form. + */ +export const FillCommunityForm = (name: string) => + Interaction.where(the`#actor fills the community form with the name "${name}"`, async (serenityActor) => { + const { page } = BrowseTheWeb.withActor(serenityActor as unknown as Actor); + const communityPage: E2ECommunityPage = new CommunityPage(new PlaywrightPageAdapter(page)); + + await communityPage.fillName(name); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-community-settings-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-community-settings-form.ts new file mode 100644 index 000000000..1cb7ad93b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/fill-community-settings-form.ts @@ -0,0 +1,33 @@ +import { Interaction, the } from '@serenity-js/core'; +import { communityPortalPageOf, settingsPageOn } from '../abilities/community-portal-page.ts'; + +/** Settings fields that can be edited on the community settings form. */ +export interface CommunitySettingsFormDetails { + name?: string; + whiteLabelDomain?: string; + domain?: string; + handle?: string; +} + +/** + * Low-level interaction that fills the community settings form fields that are + * present in the supplied details. + */ +export const FillCommunitySettingsForm = (details: CommunitySettingsFormDetails) => + Interaction.where(the`#actor fills the community settings form (${Object.keys(details).join(', ')})`, async (actor) => { + const page = await communityPortalPageOf(actor); + const settingsPage = settingsPageOn(page); + + if (details.name !== undefined) { + await settingsPage.fillName(details.name); + } + if (details.whiteLabelDomain !== undefined) { + await settingsPage.fillWhiteLabelDomain(details.whiteLabelDomain); + } + if (details.domain !== undefined) { + await settingsPage.fillDomain(details.domain); + } + if (details.handle !== undefined) { + await settingsPage.fillHandle(details.handle); + } + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-community-settings.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-community-settings.ts new file mode 100644 index 000000000..e075d47f2 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-community-settings.ts @@ -0,0 +1,13 @@ +import { Interaction, the } from '@serenity-js/core'; +import { communityPortalPageOf, communitySettingsPathOf, openCommunitySettings } from '../abilities/community-portal-page.ts'; + +/** + * Low-level interaction that opens the community admin settings screen and + * waits for the settings form to load its current values. + */ +export const OpenCommunitySettings = () => + Interaction.where(the`#actor opens the community settings screen`, async (actor) => { + const page = await communityPortalPageOf(actor); + const path = await communitySettingsPathOf(actor); + await openCommunitySettings(page, path); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-create-community-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-create-community-form.ts new file mode 100644 index 000000000..3ecf6b348 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/open-create-community-form.ts @@ -0,0 +1,14 @@ +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { type Actor, Interaction, the } from '@serenity-js/core'; + +/** + * Low-level interaction that opens the create-community form in the community + * accounts portal. + */ +export const OpenCreateCommunityForm = () => + Interaction.where(the`#actor opens the create community form`, async (serenityActor) => { + const { page } = BrowseTheWeb.withActor(serenityActor as unknown as Actor); + await page.goto('/community/accounts/create-community', { + waitUntil: 'networkidle', + }); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/read-community-settings.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/read-community-settings.ts new file mode 100644 index 000000000..49427ecca --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/read-community-settings.ts @@ -0,0 +1,17 @@ +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { communityPortalPageOf, settingsPageOn } from '../abilities/community-portal-page.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; + +/** + * Low-level interaction that reads the community name displayed on the loaded + * settings form and records it in actor notes. + */ +export const ReadCommunitySettings = () => + Interaction.where(the`#actor reads the displayed community settings`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await communityPortalPageOf(actor); + const settingsPage = settingsPageOn(page); + const displayedName = (await settingsPage.nameInput.inputValue()) ?? ''; + + await actor.attemptsTo(notes().set('displayedCommunityName', displayedName)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-community-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-community-form.ts new file mode 100644 index 000000000..d302ce455 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-community-form.ts @@ -0,0 +1,88 @@ +import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; +import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; +import { CommunityPage } from '@ocom-verification/verification-shared/pages'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import type { E2ECommunityPage } from '../../../shared/page-contracts.ts'; +import { type GraphqlPayload, graphqlErrors, hasGraphqlOperation, selectGraphqlPayload } from '../../../shared/support/graphql-response.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; + +const createCommunityOperationName = 'AccountsCommunityCreateContainerCommunityCreate'; + +type CommunityCreateData = { + communityCreate?: { + status?: { + success?: boolean; + errorMessage?: string | null; + }; + community?: { + id?: string | null; + name?: string | null; + } | null; + }; +}; + +/** + * Low-level interaction that submits the create-community form, waits for the + * create mutation (or a client-side validation error), and records the outcome + * in actor notes. + */ +export const SubmitCommunityForm = (name: string) => + Interaction.where(the`#actor submits the community form for "${name}"`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const { page } = BrowseTheWeb.withActor(actor); + const communityPage: E2ECommunityPage = new CommunityPage(new PlaywrightPageAdapter(page)); + + const createMutationResponse = page.waitForResponse(hasGraphqlOperation(createCommunityOperationName), { timeout: 15_000 }).catch(() => null); + await communityPage.clickCreate(); + + await communityPage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + const validationError = await communityPage.firstValidationError.isVisible().catch(() => false); + if (validationError) { + const errorText = await communityPage.firstValidationError.textContent(); + await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', errorText || 'Validation error')); + return; + } + + const mutationResponse = await createMutationResponse; + if (mutationResponse) { + const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as GraphqlPayload | Array> | null, (data) => Boolean(data?.communityCreate)); + const graphqlError = graphqlErrors(payload); + const mutationResult = payload?.data?.communityCreate; + const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; + const createdName = mutationResult?.community?.name ?? null; + + if (!mutationResponse.ok || graphqlError || mutationResult?.status?.success !== true || (createdName !== null && createdName !== name)) { + const message = + mutationError || + (mutationResult?.status?.success !== true + ? `${createCommunityOperationName} did not report success: ${JSON.stringify(payload)}` + : createdName !== name + ? `Expected created community name "${name}" but GraphQL returned "${createdName ?? 'null'}"` + : `Community create GraphQL request failed with HTTP ${mutationResponse.status()}`); + await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + const communityId = mutationResult?.community?.id ?? null; + if (!communityId) { + const message = `${createCommunityOperationName} succeeded but returned no community id`; + await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + await actor.attemptsTo(notes().set('communityId', communityId)); + } + + await page.waitForURL((url) => url.pathname === '/community/accounts' || url.pathname === '/community/accounts/', { timeout: 15_000 }).catch(() => undefined); + await communityPage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); + const hasErrorToast = await communityPage.errorToast.isVisible().catch(() => false); + if (hasErrorToast) { + const errorText = await communityPage.errorToast.textContent(); + const message = errorText || 'Community creation failed'; + await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + await page.getByRole('cell', { name, exact: true }).first().waitFor({ state: 'visible', timeout: 15_000 }); + await actor.attemptsTo(notes().set('communityName', name), notes().set('communityCreated', true), notes().set('errorMessage', null)); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-community-settings-form.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-community-settings-form.ts new file mode 100644 index 000000000..77249d1cb --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/interactions/submit-community-settings-form.ts @@ -0,0 +1,90 @@ +import { COMMUNITY_IDS } from '@ocom-verification/verification-shared/test-data'; +import { type Actor, Interaction, notes, the } from '@serenity-js/core'; +import { type GraphqlPayload, graphqlErrors, hasGraphqlOperation, selectGraphqlPayload } from '../../../shared/support/graphql-response.ts'; +import { communityPortalPageOf, communitySettingsPathOf, openCommunitySettings, settingsPageOn } from '../abilities/community-portal-page.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; + +const updateCommunityOperationName = 'AdminSettingsGeneralContainerCommunityUpdateSettings'; + +type CommunityUpdateSettingsData = { + communityUpdateSettings?: { + status?: { + success?: boolean; + errorMessage?: string | null; + }; + community?: { + id?: string | null; + name?: string | null; + domain?: string | null; + whiteLabelDomain?: string | null; + handle?: string | null; + } | null; + }; +}; + +/** + * Low-level interaction that submits the community settings form, waits for the + * update mutation (or a client-side validation error), and records the outcome + * in actor notes. + */ +export const SubmitCommunitySettingsForm = () => + Interaction.where(the`#actor submits the community settings form`, async (serenityActor) => { + const actor = serenityActor as unknown as Actor; + const page = await communityPortalPageOf(actor); + const settingsPage = settingsPageOn(page); + + const updateMutationResponse = page.waitForResponse(hasGraphqlOperation(updateCommunityOperationName), { timeout: 15_000 }).catch(() => null); + await settingsPage.clickSave(); + + await settingsPage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); + const hasValidationError = await settingsPage.firstValidationError.isVisible().catch(() => false); + if (hasValidationError) { + const errorText = await settingsPage.firstValidationError.textContent(); + await actor.attemptsTo(notes().set('settingsSaved', false), notes().set('errorMessage', errorText || 'Validation error')); + return; + } + + const mutationResponse = await updateMutationResponse; + if (!mutationResponse) { + const message = `Timed out waiting for the ${updateCommunityOperationName} mutation`; + await actor.attemptsTo(notes().set('settingsSaved', false), notes().set('errorMessage', message)); + throw new Error(message); + } + + const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as GraphqlPayload | Array> | null, (data) => + Boolean(data?.communityUpdateSettings), + ); + const graphqlError = graphqlErrors(payload); + const mutationResult = payload?.data?.communityUpdateSettings; + + if (graphqlError || mutationResult?.status?.success !== true) { + const message = mutationResult?.status?.errorMessage ?? graphqlError ?? `${updateCommunityOperationName} did not report success: ${JSON.stringify(payload)}`; + await actor.attemptsTo(notes().set('settingsSaved', false), notes().set('errorMessage', message)); + return; + } + + const community = mutationResult.community; + if (community?.id !== COMMUNITY_IDS.seededCommunity) { + const message = `${updateCommunityOperationName} returned community "${community?.id ?? 'missing'}" instead of "${COMMUNITY_IDS.seededCommunity}"`; + await actor.attemptsTo(notes().set('settingsSaved', false), notes().set('errorMessage', message)); + return; + } + + const persistedSettingsPage = await openCommunitySettings(page, await communitySettingsPathOf(actor)); + const [persistedName, persistedWhiteLabelDomain, persistedDomain, persistedHandle] = await Promise.all([ + persistedSettingsPage.nameInput.inputValue(), + persistedSettingsPage.whiteLabelDomainInput.inputValue(), + persistedSettingsPage.domainInput.inputValue(), + persistedSettingsPage.handleInput.inputValue(), + ]); + + await actor.attemptsTo( + notes().set('settingsSaved', true), + notes().set('errorMessage', null), + notes().set('communityId', community.id), + notes().set('communityName', persistedName ?? ''), + notes().set('savedWhiteLabelDomain', persistedWhiteLabelDomain ?? ''), + notes().set('savedDomain', persistedDomain ?? ''), + notes().set('savedHandle', persistedHandle ?? ''), + ); + }); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts index 48c0239e4..607e369a9 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/notes/community-notes.ts @@ -3,4 +3,11 @@ export interface CommunityE2ENotes { communityName: string; communityCreated: boolean; errorMessage: string | null; + portalUser: 'owner' | 'member'; + memberId: string; + settingsSaved: boolean; + displayedCommunityName: string; + savedWhiteLabelDomain: string; + savedDomain: string; + savedHandle: string; } diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-settings.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-settings.ts new file mode 100644 index 000000000..b8fb4d973 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/questions/community-settings.ts @@ -0,0 +1,12 @@ +import { notes, Question } from '@serenity-js/core'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; + +export const CommunitySettingsSavedFlag = () => Question.about('whether the community settings were saved', (actor) => actor.answer(notes().get('settingsSaved'))); + +export const DisplayedCommunityName = () => Question.about('the displayed community name', (actor) => actor.answer(notes().get('displayedCommunityName'))); + +export const SavedWhiteLabelDomain = () => Question.about('the saved white label domain', (actor) => actor.answer(notes().get('savedWhiteLabelDomain'))); + +export const SavedDomain = () => Question.about('the saved domain', (actor) => actor.answer(notes().get('savedDomain'))); + +export const SavedHandle = () => Question.about('the saved handle', (actor) => actor.answer(notes().get('savedHandle'))); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts index 97e7876fd..3278b68a6 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/create-community.steps.ts @@ -10,18 +10,17 @@ import { CommunityCreatedFlag } from '../questions/community-created-flag.ts'; import { CommunityErrorMessage } from '../questions/community-error-message.ts'; import { CommunityName } from '../questions/community-name.ts'; import { CreateCommunity } from '../tasks/create-community.ts'; - -let lastActorName = actors.CommunityOwner.name; +import { getLastActorName, setLastActorName } from './last-actor.ts'; Given('{word} is an authenticated community owner', async (actorName: string) => { - lastActorName = actorName; + setLastActorName(actorName); const actor = actorCalled(actorName); await clearKnownQueueMessages(); await actor.attemptsTo(LogInWithOAuth2(actors.CommunityOwner.email)); }); When('{word} creates a community with:', async (actorName: string, dataTable: DataTable) => { - lastActorName = actorName; + setLastActorName(actorName); const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); const name = details['name'] ?? ''; @@ -30,7 +29,7 @@ When('{word} creates a community with:', async (actorName: string, dataTable: Da }); When('{word} attempts to create a community with:', async (actorName: string, dataTable: DataTable) => { - lastActorName = actorName; + setLastActorName(actorName); const actor = actorCalled(actorName); const details = GherkinDataTable.from(dataTable).rowsHash<{ name?: string }>(); const name = details['name'] ?? ''; @@ -43,8 +42,21 @@ When('{word} attempts to create a community with:', async (actorName: string, da } }); +When('{word} attempts to create a community with a name of {int} characters', async (actorName: string, nameLength: number) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + const name = 'A'.repeat(nameLength); + + try { + await actor.attemptsTo(CreateCommunity(name)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await actor.attemptsTo(notes().set('communityId', null), notes().set('errorMessage', errorMessage), notes().set('communityCreated', false)); + } +}); + Then('the community should be created successfully', async () => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); const created = await actor.answer(CommunityCreatedFlag()); if (!created) { @@ -53,7 +65,7 @@ Then('the community should be created successfully', async () => { }); Then('the community name should be {string}', async (expectedName: string) => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); const actualName = await actor.answer(CommunityName()); if (actualName !== expectedName) { @@ -62,7 +74,7 @@ Then('the community name should be {string}', async (expectedName: string) => { }); Then('a community creation queue message should be recorded', async () => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); const communityId = await actor.answer(notes().get('communityId')); const communityName = await actor.answer(notes().get('communityName')); const message = await waitForCommunityCreationQueueMessage({ @@ -80,7 +92,7 @@ Then('a community creation queue message should be recorded', async () => { }); Then('{word} should see a community error for {string}', async (actorName: string, fieldName: string) => { - const resolvedName = ActorName.resolve(actorName, { defaultName: lastActorName }); + const resolvedName = ActorName.resolve(actorName, { defaultName: getLastActorName() }); const actor = actorCalled(resolvedName); const errorMessage = await actor.answer(CommunityErrorMessage()); @@ -99,7 +111,7 @@ Then('{word} should see a community error for {string}', async (actorName: strin }); Then('no community should be created', async () => { - const actor = actorCalled(lastActorName); + const actor = actorCalled(getLastActorName()); const created = await actor.answer(CommunityCreatedFlag()); if (created) { diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts index c04c54d61..5cac6d2a0 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/index.ts @@ -1,2 +1,3 @@ // Community context step definitions import './create-community.steps.ts'; +import './update-community-settings.steps.ts'; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/last-actor.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/last-actor.ts new file mode 100644 index 000000000..3c000c83b --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/last-actor.ts @@ -0,0 +1,15 @@ +import { actors } from '@ocom-verification/verification-shared/test-data'; + +/** + * Tracks the most recent actor across the community step-definition files so + * that then-steps without an explicit actor name (for example + * "the community name should be ...") resolve the right actor regardless of + * which feature file the preceding when-step came from. + */ +let lastActorName = actors.CommunityOwner.name; + +export const setLastActorName = (actorName: string): void => { + lastActorName = actorName; +}; + +export const getLastActorName = (): string => lastActorName; diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-community-settings.steps.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-community-settings.steps.ts new file mode 100644 index 000000000..977e37576 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/step-definitions/update-community-settings.steps.ts @@ -0,0 +1,129 @@ +import { ActorName } from '@cellix/serenity-framework/cucumber/actor-name'; +import { GherkinDataTable } from '@cellix/serenity-framework/cucumber/gherkin-data-table'; +import { type DataTable, Given, Then, When } from '@cucumber/cucumber'; +import { MEMBER_IDS, SEEDED_COMMUNITY } from '@ocom-verification/verification-shared/test-data'; +import { actorCalled, notes } from '@serenity-js/core'; +import { communityPortalPageFor } from '../../../shared/abilities/community-portal-session.ts'; +import type { CommunityE2ENotes } from '../notes/community-notes.ts'; +import { CommunityErrorMessage } from '../questions/community-error-message.ts'; +import { CommunitySettingsSavedFlag, DisplayedCommunityName, SavedDomain, SavedHandle, SavedWhiteLabelDomain } from '../questions/community-settings.ts'; +import type { CommunitySettingsFormDetails } from '../tasks/update-community-settings.ts'; +import { UpdateCommunitySettings } from '../tasks/update-community-settings.ts'; +import { ViewCommunitySettings } from '../tasks/view-community-settings.ts'; +import { getLastActorName, setLastActorName } from './last-actor.ts'; + +Given('{word} is an authenticated admin of the seeded community', async (actorName: string) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('portalUser', 'owner'), notes().set('memberId', MEMBER_IDS.seededAdminMember)); + await communityPortalPageFor('owner'); +}); + +Given('{word} is an authenticated member of the seeded community without settings permissions', async (actorName: string) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + await actor.attemptsTo(notes().set('portalUser', 'member'), notes().set('memberId', MEMBER_IDS.seededPlainMember)); + await communityPortalPageFor('member'); +}); + +When('{word} views the current community', async (actorName: string) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + await actor.attemptsTo(ViewCommunitySettings()); +}); + +When('{word} updates the community settings with:', async (actorName: string, dataTable: DataTable) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + + await actor.attemptsTo(UpdateCommunitySettings(details)); +}); + +When('{word} attempts to update the community settings with:', async (actorName: string, dataTable: DataTable) => { + setLastActorName(actorName); + const actor = actorCalled(actorName); + const details = GherkinDataTable.from(dataTable).rowsHash(); + + await actor.attemptsTo(notes().set('settingsSaved', false), notes().set('errorMessage', null)); + try { + await actor.attemptsTo(UpdateCommunitySettings(details)); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + await actor.attemptsTo(notes().set('settingsSaved', false), notes().set('errorMessage', errorMessage)); + } +}); + +Then('the community settings update should succeed', async () => { + const actor = actorCalled(getLastActorName()); + const saved = await actor.answer(CommunitySettingsSavedFlag()); + + if (!saved) { + const errorMessage = await actor.answer(CommunityErrorMessage()); + throw new Error(`Expected the community settings update to succeed${errorMessage ? `, but it failed with: ${errorMessage}` : ''}`); + } +}); + +Then('{word} should see the community name {string}', async (actorName: string, expectedName: string) => { + const resolvedName = ActorName.resolve(actorName, { defaultName: getLastActorName() }); + const actor = actorCalled(resolvedName); + const displayedName = await actor.answer(DisplayedCommunityName()); + + if (displayedName !== expectedName) { + throw new Error(`Expected the displayed community name to be "${expectedName}" but got "${displayedName}"`); + } +}); + +Then('the community white label domain should be {string}', async (expectedValue: string) => { + const actor = actorCalled(getLastActorName()); + const actualValue = await actor.answer(SavedWhiteLabelDomain()); + + if (actualValue !== expectedValue) { + throw new Error(`Expected the community white label domain to be "${expectedValue}" but got "${actualValue}"`); + } +}); + +Then('the community domain should be {string}', async (expectedValue: string) => { + const actor = actorCalled(getLastActorName()); + const actualValue = await actor.answer(SavedDomain()); + + if (actualValue !== expectedValue) { + throw new Error(`Expected the community domain to be "${expectedValue}" but got "${actualValue}"`); + } +}); + +Then('the community handle should be {string}', async (expectedValue: string) => { + const actor = actorCalled(getLastActorName()); + const actualValue = await actor.answer(SavedHandle()); + + if (actualValue !== expectedValue) { + throw new Error(`Expected the community handle to be "${expectedValue}" but got "${actualValue}"`); + } +}); + +Then('{word} should see a community error containing {string}', async (actorName: string, expectedFragment: string) => { + const resolvedName = ActorName.resolve(actorName, { defaultName: getLastActorName() }); + const actor = actorCalled(resolvedName); + const errorMessage = await actor.answer(CommunityErrorMessage()); + + if (!errorMessage) { + throw new Error(`Expected a community error containing "${expectedFragment}" but none was recorded`); + } + + if (!errorMessage.toLowerCase().includes(expectedFragment.toLowerCase())) { + throw new Error(`Expected the community error to contain "${expectedFragment}", but got: "${errorMessage}"`); + } +}); + +Then('the community should not be modified', async () => { + const actor = actorCalled(getLastActorName()); + + // Reload the settings screen as the seeded admin so the assertion reads the + // persisted community state rather than unsaved local form edits. + await actor.attemptsTo(notes().set('portalUser', 'owner'), notes().set('memberId', MEMBER_IDS.seededAdminMember), ViewCommunitySettings()); + const displayedName = await actor.answer(DisplayedCommunityName()); + + if (displayedName !== SEEDED_COMMUNITY.name) { + throw new Error(`Expected the community to remain "${SEEDED_COMMUNITY.name}" but the settings screen shows "${displayedName}"`); + } +}); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts index 9748295bc..1fe00a7b9 100644 --- a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/create-community.ts @@ -1,123 +1,9 @@ -import { PlaywrightPageAdapter } from '@cellix/serenity-framework/pages/playwright'; -import { BrowseTheWeb } from '@cellix/serenity-framework/serenity/browser'; -import { CommunityPage } from '@ocom-verification/verification-shared/pages'; -import { type Actor, Interaction, notes, the } from '@serenity-js/core'; -import type { Response } from 'playwright'; -import type { E2ECommunityPage } from '../../../shared/page-contracts.ts'; -import type { CommunityE2ENotes } from '../notes/community-notes.ts'; - -const createCommunityOperationName = 'AccountsCommunityCreateContainerCommunityCreate'; - -type CommunityCreateGraphqlPayload = { - data?: { - communityCreate?: { - status?: { - success?: boolean; - errorMessage?: string | null; - }; - community?: { - id?: string | null; - name?: string | null; - } | null; - }; - }; - errors?: Array<{ message?: string }>; -}; - -type GraphqlPayload = { - data?: TData; - errors?: Array<{ message?: string }>; -}; - -const hasGraphqlOperation = (operationName: string) => (response: Response) => { - if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { - return false; - } - - return response.request().postData()?.includes(operationName) ?? false; -}; - -const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { - if (!Array.isArray(payload)) { - return payload; - } - - return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; -}; - -const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null): string | undefined => - payload?.errors - ?.map((error) => error.message) - .filter(Boolean) - .join('; '); +import { Task, the } from '@serenity-js/core'; +import { FillCommunityForm } from '../interactions/fill-community-form.ts'; +import { OpenCreateCommunityForm } from '../interactions/open-create-community-form.ts'; +import { SubmitCommunityForm } from '../interactions/submit-community-form.ts'; /** - * Creates a community through the browser UI. + * Task that creates a community through the browser UI. */ -export const CreateCommunity = (name: string) => - Interaction.where(the`#actor creates community "${name}" via UI`, async (serenityActor) => { - const actor = serenityActor as unknown as Actor; - const { page } = BrowseTheWeb.withActor(actor); - await page.goto('/community/accounts/create-community', { - waitUntil: 'networkidle', - }); - - const adapter = new PlaywrightPageAdapter(page); - const communityPage: E2ECommunityPage = new CommunityPage(adapter); - - await communityPage.fillName(name); - - const createMutationResponse = page.waitForResponse(hasGraphqlOperation(createCommunityOperationName), { timeout: 15_000 }).catch(() => null); - await communityPage.clickCreate(); - - await communityPage.firstValidationError.waitFor({ state: 'visible', timeout: 750 }).catch(() => undefined); - const validationError = await communityPage.firstValidationError.isVisible().catch(() => false); - if (validationError) { - const errorText = await communityPage.firstValidationError.textContent(); - await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', errorText || 'Validation error')); - return; - } - - const mutationResponse = await createMutationResponse; - if (mutationResponse) { - const payload = selectGraphqlPayload((await mutationResponse.json().catch(() => null)) as CommunityCreateGraphqlPayload | CommunityCreateGraphqlPayload[] | null, (data) => Boolean(data?.communityCreate)); - const graphqlError = graphqlErrors(payload); - const mutationResult = payload?.data?.communityCreate; - const mutationError = mutationResult?.status?.errorMessage ?? graphqlError; - const createdName = mutationResult?.community?.name ?? null; - - if (!mutationResponse.ok || graphqlError || mutationResult?.status?.success !== true || (createdName !== null && createdName !== name)) { - const message = - mutationError || - (mutationResult?.status?.success !== true - ? `${createCommunityOperationName} did not report success: ${JSON.stringify(payload)}` - : createdName !== name - ? `Expected created community name "${name}" but GraphQL returned "${createdName ?? 'null'}"` - : `Community create GraphQL request failed with HTTP ${mutationResponse.status()}`); - await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - const communityId = mutationResult?.community?.id ?? null; - if (!communityId) { - const message = `${createCommunityOperationName} succeeded but returned no community id`; - await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await actor.attemptsTo(notes().set('communityId', communityId)); - } - - await page.waitForURL(/\/community\/accounts(?:\/)?(?:\?.*)?$/, { timeout: 15_000 }).catch(() => undefined); - await communityPage.errorToast.waitFor({ state: 'visible', timeout: 1_000 }).catch(() => undefined); - const hasErrorToast = await communityPage.errorToast.isVisible().catch(() => false); - if (hasErrorToast) { - const errorText = await communityPage.errorToast.textContent(); - const message = errorText || 'Community creation failed'; - await actor.attemptsTo(notes().set('communityId', null), notes().set('communityCreated', false), notes().set('errorMessage', message)); - throw new Error(message); - } - - await page.getByRole('cell', { name, exact: true }).first().waitFor({ state: 'visible', timeout: 15_000 }); - await actor.attemptsTo(notes().set('communityName', name), notes().set('communityCreated', true), notes().set('errorMessage', null)); - }); +export const CreateCommunity = (name: string) => Task.where(the`#actor creates community "${name}" via UI`, OpenCreateCommunityForm(), FillCommunityForm(name), SubmitCommunityForm(name)); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-community-settings.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-community-settings.ts new file mode 100644 index 000000000..0917d78c3 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/update-community-settings.ts @@ -0,0 +1,12 @@ +import { Task, the } from '@serenity-js/core'; +import { type CommunitySettingsFormDetails, FillCommunitySettingsForm } from '../interactions/fill-community-settings-form.ts'; +import { OpenCommunitySettings } from '../interactions/open-community-settings.ts'; +import { SubmitCommunitySettingsForm } from '../interactions/submit-community-settings-form.ts'; + +export type { CommunitySettingsFormDetails }; + +/** + * Task that updates the community settings through the admin settings screen. + */ +export const UpdateCommunitySettings = (details: CommunitySettingsFormDetails) => + Task.where(the`#actor updates the community settings (${Object.keys(details).join(', ')}) via the admin screen`, OpenCommunitySettings(), FillCommunitySettingsForm(details), SubmitCommunitySettingsForm()); diff --git a/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/view-community-settings.ts b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/view-community-settings.ts new file mode 100644 index 000000000..893fa673a --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/contexts/community/tasks/view-community-settings.ts @@ -0,0 +1,9 @@ +import { Task, the } from '@serenity-js/core'; +import { OpenCommunitySettings } from '../interactions/open-community-settings.ts'; +import { ReadCommunitySettings } from '../interactions/read-community-settings.ts'; + +/** + * Task that opens the admin settings screen and records the displayed + * community details for follow-up assertions. + */ +export const ViewCommunitySettings = () => Task.where(the`#actor views the community settings via the admin screen`, OpenCommunitySettings(), ReadCommunitySettings()); diff --git a/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts b/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts index f97c38705..3a44a391d 100644 --- a/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts +++ b/packages/ocom-verification/e2e-tests/src/cucumber-lifecycle-hooks.ts @@ -4,7 +4,10 @@ import { registerWorldLifecycleHooks } from '@cellix/serenity-framework/cucumber import { registerScreenshotOnFailureHook } from '@cellix/serenity-framework/cucumber/screenshot'; import { getTimeout } from '@cellix/serenity-framework/settings'; import type { IWorld } from '@cucumber/cucumber'; +import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; import { infrastructure } from './infrastructure.ts'; +import { mongoDbName, testMongoServer } from './servers/test-mongo-server.ts'; +import { closeCommunityPortalSessions } from './shared/abilities/community-portal-session.ts'; import type { CellixE2EWorld } from './world.ts'; const currentDir = fileURLToPath(new URL('.', import.meta.url)); @@ -15,12 +18,20 @@ export function registerLifecycleHooks(): void { beforeTimeout: getTimeout('serverStartup') + getTimeout('uiInit') * 3, scenarioTimeout: getTimeout('scenario'), before: async (world) => { + // Restore the seeded fixtures so settings-update scenarios always start + // from a known baseline regardless of what previous scenarios modified. + if (testMongoServer.isRunning()) { + await seedDatabase({ connectionString: testMongoServer.getConnectionString(), dbName: mongoDbName }); + } await world.init(); }, after: async (world) => { await world.cleanup(); }, - afterAll: () => infrastructure.stopAll(), + afterAll: async () => { + await closeCommunityPortalSessions(); + await infrastructure.stopAll(); + }, }); registerScreenshotOnFailureHook({ diff --git a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts index c967a0b89..326d1a645 100644 --- a/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts +++ b/packages/ocom-verification/e2e-tests/src/servers/test-mongo-server.ts @@ -3,7 +3,7 @@ import { getMongoPort } from '@ocom-verification/verification-shared/environment import { seedDatabase } from '@ocom-verification/verification-shared/test-data'; import { appPaths } from '../shared/environment/app-paths.ts'; -const mongoDbName = 'owner-community'; +export const mongoDbName = 'owner-community'; const mongoReplSetName = 'globaldb'; export const testMongoServer = new MongoMemoryProcessTestServer({ diff --git a/packages/ocom-verification/e2e-tests/src/shared/abilities/community-portal-session.ts b/packages/ocom-verification/e2e-tests/src/shared/abilities/community-portal-session.ts new file mode 100644 index 000000000..e48c39550 --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/abilities/community-portal-session.ts @@ -0,0 +1,63 @@ +import type { BrowserContext, Page } from 'playwright'; +import { infrastructure } from '../../infrastructure.ts'; +import { performOAuth2Login } from './oauth2-login.ts'; + +/** Community-portal users supported by the E2E login flow. */ +export type CommunityPortalUser = 'owner' | 'member'; + +interface CommunityPortalCredentials { + email: string; + password: string; +} + +/** + * Mock OIDC users defined in `apps/ui-community/mock-oidc.users.json`. The subs + * of these users match the seeded end users from + * `@ocom-verification/verification-shared` test data, so the API resolves the + * matching seeded community membership for each login. + */ +const credentialsByUser: Record = { + owner: { email: 'owner@test.example', password: 'password' }, + member: { email: 'member@test.example', password: 'password' }, +}; + +interface CommunityPortalSession { + context: BrowserContext; + page: Page; + authenticated: boolean; +} + +const sessions = new Map(); + +/** + * Return an authenticated community-portal page for the given portal user. + * + * 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 communityPortalPageFor(user: CommunityPortalUser): Promise { + const credentials = credentialsByUser[user]; + let session = sessions.get(credentials.email); + if (!session) { + const context = await infrastructure.newPortalContext('community'); + const page = await context.newPage(); + session = { context, page, authenticated: false }; + sessions.set(credentials.email, session); + } + if (!session.authenticated) { + await performOAuth2Login(session.page, credentials, '/community/accounts'); + session.authenticated = true; + } + return session.page; +} + +/** Close every community-portal browser context opened by the suite. */ +export async function closeCommunityPortalSessions(): Promise { + for (const session of sessions.values()) { + await session.context.close().catch(() => undefined); + } + sessions.clear(); +} diff --git a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts index 677ab56e7..028dcf62b 100644 --- a/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts +++ b/packages/ocom-verification/e2e-tests/src/shared/page-contracts.ts @@ -1,5 +1,10 @@ -import type { CommunityPage, HomePage } from '@ocom-verification/verification-shared/pages'; +import type { CommunityPage, CommunitySettingsPage, HomePage } from '@ocom-verification/verification-shared/pages'; export type E2EHomePage = Pick; export type E2ECommunityPage = Pick; + +export type E2ECommunitySettingsPage = Pick< + CommunitySettingsPage, + 'fillName' | 'fillWhiteLabelDomain' | 'fillDomain' | 'fillHandle' | 'clickSave' | 'nameInput' | 'whiteLabelDomainInput' | 'domainInput' | 'handleInput' | 'firstValidationError' | 'errorToast' | 'successToast' +>; diff --git a/packages/ocom-verification/e2e-tests/src/shared/support/graphql-response.ts b/packages/ocom-verification/e2e-tests/src/shared/support/graphql-response.ts new file mode 100644 index 000000000..97d798e0d --- /dev/null +++ b/packages/ocom-verification/e2e-tests/src/shared/support/graphql-response.ts @@ -0,0 +1,37 @@ +import type { Response } from 'playwright'; + +/** A GraphQL response payload with optional data and top-level errors. */ +export type GraphqlPayload = { + data?: TData; + errors?: Array<{ message?: string }>; +}; + +/** + * Playwright response predicate that matches a POSTed GraphQL operation by + * operation name. + */ +export const hasGraphqlOperation = (operationName: string) => (response: Response) => { + if (!response.url().includes('/api/graphql') || response.request().method() !== 'POST') { + return false; + } + + return response.request().postData()?.includes(operationName) ?? false; +}; + +/** + * Select the relevant GraphQL payload from a (possibly batched) response body. + */ +export const selectGraphqlPayload = (payload: GraphqlPayload | Array> | null, hasExpectedData: (data: TData | undefined) => boolean): GraphqlPayload | null => { + if (!Array.isArray(payload)) { + return payload; + } + + return payload.find((item) => hasExpectedData(item.data)) ?? payload.find((item) => item.errors?.length) ?? null; +}; + +/** Join the top-level GraphQL error messages of a payload, if any. */ +export const graphqlErrors = (payload: { errors?: Array<{ message?: string }> } | null): string | undefined => + payload?.errors + ?.map((error) => error.message) + .filter(Boolean) + .join('; '); diff --git a/packages/ocom-verification/verification-shared/src/pages/community-settings.page.ts b/packages/ocom-verification/verification-shared/src/pages/community-settings.page.ts new file mode 100644 index 000000000..f344171c9 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/pages/community-settings.page.ts @@ -0,0 +1,64 @@ +import { AdapterBackedPageObject, type ElementHandle } from '@cellix/serenity-framework/pages'; + +/** + * Page object for the community admin "Settings — General" form. + * + * Backed by a {@link PageAdapter} so the DOM acceptance suite and the + * Playwright E2E suite share the same page contract for the settings screen. + */ +export class CommunitySettingsPage extends AdapterBackedPageObject { + // antd derives input ids from the Form.Item names, which keeps these + // locators unambiguous ("Domain" placeholder text is a substring of + // "White Label Domain", so placeholder lookups would be ambiguous). + get nameInput(): ElementHandle { + return this.adapter.locator('#name'); + } + + get whiteLabelDomainInput(): ElementHandle { + return this.adapter.locator('#whiteLabelDomain'); + } + + get domainInput(): ElementHandle { + return this.adapter.locator('#domain'); + } + + get handleInput(): ElementHandle { + return this.adapter.locator('#handle'); + } + + get saveButton(): ElementHandle { + return this.adapter.getByRole('button', { name: /Save/i }); + } + + get firstValidationError(): ElementHandle { + return this.adapter.locator('.ant-form-item-explain-error'); + } + + get errorToast(): ElementHandle { + return this.adapter.locator('.ant-message-error, [role="alert"]'); + } + + get successToast(): ElementHandle { + return this.adapter.locator('.ant-message-success'); + } + + async fillName(value: string): Promise { + await this.nameInput.fill(value); + } + + async fillWhiteLabelDomain(value: string): Promise { + await this.whiteLabelDomainInput.fill(value); + } + + async fillDomain(value: string): Promise { + await this.domainInput.fill(value); + } + + async fillHandle(value: string): Promise { + await this.handleInput.fill(value); + } + + async clickSave(): Promise { + await this.saveButton.click(); + } +} diff --git a/packages/ocom-verification/verification-shared/src/pages/index.ts b/packages/ocom-verification/verification-shared/src/pages/index.ts index 92c1e1c6b..3d2b221be 100644 --- a/packages/ocom-verification/verification-shared/src/pages/index.ts +++ b/packages/ocom-verification/verification-shared/src/pages/index.ts @@ -1,4 +1,5 @@ export { CommunityPage } from './community.page.ts'; +export { CommunitySettingsPage } from './community-settings.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/scenarios/community/create-community.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/create-community.feature index d5d8a2c63..bb15dbdf8 100644 --- a/packages/ocom-verification/verification-shared/src/scenarios/community/create-community.feature +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/create-community.feature @@ -7,19 +7,19 @@ Feature: Create Community Background: Given Alice is an authenticated community owner - Scenario: Create a community with basic details - When Alice creates a community with: - | name | Test Community | - Then the community should be created successfully - And the community name should be "Test Community" - And a community creation queue message should be recorded + Scenario: Create a community with basic details + When Alice creates a community with: + | name | Test Community | + Then the community should be created successfully + And the community name should be "Test Community" + And a community creation queue message should be recorded - Scenario: Create a community with a descriptive name - When Alice creates a community with: - | name | Portland Outdoor Enthusiasts | - Then the community should be created successfully - And the community name should be "Portland Outdoor Enthusiasts" - And a community creation queue message should be recorded + Scenario: Create a community with a descriptive name + When Alice creates a community with: + | name | Portland Outdoor Enthusiasts | + Then the community should be created successfully + And the community name should be "Portland Outdoor Enthusiasts" + And a community creation queue message should be recorded @validation Scenario: Cannot create community without required name @@ -34,3 +34,9 @@ Feature: Create Community | name | | Then she should see a community error for "name" And no community should be created + + @validation @skip-ui + Scenario: Community name cannot exceed 200 characters + When Alice attempts to create a community with a name of 201 characters + Then she should see a community error for "name" + And no community should be created diff --git a/packages/ocom-verification/verification-shared/src/scenarios/community/update-community-settings.feature b/packages/ocom-verification/verification-shared/src/scenarios/community/update-community-settings.feature new file mode 100644 index 000000000..4eb447ac5 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/scenarios/community/update-community-settings.feature @@ -0,0 +1,108 @@ +Feature: Community settings management + + As a community member with settings permissions + I want to view and update my community's settings + So that the community presents the correct name and domains to its members + + Scenario: View the current community settings + Given Alice is an authenticated admin of the seeded community + When Alice views the current community + Then Alice should see the community name "Seeded Community" + + @api-only + Scenario: View community details by id + Given Alice is an authenticated admin of the seeded community + When Alice views the details of the seeded community + Then Alice should see the community name "Seeded Community" + + Scenario: Rename the community + Given Alice is an authenticated admin of the seeded community + When Alice updates the community settings with: + | name | Renamed Community | + Then the community settings update should succeed + And the community name should be "Renamed Community" + + Scenario: Set the community white label domain + Given Alice is an authenticated admin of the seeded community + When Alice updates the community settings with: + | name | Seeded Community | + | whiteLabelDomain | seeded-brand | + Then the community settings update should succeed + And the community white label domain should be "seeded-brand" + + Scenario: Set the community custom domain + Given Alice is an authenticated admin of the seeded community + When Alice updates the community settings with: + | name | Seeded Community | + | domain | seeded-community.example.com | + Then the community settings update should succeed + And the community domain should be "seeded-community.example.com" + + Scenario: Set the community handle + Given Alice is an authenticated admin of the seeded community + When Alice updates the community settings with: + | name | Seeded Community | + | handle | seeded-handle | + Then the community settings update should succeed + And the community handle should be "seeded-handle" + + @validation + Scenario: Cannot clear the community name + Given Alice is an authenticated admin of the seeded community + When Alice attempts to update the community settings with: + | name | | + Then Alice should see a community error for "name" + And the community should not be modified + + @validation @api-only + Scenario: Community name cannot be updated beyond 200 characters + Given Alice is an authenticated admin of the seeded community + When Alice attempts to update the community settings with a name of 201 characters + Then Alice should see a community error for "name" + And the community should not be modified + + @skip-ui + Scenario: Member without settings permission cannot update the community + Given Bob is an authenticated member of the seeded community without settings permissions + When Bob attempts to update the community settings with: + | name | Hijacked Community | + Then Bob should see a community error containing "do not have permission" + And the community should not be modified + + @api-only + Scenario: Admin of another community cannot update the seeded community + Given Carol is an authenticated admin of the other community + When Carol attempts to update the community settings with: + | name | Cross Community Takeover | + Then Carol should see a community error containing "do not have permission" + And the community should not be modified + + @api-only + Scenario: Staff user who can manage all communities can view any community + Given Alice is a staff user who can manage all communities + When Alice views the details of the seeded community + Then Alice should see the community name "Seeded Community" + + @api-only + Scenario: Staff user who can manage all communities can update any community + Given Alice is a staff user who can manage all communities + When Alice updates the community settings with: + | name | Staff Renamed Community | + Then the community settings update should succeed + And the community name should be "Staff Renamed Community" + + @api-only + Scenario: Staff user without manage-all-communities permission cannot update a community + Given Alice is a staff user who cannot manage all communities + When Alice attempts to update the community settings with: + | name | Staff Takeover | + Then Alice should see a community error containing "do not have permission" + And the community should not be modified + + @api-only + Scenario: Unauthenticated users cannot update community settings + Given Alice is an unauthenticated guest + When Alice attempts to update the community settings with: + | name | Anonymous Takeover | + Then the community update should be rejected as unauthorized + And the community should not be modified 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 b4a279858..d7a3fe62d 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/index.ts @@ -1,10 +1,21 @@ export { + COMMUNITY_IDS, + type CommunitySeedDocument, + communities, DEFAULT_STAFF_ROLE_NAMES, END_USER_IDS, + END_USER_ROLE_IDS, + type EndUserRoleSeedDocument, type EndUserSeedDocument, + endUserRoles, endUsers, + MEMBER_IDS, + type MemberSeedDocument, type MongoDBSeedContext, type MongoDBSeedDataFunction, + members, + OTHER_COMMUNITY, + SEEDED_COMMUNITY, STAFF_ROLE_IDS, STAFF_USER_IDS, type StaffRoleSeedDocument, diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/communities.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/communities.ts new file mode 100644 index 000000000..81ba53fce --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/communities.ts @@ -0,0 +1,54 @@ +import { END_USER_IDS } from './end-users.ts'; + +export interface CommunitySeedDocument { + _id: string; + name: string; + createdBy: string; + schemaVersion: string; + createdAt: Date; + updatedAt: Date; +} + +export const COMMUNITY_IDS = { + seededCommunity: 'f00000000000000000000001', + otherCommunity: 'f00000000000000000000002', +} as const; + +/** + * Baseline settings of the seeded community. Update scenarios assert against + * these values when verifying that a blocked update did not modify the community. + * + * The `domain`, `whiteLabelDomain`, and `handle` fields are intentionally left + * unset so update scenarios can set them without tripping the partial unique + * indexes on those fields. + */ +export const SEEDED_COMMUNITY = { + id: COMMUNITY_IDS.seededCommunity, + name: 'Seeded Community', +} as const; + +export const OTHER_COMMUNITY = { + id: COMMUNITY_IDS.otherCommunity, + name: 'Other Community', +} as const; + +const seedTimestamp = new Date('2024-01-01T00:00:00Z'); + +export const communities: CommunitySeedDocument[] = [ + { + _id: SEEDED_COMMUNITY.id, + name: SEEDED_COMMUNITY.name, + createdBy: END_USER_IDS.communityOwner, + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, + { + _id: OTHER_COMMUNITY.id, + name: OTHER_COMMUNITY.name, + createdBy: END_USER_IDS.communityMember, + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, +]; diff --git a/packages/ocom-verification/verification-shared/src/test-data/seed/end-user-roles.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/end-user-roles.ts new file mode 100644 index 000000000..bc17e6add --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/end-user-roles.ts @@ -0,0 +1,128 @@ +import { COMMUNITY_IDS } from './communities.ts'; + +interface EndUserRolePermissionsSeedDocument { + servicePermissions: { canManageServices: boolean }; + serviceTicketPermissions: { + canCreateTickets: boolean; + canManageTickets: boolean; + canAssignTickets: boolean; + canWorkOnTickets: boolean; + }; + violationTicketPermissions: { + canCreateTickets: boolean; + canManageTickets: boolean; + canAssignTickets: boolean; + canWorkOnTickets: boolean; + }; + communityPermissions: { + canManageRolesAndPermissions: boolean; + canManageCommunitySettings: boolean; + canManageSiteContent: boolean; + canManageMembers: boolean; + canEditOwnMemberProfile: boolean; + canEditOwnMemberAccounts: boolean; + }; + propertyPermissions: { + canManageProperties: boolean; + canEditOwnProperty: boolean; + }; +} + +export interface EndUserRoleSeedDocument { + _id: string; + roleType: 'end-user-roles'; + roleName: string; + community: string; + isDefault: boolean; + permissions: EndUserRolePermissionsSeedDocument; + schemaVersion: string; + createdAt: Date; + updatedAt: Date; +} + +export const END_USER_ROLE_IDS = { + seededAdminRole: 'e00000000000000000000001', + seededMemberRole: 'e00000000000000000000002', + otherCommunityAdminRole: 'e00000000000000000000003', +} as const; + +function buildPermissions(overrides: Partial): EndUserRolePermissionsSeedDocument { + const allFalseTickets = { + canCreateTickets: false, + canManageTickets: false, + canAssignTickets: false, + canWorkOnTickets: false, + }; + return { + servicePermissions: { canManageServices: false }, + serviceTicketPermissions: { ...allFalseTickets }, + violationTicketPermissions: { ...allFalseTickets }, + communityPermissions: { + canManageRolesAndPermissions: false, + canManageCommunitySettings: false, + canManageSiteContent: false, + canManageMembers: false, + canEditOwnMemberProfile: false, + canEditOwnMemberAccounts: false, + }, + propertyPermissions: { + canManageProperties: false, + canEditOwnProperty: false, + }, + ...overrides, + }; +} + +const adminCommunityPermissions = { + canManageRolesAndPermissions: true, + canManageCommunitySettings: true, + canManageSiteContent: true, + canManageMembers: true, + canEditOwnMemberProfile: true, + canEditOwnMemberAccounts: true, +}; + +const seedTimestamp = new Date('2024-01-01T00:00:00Z'); + +/** + * End-user roles seeded for community verification suites. + * + * The admin roles grant `canManageCommunitySettings`, which the community + * aggregate requires before accepting settings updates; the member role + * grants no community permissions. + */ +export const endUserRoles: EndUserRoleSeedDocument[] = [ + { + _id: END_USER_ROLE_IDS.seededAdminRole, + roleType: 'end-user-roles', + roleName: 'Seeded Community Admin', + community: COMMUNITY_IDS.seededCommunity, + isDefault: false, + permissions: buildPermissions({ communityPermissions: { ...adminCommunityPermissions } }), + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, + { + _id: END_USER_ROLE_IDS.seededMemberRole, + roleType: 'end-user-roles', + roleName: 'Seeded Community Member', + community: COMMUNITY_IDS.seededCommunity, + isDefault: true, + permissions: buildPermissions({}), + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, + { + _id: END_USER_ROLE_IDS.otherCommunityAdminRole, + roleType: 'end-user-roles', + roleName: 'Other Community Admin', + community: COMMUNITY_IDS.otherCommunity, + isDefault: false, + permissions: buildPermissions({ communityPermissions: { ...adminCommunityPermissions } }), + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }, +]; 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 f6d65410b..37d48e5d0 100644 --- a/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/index.ts @@ -1,4 +1,7 @@ +export { COMMUNITY_IDS, type CommunitySeedDocument, communities, OTHER_COMMUNITY, SEEDED_COMMUNITY } from './communities.ts'; +export { END_USER_ROLE_IDS, type EndUserRoleSeedDocument, endUserRoles } from './end-user-roles.ts'; export { END_USER_IDS, type EndUserSeedDocument, endUsers } from './end-users.ts'; +export { MEMBER_IDS, type MemberSeedDocument, members } from './members.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/members.ts b/packages/ocom-verification/verification-shared/src/test-data/seed/members.ts new file mode 100644 index 000000000..825476254 --- /dev/null +++ b/packages/ocom-verification/verification-shared/src/test-data/seed/members.ts @@ -0,0 +1,101 @@ +import { actors, type TestActor } from '../test-actors.ts'; +import { COMMUNITY_IDS } from './communities.ts'; +import { END_USER_ROLE_IDS } from './end-user-roles.ts'; +import { END_USER_IDS } from './end-users.ts'; + +export interface MemberSeedDocument { + _id: string; + memberName: string; + community: string; + role: string; + accounts: Array<{ + _id: string; + firstName: string; + lastName: string; + user: string; + statusCode: 'ACCEPTED'; + createdBy: string; + }>; + customViews: never[]; + profile: { + name: string; + email: string; + }; + schemaVersion: string; + createdAt: Date; + updatedAt: Date; +} + +export const MEMBER_IDS = { + /** Member of the seeded community whose role grants `canManageCommunitySettings`. */ + seededAdminMember: 'd00000000000000000000001', + /** Member of the seeded community whose role grants no community permissions. */ + seededPlainMember: 'd00000000000000000000002', + /** Admin member of the *other* community, used for cross-community permission checks. */ + otherCommunityAdminMember: 'd00000000000000000000003', +} as const; + +const MEMBER_ACCOUNT_IDS = { + seededAdminAccount: 'd10000000000000000000001', + seededPlainAccount: 'd10000000000000000000002', + otherCommunityAdminAccount: 'd10000000000000000000003', +} as const; + +const seedTimestamp = new Date('2024-01-01T00:00:00Z'); + +function createMemberSeedDocument(id: string, accountId: string, memberName: string, communityId: string, roleId: string, endUserId: string, actor: TestActor): MemberSeedDocument { + return { + _id: id, + memberName, + community: communityId, + role: roleId, + accounts: [ + { + _id: accountId, + firstName: actor.givenName, + lastName: actor.familyName, + user: endUserId, + statusCode: 'ACCEPTED', + createdBy: endUserId, + }, + ], + customViews: [], + profile: { + name: memberName, + email: actor.email, + }, + schemaVersion: '1.0.0', + createdAt: seedTimestamp, + updatedAt: seedTimestamp, + }; +} + +export const members: MemberSeedDocument[] = [ + createMemberSeedDocument( + MEMBER_IDS.seededAdminMember, + MEMBER_ACCOUNT_IDS.seededAdminAccount, + 'Seeded Admin Member', + COMMUNITY_IDS.seededCommunity, + END_USER_ROLE_IDS.seededAdminRole, + END_USER_IDS.communityOwner, + actors.CommunityOwner, + ), + createMemberSeedDocument( + MEMBER_IDS.seededPlainMember, + MEMBER_ACCOUNT_IDS.seededPlainAccount, + 'Seeded Plain Member', + COMMUNITY_IDS.seededCommunity, + END_USER_ROLE_IDS.seededMemberRole, + END_USER_IDS.communityMember, + actors.CommunityMember, + ), + createMemberSeedDocument( + MEMBER_IDS.otherCommunityAdminMember, + MEMBER_ACCOUNT_IDS.otherCommunityAdminAccount, + 'Other Community Admin Member', + COMMUNITY_IDS.otherCommunity, + END_USER_ROLE_IDS.otherCommunityAdminRole, + END_USER_IDS.communityMember, + actors.CommunityMember, + ), +]; 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 3845a3a33..d4c7e4335 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,8 @@ import { type Document, MongoClient, ObjectId } from 'mongodb'; +import { communities } from './communities.ts'; +import { endUserRoles } from './end-user-roles.ts'; import { endUsers } from './end-users.ts'; +import { members } from './members.ts'; import { staffRoles } from './staff-roles.ts'; import { staffUsers } from './staff-users.ts'; @@ -38,17 +41,41 @@ export async function seedDatabase(context: MongoDBSeedContext): Promise { ...user, _id: toObjectId(user._id), })); - const roles = staffRoles.map((role) => ({ - ...role, - _id: toObjectId(role._id), - })); const staff = staffUsers.map((user) => ({ ...user, _id: toObjectId(user._id), role: toObjectId(user.role), })); + const roles = staffRoles.map((role) => ({ + ...role, + _id: toObjectId(role._id), + })); + const memberRoles = endUserRoles.map((role) => ({ + ...role, + _id: toObjectId(role._id), + community: toObjectId(role.community), + })); + const communityDocuments = communities.map((community) => ({ + ...community, + _id: toObjectId(community._id), + createdBy: toObjectId(community.createdBy), + })); + const memberDocuments = members.map((member) => ({ + ...member, + _id: toObjectId(member._id), + community: toObjectId(member.community), + role: toObjectId(member.role), + accounts: member.accounts.map((account) => ({ + ...account, + _id: toObjectId(account._id), + user: toObjectId(account.user), + createdBy: toObjectId(account.createdBy), + })), + })); await upsertSeedDocuments(client, context.dbName, 'users', [...users, ...staff]); - await upsertSeedDocuments(client, context.dbName, 'roles', roles); + await upsertSeedDocuments(client, context.dbName, 'roles', [...roles, ...memberRoles]); + await upsertSeedDocuments(client, context.dbName, 'communities', communityDocuments); + await upsertSeedDocuments(client, context.dbName, 'members', memberDocuments); } finally { await client.close(); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c1b08443..5799f36a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,7 +126,8 @@ overrides: '@vitest/browser-playwright': 4.1.8 happy-dom: 20.10.1 axios: 1.16.0 - body-parser: 2.3.0 + body-parser@1: 1.20.6 + body-parser@2: 2.3.0 esbuild: 0.28.1 follow-redirects: ^1.16.0 vite: 8.0.16 @@ -138,6 +139,7 @@ overrides: '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 serve-handler>minimatch: 3.1.5 + websocket-driver: ^0.7.5 serialize-javascript@6.0.2: 7.0.5 serialize-javascript@7.0.4: 7.0.5 svgo: ^3.3.3 @@ -157,13 +159,12 @@ overrides: express-rate-limit: 8.5.1 '@azure/ms-rest-js>uuid': ^3.4.0 azurite>uuid: ^3.4.0 - ws@8.20.0: 8.21.0 playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 protobufjs: 7.6.5 ip-address: ^10.1.1 - fast-uri: ^4.0.1 + fast-uri: ^4.1.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 js-yaml: 4.3.0 @@ -171,7 +172,7 @@ overrides: 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 - ws: 8.21.0 + ws: 8.21.1 shell-quote: 1.9.0 '@grpc/grpc-js': '>=1.14.4' form-data: ^4.0.6 @@ -513,7 +514,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../packages/cellix/ui-core @@ -543,7 +544,7 @@ importers: version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) apollo-link-rest: specifier: ^0.9.0 - version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) + version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) less: specifier: ^4.4.0 version: 4.4.2 @@ -616,7 +617,7 @@ importers: version: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) storybook-addon-apollo-client: specifier: ^9.0.0 - version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) + version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) tailwindcss: specifier: ^3.4.17 version: 3.4.18(tsx@4.21.0)(yaml@2.8.3) @@ -637,7 +638,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../packages/cellix/ui-core @@ -676,7 +677,7 @@ importers: version: 6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) apollo-link-rest: specifier: ^0.9.0 - version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) + version: 0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2) less: specifier: ^4.4.0 version: 4.4.2 @@ -1380,7 +1381,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/serenity-framework': specifier: workspace:* version: link:../../cellix/serenity-framework @@ -2137,7 +2138,7 @@ importers: version: 7.22.7(antd@6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2231,7 +2232,7 @@ importers: version: 7.22.7(antd@6.3.5(luxon@3.7.2)(moment@2.30.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2389,7 +2390,7 @@ importers: dependencies: '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2471,7 +2472,7 @@ importers: version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -2550,7 +2551,7 @@ importers: version: 10.4.2(@testing-library/dom@10.4.1)(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) storybook-addon-apollo-client: specifier: ^9.0.0 - version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) + version: 9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0) typescript: specifier: 'catalog:' version: 6.0.3 @@ -2740,7 +2741,7 @@ importers: version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@graphql-typed-document-node/core': specifier: ^3.2.0 version: 3.2.0(graphql@16.12.0) @@ -2813,7 +2814,7 @@ importers: version: 6.1.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@apollo/client': specifier: ^3.13.9 - version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@cellix/ui-core': specifier: workspace:* version: link:../../cellix/ui-core @@ -7876,6 +7877,10 @@ packages: bn.js@5.2.3: resolution: {integrity: sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==} + body-parser@1.20.6: + resolution: {integrity: sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -9124,8 +9129,8 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - fast-uri@4.0.1: - resolution: {integrity: sha512-j0VntHrljgRKiWdbjL1xECG44OQu5sb7en4b8z2CcRvtDUEG5DAvkXlllrRhvbABR+m2mUiSgYBPvDj/PzB0dg==} + fast-uri@4.1.1: + resolution: {integrity: sha512-YPOs1zD5TG2+EZt+r88LwF6mclA7TPkpwMP7ZN3TO2HiHS8TXvq7QA/17iJsV9dubcLo/f8eEYqMBruyQV21hQ==} fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -9492,7 +9497,7 @@ packages: crossws: ~0.3 graphql: ^15.10.1 || ^16 uWebSockets.js: ^20 - ws: 8.21.0 + ws: 8.21.1 peerDependenciesMeta: '@fastify/websocket': optional: true @@ -9735,6 +9740,10 @@ 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'} @@ -10149,7 +10158,7 @@ packages: isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: - ws: 8.21.0 + ws: 8.21.1 istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} @@ -12123,6 +12132,10 @@ 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'} @@ -13877,8 +13890,8 @@ packages: webpack: optional: true - websocket-driver@0.7.4: - resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==} + websocket-driver@0.7.5: + resolution: {integrity: sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==} engines: {node: '>=0.8.0'} websocket-extensions@0.1.4: @@ -13979,8 +13992,8 @@ packages: write-file-atomic@3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} - ws@8.21.0: - resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -14382,7 +14395,7 @@ snapshots: dependencies: graphql: 16.12.0 - '@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) '@wry/caches': 1.0.1 @@ -14399,7 +14412,7 @@ snapshots: tslib: 2.8.1 zen-observable-ts: 1.2.5 optionalDependencies: - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.0) + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.1) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) transitivePeerDependencies: @@ -14442,7 +14455,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.3.0 + body-parser: 2.3.0(supports-color@8.1.1) content-type: 1.0.5 cors: 2.8.5 finalhandler: 2.1.1(supports-color@8.1.1) @@ -17376,10 +17389,10 @@ snapshots: '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@whatwg-node/disposablestack': 0.0.6 graphql: 16.12.0 - graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.0) - isomorphic-ws: 5.0.0(ws@8.21.0) + graphql-ws: 6.0.6(graphql@16.12.0)(ws@8.21.1) + isomorphic-ws: 5.0.0(ws@8.21.1) tslib: 2.8.1 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - '@fastify/websocket' - bufferutil @@ -17407,9 +17420,9 @@ snapshots: '@graphql-tools/utils': 10.11.0(graphql@16.12.0) '@types/ws': 8.18.1 graphql: 16.12.0 - isomorphic-ws: 5.0.0(ws@8.21.0) + isomorphic-ws: 5.0.0(ws@8.21.1) tslib: 2.8.1 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -17581,10 +17594,10 @@ snapshots: '@whatwg-node/fetch': 0.10.13 '@whatwg-node/promise-helpers': 1.3.2 graphql: 16.12.0 - isomorphic-ws: 5.0.0(ws@8.21.0) + isomorphic-ws: 5.0.0(ws@8.21.1) sync-fetch: 0.6.0-2 tslib: 2.8.1 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - '@fastify/websocket' - '@types/node' @@ -17648,7 +17661,7 @@ snapshots: '@inquirer/external-editor@1.0.3(@types/node@22.19.15)': dependencies: chardet: 2.1.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.3 optionalDependencies: '@types/node': 22.19.15 @@ -19905,7 +19918,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.1.0 vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.19.15)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@22.19.15)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - msw @@ -19923,7 +19936,7 @@ snapshots: sirv: 3.0.2 tinyrainbow: 3.1.0 vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@24.10.1)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-istanbul@4.1.8)(happy-dom@20.10.1)(jsdom@26.1.0)(vite@8.0.16(@types/node@24.10.1)(esbuild@0.28.1)(jiti@2.6.1)(less@4.4.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.3)) - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - msw @@ -20226,7 +20239,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 4.0.1 + fast-uri: 4.1.1 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -20346,9 +20359,9 @@ snapshots: normalize-path: 3.0.0 picomatch: 4.0.4 - apollo-link-rest@0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2): + apollo-link-rest@0.9.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(qs@6.15.2): dependencies: - '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) graphql: 16.12.0 qs: 6.15.2 @@ -20620,7 +20633,24 @@ snapshots: bn.js@5.2.3: {} - body-parser@2.3.0: + body-parser@1.20.6: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + body-parser@2.3.0(supports-color@8.1.1): dependencies: bytes: 3.1.2 content-type: 2.0.0 @@ -21999,7 +22029,7 @@ snapshots: dependencies: accepts: 1.3.8 array-flatten: 1.1.1 - body-parser: 2.3.0 + body-parser: 1.20.6 content-disposition: 0.5.4 content-type: 1.0.5 cookie: 0.7.2 @@ -22051,7 +22081,7 @@ snapshots: fast-json-stable-stringify@2.1.0: {} - fast-uri@4.0.1: {} + fast-uri@4.1.1: {} fast-xml-builder@1.2.0: dependencies: @@ -22076,7 +22106,7 @@ snapshots: faye-websocket@0.11.4: dependencies: - websocket-driver: 0.7.4 + websocket-driver: 0.7.5 fb-watchman@2.0.2: dependencies: @@ -22492,11 +22522,11 @@ snapshots: graphql: 16.12.0 tslib: 2.8.1 - graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0): + graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1): dependencies: graphql: 16.12.0 optionalDependencies: - ws: 8.21.0 + ws: 8.21.1 graphql@14.7.0: dependencies: @@ -22525,7 +22555,7 @@ snapshots: buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -22845,6 +22875,10 @@ snapshots: hyperdyperid@1.2.0: {} + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -23196,9 +23230,9 @@ snapshots: isomorphic-timers-promises@1.0.1: {} - isomorphic-ws@5.0.0(ws@8.21.0): + isomorphic-ws@5.0.0(ws@8.21.1): dependencies: - ws: 8.21.0 + ws: 8.21.1 istanbul-lib-coverage@3.2.2: {} @@ -23294,7 +23328,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.21.0 + ws: 8.21.1 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -25643,11 +25677,18 @@ 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 http-errors: 2.0.1 - iconv-lite: 0.7.0 + iconv-lite: 0.7.3 unpipe: 1.0.0 rc-resize-observer@1.4.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0): @@ -26512,7 +26553,7 @@ snapshots: dependencies: faye-websocket: 0.11.4 uuid: 8.3.2 - websocket-driver: 0.7.4 + websocket-driver: 0.7.5 sort-css-media-queries@2.2.0: {} @@ -26613,9 +26654,9 @@ snapshots: stoppable@1.1.0: {} - storybook-addon-apollo-client@9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0): + storybook-addon-apollo-client@9.0.0(@apollo/client@3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(graphql@16.12.0)(react@19.2.0): dependencies: - '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.0))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@apollo/client': 3.14.0(@types/react@19.2.7)(graphql-ws@6.0.6(graphql@16.12.0)(ws@8.21.1))(graphql@16.12.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) graphql: 16.12.0 react: 19.2.0 @@ -26635,7 +26676,7 @@ snapshots: recast: 0.23.11 semver: 7.8.0 use-sync-external-store: 1.6.0(react@19.2.0) - ws: 8.21.0 + ws: 8.21.1 optionalDependencies: '@types/react': 19.2.7 transitivePeerDependencies: @@ -27551,7 +27592,7 @@ snapshots: opener: 1.5.2 picocolors: 1.1.1 sirv: 2.0.4 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -27596,7 +27637,7 @@ snapshots: sockjs: 0.3.24 spdy: 4.0.2 webpack-dev-middleware: 7.4.5(webpack@5.105.4(esbuild@0.28.1)) - ws: 8.21.0 + ws: 8.21.1 optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) transitivePeerDependencies: @@ -27661,7 +27702,7 @@ snapshots: optionalDependencies: webpack: 5.105.4(esbuild@0.28.1) - websocket-driver@0.7.4: + websocket-driver@0.7.5: dependencies: http-parser-js: 0.5.10 safe-buffer: 5.2.1 @@ -27802,7 +27843,7 @@ snapshots: signal-exit: 3.0.7 typedarray-to-buffer: 3.1.5 - ws@8.21.0: {} + ws@8.21.1: {} wsl-utils@0.1.0: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d72187af9..37af6630f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -79,7 +79,8 @@ overrides: '@vitest/browser-playwright': 4.1.8 happy-dom: 20.10.1 axios: 1.16.0 - body-parser: 2.3.0 + 'body-parser@1': 1.20.6 + 'body-parser@2': 2.3.0 esbuild: 'catalog:' follow-redirects: ^1.16.0 vite: "catalog:" @@ -91,6 +92,7 @@ overrides: '@protobufjs/codegen': 2.0.5 '@protobufjs/utf8': 1.1.1 'serve-handler>minimatch': 3.1.5 + websocket-driver: ^0.7.5 'serialize-javascript@6.0.2': 7.0.5 'serialize-javascript@7.0.4': 7.0.5 svgo: ^3.3.3 @@ -110,13 +112,12 @@ overrides: express-rate-limit: 8.5.1 '@azure/ms-rest-js>uuid': '^3.4.0' 'azurite>uuid': '^3.4.0' - 'ws@8.20.0': 8.21.0 playwright-core: 1.59.0 playwright: 1.59.0 postcss: 8.5.10 protobufjs: 7.6.5 ip-address: ^10.1.1 - fast-uri: ^4.0.1 + fast-uri: ^4.1.1 '@babel/plugin-transform-modules-systemjs': 7.29.4 '@babel/core': ^7.29.6 js-yaml: 4.3.0 @@ -124,7 +125,7 @@ overrides: '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 - ws: 8.21.0 + ws: 8.21.1 shell-quote: 1.9.0 '@grpc/grpc-js': '>=1.14.4' form-data: ^4.0.6 @@ -151,5 +152,5 @@ patchedDependencies: '@azure/functions@4.11.0': patches/@azure__functions@4.11.0.patch minimumReleaseAgeExclude: - - fast-uri@3.1.3 || 4.0.1 + - fast-uri@3.1.3 || 4.0.1 || 4.1.1 - body-parser@1.20.6