Skip to content
Merged
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
81 changes: 50 additions & 31 deletions cypress/component/components/a11y-guard/a11y-guard.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,43 +55,62 @@ const FOCUSABLE_SEL = [
* - landmark-one-main : mounted fragment has no <main> landmark
* - page-has-heading-one : mounted fragment may have no <h1>
*/
const AXE_COMPONENT_RULES = {
rules: {
'color-contrast': { enabled: false },
'document-title': { enabled: false },
'html-has-lang': { enabled: false },
'landmark-one-main': { enabled: false },
'page-has-heading-one': { enabled: false },
},
};

function checkComponentA11y() {
cy.injectAxe();
cy.checkA11y('[data-cy-root]', {
rules: {
'color-contrast': { enabled: false },
'document-title': { enabled: false },
'html-has-lang': { enabled: false },
'landmark-one-main': { enabled: false },
'page-has-heading-one': { enabled: false },
},
});
cy.checkA11y('[data-cy-root]', AXE_COMPONENT_RULES);
}

describe('a11y guard — axe structural/aria (0 violations)', () => {
it('AuthCeremony with a full verify body has no axe violations', () => {
cy.mount(
<AuthCeremony title="Enter your code" description="We sent a code to your email">
<CeremonyBody />
</AuthCeremony>,
OPTS
);
checkComponentA11y();
});
it('AuthCeremony verify body and AuthFormFields cluster have no axe violations', () => {
const surfaces = [
{
label: 'AuthCeremony with a full verify body',
node: (
<AuthCeremony title="Enter your code" description="We sent a code to your email">
<CeremonyBody />
</AuthCeremony>
),
opts: OPTS,
},
{
label: 'AuthFormFields (hidden-input cluster)',
node: (
<form>
<AuthFormFields
csrf="csrf-token"
loginName="alice@acme.test"
requestId="rq1"
organization="acme"
next="/dashboard"
/>
</form>
),
opts: undefined,
},
];

it('AuthFormFields (hidden-input cluster) has no axe violations', () => {
cy.mount(
<form>
<AuthFormFields
csrf="csrf-token"
loginName="alice@acme.test"
requestId="rq1"
organization="acme"
next="/dashboard"
/>
</form>
);
checkComponentA11y();
surfaces.forEach((surface, i) => {
// cy.log names the surface in the command log — checkA11y reports the
// violation itself but not which row mounted the offending tree.
cy.log(surface.label);
cy.mount(surface.node, surface.opts);
if (i === 0) {
// axe persists on the AUT window across mounts within a test — inject
// once after the first mount instead of re-evaluating the bundle per row.
cy.injectAxe();
}
cy.checkA11y('[data-cy-root]', AXE_COMPONENT_RULES);
});
});

it('inline error banner (FormError, role="alert") has no axe violations', () => {
Expand Down
32 changes: 16 additions & 16 deletions cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,33 +35,33 @@ describe('AuthCeremony shell', () => {
});
});

describe('AuthCeremony shell — identity centering', () => {
it('centers the IdentityBadge row (items-center, not items-baseline)', () => {
cy.mount(
<AuthCeremony title="t" loginName="alice@acme.test">
<span>c</span>
</AuthCeremony>,
OPTS
);
cy.get('[data-testid="auth-ceremony-body"]')
.should('have.class', 'items-center')
.and('not.have.class', 'items-baseline');
});
});

describe('AuthCeremony shell — showBackLink suppression', () => {
it('renders no Back control when showBackLink={false}, even at a path with a real predecessor', () => {
describe('AuthCeremony shell — identity centering + showBackLink suppression', () => {
it('renders no Back control when showBackLink={false}, and centers the IdentityBadge row', () => {
// OPTS mounts at /login/password, which DOES have a predecessor in previous-step.ts
// (-> /login) — proving suppression here (not just at a dead-link path) is what makes
// Tasks 3 and 5's showBackLink={false} route changes meaningfully tested: this test
// proves the mechanism; those tasks prove the specific routes wire it through.
//
// Asserted FIRST on purpose: `cy.get('a').should('not.exist')` is a whole-DOM negative,
// so it is only meaningful on a clean mount — it must not run after a sibling mount in
// this test has rendered links of its own.
cy.mount(
<AuthCeremony title="t" showBackLink={false}>
<span>c</span>
</AuthCeremony>,
OPTS
);
cy.get('a').should('not.exist');

cy.mount(
<AuthCeremony title="t" loginName="alice@acme.test">
<span>c</span>
</AuthCeremony>,
OPTS
);
cy.get('[data-testid="auth-ceremony-body"]')
.should('have.class', 'items-center')
.and('not.have.class', 'items-baseline');
});
});

Expand Down
26 changes: 12 additions & 14 deletions cypress/component/components/back-link/previous-step.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,27 @@ import { previousStepFor } from '@/components/back-link/previous-step';

describe('previousStepFor', () => {
it('maps each ceremony step to its predecessor, including /setup/* enrollment screens, the MFA chooser, password-management screens, and terminal/headless steps (spec §5)', () => {
expect(previousStepFor('/login/password')).to.equal('/login');
expect(previousStepFor('/login/mfa')).to.equal('/login/password');
expect(previousStepFor('/signup/password')).to.equal('/signup');
expect(previousStepFor('/password/reset')).to.equal('/login/password');
expect(previousStepFor('/login/password'), '/login/password').to.equal('/login');
expect(previousStepFor('/login/mfa'), '/login/mfa').to.equal('/login/password');
expect(previousStepFor('/signup/password'), '/signup/password').to.equal('/signup');
// /signup/method mirrors /signup/password: both return to /signup.
expect(previousStepFor('/signup/method'), '/signup/method').to.equal('/signup');
expect(previousStepFor('/password/reset'), '/password/reset').to.equal('/login/password');

// The /setup/* leaf enrollment screens are reached FROM the /setup/mfa
// chooser, so Back returns there.
expect(previousStepFor('/setup/passkey')).to.equal('/setup/mfa');
expect(previousStepFor('/setup/email')).to.equal('/setup/mfa');
expect(previousStepFor('/setup/passkey'), '/setup/passkey').to.equal('/setup/mfa');
expect(previousStepFor('/setup/email'), '/setup/email').to.equal('/setup/mfa');

// The chooser itself returns to /login/password (mirrors /login/mfa → /login/password).
expect(previousStepFor('/setup/mfa')).to.equal('/login/password');
expect(previousStepFor('/setup/mfa'), '/setup/mfa').to.equal('/login/password');

// Password-management screens return to /login/password.
expect(previousStepFor('/password/new')).to.equal('/login/password');
expect(previousStepFor('/password/new'), '/password/new').to.equal('/login/password');

// Terminal/headless steps have no predecessor.
expect(previousStepFor('/login')).to.be.null;
expect(previousStepFor('/signed-in')).to.be.null;
expect(previousStepFor('/login'), '/login').to.be.null;
expect(previousStepFor('/signed-in'), '/signed-in').to.be.null;
});

it('verify/* and security-key Back goes straight to /login, not /login/mfa (fixes the sole-factor loop, 2026-07-22)', () => {
Expand All @@ -34,8 +36,4 @@ describe('previousStepFor', () => {
expect(previousStepFor('/login/passkey')).to.equal('/login');
expect(previousStepFor('/login/security-key')).to.equal('/login');
});

it('signup/method returns to /signup (mirrors signup/password)', () => {
expect(previousStepFor('/signup/method')).to.equal('/signup');
});
});
40 changes: 23 additions & 17 deletions cypress/component/components/brand-logo/brand-logo.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,30 @@
import { BrandLogo } from '@/components/brand-logo/brand-logo';

describe('BrandLogo — preserves the ceremony on its home link', () => {
it('carries requestId + organization from the current URL onto the home link', () => {
cy.mount(<BrandLogo />, {
path: '/login',
initialEntries: ['/login?requestId=oidc_V2_123&organization=org-1'],
});
cy.get('a').should('have.attr', 'href', '/?requestId=oidc_V2_123&organization=org-1');
});
it('carries ceremony params from the current URL onto the home link', () => {
const rows = [
{
label: 'requestId + organization carried onto the home link',
entry: '/login?requestId=oidc_V2_123&organization=org-1',
expectedHref: '/?requestId=oidc_V2_123&organization=org-1',
},
{
label: 'requestId alone (organization omitted) without a stray param',
entry: '/login?requestId=saml_abc',
expectedHref: '/?requestId=saml_abc',
},
{
label: 'no ceremony params (bare /login) degrades to a bare "/"',
entry: '/login',
expectedHref: '/',
},
] as const;

it('carries requestId alone (organization omitted) without a stray param', () => {
cy.mount(<BrandLogo />, {
path: '/login',
initialEntries: ['/login?requestId=saml_abc'],
rows.forEach((row) => {
cy.mount(<BrandLogo />, { path: '/login', initialEntries: [row.entry] });
cy.get('a').should(($a) => {
expect($a.attr('href'), row.label).to.equal(row.expectedHref);
});
});
cy.get('a').should('have.attr', 'href', '/?requestId=saml_abc');
});

it('degrades to a bare "/" on a page with no ceremony params (e.g. bare /login)', () => {
cy.mount(<BrandLogo />, { path: '/login', initialEntries: ['/login'] });
cy.get('a').should('have.attr', 'href', '/');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,15 @@ import { SignOutButton } from '@/components/sign-out-button/sign-out-button';
// "bg-btn-primary"; theme="link" + type="secondary" emits a class containing "underline",
// with no compound-variant overlap between the two). Assert on those classes instead.
describe('SignOutButton', () => {
it('posts to /id/logout?index with the CSRF token and defaults to the secondary/link treatment', () => {
it('posts to /id/logout?index with CSRF, defaulting to the link treatment', () => {
cy.mount(<SignOutButton csrf="tok-1" />);
cy.get('form').should('have.attr', 'action', '/id/logout?index');
cy.get('input[name="csrf"]').should('have.value', 'tok-1');
cy.contains('button', 'Sign out')
.invoke('attr', 'class')
.should('include', 'underline')
.and('not.include', 'bg-btn-primary');
});

it('renders the primary/solid treatment when emphasis="primary"', () => {
cy.mount(<SignOutButton csrf="tok-1" emphasis="primary" />);
cy.contains('button', 'Sign out')
.invoke('attr', 'class')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ function mountRejecting(
cy.window().then((win) => {
(win as unknown as { __webAuthnRealCeremony?: boolean }).__webAuthnRealCeremony = true;
ensureWebAuthnEnv(win);
// A single test may drive more than one ceremony failure (one mount per DOMException).
// Cypress only auto-restores stubs BETWEEN tests, so re-stubbing the same method inside
// one test throws "Attempted to wrap create which is already wrapped" — undo the previous
// row's stub first. No-op on the first row, where nothing is wrapped yet.
const creds = win.navigator.credentials as unknown as Record<string, { restore?: () => void }>;
creds[method]?.restore?.();
cy.stub(win.navigator.credentials, method).rejects(new win.DOMException('boom', domName));
});
cy.findByRole('button').should('not.be.disabled').click();
Expand All @@ -56,24 +62,38 @@ describe('WebAuthnButton enroll (attestation) failure copy', () => {
cy.findByText(/verification failed/i).should('not.exist');
});

it('InvalidStateError → already-registered', () => {
mountRejecting('attestation', 'create', 'InvalidStateError');
cy.findByText(/already have a passkey for this account/i).should('exist');
});

it("NotSupportedError → device can't create a passkey", () => {
mountRejecting('attestation', 'create', 'NotSupportedError');
cy.findByText(/can't create a passkey/i).should('exist');
});

it('SecurityError → security-reasons setup copy', () => {
mountRejecting('attestation', 'create', 'SecurityError');
cy.findByText(/passkey setup couldn't be completed for security reasons/i).should('exist');
});

it('unmapped DOMException → generic enroll copy', () => {
mountRejecting('attestation', 'create', 'NetworkError');
cy.findByText(/couldn't set up your passkey/i).should('exist');
// Each row is one full ceremony: remount, reject create() with the named DOMException,
// assert that reason's enroll copy. mountRejecting restores the previous row's stub and
// the remount clears the previous row's copy, so rows are independent; cy.log names the
// row in the command log right before its assertions.
it('classifies each remaining DOMException to its enroll copy', () => {
const rows: ReadonlyArray<{ label: string; domName: string; copy: RegExp }> = [
{
label: 'InvalidStateError → already-registered',
domName: 'InvalidStateError',
copy: /already have a passkey for this account/i,
},
{
label: "NotSupportedError → device can't create a passkey",
domName: 'NotSupportedError',
copy: /can't create a passkey/i,
},
{
label: 'SecurityError → security-reasons setup copy',
domName: 'SecurityError',
copy: /passkey setup couldn't be completed for security reasons/i,
},
{
label: 'unmapped DOMException (NetworkError) → generic enroll copy',
domName: 'NetworkError',
copy: /couldn't set up your passkey/i,
},
];
for (const row of rows) {
cy.log(row.label);
mountRejecting('attestation', 'create', row.domName);
cy.findByText(row.copy).should('exist');
}
});
});

Expand All @@ -84,24 +104,34 @@ describe('WebAuthnButton sign-in (assertion) failure copy', () => {
cy.findByText(/set up your passkey/i).should('exist');
});

it("NotSupportedError → device can't use a passkey to sign in", () => {
mountRejecting('assertion', 'get', 'NotSupportedError');
cy.findByText(/can't use a passkey to sign in/i).should('exist');
// Same table pattern as the enroll describe: one full ceremony per labeled row.
it('classifies NotSupportedError and SecurityError to their sign-in copy', () => {
const rows: ReadonlyArray<{ label: string; domName: string; copy: RegExp }> = [
{
label: "NotSupportedError → device can't use a passkey to sign in",
domName: 'NotSupportedError',
copy: /can't use a passkey to sign in/i,
},
{
label: 'SecurityError → security-reasons sign-in copy',
domName: 'SecurityError',
copy: /passkey sign-in couldn't be completed for security reasons/i,
},
];
for (const row of rows) {
cy.log(row.label);
mountRejecting('assertion', 'get', row.domName);
cy.findByText(row.copy).should('exist');
}
});

it('SecurityError → security-reasons sign-in copy', () => {
mountRejecting('assertion', 'get', 'SecurityError');
cy.findByText(/passkey sign-in couldn't be completed for security reasons/i).should('exist');
});

it('unmapped DOMException → generic sign-in copy', () => {
// already-registered (InvalidStateError) does not apply to a sign-in ceremony; it falls
// back to the generic verification copy rather than showing enroll-only wording — the
// same copy an unmapped DOMException (NetworkError) produces.
it('unmapped DOMException and InvalidStateError both → generic sign-in copy (already-registered N/A on sign-in)', () => {
mountRejecting('assertion', 'get', 'NetworkError');
cy.findByText(/verification failed/i).should('exist');
});

// already-registered (InvalidStateError) does not apply to a sign-in ceremony; it falls
// back to the generic verification copy rather than showing enroll-only wording.
it('InvalidStateError → generic sign-in copy (already-registered N/A on sign-in)', () => {
mountRejecting('assertion', 'get', 'InvalidStateError');
cy.findByText(/verification failed/i).should('exist');
cy.findByText(/already have a passkey/i).should('not.exist');
Expand Down
Loading
Loading