Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .snyk
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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'
12 changes: 12 additions & 0 deletions apps/ui-community/mock-oidc.users.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
]
35 changes: 27 additions & 8 deletions packages/cellix/server-oauth2-mock-seedwork/src/login-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
get(key: string): T | undefined;
set(key: string, value: T): void;
Expand Down Expand Up @@ -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<string, unknown>;
const nonceFromQuery = typeof rawQueryNonce === 'string' ? rawQueryNonce : undefined;
let loginNonceUsed: string | undefined;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down
10 changes: 10 additions & 0 deletions packages/cellix/server-oauth2-mock-seedwork/src/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion packages/cellix/server-oauth2-mock-seedwork/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<Promise<string>> {
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<string> {
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);
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
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<string | undefined> {
try {
return await actor.answer(notes<Record<typeof key, string>>().get(key));
} catch {
return undefined;
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Promise<string>> {
static displayed(): ViewedCommunityName {
return new ViewedCommunityName();
}

private constructor() {
super('viewed community name');
}

override async answeredBy(actor: AnswersQuestions & UsesAbilities): Promise<string> {
let viewedName: string | undefined;
try {
viewedName = await actor.answer(notes<CommunityNotes>().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';
}
Original file line number Diff line number Diff line change
@@ -1,47 +1,57 @@
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<CommunityDetails>();

await actor.attemptsTo(CreateCommunity.with(details));
});

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<CommunityDetails>();
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<void> {
setLastActorName(actorName);
const actor = actorCalled(actorName);

await actor.attemptsTo(notes<CommunityNotes>().set('lastCommunityId', undefined as unknown as string), notes<CommunityNotes>().set('lastValidationError', undefined as unknown as string));
await actor.attemptsTo(
notes<CommunityNotes>().set('lastCommunityId', undefined as unknown as string),
notes<CommunityNotes>().set('lastCommunityStatus', undefined as unknown as string),
notes<CommunityNotes>().set('lastValidationError', undefined as unknown as string),
);

try {
await actor.attemptsTo(CreateCommunity.with(details));
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
await actor.attemptsTo(notes<CommunityNotes>().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') {
Expand All @@ -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) {
Expand All @@ -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<CommunityNotes>().get('lastCommunityId'));
const communityName = await actor.answer(notes<CommunityNotes>().get('lastCommunityName'));

Expand All @@ -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;
Expand All @@ -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<CommunityNotes>().get('lastCommunityId'));
status = await actor.answer(notes<CommunityNotes>().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;
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
// Community context step definitions
import './create-community.steps.ts';
import './update-community-settings.steps.ts';
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading