From cd11ebdb08d751dde288fba1597feb97de636a22 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 3 Aug 2026 12:22:32 +0700 Subject: [PATCH 1/2] test(cypress): consolidate the component test suite Merge single-assertion tests into table-driven ones across routes, components, modules and resources. Assertions are preserved; a few verified duplicates are removed. Each row carries a label so a failure names the case that broke, and titles name the primary behaviour. Security-relevant guards stay as standalone tests rather than table rows. --- .../components/a11y-guard/a11y-guard.cy.tsx | 81 ++-- .../auth-ceremony/auth-ceremony.cy.tsx | 32 +- .../components/back-link/previous-step.cy.ts | 26 +- .../components/brand-logo/brand-logo.cy.tsx | 40 +- .../sign-out-button/sign-out-button.cy.tsx | 4 +- .../webauthn-button.ceremony-errors.cy.tsx | 92 +++-- .../hooks/use-auth-action-error.cy.tsx | 28 +- .../hooks/use-auth-action-recovery.cy.tsx | 64 +-- .../use-conditional-passkey-discovery.cy.tsx | 11 +- .../hooks/use-conditional-passkey.cy.tsx | 23 +- .../component/modules/analytics/rybbit.cy.tsx | 19 +- ...der.types.cy.ts => auth-provider.types.ts} | 0 .../modules/auth/fake-passkeys.cy.ts | 60 ++- .../modules/auth/provider-registry.cy.ts | 29 -- .../fake/fake-provider.domain-discovery.cy.ts | 16 - .../fake/fake-provider.email-verify.cy.ts | 36 -- .../fake-provider.seed-capabilities.cy.ts | 67 ++++ .../fake/fake-provider.user-agent.cy.ts | 30 -- .../auth/providers/zitadel/index.cy.ts | 64 +-- .../auth/providers/zitadel/mappers.cy.ts | 19 + .../providers/zitadel/mappers.otpemail.cy.ts | 19 - .../auth/providers/zitadel/mappers.p2.cy.ts | 27 +- .../auth/providers/zitadel/mappers.p5.cy.ts | 9 + .../auth/providers/zitadel/timeouts.cy.ts | 14 - .../providers/zitadel/transport.cache.cy.ts | 14 +- .../providers/zitadel/verify.adapter.cy.ts | 21 - .../modules/auth/select.server.cy.ts | 26 +- .../modules/auth/session/cookie.cy.ts | 70 ++-- .../modules/auth/session/reauth-intent.cy.ts | 43 +- .../modules/auth/session/session.cy.ts | 36 +- .../modules/fraud/maxmind-tracker.cy.tsx | 38 +- .../authorize/authorize-decision.cy.ts | 72 ++-- .../authorize/default-org-fallback.cy.ts | 19 +- .../resources/authorize/logout.cy.ts | 121 +++--- .../resources/login/device-thread.cy.ts | 96 ++--- .../resources/login/login-decision.cy.ts | 45 +-- .../resources/login/login-schema.cy.ts | 26 +- .../resources/login/login-view.cy.ts | 120 ++++-- .../login/resolve-identifier-defaults.cy.ts | 44 ++ .../resolve-identifier-domain-discovery.cy.ts | 17 - .../resolve-identifier-email-disabled.cy.ts | 17 - .../resolve-identifier-ignore-unknown.cy.ts | 12 +- .../component/resources/mfa/mfa-routing.cy.ts | 253 ++++++------ .../component/resources/mfa/mfa.schema.cy.ts | 24 +- .../component/resources/mfa/mfa.service.cy.ts | 41 +- .../otp/otp-email-url-template.cy.ts | 37 +- .../component/resources/otp/otp-enroll.cy.ts | 73 +++- .../component/resources/otp/otp-verify.cy.ts | 37 -- .../component/resources/otp/otp.service.cy.ts | 12 +- .../resources/reauth/reauth-idp-intent.cy.ts | 43 +- .../resources/reauth/reauth.service.cy.ts | 174 ++++---- .../resources/schemas/password-match.cy.ts | 26 +- .../resources/schemas/user-code.cy.ts | 31 +- .../session/session.service.signed-in.cy.ts | 65 ++- .../session/session.service.switch.cy.ts | 23 +- .../resources/shared/lifetimes.cy.ts | 21 +- .../resources/shared/next-step-params.cy.ts | 72 ++-- .../resources/shared/next-step.cy.ts | 19 +- .../shared/org-first-settings-reads.cy.ts | 62 ++- .../resources/signup/placeholder-name.cy.ts | 53 +-- .../resources/signup/signup-decision.cy.ts | 16 +- .../resources/signup/signup-view.cy.ts | 285 ++++++------- .../resources/signup/signup.schema.cy.ts | 38 +- .../resources/signup/signup.service.cy.ts | 222 ++++------- .../resources/sso/default-org-fallback.cy.ts | 59 +-- .../resources/sso/derive-idp-name.cy.ts | 53 +-- .../resources/sso/idp-callback.cy.ts | 66 +-- .../resources/sso/saml-binding.cy.ts | 18 +- .../component/resources/sso/sso-action.cy.ts | 111 +++--- .../resources/sso/sso-callback.cy.ts | 376 ++++++++++-------- .../verify/verify-url-template.cy.ts | 15 +- .../resources/verify/verify.schema.cy.ts | 17 +- .../resources/verify/verify.service.cy.ts | 25 +- .../component/resources/webauthn/aaguid.cy.ts | 68 ++-- .../resources/webauthn/webauthn.cy.ts | 148 +++---- .../resources/webauthn/webauthn.service.cy.ts | 55 ++- cypress/component/routes/accounts-row.cy.tsx | 52 +-- .../routes/inline-action-error.cy.tsx | 4 +- .../login/conditional-passkey-loader.cy.ts | 10 +- .../routes/login/discovery-loader.cy.ts | 71 ++-- .../routes/login/passkey-discover.cy.ts | 152 +++---- cypress/component/routes/logout/logout.cy.tsx | 16 +- cypress/component/routes/passkeys-ui.cy.tsx | 64 ++- .../routes/password/password-backlink.cy.tsx | 32 +- cypress/component/routes/paths.cy.ts | 57 ++- cypress/component/routes/reauth.cy.tsx | 10 +- .../routes/setup/authenticator-guard.cy.ts | 46 ++- .../routes/setup/setup-render.cy.tsx | 13 +- cypress/component/routes/signed-in.cy.tsx | 8 +- .../routes/signup/default-org-idps.cy.ts | 94 ++--- .../routes/signup/method-back-link.cy.tsx | 10 +- .../routes/signup/password-render.cy.tsx | 5 +- .../routes/signup/signup-render.cy.tsx | 4 +- .../routes/sso/provider-error-render.cy.tsx | 39 +- .../component/routes/sso/sso-render.cy.tsx | 35 +- cypress/component/server/env.cy.ts | 21 +- .../server/middleware/legacy-redirects.cy.ts | 29 +- .../server/middleware/secure-headers.cy.ts | 41 +- .../component/support/to-request-url.cy.ts | 34 +- .../utils/errors/auth-error-messages.cy.tsx | 27 +- .../component/utils/errors/auth-error.cy.ts | 38 +- 101 files changed, 2670 insertions(+), 2527 deletions(-) rename cypress/component/modules/auth/{auth-provider.types.cy.ts => auth-provider.types.ts} (100%) delete mode 100644 cypress/component/modules/auth/provider-registry.cy.ts delete mode 100644 cypress/component/modules/auth/providers/fake/fake-provider.domain-discovery.cy.ts delete mode 100644 cypress/component/modules/auth/providers/fake/fake-provider.email-verify.cy.ts create mode 100644 cypress/component/modules/auth/providers/fake/fake-provider.seed-capabilities.cy.ts delete mode 100644 cypress/component/modules/auth/providers/fake/fake-provider.user-agent.cy.ts delete mode 100644 cypress/component/modules/auth/providers/zitadel/mappers.otpemail.cy.ts delete mode 100644 cypress/component/modules/auth/providers/zitadel/timeouts.cy.ts delete mode 100644 cypress/component/modules/auth/providers/zitadel/verify.adapter.cy.ts create mode 100644 cypress/component/resources/login/resolve-identifier-defaults.cy.ts delete mode 100644 cypress/component/resources/login/resolve-identifier-domain-discovery.cy.ts delete mode 100644 cypress/component/resources/login/resolve-identifier-email-disabled.cy.ts delete mode 100644 cypress/component/resources/otp/otp-verify.cy.ts diff --git a/cypress/component/components/a11y-guard/a11y-guard.cy.tsx b/cypress/component/components/a11y-guard/a11y-guard.cy.tsx index b86ecb6330..6fa5ef3f45 100644 --- a/cypress/component/components/a11y-guard/a11y-guard.cy.tsx +++ b/cypress/component/components/a11y-guard/a11y-guard.cy.tsx @@ -55,43 +55,62 @@ const FOCUSABLE_SEL = [ * - landmark-one-main : mounted fragment has no
landmark * - page-has-heading-one : mounted fragment may have no

*/ +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( - - - , - OPTS - ); - checkComponentA11y(); - }); + it('AuthCeremony verify body and AuthFormFields cluster have no axe violations', () => { + const surfaces = [ + { + label: 'AuthCeremony with a full verify body', + node: ( + + + + ), + opts: OPTS, + }, + { + label: 'AuthFormFields (hidden-input cluster)', + node: ( +
+ + + ), + opts: undefined, + }, + ]; - it('AuthFormFields (hidden-input cluster) has no axe violations', () => { - cy.mount( -
- - - ); - 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', () => { diff --git a/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx b/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx index 3b5bea9a80..8c8ca58bdf 100644 --- a/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx +++ b/cypress/component/components/auth-ceremony/auth-ceremony.cy.tsx @@ -35,26 +35,16 @@ describe('AuthCeremony shell', () => { }); }); -describe('AuthCeremony shell — identity centering', () => { - it('centers the IdentityBadge row (items-center, not items-baseline)', () => { - cy.mount( - - c - , - 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( c @@ -62,6 +52,16 @@ describe('AuthCeremony shell — showBackLink suppression', () => { OPTS ); cy.get('a').should('not.exist'); + + cy.mount( + + c + , + OPTS + ); + cy.get('[data-testid="auth-ceremony-body"]') + .should('have.class', 'items-center') + .and('not.have.class', 'items-baseline'); }); }); diff --git a/cypress/component/components/back-link/previous-step.cy.ts b/cypress/component/components/back-link/previous-step.cy.ts index 4ba7b6392e..c368f7679a 100644 --- a/cypress/component/components/back-link/previous-step.cy.ts +++ b/cypress/component/components/back-link/previous-step.cy.ts @@ -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)', () => { @@ -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'); - }); }); diff --git a/cypress/component/components/brand-logo/brand-logo.cy.tsx b/cypress/component/components/brand-logo/brand-logo.cy.tsx index 82909b3164..dc4ca37e3c 100644 --- a/cypress/component/components/brand-logo/brand-logo.cy.tsx +++ b/cypress/component/components/brand-logo/brand-logo.cy.tsx @@ -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(, { - 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(, { - path: '/login', - initialEntries: ['/login?requestId=saml_abc'], + rows.forEach((row) => { + cy.mount(, { 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(, { path: '/login', initialEntries: ['/login'] }); - cy.get('a').should('have.attr', 'href', '/'); }); }); diff --git a/cypress/component/components/sign-out-button/sign-out-button.cy.tsx b/cypress/component/components/sign-out-button/sign-out-button.cy.tsx index ecdd1ac1c4..e471185a01 100644 --- a/cypress/component/components/sign-out-button/sign-out-button.cy.tsx +++ b/cypress/component/components/sign-out-button/sign-out-button.cy.tsx @@ -6,7 +6,7 @@ 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(); cy.get('form').should('have.attr', 'action', '/id/logout?index'); cy.get('input[name="csrf"]').should('have.value', 'tok-1'); @@ -14,9 +14,7 @@ describe('SignOutButton', () => { .invoke('attr', 'class') .should('include', 'underline') .and('not.include', 'bg-btn-primary'); - }); - it('renders the primary/solid treatment when emphasis="primary"', () => { cy.mount(); cy.contains('button', 'Sign out') .invoke('attr', 'class') diff --git a/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx b/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx index afaed8200f..06ae597494 100644 --- a/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx +++ b/cypress/component/components/webauthn-button/webauthn-button.ceremony-errors.cy.tsx @@ -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 void }>; + creds[method]?.restore?.(); cy.stub(win.navigator.credentials, method).rejects(new win.DOMException('boom', domName)); }); cy.findByRole('button').should('not.be.disabled').click(); @@ -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'); + } }); }); @@ -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'); diff --git a/cypress/component/hooks/use-auth-action-error.cy.tsx b/cypress/component/hooks/use-auth-action-error.cy.tsx index b199077a91..3456054fd6 100644 --- a/cypress/component/hooks/use-auth-action-error.cy.tsx +++ b/cypress/component/hooks/use-auth-action-error.cy.tsx @@ -11,14 +11,26 @@ function ActionErrorHarness({ actionData }: { actionData: unknown }) { return
{msg ?? '__undefined__'}
; } -describe('useAuthActionError', () => { - it('resolves the message from actionData.error (inline-only surface, no toast)', () => { - cy.mount(); - cy.get('[data-testid="msg"]').should('have.text', 'Incorrect credentials. Please try again.'); - }); +const CASES: ReadonlyArray<{ label: string; actionData: unknown; expected: string }> = [ + { + label: 'known error code', + actionData: { error: 'INVALID_CREDENTIALS' }, + expected: 'Incorrect credentials. Please try again.', + }, + { + label: 'no error → undefined', + actionData: undefined, + expected: '__undefined__', + }, +]; - it('returns undefined for actionData without an error', () => { - cy.mount(); - cy.get('[data-testid="msg"]').should('have.text', '__undefined__'); +describe('useAuthActionError', () => { + it('resolves actionData.error to a message, undefined without an error (inline-only surface, no toast)', () => { + for (const row of CASES) { + cy.mount(); + cy.get('[data-testid="msg"]').should(($el) => { + expect($el, row.label).to.have.text(row.expected); + }); + } }); }); diff --git a/cypress/component/hooks/use-auth-action-recovery.cy.tsx b/cypress/component/hooks/use-auth-action-recovery.cy.tsx index f560d99e40..657b60102b 100644 --- a/cypress/component/hooks/use-auth-action-recovery.cy.tsx +++ b/cypress/component/hooks/use-auth-action-recovery.cy.tsx @@ -25,26 +25,47 @@ function RecoveryHarness({ } describe('useAuthActionRecovery', () => { - it('resolves message + recovery for a recoverable code (inline-only surface, no toast)', () => { - cy.mount(); - cy.get('[data-testid="message"]').should('have.text', 'Your session has expired.'); - cy.get('[data-testid="recovery-to"]').should('have.text', '/login'); - cy.get('[data-testid="recovery-label"]').should('have.text', 'Sign in again'); - }); + // Table: actionData varies (no ctx); every row asserts all three always-rendered + // testids. The SESSION_EXPIRED row also carries the former standalone + // "bare /login when no ctx is forwarded" test — identical mount, identical + // recovery-to === '/login' assertion. + const RESOLUTION_ROWS = [ + { + label: 'recoverable SESSION_EXPIRED (no ctx → bare /login)', + actionData: { error: 'SESSION_EXPIRED' }, + message: 'Your session has expired.', + to: '/login', + labelText: 'Sign in again', + }, + { + label: 'non-recoverable INVALID_CREDENTIALS (message only, no recovery)', + actionData: { error: 'INVALID_CREDENTIALS' }, + message: 'Incorrect credentials. Please try again.', + to: '__none__', + labelText: '__none__', + }, + { + label: 'actionData without error (undefined message + recovery)', + actionData: undefined, + message: '__undefined__', + to: '__none__', + labelText: '__none__', + }, + ] as const; - it('resolves message but no recovery for a non-recoverable code', () => { - cy.mount(); - cy.get('[data-testid="message"]').should( - 'have.text', - 'Incorrect credentials. Please try again.' - ); - cy.get('[data-testid="recovery-to"]').should('have.text', '__none__'); - }); - - it('returns undefined message + recovery when actionData has no error', () => { - cy.mount(); - cy.get('[data-testid="message"]').should('have.text', '__undefined__'); - cy.get('[data-testid="recovery-to"]').should('have.text', '__none__'); + it('resolves message + recovery from actionData.error (inline-only surface, no toast)', () => { + RESOLUTION_ROWS.forEach(({ label, actionData, message, to, labelText }) => { + cy.mount(); + cy.get('[data-testid="message"]').should(($el) => { + expect($el.text(), `${label} → message`).to.equal(message); + }); + cy.get('[data-testid="recovery-to"]').should(($el) => { + expect($el.text(), `${label} → recovery.to`).to.equal(to); + }); + cy.get('[data-testid="recovery-label"]').should(($el) => { + expect($el.text(), `${label} → recovery.label`).to.equal(labelText); + }); + }); }); // OIDC ceremony preservation: the hook forwards the in-scope ceremony ctx @@ -62,9 +83,4 @@ describe('useAuthActionRecovery', () => { '/login?requestId=rq1&organization=acme' ); }); - - it('yields a bare /login recovery when no ctx is forwarded', () => { - cy.mount(); - cy.get('[data-testid="recovery-to"]').should('have.text', '/login'); - }); }); diff --git a/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx index c58e2e3a76..47013c6051 100644 --- a/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx +++ b/cypress/component/hooks/use-conditional-passkey-discovery.cy.tsx @@ -131,13 +131,10 @@ describe('useConditionalPasskey — discovery mode', () => { cy.then(() => expect(verifyPosts).to.have.length(1)); }); - it('without auto-resolve the ceremony parks in armed — no discover POST', () => { - const { discoverPosts } = mountHarness({}); - cy.get('[data-testid="phase"]').should('have.text', 'armed'); - cy.then(() => expect(discoverPosts).to.have.length(0)); - }); - - it('abort() retires discovery permanently', () => { + // Also covers the parked state: without auto-resolve the ceremony sits in `armed` and + // issues no discover POST — asserted here before the abort, on the same mount, so a + // separate test for it would repeat these exact assertions. + it('parks in armed without auto-resolve, and abort() retires discovery permanently', () => { const { discoverPosts, verifyPosts } = mountHarness({}); cy.get('[data-testid="phase"]').should('have.text', 'armed'); cy.get('[data-testid="abort"]').click(); diff --git a/cypress/component/hooks/use-conditional-passkey.cy.tsx b/cypress/component/hooks/use-conditional-passkey.cy.tsx index f2b5403ed9..3bec1d8f7e 100644 --- a/cypress/component/hooks/use-conditional-passkey.cy.tsx +++ b/cypress/component/hooks/use-conditional-passkey.cy.tsx @@ -89,18 +89,19 @@ describe('useConditionalPasskey', () => { cy.then(() => expect(posts).to.have.length(0)); }); - it('enabled=false stays fully inert', () => { + it('inert gates: enabled=false and null options stay fully inert', () => { (window as W).__conditionalPasskeyAutoResolve = true; - const posts = mountHarness({ enabled: false }); - cy.get('[data-testid="phase"]').should('have.text', 'idle'); - cy.then(() => expect(posts).to.have.length(0)); - }); - - it('null options stay fully inert', () => { - (window as W).__conditionalPasskeyAutoResolve = true; - const posts = mountHarness({ options: null }); - cy.get('[data-testid="phase"]').should('have.text', 'idle'); - cy.then(() => expect(posts).to.have.length(0)); + const ROWS: Array<{ label: string; props: { enabled?: boolean; options?: unknown } }> = [ + { label: 'enabled=false', props: { enabled: false } }, + { label: 'null options', props: { options: null } }, + ]; + for (const row of ROWS) { + const posts = mountHarness(row.props); + cy.get('[data-testid="phase"]').should(($phase) => { + expect($phase, `${row.label}: phase`).to.have.text('idle'); + }); + cy.then(() => expect(posts, `${row.label}: no POSTs`).to.have.length(0)); + } }); it('abort() retires the ceremony permanently', () => { diff --git a/cypress/component/modules/analytics/rybbit.cy.tsx b/cypress/component/modules/analytics/rybbit.cy.tsx index 6af7eb95c4..688a2dc768 100644 --- a/cypress/component/modules/analytics/rybbit.cy.tsx +++ b/cypress/component/modules/analytics/rybbit.cy.tsx @@ -42,12 +42,12 @@ describe('resolveRybbitSiteId', () => { }); describe('RybbitAnalytics', () => { - it('renders nothing when siteId is falsy', () => { + it('renders no script tag when siteId is falsy, and renders the Rybbit script with siteId, tag and nonce when it is set', () => { + // Asserted FIRST on purpose: `should('not.exist')` on `script[data-site-id]` is a + // whole-DOM negative, so it is only meaningful before any mount has injected a script. cy.mount(); cy.get('script[data-site-id]').should('not.exist'); - }); - it('renders the Rybbit script tag with siteId, tag and nonce', () => { cy.mount(); cy.get('script[data-site-id="997f89789d8f"]').should(($el) => { const el = $el.get(0) as HTMLScriptElement; @@ -72,20 +72,19 @@ describe('TrackOnMount + identifyUser', () => { }); }); - it('identifyUser calls window.rybbit.identify with the given user id', () => { + it('identifies the user with optional traits, and clears the identified user', () => { + // Indexed off `filter`, NOT `find` — both calls land in the same recorder here, and + // `find` would return call #0 again for the second assertion, silently never checking + // the traits payload. identifyUser('user-42'); - expect(rybbitCalls().find((c) => c.fn === 'identify')?.args).to.deep.equal(['user-42']); - }); + expect(rybbitCalls().filter((c) => c.fn === 'identify')[0]?.args).to.deep.equal(['user-42']); - it('identifyUser passes traits through when given', () => { identifyUser('user-42', { email: 'user@example.com' }); - expect(rybbitCalls().find((c) => c.fn === 'identify')?.args).to.deep.equal([ + expect(rybbitCalls().filter((c) => c.fn === 'identify')[1]?.args).to.deep.equal([ 'user-42', { email: 'user@example.com' }, ]); - }); - it('clearIdentifiedUser calls window.rybbit.clearUserId', () => { clearIdentifiedUser(); expect(rybbitCalls().filter((c) => c.fn === 'clearUserId')).to.have.length(1); }); diff --git a/cypress/component/modules/auth/auth-provider.types.cy.ts b/cypress/component/modules/auth/auth-provider.types.ts similarity index 100% rename from cypress/component/modules/auth/auth-provider.types.cy.ts rename to cypress/component/modules/auth/auth-provider.types.ts diff --git a/cypress/component/modules/auth/fake-passkeys.cy.ts b/cypress/component/modules/auth/fake-passkeys.cy.ts index 6baeeba812..47b28a446a 100644 --- a/cypress/component/modules/auth/fake-passkeys.cy.ts +++ b/cypress/component/modules/auth/fake-passkeys.cy.ts @@ -7,53 +7,49 @@ import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; const seedUser = { id: 'u1', loginName: 'alice@acme.test' }; describe('FakeAuthProvider — passkey inventory (port mirror)', () => { - it('verifyPasskey records a named active passkey; listPasskeys returns it', async () => { - const fake = new FakeAuthProvider({ users: [seedUser] }); - await fake.verifyPasskey('u1', 'pk-1', {}, 'MacBook Touch ID'); - const [passkey] = await fake.listPasskeys('u1'); + it('records a named active passkey with an ISO createdAt, defaulting the name when absent', async () => { + const named = new FakeAuthProvider({ users: [seedUser] }); + await named.verifyPasskey('u1', 'pk-1', {}, 'MacBook Touch ID'); + const [passkey] = await named.listPasskeys('u1'); const { createdAt, ...rest } = passkey; expect(rest).to.deep.equal({ id: 'pk-1', state: 'active', name: 'MacBook Touch ID' }); expect(createdAt).to.be.a('string'); expect(new Date(createdAt).toISOString()).to.equal(createdAt); - expect(await fake.listAuthMethods('u1')).to.include('passkey'); - }); - - it('defaults the name when verifyPasskey gets no passkeyName (Zitadel parity)', async () => { - const fake = new FakeAuthProvider({ users: [seedUser] }); - await fake.verifyPasskey('u1', 'pk-1', {}); - expect((await fake.listPasskeys('u1'))[0].name).to.equal('Passkey'); - }); + expect(await named.listAuthMethods('u1')).to.include('passkey'); - it('removePasskey is idempotent and un-enrolls the method with the last passkey', async () => { - const fake = new FakeAuthProvider({ users: [seedUser] }); - await fake.verifyPasskey('u1', 'pk-1', {}, 'A'); - await fake.removePasskey('u1', 'pk-1'); - await fake.removePasskey('u1', 'pk-1'); // second call must not throw (removal race) - expect(await fake.listPasskeys('u1')).to.deep.equal([]); - expect(await fake.listAuthMethods('u1')).to.not.include('passkey'); + // Same call minus the 4th arg — on a fresh provider so the default is observed on a + // passkey this case created, not one left over from the named case above. + const unnamed = new FakeAuthProvider({ users: [seedUser] }); + await unnamed.verifyPasskey('u1', 'pk-1', {}); + expect((await unnamed.listPasskeys('u1'))[0].name).to.equal('Passkey'); }); - it('honors the passkeys seed (e2e fixture path)', async () => { - const fake = new FakeAuthProvider({ - users: [seedUser], - passkeys: { u1: [{ id: 'pk-s', state: 'active', name: 'Seeded key' }] }, - }); - expect(await fake.listPasskeys('u1')).to.have.length(1); - }); + it('removes idempotently, honors the seed, and clears both the enrolled and seeded methods', async () => { + // Dynamically enrolled passkey: removing the last one un-enrolls the method, and a + // second remove must not throw (removal race). + const enrolled = new FakeAuthProvider({ users: [seedUser] }); + await enrolled.verifyPasskey('u1', 'pk-1', {}, 'A'); + await enrolled.removePasskey('u1', 'pk-1'); + await enrolled.removePasskey('u1', 'pk-1'); + expect(await enrolled.listPasskeys('u1')).to.deep.equal([]); + expect(await enrolled.listAuthMethods('u1')).to.not.include('passkey'); - it('removePasskey also clears a SEEDED static authMethods entry, not just the dynamic enrolled set', async () => { // listAuthMethods unions the dynamic `enrolled` set with the seed-time `authMethods` // array — a test seeding BOTH (the e2e-fixture pattern) would otherwise still see // 'passkey' reported as enrolled after the last passkey is removed, since only the // dynamic set was ever cleared. - const fake = new FakeAuthProvider({ + const fromSeed = new FakeAuthProvider({ users: [seedUser], authMethods: { u1: ['passkey'] }, passkeys: { u1: [{ id: 'pk-s', state: 'active', name: 'Seeded key' }] }, }); - expect(await fake.listAuthMethods('u1')).to.include('passkey'); - await fake.removePasskey('u1', 'pk-s'); - expect(await fake.listPasskeys('u1')).to.deep.equal([]); - expect(await fake.listAuthMethods('u1')).to.not.include('passkey'); + // Asserted BEFORE the removal on purpose: this is what proves the `passkeys` seed is + // honored at all. A provider that ignored the seed would still pass the length-0 check + // after removePasskey, so the post-removal assertion alone cannot stand in for it. + expect(await fromSeed.listPasskeys('u1')).to.have.length(1); + expect(await fromSeed.listAuthMethods('u1')).to.include('passkey'); + await fromSeed.removePasskey('u1', 'pk-s'); + expect(await fromSeed.listPasskeys('u1')).to.deep.equal([]); + expect(await fromSeed.listAuthMethods('u1')).to.not.include('passkey'); }); }); diff --git a/cypress/component/modules/auth/provider-registry.cy.ts b/cypress/component/modules/auth/provider-registry.cy.ts deleted file mode 100644 index e5d8df531c..0000000000 --- a/cypress/component/modules/auth/provider-registry.cy.ts +++ /dev/null @@ -1,29 +0,0 @@ -// cypress/component/modules/auth/provider-registry.cy.ts -// -// Node-bound (cy.task) port of app/modules/auth/__tests__/provider-registry.test.ts. -// -// providerRegistry lives in select.server.ts, which is stubbed (fake-only) in the Vite browser -// bundle — so the "exactly fake + zitadel" binding-point assertion would be vacuous in the browser. -// The REAL registry (both binding points + the fake process-singleton) runs in Bun via cy.task. -import { callService } from '../../../support/node/call-service'; - -describe('providerRegistry — one binding point', () => { - it('has exactly the fake and zitadel modes, and the fake entry is a process-stable singleton', () => { - callService({ - fn: 'selectProvider', - selectOp: 'registryKeys', - request: { url: 'http://localhost/id' }, - }) - .then((v) => { - expect((v.outcome as { keys: string[] }).keys).to.deep.equal(['fake', 'zitadel']); - return callService({ - fn: 'selectProvider', - selectOp: 'fakeSingleton', - request: { url: 'http://localhost/id' }, - }); - }) - .then((v) => { - expect((v.outcome as { same: boolean }).same).to.equal(true); - }); - }); -}); diff --git a/cypress/component/modules/auth/providers/fake/fake-provider.domain-discovery.cy.ts b/cypress/component/modules/auth/providers/fake/fake-provider.domain-discovery.cy.ts deleted file mode 100644 index b7241af32a..0000000000 --- a/cypress/component/modules/auth/providers/fake/fake-provider.domain-discovery.cy.ts +++ /dev/null @@ -1,16 +0,0 @@ -// cypress/component/modules/auth/providers/fake/fake-provider.domain-discovery.cy.ts -// -// Component (no-mount) port of -// app/modules/auth/providers/fake/__tests__/fake-provider.domain-discovery.test.ts. -// -// NOTE: this exercises a FAKE provider (test double / harness), not production security logic. -import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; - -describe('FakeAuthProvider — findOrgByDomain', () => { - it('resolves a seeded domain → orgId, and null for unknown domains or when none are seeded', async () => { - const p = new FakeAuthProvider({ orgDomains: { 'acme.test': 'org-acme' } }); - expect(await p.findOrgByDomain('acme.test')).to.deep.equal({ orgId: 'org-acme' }); - expect(await p.findOrgByDomain('unknown.test')).to.be.null; - expect(await new FakeAuthProvider().findOrgByDomain('acme.test')).to.be.null; - }); -}); diff --git a/cypress/component/modules/auth/providers/fake/fake-provider.email-verify.cy.ts b/cypress/component/modules/auth/providers/fake/fake-provider.email-verify.cy.ts deleted file mode 100644 index c3652f9eb6..0000000000 --- a/cypress/component/modules/auth/providers/fake/fake-provider.email-verify.cy.ts +++ /dev/null @@ -1,36 +0,0 @@ -// cypress/component/modules/auth/providers/fake/fake-provider.email-verify.cy.ts -// -// Component (no-mount) port of -// app/modules/auth/providers/fake/__tests__/fake-provider.email-verify.test.ts. -// -// NOTE: this exercises a FAKE provider (test double / harness), not production security logic. -// One representative test covering the register(emailVerified) and markEmailVerified paths. -import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; - -describe('FakeAuthProvider — email verification', () => { - it('register honors emailVerified (true marks it immediately, default/false leaves it unverified), and markEmailVerified marks it idempotently without an email arg', async () => { - const p = new FakeAuthProvider(); - const verified = await p.register({ - email: 'pre-verified@acme.test', - firstName: 'Pre', - lastName: 'Verified', - emailVerified: true, - }); - expect(p.isEmailVerified(verified.id)).to.equal(true); - - const unverified = await p.register({ - email: 'unverified@acme.test', - firstName: 'Un', - lastName: 'Verified', - }); - expect(p.isEmailVerified(unverified.id)).to.equal(false); - - const seeded = new FakeAuthProvider({ - users: [{ id: 'u1', loginName: 'mark@acme.test', displayName: 'Mark' }], - }); - expect(seeded.isEmailVerified('u1')).to.equal(false); - await seeded.markEmailVerified('u1'); - await seeded.markEmailVerified('u1'); // idempotent - expect(seeded.isEmailVerified('u1')).to.equal(true); - }); -}); diff --git a/cypress/component/modules/auth/providers/fake/fake-provider.seed-capabilities.cy.ts b/cypress/component/modules/auth/providers/fake/fake-provider.seed-capabilities.cy.ts new file mode 100644 index 0000000000..0c469126ad --- /dev/null +++ b/cypress/component/modules/auth/providers/fake/fake-provider.seed-capabilities.cy.ts @@ -0,0 +1,67 @@ +// cypress/component/modules/auth/providers/fake/fake-provider.seed-capabilities.cy.ts +// +// Component (no-mount) merge of three former single-test specs, all of which were trivial +// seed/flag/opts pass-through checks on the SAME test double: +// • fake-provider.domain-discovery.cy.ts — findOrgByDomain against the orgDomains seed +// • fake-provider.email-verify.cy.ts — register(emailVerified) + markEmailVerified +// • fake-provider.user-agent.cy.ts — userAgent forwarding onto lastCreateSessionOpts +// +// NOTE: this exercises a FAKE provider (test double / harness), not production security logic. +// Every assertion from all three files is preserved verbatim; each group constructs its own +// provider instances, so merging them into one `it` introduces no shared state between groups. +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; + +describe('FakeAuthProvider — seed-driven capabilities', () => { + it('resolves seeded org domains, honors emailVerified, and forwards userAgent to createSession', async () => { + // findOrgByDomain: seeded hit, unknown miss, and unseeded provider miss. + const domains = new FakeAuthProvider({ orgDomains: { 'acme.test': 'org-acme' } }); + expect(await domains.findOrgByDomain('acme.test')).to.deep.equal({ orgId: 'org-acme' }); + expect(await domains.findOrgByDomain('unknown.test')).to.be.null; + expect(await new FakeAuthProvider().findOrgByDomain('acme.test')).to.be.null; + + // register(emailVerified): true marks immediately, omitted leaves it unverified. + const registrar = new FakeAuthProvider(); + const verified = await registrar.register({ + email: 'pre-verified@acme.test', + firstName: 'Pre', + lastName: 'Verified', + emailVerified: true, + }); + expect(registrar.isEmailVerified(verified.id)).to.equal(true); + + const unverified = await registrar.register({ + email: 'unverified@acme.test', + firstName: 'Un', + lastName: 'Verified', + }); + expect(registrar.isEmailVerified(unverified.id)).to.equal(false); + + // markEmailVerified: marks idempotently, without an email arg. + const seeded = new FakeAuthProvider({ + users: [{ id: 'u1', loginName: 'mark@acme.test', displayName: 'Mark' }], + }); + expect(seeded.isEmailVerified('u1')).to.equal(false); + await seeded.markEmailVerified('u1'); + await seeded.markEmailVerified('u1'); // idempotent + expect(seeded.isEmailVerified('u1')).to.equal(true); + + // userAgent forwarding: full object, omitted, and partial. + const full = new FakeAuthProvider(); + const ua = { + fingerprintId: 'fp-abc', + ip: '1.2.3.4', + description: 'Chrome 124 · Blink 537.36 · macOS 10.15', + header: { 'user-agent': { values: ['Mozilla/5.0'] } }, + }; + await full.createSession({}, { userAgent: ua }); + expect(full.lastCreateSessionOpts?.userAgent).to.deep.equal(ua); + + const omitted = new FakeAuthProvider(); + await omitted.createSession({}); + expect(omitted.lastCreateSessionOpts?.userAgent).to.be.undefined; + + const partial = new FakeAuthProvider(); + await partial.createSession({}, { userAgent: { fingerprintId: 'fp-only' } }); + expect(partial.lastCreateSessionOpts?.userAgent).to.deep.equal({ fingerprintId: 'fp-only' }); + }); +}); diff --git a/cypress/component/modules/auth/providers/fake/fake-provider.user-agent.cy.ts b/cypress/component/modules/auth/providers/fake/fake-provider.user-agent.cy.ts deleted file mode 100644 index b49304096b..0000000000 --- a/cypress/component/modules/auth/providers/fake/fake-provider.user-agent.cy.ts +++ /dev/null @@ -1,30 +0,0 @@ -// cypress/component/modules/auth/providers/fake/fake-provider.user-agent.cy.ts -// -// Component (no-mount) port of -// app/modules/auth/providers/fake/__tests__/fake-provider.user-agent.test.ts. -// -// NOTE: this exercises a FAKE provider (test double / harness), not production security logic. -// One representative test covering the userAgent pass-through (full, partial, and omitted). -import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; - -describe('FakeAuthProvider — userAgent forwarding', () => { - it('records userAgent (full or partial) on lastCreateSessionOpts when passed, and leaves it undefined when omitted', async () => { - const p = new FakeAuthProvider(); - const ua = { - fingerprintId: 'fp-abc', - ip: '1.2.3.4', - description: 'Chrome 124 · Blink 537.36 · macOS 10.15', - header: { 'user-agent': { values: ['Mozilla/5.0'] } }, - }; - await p.createSession({}, { userAgent: ua }); - expect(p.lastCreateSessionOpts?.userAgent).to.deep.equal(ua); - - const p2 = new FakeAuthProvider(); - await p2.createSession({}); - expect(p2.lastCreateSessionOpts?.userAgent).to.be.undefined; - - const p3 = new FakeAuthProvider(); - await p3.createSession({}, { userAgent: { fingerprintId: 'fp-only' } }); - expect(p3.lastCreateSessionOpts?.userAgent).to.deep.equal({ fingerprintId: 'fp-only' }); - }); -}); diff --git a/cypress/component/modules/auth/providers/zitadel/index.cy.ts b/cypress/component/modules/auth/providers/zitadel/index.cy.ts index 9446bd26c9..8fb6c10a0b 100644 --- a/cypress/component/modules/auth/providers/zitadel/index.cy.ts +++ b/cypress/component/modules/auth/providers/zitadel/index.cy.ts @@ -11,6 +11,7 @@ // (build a request, forward it, map the response) and are merged into a single test. // isInstanceAdmin's bearer-token forwarding and the RPC deadline behavior are kept standalone. import { ZitadelAuthProvider } from '@/modules/auth/providers/zitadel/index'; +import { TIMEOUTS } from '@/modules/auth/providers/zitadel/timeouts'; import * as transport from '@/modules/auth/providers/zitadel/transport'; import { ProviderError } from '@/modules/auth/types'; import { AuthFactorState } from '@zitadel/proto/zitadel/user/v2/user_pb'; @@ -123,7 +124,15 @@ describe('ZitadelAuthProvider — isInstanceAdmin', () => { // ── RPC deadline ─────────────────────────────────────────────────────────────── describe('ZitadelAuthProvider — RPC deadline', () => { - it('rejects a never-resolving RPC with a deadline error instead of hanging', async () => { + it('exposes bounded admin-check and gRPC deadlines, and rejects a hanging RPC', async () => { + // Range gates for both deadline constants, folded in from the standalone timeouts.cy.ts. + // GRPC_CALL_MS is additionally proven behaviorally below; ADMIN_CHECK_MS has no behavioral + // test anywhere, so its bounds check is kept here rather than dropped. + expect(TIMEOUTS.ADMIN_CHECK_MS).to.be.greaterThan(0); + expect(TIMEOUTS.ADMIN_CHECK_MS).to.be.at.most(30_000); + expect(TIMEOUTS.GRPC_CALL_MS).to.be.greaterThan(0); + expect(TIMEOUTS.GRPC_CALL_MS).to.be.at.most(30_000); + // Sinon fake timers — synchronous API so the fake clock is in place *before* // provider() creates its internal deadline setTimeout (GRPC_CALL_MS = 10 000 ms). // cy.clock() is asynchronous (queued), so it cannot guarantee the clock is ready @@ -252,17 +261,37 @@ describe('ZitadelAuthProvider — session/credential request building', () => { // These tests drive that metadata RPC into failure and assert the primary operation still // resolves, plus the listPasskeys join behaviour (present/absent createdAt per row). describe('ZitadelAuthProvider — passkey metadata best-effort scopes', () => { - it('verifyPasskey resolves even when the setUserMetadata created-at stamp throws', async () => { - const verifySpy = cy.stub().resolves({}); - stubClient({ - verifyPasskeyRegistration: verifySpy, - setUserMetadata: async () => { - throw new Error('metadata backend down'); + it('verifyPasskey/removePasskey resolve even when their best-effort metadata RPC throws', async () => { + const rows = [ + { + label: 'verifyPasskey survives setUserMetadata failure', + rpc: 'verifyPasskeyRegistration', + metaRpc: 'setUserMetadata', + run: (p: ZitadelAuthProvider) => p.verifyPasskey('u1', 'pk1', { fake: true }), }, - }); - // Must not throw — enrollment is not allowed to fail because of the best-effort stamp. - await provider().verifyPasskey('u1', 'pk1', { fake: true }); - expect(verifySpy).to.have.callCount(1); + { + label: 'removePasskey survives deleteUserMetadata failure', + rpc: 'removePasskey', + metaRpc: 'deleteUserMetadata', + run: (p: ZitadelAuthProvider) => p.removePasskey('u1', 'pk1'), + }, + ] as const; + + for (const row of rows) { + // Fresh free-standing stub per row (nothing is wrapped, so no restore hazard); + // stubClient() replaces the whole transport impl for the row. + const primarySpy = cy.stub().resolves({}); + stubClient({ + [row.rpc]: primarySpy, + [row.metaRpc]: async () => { + throw new Error('metadata backend down'); + }, + }); + // Must not throw — the primary operation is not allowed to fail because of the + // best-effort metadata RPC (created-at stamp on enroll; key cleanup on removal). + await row.run(provider()); + expect(primarySpy, row.label).to.have.callCount(1); + } }); it('listPasskeys degrades to date-less rows (all createdAt absent) when listUserMetadata throws', async () => { @@ -310,19 +339,6 @@ describe('ZitadelAuthProvider — passkey metadata best-effort scopes', () => { // not just an undefined value. expect(pk2).to.not.have.property('createdAt'); }); - - it('removePasskey resolves even when the deleteUserMetadata cleanup throws', async () => { - const removeSpy = cy.stub().resolves({}); - stubClient({ - removePasskey: removeSpy, - deleteUserMetadata: async () => { - throw new Error('metadata backend down'); - }, - }); - // Must not throw — removal succeeded; an orphaned metadata key is harmless. - await provider().removePasskey('u1', 'pk1'); - expect(removeSpy).to.have.callCount(1); - }); }); // ── Cross-device session methods ─────────────────────────────────────────── diff --git a/cypress/component/modules/auth/providers/zitadel/mappers.cy.ts b/cypress/component/modules/auth/providers/zitadel/mappers.cy.ts index bca40ef84e..6b5d36e57d 100644 --- a/cypress/component/modules/auth/providers/zitadel/mappers.cy.ts +++ b/cypress/component/modules/auth/providers/zitadel/mappers.cy.ts @@ -52,6 +52,25 @@ describe('normalizeError (ConnectError → ProviderError)', () => { message: '[failed_precondition] Session already terminated (COMMAND-Hewfq)', }); expect(deadSession.code).to.equal('ALREADY_DONE'); + + // Complementary branch (folded in from verify.adapter.cy.ts): an unrelated code-9 message + // must NOT be swept into ALREADY_DONE — it stays FAILED_PRECONDITION. + const unrelated = normalizeError(ce(9, 'precondition failed')); + expect(unrelated).to.be.instanceOf(ProviderError); + expect(unrelated.code).to.equal('FAILED_PRECONDITION'); + + // Phase 2 extensions (folded in from mappers.p2.cy.ts): code 3 + /complexity/i and code 6. + const complexity = normalizeError(ce(3, 'Password does not meet complexity requirements')); + expect(complexity).to.be.instanceOf(ProviderError); + expect(complexity.code).to.equal('PASSWORD_COMPLEXITY'); + expect(normalizeError(ce(6, 'user already exists')).code).to.equal('ALREADY_EXISTS'); + + // P1 regression guard: failedAttempts wins over message discrimination. Distinct from the + // failedAttempts case above — here the MESSAGE says "complexity", so a mapper that checked + // the message before the detail would wrongly return PASSWORD_COMPLEXITY. + const p1 = normalizeError(ce(3, 'complexity check', [{ failedAttempts: 1 }])); + expect(p1.code).to.equal('INVALID_CREDENTIALS'); + expect(p1.detail?.failedAttempts).to.equal(1); }); }); diff --git a/cypress/component/modules/auth/providers/zitadel/mappers.otpemail.cy.ts b/cypress/component/modules/auth/providers/zitadel/mappers.otpemail.cy.ts deleted file mode 100644 index 20a0a9781d..0000000000 --- a/cypress/component/modules/auth/providers/zitadel/mappers.otpemail.cy.ts +++ /dev/null @@ -1,19 +0,0 @@ -// cypress/component/modules/auth/providers/zitadel/mappers.otpemail.cy.ts -// -// Component (no-mount) port of app/modules/auth/providers/zitadel/__tests__/mappers.otpemail.test.ts. -// Pure toChallengeRequest mapper — browser-side Chai only. -// -// send / send-template kinds are already exercised in mappers.p5.cy.ts; kept here is only the -// return-code kind, the one branch unique to this spec. -import { toChallengeRequest } from '@/modules/auth/providers/zitadel/mappers'; - -describe('toChallengeRequest — otpEmail returnCode', () => { - it("maps { kind: 'return-code' } to the proto returnCode delivery case, omitting other fields", () => { - const result = toChallengeRequest({ otpEmail: { kind: 'return-code' } }); - expect(result.otpEmail).to.deep.equal({ - deliveryType: { case: 'returnCode', value: {} }, - }); - expect(result.webAuthN).to.be.undefined; - expect(result.otpSms).to.be.undefined; - }); -}); diff --git a/cypress/component/modules/auth/providers/zitadel/mappers.p2.cy.ts b/cypress/component/modules/auth/providers/zitadel/mappers.p2.cy.ts index c3d5c4eb33..58df5fb647 100644 --- a/cypress/component/modules/auth/providers/zitadel/mappers.p2.cy.ts +++ b/cypress/component/modules/auth/providers/zitadel/mappers.p2.cy.ts @@ -7,29 +7,10 @@ // regression guard (failedAttempts must still win over message discrimination). Code-9 // already/verified mapping is exercised in mappers.cy.ts and verify.adapter.cy.ts, so it is not // repeated here. -import { normalizeError, toRegisterRequest } from '@/modules/auth/providers/zitadel/mappers'; -import { ProviderError } from '@/modules/auth/types'; - -// minimal ConnectError shape -const ce = (code: number, message = 'boom', details: unknown[] = []) => ({ - code, - message, - findDetails: () => details, -}); - -describe('normalizeError Phase 2 extensions', () => { - it('maps code 3 + /complexity/i to PASSWORD_COMPLEXITY, code 6 to ALREADY_EXISTS, and preserves P1: failedAttempts wins over message discrimination', () => { - const complexity = normalizeError(ce(3, 'Password does not meet complexity requirements')); - expect(complexity).to.be.instanceOf(ProviderError); - expect(complexity.code).to.equal('PASSWORD_COMPLEXITY'); - expect(normalizeError(ce(6, 'user already exists')).code).to.equal('ALREADY_EXISTS'); - - // P1 regression: failedAttempts still wins over message discrimination - const e = normalizeError(ce(3, 'complexity check', [{ failedAttempts: 1 }])); - expect(e.code).to.equal('INVALID_CREDENTIALS'); - expect(e.detail?.failedAttempts).to.equal(1); - }); -}); +// The Phase 2 normalizeError extensions (code 3 + /complexity/i → PASSWORD_COMPLEXITY, code 6 → +// ALREADY_EXISTS) and the P1 failedAttempts-wins regression guard now live in mappers.cy.ts, +// alongside the rest of the normalizeError coverage — one function, one table. +import { toRegisterRequest } from '@/modules/auth/providers/zitadel/mappers'; describe('toRegisterRequest', () => { it('maps minimal input to AddHumanUser shape, and includes organization/passwordType/email-verification oneofs when provided', () => { diff --git a/cypress/component/modules/auth/providers/zitadel/mappers.p5.cy.ts b/cypress/component/modules/auth/providers/zitadel/mappers.p5.cy.ts index c67af7f61e..f4a3885bfc 100644 --- a/cypress/component/modules/auth/providers/zitadel/mappers.p5.cy.ts +++ b/cypress/component/modules/auth/providers/zitadel/mappers.p5.cy.ts @@ -138,5 +138,14 @@ describe('toPasswordComplexity / toChallengeRequest', () => { expect( toChallengeRequest({ otpEmail: { kind: 'send-template', urlTemplate: tmpl } }).otpEmail ).to.deep.equal({ deliveryType: { case: 'sendCode', value: { urlTemplate: tmpl } } }); + + // return-code kind (folded in from mappers.otpemail.cy.ts) — completes the delivery-kind + // enum here, including the "no other delivery oneofs are populated" property. + const returnCode = toChallengeRequest({ otpEmail: { kind: 'return-code' } }); + expect(returnCode.otpEmail).to.deep.equal({ + deliveryType: { case: 'returnCode', value: {} }, + }); + expect(returnCode.webAuthN).to.be.undefined; + expect(returnCode.otpSms).to.be.undefined; }); }); diff --git a/cypress/component/modules/auth/providers/zitadel/timeouts.cy.ts b/cypress/component/modules/auth/providers/zitadel/timeouts.cy.ts deleted file mode 100644 index be0762690c..0000000000 --- a/cypress/component/modules/auth/providers/zitadel/timeouts.cy.ts +++ /dev/null @@ -1,14 +0,0 @@ -// cypress/component/modules/auth/providers/zitadel/timeouts.cy.ts -// -// Component (no-mount) port of app/modules/auth/providers/zitadel/__tests__/timeouts.test.ts. -// Pure constant export — browser-side Chai only. -import { TIMEOUTS } from '@/modules/auth/providers/zitadel/timeouts'; - -describe('TIMEOUTS', () => { - it('exposes positive, bounded admin-check and gRPC per-call deadlines (ms)', () => { - expect(TIMEOUTS.ADMIN_CHECK_MS).to.be.greaterThan(0); - expect(TIMEOUTS.ADMIN_CHECK_MS).to.be.at.most(30_000); - expect(TIMEOUTS.GRPC_CALL_MS).to.be.greaterThan(0); - expect(TIMEOUTS.GRPC_CALL_MS).to.be.at.most(30_000); - }); -}); diff --git a/cypress/component/modules/auth/providers/zitadel/transport.cache.cy.ts b/cypress/component/modules/auth/providers/zitadel/transport.cache.cy.ts index 1156a1041f..c754248c63 100644 --- a/cypress/component/modules/auth/providers/zitadel/transport.cache.cy.ts +++ b/cypress/component/modules/auth/providers/zitadel/transport.cache.cy.ts @@ -18,24 +18,28 @@ import { callService } from '../../../../../support/node/call-service'; import type { Verdict } from '../../../../../support/node/call-service'; describe('transport client cache', () => { - it('does not grow unbounded across many distinct session tokens', () => { + // Both ops chained in one test, matching the pattern transport.cy.ts already uses for 4 + // chained calls. Each callService spawns a fresh Bun process, so the two remain fully + // independent; the only cost is that a cap failure would mask the rotated-token result. + it("does not grow unbounded across many distinct session tokens, and a rotated token does not reuse the previous token's client", () => { callService({ fn: 'transportCacheCheck', transportOp: 'clientCacheCap', request: { url: 'https://z.test' }, }).then((v: Verdict) => { const { size, max } = v.outcome as { size: number; max: number }; - expect(size).to.be.at.most(max); + expect(size, 'clientCacheCap: size <= max').to.be.at.most(max); }); - }); - it("a rotated token does not reuse the previous token's client", () => { callService({ fn: 'transportCacheCheck', transportOp: 'clientCacheRotatedToken', request: { url: 'https://z.test' }, }).then((v: Verdict) => { - expect((v.outcome as { distinct: boolean }).distinct).to.equal(true); + expect( + (v.outcome as { distinct: boolean }).distinct, + 'clientCacheRotatedToken: distinct client' + ).to.equal(true); }); }); }); diff --git a/cypress/component/modules/auth/providers/zitadel/verify.adapter.cy.ts b/cypress/component/modules/auth/providers/zitadel/verify.adapter.cy.ts deleted file mode 100644 index c949b6ab16..0000000000 --- a/cypress/component/modules/auth/providers/zitadel/verify.adapter.cy.ts +++ /dev/null @@ -1,21 +0,0 @@ -// cypress/component/modules/auth/providers/zitadel/verify.adapter.cy.ts -// -// Component (no-mount) port of app/modules/auth/providers/zitadel/__tests__/verify.adapter.test.ts. -// Pure normalizeError mapping — browser-side Chai only. -// -// code 9 + already/verified → ALREADY_DONE is exercised (with an explicit regression-guard -// comment) in mappers.cy.ts; kept here is the complementary branch — an unrelated code-9 -// message must NOT be swept into ALREADY_DONE. -import { normalizeError } from '@/modules/auth/providers/zitadel/mappers'; -import { ProviderError } from '@/modules/auth/types'; - -// minimal ConnectError shape -const ce = (code: number, message = 'boom') => ({ code, message, findDetails: () => [] }); - -describe('normalizeError — verification error codes', () => { - it('code 9 + unrelated message → FAILED_PRECONDITION, not ALREADY_DONE/UNKNOWN', () => { - const e = normalizeError(ce(9, 'precondition failed')); - expect(e).to.be.instanceOf(ProviderError); - expect(e.code).to.equal('FAILED_PRECONDITION'); - }); -}); diff --git a/cypress/component/modules/auth/select.server.cy.ts b/cypress/component/modules/auth/select.server.cy.ts index f15477df7d..f68f1ccad7 100644 --- a/cypress/component/modules/auth/select.server.cy.ts +++ b/cypress/component/modules/auth/select.server.cy.ts @@ -6,15 +6,35 @@ // ZitadelAuthProvider gRPC transport), and the stub returns a fake-only registry — so asserting // against it in the browser would test the STUB, not the real binding point. The REAL // getAuthProvider (fake↔zitadel selection) runs in Bun via cy.task. +// +// Absorbed provider-registry.cy.ts (deleted): it drove the SAME production module through the +// same `fn: 'selectProvider'` task, differing only by `selectOp`, so both now chain in one test — +// matching the 4-call chaining pattern transport.cy.ts already uses. Every assertion is kept. import { callService } from '../../../support/node/call-service'; -describe('getAuthProvider', () => { - it('returns the FakeAuthProvider when AUTH_PROVIDER=fake, and a Zitadel-backed provider otherwise', () => { +describe('select.server — providerRegistry binding points + getAuthProvider', () => { + it('exposes exactly fake+zitadel with a stable singleton, and selects the provider per AUTH_PROVIDER', () => { callService({ fn: 'selectProvider', - selectOp: 'fakeIsInstance', + selectOp: 'registryKeys', request: { url: 'http://localhost/id' }, }) + .then((v) => { + expect((v.outcome as { keys: string[] }).keys).to.deep.equal(['fake', 'zitadel']); + return callService({ + fn: 'selectProvider', + selectOp: 'fakeSingleton', + request: { url: 'http://localhost/id' }, + }); + }) + .then((v) => { + expect((v.outcome as { same: boolean }).same).to.equal(true); + return callService({ + fn: 'selectProvider', + selectOp: 'fakeIsInstance', + request: { url: 'http://localhost/id' }, + }); + }) .then((v) => { expect((v.outcome as { isFake: boolean }).isFake).to.equal(true); return callService({ diff --git a/cypress/component/modules/auth/session/cookie.cy.ts b/cypress/component/modules/auth/session/cookie.cy.ts index 0d9e89d597..7ae7e554f7 100644 --- a/cypress/component/modules/auth/session/cookie.cy.ts +++ b/cypress/component/modules/auth/session/cookie.cy.ts @@ -9,41 +9,47 @@ import { callService } from '../../../../support/node/call-service'; describe('cookie layer', () => { - it('round-trips a session list through serialize → parse', () => { - callService({ - fn: 'cookieRoundTripCheck', - cookieOp: 'roundTrip2', - request: { url: 'http://localhost/id' }, - }).then((v) => { - expect((v.outcome as { ids: string[] }).ids).to.deep.equal(['s1', 's2']); - }); - }); - - it('returns [] for a tampered cookie value (invalid signature)', () => { + // The three ops chain in one test, matching the established cy.task chaining pattern. Each + // callService spawns a fresh Bun process, so they stay fully independent; the only cost is + // that an earlier failure masks the later results. The TAMPER check therefore runs FIRST — + // it is the signature-verification assertion, and it must never be the one that gets masked. + it('rejects a tampered cookie, round-trips a session list, and caps the value at 2048 bytes', () => { callService({ fn: 'cookieRoundTripCheck', cookieOp: 'tampered', request: { url: 'http://localhost/id' }, - }).then((v) => { - expect((v.outcome as { result: string[] }).result).to.deep.equal([]); - }); - }); - - it('overflow: serialized value ≤ 2048 bytes and only newest entries survive', () => { - callService({ - fn: 'cookieRoundTripCheck', - cookieOp: 'overflow', - request: { url: 'http://localhost/id' }, - }).then((v) => { - const o = v.outcome as { - bytes: number; - parsedIds: string[]; - expectedIds: string[]; - parsedLen: number; - }; - expect(o.bytes).to.be.at.most(2048); - expect(o.parsedIds).to.deep.equal(o.expectedIds); - expect(o.parsedLen).to.be.lessThan(10); - }); + }) + .then((v) => { + expect((v.outcome as { result: string[] }).result, 'tampered: parses to []').to.deep.equal( + [] + ); + return callService({ + fn: 'cookieRoundTripCheck', + cookieOp: 'roundTrip2', + request: { url: 'http://localhost/id' }, + }); + }) + .then((v) => { + expect((v.outcome as { ids: string[] }).ids, 'roundTrip2: ids survive').to.deep.equal([ + 's1', + 's2', + ]); + return callService({ + fn: 'cookieRoundTripCheck', + cookieOp: 'overflow', + request: { url: 'http://localhost/id' }, + }); + }) + .then((v) => { + const o = v.outcome as { + bytes: number; + parsedIds: string[]; + expectedIds: string[]; + parsedLen: number; + }; + expect(o.bytes, 'overflow: bytes <= 2048').to.be.at.most(2048); + expect(o.parsedIds, 'overflow: only newest entries survive').to.deep.equal(o.expectedIds); + expect(o.parsedLen, 'overflow: fewer than the 10 written').to.be.lessThan(10); + }); }); }); diff --git a/cypress/component/modules/auth/session/reauth-intent.cy.ts b/cypress/component/modules/auth/session/reauth-intent.cy.ts index 5a0b21be9b..5984919370 100644 --- a/cypress/component/modules/auth/session/reauth-intent.cy.ts +++ b/cypress/component/modules/auth/session/reauth-intent.cy.ts @@ -16,29 +16,32 @@ interface ReauthCheck { } describe('checkReauthIntent (shared identity guard)', () => { - it('matching identity → no mismatch, intent echoed, clear cookie present', () => { - callService({ - fn: 'reauthIntentCheck', - reauthOp: 'checkMatch', - request: { url: 'http://localhost/id' }, - }).then((v) => { - const r = v.outcome as ReauthCheck; - expect(r.intent).to.equal('alice@acme.test'); - expect(r.mismatch).to.equal(false); - expect(r.clearCookie).to.include('reauth-intent='); - }); - }); - - it('different identity → mismatch true, clear cookie still present', () => { + // The match/mismatch pair chains in one test, matching the established cy.task pattern. Each + // callService spawns a fresh Bun process, so the two decisions stay fully independent. The + // MISMATCH case runs first: it is the one that actually denies a re-auth against the wrong + // identity, so it must never be masked by a failure in the permissive case. + it('echoes the intent and clears the cookie, flagging mismatch only on a different identity', () => { callService({ fn: 'reauthIntentCheck', reauthOp: 'checkMismatch', request: { url: 'http://localhost/id' }, - }).then((v) => { - const r = v.outcome as ReauthCheck; - expect(r.intent).to.equal('alice@acme.test'); - expect(r.mismatch).to.equal(true); - expect(r.clearCookie).to.include('reauth-intent='); - }); + }) + .then((v) => { + const r = v.outcome as ReauthCheck; + expect(r.intent, 'mismatch: intent echoed').to.equal('alice@acme.test'); + expect(r.mismatch, 'mismatch: flagged').to.equal(true); + expect(r.clearCookie, 'mismatch: clear cookie present').to.include('reauth-intent='); + return callService({ + fn: 'reauthIntentCheck', + reauthOp: 'checkMatch', + request: { url: 'http://localhost/id' }, + }); + }) + .then((v) => { + const r = v.outcome as ReauthCheck; + expect(r.intent, 'match: intent echoed').to.equal('alice@acme.test'); + expect(r.mismatch, 'match: not flagged').to.equal(false); + expect(r.clearCookie, 'match: clear cookie present').to.include('reauth-intent='); + }); }); }); diff --git a/cypress/component/modules/auth/session/session.cy.ts b/cypress/component/modules/auth/session/session.cy.ts index 4fe0ba7ee5..b974196044 100644 --- a/cypress/component/modules/auth/session/session.cy.ts +++ b/cypress/component/modules/auth/session/session.cy.ts @@ -30,13 +30,22 @@ describe('session store (cookie-array logic)', () => { expect(listSessions(next, NOW)).to.have.length(1); expect(mostRecent(next)?.changeTs).to.equal('2'); }); - it('drops expired sessions on list', () => { + it('drops expired sessions but keeps an entry with an empty expirationTs', () => { + // Mixed list first: proves listSessions filters PER ENTRY rather than keeping or + // dropping the whole array, which the single-entry table below cannot show. const expired = { ...base, id: 's2', expirationTs: '1' }; expect(listSessions([base, expired], NOW)).to.have.length(1); - }); - it('KEEPS an entry with an empty expirationTs (Zitadel session created without a lifetime → no expirationDate)', () => { - const noExpiry = { ...base, id: 's3', expirationTs: '' }; - expect(listSessions([noExpiry], NOW)).to.have.length(1); + + for (const [expirationTs, kept] of [ + ['1', false], + ['9999999999999', true], + ['', true], + ] as const) { + expect( + listSessions([{ ...base, id: 's3', expirationTs }], NOW), + `expirationTs=${JSON.stringify(expirationTs)}` + ).to.have.length(kept ? 1 : 0); + } }); it('caps the cookie at the byte budget, evicting the oldest by changeTs first', () => { const sizeOf = (list: SessionEntry[]) => list.length * 800; @@ -118,12 +127,15 @@ describe('byLoginName', () => { describe('needsLivenessCheck', () => { const livenessBase = { id: 's', token: 't', loginName: 'a', creationTs: '0', changeTs: '0' }; - it('flags an entry with an empty expirationTs (unknown expiry → must verify with provider)', () => { - expect(needsLivenessCheck({ ...livenessBase, expirationTs: '' })).to.equal(true); - }); - it('does not flag an entry with a known future expiry', () => { - expect( - needsLivenessCheck({ ...livenessBase, expirationTs: '2099-01-01T00:00:00.000Z' }) - ).to.equal(false); + it('flags an unknown expiry, but not a known future one', () => { + for (const [expirationTs, flagged] of [ + ['', true], + ['2099-01-01T00:00:00.000Z', false], + ] as const) { + expect( + needsLivenessCheck({ ...livenessBase, expirationTs }), + `expirationTs=${JSON.stringify(expirationTs)}` + ).to.equal(flagged); + } }); }); diff --git a/cypress/component/modules/fraud/maxmind-tracker.cy.tsx b/cypress/component/modules/fraud/maxmind-tracker.cy.tsx index 4497ede24e..b62f1af5f8 100644 --- a/cypress/component/modules/fraud/maxmind-tracker.cy.tsx +++ b/cypress/component/modules/fraud/maxmind-tracker.cy.tsx @@ -47,29 +47,27 @@ describe('readMaxMindTrackingToken', () => { describe('syncMaxMindTokenToRef', () => { beforeEach(() => window.sessionStorage.clear()); - it('writes a freshly-captured token into the ref input, simulating a submit that races the periodic sync', () => { - // Nothing mirrored into sessionStorage yet when the ref is created (mirrors the real - // scenario: the periodic interval hasn't ticked, or device.js hasn't captured the cookie - // at mount time) — then the token lands and the user clicks submit immediately after. - const input = document.createElement('input'); - input.value = ''; - window.sessionStorage.setItem(MAXMIND_TOKEN_STORAGE_KEY, 'tok-submit-time'); + it('syncs a captured token into the ref, leaves it untouched when absent, tolerates a null ref', () => { + for (const [stored, initial, expected] of [ + // Nothing mirrored into sessionStorage yet when the ref is created (mirrors the real + // scenario: the periodic interval hasn't ticked, or device.js hasn't captured the + // cookie at mount time) — then the token lands and the user submits immediately after. + ['tok-submit-time', '', 'tok-submit-time'], + // No token captured at all — the server-round-tripped value must survive untouched. + [undefined, 'server-round-tripped-value', 'server-round-tripped-value'], + ] as const) { + window.sessionStorage.clear(); + if (stored) window.sessionStorage.setItem(MAXMIND_TOKEN_STORAGE_KEY, stored); + const input = document.createElement('input'); + input.value = initial; - syncMaxMindTokenToRef({ current: input }); + syncMaxMindTokenToRef({ current: input }); - expect(input.value).to.equal('tok-submit-time'); - }); - - it('leaves the ref input untouched when no token has been captured yet', () => { - const input = document.createElement('input'); - input.value = 'server-round-tripped-value'; - - syncMaxMindTokenToRef({ current: input }); - - expect(input.value).to.equal('server-round-tripped-value'); - }); + expect(input.value, `stored=${String(stored)}`).to.equal(expected); + } - it('is a no-op (does not throw) when ref.current is null', () => { + // A null ref must not throw even when a token IS available to write. + window.sessionStorage.clear(); window.sessionStorage.setItem(MAXMIND_TOKEN_STORAGE_KEY, 'tok-ignored'); expect(() => syncMaxMindTokenToRef({ current: null })).to.not.throw(); }); diff --git a/cypress/component/resources/authorize/authorize-decision.cy.ts b/cypress/component/resources/authorize/authorize-decision.cy.ts index 6b06de1162..25f00d4a97 100644 --- a/cypress/component/resources/authorize/authorize-decision.cy.ts +++ b/cypress/component/resources/authorize/authorize-decision.cy.ts @@ -3,41 +3,53 @@ import { decideAuthorize } from '@/resources/authorize/authorize-decision'; const base: AuthRequest = { id: 'r1', scopes: [], prompt: [] }; +// Plain prompt routing. The prompt=none silent-auth gate is deliberately NOT in this table — +// it is the OIDC security decision and stays standalone below. +const ROUTING: Array<{ + label: string; + prompt: string[]; + hasSessions: boolean; + organization?: string; + target: string; + params?: Record; +}> = [ + { label: 'CREATE prompt', prompt: ['create'], hasSessions: true, target: '/signup' }, + { + // issue #99: never render an empty picker. + label: 'SELECT_ACCOUNT with no sessions', + prompt: ['select_account'], + hasSessions: false, + target: '/login', + }, + { + label: 'SELECT_ACCOUNT with sessions (the picker is the right screen)', + prompt: ['select_account'], + hasSessions: true, + target: '/accounts', + }, + { + label: 'SELECT_ACCOUNT threads organization onto the session-less /login bootstrap', + prompt: ['select_account'], + hasSessions: false, + organization: 'org-1', + target: '/login', + params: { organization: 'org-1' }, + }, +]; + describe('decideAuthorize', () => { - it('CREATE prompt → /signup', () => { - expect( - decideAuthorize({ authRequest: { ...base, prompt: ['create'] }, hasSessions: true }).target - ).to.equal('/signup'); + it('routes each prompt to the right screen, threading organization onto the session-less bootstrap', () => { + for (const { label, prompt, hasSessions, organization, target, params } of ROUTING) { + const r = decideAuthorize({ authRequest: { ...base, prompt }, hasSessions, organization }); + expect(r.target, `${label}: target`).to.equal(target); + if (params) expect(r.params, `${label}: params`).to.deep.equal(params); + } }); + + // Silent-auth gate: prompt=none must never fall back to an interactive screen. it('NONE prompt without a valid session → error(no-session)', () => { const r = decideAuthorize({ authRequest: { ...base, prompt: ['none'] }, hasSessions: false }); expect(r.target).to.equal('error'); expect(r.error).to.equal('NO_ACTIVE_SESSION'); }); - - it('SELECT_ACCOUNT with no sessions → /login (issue #99: never an empty picker)', () => { - const r = decideAuthorize({ - authRequest: { ...base, prompt: ['select_account'] }, - hasSessions: false, - }); - expect(r.target).to.equal('/login'); - }); - - it('SELECT_ACCOUNT with sessions → /accounts (the picker is still the right screen)', () => { - const r = decideAuthorize({ - authRequest: { ...base, prompt: ['select_account'] }, - hasSessions: true, - }); - expect(r.target).to.equal('/accounts'); - }); - - it('SELECT_ACCOUNT threads organization onto the session-less /login bootstrap', () => { - const r = decideAuthorize({ - authRequest: { ...base, prompt: ['select_account'] }, - hasSessions: false, - organization: 'org-1', - }); - expect(r.target).to.equal('/login'); - expect(r.params).to.deep.equal({ organization: 'org-1' }); - }); }); diff --git a/cypress/component/resources/authorize/default-org-fallback.cy.ts b/cypress/component/resources/authorize/default-org-fallback.cy.ts index 301c4819e7..660f55f00f 100644 --- a/cypress/component/resources/authorize/default-org-fallback.cy.ts +++ b/cypress/component/resources/authorize/default-org-fallback.cy.ts @@ -8,7 +8,7 @@ import { callService } from '../../../support/node/call-service'; describe('/authorize — explicit-only org threading', () => { - it('does NOT thread an organization into /login when the OIDC request carries no org scope', () => { + it('threads only an explicit org-id scope into /login', () => { callService({ fn: 'resolveAuthorize', seed: { @@ -17,15 +17,14 @@ describe('/authorize — explicit-only org threading', () => { }, request: { url: 'http://localhost/id/authorize?authRequest=req1' }, }).then((v) => { - expect(v.response?.status).to.equal(302); + expect(v.response?.status, 'no org scope: status').to.equal(302); const loc = v.response?.location ?? ''; - expect(loc).to.contain('/login'); - expect(loc).to.contain('requestId=oidc_req1'); - expect(loc).to.not.contain('organization='); // explicit-only → absent for a no-org request + expect(loc, 'no org scope: bootstraps /login').to.contain('/login'); + expect(loc, 'no org scope: requestId threaded').to.contain('requestId=oidc_req1'); + // explicit-only → the seeded default org must NOT leak in here. + expect(loc, 'no org scope: organization absent').to.not.contain('organization='); }); - }); - it('threads the explicit org scope into /login when the OIDC request carries an org-id scope', () => { callService({ fn: 'resolveAuthorize', seed: { @@ -40,10 +39,10 @@ describe('/authorize — explicit-only org threading', () => { }, request: { url: 'http://localhost/id/authorize?authRequest=req2' }, }).then((v) => { - expect(v.response?.status).to.equal(302); + expect(v.response?.status, 'explicit org scope: status').to.equal(302); const loc = v.response?.location ?? ''; - expect(loc).to.contain('/login'); - expect(loc).to.contain('organization=99999'); + expect(loc, 'explicit org scope: bootstraps /login').to.contain('/login'); + expect(loc, 'explicit org scope: threaded verbatim').to.contain('organization=99999'); }); }); }); diff --git a/cypress/component/resources/authorize/logout.cy.ts b/cypress/component/resources/authorize/logout.cy.ts index 058e75a237..d11639e823 100644 --- a/cypress/component/resources/authorize/logout.cy.ts +++ b/cypress/component/resources/authorize/logout.cy.ts @@ -9,7 +9,7 @@ // Regression coverage for the post-logout stale-cookie bug (validate-before-reuse): dead → // /login + session_stale (self-heal); transient → /error (NOT a silent re-login — the crux of // the fix is not conflating a transient lookup failure with a genuinely dead session). -import { callService, type AuditEvent } from '../../../support/node/call-service'; +import { callService, type AuditEvent, type Scenario } from '../../../support/node/call-service'; const RAW_ID = 'cb'; // singleton seed: requestId `cb` has prompt:[] → OIDC callback path @@ -75,71 +75,64 @@ describe('/authorize — stale-cookie self-heal (validate before reuse)', () => describe('/authorize — read-after-write retry on a freshly-created session (NOT_FOUND)', () => { const NOW_MS = Date.parse('2026-06-24T12:00:00.000Z'); - it('FRESH session (creationTs within the retry window): NOT_FOUND once then alive on retry → finalizes the callback (no self-heal)', () => { - callService({ - fn: 'resolveAuthorize', - provider: 'singleton', - // Consumed by the FIRST getSession call; the retry then falls through to this seeded - // live session, simulating "the write landed, the first read just raced a lagging replica". - liveSessions: [{ id: 'fresh-race-1', token: 'tok-fresh-race-1' }], - sessionResults: { 'fresh-race-1': { mode: 'throw-once', code: 'NOT_FOUND' } }, - nowMs: NOW_MS, - request: { - url: `http://localhost/id/authorize?requestId=oidc_${RAW_ID}&sessionId=fresh-race-1`, - sessions: [ - { - id: 'fresh-race-1', - token: 'tok-fresh-race-1', - loginName: 'alice@acme.test', - // 2s old — well inside the retry window (this is the "just redirected back from - // register" case). - creationTs: new Date(NOW_MS - 2000).toISOString(), - }, - ], + it('FRESH session (creationTs within the retry window): NOT_FOUND then alive → the bounded backoff keeps polling until the replica catches up and finalizes the callback (no self-heal)', () => { + // Each row scripts N failing getSession reads before falling through to the seeded live + // session — "the write landed, the read(s) just raced a lagging replica". Distinct session + // ids per row keep the singleton provider's scripted results independent. + const ROWS: ReadonlyArray<{ + label: string; + id: string; + script: NonNullable[string]; + }> = [ + { + // Consumed by the FIRST getSession call; the retry then finds the live session (this is + // the "just redirected back from register" case). + label: 'replica lags one read cycle', + id: 'fresh-race-1', + script: { mode: 'throw-once', code: 'NOT_FOUND' }, }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - const loc = v.response?.location ?? ''; - expect(loc).to.include('client.acme.test/callback'); - expect(loc).to.include(`code=fake_${RAW_ID}_fresh-race-1`); - expect(loc).to.not.include('/login'); - // No self-heal fired — the retry recovered the session, so it must NOT look like the - // stale-cookie case above. - expect(v.audit.some((e) => e.event === 'session_stale')).to.equal(false); - }); - }); - - it('FRESH session lagging MORE than one read cycle: NOT_FOUND twice then alive → the bounded backoff loop keeps polling and finalizes (no self-heal)', () => { - callService({ - fn: 'resolveAuthorize', - provider: 'singleton', - // Throws NOT_FOUND for the first TWO getSession calls (the initial probe + the first retry), - // then falls through to this live session on the third — a replica that lags past a single - // retry. Proves healIfSessionDead's loop keeps going instead of giving up after one attempt. - liveSessions: [{ id: 'fresh-race-2', token: 'tok-fresh-race-2' }], - sessionResults: { - 'fresh-race-2': { mode: 'throw-times', code: 'NOT_FOUND', times: 2 }, - }, - nowMs: NOW_MS, - request: { - url: `http://localhost/id/authorize?requestId=oidc_${RAW_ID}&sessionId=fresh-race-2`, - sessions: [ - { - id: 'fresh-race-2', - token: 'tok-fresh-race-2', - loginName: 'alice@acme.test', - // 2s old — inside the retry window. - creationTs: new Date(NOW_MS - 2000).toISOString(), - }, - ], + { + // Throws NOT_FOUND for the first TWO getSession calls (the initial probe + the first + // retry), then falls through on the third — a replica that lags past a single retry. + // Proves healIfSessionDead's loop keeps going instead of giving up after one attempt. + label: 'replica lags two read cycles', + id: 'fresh-race-2', + script: { mode: 'throw-times', code: 'NOT_FOUND', times: 2 }, }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - const loc = v.response?.location ?? ''; - expect(loc).to.include('client.acme.test/callback'); - expect(loc).to.include(`code=fake_${RAW_ID}_fresh-race-2`); - expect(loc).to.not.include('/login'); - expect(v.audit.some((e) => e.event === 'session_stale')).to.equal(false); + ]; + + ROWS.forEach(({ label, id, script }) => { + callService({ + fn: 'resolveAuthorize', + provider: 'singleton', + liveSessions: [{ id, token: `tok-${id}` }], + sessionResults: { [id]: script }, + nowMs: NOW_MS, + request: { + url: `http://localhost/id/authorize?requestId=oidc_${RAW_ID}&sessionId=${id}`, + sessions: [ + { + id, + token: `tok-${id}`, + loginName: 'alice@acme.test', + // 2s old — well inside the retry window. + creationTs: new Date(NOW_MS - 2000).toISOString(), + }, + ], + }, + }).then((v) => { + expect(v.response?.status, `${label}: 302`).to.equal(302); + const loc = v.response?.location ?? ''; + expect(loc, `${label}: callback host`).to.include('client.acme.test/callback'); + expect(loc, `${label}: minted code`).to.include(`code=fake_${RAW_ID}_${id}`); + expect(loc, `${label}: no /login re-prompt`).to.not.include('/login'); + // No self-heal fired — the retry recovered the session, so it must NOT look like the + // stale-cookie case above. + expect( + v.audit.some((e) => e.event === 'session_stale'), + `${label}: no session_stale` + ).to.equal(false); + }); }); }); diff --git a/cypress/component/resources/login/device-thread.cy.ts b/cypress/component/resources/login/device-thread.cy.ts index e57fa6ad8c..e608744dca 100644 --- a/cypress/component/resources/login/device-thread.cy.ts +++ b/cypress/component/resources/login/device-thread.cy.ts @@ -24,54 +24,60 @@ function makeProvider() { }); } +// 755-M8: the two requestId prefixes take DIFFERENT post-password hops — device_ goes +// straight to /signed-in, oidc_ keeps the /authorize finalization carve-out. Same ceremony, +// same assertion shape, so one table with the expected destination per prefix. +const CEREMONIES: Array<{ + label: string; + requestId: string; + targetPattern: RegExp; + mustNotContain?: string; +}> = [ + { + label: 'device_ requestId reaches /signed-in', + requestId: REQUEST_ID, + targetPattern: /^\/signed-in/, + mustNotContain: '/authorize', + }, + { + label: 'oidc_ requestId keeps the /authorize finalization carve-out', + requestId: 'oidc_V2_abc123', + targetPattern: /^\/authorize/, + }, +]; + describe('device_ requestId threading through the login ceremony', () => { - it('password flow accepts a device_ requestId and threads it (not rejected)', async () => { - const fake = makeProvider(); + it('threads the requestId through identifier → password, sending device_ to /signed-in and oidc_ to /authorize (755-M8)', async () => { + for (const { label, requestId, targetPattern, mustNotContain } of CEREMONIES) { + const fake = makeProvider(); - // 1) identifier step establishes the ceremony session entry. - const idResult = await resolveIdentifier(fake, [], { - loginName: 'alice@acme.test', - requestId: REQUEST_ID, - emailDeliveryEnabled: true, - }); - expect(idResult.ok).to.equal(true); - if (!idResult.ok) return; - expect(idResult.sessions.length).to.be.greaterThan(0); + // 1) identifier step establishes the ceremony session entry. + const idResult = await resolveIdentifier(fake, [], { + loginName: 'alice@acme.test', + requestId, + emailDeliveryEnabled: true, + }); + expect(idResult.ok, `${label}: identifier step`).to.equal(true); + if (!idResult.ok) return; + expect(idResult.sessions.length, `${label}: ceremony session created`).to.be.greaterThan(0); - // 2) password step must accept the threaded device_ requestId. - const pwResult = await verifyLoginPassword(fake, idResult.sessions, { - loginName: 'alice@acme.test', - password: 'hunter2', - requestId: REQUEST_ID, - }); - expect(pwResult.ok).to.equal(true); - if (!pwResult.ok) return; - // The device_ requestId survives into the post-password redirect target. - expect(pwResult.target).to.contain(`requestId=${REQUEST_ID}`); - // 755-M8: a device_ requestId must reach /signed-in (NOT /authorize finalization). - expect(pwResult.target).to.match(/^\/signed-in/); - expect(pwResult.target).not.to.contain('/authorize'); - }); + // 2) password step must accept the threaded requestId. + const pwResult = await verifyLoginPassword(fake, idResult.sessions, { + loginName: 'alice@acme.test', + password: 'hunter2', + requestId, + }); + expect(pwResult.ok, `${label}: password step`).to.equal(true); + if (!pwResult.ok) return; - it('755-M8: an oidc_ requestId STILL takes the /authorize finalization carve-out', async () => { - const fake = makeProvider(); - const oidcReq = 'oidc_V2_abc123'; - const idResult = await resolveIdentifier(fake, [], { - loginName: 'alice@acme.test', - requestId: oidcReq, - emailDeliveryEnabled: true, - }); - expect(idResult.ok).to.equal(true); - if (!idResult.ok) return; - const pwResult = await verifyLoginPassword(fake, idResult.sessions, { - loginName: 'alice@acme.test', - password: 'hunter2', - requestId: oidcReq, - }); - expect(pwResult.ok).to.equal(true); - if (!pwResult.ok) return; - // Non-device requestIds keep the OIDC finalization hop to /authorize. - expect(pwResult.target).to.match(/^\/authorize/); - expect(pwResult.target).to.contain(`requestId=${oidcReq}`); + // The requestId survives into the post-password redirect target. + expect(pwResult.target, `${label}: requestId survives`).to.contain(`requestId=${requestId}`); + expect(pwResult.target, `${label}: destination`).to.match(targetPattern); + if (mustNotContain) { + expect(pwResult.target, `${label}: no ${mustNotContain} hop`).not.to.contain( + mustNotContain + ); + } + } }); }); diff --git a/cypress/component/resources/login/login-decision.cy.ts b/cypress/component/resources/login/login-decision.cy.ts index 33a36c2ec3..11e0611679 100644 --- a/cypress/component/resources/login/login-decision.cy.ts +++ b/cypress/component/resources/login/login-decision.cy.ts @@ -38,32 +38,27 @@ describe('decideAfterIdentifier → discriminated Decision union', () => { expect(d).to.deep.equal({ kind: 'redirect', path: '/login/method' }); }); - it('two methods still route to /login/method', () => { - const d = decideAfterIdentifier({ - methods: ['idp', 'password'], - settings: { - allowPassword: true, - allowExternalIdp: true, - passkeysType: 'not_allowed', - } as LoginSettings, - emailDeliveryEnabled: true, - context: PRIMARY, - }); - expect(d).to.deep.equal({ kind: 'redirect', path: '/login/method' }); - }); + // Same settings, same full-object deep.equal — only the enrolled-method list and the + // expected destination vary. + const ROUTING: [label: string, methods: string[], path: string][] = [ + ['two methods → the chooser', ['idp', 'password'], '/login/method'], + ['zero methods → the invite path', [], '/verify'], + ]; - it('zero enrolled methods still routes to /verify (invite path)', () => { - const d = decideAfterIdentifier({ - methods: [], - settings: { - allowPassword: true, - allowExternalIdp: true, - passkeysType: 'not_allowed', - } as LoginSettings, - emailDeliveryEnabled: true, - context: PRIMARY, - }); - expect(d).to.deep.equal({ kind: 'redirect', path: '/verify' }); + it('routes two enrolled methods to /login/method and zero enrolled methods to /verify (invite path)', () => { + for (const [label, methods, path] of ROUTING) { + const d = decideAfterIdentifier({ + methods, + settings: { + allowPassword: true, + allowExternalIdp: true, + passkeysType: 'not_allowed', + } as LoginSettings, + emailDeliveryEnabled: true, + context: PRIMARY, + }); + expect(d, label).to.deep.equal({ kind: 'redirect', path }); + } }); it('enrolled password but policy forbids it → PASSWORD_NOT_ALLOWED', () => { diff --git a/cypress/component/resources/login/login-schema.cy.ts b/cypress/component/resources/login/login-schema.cy.ts index 1edf12f805..3b46970276 100644 --- a/cypress/component/resources/login/login-schema.cy.ts +++ b/cypress/component/resources/login/login-schema.cy.ts @@ -4,19 +4,19 @@ // Pure Zod schema utilities → browser-side Chai only. import { isEmailLike, isPhoneLike } from '@/resources/login/login.schema'; -describe('isPhoneLike', () => { - it('true for phone-shaped input with no @', () => { - expect(isPhoneLike('+15550000000')).to.equal(true); - expect(isPhoneLike('0612345678')).to.equal(true); - expect(isPhoneLike('+1 555 000 0000')).to.equal(true); - }); -}); +describe('isPhoneLike / isEmailLike', () => { + it('classifies phone-shaped and email-shaped identifiers', () => { + for (const phone of ['+15550000000', '0612345678', '+1 555 000 0000']) { + expect(isPhoneLike(phone), `isPhoneLike(${phone})`).to.equal(true); + } -describe('isEmailLike', () => { - it('matches email-shaped identifiers (incl. domain-suffixed usernames)', () => { - expect(isEmailLike('a@b.com')).to.equal(true); - expect(isEmailLike('alice@acme.test')).to.equal(true); - expect(isEmailLike('alice@acme.zitadel.cloud')).to.equal(true); - expect(isEmailLike(' alice@acme.test ')).to.equal(true); + for (const email of [ + 'a@b.com', + 'alice@acme.test', + 'alice@acme.zitadel.cloud', + ' alice@acme.test ', + ]) { + expect(isEmailLike(email), `isEmailLike(${JSON.stringify(email)})`).to.equal(true); + } }); }); diff --git a/cypress/component/resources/login/login-view.cy.ts b/cypress/component/resources/login/login-view.cy.ts index 473e7166a8..eee2145eae 100644 --- a/cypress/component/resources/login/login-view.cy.ts +++ b/cypress/component/resources/login/login-view.cy.ts @@ -26,21 +26,87 @@ const settings = ( const IDP = [{ id: 'i', name: 'G', type: 'oidc' } as never]; +type View = ReturnType; +type Args = Parameters; + describe('resolveLoginView', () => { - // The identifier field is a prerequisite for password, passkey (usernameless is - // unsupported upstream — zitadel/zitadel#8899) AND email-link, so it must not be - // gated on allowPassword alone. - it('shows the identifier form for password, passkey, or email-link independently', () => { - expect( - resolveLoginView(settings({ allowPassword: true }), [], false).showIdentifierForm - ).to.equal(true); - expect( - resolveLoginView(settings({ passkeysAllowed: true }), [], false).showIdentifierForm - ).to.equal(true); + // Consolidated from three one-shape tests (identifier-form gating, email-link-only + // Continue suppression, signInUnavailable matrix) into one labeled partial table — + // the same [label, Args, Partial] pattern as signup-view.cy.ts. Every original + // assertion is preserved as a row/field; a failure names ''. + const CASES: [string, Args, Partial][] = [ + // ── showIdentifierForm ──────────────────────────────────────────────────────────── + // The identifier field is a prerequisite for password, passkey (usernameless is + // unsupported upstream — zitadel/zitadel#8899) AND email-link, so it must not be + // gated on allowPassword alone. + [ + 'password alone shows the identifier form', + [settings({ allowPassword: true }), [], false], + { showIdentifierForm: true }, + ], + [ + 'passkey alone shows the identifier form', + [settings({ passkeysAllowed: true }), [], false], + { showIdentifierForm: true }, + ], // email-link alone: delivery on, org has not disabled email login. - expect(resolveLoginView(settings({}), [], true).showIdentifierForm).to.equal(true); - // nothing at all → no form. - expect(resolveLoginView(settings({}), [], false).showIdentifierForm).to.equal(false); + [ + 'email-link alone shows the identifier form', + [settings({}), [], true], + { showIdentifierForm: true }, + ], + ['nothing at all → no form', [settings({}), [], false], { showIdentifierForm: false }], + // ── email-link only: form without Continue ──────────────────────────────────────── + // "Continue" routes through decideAfterIdentifier; with neither password nor passkey + // that resolves to NO_SUPPORTED_METHOD, so the button must not render. + [ + 'email-link only hides Continue but keeps the form', + [settings({}), [], true], + { + showContinue: false, + showEmailLink: true, + showIdentifierForm: true, + signInUnavailable: false, + }, + ], + // ── signInUnavailable: only when neither an identifier nor an IdP path exists ───── + [ + 'no password, no passkey, no IdP, delivery off → genuinely unavailable', + [settings({}), [], false], + { signInUnavailable: true }, + ], + [ + 'password alone clears unavailable', + [settings({ allowPassword: true }), [], false], + { signInUnavailable: false }, + ], + // An IdP alone clears it even with no identifier path. + [ + 'an IdP alone clears unavailable', + [settings({ allowExternalIdp: true }), IDP, false], + { signInUnavailable: false }, + ], + // Email delivery on is itself a path (reverses the 2026-07-06 assumption). + [ + 'email delivery on clears unavailable', + [settings({}), [], true], + { signInUnavailable: false }, + ], + // …but not when the org disabled email login. + [ + 'delivery on but org disabled email login → unavailable', + [settings({ disableLoginWithEmail: true }), [], true], + { signInUnavailable: true }, + ], + ]; + + it('resolves identifier form, Continue, and unavailability across the policy matrix', () => { + for (const [label, args, expected] of CASES) { + const view = resolveLoginView(...args); + for (const [field, value] of Object.entries(expected)) { + expect(view[field as keyof View], `${label} → ${field}`).to.equal(value); + } + } }); // REGRESSION: a passkey-only org with no loginName used to render an EMPTY card — the @@ -52,34 +118,6 @@ describe('resolveLoginView', () => { expect(view.showContinue).to.equal(true); expect(view.signInUnavailable).to.equal(false); }); - - // "Continue" routes through decideAfterIdentifier; with neither password nor passkey - // that resolves to NO_SUPPORTED_METHOD, so the button must not render. - it('hides Continue when only email-link is available, but keeps the form', () => { - const view = resolveLoginView(settings({}), [], true); - expect(view.showContinue).to.equal(false); - expect(view.showEmailLink).to.equal(true); - expect(view.showIdentifierForm).to.equal(true); - expect(view.signInUnavailable).to.equal(false); - }); - - it('flags sign-in unavailable only when neither an identifier nor an IdP path exists', () => { - // No password, no passkey, no IdP, email delivery OFF → genuinely unavailable. - expect(resolveLoginView(settings({}), [], false).signInUnavailable).to.equal(true); - expect( - resolveLoginView(settings({ allowPassword: true }), [], false).signInUnavailable - ).to.equal(false); - // An IdP alone clears it even with no identifier path. - expect( - resolveLoginView(settings({ allowExternalIdp: true }), IDP, false).signInUnavailable - ).to.equal(false); - // Email delivery on is itself a path (reverses the 2026-07-06 assumption). - expect(resolveLoginView(settings({}), [], true).signInUnavailable).to.equal(false); - // …but not when the org disabled email login. - expect( - resolveLoginView(settings({ disableLoginWithEmail: true }), [], true).signInUnavailable - ).to.equal(true); - }); }); describe('attemptsRemaining + resolveIdentifierField', () => { diff --git a/cypress/component/resources/login/resolve-identifier-defaults.cy.ts b/cypress/component/resources/login/resolve-identifier-defaults.cy.ts new file mode 100644 index 0000000000..98847bf21d --- /dev/null +++ b/cypress/component/resources/login/resolve-identifier-defaults.cy.ts @@ -0,0 +1,44 @@ +// cypress/component/resources/login/resolve-identifier-defaults.cy.ts +// +// Component (no-mount) merge of three former single-test specs, each pinning the DEFAULT-OFF +// branch of a different Zitadel login-policy flag: +// • resolve-identifier-domain-discovery.cy.ts — allowDomainDiscovery off → no domain lookup +// • resolve-identifier-email-disabled.cy.ts — disableLoginWithEmail off → unchanged +// • resolve-identifier-ignore-unknown.cy.ts — ignoreUnknownUsernames off → unchanged +// +// All three made the same resolveIdentifier call with a different provider seed and asserted +// the IDENTICAL result, so they collapse into one table keyed by the flag under test. The +// seeds are preserved verbatim — each one is what makes its flag's "off" branch meaningful +// (an orgDomains seed that must NOT be consulted, a non-email loginName, an email loginName). +import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; +import { resolveIdentifier } from '@/resources/login'; + +type Seed = ConstructorParameters[0]; + +const CASES: [flag: string, seed: Seed][] = [ + // A seeded org domain that must never be consulted while discovery is off. + ['allowDomainDiscovery OFF — no domain lookup', { orgDomains: { 'acme.test': 'org-acme' } }], + // A non-email loginName: the email-shaped identifier must still miss. + [ + 'disableLoginWithEmail OFF — detect-for-copy unchanged', + { users: [{ id: 'u1', loginName: 'alice' }] }, + ], + // An email loginName that simply does not match the queried one. + [ + 'ignoreUnknownUsernames OFF — unknown identifier still reported', + { users: [{ id: 'u1', loginName: 'alice@acme.test' }] }, + ], +]; + +describe('resolveIdentifier — policy-flag defaults (all OFF)', () => { + it('reports USER_NOT_FOUND for an unknown identifier under every default-off policy flag', async () => { + for (const [flag, seed] of CASES) { + const provider = new FakeAuthProvider(seed); + const result = await resolveIdentifier(provider, [], { + loginName: 'ghost@acme.test', + emailDeliveryEnabled: true, + }); + expect(result, flag).to.deep.equal({ ok: false, error: 'USER_NOT_FOUND' }); + } + }); +}); diff --git a/cypress/component/resources/login/resolve-identifier-domain-discovery.cy.ts b/cypress/component/resources/login/resolve-identifier-domain-discovery.cy.ts deleted file mode 100644 index ef0798a269..0000000000 --- a/cypress/component/resources/login/resolve-identifier-domain-discovery.cy.ts +++ /dev/null @@ -1,17 +0,0 @@ -// cypress/component/resources/login/resolve-identifier-domain-discovery.cy.ts -// -// Component (no-mount) port of resolve-identifier-domain-discovery.test.ts. -// allowDomainDiscovery (settings-gated, default-off, both-branch). -import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; -import { resolveIdentifier } from '@/resources/login'; - -describe('resolveIdentifier — allowDomainDiscovery', () => { - it('OFF (default): no domain lookup; unknown user → USER_NOT_FOUND', async () => { - const p = new FakeAuthProvider({ orgDomains: { 'acme.test': 'org-acme' } }); - const r = await resolveIdentifier(p, [], { - loginName: 'ghost@acme.test', - emailDeliveryEnabled: true, - }); - expect(r).to.deep.equal({ ok: false, error: 'USER_NOT_FOUND' }); - }); -}); diff --git a/cypress/component/resources/login/resolve-identifier-email-disabled.cy.ts b/cypress/component/resources/login/resolve-identifier-email-disabled.cy.ts deleted file mode 100644 index 3933ad382b..0000000000 --- a/cypress/component/resources/login/resolve-identifier-email-disabled.cy.ts +++ /dev/null @@ -1,17 +0,0 @@ -// cypress/component/resources/login/resolve-identifier-email-disabled.cy.ts -// -// Component (no-mount) port of resolve-identifier-email-disabled.test.ts. -// Uses fresh FakeAuthProvider instances; no cy.mount needed. -import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; -import { resolveIdentifier } from '@/resources/login'; - -describe('resolveIdentifier — disableLoginWithEmail (detect-for-copy)', () => { - it('OFF (default): email-shaped unknown identifier → USER_NOT_FOUND (unchanged)', async () => { - const p = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'alice' }] }); - const r = await resolveIdentifier(p, [], { - loginName: 'ghost@acme.test', - emailDeliveryEnabled: true, - }); - expect(r).to.deep.equal({ ok: false, error: 'USER_NOT_FOUND' }); - }); -}); diff --git a/cypress/component/resources/login/resolve-identifier-ignore-unknown.cy.ts b/cypress/component/resources/login/resolve-identifier-ignore-unknown.cy.ts index bbe9f82195..c103fe77ee 100644 --- a/cypress/component/resources/login/resolve-identifier-ignore-unknown.cy.ts +++ b/cypress/component/resources/login/resolve-identifier-ignore-unknown.cy.ts @@ -4,16 +4,10 @@ import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider'; import { resolveIdentifier } from '@/resources/login'; +// The OFF (default) branch lives in resolve-identifier-defaults.cy.ts, alongside the other +// default-off policy flags — same seed, same USER_NOT_FOUND assertion. Only the ON branch, +// which has no equivalent there, is kept here. describe('resolveIdentifier — ignoreUnknownUsernames', () => { - it('OFF (default): unknown identifier returns USER_NOT_FOUND (unchanged)', async () => { - const p = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'alice@acme.test' }] }); - const r = await resolveIdentifier(p, [], { - loginName: 'ghost@acme.test', - emailDeliveryEnabled: true, - }); - expect(r).to.deep.equal({ ok: false, error: 'USER_NOT_FOUND' }); - }); - it('ON: the ghost target is whatever a real PASSWORD-ONLY account resolves to', () => { // Pinned as a comparison, never as a literal. The ghost has no destination of its own — its // only job is to collide with the password-only account's, and a literal '/login/method' here diff --git a/cypress/component/resources/mfa/mfa-routing.cy.ts b/cypress/component/resources/mfa/mfa-routing.cy.ts index cb6cdf7510..438ea61800 100644 --- a/cypress/component/resources/mfa/mfa-routing.cy.ts +++ b/cypress/component/resources/mfa/mfa-routing.cy.ts @@ -36,56 +36,64 @@ const base = (over: Partial = {}): MfaRoutingInput => ({ function expectRoute( r: ReturnType, path: string, - params?: Record + params?: Record, + label = '' ): void { - expect(r.kind).to.equal('route'); + expect(r.kind, `${label}: kind`).to.equal('route'); if (r.kind !== 'route') return; - expect(r.path).to.equal(path); - if (params) expect(r.params).to.include(params); + expect(r.path, `${label}: path`).to.equal(path); + if (params) expect(r.params, `${label}: params`).to.include(params); } -describe('nextMfaStep', () => { - it('done when a passwordless passkey is user-verified and fresh', () => { - const factors: Factors = { passkey: { verifiedAt: freshDate } }; - expect( - nextMfaStep( - base({ - factors, - userVerified: true, - settings: settings({ multiFactorCheckLifetimeMs: 1000 }), - }) - ) - ).to.deep.equal({ kind: 'done' }); - }); - - it('NOT done when passkey fresh but NOT user-verified (falls through to enrolled-method routing)', () => { - const factors: Factors = { passkey: { verifiedAt: freshDate } }; - expectRoute( - nextMfaStep(base({ factors, userVerified: false, enrolledMethods: ['totp'] })), - '/login/verify/authenticator' - ); - }); +const passkeyFresh: Factors = { passkey: { verifiedAt: freshDate } }; +const totpFresh: Factors = { totp: { verifiedAt: freshDate } }; - it('done when a second factor (totp) is already fresh', () => { - const factors: Factors = { totp: { verifiedAt: freshDate } }; - expect( - nextMfaStep(base({ factors, settings: settings({ secondFactorCheckLifetimeMs: 1000 }) })) - ).to.deep.equal({ kind: 'done' }); - }); +describe('nextMfaStep', () => { + // Every input that must terminate the MFA gate. + const DONE: [label: string, input: MfaRoutingInput][] = [ + [ + 'passwordless passkey user-verified and fresh', + base({ + factors: passkeyFresh, + userVerified: true, + settings: settings({ multiFactorCheckLifetimeMs: 1000 }), + }), + ], + [ + 'second factor (totp) already fresh', + base({ factors: totpFresh, settings: settings({ secondFactorCheckLifetimeMs: 1000 }) }), + ], + ['no 2nd factor enrolled, not forced, no skip window configured', base()], + [ + // No fresh passkey factor (password login, userVerified:false), skip window configured + // and never skipped → WITHOUT the passkey rule this routes to /setup/mfa?force=false. + // A passkey is passwordless-primary strong auth the user already has, so the *optional* + // step-6 "set up MFA" nudge is confusing UX and is suppressed. This affects ONLY the + // skippable nudge — a hard org policy (forced MFA) still applies, see the routes table. + // NOTE: a DIFFERENT nudge from the backup-method/lockout banner on the /passkeys + // management route (methodCount === 1). No overlap: separate file, trigger, and intent. + 'enrolled passkey suppresses the step-6 nudge (password login, skip window elapsed)', + base({ + enrolledMethods: ['passkey'], + settings: settings({ mfaInitSkipLifetimeMs: 1000 }), + mfaInitSkippedAt: null, + }), + ], + [ + 'fresh user-verified passwordless passkey still done via step 1, even when enrolled', + base({ + factors: passkeyFresh, + userVerified: true, + enrolledMethods: ['passkey'], + settings: settings({ multiFactorCheckLifetimeMs: 1000 }), + }), + ], + ]; - it('NOT done when the only fresh second factor is stale', () => { - const factors: Factors = { totp: { verifiedAt: freshDate } }; - expectRoute( - nextMfaStep( - base({ - factors, - enrolledMethods: ['totp'], - nowMs: T0 + 5000, - settings: settings({ secondFactorCheckLifetimeMs: 1000 }), - }) - ), - '/login/verify/authenticator' - ); + it('is done for every input that satisfies the MFA gate', () => { + for (const [label, input] of DONE) { + expect(nextMfaStep(input), label).to.deep.equal({ kind: 'done' }); + } }); const directRoutes: Array<[AuthMethod, string]> = [ @@ -94,28 +102,79 @@ describe('nextMfaStep', () => { ['otp_sms', '/login/verify/sms'], ['u2f', '/login/security-key'], ]; - directRoutes.forEach(([method, path]) => { - it(`routes directly to ${method} use-screen when it is the only enrolled 2nd factor`, () => { - expectRoute(nextMfaStep(base({ enrolledMethods: [method] })), path); - }); - }); - - it('routes to /login/mfa when more than one 2nd factor is enrolled', () => { - expectRoute(nextMfaStep(base({ enrolledMethods: ['totp', 'otp_email'] })), '/login/mfa'); - }); - it('routes to /setup/mfa (force=true,checkAfter=true) when forceMfa and no 2nd factor enrolled', () => { - expectRoute(nextMfaStep(base({ settings: settings({ forceMfa: true }) })), '/setup/mfa', { - force: 'true', - checkAfter: 'true', - }); - }); + // Every input that must send the user to a specific screen. + const ROUTES: Array< + [label: string, input: MfaRoutingInput, path: string, params?: Record] + > = [ + [ + 'passkey fresh but NOT user-verified falls through to enrolled-method routing', + base({ factors: passkeyFresh, userVerified: false, enrolledMethods: ['totp'] }), + '/login/verify/authenticator', + ], + [ + 'the only fresh second factor is stale', + base({ + factors: totpFresh, + enrolledMethods: ['totp'], + nowMs: T0 + 5000, + settings: settings({ secondFactorCheckLifetimeMs: 1000 }), + }), + '/login/verify/authenticator', + ], + ...directRoutes.map(([method, path]): [string, MfaRoutingInput, string] => [ + `${method} is the only enrolled 2nd factor`, + base({ enrolledMethods: [method] }), + path, + ]), + [ + 'more than one 2nd factor enrolled', + base({ enrolledMethods: ['totp', 'otp_email'] }), + '/login/mfa', + ], + [ + 'forceMfa with no 2nd factor enrolled', + base({ settings: settings({ forceMfa: true }) }), + '/setup/mfa', + { force: 'true', checkAfter: 'true' }, + ], + [ + // 755-M10: suppressMfaSetupNudge (account-switch) must not weaken FORCED setup. + 'suppressMfaSetupNudge does NOT bypass FORCED setup (step 5)', + base({ settings: settings({ forceMfa: true }), suppressMfaSetupNudge: true }), + '/setup/mfa', + { force: 'true', checkAfter: 'true' }, + ], + [ + 'an enrolled passkey does NOT bypass FORCED MFA (step 5)', + base({ + enrolledMethods: ['passkey'], + settings: settings({ forceMfa: true, mfaInitSkipLifetimeMs: 1000 }), + }), + '/setup/mfa', + { force: 'true', checkAfter: 'true' }, + ], + [ + 'WITHOUT a passkey, no 2nd factor, not forced, skip window elapsed → skippable nudge fires', + base({ + enrolledMethods: [], + settings: settings({ mfaInitSkipLifetimeMs: 1000 }), + mfaInitSkippedAt: null, + }), + '/setup/mfa', + { force: 'false', checkAfter: 'true' }, + ], + ]; - it('is done when no 2nd factor enrolled, not forced, and no skip window configured', () => { - expect(nextMfaStep(base())).to.deep.equal({ kind: 'done' }); + it('routes to the correct screen for every non-done input', () => { + for (const [label, input, path, params] of ROUTES) { + expectRoute(nextMfaStep(input), path, params, label); + } }); // ── Bug C: intersect enrolled methods with policy-allowed secondFactors ────── + // Kept standalone (NOT folded into the routes table): these are the named lockout / + // escape-hatch regression guards, and they must fail in isolation to be diagnosable. it('drops a policy-disabled enrolled factor and routes to the remaining allowed one', () => { expectRoute( @@ -140,76 +199,4 @@ describe('nextMfaStep', () => { '/setup/mfa' ); }); - - // ── 755-M10: suppressMfaSetupNudge (account-switch) ────────────────────────── - - it('still routes to FORCED setup (step 5) even when suppressMfaSetupNudge is set', () => { - expectRoute( - nextMfaStep(base({ settings: settings({ forceMfa: true }), suppressMfaSetupNudge: true })), - '/setup/mfa', - { force: 'true', checkAfter: 'true' } - ); - }); - - // ── enrolled passkey suppresses the step-6 skippable MFA-setup nudge ────────── - // A passkey is passwordless-primary strong auth the user already has, so the *optional* - // "set up MFA" nudge is confusing UX ("I have a passkey, why set up MFA?") and is suppressed. - // This affects ONLY the skippable step-6 nudge — a hard org policy (forced MFA) still applies. - // NOTE: this is a DIFFERENT nudge from the backup-method/lockout banner on the /passkeys - // management route (methodCount === 1). No overlap: separate file, trigger, and intent. - - it('is done (step-6 nudge suppressed) when an enrolled passkey exists, password login, skip window elapsed', () => { - // No fresh passkey factor (password login, userVerified:false), skip window configured and - // never skipped → WITHOUT the passkey rule this routes to /setup/mfa?force=false. - expect( - nextMfaStep( - base({ - enrolledMethods: ['passkey'], - settings: settings({ mfaInitSkipLifetimeMs: 1000 }), - mfaInitSkippedAt: null, - }) - ) - ).to.deep.equal({ kind: 'done' }); - }); - - it('an enrolled passkey does NOT bypass FORCED MFA (step 5 still routes to setup, force=true)', () => { - expectRoute( - nextMfaStep( - base({ - enrolledMethods: ['passkey'], - settings: settings({ forceMfa: true, mfaInitSkipLifetimeMs: 1000 }), - }) - ), - '/setup/mfa', - { force: 'true', checkAfter: 'true' } - ); - }); - - it('WITHOUT a passkey, no 2nd factor, not forced, skip window elapsed → skippable nudge still fires (unchanged)', () => { - expectRoute( - nextMfaStep( - base({ - enrolledMethods: [], - settings: settings({ mfaInitSkipLifetimeMs: 1000 }), - mfaInitSkippedAt: null, - }) - ), - '/setup/mfa', - { force: 'false', checkAfter: 'true' } - ); - }); - - it('a fresh user-verified passwordless passkey is still done via step 1 (unchanged), even when enrolled', () => { - const factors: Factors = { passkey: { verifiedAt: freshDate } }; - expect( - nextMfaStep( - base({ - factors, - userVerified: true, - enrolledMethods: ['passkey'], - settings: settings({ multiFactorCheckLifetimeMs: 1000 }), - }) - ) - ).to.deep.equal({ kind: 'done' }); - }); }); diff --git a/cypress/component/resources/mfa/mfa.schema.cy.ts b/cypress/component/resources/mfa/mfa.schema.cy.ts index 3681665b6e..57d2dd95ac 100644 --- a/cypress/component/resources/mfa/mfa.schema.cy.ts +++ b/cypress/component/resources/mfa/mfa.schema.cy.ts @@ -6,16 +6,20 @@ // exercised by any e2e flow.) import { otpCodeSchema } from '@/resources/mfa/mfa.schema'; -describe('mfa schemas', () => { - it('accepts a 6-digit OTP code and rejects others', () => { - expect(otpCodeSchema.safeParse({ code: '123456' }).success).to.equal(true); - expect(otpCodeSchema.safeParse({ code: 'abc' }).success).to.equal(false); - expect(otpCodeSchema.safeParse({ code: '12' }).success).to.equal(false); - }); +// TOTP/authenticator codes MUST stay exactly 6 digits — both too-short and too-long inputs +// are rejected. Widening this schema in either direction would weaken TOTP. +const CASES: [code: string, accepted: boolean][] = [ + ['123456', true], + ['abc', false], + ['12', false], + ['1234567', false], + ['86230120', false], +]; - it('keeps otpCodeSchema strictly 6 digits — rejects 8-digit codes (TOTP regression guard)', () => { - // TOTP/authenticator codes MUST stay exactly 6 digits; widening here would weaken TOTP. - expect(otpCodeSchema.safeParse({ code: '86230120' }).success).to.equal(false); - expect(otpCodeSchema.safeParse({ code: '1234567' }).success).to.equal(false); +describe('mfa schemas', () => { + it('accepts exactly a 6-digit OTP code and rejects non-numeric, short, and 7/8-digit codes (TOTP regression guard)', () => { + for (const [code, accepted] of CASES) { + expect(otpCodeSchema.safeParse({ code }).success, `code=${code}`).to.equal(accepted); + } }); }); diff --git a/cypress/component/resources/mfa/mfa.service.cy.ts b/cypress/component/resources/mfa/mfa.service.cy.ts index 14850168b6..8838064732 100644 --- a/cypress/component/resources/mfa/mfa.service.cy.ts +++ b/cypress/component/resources/mfa/mfa.service.cy.ts @@ -23,17 +23,15 @@ describe('chooseMfaMethod — findUser failure audit (security: routing continue mfaInput: { loginName: 'alice@acme.test' }, }; - it('emits an mfa_method_chosen failure audit line when findUser throws', () => { + // All three assertions read DIFFERENT fields of the SAME verdict — the scenario was being + // re-run identically three times. One run, all assertions. + it('audits a findUser failure with a hashed actor, and still routes to the use-screen', () => { callService(scenario).then((v) => { const failure = v.audit.find( (e) => e.event === 'mfa_method_chosen' && e.outcome === 'failure' ); expect(failure, 'a failure audit event').to.not.equal(undefined); - }); - }); - it('still emits the success routing event even when findUser throws (routing continues)', () => { - callService(scenario).then((v) => { const success = v.audit.find( (e) => e.event === 'mfa_method_chosen' && e.outcome === 'success' ); @@ -42,14 +40,8 @@ describe('chooseMfaMethod — findUser failure audit (security: routing continue const o = v.outcome as { ok: boolean; target?: string }; expect(o.ok).to.equal(true); expect(o.target ?? '').to.include('/login/verify/authenticator'); - }); - }); - it('does NOT put raw loginName in the failure audit fields (hashed actor only)', () => { - callService(scenario).then((v) => { - const failure = v.audit.find( - (e) => e.event === 'mfa_method_chosen' && e.outcome === 'failure' - ); + // No PII leak: the failure line must carry a hashed actor, never the raw loginName. expect(failure?.loginName, 'no raw loginName').to.equal(undefined); expect(typeof failure?.actor, 'hashed actor present').to.equal('string'); }); @@ -108,24 +100,16 @@ describe('resolveMfaSetup — auto-skip when no MFA methods are offerable', () = mfaInput: { loginName: 'nomfa@test.example' }, }; - it('returns a redirect (not setup) when no MFA methods are offerable', () => { + // Three assertions on three fields of the SAME verdict — one run instead of three. + it('returns a redirect (not setup) whose target does not loop back to /setup/mfa, and emits an mfa_skip success audit line', () => { callService(baseScenario).then((v) => { - const o = v.outcome as { kind: string; offerableKeys?: unknown[] }; + const o = v.outcome as { kind: string; target?: string; offerableKeys?: unknown[] }; expect(o.kind, 'auto-skip: kind must be redirect, not setup').to.equal('redirect'); - }); - }); - - it('redirect target does not loop back to /setup/mfa', () => { - callService(baseScenario).then((v) => { - const o = v.outcome as { kind: string; target?: string }; expect(o.target ?? '', 'target must not loop back to /setup/mfa').to.not.include( '/setup/mfa' ); - }); - }); - it('emits an mfa_skip success audit line on auto-skip', () => { - callService(baseScenario).then((v) => { + // Stamping the skip is what prevents a re-prompt loop. const skipAudit = v.audit.find((e) => e.event === 'mfa_skip' && e.outcome === 'success'); expect(skipAudit, 'mfa_skip success audit must be emitted').to.not.equal(undefined); }); @@ -210,16 +194,11 @@ describe('resolveMfaSetup — normal path returns offerableKeys when MFA methods mfaInput: { loginName: 'alice@acme.test' }, }; - it('returns kind: setup (not redirect) when offerable MFA methods exist', () => { + // Both assertions read the SAME verdict — one run instead of two. + it('returns kind: setup (not redirect) with a non-empty offerableKeys when MFA capabilities are enabled', () => { callService(scenario).then((v) => { const o = v.outcome as { kind: string; offerableKeys?: string[] }; expect(o.kind, 'chooser renders when methods available').to.equal('setup'); - }); - }); - - it('offerableKeys is non-empty when MFA capabilities are enabled', () => { - callService(scenario).then((v) => { - const o = v.outcome as { kind: string; offerableKeys?: string[] }; expect((o.offerableKeys ?? []).length, 'offerable keys non-empty').to.be.greaterThan(0); }); }); diff --git a/cypress/component/resources/otp/otp-email-url-template.cy.ts b/cypress/component/resources/otp/otp-email-url-template.cy.ts index 8ab6eb5817..82da107ab0 100644 --- a/cypress/component/resources/otp/otp-email-url-template.cy.ts +++ b/cypress/component/resources/otp/otp-email-url-template.cy.ts @@ -7,27 +7,28 @@ // (no hardcoded https) — the Host-header email-link-injection defense. import { otpEmailUrlTemplate } from '@/resources/otp/otp-email-url-template'; +// The dropped third test asserted `startsWith('http://localhost:3000/id/')` for the same +// origin — strictly weaker than the exact-string equal below, which already pins the origin +// verbatim including its scheme. The remaining two cases are one table. describe('otpEmailUrlTemplate', () => { - it('builds an absolute /id/login/verify/email url with RAW (unencoded) OTPEmail placeholders', () => { - const t = otpEmailUrlTemplate({ - origin: 'http://localhost:3000', - loginName: 'alice@acme.test', - }); - expect(t).to.equal( + it('builds the verify-email url from a verbatim origin, keeping OTPEmail placeholders literal', () => { + // Exact string: pins the origin verbatim, the /id basename, raw placeholder braces, and + // the percent-encoding of the real loginName — all in one assertion. + expect( + otpEmailUrlTemplate({ origin: 'http://localhost:3000', loginName: 'alice@acme.test' }) + ).to.equal( 'http://localhost:3000/id/login/verify/email?code={{.Code}}&userId={{.UserID}}&sessionId={{.SessionID}}&loginName=alice%40acme.test' ); - }); - - it('keeps the {{.Code}}/{{.UserID}}/{{.SessionID}} braces literal (never percent-encoded)', () => { - const t = otpEmailUrlTemplate({ origin: 'https://h', loginName: 'x' }); - expect(t).to.include('code={{.Code}}'); - expect(t).to.include('userId={{.UserID}}'); - expect(t).to.include('sessionId={{.SessionID}}'); - expect(t).to.not.include('%7B'); - }); - it('uses the passed origin VERBATIM, including its scheme (no hardcoded https)', () => { - const t = otpEmailUrlTemplate({ origin: 'http://localhost:3000', loginName: 'x' }); - expect(t.startsWith('http://localhost:3000/id/')).to.equal(true); + // Braces must survive on a different origin too, and must never be percent-encoded. + const other = otpEmailUrlTemplate({ origin: 'https://h', loginName: 'x' }); + for (const placeholder of [ + 'code={{.Code}}', + 'userId={{.UserID}}', + 'sessionId={{.SessionID}}', + ]) { + expect(other, `literal ${placeholder}`).to.include(placeholder); + } + expect(other, 'no percent-encoded brace').to.not.include('%7B'); }); }); diff --git a/cypress/component/resources/otp/otp-enroll.cy.ts b/cypress/component/resources/otp/otp-enroll.cy.ts index 04ef4dedca..4726853767 100644 --- a/cypress/component/resources/otp/otp-enroll.cy.ts +++ b/cypress/component/resources/otp/otp-enroll.cy.ts @@ -22,32 +22,69 @@ describe('setupSkipSchema — graceful tampered force/checkAfter params (otp-enr }); }); -describe('otp-enroll loader guard-fail — threads requestId/organization (regression: dead-end mid-ceremony)', () => { - it('redirects to a bare /login when no session matches and no ceremony context is present', () => { - callService({ +// Both OTP loaders enforce the IDENTICAL guard-fail bounce contract, and otp-verify.cy.ts +// (now deleted) asserted exactly this shape against otpVerifyLoader. Merged here as one +// 4-row table: {enroll, verify} × {no ceremony context, mid-ceremony}. +const CEREMONY_QUERY = '&requestId=oidc_V2_123&organization=org-1'; + +const BOUNCES: Array<{ + label: string; + scenario: Parameters[0]; + expectedLocation: string; +}> = [ + { + label: 'enroll, no ceremony context', + scenario: { fn: 'otpEnrollLoader', provider: 'singleton', otpEnrollConfig: { verifyPath: '/login/verify/email' }, request: { url: 'http://localhost/id/setup/email?loginName=ghost@nowhere.test' }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.location).to.equal('/login'); - }); - }); - - it('threads requestId/organization onto the /login bounce when the ceremony is in flight', () => { - callService({ + }, + expectedLocation: '/login', + }, + { + label: 'enroll, ceremony in flight', + scenario: { fn: 'otpEnrollLoader', provider: 'singleton', otpEnrollConfig: { verifyPath: '/login/verify/email' }, request: { - url: - 'http://localhost/id/setup/email?loginName=ghost@nowhere.test' + - '&requestId=oidc_V2_123&organization=org-1', + url: `http://localhost/id/setup/email?loginName=ghost@nowhere.test${CEREMONY_QUERY}`, + }, + }, + expectedLocation: '/login?requestId=oidc_V2_123&organization=org-1', + }, + { + label: 'verify, no ceremony context', + scenario: { + fn: 'otpVerifyLoader', + provider: 'singleton', + otpVerifyConfig: { channel: 'email', verifyPath: '/login/verify/email' }, + request: { url: 'http://localhost/id/login/verify/email?loginName=ghost@nowhere.test' }, + }, + expectedLocation: '/login', + }, + { + label: 'verify, ceremony in flight', + scenario: { + fn: 'otpVerifyLoader', + provider: 'singleton', + otpVerifyConfig: { channel: 'email', verifyPath: '/login/verify/email' }, + request: { + url: `http://localhost/id/login/verify/email?loginName=ghost@nowhere.test${CEREMONY_QUERY}`, }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.location).to.equal('/login?requestId=oidc_V2_123&organization=org-1'); - }); + }, + expectedLocation: '/login?requestId=oidc_V2_123&organization=org-1', + }, +]; + +describe('otp enroll/verify loader guard-fail — threads requestId/organization (regression: dead-end mid-ceremony)', () => { + it('bounces both loaders to /login, threading requestId/organization mid-ceremony', () => { + for (const { label, scenario, expectedLocation } of BOUNCES) { + callService(scenario).then((v) => { + expect(v.response?.status, `${label}: status`).to.equal(302); + expect(v.response?.location, `${label}: location`).to.equal(expectedLocation); + }); + } }); }); diff --git a/cypress/component/resources/otp/otp-verify.cy.ts b/cypress/component/resources/otp/otp-verify.cy.ts deleted file mode 100644 index b8f6c4489f..0000000000 --- a/cypress/component/resources/otp/otp-verify.cy.ts +++ /dev/null @@ -1,37 +0,0 @@ -// cypress/component/resources/otp/otp-verify.cy.ts -// -// cy.task node-spec port covering createOtpVerifyHandlers' loader guard-fail (otp-verify.ts -// ~line 110): no matching session for the loginName redirects to /login. Node-bound because the -// loader reads a signed `sessions` cookie off a real Request (Cookie header blocked in the -// browser by the Fetch spec). -import { callService } from '../../../support/node/call-service'; - -describe('otp-verify loader guard-fail — threads requestId/organization (regression: dead-end mid-ceremony)', () => { - it('redirects to a bare /login when no session matches and no ceremony context is present', () => { - callService({ - fn: 'otpVerifyLoader', - provider: 'singleton', - otpVerifyConfig: { channel: 'email', verifyPath: '/login/verify/email' }, - request: { url: 'http://localhost/id/login/verify/email?loginName=ghost@nowhere.test' }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.location).to.equal('/login'); - }); - }); - - it('threads requestId/organization onto the /login bounce when the ceremony is in flight', () => { - callService({ - fn: 'otpVerifyLoader', - provider: 'singleton', - otpVerifyConfig: { channel: 'email', verifyPath: '/login/verify/email' }, - request: { - url: - 'http://localhost/id/login/verify/email?loginName=ghost@nowhere.test' + - '&requestId=oidc_V2_123&organization=org-1', - }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.location).to.equal('/login?requestId=oidc_V2_123&organization=org-1'); - }); - }); -}); diff --git a/cypress/component/resources/otp/otp.service.cy.ts b/cypress/component/resources/otp/otp.service.cy.ts index ba867f42ba..a3327d3854 100644 --- a/cypress/component/resources/otp/otp.service.cy.ts +++ b/cypress/component/resources/otp/otp.service.cy.ts @@ -85,17 +85,17 @@ describe('submitOtpCode — accepts 8-digit delivered codes (Bug B)', () => { const field = (channel: 'email' | 'sms') => (channel === 'email' ? 'otpEmail' : 'otpSms'); - (['email', 'sms'] as const).forEach((channel) => { - it(`${channel}: accepts an 8-digit code and forwards it to the provider (not INVALID_INPUT)`, () => { + it('both channels accept an 8-digit code and forward it to the provider (not INVALID_INPUT)', () => { + for (const channel of ['email', 'sms'] as const) { run(channel, '86230120').then((v) => { const o = v.outcome as { ok: boolean; target?: string }; - expect(o.ok).to.equal(true); - expect(typeof o.target).to.equal('string'); + expect(o.ok, `${channel}: ok`).to.equal(true); + expect(typeof o.target, `${channel}: target`).to.equal('string'); const forwarded = (v.calls?.updateSession ?? []) .map((c) => (c[2] as Record)[field(channel)]) .filter((x): x is string => typeof x === 'string'); - expect(forwarded).to.include('86230120'); + expect(forwarded, `${channel}: code forwarded`).to.include('86230120'); }); - }); + } }); }); diff --git a/cypress/component/resources/reauth/reauth-idp-intent.cy.ts b/cypress/component/resources/reauth/reauth-idp-intent.cy.ts index 1ec346a677..f8122a2dfb 100644 --- a/cypress/component/resources/reauth/reauth-idp-intent.cy.ts +++ b/cypress/component/resources/reauth/reauth-idp-intent.cy.ts @@ -31,27 +31,28 @@ describe('startReauthIdpIntent', () => { expect(capturedUrls?.failure).to.include('returnTo=%2Fpasskeys'); }); - it('maps a ProviderError from provider.startIdpIntent to IDP_UNAVAILABLE', async () => { - const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); - fake.startIdpIntent = async () => { - throw new ProviderError('UNAVAILABLE', 'idp down'); - }; - const result = await startReauthIdpIntent(fake, { - idpId: 'idp-google', - origin: 'http://localhost:3000', - returnTo: '/passkeys', - }); - expect(result).to.deep.equal({ ok: false, error: 'IDP_UNAVAILABLE' }); - }); + // Both failure modes collapse to the SAME deep.equal, differing only in how the provider + // fails: it throws, or it returns a response with no authUrl. + const FAILURES: [label: string, startIdpIntent: () => Promise][] = [ + [ + 'provider throws a ProviderError', + async () => { + throw new ProviderError('UNAVAILABLE', 'idp down'); + }, + ], + ['provider returns no authUrl', async () => ({})], + ]; - it('maps a missing authUrl to IDP_UNAVAILABLE', async () => { - const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); - fake.startIdpIntent = async () => ({}); - const result = await startReauthIdpIntent(fake, { - idpId: 'idp-google', - origin: 'http://localhost:3000', - returnTo: '/passkeys', - }); - expect(result).to.deep.equal({ ok: false, error: 'IDP_UNAVAILABLE' }); + it('maps every startIdpIntent failure mode (a thrown ProviderError, a missing authUrl) to IDP_UNAVAILABLE', async () => { + for (const [label, startIdpIntent] of FAILURES) { + const fake = new FakeAuthProvider({ users: [{ id: 'u1', loginName: 'mia@acme.test' }] }); + fake.startIdpIntent = startIdpIntent as typeof fake.startIdpIntent; + const result = await startReauthIdpIntent(fake, { + idpId: 'idp-google', + origin: 'http://localhost:3000', + returnTo: '/passkeys', + }); + expect(result, label).to.deep.equal({ ok: false, error: 'IDP_UNAVAILABLE' }); + } }); }); diff --git a/cypress/component/resources/reauth/reauth.service.cy.ts b/cypress/component/resources/reauth/reauth.service.cy.ts index a886707cb4..7c932f0119 100644 --- a/cypress/component/resources/reauth/reauth.service.cy.ts +++ b/cypress/component/resources/reauth/reauth.service.cy.ts @@ -45,21 +45,35 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => } }); - it('loadReauth falls back to the Zitadel-configured default destination when returnTo is absent', async () => { + it('falls back to the configured default returnTo, then to /passkeys', async () => { // Mirrors /signed-in's own fallback priority (admin console → Zitadel default → // env default → /passkeys) — reauth is reached from multiple flows (passkeys, // sso, ...), so a caller-less visit shouldn't blindly land on /passkeys. - const { fake, sessions } = await seeded(); - fake.setLoginDefaultRedirectUri('https://app.acme.test/dashboard'); - const v = await loadReauth(fake, sessions, { - returnTo: null, - method: null, - domain: 'localhost', - emailDeliveryEnabled: false, - consoleUrl: 'https://console.acme.test', - }); - expect(v.kind).to.equal('view'); - if (v.kind === 'view') expect(v.returnTo).to.equal('https://app.acme.test/dashboard'); + const load = async (configuredDefault?: string) => { + const { fake, sessions } = await seeded(); + if (configuredDefault) fake.setLoginDefaultRedirectUri(configuredDefault); + return loadReauth(fake, sessions, { + returnTo: null, + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + }; + + const configured = await load('https://app.acme.test/dashboard'); + expect(configured.kind, 'configured default: kind').to.equal('view'); + if (configured.kind === 'view') { + expect(configured.returnTo, 'configured default wins').to.equal( + 'https://app.acme.test/dashboard' + ); + } + + const bare = await load(); + expect(bare.kind, 'nothing configured: kind').to.equal('view'); + if (bare.kind === 'view') { + expect(bare.returnTo, 'falls through to /passkeys').to.equal('/passkeys'); + } }); it('performReauth preserves the Zitadel-configured default returnTo across the full round-trip', async () => { @@ -91,19 +105,6 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => if (r.ok) expect(r.target).to.equal('https://app.acme.test/dashboard'); }); - it('loadReauth falls back to /passkeys when returnTo is absent AND nothing is configured', async () => { - const { fake, sessions } = await seeded(); - const v = await loadReauth(fake, sessions, { - returnTo: null, - method: null, - domain: 'localhost', - emailDeliveryEnabled: false, - consoleUrl: 'https://console.acme.test', - }); - expect(v.kind).to.equal('view'); - if (v.kind === 'view') expect(v.returnTo).to.equal('/passkeys'); - }); - it('performReauth(password) updates the SAME session id, rotates the token, and targets returnTo', async () => { const { fake, sessions } = await seeded(); const r = await performReauth(fake, sessions, { @@ -227,38 +228,22 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => expect(threw).to.exist; }); - it('loadReauth lists idp as a method and populates linkedIdps for a Google-linked user', async () => { - const fake = new FakeAuthProvider({ - users: [USER], - authMethods: { u1: ['idp'] }, - capabilities: { externalIdp: true }, - }); - fake.setActiveIdPs?.([{ id: 'idp-google', name: 'Google', type: 'GOOGLE' }]); - fake.setIdpLinks?.('u1', [ - { idpId: 'idp-google', idpUserId: 'g-1', idpUserName: 'mia@gmail.com' }, - ]); - const s = await fake.createSession({}, { userId: 'u1' }); - const sessions: SessionEntry[] = [ - { - id: s.id, - token: s.token, - loginName: USER.loginName, - creationTs: s.changedAt, - expirationTs: s.expiresAt, - changeTs: s.changedAt, - }, - ]; - const v = await loadReauth(fake, sessions, { - returnTo: '/passkeys', - method: null, - domain: 'localhost', - emailDeliveryEnabled: false, - consoleUrl: 'https://console.acme.test', - }); - expect(v.kind).to.equal('view'); - if (v.kind === 'view') { - expect(v.methods).to.include('idp'); - expect(v.linkedIdps).to.deep.equal([ + // Same setup, same assertion shape — only the linked provider's type differs, and with it + // whether idp is offerable at all. LDAP is a directory bind, not a browser-redirect IdP, so + // it must never surface as a re-auth method. + const LINKED_IDPS: Array<{ + label: string; + idp: { id: string; name: string; type: string }; + link: { idpId: string; idpUserId: string; idpUserName: string }; + offersIdp: boolean; + expectedLinked: Record[]; + }> = [ + { + label: 'Google-linked user', + idp: { id: 'idp-google', name: 'Google', type: 'GOOGLE' }, + link: { idpId: 'idp-google', idpUserId: 'g-1', idpUserName: 'mia@gmail.com' }, + offersIdp: true, + expectedLinked: [ { idpId: 'idp-google', idpUserId: 'g-1', @@ -266,40 +251,49 @@ describe('reauth.service — verify one factor onto the EXISTING session', () => name: 'Google', type: 'GOOGLE', }, - ]); - } - }); + ], + }, + { + label: 'LDAP-only user', + idp: { id: 'idp-ldap', name: 'Corporate LDAP', type: 'LDAP' }, + link: { idpId: 'idp-ldap', idpUserId: 'ldap-1', idpUserName: 'mia' }, + offersIdp: false, + expectedLinked: [], + }, + ]; - it('loadReauth omits idp when the only linked provider is LDAP', async () => { - const fake = new FakeAuthProvider({ - users: [USER], - authMethods: { u1: ['idp'] }, - capabilities: { externalIdp: true }, - }); - fake.setActiveIdPs?.([{ id: 'idp-ldap', name: 'Corporate LDAP', type: 'LDAP' }]); - fake.setIdpLinks?.('u1', [{ idpId: 'idp-ldap', idpUserId: 'ldap-1', idpUserName: 'mia' }]); - const s = await fake.createSession({}, { userId: 'u1' }); - const sessions: SessionEntry[] = [ - { - id: s.id, - token: s.token, - loginName: USER.loginName, - creationTs: s.changedAt, - expirationTs: s.expiresAt, - changeTs: s.changedAt, - }, - ]; - const v = await loadReauth(fake, sessions, { - returnTo: '/passkeys', - method: null, - domain: 'localhost', - emailDeliveryEnabled: false, - consoleUrl: 'https://console.acme.test', - }); - expect(v.kind).to.equal('view'); - if (v.kind === 'view') { - expect(v.methods).to.not.include('idp'); - expect(v.linkedIdps).to.deep.equal([]); + it('lists idp for a browser-redirect IdP, omitting it when only LDAP is linked', async () => { + for (const { label, idp, link, offersIdp, expectedLinked } of LINKED_IDPS) { + const fake = new FakeAuthProvider({ + users: [USER], + authMethods: { u1: ['idp'] }, + capabilities: { externalIdp: true }, + }); + fake.setActiveIdPs?.([idp]); + fake.setIdpLinks?.('u1', [link]); + const s = await fake.createSession({}, { userId: 'u1' }); + const sessions: SessionEntry[] = [ + { + id: s.id, + token: s.token, + loginName: USER.loginName, + creationTs: s.changedAt, + expirationTs: s.expiresAt, + changeTs: s.changedAt, + }, + ]; + const v = await loadReauth(fake, sessions, { + returnTo: '/passkeys', + method: null, + domain: 'localhost', + emailDeliveryEnabled: false, + consoleUrl: 'https://console.acme.test', + }); + expect(v.kind, `${label}: kind`).to.equal('view'); + if (v.kind !== 'view') continue; + if (offersIdp) expect(v.methods, `${label}: methods`).to.include('idp'); + else expect(v.methods, `${label}: methods`).to.not.include('idp'); + expect(v.linkedIdps, `${label}: linkedIdps`).to.deep.equal(expectedLinked); } }); }); diff --git a/cypress/component/resources/schemas/password-match.cy.ts b/cypress/component/resources/schemas/password-match.cy.ts index 47c65e0d72..4f22cb5d3d 100644 --- a/cypress/component/resources/schemas/password-match.cy.ts +++ b/cypress/component/resources/schemas/password-match.cy.ts @@ -10,23 +10,27 @@ const schema = withPasswordMatch(z.object({ password: z.string(), confirmPasswor }); describe('withPasswordMatch', () => { - it('fails when passwords differ, error on confirmPassword path', () => { - const r = schema.safeParse({ password: 'a', confirmPassword: 'b' }); - expect(r.success).to.equal(false); - if (!r.success) { - expect(r.error.issues[0].path).to.include('confirmPassword'); + it('reports a password mismatch on the confirm path, for an explicit and the default field', () => { + // Explicit confirmField override. + const explicit = schema.safeParse({ password: 'a', confirmPassword: 'b' }); + expect(explicit.success, 'explicit confirmField: mismatch rejected').to.equal(false); + if (!explicit.success) { + expect(explicit.error.issues[0].path, 'explicit confirmField: error path').to.include( + 'confirmPassword' + ); } - }); - it('defaults to the real confirm field name used by the live schemas', () => { + // Default confirm field name. const real = withPasswordMatch(z.object({ password: z.string(), confirm: z.string() })); const ok = real.safeParse({ password: 'a', confirm: 'a' }); - expect(ok.success).to.equal(true); + expect(ok.success, 'default confirm: match accepted').to.equal(true); const bad = real.safeParse({ password: 'a', confirm: 'b' }); - expect(bad.success).to.equal(false); + expect(bad.success, 'default confirm: mismatch rejected').to.equal(false); if (!bad.success) { - expect(bad.error.issues[0].path).to.include('confirm'); - expect(bad.error.issues[0].message).to.equal('Passwords must match'); + expect(bad.error.issues[0].path, 'default confirm: error path').to.include('confirm'); + expect(bad.error.issues[0].message, 'default confirm: message').to.equal( + 'Passwords must match' + ); } }); }); diff --git a/cypress/component/resources/schemas/user-code.cy.ts b/cypress/component/resources/schemas/user-code.cy.ts index 50475a3162..f14cde6494 100644 --- a/cypress/component/resources/schemas/user-code.cy.ts +++ b/cypress/component/resources/schemas/user-code.cy.ts @@ -1,25 +1,26 @@ import { userCodeSchema } from '@/resources/schemas/user-code'; describe('userCodeSchema', () => { - it('accepts an OAuth device user_code shape', () => { - expect(userCodeSchema.safeParse('WDJB-MJHT').success).to.be.true; - }); + // Shape and length rules. The query-injection case is deliberately NOT in this table — + // it is the security guard and stays standalone below so it fails on its own. + const CASES: [label: string, input: string | undefined, accepted: boolean][] = [ + ['OAuth device user_code shape', 'WDJB-MJHT', true], + ['underscores', 'WDJB_MJHT', true], + ['over 64 characters', 'A'.repeat(65), false], + ['absence is valid (optional)', undefined, true], + ]; + + it('accepts the OAuth device user_code shape (dashes, underscores, absent) and rejects over-length values', () => { + for (const [label, input, accepted] of CASES) { + expect(userCodeSchema.safeParse(input).success, label).to.equal(accepted); + } - it('accepts underscores', () => { - expect(userCodeSchema.safeParse('WDJB_MJHT').success).to.be.true; + // Absence must parse to undefined, not to a coerced empty string. + const absent = userCodeSchema.safeParse(undefined); + expect(absent.success && absent.data).to.equal(undefined); }); it('rejects query-injection characters — SECURITY', () => { expect(userCodeSchema.safeParse('X&loginName=admin').success).to.be.false; }); - - it('rejects a value over 64 characters', () => { - expect(userCodeSchema.safeParse('A'.repeat(65)).success).to.be.false; - }); - - it('treats absence as valid (optional)', () => { - const r = userCodeSchema.safeParse(undefined); - expect(r.success).to.be.true; - expect(r.success && r.data).to.equal(undefined); - }); }); diff --git a/cypress/component/resources/session/session.service.signed-in.cy.ts b/cypress/component/resources/session/session.service.signed-in.cy.ts index c45cb706d3..273a0285d4 100644 --- a/cypress/component/resources/session/session.service.signed-in.cy.ts +++ b/cypress/component/resources/session/session.service.signed-in.cy.ts @@ -70,42 +70,35 @@ describe('resolveSignedIn — no-session redirect', () => { }); describe('resolveSignedIn — terminal page carries the analytics-identify userId', () => { - it("resolves the provider session's user id onto the terminal page outcome", () => { - callService({ - fn: 'resolveSignedIn', - provider: 'fresh', - seed: {}, - liveSessions: [ - { id: 's1', token: 't1', user: { id: 'user-42', loginName: 'alice@acme.test' } }, - ], - signedInConfig: cfg(), - request: { - url: 'http://localhost/id/signed-in', - sessions: COOKIE({ id: 's1', token: 't1' }), - }, - }).then((v) => { - const o = v.outcome as { kind: string; loginName?: string | null; userId?: string | null }; - expect(o.kind).to.equal('page'); - expect(o.loginName).to.equal('alice@acme.test'); - expect(o.userId).to.equal('user-42'); - }); - }); + // Same call, same assertion shape — only the cookie token and the expected userId vary. A + // mismatched token must degrade userId to null WITHOUT blocking the page. + const CASES: [label: string, token: string, expectedUserId: string | null][] = [ + ['resolving token', 't1', 'user-42'], + ['stale/mismatched token', 'wrong-token', null], + ]; - it('degrades userId to null when the cookie session does not resolve against the provider (e.g. a stale/mismatched token), without blocking the page', () => { - callService({ - fn: 'resolveSignedIn', - provider: 'fresh', - seed: {}, - liveSessions: [{ id: 's1', token: 't1' }], - signedInConfig: cfg(), - request: { - url: 'http://localhost/id/signed-in', - sessions: COOKIE({ id: 's1', token: 'wrong-token' }), - }, - }).then((v) => { - const o = v.outcome as { kind: string; userId?: string | null }; - expect(o.kind).to.equal('page'); - expect(o.userId).to.equal(null); - }); + it("resolves the provider session's user id onto the terminal page, degrading to null (never blocking) when the cookie session does not resolve", () => { + for (const [label, token, expectedUserId] of CASES) { + callService({ + fn: 'resolveSignedIn', + provider: 'fresh', + seed: {}, + liveSessions: [ + { id: 's1', token: 't1', user: { id: 'user-42', loginName: 'alice@acme.test' } }, + ], + signedInConfig: cfg(), + request: { + url: 'http://localhost/id/signed-in', + sessions: COOKIE({ id: 's1', token }), + }, + }).then((v) => { + const o = v.outcome as { kind: string; loginName?: string | null; userId?: string | null }; + expect(o.kind, `${label}: page still renders`).to.equal('page'); + expect(o.userId, `${label}: userId`).to.equal(expectedUserId); + if (expectedUserId) { + expect(o.loginName, `${label}: loginName`).to.equal('alice@acme.test'); + } + }); + } }); }); diff --git a/cypress/component/resources/session/session.service.switch.cy.ts b/cypress/component/resources/session/session.service.switch.cy.ts index c3abf2539b..bc2275b3c5 100644 --- a/cypress/component/resources/session/session.service.switch.cy.ts +++ b/cypress/component/resources/session/session.service.switch.cy.ts @@ -47,7 +47,11 @@ describe('removeAccount — ceremony requestId threading', () => { const liveSessions = [{ id: 's1', token: 'tok-s1', user: ALICE }]; const cookie = [{ id: 's1', token: 'tok-s1', loginName: 'alice@acme.test' }]; - it('carries an allowlisted requestId onto the /accounts redirect', () => { + const REQ = encodeURIComponent('oidc_V3-current'); + + it('carries an allowlisted requestId — and the ceremony organization when present — onto the /accounts redirect', () => { + // No organization: asserted with an EXACT equal, which also proves no stray params are + // appended. This is why it is not folded into the include-based case below. callService({ fn: 'removeAccount', seed, @@ -59,12 +63,13 @@ describe('removeAccount — ceremony requestId threading', () => { }, }).then((v) => { const o = v.outcome as Outcome; - expect(o.kind).to.equal('redirect'); - expect(o.location).to.equal(`/accounts?requestId=${encodeURIComponent('oidc_V3-current')}`); + expect(o.kind, 'requestId only: redirect').to.equal('redirect'); + expect(o.location, 'requestId only: exact target').to.equal(`/accounts?requestId=${REQ}`); }); - }); - it('carries the ceremony organization onto the /accounts redirect (regression: removeSchema dropped it)', () => { + // With organization (regression: removeSchema dropped it). Both the ceremony id AND the + // org scope must survive, so "Add an account"/signup after a remove resolves the correct + // org instead of falling back to the default. callService({ fn: 'removeAccount', seed, @@ -81,11 +86,9 @@ describe('removeAccount — ceremony requestId threading', () => { }, }).then((v) => { const o = v.outcome as Outcome; - expect(o.kind).to.equal('redirect'); - // Both the ceremony id AND the org scope survive, so "Add an account"/signup after a remove - // resolves the correct org instead of falling back to the default. - expect(o.location).to.include(`requestId=${encodeURIComponent('oidc_V3-current')}`); - expect(o.location).to.include('organization=org-1'); + expect(o.kind, 'with org: redirect').to.equal('redirect'); + expect(o.location, 'with org: requestId survives').to.include(`requestId=${REQ}`); + expect(o.location, 'with org: org scope survives').to.include('organization=org-1'); }); }); }); diff --git a/cypress/component/resources/shared/lifetimes.cy.ts b/cypress/component/resources/shared/lifetimes.cy.ts index eb9e23b179..ccae15c1cb 100644 --- a/cypress/component/resources/shared/lifetimes.cy.ts +++ b/cypress/component/resources/shared/lifetimes.cy.ts @@ -8,18 +8,17 @@ import { isFactorFresh, primaryFresh } from '@/resources/shared/lifetimes'; const T0 = Date.parse('2026-01-01T00:00:00.000Z'); const at = (iso: string | null) => ({ verifiedAt: iso === null ? null : new Date(iso) }); -describe('isFactorFresh', () => { - it('is true inside the window', () => { - expect(isFactorFresh(at('2026-01-01T00:00:00.000Z'), T0 + 500, 1000)).to.equal(true); - }); -}); - -describe('primaryFresh / secondFactorFresh / passwordlessPasskeyFresh', () => { +describe('isFactorFresh / primaryFresh', () => { const fresh = '2026-01-01T00:00:00.000Z'; - it('primaryFresh true if any of password/passkey/idp is fresh', () => { + + it('reports a factor inside its window as fresh, and primaryFresh is true if ANY of password/passkey/idp is fresh', () => { + expect(isFactorFresh(at(fresh), T0 + 500, 1000), 'inside the window').to.equal(true); + const f: Factors = { password: at(fresh) }; - expect(primaryFresh(f, T0 + 100, 1000)).to.equal(true); - expect(primaryFresh({ idpIntent: at(fresh) }, T0 + 100, 1000)).to.equal(true); - expect(primaryFresh({}, T0 + 100, 1000)).to.equal(false); + expect(primaryFresh(f, T0 + 100, 1000), 'password fresh').to.equal(true); + expect(primaryFresh({ idpIntent: at(fresh) }, T0 + 100, 1000), 'idpIntent fresh').to.equal( + true + ); + expect(primaryFresh({}, T0 + 100, 1000), 'no factors at all').to.equal(false); }); }); diff --git a/cypress/component/resources/shared/next-step-params.cy.ts b/cypress/component/resources/shared/next-step-params.cy.ts index 5d1e45d090..4f21338b82 100644 --- a/cypress/component/resources/shared/next-step-params.cy.ts +++ b/cypress/component/resources/shared/next-step-params.cy.ts @@ -32,43 +32,53 @@ describe('nextStepWithParams requestId validation', () => { }); describe('ssoErrorRedirect — threads requestId/organization (regression: dropped on SSO error redirects)', () => { - it('builds the bare reason-only URL when no ceremony context is given', () => { - const url = ssoErrorRedirect('google', 'context-missing'); - expect(url).to.equal('/sso/google/error?reason=context-missing'); - }); - - it('threads requestId and organization onto the error redirect when present', () => { - const url = ssoErrorRedirect('google', 'signin_failed', 'oidc_V2_123', 'org-1'); - expect(url).to.equal( - '/sso/google/error?reason=signin_failed&requestId=oidc_V2_123&organization=org-1' - ); - }); - - it('threads requestId alone (organization omitted) without a stray param', () => { - const url = ssoErrorRedirect('google', 'signin_failed', 'oidc_V2_123'); - expect(url).to.equal('/sso/google/error?reason=signin_failed&requestId=oidc_V2_123'); - }); + const CASES: [label: string, args: Parameters, expected: string][] = [ + [ + 'bare reason-only when no ceremony context', + ['google', 'context-missing'], + '/sso/google/error?reason=context-missing', + ], + [ + 'requestId + organization both threaded', + ['google', 'signin_failed', 'oidc_V2_123', 'org-1'], + '/sso/google/error?reason=signin_failed&requestId=oidc_V2_123&organization=org-1', + ], + [ + 'requestId alone, no stray organization param', + ['google', 'signin_failed', 'oidc_V2_123'], + '/sso/google/error?reason=signin_failed&requestId=oidc_V2_123', + ], + [ + 'slug and reason are URL-encoded', + ['my provider', 'some/reason'], + '/sso/my%20provider/error?reason=some%2Freason', + ], + ]; - it('URL-encodes the slug and reason', () => { - const url = ssoErrorRedirect('my provider', 'some/reason'); - expect(url).to.equal('/sso/my%20provider/error?reason=some%2Freason'); + it('builds the error redirect for every ceremony-context combination, URL-encoding slug and reason', () => { + for (const [label, args, expected] of CASES) { + expect(ssoErrorRedirect(...args), label).to.equal(expected); + } }); }); describe('loginBounceTarget — resource-layer /login guard-fail bounce (mirrors routes/login-bounce.ts)', () => { - it('bounces to a bare /login when no requestId is present', () => { - expect(loginBounceTarget()).to.equal('/login'); - expect(loginBounceTarget(undefined, 'org-1')).to.equal('/login'); // org never leaks alone - }); - - it('threads requestId + organization when a requestId is present', () => { - expect(loginBounceTarget('oidc_V2_123', 'org-1')).to.equal( - '/login?requestId=oidc_V2_123&organization=org-1' - ); - }); + const CASES: [label: string, args: Parameters, expected: string][] = [ + ['no context', [], '/login'], + // An organization must never leak onto the bounce without a requestId to scope it. + ['organization alone never leaks', [undefined, 'org-1'], '/login'], + [ + 'requestId + organization', + ['oidc_V2_123', 'org-1'], + '/login?requestId=oidc_V2_123&organization=org-1', + ], + ['requestId alone', ['oidc_V2_123'], '/login?requestId=oidc_V2_123'], + ]; - it('threads requestId alone when organization is absent', () => { - expect(loginBounceTarget('oidc_V2_123')).to.equal('/login?requestId=oidc_V2_123'); + it('bounces to a bare /login without a requestId and threads requestId (+organization) when present', () => { + for (const [label, args, expected] of CASES) { + expect(loginBounceTarget(...args), label).to.equal(expected); + } }); }); diff --git a/cypress/component/resources/shared/next-step.cy.ts b/cypress/component/resources/shared/next-step.cy.ts index 92e14158ee..34fb211075 100644 --- a/cypress/component/resources/shared/next-step.cy.ts +++ b/cypress/component/resources/shared/next-step.cy.ts @@ -16,13 +16,12 @@ const settings: LoginSettings = { const NOW = Date.parse('2026-01-01T00:00:00Z'); describe('nextStep', () => { - it('routes to /signed-in when password is verified and no MFA is forced', () => { - const factors: Factors = { password: { verifiedAt: new Date('2026-01-01T00:00:00Z') } }; - expect(nextStep({ factors, settings, nowMs: NOW })).to.equal('/signed-in'); - }); - it('routes to /setup/mfa?force=true&checkAfter=true when MFA is forced but no 2nd factor is enrolled', () => { - const factors: Factors = { password: { verifiedAt: new Date('2026-01-01T00:00:00Z') } }; - const result = nextStep({ + const factors: Factors = { password: { verifiedAt: new Date('2026-01-01T00:00:00Z') } }; + + it('routes to /signed-in when MFA is not forced, and to /setup/mfa?force=true when it is', () => { + expect(nextStep({ factors, settings, nowMs: NOW }), 'not forced').to.equal('/signed-in'); + + const forced = nextStep({ factors, settings: { ...settings, forceMfa: true }, nowMs: NOW, @@ -31,8 +30,8 @@ describe('nextStep', () => { userVerified: false, mfaInitSkippedAt: null, }); - expect(result).to.contain('/setup/mfa'); - expect(result).to.contain('force=true'); - expect(result).to.contain('checkAfter=true'); + expect(forced, 'forced: path').to.contain('/setup/mfa'); + expect(forced, 'forced: force flag').to.contain('force=true'); + expect(forced, 'forced: checkAfter flag').to.contain('checkAfter=true'); }); }); diff --git a/cypress/component/resources/shared/org-first-settings-reads.cy.ts b/cypress/component/resources/shared/org-first-settings-reads.cy.ts index 06ba7c429c..41f5472e43 100644 --- a/cypress/component/resources/shared/org-first-settings-reads.cy.ts +++ b/cypress/component/resources/shared/org-first-settings-reads.cy.ts @@ -15,39 +15,37 @@ import { callService } from '../../../support/node/call-service'; const ALICE = 'alice@acme.test'; describe('login/method loader — org-first getLoginSettings + getBranding', () => { - it('NO ?organization → both reads get the resolved default org; provider default consulted once', () => { - callService({ - fn: 'loginMethodLoader', - provider: 'singleton', - request: { - url: `http://localhost/id/login/method?loginName=${ALICE}`, - // The chooser loader is session-gated: it only identifies a user the ceremony has - // already planted a LIVE session for (the identifier step writes it on the same - // response that redirects here). - sessions: [{ id: 's1', token: 'tok-s1', loginName: ALICE }], - }, - recordCalls: ['getDefaultOrg', 'getLoginSettings', 'getBranding'], - }).then((v) => { - expect(v.calls?.getDefaultOrg).to.have.length(1); - expect(v.calls?.getLoginSettings?.[0]?.[0]).to.equal('org-default-fake'); - expect(v.calls?.getBranding?.[0]?.[0]).to.equal('org-default-fake'); - }); - }); + // One contract exercised twice — raw org EMPTY vs PRESENT. Same call, same three + // assertions; only the URL and the expected org / default-lookup count vary. Each row is + // its own cy.task (fresh Bun process), so the recorded-call arrays cannot couple. + const CASES: [label: string, query: string, expectedOrg: string, defaultOrgCalls: number][] = [ + ['NO ?organization', '', 'org-default-fake', 1], + ['explicit ?organization', '&organization=org-explicit', 'org-explicit', 0], + ]; - it('explicit ?organization → both reads get it; provider default never consulted', () => { - callService({ - fn: 'loginMethodLoader', - provider: 'singleton', - request: { - url: `http://localhost/id/login/method?loginName=${ALICE}&organization=org-explicit`, - sessions: [{ id: 's1', token: 'tok-s1', loginName: ALICE }], - }, - recordCalls: ['getDefaultOrg', 'getLoginSettings', 'getBranding'], - }).then((v) => { - expect(v.calls?.getDefaultOrg).to.have.length(0); - expect(v.calls?.getLoginSettings?.[0]?.[0]).to.equal('org-explicit'); - expect(v.calls?.getBranding?.[0]?.[0]).to.equal('org-explicit'); - }); + it('resolves the org org-first for both reads: an explicit ?organization wins and skips the provider default, an empty one falls back to it', () => { + for (const [label, query, expectedOrg, defaultOrgCalls] of CASES) { + callService({ + fn: 'loginMethodLoader', + provider: 'singleton', + request: { + url: `http://localhost/id/login/method?loginName=${ALICE}${query}`, + // The chooser loader is session-gated: it only identifies a user the ceremony has + // already planted a LIVE session for (the identifier step writes it on the same + // response that redirects here). + sessions: [{ id: 's1', token: 'tok-s1', loginName: ALICE }], + }, + recordCalls: ['getDefaultOrg', 'getLoginSettings', 'getBranding'], + }).then((v) => { + expect(v.calls?.getDefaultOrg, `${label}: getDefaultOrg lookups`).to.have.length( + defaultOrgCalls + ); + expect(v.calls?.getLoginSettings?.[0]?.[0], `${label}: getLoginSettings org`).to.equal( + expectedOrg + ); + expect(v.calls?.getBranding?.[0]?.[0], `${label}: getBranding org`).to.equal(expectedOrg); + }); + } }); }); diff --git a/cypress/component/resources/signup/placeholder-name.cy.ts b/cypress/component/resources/signup/placeholder-name.cy.ts index 00ebab3de5..7dbdbc7e71 100644 --- a/cypress/component/resources/signup/placeholder-name.cy.ts +++ b/cypress/component/resources/signup/placeholder-name.cy.ts @@ -9,35 +9,38 @@ // empty names, so non-empty is a hard requirement, not a nicety. import { placeholderNameFromEmail } from '@/resources/signup/placeholder-name'; -const expectPlaceholder = (email: string, expected: string) => { +const expectPlaceholder = (email: string, expected: string, label: string) => { const result = placeholderNameFromEmail(email); - expect(result).to.deep.equal({ firstName: expected, lastName: expected }); + expect(result, label).to.deep.equal({ firstName: expected, lastName: expected }); // The invariant the annotation trigger depends on, asserted explicitly on every case. - expect(result.firstName).to.equal(result.lastName); - expect(result.firstName.length).to.be.greaterThan(0); + expect(result.firstName, `${label}: both fields match`).to.equal(result.lastName); + expect(result.firstName.length, `${label}: non-empty`).to.be.greaterThan(0); }; -describe('placeholderNameFromEmail', () => { - it('returns the raw local part in BOTH fields — never splits or title-cases into fake name parts', () => { - // The motivating bug: ordering is undecidable (this user's given name is Ollie). - expectPlaceholder('miller_ollie@hotmail.com', 'miller_ollie'); - expectPlaceholder('john@x.com', 'john'); - expectPlaceholder('first.middle.last@x.com', 'first.middle.last'); - expectPlaceholder('a-b_c@x.com', 'a-b_c'); - }); - - it('strips +tags and trims before deriving the placeholder', () => { - expectPlaceholder('user+tag@x.com', 'user'); - expectPlaceholder('miller_ollie+newsletter@hotmail.com', 'miller_ollie'); - }); +// Every case was already a call to the same helper, split across four `it()`s purely by +// theme — collapsed into one table, with the theme kept as each row's label. +const CASES: [label: string, email: string, expected: string][] = [ + // Raw local part in BOTH fields — never split or title-cased into fake name parts. + // The motivating bug: ordering is undecidable (this user's given name is Ollie). + ['raw local part', 'miller_ollie@hotmail.com', 'miller_ollie'], + ['raw local part', 'john@x.com', 'john'], + ['raw local part with dots', 'first.middle.last@x.com', 'first.middle.last'], + ['raw local part with dashes/underscores', 'a-b_c@x.com', 'a-b_c'], + // +tags stripped and whitespace trimmed before deriving the placeholder. + ['strips +tag', 'user+tag@x.com', 'user'], + ['strips +tag', 'miller_ollie+newsletter@hotmail.com', 'miller_ollie'], + // Degenerate local parts fall back to 'user' (Zitadel rejects empty names). + ['degenerate: no local part', '@x.com', 'user'], + ['degenerate: tag only', '+tag@x.com', 'user'], + ['degenerate: whitespace only', ' @x.com', 'user'], + // Zitadel name-length ceiling. + ['caps at 200 characters', `${'a'.repeat(250)}@x.com`, 'a'.repeat(200)], +]; - it("falls back to 'user' when the local part is degenerate", () => { - expectPlaceholder('@x.com', 'user'); - expectPlaceholder('+tag@x.com', 'user'); - expectPlaceholder(' @x.com', 'user'); - }); - - it('caps the placeholder at 200 characters (Zitadel name-length ceiling)', () => { - expectPlaceholder(`${'a'.repeat(250)}@x.com`, 'a'.repeat(200)); +describe('placeholderNameFromEmail', () => { + it('duplicates the sanitized local part into both name fields, capped at 200 characters', () => { + for (const [label, email, expected] of CASES) { + expectPlaceholder(email, expected, `${label} (${email})`); + } }); }); diff --git a/cypress/component/resources/signup/signup-decision.cy.ts b/cypress/component/resources/signup/signup-decision.cy.ts index b3158e43d7..fd1961c8f9 100644 --- a/cypress/component/resources/signup/signup-decision.cy.ts +++ b/cypress/component/resources/signup/signup-decision.cy.ts @@ -7,23 +7,23 @@ import { decideSignupIdpIntent, } from '@/resources/signup/signup-decision'; -describe('decideSignupIdpIntent', () => { - it('redirects to the provider authUrl on success and surfaces the error code on failure', () => { +describe('decideSignupIdpIntent / decideAfterSignupIdentifier', () => { + it('resolves the IdP-intent outcome and the post-identifier signup route', () => { expect( - decideSignupIdpIntent({ ok: true, authUrl: 'https://idp.example/start?x=1' }) + decideSignupIdpIntent({ ok: true, authUrl: 'https://idp.example/start?x=1' }), + 'idp intent: success' ).to.deep.equal({ kind: 'redirect', path: 'https://idp.example/start?x=1', }); - expect(decideSignupIdpIntent({ ok: false, error: 'IDP_START_FAILED' })).to.deep.equal({ + expect( + decideSignupIdpIntent({ ok: false, error: 'IDP_START_FAILED' }), + 'idp intent: failure' + ).to.deep.equal({ kind: 'error', error: 'IDP_START_FAILED', }); - }); -}); -describe('decideAfterSignupIdentifier', () => { - it('routes to /signup/method with the duplicated placeholder name, threads optional org/requestId, and omits absent keys', () => { const withContext = decideAfterSignupIdentifier({ email: 'alice.smith@example.com', organization: 'acme', diff --git a/cypress/component/resources/signup/signup-view.cy.ts b/cypress/component/resources/signup/signup-view.cy.ts index 2671f1b04f..d968ce5c1f 100644 --- a/cypress/component/resources/signup/signup-view.cy.ts +++ b/cypress/component/resources/signup/signup-view.cy.ts @@ -2,6 +2,19 @@ // // Component (no-mount) port of app/resources/signup/__tests__/signup-view.test.ts. // Pure view resolver → browser-side Chai only. +// +// Consolidated from 16 one-assertion tests into two tables. Only 11 distinct +// resolveSignupView(settings, idps, emailDeliveryEnabled, requireEmailVerification) input +// tuples existed across those 16; three tests were exact duplicates of another test's call +// whose deep.equal already pinned the single field they checked, and were dropped: +// • 'keeps allowEmailEntry when Zitadel policy allows email AND delivery is on' +// — byte-identical call + assertion to the passwordless-org row below. +// • 'signupUnavailable=false ... an IdP is present' — same call as the delivery-off row, +// whose deep.equal already pins signupUnavailable: false. +// • 'showPasskey=true with delivery' — same call as the passwordless-org row, whose +// deep.equal already pins showPasskey: true. +// Every other assertion is preserved, and each row carries a label so a failure names the +// exact policy combination that broke. import type { IdProvider, LoginSettings } from '@/modules/auth/types'; import { resolveSignupView } from '@/resources/signup/signup-view'; @@ -14,21 +27,26 @@ const base = { } as unknown as LoginSettings; const idps = [{ id: 'idp-g', name: 'Google', type: 'GOOGLE' }] as unknown as IdProvider[]; -describe('resolveSignupView', () => { - it('shows IdP buttons + email link + passkey, no password (passwordless org)', () => { - expect(resolveSignupView(base, idps, true, true)).to.deep.equal({ - showIdpButtons: true, - allowEmailEntry: true, - showEmailLink: true, - showPasskey: true, - showPassword: false, - signupUnavailable: false, - }); - }); +type View = ReturnType; +type Args = [LoginSettings, IdProvider[], boolean, boolean]; - it('reflects allowPassword, missing IdPs, disabled passkeys, disabled registration, and disabled email delivery together', () => { - expect( - resolveSignupView( +describe('resolveSignupView', () => { + const FULL: [string, Args, View][] = [ + [ + 'passwordless org: IdP buttons + email link + passkey, no password', + [base, idps, true, true], + { + showIdpButtons: true, + allowEmailEntry: true, + showEmailLink: true, + showPasskey: true, + showPassword: false, + signupUnavailable: false, + }, + ], + [ + 'allowPassword on, no IdPs, passkeys off, registration off, delivery off', + [ { ...base, allowPassword: true, @@ -37,133 +55,116 @@ describe('resolveSignupView', () => { } as LoginSettings, [], false, - true - ) - ).to.deep.equal({ - showIdpButtons: false, - allowEmailEntry: false, - showEmailLink: false, - showPasskey: false, - showPassword: true, - signupUnavailable: true, - }); - }); - - it('hides email entry and link when emailDeliveryEnabled=false, regardless of Zitadel policy', () => { - // RED→GREEN: before the fix allowEmailEntry was true when delivery was off. - // With delivery off the whole email path must hide so signup is IdP-only - // (when no password option or verification is required — passkeys also need delivery). - expect(resolveSignupView(base, idps, false, true)).to.deep.equal({ - showIdpButtons: true, - allowEmailEntry: false, - showEmailLink: false, - // passkey hides without delivery (passkey signup also sends a verification email) - showPasskey: false, - showPassword: false, - signupUnavailable: false, - }); - }); - - it('keeps allowEmailEntry when Zitadel policy allows email AND delivery is on', () => { - expect(resolveSignupView(base, idps, true, true)).to.deep.equal({ - showIdpButtons: true, - allowEmailEntry: true, - showEmailLink: true, - showPasskey: true, - showPassword: false, - signupUnavailable: false, - }); - }); - - it('hides email entry when Zitadel disableLoginWithEmail=true even if delivery is on', () => { - expect( - resolveSignupView({ ...base, disableLoginWithEmail: true } as LoginSettings, idps, true, true) - ).to.deep.equal({ - showIdpButtons: true, - allowEmailEntry: false, - showEmailLink: false, - showPasskey: true, - showPassword: false, - signupUnavailable: false, - }); - }); - - // --- signupUnavailable edge-case coverage --- - - it('signupUnavailable=true when allowRegister=false (policy disabled)', () => { - const result = resolveSignupView( - { ...base, allowRegister: false } as LoginSettings, - idps, - true, - true - ); - expect(result.signupUnavailable).to.equal(true); - }); - - it('signupUnavailable=true when allowRegister=true but no IdPs and email delivery off (blank index)', () => { - // Registration allowed by policy but no usable entry method on the index screen. - // base has allowPassword:false, requireEmailVerification:true → password path would - // strand on check-your-email even if shown, so allowEmailEntry stays false here. - const result = resolveSignupView(base, [], false, true); - expect(result.signupUnavailable).to.equal(true); - }); - - it('signupUnavailable=false when allowRegister=true and an IdP is present (even if email delivery off)', () => { - const result = resolveSignupView(base, idps, false, true); - expect(result.signupUnavailable).to.equal(false); - }); - - it('signupUnavailable=false when allowRegister=true and email entry is available (even if no IdPs)', () => { - const result = resolveSignupView(base, [], true, true); - expect(result.signupUnavailable).to.equal(false); - }); - - // ── Task 2: no-delivery password signup — allowEmailEntry without delivery ────────────────── - - it('allowEmailEntry=true without delivery when allowPassword=true AND requireEmailVerification=false', () => { - // RED: before the fix this returned false because delivery was off. - // GREEN: password signup can skip verification, so email entry is safe to show. - const settings = { ...base, allowPassword: true } as LoginSettings; - const result = resolveSignupView(settings, [], false, false); - expect(result.allowEmailEntry).to.equal(true); - }); - - it('allowEmailEntry=false without delivery when requireEmailVerification=true (would strand on check-your-email)', () => { - const settings = { ...base, allowPassword: true } as LoginSettings; - const result = resolveSignupView(settings, [], false, true); - expect(result.allowEmailEntry).to.equal(false); - }); - - it('allowEmailEntry=false without delivery when allowPassword=false (no non-delivery-gated method)', () => { - // base has allowPassword:false — even with requireEmailVerification=false, no method - // can complete without delivery, so email entry must stay hidden. - const result = resolveSignupView(base, [], false, false); - expect(result.allowEmailEntry).to.equal(false); - }); - - it('showEmailLink=false without delivery even when allowEmailEntry=true (link needs delivery)', () => { - const settings = { ...base, allowPassword: true } as LoginSettings; - const result = resolveSignupView(settings, [], false, false); - expect(result.showEmailLink).to.equal(false); - }); - - it('showPasskey=false without delivery even when passkeysType=allowed (passkey signup sends verification email)', () => { - // RED: before the fix passkey was shown whenever passkeysType==='allowed'. - // GREEN: passkey signup also triggers an email verification step, so it requires delivery. - const settings = { ...base, passkeysType: 'allowed' } as LoginSettings; - const result = resolveSignupView(settings, [], false, false); - expect(result.showPasskey).to.equal(false); - }); - - it('showPasskey=true with delivery when passkeysType=allowed', () => { - const result = resolveSignupView(base, idps, true, true); - expect(result.showPasskey).to.equal(true); - }); - - it('signupUnavailable=false when no-delivery + allowPassword=true + requireEmailVerification=false (password path is viable)', () => { - // allowEmailEntry is true in this case → signup is not unavailable - const settings = { ...base, allowPassword: true, allowExternalIdp: false } as LoginSettings; - const result = resolveSignupView(settings, [], false, false); - expect(result.signupUnavailable).to.equal(false); + true, + ], + { + showIdpButtons: false, + allowEmailEntry: false, + showEmailLink: false, + showPasskey: false, + showPassword: true, + signupUnavailable: true, + }, + ], + [ + // RED→GREEN: before the fix allowEmailEntry was true when delivery was off. With + // delivery off the whole email path must hide so signup is IdP-only (no password + // option and verification required — passkeys also need delivery). + 'delivery off hides the entire email path regardless of Zitadel policy', + [base, idps, false, true], + { + showIdpButtons: true, + allowEmailEntry: false, + showEmailLink: false, + showPasskey: false, + showPassword: false, + signupUnavailable: false, + }, + ], + [ + 'disableLoginWithEmail hides email entry even with delivery on', + [{ ...base, disableLoginWithEmail: true } as LoginSettings, idps, true, true], + { + showIdpButtons: true, + allowEmailEntry: false, + showEmailLink: false, + showPasskey: true, + showPassword: false, + signupUnavailable: false, + }, + ], + ]; + + // FULL and PARTIAL run in one test: both drive the same pure resolver, and a failure names + // its own row, so splitting them bought no diagnostic value. + + const PARTIAL: [string, Args, Partial][] = [ + // ── signupUnavailable edge cases ────────────────────────────────────────────────── + [ + 'allowRegister=false (policy disabled)', + [{ ...base, allowRegister: false } as LoginSettings, idps, true, true], + { signupUnavailable: true }, + ], + [ + // Registration allowed by policy but no usable entry method on the index screen. + // base has allowPassword:false + verification required → the password path would + // strand on check-your-email even if shown, so allowEmailEntry stays false here. + 'allowRegister=true but no IdPs and delivery off (blank index)', + [base, [], false, true], + { signupUnavailable: true }, + ], + [ + 'email entry available even with no IdPs', + [base, [], true, true], + { signupUnavailable: false }, + ], + // ── no-delivery password signup: allowEmailEntry without delivery ───────────────── + [ + // RED: before the fix this returned false because delivery was off. + // GREEN: password signup can skip verification, so email entry is safe to show — + // but the emailed LINK still needs delivery and must stay hidden. + 'allowPassword=true + no verification required, delivery off', + [{ ...base, allowPassword: true } as LoginSettings, [], false, false], + { allowEmailEntry: true, showEmailLink: false }, + ], + [ + 'allowPassword=true + verification required, delivery off (would strand on check-your-email)', + [{ ...base, allowPassword: true } as LoginSettings, [], false, true], + { allowEmailEntry: false }, + ], + [ + // base has allowPassword:false and passkeysType:'allowed' — even with verification + // not required, no method can complete without delivery, so email entry stays + // hidden. RED→GREEN for passkey: before the fix it was shown whenever + // passkeysType==='allowed', ignoring that passkey signup also sends a verification + // email and therefore requires delivery. + 'no password, delivery off (no non-delivery-gated method)', + [base, [], false, false], + { allowEmailEntry: false, showPasskey: false }, + ], + [ + // allowEmailEntry is true in this case → signup is not unavailable. + 'password path viable with IdP off, delivery off', + [ + { ...base, allowPassword: true, allowExternalIdp: false } as LoginSettings, + [], + false, + false, + ], + { signupUnavailable: false }, + ], + ]; + + it('resolves the full view shape and individual flags across the policy/delivery matrix', () => { + for (const [label, args, expected] of FULL) { + expect(resolveSignupView(...args), label).to.deep.equal(expected); + } + + for (const [label, args, expected] of PARTIAL) { + const result = resolveSignupView(...args); + for (const [field, value] of Object.entries(expected)) { + expect(result[field as keyof View], `${label} → ${field}`).to.equal(value); + } + } }); }); diff --git a/cypress/component/resources/signup/signup.schema.cy.ts b/cypress/component/resources/signup/signup.schema.cy.ts index b497c47c49..620f766bf9 100644 --- a/cypress/component/resources/signup/signup.schema.cy.ts +++ b/cypress/component/resources/signup/signup.schema.cy.ts @@ -4,40 +4,32 @@ // Zod registration + password schemas → browser-side Chai only. import { registerSchema, signupPasswordSchema } from '@/resources/signup/signup.schema'; -describe('registerSchema', () => { - it('accepts a valid registration and rejects an invalid email', () => { - expect( - registerSchema.safeParse({ - email: 'alice@acme.test', - firstName: 'Alice', - lastName: 'Acme', - }).success - ).to.equal(true); - expect( - registerSchema.safeParse({ - email: 'not-an-email', - firstName: 'Alice', - lastName: 'Acme', - }).success - ).to.equal(false); - }); -}); +describe('registerSchema / signupPasswordSchema', () => { + it('accepts a valid registration, rejects a bad email, and requires matching passwords', () => { + for (const [label, email, accepted] of [ + ['valid email', 'alice@acme.test', true], + ['invalid email', 'not-an-email', false], + ] as const) { + expect( + registerSchema.safeParse({ email, firstName: 'Alice', lastName: 'Acme' }).success, + label + ).to.equal(accepted); + } -describe('signupPasswordSchema', () => { - it('rejects mismatched passwords with a clear message and accepts matching ones', () => { const mismatch = signupPasswordSchema.safeParse({ password: 'correct-horse', confirm: 'wrong-horse', }); - expect(mismatch.success).to.equal(false); + expect(mismatch.success, 'mismatched passwords rejected').to.equal(false); if (!mismatch.success) { const confirmError = mismatch.error.issues.find((i) => i.path.includes('confirm')); - expect(confirmError?.message).to.equal('Passwords must match'); + expect(confirmError?.message, 'error on the confirm path').to.equal('Passwords must match'); } expect( signupPasswordSchema.safeParse({ password: 'correct-horse', confirm: 'correct-horse' }) - .success + .success, + 'matching passwords accepted' ).to.equal(true); }); }); diff --git a/cypress/component/resources/signup/signup.service.cy.ts b/cypress/component/resources/signup/signup.service.cy.ts index aec9f20027..bb17473950 100644 --- a/cypress/component/resources/signup/signup.service.cy.ts +++ b/cypress/component/resources/signup/signup.service.cy.ts @@ -30,14 +30,14 @@ function run(s: Scenario): Cypress.Chainable { // RED: orgId === undefined. GREEN: orgId === 'org-default-fake'. // ───────────────────────────────────────────────────────────────────────────── -describe('registerAndLinkIdp — default-org resolution on bare flow (no organization)', () => { - it('calls register with the resolved default org (not undefined) when organization is omitted', () => { - run({ - fn: 'registerAndLinkIdp', - request: { url: `${BASE_URL}/signup` }, - provider: 'singleton', - // No organization in signupInput → bare flow - signupInput: { +describe('register paths — default-org resolution on bare flow (no organization)', () => { + // Four byte-identical assertions that differed only by `fn` and the signupInput payload. + // Kept as a labeled table so a failure still names which register path regressed. + const BARE_FLOW: [fn: string, path: string, signupInput: Record][] = [ + [ + 'registerAndLinkIdp', + '/signup', + { email: 'idp-bare@test.com', firstName: 'Idp', lastName: 'Bare', @@ -47,97 +47,59 @@ describe('registerAndLinkIdp — default-org resolution on bare flow (no organiz idpUserId: 'g-bare', idpUserName: 'idp-bare@test.com', }, - recordCalls: ['register'], - }).then((verdict) => { - expect(verdict.ok, verdict.error ?? '').to.be.true; - const registerCalls = (verdict.calls?.['register'] ?? []) as Array<[Record]>; - expect(registerCalls.length, 'register was called').to.be.greaterThan(0); - const registerInput = registerCalls[0][0]; - expect(registerInput.orgId, 'orgId must be the resolved default org, not undefined').to.equal( - 'org-default-fake' - ); - }); - }); -}); - -describe('registerPasskeyFirst — default-org resolution on bare flow (no organization)', () => { - it('calls register with the resolved default org (not undefined) when organization is omitted', () => { - run({ - fn: 'registerPasskeyFirst', - request: { url: `${BASE_URL}/signup` }, - provider: 'singleton', - signupInput: { + ], + [ + 'registerPasskeyFirst', + '/signup', + { email: 'passkey-bare@test.com', firstName: 'Passkey', lastName: 'Bare', requireVerification: false, origin: ORIGIN, - // No organization → bare flow }, - recordCalls: ['register'], - }).then((verdict) => { - expect(verdict.ok, verdict.error ?? '').to.be.true; - const registerCalls = (verdict.calls?.['register'] ?? []) as Array<[Record]>; - expect(registerCalls.length, 'register was called').to.be.greaterThan(0); - const registerInput = registerCalls[0][0]; - expect(registerInput.orgId, 'orgId must be the resolved default org, not undefined').to.equal( - 'org-default-fake' - ); - }); - }); -}); - -describe('registerWithPassword — default-org resolution on bare flow (no organization)', () => { - it('calls register with the resolved default org (not undefined) when organization is omitted', () => { - run({ - fn: 'registerWithPassword', - request: { url: `${BASE_URL}/signup/password` }, - provider: 'singleton', - signupInput: { + ], + [ + 'registerWithPassword', + '/signup/password', + { email: 'pw-bare@test.com', firstName: 'Pw', lastName: 'Bare', password: 'hunter2hunter2', requireVerification: false, origin: ORIGIN, - // No organization → bare flow }, - recordCalls: ['register'], - }).then((verdict) => { - expect(verdict.ok, verdict.error ?? '').to.be.true; - const registerCalls = (verdict.calls?.['register'] ?? []) as Array<[Record]>; - expect(registerCalls.length, 'register was called').to.be.greaterThan(0); - const registerInput = registerCalls[0][0]; - expect(registerInput.orgId, 'orgId must be the resolved default org, not undefined').to.equal( - 'org-default-fake' - ); - }); - }); -}); + ], + [ + 'registerEmailLinkSignup', + '/signup', + { email: 'emaillink-bare@test.com', firstName: 'Email', lastName: 'Bare', origin: ORIGIN }, + ], + ]; -describe('registerEmailLinkSignup — default-org resolution on bare flow (no organization)', () => { - it('calls register with the resolved default org (not undefined) when organization is omitted', () => { - run({ - fn: 'registerEmailLinkSignup', - request: { url: `${BASE_URL}/signup` }, - provider: 'singleton', - signupInput: { - email: 'emaillink-bare@test.com', - firstName: 'Email', - lastName: 'Bare', - origin: ORIGIN, - // No organization → bare flow - }, - recordCalls: ['register'], - }).then((verdict) => { - expect(verdict.ok, verdict.error ?? '').to.be.true; - const registerCalls = (verdict.calls?.['register'] ?? []) as Array<[Record]>; - expect(registerCalls.length, 'register was called').to.be.greaterThan(0); - const registerInput = registerCalls[0][0]; - expect(registerInput.orgId, 'orgId must be the resolved default org, not undefined').to.equal( - 'org-default-fake' - ); - }); + it('every register path calls register with the resolved default org (not undefined) when organization is omitted', () => { + for (const [fn, path, signupInput] of BARE_FLOW) { + run({ + fn, + request: { url: `${BASE_URL}${path}` }, + provider: 'singleton', + // No organization in signupInput → bare flow + signupInput, + recordCalls: ['register'], + } as Scenario).then((verdict) => { + expect(verdict.ok, `${fn}: ${verdict.error ?? ''}`).to.be.true; + const registerCalls = (verdict.calls?.['register'] ?? []) as Array< + [Record] + >; + expect(registerCalls.length, `${fn}: register was called`).to.be.greaterThan(0); + const registerInput = registerCalls[0][0]; + expect( + registerInput.orgId, + `${fn}: orgId must be the resolved default org, not undefined` + ).to.equal('org-default-fake'); + }); + } }); }); @@ -195,7 +157,10 @@ describe('registerEmailLinkSignup', () => { // ───────────────────────────────────────────────────────────────────────────── describe('registerWithPassword — verification skip (requireVerification=false)', () => { - it('calls register with emailVerified:true and NO verifyUrlTemplate when requireVerification=false', () => { + // The two register-arg cases stay written out rather than table-driven: they assert + // DIFFERENT fields with different matchers (a present non-empty string vs. undefined), + // so a shared row shape would either lose a matcher or obscure which field failed. + it('sets emailVerified without verifyUrlTemplate when verification is off, and the inverse when on', () => { // RED: before the fix, register was always called with verifyUrlTemplate and no emailVerified. // GREEN: when requireVerification=false, emailVerified:true + no verifyUrlTemplate. run({ @@ -215,37 +180,14 @@ describe('registerWithPassword — verification skip (requireVerification=false) }).then((verdict) => { expect(verdict.ok, verdict.error ?? '').to.be.true; const registerCalls = (verdict.calls?.['register'] ?? []) as Array<[Record]>; - expect(registerCalls.length, 'register was called once').to.equal(1); + expect(registerCalls.length, 'no-verify: register was called once').to.equal(1); const arg = registerCalls[0][0]; // Must be pre-verified — Zitadel marks email verified, sends nothing. - expect(arg.emailVerified, 'emailVerified must be true').to.equal(true); + expect(arg.emailVerified, 'no-verify: emailVerified must be true').to.equal(true); // Must NOT include verifyUrlTemplate — that would trigger Zitadel's sendCode path. - expect(arg.verifyUrlTemplate, 'verifyUrlTemplate must be absent').to.be.undefined; + expect(arg.verifyUrlTemplate, 'no-verify: verifyUrlTemplate must be absent').to.be.undefined; }); - }); - it('result is a redirect (not sent-with-session) when requireVerification=false', () => { - run({ - fn: 'registerWithPassword', - request: { url: `${BASE_URL}/signup/password` }, - provider: 'singleton', - signupInput: { - email: 'noverify2@test.com', - firstName: 'No', - lastName: 'Verify', - password: 'hunter2hunter2', - requireVerification: false, - origin: ORIGIN, - }, - }).then((verdict) => { - expect(verdict.ok, verdict.error ?? '').to.be.true; - const r = verdict.outcome as Record; - // No-verification path must redirect, never stall on "check your email". - expect(r.kind).to.equal('redirect'); - }); - }); - - it('calls register with verifyUrlTemplate and NO emailVerified when requireVerification=true (existing path unchanged)', () => { run({ fn: 'registerWithPassword', request: { url: `${BASE_URL}/signup/password` }, @@ -263,33 +205,45 @@ describe('registerWithPassword — verification skip (requireVerification=false) }).then((verdict) => { expect(verdict.ok, verdict.error ?? '').to.be.true; const registerCalls = (verdict.calls?.['register'] ?? []) as Array<[Record]>; - expect(registerCalls.length, 'register was called once').to.equal(1); + expect(registerCalls.length, 'with-verify: register was called once').to.equal(1); const arg = registerCalls[0][0]; // Verification ON: verifyUrlTemplate present, emailVerified absent/falsy. - expect(arg.verifyUrlTemplate, 'verifyUrlTemplate must be present').to.be.a('string').and.not - .be.empty; - expect(arg.emailVerified, 'emailVerified must be absent').to.be.undefined; + expect(arg.verifyUrlTemplate, 'with-verify: verifyUrlTemplate must be present').to.be.a( + 'string' + ).and.not.be.empty; + expect(arg.emailVerified, 'with-verify: emailVerified must be absent').to.be.undefined; }); }); - it('result is sent-with-session when requireVerification=true (existing path unchanged)', () => { - run({ - fn: 'registerWithPassword', - request: { url: `${BASE_URL}/signup/password` }, - provider: 'singleton', - signupInput: { - email: 'withverify2@test.com', - firstName: 'With', - lastName: 'Verify', - password: 'hunter2hunter2', - requireVerification: true, - origin: ORIGIN, - }, - }).then((verdict) => { - expect(verdict.ok, verdict.error ?? '').to.be.true; - const r = verdict.outcome as Record; - expect(r.kind).to.equal('sent-with-session'); - }); + // Kept as its own `it()` rather than a helper invoked from the test above: assertions that + // live in a plain function are only as alive as their call site, and orphaning that one + // call would silently retire them — no failing test, no change in reported test count. + const OUTCOMES: [requireVerification: boolean, expectedKind: string][] = [ + // No-verification path must redirect, never stall on "check your email". + [false, 'redirect'], + [true, 'sent-with-session'], + ]; + + it('resolves to a redirect when requireVerification=false and to sent-with-session when it is true', () => { + for (const [requireVerification, expectedKind] of OUTCOMES) { + run({ + fn: 'registerWithPassword', + request: { url: `${BASE_URL}/signup/password` }, + provider: 'singleton', + signupInput: { + email: `outcome-${expectedKind}@test.com`, + firstName: 'Outcome', + lastName: 'Verify', + password: 'hunter2hunter2', + requireVerification, + origin: ORIGIN, + }, + }).then((verdict) => { + expect(verdict.ok, verdict.error ?? '').to.be.true; + const r = verdict.outcome as Record; + expect(r.kind, `requireVerification=${requireVerification}`).to.equal(expectedKind); + }); + } }); }); diff --git a/cypress/component/resources/sso/default-org-fallback.cy.ts b/cypress/component/resources/sso/default-org-fallback.cy.ts index 2c412544c0..2f0060271b 100644 --- a/cypress/component/resources/sso/default-org-fallback.cy.ts +++ b/cypress/component/resources/sso/default-org-fallback.cy.ts @@ -13,31 +13,40 @@ import { callService } from '../../../support/node/call-service'; const RECORD = ['getDefaultOrg', 'getActiveIdPs'] as const; describe('SSO IdP-display flows — org-first / default-org fallback', () => { - describe('idp-providers wrapper (the choke point)', () => { - it('with NO org, threads the resolved default org into getActiveIdPs (not undefined)', () => { - callService({ - fn: 'activeIdPsProbe', - provider: 'singleton', - request: { url: 'http://localhost/id/sso' }, - recordCalls: [...RECORD], - }).then((v) => { - expect(v.calls?.getDefaultOrg).to.have.length(1); - expect(v.calls?.getActiveIdPs?.[0]?.[0]).to.equal('org-default-fake'); + // Both flows assert the identical two-call shape, differing only by which entry point + // drives the choke point. Chained in one test; each callService spawns a fresh Bun + // process, so the two remain independent. + it('resolves the default org for the active-IdP lookup on both entry points', () => { + callService({ + fn: 'activeIdPsProbe', + provider: 'singleton', + request: { url: 'http://localhost/id/sso' }, + recordCalls: [...RECORD], + }) + .then((v) => { + expect(v.calls?.getDefaultOrg, 'activeIdPsProbe: getDefaultOrg called once').to.have.length( + 1 + ); + expect( + v.calls?.getActiveIdPs?.[0]?.[0], + 'activeIdPsProbe: resolved org threaded through' + ).to.equal('org-default-fake'); + return callService({ + fn: 'runSsoAction', + provider: 'singleton', + request: { + url: 'http://localhost/id/sso', + form: { intent: 'start', provider: 'google' }, + }, + recordCalls: [...RECORD], + }); + }) + .then((v) => { + expect(v.calls?.getDefaultOrg, 'runSsoAction: getDefaultOrg called once').to.have.length(1); + expect( + v.calls?.getActiveIdPs?.[0]?.[0], + 'runSsoAction: resolved org threaded through' + ).to.equal('org-default-fake'); }); - }); - }); - - describe('runSsoAction (start intent)', () => { - it('with NO organization, resolves the default org for the active-IdP lookup', () => { - callService({ - fn: 'runSsoAction', - provider: 'singleton', - request: { url: 'http://localhost/id/sso', form: { intent: 'start', provider: 'google' } }, - recordCalls: [...RECORD], - }).then((v) => { - expect(v.calls?.getDefaultOrg).to.have.length(1); - expect(v.calls?.getActiveIdPs?.[0]?.[0]).to.equal('org-default-fake'); - }); - }); }); }); diff --git a/cypress/component/resources/sso/derive-idp-name.cy.ts b/cypress/component/resources/sso/derive-idp-name.cy.ts index 5bc81690bd..85158c20d5 100644 --- a/cypress/component/resources/sso/derive-idp-name.cy.ts +++ b/cypress/component/resources/sso/derive-idp-name.cy.ts @@ -4,30 +4,37 @@ // deriveIdpProfileName is pure (string fallback chain) → runs browser-side with Chai. import { deriveIdpProfileName } from '@/resources/sso/derive-idp-name'; -describe('deriveIdpProfileName', () => { - it('passes through given/family names unchanged when both are present', () => { - expect( - deriveIdpProfileName({ - firstName: 'Ada', - lastName: 'Lovelace', - displayName: 'Should Be Ignored', - idpUserName: 'ada', - }) - ).to.deep.equal({ firstName: 'Ada', lastName: 'Lovelace' }); - }); +type Draft = Parameters[0]; - it('falls back to idpUserName for BOTH names when the draft has no name at all (GitHub)', () => { - expect(deriveIdpProfileName({ idpUserName: 'anindia0703' })).to.deep.equal({ - firstName: 'anindia0703', - lastName: 'anindia0703', - }); - }); +const CASES: [label: string, draft: Draft, expected: { firstName: string; lastName: string }][] = [ + [ + 'both names present — displayName/idpUserName ignored', + { + firstName: 'Ada', + lastName: 'Lovelace', + displayName: 'Should Be Ignored', + idpUserName: 'ada', + }, + { firstName: 'Ada', lastName: 'Lovelace' }, + ], + [ + // GitHub sends no name at all — idpUserName fills BOTH fields. + 'no name at all → idpUserName in both fields', + { idpUserName: 'anindia0703' }, + { firstName: 'anindia0703', lastName: 'anindia0703' }, + ], + ['empty draft → "user"', {}, { firstName: 'user', lastName: 'user' }], + [ + 'whitespace-only idpUserName → "user"', + { idpUserName: ' ' }, + { firstName: 'user', lastName: 'user' }, + ], +]; - it('falls back to "user" when no name and no idpUserName are available', () => { - expect(deriveIdpProfileName({})).to.deep.equal({ firstName: 'user', lastName: 'user' }); - expect(deriveIdpProfileName({ idpUserName: ' ' })).to.deep.equal({ - firstName: 'user', - lastName: 'user', - }); +describe('deriveIdpProfileName', () => { + it('passes through given/family names when present, falls back to idpUserName for BOTH names, then to "user"', () => { + for (const [label, draft, expected] of CASES) { + expect(deriveIdpProfileName(draft), label).to.deep.equal(expected); + } }); }); diff --git a/cypress/component/resources/sso/idp-callback.cy.ts b/cypress/component/resources/sso/idp-callback.cy.ts index aa3adb7d33..2f39136791 100644 --- a/cypress/component/resources/sso/idp-callback.cy.ts +++ b/cypress/component/resources/sso/idp-callback.cy.ts @@ -32,37 +32,55 @@ const base = { const linkBase = { allowAutoLink: false, allowLinkAnyEmail: false } as const; const LINK = { idpId: 'idp-g', idpUserId: 'g-1', idpUserName: 'you@gmail.com' }; -describe('decideIdpCallback — existing-account handling', () => { - it('auto-links when email is IdP-verified and the account has no password', () => { - const d = decideIdpCallback({ +// The four plain routing decisions on the link=false path. Only these are table-driven — +// every ownership / POSTURE-B2 guard below stays standalone so an identity-takeover +// regression fails in isolation and is immediately diagnosable. +// +// `full` means the whole decision object is pinned; `kind` means only the discriminant was +// ever asserted (the auto-create case), preserved exactly rather than tightened. +const ROUTING: Array<{ + label: string; + input: Parameters[0]; + full?: ReturnType; + kind?: string; +}> = [ + { + label: 'email IdP-verified, existing account has no password → auto-link', + input: { ...base, intent: intentOf({ emailVerified: true }), existingAccount: { userId: 'u1', hasPassword: false }, - }); - expect(d).to.deep.equal({ kind: 'auto-link', userId: 'u1', link: LINK }); - }); - - it('requires sign-in when the existing account has a password', () => { - const d = decideIdpCallback({ + }, + full: { kind: 'auto-link', userId: 'u1', link: LINK }, + }, + { + label: 'existing account has a password → link-needs-auth', + input: { ...base, intent: intentOf({ emailVerified: true }), existingAccount: { userId: 'u1', hasPassword: true }, - }); - expect(d).to.deep.equal({ kind: 'link-needs-auth', email: 'you@gmail.com' }); - }); - - it('auto-creates when no existing account matches', () => { - const d = decideIdpCallback({ ...base, intent: intentOf(), existingAccount: null }); - expect(d.kind).to.equal('auto-create'); - }); + }, + full: { kind: 'link-needs-auth', email: 'you@gmail.com' }, + }, + { + label: 'no existing account matches → auto-create', + input: { ...base, intent: intentOf(), existingAccount: null }, + kind: 'auto-create', + }, + { + label: 'IdP identity already linked → sign-in', + input: { ...base, intent: intentOf({ userId: 'u9' }), existingAccount: null }, + full: { kind: 'sign-in', userId: 'u9' }, + }, +]; - it('still signs in when the IdP identity is already linked', () => { - const d = decideIdpCallback({ - ...base, - intent: intentOf({ userId: 'u9' }), - existingAccount: null, - }); - expect(d).to.deep.equal({ kind: 'sign-in', userId: 'u9' }); +describe('decideIdpCallback — existing-account handling', () => { + it('routes each existing-account shape to the right decision (auto-link / link-needs-auth / auto-create / sign-in)', () => { + for (const { label, input, full, kind } of ROUTING) { + const d = decideIdpCallback(input); + if (full) expect(d, label).to.deep.equal(full); + else expect(d.kind, label).to.equal(kind); + } }); }); diff --git a/cypress/component/resources/sso/saml-binding.cy.ts b/cypress/component/resources/sso/saml-binding.cy.ts index 2c08f2921c..2cdba6cffe 100644 --- a/cypress/component/resources/sso/saml-binding.cy.ts +++ b/cypress/component/resources/sso/saml-binding.cy.ts @@ -6,19 +6,23 @@ import type { SamlResponse } from '@/modules/auth/types'; import { resolveSamlBinding } from '@/resources/sso/saml-binding'; describe('resolveSamlBinding', () => { - it('redirect binding yields a redirect result', () => { - const r: SamlResponse = { url: 'https://sp.test/acs?SAMLResponse=abc', binding: 'redirect' }; - expect(resolveSamlBinding(r)).to.deep.equal({ kind: 'redirect', url: r.url }); - }); + it('yields a redirect result for the redirect binding and shaped form fields for the post binding', () => { + const redirect: SamlResponse = { + url: 'https://sp.test/acs?SAMLResponse=abc', + binding: 'redirect', + }; + expect(resolveSamlBinding(redirect), 'redirect binding').to.deep.equal({ + kind: 'redirect', + url: redirect.url, + }); - it('post binding yields the form fields', () => { - const r: SamlResponse = { + const post: SamlResponse = { url: 'https://sp.test/acs', binding: 'post', relayState: 'rs', samlResponse: 'b64', }; - expect(resolveSamlBinding(r)).to.deep.equal({ + expect(resolveSamlBinding(post), 'post binding').to.deep.equal({ kind: 'post', url: 'https://sp.test/acs', fields: { RelayState: 'rs', SAMLResponse: 'b64' }, diff --git a/cypress/component/resources/sso/sso-action.cy.ts b/cypress/component/resources/sso/sso-action.cy.ts index 74ba3073fc..2a58663f67 100644 --- a/cypress/component/resources/sso/sso-action.cy.ts +++ b/cypress/component/resources/sso/sso-action.cy.ts @@ -30,32 +30,39 @@ describe('ALLOW_IDP_UNLINK env parsing (SEC-5, fail-closed)', () => { }); describe('runSsoAction — provider error handling', () => { - it('start: provider error returns a handled redirect and logs failure (no 500)', () => { + it('handles a provider error with a redirect and failure audit, preserving org scope', () => { callService({ fn: 'runSsoAction', provider: 'singleton', startIdpIntentError: 'UNAVAILABLE', request: { url: BASE, form: { intent: 'start', provider: 'google' } }, - }).then((v) => { - expect([302, 502]).to.include(v.response?.status); - expect(v.audit.some((e) => e.outcome === 'failure')).to.equal(true); - }); - }); - - it('start: provider error threads organization into the error redirect (so the "Back to sign in" link keeps org scope)', () => { - callService({ - fn: 'runSsoAction', - provider: 'singleton', - startIdpIntentError: 'UNAVAILABLE', - request: { - url: BASE, - form: { intent: 'start', provider: 'google', organization: 'org-1' }, - }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.location ?? '').to.include('organization=org-1'); - expect(v.audit.some((e) => e.outcome === 'failure')).to.equal(true); - }); + }) + .then((v) => { + expect([302, 502], 'bare: handled status, not a 500').to.include(v.response?.status); + expect( + v.audit.some((e) => e.outcome === 'failure'), + 'bare: failure audited' + ).to.equal(true); + return callService({ + fn: 'runSsoAction', + provider: 'singleton', + startIdpIntentError: 'UNAVAILABLE', + request: { + url: BASE, + form: { intent: 'start', provider: 'google', organization: 'org-1' }, + }, + }); + }) + .then((v) => { + expect(v.response?.status, 'with org: redirect').to.equal(302); + expect(v.response?.location ?? '', 'with org: org scope preserved').to.include( + 'organization=org-1' + ); + expect( + v.audit.some((e) => e.outcome === 'failure'), + 'with org: failure audited' + ).to.equal(true); + }); }); }); @@ -71,8 +78,8 @@ describe('runSsoAction — start: provider slug is hardened against URL-injectio }); }); -describe('runSsoAction — IdP start: organization must be threaded into idpReturnUrls', () => { - it('sso start threads organization into the IdP success return URL (trusted origin, not Host)', () => { +describe('runSsoAction — IdP start: params must be threaded into idpReturnUrls', () => { + it('threads organization and deviceTrackingToken into the IdP return url from the trusted origin', () => { callService({ fn: 'runSsoAction', provider: 'singleton', @@ -82,32 +89,36 @@ describe('runSsoAction — IdP start: organization must be threaded into idpRetu url: SPOOFED, form: { intent: 'start', provider: 'google', organization: 'org-123' }, }, - }).then((v) => { - const calls = v.calls?.startIdpIntent ?? []; - expect(calls.length).to.equal(1); - const urls = calls[0][1] as { success: string; failure: string }; - expect(urls.success).to.include(`${PUBLIC_ORIGIN}/id/sso/`); - expect(urls.success).to.include('organization=org-123'); - }); - }); -}); - -describe('runSsoAction — IdP start: deviceTrackingToken must be threaded into idpReturnUrls', () => { - it('sso start threads deviceTrackingToken into the IdP success return URL (MaxMind fraud-signal parity)', () => { - callService({ - fn: 'runSsoAction', - provider: 'singleton', - env: { PUBLIC_ORIGIN }, - recordCalls: ['startIdpIntent'], - request: { - url: BASE, - form: { intent: 'start', provider: 'google', deviceTrackingToken: 'mm-token-xyz' }, - }, - }).then((v) => { - const calls = v.calls?.startIdpIntent ?? []; - expect(calls.length).to.equal(1); - const urls = calls[0][1] as { success: string; failure: string }; - expect(urls.success).to.include('deviceTrackingToken=mm-token-xyz'); - }); + }) + .then((v) => { + const calls = v.calls?.startIdpIntent ?? []; + expect(calls.length, 'organization: startIdpIntent called once').to.equal(1); + const urls = calls[0][1] as { success: string; failure: string }; + // The request Host is evil.example — the return URL must still be built from the + // trusted PUBLIC_ORIGIN, never the attacker-controlled Host header. + expect(urls.success, 'organization: trusted origin, not Host').to.include( + `${PUBLIC_ORIGIN}/id/sso/` + ); + expect(urls.success, 'organization: threaded').to.include('organization=org-123'); + return callService({ + fn: 'runSsoAction', + provider: 'singleton', + env: { PUBLIC_ORIGIN }, + recordCalls: ['startIdpIntent'], + request: { + url: BASE, + form: { intent: 'start', provider: 'google', deviceTrackingToken: 'mm-token-xyz' }, + }, + }); + }) + .then((v) => { + const calls = v.calls?.startIdpIntent ?? []; + expect(calls.length, 'deviceTrackingToken: startIdpIntent called once').to.equal(1); + const urls = calls[0][1] as { success: string; failure: string }; + // MaxMind fraud-signal parity. + expect(urls.success, 'deviceTrackingToken: threaded').to.include( + 'deviceTrackingToken=mm-token-xyz' + ); + }); }); }); diff --git a/cypress/component/resources/sso/sso-callback.cy.ts b/cypress/component/resources/sso/sso-callback.cy.ts index 846456f215..d0af729777 100644 --- a/cypress/component/resources/sso/sso-callback.cy.ts +++ b/cypress/component/resources/sso/sso-callback.cy.ts @@ -23,24 +23,134 @@ const isSignedInOrAuthorize = (loc: string) => loc === '/signed-in' || loc.start const CB = (provider = 'google', query = 'id=intent-1&token=tok-1') => `https://auth.localtest.me/sso/${provider}/callback?${query}`; -describe('processIdpCallback — provider error handling', () => { - it('redirects to the SSO error page and logs idp.signin failure when retrieveIdpIntent throws', () => { - callService({ +// Every scripted-failure path that must land on the SSO error page with a SPECIFIC, actionable +// reason rather than the generic signin_failed. Merged from four separate describes that each +// drove processIdpCallback with one scripted error and asserted the same result shape. +// +// NOT merged here: the same-email collision hard error (reason=account-exists) — that one also +// asserts the no-session-cookie and PII-safe-audit properties of the default fail-closed +// posture, and stays standalone below. +const AUTOLINK_INTENT: Scenario['idpIntent'] = { + userId: null, + information: { idpId: 'idp-g', idpUserId: 'g-al', idpUserName: 'you@gmail.com' }, + draft: { email: 'you@gmail.com', firstName: 'You', lastName: 'User', emailVerified: true }, +}; +const AUTOLINK_SEED = { + users: [{ id: 'u1', loginName: 'you@gmail.com', displayName: 'You User' }], +}; + +interface ErrorCase { + label: string; + scenario: Scenario; + /** Substrings that MUST appear in the redirect Location. */ + includes: string[]; + /** Substrings that must NOT appear — the point of the 755-J1/K1 reason mapping. */ + excludes: string[]; + /** Audit event that must carry a failure outcome. */ + auditFailure?: string; + /** Needle that must NOT appear in Set-Cookie (no session may be minted). */ + noCookieNeedle?: string; +} + +const ERROR_CASES: ErrorCase[] = [ + { + label: 'retrieveIdpIntent throws', + scenario: { fn: 'processIdpCallback', provider: 'singleton', slug: 'google', idpIntentError: 'UNAVAILABLE', request: { url: CB('google', 'id=intent1&token=tok') }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.location ?? '').to.include('/sso/google/error'); - expect(hasAudit(v.audit, 'idp.signin', 'failure')).to.equal(true); - }); + }, + includes: ['/sso/google/error'], + excludes: [], + auditFailure: 'idp.signin', + }, + { + // 755-J1: ALREADY_EXISTS on the link must stay distinguishable from a generic failure. + label: '755-J1 link ALREADY_EXISTS → identity-linked-elsewhere', + scenario: { + fn: 'processIdpCallback', + slug: 'google', + env: { ALLOW_IDP_AUTO_LINK: 'true' }, // legacy auto-link path under test (Step-5 catch) + seed: AUTOLINK_SEED, + idpIntent: AUTOLINK_INTENT, + addIdpLinkError: 'ALREADY_EXISTS', + request: { url: CB() }, + }, + includes: ['/sso/google/error', 'reason=identity-linked-elsewhere'], + excludes: ['reason=signin_failed'], + auditFailure: 'idp.link', + noCookieNeedle: 'sess-', + }, + { + label: '755-J1 link non-ALREADY_EXISTS → providerErrorCode (signin_failed)', + scenario: { + fn: 'processIdpCallback', + slug: 'google', + env: { ALLOW_IDP_AUTO_LINK: 'true' }, // legacy auto-link path under test (Step-5 catch) + seed: AUTOLINK_SEED, + idpIntent: AUTOLINK_INTENT, + addIdpLinkError: 'FAILED_PRECONDITION', + request: { url: CB() }, + }, + includes: ['/sso/google/error', 'reason=signin_failed'], + excludes: ['identity-linked-elsewhere'], + }, + { + // 755-K1 real-world bug: findUser's same-email pre-check only matches Zitadel's exact + // loginName, so an org whose loginName differs from the raw email (the Zitadel + // domain-suffix default) never finds a real collision — decideIdpCallback falls through to + // auto-create believing the user is new, and Zitadel's own addHumanUser then correctly + // rejects the duplicate with ALREADY_EXISTS. The auto-create catch must not collapse that + // into the actionable-less signin_failed, the way the sibling `link` branch already avoids. + label: '755-K1 auto-create ALREADY_EXISTS → registration-conflict', + scenario: { + fn: 'processIdpCallback', + slug: 'google', + seed: {}, // no existing users — decision routes to auto-create, mirroring the miss + idpIntent: REGISTER_INTENT_VERIFIED, + registerError: 'ALREADY_EXISTS', + request: { url: CB() }, + }, + includes: ['/sso/google/error', 'reason=registration-conflict'], + excludes: ['reason=signin_failed'], + auditFailure: 'idp.register', + }, +]; + +describe('processIdpCallback — scripted-failure reason mapping', () => { + it('maps every scripted failure to a specific actionable reason', () => { + for (const c of ERROR_CASES) { + callService(c.scenario).then((v) => { + expect(v.response?.status, `${c.label}: status`).to.equal(302); + const loc = v.response?.location ?? ''; + for (const needle of c.includes) { + expect(loc, `${c.label}: location includes ${needle}`).to.include(needle); + } + for (const needle of c.excludes) { + expect(loc, `${c.label}: location excludes ${needle}`).to.not.include(needle); + } + if (c.auditFailure) { + expect( + hasAudit(v.audit, c.auditFailure, 'failure'), + `${c.label}: ${c.auditFailure} failure audited` + ).to.equal(true); + } + if (c.noCookieNeedle) { + expect(v.response?.setCookie ?? '', `${c.label}: no session minted`).to.not.include( + c.noCookieNeedle + ); + } + }); + } }); }); describe('processIdpCallback — existing same-email account auto-link (Task-3)', () => { - it('auto-links and signs in when existing account is passwordless + email is IdP-verified', () => { + // Both success paths land the same way (302 → signed-in/authorize with a session cookie); + // they differ only in whether an existing same-email account is seeded. + it('signs in with a session cookie on both the auto-link and auto-create paths', () => { callService({ fn: 'processIdpCallback', slug: 'google', @@ -49,13 +159,13 @@ describe('processIdpCallback — existing same-email account auto-link (Task-3)' idpIntent: REGISTER_INTENT_VERIFIED, request: { url: CB() }, }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(isSignedInOrAuthorize(v.response?.location ?? '')).to.equal(true); - expect(v.response?.setCookie ?? '').to.include('sessions='); + expect(v.response?.status, 'auto-link: status').to.equal(302); + expect(isSignedInOrAuthorize(v.response?.location ?? ''), 'auto-link: signed in').to.equal( + true + ); + expect(v.response?.setCookie ?? '', 'auto-link: session minted').to.include('sessions='); }); - }); - it('auto-creates and signs in when no existing account with the same email (new IdP user)', () => { callService({ fn: 'processIdpCallback', slug: 'google', @@ -75,7 +185,11 @@ describe('processIdpCallback — existing same-email account auto-link (Task-3)' }); }); - it('forwards a deviceTrackingToken on the callback URL to the new session as MaxMind metadata (IdP fraud-signal parity)', () => { + // Both the auto-CREATE path (fresh identity) and the SIGN-IN path (an ALREADY-linked IdP + // identity, intent.userId present) must forward deviceTrackingToken to the resulting + // session's metadata — the sign-in path via signInWithIdpIntent's own deviceTrackingToken + // opt. Merged from two describes that asserted the same property on the two branches. + it('forwards deviceTrackingToken to the session on both the auto-create and sign-in paths', () => { callService({ fn: 'processIdpCallback', slug: 'google', @@ -83,14 +197,46 @@ describe('processIdpCallback — existing same-email account auto-link (Task-3)' idpIntent: REGISTER_INTENT_VERIFIED, request: { url: CB('google', 'id=intent-1&token=tok-1&deviceTrackingToken=mm-idp-token-1') }, inspect: { lastCreateSessionOpts: true }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(isSignedInOrAuthorize(v.response?.location ?? '')).to.equal(true); - const opts = v.inspect?.lastCreateSessionOpts as { - metadata?: Record; - } | null; - expect(opts?.metadata?.['maxmind/tracking-token']).to.equal('mm-idp-token-1'); - }); + }) + .then((v) => { + expect(v.response?.status, 'auto-create: status').to.equal(302); + expect( + isSignedInOrAuthorize(v.response?.location ?? ''), + 'auto-create: signed in' + ).to.equal(true); + const opts = v.inspect?.lastCreateSessionOpts as { + metadata?: Record; + } | null; + expect(opts?.metadata?.['maxmind/tracking-token'], 'auto-create: token forwarded').to.equal( + 'mm-idp-token-1' + ); + + return callService({ + fn: 'processIdpCallback', + slug: 'google', + seed: { + users: [{ id: 'u-signin', loginName: 'linked@idp.test', displayName: 'Linked' }], + }, + idpIntent: { + userId: 'u-signin', + information: { idpId: 'idp-g', idpUserId: 'g-linked', idpUserName: 'linked@idp.test' }, + draft: null, + }, + request: { + url: CB('google', 'id=intent-si&token=tok-si&deviceTrackingToken=mm-idp-token-signin'), + }, + inspect: { lastCreateSessionOpts: true }, + }); + }) + .then((v) => { + expect(v.response?.status, 'sign-in: status').to.equal(302); + const opts = v.inspect?.lastCreateSessionOpts as { + metadata?: Record; + } | null; + expect(opts?.metadata?.['maxmind/tracking-token'], 'sign-in: token forwarded').to.equal( + 'mm-idp-token-signin' + ); + }); }); }); @@ -119,34 +265,6 @@ describe('processIdpCallback — account-link-by-email observability log (PII-sa }); }); -describe('processIdpCallback — sign-in path MaxMind fraud-signal parity', () => { - // Mirrors the auto-create test above — the sign-in path (an ALREADY-linked IdP identity, - // intent.userId present) must forward deviceTrackingToken to the resulting session's metadata - // exactly like a fresh registration does, via signInWithIdpIntent's own deviceTrackingToken opt. - it('forwards a deviceTrackingToken on the callback URL to the session on a returning-user sign-in', () => { - callService({ - fn: 'processIdpCallback', - slug: 'google', - seed: { users: [{ id: 'u-signin', loginName: 'linked@idp.test', displayName: 'Linked' }] }, - idpIntent: { - userId: 'u-signin', - information: { idpId: 'idp-g', idpUserId: 'g-linked', idpUserName: 'linked@idp.test' }, - draft: null, - }, - request: { - url: CB('google', 'id=intent-si&token=tok-si&deviceTrackingToken=mm-idp-token-signin'), - }, - inspect: { lastCreateSessionOpts: true }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - const opts = v.inspect?.lastCreateSessionOpts as { - metadata?: Record; - } | null; - expect(opts?.metadata?.['maxmind/tracking-token']).to.equal('mm-idp-token-signin'); - }); - }); -}); - describe('processIdpCallback — last-used-login Set-Cookie', () => { const IDP = 'idp-g'; @@ -168,78 +286,6 @@ describe('processIdpCallback — last-used-login Set-Cookie', () => { }); }); -describe('processIdpCallback — 755-J1 link failure reason mapping', () => { - const AUTOLINK_INTENT: Scenario['idpIntent'] = { - userId: null, - information: { idpId: 'idp-g', idpUserId: 'g-al', idpUserName: 'you@gmail.com' }, - draft: { email: 'you@gmail.com', firstName: 'You', lastName: 'User', emailVerified: true }, - }; - - it('maps ALREADY_EXISTS to reason=identity-linked-elsewhere (not signin_failed)', () => { - callService({ - fn: 'processIdpCallback', - slug: 'google', - env: { ALLOW_IDP_AUTO_LINK: 'true' }, // legacy auto-link path under test (Step-5 catch) - seed: { users: [{ id: 'u1', loginName: 'you@gmail.com', displayName: 'You User' }] }, - idpIntent: AUTOLINK_INTENT, - addIdpLinkError: 'ALREADY_EXISTS', - request: { url: CB() }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - const loc = v.response?.location ?? ''; - expect(loc).to.include('/sso/google/error'); - expect(loc).to.include('reason=identity-linked-elsewhere'); - expect(loc).to.not.include('reason=signin_failed'); - expect(hasAudit(v.audit, 'idp.link', 'failure')).to.equal(true); - expect(v.response?.setCookie ?? '').to.not.include('sess-'); - }); - }); - - it('maps a non-ALREADY_EXISTS link ProviderError through providerErrorCode (signin_failed)', () => { - callService({ - fn: 'processIdpCallback', - slug: 'google', - env: { ALLOW_IDP_AUTO_LINK: 'true' }, // legacy auto-link path under test (Step-5 catch) - seed: { users: [{ id: 'u1', loginName: 'you@gmail.com', displayName: 'You User' }] }, - idpIntent: AUTOLINK_INTENT, - addIdpLinkError: 'FAILED_PRECONDITION', - request: { url: CB() }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - const loc = v.response?.location ?? ''; - expect(loc).to.include('/sso/google/error'); - expect(loc).to.include('reason=signin_failed'); - expect(loc).to.not.include('identity-linked-elsewhere'); - }); - }); -}); - -describe('processIdpCallback — 755-K1 auto-create failure reason mapping', () => { - // Real-world bug: findUser's same-email pre-check only matches Zitadel's exact loginName, so an - // org whose loginName differs from the raw email (the Zitadel domain-suffix default) never finds - // a real collision — decideIdpCallback falls through to auto-create believing the user is new, - // and Zitadel's own addHumanUser then correctly rejects the duplicate with ALREADY_EXISTS. The - // auto-create catch block must not collapse that into the generic, actionable-less signin_failed - // the same way the sibling `link` branch already avoids doing for its own ALREADY_EXISTS case. - it('maps ALREADY_EXISTS from a fresh registration to a clear reason (not signin_failed)', () => { - callService({ - fn: 'processIdpCallback', - slug: 'google', - seed: {}, // no existing users — decision routes to auto-create, mirroring the missed collision - idpIntent: REGISTER_INTENT_VERIFIED, - registerError: 'ALREADY_EXISTS', - request: { url: CB() }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - const loc = v.response?.location ?? ''; - expect(loc).to.include('/sso/google/error'); - expect(loc).to.include('reason=registration-conflict'); - expect(loc).to.not.include('reason=signin_failed'); - expect(hasAudit(v.audit, 'idp.register', 'failure')).to.equal(true); - }); - }); -}); - describe('processIdpCallback — same-email collision hard error (default: ALLOW_IDP_AUTO_LINK off)', () => { it('redirects to the SSO error page with reason=account-exists instead of auto-linking', () => { callService({ @@ -266,7 +312,9 @@ describe('processIdpCallback — default-org resolution for IdP auto-create (bar // FIX: resolveOrg(provider, organization) → 'org-default-fake' → register receives that org. // The fake does NOT throw on undefined orgId (it ignores it), so we assert the ARG, not // the outcome, to get a genuine RED before the fix. - it('calls register with the resolved default org (not undefined) on a bare flow (no ?organization=)', () => { + // Both consumers of the resolved org were driven by an IDENTICAL scenario differing only by + // which call it recorded — one run now records both. + it('calls register AND getLoginSettings with the resolved default org (not undefined) on a bare flow (no ?organization=)', () => { callService({ fn: 'processIdpCallback', slug: 'google', @@ -275,12 +323,13 @@ describe('processIdpCallback — default-org resolution for IdP auto-create (bar idpIntent: REGISTER_INTENT_VERIFIED, // No organization= in the callback URL → raw organization is undefined request: { url: CB('google', 'id=intent-1&token=tok-1') }, - // Capture the args passed to provider.register - recordCalls: ['register'], + // Capture the args passed to both provider calls + recordCalls: ['register', 'getLoginSettings'], }).then((v) => { // Must route to success (not an error page) expect(v.response?.status).to.equal(302); expect(isSignedInOrAuthorize(v.response?.location ?? '')).to.equal(true); + // The register call must receive the resolved default org, NOT undefined const registerCalls = (v.calls?.['register'] ?? []) as Array<[Record]>; expect(registerCalls.length, 'register was called').to.be.greaterThan(0); @@ -288,24 +337,13 @@ describe('processIdpCallback — default-org resolution for IdP auto-create (bar expect(registerInput.orgId, 'orgId must be the resolved default org, not undefined').to.equal( 'org-default-fake' ); - }); - }); - it('calls getLoginSettings with the resolved default org (not undefined) on a bare flow', () => { - callService({ - fn: 'processIdpCallback', - slug: 'google', - seed: {}, - idpIntent: REGISTER_INTENT_VERIFIED, - request: { url: CB('google', 'id=intent-1&token=tok-1') }, - recordCalls: ['getLoginSettings'], - }).then((v) => { const settingsCalls = (v.calls?.['getLoginSettings'] ?? []) as Array<[string | undefined]>; expect(settingsCalls.length, 'getLoginSettings was called').to.be.greaterThan(0); - const orgArg = settingsCalls[0][0]; - expect(orgArg, 'getLoginSettings must receive resolved default org, not undefined').to.equal( - 'org-default-fake' - ); + expect( + settingsCalls[0][0], + 'getLoginSettings must receive resolved default org, not undefined' + ).to.equal('org-default-fake'); }); }); }); @@ -499,30 +537,38 @@ describe('processIdpCallback — fresh-identity link ceremony (Req 2)', () => { }); describe('processIdpCallback — passkey-hint write', () => { - it('auto-link sign-in writes passkey-hint = the IdP-vouched loginName', () => { - callService({ - fn: 'processIdpCallback', - slug: 'google', - env: { ALLOW_IDP_AUTO_LINK: 'true' }, - seed: { users: [{ id: 'u1', loginName: 'you@gmail.com', displayName: 'You User' }] }, - idpIntent: REGISTER_INTENT_VERIFIED, - request: { url: CB() }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.passkeyHint).to.equal('you@gmail.com'); - }); - }); + // Both success paths must write passkey-hint = the loginName the session lands on: + // auto-link uses the IdP-vouched existing loginName, auto-create the freshly created one. + const HINT_CASES: Array<{ label: string; scenario: Scenario }> = [ + { + label: 'auto-link', + scenario: { + fn: 'processIdpCallback', + slug: 'google', + env: { ALLOW_IDP_AUTO_LINK: 'true' }, + seed: { users: [{ id: 'u1', loginName: 'you@gmail.com', displayName: 'You User' }] }, + idpIntent: REGISTER_INTENT_VERIFIED, + request: { url: CB() }, + }, + }, + { + label: 'auto-create', + scenario: { + fn: 'processIdpCallback', + slug: 'google', + seed: { users: [] }, // no existing account → auto-create path + idpIntent: REGISTER_INTENT_VERIFIED, + request: { url: CB() }, + }, + }, + ]; - it('auto-create writes passkey-hint = the freshly created loginName', () => { - callService({ - fn: 'processIdpCallback', - slug: 'google', - seed: { users: [] }, // no existing account → auto-create path - idpIntent: REGISTER_INTENT_VERIFIED, - request: { url: CB() }, - }).then((v) => { - expect(v.response?.status).to.equal(302); - expect(v.response?.passkeyHint).to.equal('you@gmail.com'); - }); + it('writes passkey-hint = the signed-in loginName on both the auto-link and auto-create paths', () => { + for (const c of HINT_CASES) { + callService(c.scenario).then((v) => { + expect(v.response?.status, `${c.label}: status`).to.equal(302); + expect(v.response?.passkeyHint, `${c.label}: passkey-hint`).to.equal('you@gmail.com'); + }); + } }); }); diff --git a/cypress/component/resources/verify/verify-url-template.cy.ts b/cypress/component/resources/verify/verify-url-template.cy.ts index 7e626fe1bf..78eecbcccc 100644 --- a/cypress/component/resources/verify/verify-url-template.cy.ts +++ b/cypress/component/resources/verify/verify-url-template.cy.ts @@ -8,15 +8,16 @@ import { verifyUrlTemplate } from '@/resources/verify/verify-url-template'; describe('verifyUrlTemplate', () => { - it('builds an absolute /id/verify url with RAW (unencoded) provider placeholders', () => { - const t = verifyUrlTemplate({ origin: 'https://auth.localtest.me:30000' }); - expect(t).to.equal( + it('builds the verify url from the trusted origin with raw placeholders, threading requestId', () => { + // Exact string: pins the trusted origin, the /id basename, and the raw placeholder braces + // (Zitadel does NOT decode %7B%7B in email links). + expect(verifyUrlTemplate({ origin: 'https://auth.localtest.me:30000' }), 'bare').to.equal( 'https://auth.localtest.me:30000/id/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}' ); - }); - it('threads requestId (URL-encoded) when present', () => { - const t = verifyUrlTemplate({ origin: 'https://h', requestId: 'oidc_9' }); - expect(t).to.contain('&requestId=oidc_9'); + expect( + verifyUrlTemplate({ origin: 'https://h', requestId: 'oidc_9' }), + 'requestId threaded' + ).to.contain('&requestId=oidc_9'); }); }); diff --git a/cypress/component/resources/verify/verify.schema.cy.ts b/cypress/component/resources/verify/verify.schema.cy.ts index 721c2818c8..8a04794c2c 100644 --- a/cypress/component/resources/verify/verify.schema.cy.ts +++ b/cypress/component/resources/verify/verify.schema.cy.ts @@ -4,14 +4,15 @@ // Zod verify code schema → browser-side Chai only. import { verifyCodeSchema } from '@/resources/verify/verify.schema'; -describe('verifyCodeSchema', () => { - it('accepts userId and code at minimum', () => { - const result = verifyCodeSchema.safeParse({ userId: 'u1', code: '123456' }); - expect(result.success).to.equal(true); - }); +const CASES: [label: string, input: Record, accepted: boolean][] = [ + ['userId + code (the minimum)', { userId: 'u1', code: '123456' }, true], + ['missing code', { userId: 'u1' }, false], +]; - it('rejects missing code', () => { - const result = verifyCodeSchema.safeParse({ userId: 'u1' }); - expect(result.success).to.equal(false); +describe('verifyCodeSchema', () => { + it('accepts userId and code at minimum, rejecting input with the code missing', () => { + for (const [label, input, accepted] of CASES) { + expect(verifyCodeSchema.safeParse(input).success, label).to.equal(accepted); + } }); }); diff --git a/cypress/component/resources/verify/verify.service.cy.ts b/cypress/component/resources/verify/verify.service.cy.ts index fe25105378..be46d848bb 100644 --- a/cypress/component/resources/verify/verify.service.cy.ts +++ b/cypress/component/resources/verify/verify.service.cy.ts @@ -218,7 +218,11 @@ describe('resendEmailCode — action intent=resend (ownership gate)', () => { // ───────────────────────────────────────────────────────────────────────────── describe('submitEmailCode — action default verify intent', () => { - it('verifies the email and redirects to /authorize when active session + requestId are present', () => { + // The two target-shape cases: an ACTIVE session finalizes straight to /authorize, while no + // active session hands off to /verify/success carrying the ceremony context so the user can + // resume after signing in. Same call, same shape — only isSessionActive and the expected + // target differ. (The ownership-gate tests above are untouched.) + it('redirects to /authorize with an active session, and to /verify/success without one', () => { run({ fn: 'submitEmailCode', request: { url: `${BASE_URL}/verify` }, @@ -236,12 +240,12 @@ describe('submitEmailCode — action default verify intent', () => { }).then((verdict) => { expect(verdict.ok, verdict.error ?? '').to.be.true; const r = verdict.outcome as Record; - expect(r.ok).to.be.true; - expect(r.target).to.eq('/authorize?requestId=oidc_99'); + expect(r.ok, 'active session: ok').to.be.true; + expect(r.target, 'active session: finalizes at /authorize').to.eq( + '/authorize?requestId=oidc_99' + ); }); - }); - it('redirects to /verify/success carrying loginName/requestId/organization when no active session', () => { run({ fn: 'submitEmailCode', request: { url: `${BASE_URL}/verify` }, @@ -260,11 +264,12 @@ describe('submitEmailCode — action default verify intent', () => { }).then((verdict) => { expect(verdict.ok, verdict.error ?? '').to.be.true; const r = verdict.outcome as Record; - expect(r.ok).to.be.true; - expect(r.target as string).to.include('/verify/success?'); - expect(r.target as string).to.include('loginName=alice%40acme.test'); - expect(r.target as string).to.include('requestId=oidc_7'); - expect(r.target as string).to.include('organization=org-1'); + expect(r.ok, 'no session: ok').to.be.true; + const target = r.target as string; + expect(target, 'no session: hands off to /verify/success').to.include('/verify/success?'); + expect(target, 'no session: loginName carried').to.include('loginName=alice%40acme.test'); + expect(target, 'no session: requestId carried').to.include('requestId=oidc_7'); + expect(target, 'no session: organization carried').to.include('organization=org-1'); }); }); diff --git a/cypress/component/resources/webauthn/aaguid.cy.ts b/cypress/component/resources/webauthn/aaguid.cy.ts index be82a207f1..774edfae39 100644 --- a/cypress/component/resources/webauthn/aaguid.cy.ts +++ b/cypress/component/resources/webauthn/aaguid.cy.ts @@ -10,36 +10,50 @@ import { aaguidFromAttestationObject, defaultPasskeyName } from '@/resources/web const MAC_CHROME_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36'; -describe('aaguidFromAttestationObject / defaultPasskeyName — default naming', () => { - it('decodes a known AAGUID and maps it to the vendored catalog name', () => { - // fbfc3007-154e-4ecc-8c0b-6e020557d7bd — Apple's authenticator (named - // 'Apple Passwords' in the vendored catalog snapshot, upstream 9e867bf). - const att = attestationObject(authDataWith(0x45, 'fbfc3007154e4ecc8c0b6e020557d7bd')); - expect(aaguidFromAttestationObject(att)).to.equal('fbfc3007-154e-4ecc-8c0b-6e020557d7bd'); - expect(defaultPasskeyName('fbfc3007-154e-4ecc-8c0b-6e020557d7bd', MAC_CHROME_UA)).to.equal( - 'Apple Passwords' - ); - }); +const APPLE_AAGUID = 'fbfc3007-154e-4ecc-8c0b-6e020557d7bd'; +const ZERO_AAGUID = '00000000-0000-0000-0000-000000000000'; - it('returns the zero UUID for a zeroed AAGUID and falls back to the UA name', () => { - const att = attestationObject(authDataWith(0x45, '00000000000000000000000000000000')); - expect(aaguidFromAttestationObject(att)).to.equal('00000000-0000-0000-0000-000000000000'); - expect(defaultPasskeyName('00000000-0000-0000-0000-000000000000', MAC_CHROME_UA)).to.equal( - 'Chrome on macOS' - ); - }); +// Extraction: every input shape, valid and malformed. A null result must never throw — +// AAGUID failure must not block enrollment. +const EXTRACTION: [label: string, input: string, expected: string | null][] = [ + // fbfc3007-... is Apple's authenticator. + [ + 'known AAGUID', + attestationObject(authDataWith(0x45, 'fbfc3007154e4ecc8c0b6e020557d7bd')), + APPLE_AAGUID, + ], + [ + 'zeroed AAGUID', + attestationObject(authDataWith(0x45, '00000000000000000000000000000000')), + ZERO_AAGUID, + ], + // UP only, no AT flag → no attested credential data at all. + ['AT flag absent', attestationObject(authDataWith(0x01)), null], + ['garbage', '!!!', null], + ['empty string', '', null], + // truncated CBOR: a map header promising entries that never arrive + ['truncated CBOR', 'owFj', null], +]; - it('never throws on garbage input — returns null and the UA fallback (AAGUID failure never blocks enrollment)', () => { - expect(aaguidFromAttestationObject('!!!')).to.equal(null); - expect(aaguidFromAttestationObject('')).to.equal(null); - // truncated CBOR: a map header promising entries that never arrive - expect(aaguidFromAttestationObject('owFj')).to.equal(null); - expect(defaultPasskeyName(null, MAC_CHROME_UA)).to.equal('Chrome on macOS'); - expect(defaultPasskeyName(null, '')).to.equal('This device'); +// Naming: catalog hit, catalog miss → UA, no AAGUID → UA, and no UA either → generic. +const NAMING: [label: string, aaguid: string | null, ua: string, expected: string][] = [ + // 'Apple Passwords' in the vendored catalog snapshot, upstream 9e867bf. + ['catalog hit', APPLE_AAGUID, MAC_CHROME_UA, 'Apple Passwords'], + ['zeroed AAGUID falls back to UA', ZERO_AAGUID, MAC_CHROME_UA, 'Chrome on macOS'], + ['null AAGUID falls back to UA', null, MAC_CHROME_UA, 'Chrome on macOS'], + ['no AAGUID and no UA', null, '', 'This device'], +]; + +describe('aaguidFromAttestationObject / defaultPasskeyName — default naming', () => { + it('extracts the AAGUID from a valid attestation, returning null for malformed input', () => { + for (const [label, input, expected] of EXTRACTION) { + expect(aaguidFromAttestationObject(input), label).to.equal(expected); + } }); - it('returns null when the AT flag is absent (no attested credential data)', () => { - const att = attestationObject(authDataWith(0x01)); // UP only, no AT, no AAGUID bytes - expect(aaguidFromAttestationObject(att)).to.equal(null); + it('resolves the default passkey name from the vendored catalog, falling back to the UA and then to a generic label', () => { + for (const [label, aaguid, ua, expected] of NAMING) { + expect(defaultPasskeyName(aaguid, ua), label).to.equal(expected); + } }); }); diff --git a/cypress/component/resources/webauthn/webauthn.cy.ts b/cypress/component/resources/webauthn/webauthn.cy.ts index 129842d23f..0327e50d72 100644 --- a/cypress/component/resources/webauthn/webauthn.cy.ts +++ b/cypress/component/resources/webauthn/webauthn.cy.ts @@ -42,19 +42,22 @@ describe('classifyWebAuthnError', () => { ['SecurityError', 'security'], ['NetworkError', 'unknown'], // a DOMException whose name is not mapped ]; - for (const [name, reason] of cases) { - it(`maps DOMException "${name}" → "${reason}"`, () => { - expect(classifyWebAuthnError(new DOMException('boom', name))).to.equal(reason); - }); - } + it('maps every spec DOMException name to its stable reason, and every non-DOMException value → "unknown"', () => { + for (const [name, reason] of cases) { + expect(classifyWebAuthnError(new DOMException('boom', name)), name).to.equal(reason); + } - it('maps a plain Error and other non-DOMException values → "unknown"', () => { - expect(classifyWebAuthnError(new Error('nope'))).to.equal('unknown'); - // A plain object carrying a spoofed WebAuthn name is still not a DOMException. - expect(classifyWebAuthnError({ name: 'NotAllowedError' })).to.equal('unknown'); - expect(classifyWebAuthnError('NotAllowedError')).to.equal('unknown'); - expect(classifyWebAuthnError(null)).to.equal('unknown'); - expect(classifyWebAuthnError(undefined)).to.equal('unknown'); + // Nothing that merely LOOKS like a DOMException may be classified — a plain object + // carrying a spoofed WebAuthn name is still not a DOMException. + for (const [label, value] of [ + ['plain Error', new Error('nope')], + ['spoofed name object', { name: 'NotAllowedError' }], + ['bare string', 'NotAllowedError'], + ['null', null], + ['undefined', undefined], + ] as const) { + expect(classifyWebAuthnError(value), label).to.equal('unknown'); + } }); }); @@ -77,66 +80,73 @@ function ensureWebAuthnEnv(): void { } } -describe('marshalAssertion (sign-in) ceremony error handling', () => { - it('maps a null credential (user-cancel) to WebAuthnCeremonyError reason "not-allowed"', () => { - ensureWebAuthnEnv(); - cy.stub(window.navigator.credentials, 'get').resolves(null); - return marshalAssertion(PK_GET).then( - () => { - throw new Error('expected a WebAuthnCeremonyError'); - }, - (err: unknown) => { - expect(err).to.be.instanceOf(WebAuthnCeremonyError); - expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); - } - ); - }); +/** + * Re-stub navigator.credentials for the next row. cy.stub auto-restores BETWEEN tests, not + * within one — so a table that stubs the same method twice in a single `it` throws + * "Attempted to wrap create which is already wrapped" without this explicit restore. + */ +function stubCredential(method: 'get' | 'create', failure: DOMException | null): void { + ensureWebAuthnEnv(); + const creds = window.navigator.credentials as unknown as Record void }>; + creds[method]?.restore?.(); + const stub = cy.stub(window.navigator.credentials, method); + if (failure) stub.rejects(failure); + else stub.resolves(null); // null credential === user cancel +} - it('classifies a thrown DOMException (NotAllowedError → "not-allowed")', () => { - ensureWebAuthnEnv(); - cy.stub(window.navigator.credentials, 'get').rejects( - new DOMException('no authenticator', 'NotAllowedError') - ); - return marshalAssertion(PK_GET).then( - () => { - throw new Error('expected a WebAuthnCeremonyError'); - }, - (err: unknown) => { - expect(err).to.be.instanceOf(WebAuthnCeremonyError); - expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); - } - ); - }); -}); +// Both ceremonies, both failure modes: a null return (user-cancel) and a thrown DOMException. +const CEREMONIES: Array<{ + label: string; + method: 'get' | 'create'; + failure: DOMException | null; + run: () => Promise; + reason: WebAuthnReason; +}> = [ + { + label: 'marshalAssertion (sign-in) null credential', + method: 'get', + failure: null, + run: () => marshalAssertion(PK_GET), + reason: 'not-allowed', + }, + { + label: 'marshalAssertion (sign-in) NotAllowedError', + method: 'get', + failure: new DOMException('no authenticator', 'NotAllowedError'), + run: () => marshalAssertion(PK_GET), + reason: 'not-allowed', + }, + { + label: 'createAttestation (enroll) InvalidStateError', + method: 'create', + failure: new DOMException('excluded credential present', 'InvalidStateError'), + run: () => createAttestation(PK_CREATE), + reason: 'already-registered', + }, + { + label: 'createAttestation (enroll) null credential', + method: 'create', + failure: null, + run: () => createAttestation(PK_CREATE), + reason: 'not-allowed', + }, +]; -describe('createAttestation (enroll) ceremony error handling', () => { - it('classifies a thrown DOMException (InvalidStateError → "already-registered")', () => { - ensureWebAuthnEnv(); - cy.stub(window.navigator.credentials, 'create').rejects( - new DOMException('excluded credential present', 'InvalidStateError') - ); - return createAttestation(PK_CREATE).then( - () => { - throw new Error('expected a WebAuthnCeremonyError'); - }, - (err: unknown) => { - expect(err).to.be.instanceOf(WebAuthnCeremonyError); - expect((err as WebAuthnCeremonyError).reason).to.equal('already-registered'); - } - ); - }); +describe('marshalAssertion / createAttestation ceremony error handling', () => { + it('rejects with a classified WebAuthnCeremonyError on a null credential and a thrown DOMException', async () => { + for (const { label, method, failure, run, reason } of CEREMONIES) { + stubCredential(method, failure); - it('maps a null credential (user-cancel) to WebAuthnCeremonyError reason "not-allowed"', () => { - ensureWebAuthnEnv(); - cy.stub(window.navigator.credentials, 'create').resolves(null); - return createAttestation(PK_CREATE).then( - () => { - throw new Error('expected a WebAuthnCeremonyError'); - }, - (err: unknown) => { - expect(err).to.be.instanceOf(WebAuthnCeremonyError); - expect((err as WebAuthnCeremonyError).reason).to.equal('not-allowed'); + let thrown: unknown = null; + try { + await run(); + } catch (err) { + thrown = err; } - ); + expect(thrown, `${label}: rejected with a WebAuthnCeremonyError`).to.be.instanceOf( + WebAuthnCeremonyError + ); + expect((thrown as WebAuthnCeremonyError).reason, `${label}: reason`).to.equal(reason); + } }); }); diff --git a/cypress/component/resources/webauthn/webauthn.service.cy.ts b/cypress/component/resources/webauthn/webauthn.service.cy.ts index fb2238318d..a7b718aa48 100644 --- a/cypress/component/resources/webauthn/webauthn.service.cy.ts +++ b/cypress/component/resources/webauthn/webauthn.service.cy.ts @@ -147,35 +147,34 @@ describe('requestPasskeyAttestation / requestU2FAttestation — challenge audit }); describe('requestWebAuthnChallenge — guard-fail bounce target threading', () => { - it('bounces to /login threading requestId + organization when there is no matching session (mirrors the attestation siblings)', () => { - callService({ - fn: 'requestWebAuthnChallenge', - provider: 'singleton', - request: { url: 'http://localhost/id/login/passkey' }, // no sessions - attestationInput: { - loginName: 'ghost@nowhere.test', - domain: 'localhost', - requestId: 'oidc_V2_789', - organization: 'org-1', - }, - }).then((v) => { - const o = v.outcome as { kind: string; target?: string }; - expect(o.kind).to.equal('redirect'); - expect(o.target).to.equal('/login?requestId=oidc_V2_789&organization=org-1'); - }); - }); + // Same call, same assertion shape — only the ceremony context and expected target vary. + const BOUNCES: [label: string, ceremony: Record, expectedTarget: string][] = [ + [ + // Mirrors the attestation siblings: a dead session mid-ceremony must resume after re-login. + 'requestId + organization', + { requestId: 'oidc_V2_789', organization: 'org-1' }, + '/login?requestId=oidc_V2_789&organization=org-1', + ], + ['no ceremony context', {}, '/login'], + ]; - it('bounces to a bare /login when no ceremony context is present', () => { - callService({ - fn: 'requestWebAuthnChallenge', - provider: 'singleton', - request: { url: 'http://localhost/id/login/passkey' }, // no sessions - attestationInput: { loginName: 'ghost@nowhere.test', domain: 'localhost' }, - }).then((v) => { - const o = v.outcome as { kind: string; target?: string }; - expect(o.kind).to.equal('redirect'); - expect(o.target).to.equal('/login'); - }); + it('bounces to /login when there is no matching session, threading requestId + organization when present and bare otherwise', () => { + for (const [label, ceremony, expectedTarget] of BOUNCES) { + callService({ + fn: 'requestWebAuthnChallenge', + provider: 'singleton', + request: { url: 'http://localhost/id/login/passkey' }, // no sessions + attestationInput: { + loginName: 'ghost@nowhere.test', + domain: 'localhost', + ...ceremony, + }, + }).then((v) => { + const o = v.outcome as { kind: string; target?: string }; + expect(o.kind, `${label}: redirect`).to.equal('redirect'); + expect(o.target, `${label}: target`).to.equal(expectedTarget); + }); + } }); }); diff --git a/cypress/component/routes/accounts-row.cy.tsx b/cypress/component/routes/accounts-row.cy.tsx index c12c1ff7e6..629b88b67f 100644 --- a/cypress/component/routes/accounts-row.cy.tsx +++ b/cypress/component/routes/accounts-row.cy.tsx @@ -49,8 +49,10 @@ const account = (over: Record = {}) => ({ }); describe('accounts row — switch form structure', () => { - it('renders the switch form with CSRF + sessionId hidden inputs, and keeps remove as a SEPARATE form', () => { - mountAccounts({ csrfToken: 'csrf-tok', accounts: [account()] }); + it('renders switch and remove as separate forms, with CSRF + sessionId and an IdP badge', () => { + mountAccounts({ csrfToken: 'csrf-tok', accounts: [account({ idpName: 'Google' })] }); + // IdP badge branch (folded in from its own test — same mount, one extra account field). + cy.contains('Google').should('exist'); cy.get('form:has(input[name="intent"][value="switch"])').within(() => { cy.get('input[name="sessionId"]').should('have.value', 's1'); cy.get('input[name="csrf"]').should('have.value', 'csrf-tok'); @@ -66,11 +68,6 @@ describe('accounts row — switch form structure', () => { }); }); - it('renders an IdP badge when idpName is present', () => { - mountAccounts({ csrfToken: 't', accounts: [account({ idpName: 'Google' })] }); - cy.contains('Google').should('exist'); - }); - it('threads organization (alongside requestId) through the switch/remove hidden inputs and "Add another account" (regression: organization silently dropped)', () => { mountAccounts({ csrfToken: 'csrf-tok', @@ -97,21 +94,30 @@ describe('accounts row — switch form structure', () => { }); describe('addAccountHref', () => { - it('carries an OIDC ceremony requestId and organization', () => { - expect( - addAccountHref({ requestId: 'oidc_abc', organization: 'org-1', userCode: null }) - ).to.equal('/login?requestId=oidc_abc&organization=org-1&add=1'); - }); - - it('prefers the device user_code, rewriting it as a device_ requestId', () => { - expect( - addAccountHref({ requestId: 'oidc_abc', organization: undefined, userCode: 'WDJB-MJHT' }) - ).to.equal('/login?requestId=device_WDJB-MJHT&add=1'); - }); - - it('omits absent values rather than emitting empty params', () => { - expect(addAccountHref({ requestId: null, organization: undefined, userCode: null })).to.equal( - '/login?add=1' - ); + it('builds the add-account href for ceremony, device, and bare inputs', () => { + const rows: Array<{ + label: string; + args: Parameters[0]; + expected: string; + }> = [ + { + label: 'carries an OIDC ceremony requestId and organization', + args: { requestId: 'oidc_abc', organization: 'org-1', userCode: null }, + expected: '/login?requestId=oidc_abc&organization=org-1&add=1', + }, + { + label: 'prefers the device user_code, rewriting it as a device_ requestId', + args: { requestId: 'oidc_abc', organization: undefined, userCode: 'WDJB-MJHT' }, + expected: '/login?requestId=device_WDJB-MJHT&add=1', + }, + { + label: 'omits absent values rather than emitting empty params', + args: { requestId: null, organization: undefined, userCode: null }, + expected: '/login?add=1', + }, + ]; + for (const row of rows) { + expect(addAccountHref(row.args), row.label).to.equal(row.expected); + } }); }); diff --git a/cypress/component/routes/inline-action-error.cy.tsx b/cypress/component/routes/inline-action-error.cy.tsx index 0008eac7db..c4e2b78792 100644 --- a/cypress/component/routes/inline-action-error.cy.tsx +++ b/cypress/component/routes/inline-action-error.cy.tsx @@ -48,12 +48,10 @@ function mountAt( describe('accounts — inline action error (no toast)', () => { const loaderData = { csrfToken: 't', accounts: [] }; - it('renders a role="alert" banner when actionData.error is set', () => { + it('renders a role="alert" banner when actionData.error is set, and NO banner when there is no action error', () => { mountAt(AccountPicker, 'accounts', '/accounts', loaderData, { error: 'SESSION_EXPIRED' }); cy.get('[role="alert"]').should('exist'); - }); - it('renders NO alert banner when there is no action error', () => { mountAt(AccountPicker, 'accounts', '/accounts', loaderData); cy.contains('Choose an account').should('exist'); cy.get('[role="alert"]').should('not.exist'); diff --git a/cypress/component/routes/login/conditional-passkey-loader.cy.ts b/cypress/component/routes/login/conditional-passkey-loader.cy.ts index 905ea10861..f2d6f92161 100644 --- a/cypress/component/routes/login/conditional-passkey-loader.cy.ts +++ b/cypress/component/routes/login/conditional-passkey-loader.cy.ts @@ -4,6 +4,8 @@ // ?organization=org1 is threaded so the loader RENDERS (same note as // last-used-login-loader.cy.ts). Singleton seed: u5 passkey-user@acme.test has // authMethods ['password','passkey']; u1 alice@acme.test is password-only. +// The hintless baseline (no hint → conditionalPasskey === null) lives in +// discovery-loader.cy.ts (its first test makes the identical call and assertion). import { callService } from '../../../support/node/call-service'; const PK_USER = 'passkey-user@acme.test'; @@ -14,14 +16,6 @@ type LoaderBody = { }; describe('/login loader — conditional passkey arming', () => { - it('no hint → the HINT arm stays inert (discovery arming is covered in discovery-loader.cy.ts)', () => { - callService({ fn: 'loginLoader', provider: 'singleton', request: { url: URL_BASE } }).then( - (v) => { - expect((v.response?.dataBody as LoaderBody).conditionalPasskey).to.equal(null); - } - ); - }); - it('hinted passkey user → arms: challenge returned, ceremony session persisted', () => { callService({ fn: 'loginLoader', diff --git a/cypress/component/routes/login/discovery-loader.cy.ts b/cypress/component/routes/login/discovery-loader.cy.ts index c50864b2c3..e231fb5ef6 100644 --- a/cypress/component/routes/login/discovery-loader.cy.ts +++ b/cypress/component/routes/login/discovery-loader.cy.ts @@ -6,7 +6,7 @@ // all of them — as long as the AUTH_PASSKEY_DISCOVERY_ENABLED kill switch is on. A // discovery arm must be free: self-minted options, NO Zitadel session, NO Set-Cookie. // Sibling of conditional-passkey-loader.cy.ts (the hinted path). -import { callService } from '../../../support/node/call-service'; +import { callService, type Scenario } from '../../../support/node/call-service'; const PK_USER = 'passkey-user@acme.test'; // u5, authMethods ['password','passkey'] const URL_BASE = 'http://localhost/id/login?organization=org1'; @@ -52,39 +52,48 @@ describe('/login loader — identity-discovery arming', () => { }); }); - it('a live session no longer suppresses discovery (the add-account population)', () => { - callService({ - fn: 'loginLoader', - provider: 'singleton', - liveSessions: [{ id: 's5', token: 't5', user: { id: 'u5', loginName: PK_USER } }], - request: { - url: URL_BASE, - sessions: [{ id: 's5', token: 't5', loginName: PK_USER }], + it('session entries do not suppress discovery arming', () => { + const rows: Array<{ label: string; scenario: Scenario }> = [ + { + label: 'live session (the add-account population)', + scenario: { + fn: 'loginLoader', + provider: 'singleton', + liveSessions: [{ id: 's5', token: 't5', user: { id: 'u5', loginName: PK_USER } }], + request: { + url: URL_BASE, + sessions: [{ id: 's5', token: 't5', loginName: PK_USER }], + }, + }, }, - }).then((v) => { - const body = v.response?.dataBody as LoaderBody; - expect(body.identityDiscovery?.publicKeyCredentialRequestOptions, 'discovery armed').to.exist; - }); - }); - - it('a STALE (expired) session entry does NOT suppress discovery', () => { - callService({ - fn: 'loginLoader', - provider: 'singleton', - request: { - url: URL_BASE, - sessions: [ - { - id: 's-old', - token: 't-old', - loginName: PK_USER, - expirationTs: '2020-01-01T00:00:00.000Z', + { + label: 'STALE (expired) session entry', + scenario: { + fn: 'loginLoader', + provider: 'singleton', + request: { + url: URL_BASE, + sessions: [ + { + id: 's-old', + token: 't-old', + loginName: PK_USER, + expirationTs: '2020-01-01T00:00:00.000Z', + }, + ], }, - ], + }, }, - }).then((v) => { - const body = v.response?.dataBody as LoaderBody; - expect(body.identityDiscovery?.publicKeyCredentialRequestOptions).to.exist; + ]; + + rows.forEach(({ label, scenario }) => { + callService(scenario).then((v) => { + const body = v.response?.dataBody as LoaderBody; + expect( + body.identityDiscovery?.publicKeyCredentialRequestOptions, + `${label}: discovery armed` + ).to.exist; + }); }); }); diff --git a/cypress/component/routes/login/passkey-discover.cy.ts b/cypress/component/routes/login/passkey-discover.cy.ts index 0a16fa6f38..04f59cc9fe 100644 --- a/cypress/component/routes/login/passkey-discover.cy.ts +++ b/cypress/component/routes/login/passkey-discover.cy.ts @@ -72,81 +72,89 @@ describe('/login/passkey-discover action', () => { }); }); - it('kill switch (AUTH_PASSKEY_DISCOVERY_ENABLED=false) → the SAME opaque 400', () => { - callService({ - fn: 'passkeyDiscoverAction', - provider: 'singleton', - env: { AUTH_PASSKEY_DISCOVERY_ENABLED: 'false' }, - request: { url: URL, form: { credential: assertionWith(B64_U5) }, csrf: true }, - }).then((v) => { - expect(v.response?.status).to.equal(400); - expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); - expect( - v.audit?.some( - (a) => - a.event === 'passkey_discover' && - a.outcome === 'failure' && - (a as { reason?: string }).reason === 'disabled' - ), - 'audited as disabled' - ).to.equal(true); - }); - }); - - it('absent userHandle (non-resident key) → opaque DISCOVERY_FAILED 400', () => { - callService({ - fn: 'passkeyDiscoverAction', - provider: 'singleton', - request: { url: URL, form: { credential: assertionWith(null) }, csrf: true }, - }).then((v) => { - expect(v.response?.status).to.equal(400); - expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); - }); - }); - - it('unknown userHandle → the SAME opaque DISCOVERY_FAILED 400, real reason in the audit', () => { - callService({ - fn: 'passkeyDiscoverAction', - provider: 'singleton', - request: { url: URL, form: { credential: assertionWith(B64_UNKNOWN) }, csrf: true }, - }).then((v) => { - expect(v.response?.status).to.equal(400); - expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); - // Enumeration parity is CALLER-facing only — operators get the specific reason. - expect( - v.audit?.some( - (a) => - a.event === 'passkey_discover' && - a.outcome === 'failure' && - (a as { reason?: string }).reason === 'unresolved_user' - ), - 'audited as unresolved_user' - ).to.equal(true); - }); - }); - - it('user without a passkey method → the SAME opaque DISCOVERY_FAILED 400', () => { - callService({ - fn: 'passkeyDiscoverAction', - provider: 'singleton', - request: { url: URL, form: { credential: assertionWith(B64_U1) }, csrf: true }, - }).then((v) => { - expect(v.response?.status).to.equal(400); - expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); - }); - }); + // ENUMERATION PARITY: every user-dependent failure must collapse into the SAME opaque + // 400, so a caller cannot distinguish "no such user" from "user has no passkey" from + // "key is non-resident". One table precisely because the property IS that these inputs + // are indistinguishable to the caller — scattered across separate tests it reads as + // coincidence rather than contract. + // + // Where an operator-facing audit reason exists it is asserted per row: parity is + // caller-facing only, and operators must still get the specific cause. + const OPAQUE_FAILURES: Array<{ + label: string; + scenario: Parameters[0]; + auditReason?: string; + }> = [ + { + label: 'kill switch (AUTH_PASSKEY_DISCOVERY_ENABLED=false)', + scenario: { + fn: 'passkeyDiscoverAction', + provider: 'singleton', + env: { AUTH_PASSKEY_DISCOVERY_ENABLED: 'false' }, + request: { url: URL, form: { credential: assertionWith(B64_U5) }, csrf: true }, + }, + auditReason: 'disabled', + }, + { + label: 'absent userHandle (non-resident key)', + scenario: { + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: assertionWith(null) }, csrf: true }, + }, + }, + { + label: 'unknown userHandle', + scenario: { + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: assertionWith(B64_UNKNOWN) }, csrf: true }, + }, + auditReason: 'unresolved_user', + }, + { + label: 'user without a passkey method', + scenario: { + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: assertionWith(B64_U1) }, csrf: true }, + }, + }, + { + label: 'malformed credential JSON (shape violations are non-events)', + scenario: { + fn: 'passkeyDiscoverAction', + provider: 'singleton', + request: { url: URL, form: { credential: 'not-json{' }, csrf: true }, + }, + }, + ]; - it('malformed credential JSON → opaque DISCOVERY_FAILED 400 (shape violations are non-events)', () => { - callService({ - fn: 'passkeyDiscoverAction', - provider: 'singleton', - request: { url: URL, form: { credential: 'not-json{' }, csrf: true }, - }).then((v) => { - expect(v.response?.status).to.equal(400); - expect((v.response?.dataBody as { error?: string }).error).to.equal('DISCOVERY_FAILED'); - }); + it('collapses every user-dependent failure into the SAME opaque DISCOVERY_FAILED 400, with the real reason only in the audit', () => { + for (const { label, scenario, auditReason } of OPAQUE_FAILURES) { + callService(scenario).then((v) => { + expect(v.response?.status, `${label}: status`).to.equal(400); + expect( + (v.response?.dataBody as { error?: string }).error, + `${label}: opaque body` + ).to.equal('DISCOVERY_FAILED'); + if (auditReason) { + expect( + v.audit?.some( + (a) => + a.event === 'passkey_discover' && + a.outcome === 'failure' && + (a as { reason?: string }).reason === auditReason + ), + `${label}: audited as ${auditReason}` + ).to.equal(true); + } + }); + } }); + // Kept out of the table: a DIFFERENT error code. The schema boundary is not + // user-dependent and is therefore deliberately outside the parity contract. it('missing credential field → INVALID_INPUT 400 (schema boundary, not user-dependent)', () => { callService({ fn: 'passkeyDiscoverAction', diff --git a/cypress/component/routes/logout/logout.cy.tsx b/cypress/component/routes/logout/logout.cy.tsx index 99bf6e23be..03365944df 100644 --- a/cypress/component/routes/logout/logout.cy.tsx +++ b/cypress/component/routes/logout/logout.cy.tsx @@ -26,17 +26,18 @@ function mountLogout() { }); return mount(withProviders()); } - describe('Logout confirm form — index-route POST disambiguation', () => { - it('targets the index action via ?index (not the action-less layout)', () => { + it('targets the index action via ?index, falling back to the generic confirm copy', () => { mountLogout(); cy.contains('button', /sign out/i).should('exist'); cy.get('form').should('have.attr', 'method', 'post'); // Native
posts to its action verbatim. Without ?index, RR routes POST to the // action-less logout/layout (→ 405); ?index targets routes/logout/index which owns the action. cy.get('form').invoke('attr', 'action').should('include', '?index'); + // mountLogout() seeds no loginName — the same falsy branch the standalone + // no-active-session test used to mount separately (loginName: ''). + cy.contains('Are you sure you want to sign out?').should('be.visible'); }); - it('shows "Sign out of " when an active session exists (no switch link)', () => { const router = createMemoryRouter([{ id: 'logout', path: '/logout', element: }], { initialEntries: ['/logout'], @@ -53,13 +54,4 @@ describe('Logout confirm form — index-route POST disambiguation', () => { cy.contains(/not you\?/i).should('not.exist'); cy.get('a[href="/login"], a[href^="/login?"]').should('not.exist'); }); - - it('falls back to the generic confirm copy when there is no active session', () => { - const router = createMemoryRouter([{ id: 'logout', path: '/logout', element: }], { - initialEntries: ['/logout'], - hydrationData: { loaderData: { logout: { csrfToken: 'test-csrf', loginName: '' } } }, - }); - mount(withProviders()); - cy.contains('Are you sure you want to sign out?').should('be.visible'); - }); }); diff --git a/cypress/component/routes/passkeys-ui.cy.tsx b/cypress/component/routes/passkeys-ui.cy.tsx index e9c90b3eec..511982f28f 100644 --- a/cypress/component/routes/passkeys-ui.cy.tsx +++ b/cypress/component/routes/passkeys-ui.cy.tsx @@ -65,25 +65,22 @@ function mountPasskeys( } describe('/id/passkeys — UI contract', () => { - it('active row: no badge, trash trigger with aria-label opens the danger confirm dialog', () => { + it('renders an active row with no badge or Added line, plus the identity switch link', () => { mountPasskeys(); cy.contains('Seeded laptop').should('be.visible'); cy.contains('Active').should('not.exist'); + // Seeded row carries no createdAt → no "Added " second line. + cy.contains('Added ').should('not.exist'); + // Identity row + sign-out (folded in — same bare mountPasskeys() fixture). + cy.contains('Logged in as').should('be.visible'); + cy.contains('mia@acme.test').should('be.visible'); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); + cy.contains('button', 'Sign out').should('be.visible'); cy.get('button[aria-label="Remove Seeded laptop"]').should('exist').click(); cy.contains('Remove this passkey?').should('be.visible'); cy.contains('button', 'Remove passkey').should('be.visible'); }); - it('inactive row keeps a muted Inactive badge', () => { - mountPasskeys({ - view: { - ...BASE_LOADER.view, - passkeys: [{ id: 'pk2', state: 'inactive', name: 'Stuck enrollment' }], - }, - }); - cy.contains('Inactive').should('be.visible'); - }); - it('successful removal opens the sign-out-others dialog; Not now closes it', () => { mountPasskeys(undefined, { removed: 'Seeded laptop' }); cy.contains('Passkey removed').should('be.visible'); @@ -119,51 +116,38 @@ describe('/id/passkeys — UI contract', () => { cy.get('ul').should('not.exist'); }); - it('shows the active login name, a "Not you?" switch link, and a sign-out action', () => { - mountPasskeys(); - cy.contains('Logged in as').should('be.visible'); - cy.contains('mia@acme.test').should('be.visible'); - cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); - cy.contains('button', 'Sign out').should('be.visible'); - }); - - it('row with createdAt renders the muted "Added " second line', () => { - const createdAt = '2026-07-21T10:00:00.000Z'; + it('renders the muted Added line for rows with createdAt', () => { // Same Intl path the app uses — deterministic across CI timezones. - const expected = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format( - new Date(createdAt) - ); + const fmt = (iso: string) => + new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format(new Date(iso)); + + const activeCreatedAt = '2026-07-21T10:00:00.000Z'; mountPasskeys({ view: { ...BASE_LOADER.view, - passkeys: [{ id: 'pk1', state: 'active', name: 'Seeded laptop', createdAt }], + passkeys: [ + { id: 'pk1', state: 'active', name: 'Seeded laptop', createdAt: activeCreatedAt }, + ], }, }); // Styling contract: the date line must carry the muted/small-text treatment. - cy.contains(`Added ${expected}`) + cy.contains(`Added ${fmt(activeCreatedAt)}`) .should('be.visible') .and('have.class', 'text-muted-foreground') .and('have.class', 'text-xs'); - }); - - it('row without createdAt renders no Added line (no created-at metadata)', () => { - mountPasskeys(); - cy.contains('Seeded laptop').should('be.visible'); - cy.contains('Added ').should('not.exist'); - }); - it('inactive row with createdAt renders both the Inactive badge and the Added line', () => { - const createdAt = '2026-07-15T08:30:00.000Z'; - const expected = new Intl.DateTimeFormat('en', { dateStyle: 'medium' }).format( - new Date(createdAt) - ); + // Inactive row: badge AND date line together. Subsumes the standalone + // "inactive row keeps a muted Inactive badge" case (strict subset of this one). + const inactiveCreatedAt = '2026-07-15T08:30:00.000Z'; mountPasskeys({ view: { ...BASE_LOADER.view, - passkeys: [{ id: 'pk2', state: 'inactive', name: 'Stuck enrollment', createdAt }], + passkeys: [ + { id: 'pk2', state: 'inactive', name: 'Stuck enrollment', createdAt: inactiveCreatedAt }, + ], }, }); cy.contains('Inactive').should('be.visible'); - cy.contains(`Added ${expected}`).should('be.visible'); + cy.contains(`Added ${fmt(inactiveCreatedAt)}`).should('be.visible'); }); }); diff --git a/cypress/component/routes/password/password-backlink.cy.tsx b/cypress/component/routes/password/password-backlink.cy.tsx index 7102a239cc..1bd6be7310 100644 --- a/cypress/component/routes/password/password-backlink.cy.tsx +++ b/cypress/component/routes/password/password-backlink.cy.tsx @@ -39,33 +39,25 @@ function mountAt( return mount(withProviders()); } -describe('password BackLink', () => { - it('password/new renders a Back link to /login/password (preserving the query)', () => { - mountAt(PasswordNew, 'password-new', '/password/new', { - csrfToken: 't', - code: 'c', - userId: 'u', - organization: undefined, - requestId: undefined, - }); +describe('password BackLink + inline action error (no toast)', () => { + const loaderData = { + csrfToken: 't', + code: 'c', + userId: 'u', + organization: undefined, + requestId: undefined, + }; + + it('renders a Back link preserving the query, and a role=alert banner on error', () => { + mountAt(PasswordNew, 'password-new', '/password/new', loaderData); cy.contains(/Choose a new password/i).should('exist'); cy.get('a[href*="/login/password"]') .should('exist') .and(($a) => { expect($a.attr('href')).to.include('loginName=a%40b.test'); }); - }); -}); -describe('password inline action error (no toast)', () => { - it('password/new renders a role="alert" banner when actionData.error is set', () => { - mountAt( - PasswordNew, - 'password-new', - '/password/new', - { csrfToken: 't', code: 'c', userId: 'u', organization: undefined, requestId: undefined }, - { error: 'INVALID_INPUT' } - ); + mountAt(PasswordNew, 'password-new', '/password/new', loaderData, { error: 'INVALID_INPUT' }); cy.get('[role="alert"]').should('exist'); }); }); diff --git a/cypress/component/routes/paths.cy.ts b/cypress/component/routes/paths.cy.ts index d36cd19243..f67b486dfe 100644 --- a/cypress/component/routes/paths.cy.ts +++ b/cypress/component/routes/paths.cy.ts @@ -6,27 +6,42 @@ // Migrated from: app/routes/__tests__/paths.test.ts import { paths } from '@/routes/paths'; -describe("paths.ts — typed builders return today's exact strings", () => { - it('builds login ceremony paths', () => { - expect(paths.login.index()).to.equal('/login'); - expect(paths.login.method()).to.equal('/login/method'); - expect(paths.login.password()).to.equal('/login/password'); - expect(paths.login.verify.email({})).to.equal('/login/verify/email'); - expect(paths.login.verify.email({ loginName: 'a@b.test', code: '123' })).to.equal( - '/login/verify/email?loginName=a%40b.test&code=123' - ); - expect(paths.login.verify.sms({})).to.equal('/login/verify/sms'); - expect(paths.login.verify.authenticator({})).to.equal('/login/verify/authenticator'); - }); +// Every builder is a pure call → exact string, so the whole surface is one table. +// Rows are evaluated eagerly: these are pure functions with no side effects. +const CASES: [label: string, actual: string, expected: string][] = [ + ['login.index', paths.login.index(), '/login'], + ['login.method', paths.login.method(), '/login/method'], + ['login.password', paths.login.password(), '/login/password'], + ['login.verify.email (bare)', paths.login.verify.email({}), '/login/verify/email'], + [ + 'login.verify.email (params URL-encoded)', + paths.login.verify.email({ loginName: 'a@b.test', code: '123' }), + '/login/verify/email?loginName=a%40b.test&code=123', + ], + ['login.verify.sms', paths.login.verify.sms({}), '/login/verify/sms'], + [ + 'login.verify.authenticator', + paths.login.verify.authenticator({}), + '/login/verify/authenticator', + ], + ['passkeys (bare)', paths.passkeys(), '/passkeys'], + ['reauth (bare)', paths.reauth(), '/reauth'], + [ + 'reauth (method + returnTo encoded)', + paths.reauth({ method: 'password', returnTo: '/passkeys' }), + '/reauth?method=password&returnTo=%2Fpasskeys', + ], + [ + 'passkeys (absolute returnTo encoded)', + paths.passkeys({ returnTo: 'https://portal.test/settings' }), + '/passkeys?returnTo=https%3A%2F%2Fportal.test%2Fsettings', + ], +]; - it('builds passkey-management and reauth paths', () => { - expect(paths.passkeys()).to.equal('/passkeys'); - expect(paths.reauth()).to.equal('/reauth'); - expect(paths.reauth({ method: 'password', returnTo: '/passkeys' })).to.equal( - '/reauth?method=password&returnTo=%2Fpasskeys' - ); - expect(paths.passkeys({ returnTo: 'https://portal.test/settings' })).to.equal( - '/passkeys?returnTo=https%3A%2F%2Fportal.test%2Fsettings' - ); +describe("paths.ts — typed builders return today's exact strings", () => { + it('builds every login ceremony, passkey-management and reauth path, URL-encoding params', () => { + for (const [label, actual, expected] of CASES) { + expect(actual, label).to.equal(expected); + } }); }); diff --git a/cypress/component/routes/reauth.cy.tsx b/cypress/component/routes/reauth.cy.tsx index f5bd4b8915..95f8d6b0bd 100644 --- a/cypress/component/routes/reauth.cy.tsx +++ b/cypress/component/routes/reauth.cy.tsx @@ -90,22 +90,16 @@ describe('/reauth — in-place passkey ceremony', () => { releaseChallenge = null; }); - it('shows the active identity with a "Not you?" link to /accounts', () => { + it('shows the identity and method chooser, and fires the Passkey ceremony in place', () => { mountReauth(); + // Identity row and chooser entries (folded in — all three shared one mountReauth()). cy.contains('Logged in as').should('be.visible'); cy.contains(CHOOSER_VIEW.loginName).should('be.visible'); cy.contains('a', 'Not you?').should('have.attr', 'href', '/accounts'); - }); - - it('lists Passkey and Password as chooser entries', () => { - mountReauth(); cy.contains("Confirm it's you").should('be.visible'); cy.contains('button', 'Passkey').should('be.visible'); cy.contains('a', 'Password').should('be.visible'); - }); - it('Passkey fires the ceremony in place and submits the pre-baked credential', () => { - mountReauth(); cy.contains('button', 'Passkey').click(); // Lazy challenge (fetcher.load ?method=passkey) → pre-baked credential → POST. cy.wrap(null).should(() => { diff --git a/cypress/component/routes/setup/authenticator-guard.cy.ts b/cypress/component/routes/setup/authenticator-guard.cy.ts index dd9e8321e0..629b611394 100644 --- a/cypress/component/routes/setup/authenticator-guard.cy.ts +++ b/cypress/component/routes/setup/authenticator-guard.cy.ts @@ -10,29 +10,31 @@ import { callService } from '../../../support/node/call-service'; const BASE = 'http://localhost/id/setup/authenticator'; -describe('/setup/authenticator loader — broken-session guard', () => { - it('no active session WITHOUT requestId → redirect to bare /login', () => { - callService({ - fn: 'setupAuthenticatorLoader', - request: { url: `${BASE}?loginName=alice%40acme.test` }, - }).then((v) => { - expect(v.error).to.be.undefined; - expect(v.response!.isResponse).to.be.true; - expect(v.response!.status).to.equal(302); - expect(v.response!.location).to.equal('/login'); - }); - }); +const GUARD_ROWS = [ + { + label: 'WITHOUT requestId → bare /login', + url: `${BASE}?loginName=alice%40acme.test`, + expectedLocation: '/login', + }, + { + label: 'WITH requestId → preserves requestId + organization', + url: `${BASE}?loginName=alice%40acme.test&requestId=rq1&organization=acme`, + expectedLocation: '/login?requestId=rq1&organization=acme', + }, +] as const; - it('no active session WITH requestId → redirect preserves requestId + organization', () => { - callService({ - fn: 'setupAuthenticatorLoader', - request: { - url: `${BASE}?loginName=alice%40acme.test&requestId=rq1&organization=acme`, - }, - }).then((v) => { - expect(v.response!.isResponse).to.be.true; - expect(v.response!.status).to.equal(302); - expect(v.response!.location).to.equal('/login?requestId=rq1&organization=acme'); +describe('/setup/authenticator loader — broken-session guard', () => { + it('no active session → redirect to /login, preserving ceremony params when present', () => { + GUARD_ROWS.forEach(({ label, url, expectedLocation }) => { + callService({ + fn: 'setupAuthenticatorLoader', + request: { url }, + }).then((v) => { + expect(v.error, `${label}: error`).to.be.undefined; + expect(v.response!.isResponse, `${label}: isResponse`).to.be.true; + expect(v.response!.status, `${label}: status`).to.equal(302); + expect(v.response!.location, `${label}: location`).to.equal(expectedLocation); + }); }); }); }); diff --git a/cypress/component/routes/setup/setup-render.cy.tsx b/cypress/component/routes/setup/setup-render.cy.tsx index f820d8fb87..fd6f560b4a 100644 --- a/cypress/component/routes/setup/setup-render.cy.tsx +++ b/cypress/component/routes/setup/setup-render.cy.tsx @@ -102,21 +102,22 @@ describe('setup/mfa — shared primitive adoption', () => { describe('F4 — Authenticator app link is announced once (decorative icon)', () => { const totpLoader = { ...IDENTITY, offerableKeys: ['totpOtp', 'emailOtp'] }; - it('renders the Authenticator icon as a decorative img (alt="" + aria-hidden)', () => { + it('renders the Authenticator icon as decorative, and suppresses the Back link', () => { mountRoute(SetupMfa, 'mfa', totpLoader); cy.get('img[src*="totp.png"]') .should('have.attr', 'alt', '') .and('have.attr', 'aria-hidden', 'true'); + + // Entry-step BackLink suppression, folded in from the "setup/* — BackLink renders to + // the predecessor" block. Kept as its own mount so the offerableKeys input is unchanged: + // auth-ceremony.cy.tsx proves the showBackLink mechanism, this proves setup/mfa wires it. + mountRoute(SetupMfa, 'mfa', { ...IDENTITY, offerableKeys: ['passkey'] }); + cy.get('a[href*="/login/password"]').should('not.exist'); }); }); }); describe('setup/* — BackLink renders to the predecessor', () => { - it('setup/mfa suppresses the Back link (entry step)', () => { - mountRoute(SetupMfa, 'mfa', { ...IDENTITY, offerableKeys: ['passkey'] }); - cy.get('a[href*="/login/password"]').should('not.exist'); - }); - it('clicking Back on /setup/passkey does not disable "Register passkey" while the predecessor loads', () => { // Regression: WebAuthnButton's busy state defaulted to navigation.state !== 'idle', // which is also true for an UNRELATED Link navigation elsewhere on the page (the diff --git a/cypress/component/routes/signed-in.cy.tsx b/cypress/component/routes/signed-in.cy.tsx index cd487c0b52..e708f30162 100644 --- a/cypress/component/routes/signed-in.cy.tsx +++ b/cypress/component/routes/signed-in.cy.tsx @@ -34,19 +34,13 @@ function mountSignedIn(loginName: string | null = 'mia@acme.test') { } describe('/signed-in — identity + switch-account link + sign-out', () => { - it('shows "You are signed in as " with a "Not you?" link to /accounts', () => { + it('renders the signed-in identity with a Not you? link and a Sign out form posting to ?index', () => { mountSignedIn(); cy.contains('You are signed in as').should('be.visible'); cy.contains('mia@acme.test').should('be.visible'); cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); - }); - - it('the Sign out form posts to /id/logout?index', () => { - mountSignedIn(); cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('be.visible'); - }); - it('renders no identity line when loginName is absent', () => { mountSignedIn(null); cy.contains('You are signed in as').should('not.exist'); }); diff --git a/cypress/component/routes/signup/default-org-idps.cy.ts b/cypress/component/routes/signup/default-org-idps.cy.ts index bb09028dd6..246726e8ca 100644 --- a/cypress/component/routes/signup/default-org-idps.cy.ts +++ b/cypress/component/routes/signup/default-org-idps.cy.ts @@ -12,57 +12,59 @@ // // Mirror of: cypress/component/routes/login/default-org-fallback.cy.ts import { callService } from '../../../support/node/call-service'; +import type { Scenario } from '../../../support/node/call-service'; -describe('signup loaders — default-org fallback is display-only for IdP list', () => { - it('signupIndexLoader: bare /signup (no ?organization=) → getActiveIdPs called with default org, not undefined', () => { - callService({ - fn: 'signupIndexLoader', - provider: 'singleton', - request: { url: 'http://localhost/id/signup?requestId=oidc_test123' }, - recordCalls: ['getDefaultOrg', 'getLoginSettings', 'getActiveIdPs'], - }).then((v) => { - // no redirect — bare signup URL must NOT produce a 302 - expect(v.response?.isResponse ?? false).to.equal(false); - const status = v.response?.status ?? 200; - expect(status).to.not.equal(302); - - // resolveOrg consulted the default org exactly once (for display reads) - expect(v.calls?.getDefaultOrg).to.have.length(1); +// Both loaders must honor the same five-part contract on a bare signup URL; each row is a +// separate cy.task (its own Bun process + its own recorded-call arrays), differing only by +// {fn, url}. The row label prefixes every assertion so a failure names the exact loader. +const LOADERS: Array<{ label: string; fn: Scenario['fn']; url: string }> = [ + { + label: 'signupIndexLoader bare /signup', + fn: 'signupIndexLoader', + url: 'http://localhost/id/signup?requestId=oidc_test123', + }, + { + label: 'signupMethodLoader bare /signup/method', + fn: 'signupMethodLoader', + url: 'http://localhost/id/signup/method?loginName=test%40example.com&requestId=oidc_test123', + }, +]; - // getLoginSettings scoped to the default org - expect(v.calls?.getLoginSettings).to.have.length(1); - expect((v.calls?.getLoginSettings ?? [])[0]?.[0]).to.equal('org-default-fake'); - - // getActiveIdPs MUST also use the resolved default org — NOT undefined - expect(v.calls?.getActiveIdPs).to.have.length(1); - expect((v.calls?.getActiveIdPs ?? [])[0]?.[0]).to.equal('org-default-fake'); - }); - }); - - it('signupMethodLoader: bare /signup/method (no ?organization=) → getActiveIdPs called with default org, not undefined', () => { - callService({ - fn: 'signupMethodLoader', - provider: 'singleton', - request: { - url: 'http://localhost/id/signup/method?loginName=test%40example.com&requestId=oidc_test123', - }, - recordCalls: ['getDefaultOrg', 'getLoginSettings', 'getActiveIdPs'], - }).then((v) => { - // no redirect - expect(v.response?.isResponse ?? false).to.equal(false); - const status = v.response?.status ?? 200; - expect(status).to.not.equal(302); +describe('signup loaders — default-org fallback is display-only for IdP list', () => { + it('bare signup URLs (no ?organization=) → getActiveIdPs called with default org, not undefined', () => { + LOADERS.forEach(({ label, fn, url }) => { + callService({ + fn, + provider: 'singleton', + request: { url }, + recordCalls: ['getDefaultOrg', 'getLoginSettings', 'getActiveIdPs'], + }).then((v) => { + // no redirect — bare signup URL must NOT produce a 302 + expect( + v.response?.isResponse ?? false, + `${label}: must not return a redirect Response` + ).to.equal(false); + const status = v.response?.status ?? 200; + expect(status, `${label}: status must not be 302`).to.not.equal(302); - // resolveOrg consulted the default org - expect(v.calls?.getDefaultOrg).to.have.length(1); + // resolveOrg consulted the default org exactly once (for display reads) + expect(v.calls?.getDefaultOrg, `${label}: getDefaultOrg call count`).to.have.length(1); - // getLoginSettings scoped to the default org - expect(v.calls?.getLoginSettings).to.have.length(1); - expect((v.calls?.getLoginSettings ?? [])[0]?.[0]).to.equal('org-default-fake'); + // getLoginSettings scoped to the default org + expect(v.calls?.getLoginSettings, `${label}: getLoginSettings call count`).to.have.length( + 1 + ); + expect( + (v.calls?.getLoginSettings ?? [])[0]?.[0], + `${label}: getLoginSettings org arg` + ).to.equal('org-default-fake'); - // getActiveIdPs MUST also use the resolved default org — NOT undefined - expect(v.calls?.getActiveIdPs).to.have.length(1); - expect((v.calls?.getActiveIdPs ?? [])[0]?.[0]).to.equal('org-default-fake'); + // getActiveIdPs MUST also use the resolved default org — NOT undefined + expect(v.calls?.getActiveIdPs, `${label}: getActiveIdPs call count`).to.have.length(1); + expect((v.calls?.getActiveIdPs ?? [])[0]?.[0], `${label}: getActiveIdPs org arg`).to.equal( + 'org-default-fake' + ); + }); }); }); }); diff --git a/cypress/component/routes/signup/method-back-link.cy.tsx b/cypress/component/routes/signup/method-back-link.cy.tsx index 491931d569..b8109f83ad 100644 --- a/cypress/component/routes/signup/method-back-link.cy.tsx +++ b/cypress/component/routes/signup/method-back-link.cy.tsx @@ -47,20 +47,16 @@ function mountSignupMethod() { return mount(withI18n()); } -describe('/signup/method — exactly one Back control, targeting /signup', () => { - it('renders a single Back link (no duplicate)', () => { +describe('/signup/method — exactly one Back control + identity, both targeting /signup', () => { + it('renders a single Back link (no duplicate) and shows "Signing up as . Not you?" linking back to /signup', () => { mountSignupMethod(); cy.get('a') .filter(':contains("Back")') .should('have.length', 1) .and('have.attr', 'href') .and('match', /^\/signup(\?|$)/); - }); -}); -describe('/signup/method — identity + Not you? (new, mirrors signup/password)', () => { - it('shows "Signing up as . Not you?" linking back to /signup', () => { - mountSignupMethod(); + // Identity + "Not you?" (mirrors signup/password) — same mountSignupMethod() fixture. cy.contains('Signing up as').should('be.visible'); cy.contains(LOADER_DATA.loginName).should('be.visible'); cy.findByRole('link', { name: /not you/i }) diff --git a/cypress/component/routes/signup/password-render.cy.tsx b/cypress/component/routes/signup/password-render.cy.tsx index 5a63d9cc36..f0467792bb 100644 --- a/cypress/component/routes/signup/password-render.cy.tsx +++ b/cypress/component/routes/signup/password-render.cy.tsx @@ -63,16 +63,15 @@ function mountPassword( } describe('signup/password — render adoption', () => { - it('renders the ceremony-owned BackLink pointing to /signup', () => { + it('renders the ceremony BackLink to /signup and threads requestId + organization', () => { + // Bare fixture: the ceremony-owned BackLink resolves to /signup. mountPassword(); cy.findByRole('link', { name: /back/i }, { timeout: 6000 }).should( 'have.attr', 'href', '/signup' ); - }); - it('"Not you?" link threads the ceremony requestId + organization back to /signup (not a bare /signup)', () => { // Seed real ceremony params so the assertion actually exercises threading — with the bare // fixture (requestId/organization undefined) the href degrades to "/signup" no matter whether // threading works, is hardcoded, or is wired to the wrong fields, so it proves nothing. diff --git a/cypress/component/routes/signup/signup-render.cy.tsx b/cypress/component/routes/signup/signup-render.cy.tsx index 956fc1e08f..63554560d9 100644 --- a/cypress/component/routes/signup/signup-render.cy.tsx +++ b/cypress/component/routes/signup/signup-render.cy.tsx @@ -80,7 +80,7 @@ function mountSignup(data: unknown, onSubmitFormData?: (form: FormData) => void) } describe('signup/index — render adoption', () => { - it('emits csrf + requestId + organization hidden inputs on the IdP button form', () => { + it('emits csrf + requestId + organization on the IdP form, and hides Email entry when disabled', () => { mountSignup(loaderData()); cy.contains('Google', { timeout: 6000 }).should('exist'); // The first form is the IdP form; it must carry csrf, requestId, organization. @@ -91,9 +91,7 @@ describe('signup/index — render adoption', () => { cy.get('input[type="hidden"][name="requestId"]').should('have.value', 'rq-123'); cy.get('input[type="hidden"][name="organization"]').should('have.value', 'acme'); }); - }); - it('shows IdP button and hides Email entry button when emailDeliveryEnabled=false (IdP-only signup)', () => { // view.allowEmailEntry=false when delivery is off — the Email button must not appear. mountSignup( loaderData({ diff --git a/cypress/component/routes/sso/provider-error-render.cy.tsx b/cypress/component/routes/sso/provider-error-render.cy.tsx index e6c02b54c8..ad7d7db277 100644 --- a/cypress/component/routes/sso/provider-error-render.cy.tsx +++ b/cypress/component/routes/sso/provider-error-render.cy.tsx @@ -6,26 +6,25 @@ // requestId/organization off the CURRENT URL and threads them onto the link. import SsoError from '@/routes/sso/provider/error'; -describe('sso/provider/error — "Back to sign in" preserves the ceremony', () => { - it('threads requestId + organization from the current URL onto "Back to sign in"', () => { - cy.mount(, { - path: '/sso/:provider/error', - initialEntries: [ - '/sso/google/error?reason=signin_failed&requestId=oidc_V2_123&organization=org-1', - ], - }); - cy.findByRole('link', { name: 'Back to sign in' }).should( - 'have.attr', - 'href', - '/login?requestId=oidc_V2_123&organization=org-1' - ); - }); +// Positive and negative of the same link-href feature: same mount, same findByRole +// assertion, differing only by the URL the error page is rendered at. No whole-DOM +// negative involved (the link is always present), so mount order is not load-bearing. +const CASES: [label: string, entry: string, href: string][] = [ + [ + 'ceremony context present', + '/sso/google/error?reason=signin_failed&requestId=oidc_V2_123&organization=org-1', + '/login?requestId=oidc_V2_123&organization=org-1', + ], + ['no ceremony context', '/sso/google/error?reason=signin_failed', '/login'], +]; - it('degrades to a bare /login when no ceremony context is present', () => { - cy.mount(, { - path: '/sso/:provider/error', - initialEntries: ['/sso/google/error?reason=signin_failed'], - }); - cy.findByRole('link', { name: 'Back to sign in' }).should('have.attr', 'href', '/login'); +describe('sso/provider/error — "Back to sign in" preserves the ceremony', () => { + it('threads requestId + organization from the current URL onto "Back to sign in", degrading to a bare /login without them', () => { + for (const [label, entry, href] of CASES) { + cy.mount(, { path: '/sso/:provider/error', initialEntries: [entry] }); + cy.findByRole('link', { name: 'Back to sign in' }).should(($a) => { + expect($a.attr('href'), label).to.equal(href); + }); + } }); }); diff --git a/cypress/component/routes/sso/sso-render.cy.tsx b/cypress/component/routes/sso/sso-render.cy.tsx index 2659f8c79b..5fb88d8b9b 100644 --- a/cypress/component/routes/sso/sso-render.cy.tsx +++ b/cypress/component/routes/sso/sso-render.cy.tsx @@ -11,7 +11,6 @@ // allowUnlink-gate, and multi-identity permutations are cut here. import SsoIndex from '@/routes/sso/index'; import SsoLdap from '@/routes/sso/ldap'; -import SsoError from '@/routes/sso/provider/error'; import { ConformAdapter } from '@datum-cloud/datum-ui/form/adapters/conform'; import { setupI18n } from '@lingui/core'; import { I18nProvider } from '@lingui/react'; @@ -96,13 +95,17 @@ describe('SsoIndex — unlink guard: dialog confirm + disabled sole sign-in meth allowUnlink: true, }; - it('disables the Unlink control for the sole-method (unlinkable:false) row', () => { + it('disables Unlink for the sole-method row, and shows the active login name', () => { mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', loaderData); cy.contains('Linked accounts').should('exist'); // Inert via aria-disabled (kept focusable for a11y), not the native `disabled` attribute. cy.get('button[aria-disabled="true"]').contains('Unlink').should('exist'); + // Identity/sign-out row (folded in — same mount). + cy.contains('Logged in as').should('exist'); + cy.contains(loaderData.loginName).should('exist'); + cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); + cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('exist'); }); - it('keeps the unlink confirm form out of the DOM until the dialog is opened', () => { mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', loaderData); cy.contains('Linked accounts').should('exist'); @@ -116,26 +119,8 @@ describe('SsoIndex — unlink guard: dialog confirm + disabled sole sign-in meth // …and exposes an enabled submit button to complete the unlink (the "Confirm submits" path). cy.get('button[type="submit"]').contains('Unlink').should('exist').and('not.be.disabled'); }); - - it('shows the active login name with a "Not you?" switch link and a Sign out control', () => { - mountRoute(SsoIndex, 'sso-index', '/sso', '/sso', loaderData); - cy.contains('Logged in as').should('exist'); - cy.contains(loaderData.loginName).should('exist'); - cy.findByRole('link', { name: /not you\?/i }).should('have.attr', 'href', '/accounts'); - cy.get('form[action="/id/logout?index"]').contains('button', 'Sign out').should('exist'); - }); -}); - -// ── sso/provider/error ──────────────────────────────────────────────────────── - -describe('SsoError — typed paths.login.index() emits the byte-frozen login URL', () => { - it('"Back to sign in" link resolves to the byte-frozen /login URL', () => { - mountRoute( - SsoError, - 'sso-error', - '/sso/:provider/error', - '/sso/google/error?reason=access-denied' - ); - cy.contains('a', /Back to sign in/i).should('have.attr', 'href', '/login'); - }); }); +// sso/provider/error's "Back to sign in" → bare /login assertion lived here too, but it was a +// literal duplicate of provider-error-render.cy.tsx's "degrades to a bare /login when no +// ceremony context is present" (same component, same expected href; only an unused `reason` +// query param differed). Removed as a true duplicate — that file remains its sole owner. diff --git a/cypress/component/server/env.cy.ts b/cypress/component/server/env.cy.ts index 787edb185b..0c88d8edd1 100644 --- a/cypress/component/server/env.cy.ts +++ b/cypress/component/server/env.cy.ts @@ -3,16 +3,17 @@ // Pure function: requireEmailVerification takes a plain Record — no node deps. import { requireEmailVerification } from '@/server/env'; -describe('requireEmailVerification', () => { - it('defaults to false when EMAIL_VERIFICATION is unset (opt-in)', () => { - expect(requireEmailVerification({})).to.equal(false); - }); +// Opt-in by default: only the exact string 'true' enables verification. +const CASES: [label: string, env: Record, expected: boolean][] = [ + ['unset (opt-in default)', {}, false], + ['explicitly "false"', { EMAIL_VERIFICATION: 'false' }, false], + ['exactly "true"', { EMAIL_VERIFICATION: 'true' }, true], +]; - it('returns false when EMAIL_VERIFICATION is explicitly "false"', () => { - expect(requireEmailVerification({ EMAIL_VERIFICATION: 'false' })).to.equal(false); - }); - - it('returns true only when EMAIL_VERIFICATION is exactly "true"', () => { - expect(requireEmailVerification({ EMAIL_VERIFICATION: 'true' })).to.equal(true); +describe('requireEmailVerification', () => { + it('defaults to false and returns true only when EMAIL_VERIFICATION is exactly "true"', () => { + for (const [label, env, expected] of CASES) { + expect(requireEmailVerification(env), label).to.equal(expected); + } }); }); diff --git a/cypress/component/server/middleware/legacy-redirects.cy.ts b/cypress/component/server/middleware/legacy-redirects.cy.ts index 8f9c693437..9a7fee5d9c 100644 --- a/cypress/component/server/middleware/legacy-redirects.cy.ts +++ b/cypress/component/server/middleware/legacy-redirects.cy.ts @@ -3,17 +3,24 @@ // Pure string → string mapper — no node deps. import { legacyRedirectTarget } from '@/server/middleware/legacy-redirects'; -describe('legacyRedirectTarget', () => { - it('renames idp/link → sso/link and preserves the query string', () => { - expect(legacyRedirectTarget('/ui/v2/login/idp/link', '?organization=acme')).to.equal( - '/id/sso/link?organization=acme' - ); - }); +// null means "not a legacy path — leave it alone"; a string is the rewrite target. +const CASES: [label: string, path: string, query: string, expected: string | null][] = [ + [ + 'idp/link renamed, query preserved', + '/ui/v2/login/idp/link', + '?organization=acme', + '/id/sso/link?organization=acme', + ], + ['unknown legacy subpath falls back to the login index', '/ui/v2/login/bogus', '', '/id/login'], + ['over-long legacy subpath falls back too', '/ui/v2/login/idp/link/extra', '', '/id/login'], + ['already-current path is not a legacy path', '/id/login', '', null], + ['prefix lookalike is not a legacy path', '/ui/v2/loginXYZ', '', null], +]; - it('falls back to the login index for unknown/malformed legacy subpaths, and returns null for non-legacy paths', () => { - expect(legacyRedirectTarget('/ui/v2/login/bogus', '')).to.equal('/id/login'); - expect(legacyRedirectTarget('/ui/v2/login/idp/link/extra', '')).to.equal('/id/login'); - expect(legacyRedirectTarget('/id/login', '')).to.be.null; - expect(legacyRedirectTarget('/ui/v2/loginXYZ', '')).to.be.null; +describe('legacyRedirectTarget', () => { + it('rewrites legacy login paths (preserving the query), falls back to the login index for unknown subpaths, and returns null for non-legacy paths', () => { + for (const [label, path, query, expected] of CASES) { + expect(legacyRedirectTarget(path, query), label).to.equal(expected); + } }); }); diff --git a/cypress/component/server/middleware/secure-headers.cy.ts b/cypress/component/server/middleware/secure-headers.cy.ts index ee37b18e1a..d7582dfc83 100644 --- a/cypress/component/server/middleware/secure-headers.cy.ts +++ b/cypress/component/server/middleware/secure-headers.cy.ts @@ -40,27 +40,34 @@ describe('CSP style-src policy', () => { }); describe('resolveFrameAncestors', () => { - it('parses a space- or comma-separated allowlist of origins', () => { - expect(resolveFrameAncestors('https://a.example.com https://b.example.com')).to.deep.equal([ - 'https://a.example.com', - 'https://b.example.com', - ]); - expect(resolveFrameAncestors('https://a.example.com, https://b.example.com')).to.deep.equal([ - 'https://a.example.com', - 'https://b.example.com', - ]); + // Parsing and token-validation of an OPERATOR-set allowlist (deploy config, not + // attacker input). Same call, same deep.equal shape, differing only by input string. + const PARSING: [label: string, input: string, expected: string[]][] = [ + [ + 'space-separated allowlist', + 'https://a.example.com https://b.example.com', + ['https://a.example.com', 'https://b.example.com'], + ], + [ + 'comma-separated allowlist', + 'https://a.example.com, https://b.example.com', + ['https://a.example.com', 'https://b.example.com'], + ], + ['unparseable token', 'not-a-url', ["'none'"]], + ['non-http(s) scheme', 'ftp://x.example.com', ["'none'"]], + ['path stripped to origin', 'https://a.example.com/embed', ['https://a.example.com']], + ]; + + it("parses space- or comma-separated origins and drops unparseable / non-http(s) tokens, falling back to 'none' if nothing valid remains", () => { + for (const [label, input, expected] of PARSING) { + expect(resolveFrameAncestors(input), label).to.deep.equal(expected); + } }); + // Kept standalone: this is the clickjacking footgun, not a parsing case. A wildcard + // that survived would expose every page to framing, so it must fail on its own. it("rejects a bare wildcard and falls back to 'none' (clickjacking footgun)", () => { expect(resolveFrameAncestors('*')).to.deep.equal(["'none'"]); expect(resolveFrameAncestors('https://a.example.com *')).to.deep.equal(["'none'"]); }); - - it("drops unparseable / non-http(s) tokens, falling back to 'none' if nothing valid remains", () => { - expect(resolveFrameAncestors('not-a-url')).to.deep.equal(["'none'"]); - expect(resolveFrameAncestors('ftp://x.example.com')).to.deep.equal(["'none'"]); - expect(resolveFrameAncestors('https://a.example.com/embed')).to.deep.equal([ - 'https://a.example.com', - ]); - }); }); diff --git a/cypress/component/support/to-request-url.cy.ts b/cypress/component/support/to-request-url.cy.ts index 4d077226d3..0c8e412efe 100644 --- a/cypress/component/support/to-request-url.cy.ts +++ b/cypress/component/support/to-request-url.cy.ts @@ -11,19 +11,27 @@ describe('toRequestUrl', () => { }); }); -describe('extractChooserMethods', () => { - it('reads the chooser loader payload back as the account methods, in a stable order', () => { - // Shape of a single-fetch (turbo-stream) loader payload: a flat array of keys and values. - const body = - '[{"loginName":1,"methods":2,"idps":5},"a@b.test",["password","passkey"],"tok",[]]'; - expect(extractChooserMethods(body)).to.deep.equal(['passkey', 'password']); - }); +// Each row is one pure call → one deep.equal on the parsed methods array. The first body +// is the shape of a single-fetch (turbo-stream) loader payload: a flat array of keys and +// values. +const CASES: [label: string, body: string, expected: string[]][] = [ + [ + 'chooser loader payload, stable order', + '[{"loginName":1,"methods":2,"idps":5},"a@b.test",["password","passkey"],"tok",[]]', + ['passkey', 'password'], + ], + [ + 'sole-password account — the only case that gets a password step', + '[{"methods":1},["password"]]', + ['password'], + ], + ['no methods — a sole-IdP 302 has no loader data', '', []], +]; - it('reports a single method for the sole-password account — the only case that gets a password step', () => { - expect(extractChooserMethods('[{"methods":1},["password"]]')).to.deep.equal(['password']); - }); - - it('reports nothing for a body carrying no methods (a sole-IdP 302 has no loader data)', () => { - expect(extractChooserMethods('')).to.deep.equal([]); +describe('extractChooserMethods', () => { + it('reads the chooser loader payload back as the account methods in a stable order, down to a single method or none', () => { + for (const [label, body, expected] of CASES) { + expect(extractChooserMethods(body), label).to.deep.equal(expected); + } }); }); diff --git a/cypress/component/utils/errors/auth-error-messages.cy.tsx b/cypress/component/utils/errors/auth-error-messages.cy.tsx index ab7c94ff10..4e8e3a1da9 100644 --- a/cypress/component/utils/errors/auth-error-messages.cy.tsx +++ b/cypress/component/utils/errors/auth-error-messages.cy.tsx @@ -43,17 +43,22 @@ describe('useAuthErrorMessage — device-code lookup codes (lowercase)', () => { }); }); -describe('useAuthErrorMessage — baseline behavior', () => { - it('returns undefined for no code (empty error surface)', () => { - cy.mount(); - cy.get('[data-testid="msg"]').should('have.text', '__undefined__'); - }); +// Baseline lookup: same mount, same have.text assertion, differing only by code and +// expected message. The harness always renders the msg node, so no whole-DOM negative +// is involved and mount order is not load-bearing. +const BASELINE: [label: string, code: string | undefined, expected: string][] = [ + ['no code (empty error surface)', undefined, '__undefined__'], + ['known code', 'INVALID_CREDENTIALS', 'Incorrect credentials. Please try again.'], + ['unknown code falls back to generic', 'SOME_UNKNOWN_CODE', GENERIC], +]; - it('resolves a known code to its specific message, and falls back to the generic message for an unknown code', () => { - cy.mount(); - cy.get('[data-testid="msg"]').should('have.text', 'Incorrect credentials. Please try again.'); - - cy.mount(); - cy.get('[data-testid="msg"]').should('have.text', GENERIC); +describe('useAuthErrorMessage — baseline behavior', () => { + it('returns undefined for no code, resolves a known code to its message, and falls back to the generic message for an unknown one', () => { + for (const [label, code, expected] of BASELINE) { + cy.mount(); + cy.get('[data-testid="msg"]').should(($el) => { + expect($el.text(), label).to.equal(expected); + }); + } }); }); diff --git a/cypress/component/utils/errors/auth-error.cy.ts b/cypress/component/utils/errors/auth-error.cy.ts index 87da7eca8b..5acdd6d044 100644 --- a/cypress/component/utils/errors/auth-error.cy.ts +++ b/cypress/component/utils/errors/auth-error.cy.ts @@ -18,19 +18,25 @@ const GENERIC = { }; describe('authErrorMessage', () => { - it('returns the exact mapped message for each known code', () => { + it('returns the exact mapped message for every known code, and the generic fallback for an unknown code or null/undefined', () => { + // Every catalogued code resolves to its own entry... const codes = Object.keys(AUTH_ERRORS) as AuthErrorCode[]; for (const code of codes) { - expect(authErrorMessage(code)).to.deep.equal(AUTH_ERRORS[code]); + expect(authErrorMessage(code), `known code ${code}`).to.deep.equal(AUTH_ERRORS[code]); } - }); - it('returns the generic fallback for an unknown code, and for null/undefined', () => { - expect(authErrorMessage('not_a_real_code')).to.deep.equal(GENERIC); - expect(authErrorMessage(null)).to.deep.equal(GENERIC); - expect(authErrorMessage(undefined)).to.deep.equal(GENERIC); + // ...and everything outside the catalog collapses to the generic message. + const UNKNOWN: [label: string, input: string | null | undefined][] = [ + ['unknown code', 'not_a_real_code'], + ['null', null], + ['undefined', undefined], + ]; + for (const [label, input] of UNKNOWN) { + expect(authErrorMessage(input), label).to.deep.equal(GENERIC); + } }); + // Kept standalone: the tamper/XSS invariant, with its own not.include assertions. it('does NOT echo a tampered/raw query value back to the caller (security)', () => { const tampered = ''; const result = authErrorMessage(tampered); @@ -43,16 +49,20 @@ describe('authErrorMessage', () => { }); describe('providerErrorCode', () => { - it('maps UNAVAILABLE to service_unavailable', () => { - expect(providerErrorCode('UNAVAILABLE')).to.equal('service_unavailable'); - }); + const MAPPINGS: [label: string, input: string | undefined, expected: string][] = [ + ['UNAVAILABLE is the one specific mapping', 'UNAVAILABLE', 'service_unavailable'], + ['other provider code', 'DEADLINE_EXCEEDED', 'signin_failed'], + ['unknown code', 'UNKNOWN', 'signin_failed'], + ['undefined', undefined, 'signin_failed'], + ]; - it('falls back to signin_failed for any other code, or for undefined', () => { - expect(providerErrorCode('DEADLINE_EXCEEDED')).to.equal('signin_failed'); - expect(providerErrorCode('UNKNOWN')).to.equal('signin_failed'); - expect(providerErrorCode(undefined)).to.equal('signin_failed'); + it('maps UNAVAILABLE to service_unavailable and falls back to signin_failed for any other code or undefined', () => { + for (const [label, input, expected] of MAPPINGS) { + expect(providerErrorCode(input), label).to.equal(expected); + } }); + // Kept standalone: a closure property over the catalog, not an input→output row. it('only ever returns a known AuthErrorCode (so authErrorMessage never falls through)', () => { const result = providerErrorCode('UNAVAILABLE'); expect(AUTH_ERRORS[result]).to.not.be.undefined; From 6111c1909fda70a7dc0b67b33b9d6e3af514d120 Mon Sep 17 00:00:00 2001 From: Yahya Fakhroji Date: Mon, 3 Aug 2026 12:23:06 +0700 Subject: [PATCH 2/2] test(cypress): cover the untested branch of five guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each was only exercised in one direction, so an implementation stuck at that answer would have passed: post-logout redirect validation, same-origin returnTo, the requestId allowlist on account removal, factor freshness, and the authorize bridge. All pass against the current implementations — these were coverage gaps, not defects. --- .../component/resources/login/bridge.cy.ts | 18 ++++++++-- .../session/post-logout-redirect.cy.ts | 33 +++++++++++++++++ .../session/session.service.switch.cy.ts | 29 +++++++++++++++ .../resources/shared/lifetimes.cy.ts | 36 +++++++++++++++++-- .../resources/sso/sso-returnto-cache.cy.ts | 28 +++++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) diff --git a/cypress/component/resources/login/bridge.cy.ts b/cypress/component/resources/login/bridge.cy.ts index 4589d3b405..f550e01d88 100644 --- a/cypress/component/resources/login/bridge.cy.ts +++ b/cypress/component/resources/login/bridge.cy.ts @@ -8,8 +8,22 @@ function params(search: string): URLSearchParams { return new URL(`http://localhost/id/login${search}`).searchParams; } +// Only the `false` branch was covered, so a predicate hardwired to false would have passed +// while the OIDC and SAML bridges silently stopped firing — a dead protocol hop that no test +// would notice. Both true branches are distinct params, asserted separately. +const CASES: [label: string, search: string, expected: boolean][] = [ + ['an OIDC authRequest bridges', '?authRequest=oidc_V2_abc', true], + ['a SAML samlRequest bridges', '?samlRequest=saml_abc', true], + // The post-identifier return carries requestId, NOT authRequest — bridging here would + // bounce the user back to /authorize and loop. + ['the post-identifier ?requestId= return does NOT (no loop)', '?requestId=oidc_V2_abc', false], + ['a bare /login does not bridge', '', false], +]; + describe('/login → /authorize protocol bridge', () => { - it('does NOT re-trigger on the post-identifier ?requestId= return (no loop)', () => { - expect(shouldBridgeToAuthorize(params('?requestId=oidc_V2_abc'))).to.equal(false); + it('bridges on an OIDC authRequest or a SAML samlRequest, but never on the post-identifier requestId return', () => { + for (const [label, search, expected] of CASES) { + expect(shouldBridgeToAuthorize(params(search)), label).to.equal(expected); + } }); }); diff --git a/cypress/component/resources/session/post-logout-redirect.cy.ts b/cypress/component/resources/session/post-logout-redirect.cy.ts index e2b1913df7..9fdd2c735c 100644 --- a/cypress/component/resources/session/post-logout-redirect.cy.ts +++ b/cypress/component/resources/session/post-logout-redirect.cy.ts @@ -8,5 +8,38 @@ describe('validatePostLogoutRedirect', () => { expect( validatePostLogoutRedirect(req('?post_logout_redirect=http://localhost:3001/login'), ALLOW) ).to.equal('http://localhost:3001/login'); + // The RP may send either spelling; both are honored. + expect( + validatePostLogoutRedirect( + req('?post_logout_redirect_uri=http://localhost:3001/login'), + ALLOW + ) + ).to.equal('http://localhost:3001/login'); + }); + + // The reject half of the guard. Without these, nothing proved the allowlist was + // load-bearing — a build that returned `target` unconditionally would have passed the + // accept case alone. null means "not honored"; the caller falls back to /logout/success. + const REJECTED: [label: string, query: string][] = [ + ['origin not on the allowlist', '?post_logout_redirect=https://evil.example/steal'], + // Same host, different port/scheme — origin comparison must be exact, not substring. + ['allowlisted host on a different port', '?post_logout_redirect=http://localhost:9999/login'], + [ + 'allowlisted host over a different scheme', + '?post_logout_redirect=https://localhost:3001/login', + ], + // Zitadel's own default is a relative path, and it is NOT a route in this UI. new URL() + // throws on it, so it must be rejected rather than redirected to. + ['a relative path (Zitadel default /logout/done)', '?post_logout_redirect=/logout/done'], + ['a protocol-relative authority', '?post_logout_redirect=//evil.example/steal'], + ['a javascript: URL', '?post_logout_redirect=javascript:alert(1)'], + ['no redirect parameter at all', ''], + ['an empty redirect parameter', '?post_logout_redirect='], + ]; + + it('returns null for anything that is not an allowlisted absolute URL, so the caller falls back to /logout/success', () => { + for (const [label, query] of REJECTED) { + expect(validatePostLogoutRedirect(req(query), ALLOW), label).to.equal(null); + } }); }); diff --git a/cypress/component/resources/session/session.service.switch.cy.ts b/cypress/component/resources/session/session.service.switch.cy.ts index bc2275b3c5..b75c0adc06 100644 --- a/cypress/component/resources/session/session.service.switch.cy.ts +++ b/cypress/component/resources/session/session.service.switch.cy.ts @@ -91,4 +91,33 @@ describe('removeAccount — ceremony requestId threading', () => { expect(o.location, 'with org: org scope survives').to.include('organization=org-1'); }); }); + + // The drop half of the allowlist, which the file header has claimed since it was written + // but nothing asserted. REQUEST_ID_PATTERN is /^(oidc|saml|device)_/ — anything else is + // attacker-supplied text that must never be reflected into a redirect the user follows. + // Without this, a build that threaded the form value verbatim passed every existing test. + const REJECTED: [label: string, requestId: string][] = [ + ['an unknown prefix', 'evil_x'], + ['an absolute URL smuggled as a requestId', 'evil_https://evil.example/steal'], + ['a bare value with no prefix at all', 'V3-current'], + ]; + + it('drops a requestId that fails the prefix allowlist, redirecting to a bare /accounts rather than reflecting it', () => { + for (const [label, requestId] of REJECTED) { + callService({ + fn: 'removeAccount', + seed, + liveSessions, + request: { + url: 'http://localhost/id/accounts', + sessions: cookie, + form: { intent: 'remove', sessionId: 's1', requestId }, + }, + }).then((v) => { + const o = v.outcome as Outcome; + expect(o.kind, `${label}: redirect`).to.equal('redirect'); + expect(o.location, `${label}: value not reflected`).to.equal('/accounts'); + }); + } + }); }); diff --git a/cypress/component/resources/shared/lifetimes.cy.ts b/cypress/component/resources/shared/lifetimes.cy.ts index ccae15c1cb..cfa278fbcd 100644 --- a/cypress/component/resources/shared/lifetimes.cy.ts +++ b/cypress/component/resources/shared/lifetimes.cy.ts @@ -11,8 +11,40 @@ const at = (iso: string | null) => ({ verifiedAt: iso === null ? null : new Date describe('isFactorFresh / primaryFresh', () => { const fresh = '2026-01-01T00:00:00.000Z'; - it('reports a factor inside its window as fresh, and primaryFresh is true if ANY of password/passkey/idp is fresh', () => { - expect(isFactorFresh(at(fresh), T0 + 500, 1000), 'inside the window').to.equal(true); + // Both directions. Only the `true` branch was covered before, so an implementation that + // returned true unconditionally — the fail-OPEN direction, where a stale factor is treated + // as fresh and a re-auth prompt is skipped — would have passed. + const FRESHNESS: Array< + [ + label: string, + verifiedAt: string | null, + nowMs: number, + lifetimeMs: number | undefined, + expected: boolean, + ] + > = [ + ['inside the window', fresh, T0 + 500, 1000, true], + // Boundary is inclusive (nowMs - verifiedMs <= lifetimeMs), so the edge is still fresh. + ['exactly at the window edge', fresh, T0 + 1000, 1000, true], + ['one millisecond past the edge', fresh, T0 + 1001, 1000, false], + ['well past the window', fresh, T0 + 5000, 1000, false], + ['never verified (null)', null, T0 + 100, 1000, false], + // No lifetime configured means the factor never expires — a distinct branch from + // "inside the window", and the one that decides whether a re-auth prompt appears at all. + ['no lifetime configured (undefined)', fresh, T0 + 999_999, undefined, true], + [ + 'zero lifetime is treated as "never expires", not "always stale"', + fresh, + T0 + 999_999, + 0, + true, + ], + ]; + + it('treats a factor as fresh up to and including its window edge, stale past it, and never-expiring when no lifetime is configured; primaryFresh is true if ANY of password/passkey/idp is fresh', () => { + for (const [label, verifiedAt, nowMs, lifetimeMs, expected] of FRESHNESS) { + expect(isFactorFresh(at(verifiedAt), nowMs, lifetimeMs), label).to.equal(expected); + } const f: Factors = { password: at(fresh) }; expect(primaryFresh(f, T0 + 100, 1000), 'password fresh').to.equal(true); diff --git a/cypress/component/resources/sso/sso-returnto-cache.cy.ts b/cypress/component/resources/sso/sso-returnto-cache.cy.ts index a0822f6bbb..4b29d3b548 100644 --- a/cypress/component/resources/sso/sso-returnto-cache.cy.ts +++ b/cypress/component/resources/sso/sso-returnto-cache.cy.ts @@ -16,4 +16,32 @@ describe('safeSameOriginReturnTo — same-origin allowlist (open-redirect guard) it('rejects a protocol-relative URL (//evil.com) → safe default', () => { expect(safeSameOriginReturnTo('//evil.example/steal', ORIGIN)).to.equal('/'); }); + + // The ACCEPT half. Without it, a guard that returned the safe default unconditionally + // would pass every reject case above while silently breaking every legitimate return — + // the tests would be green and the feature dead. + const ACCEPTED: [label: string, candidate: string, expected: string][] = [ + ['a plain app-relative path', '/passkeys', '/passkeys'], + ['a relative path with a query', '/setup/passkey?force=true', '/setup/passkey?force=true'], + [ + // An absolute URL on the trusted origin is reduced to its path — callers redirect + // relative, and this keeps the origin from being restated. + 'a same-origin absolute URL, reduced to path + query', + `${ORIGIN}/passkeys?x=1`, + '/passkeys?x=1', + ], + // The fragment never survives: the guard returns pathname + search only. Asserted so a + // future change that starts echoing candidate-controlled hash text is caught here. + ['a fragment is dropped', '/passkeys#tok', '/passkeys'], + ]; + + it('passes a legitimate same-origin path through unchanged, reducing an absolute same-origin URL to path + query and dropping any fragment', () => { + for (const [label, candidate, expected] of ACCEPTED) { + expect(safeSameOriginReturnTo(candidate, ORIGIN), label).to.equal(expected); + } + }); + + it('falls back to the safe default when no candidate is supplied', () => { + expect(safeSameOriginReturnTo(undefined, ORIGIN)).to.equal('/'); + }); });