|
| 1 | +// Email-link (passwordless) signup acceptance — real Zitadel + Mailpit. |
| 2 | +// Proves the new passwordless entry/exit: /id/signup (collect email; name parsed |
| 3 | +// from it) → /id/signup/method → "Email me a sign-in link" → register() creates a |
| 4 | +// REAL Zitadel user and Zitadel sends the verification email to Mailpit → the emailed |
| 5 | +// link lands on OUR /id/signup/complete (built from signupCompleteUrlTemplate) which |
| 6 | +// verifies the email, enrolls otpEmail, self-authenticates the session, and redirects |
| 7 | +// to the skippable passkey nudge at /id/setup/passkey. |
| 8 | +// |
| 9 | +// Gated: runs only with `--env ACCEPTANCE=1`. App must run with AUTH_PROVIDER=zitadel |
| 10 | +// AND PUBLIC_ORIGIN=http://localhost:3000 against the local stack; the cypress process |
| 11 | +// needs NODE_EXTRA_CA_CERTS=./dev-ca.crt AND Mailpit port-forwarded (svc/mailpit 8025) |
| 12 | +// so the fetchEmailCode task can read the mail. Mirrors signup-email.acceptance.cy.ts. |
| 13 | +// |
| 14 | +// NOTE: signup mutates real state — each run creates a brand-new Zitadel user, so the |
| 15 | +// email MUST be unique per run. |
| 16 | + |
| 17 | +const RUN = String(Cypress.env('ACCEPTANCE') ?? '') === '1'; |
| 18 | + |
| 19 | +// Unique recipient per run — signup creates a real user keyed on this email. |
| 20 | +const LINK_EMAIL = `e2e-emaillink-${Date.now()}@example.test`; |
| 21 | + |
| 22 | +// Bounded poll for the sign-in/verification mail (absorbs SMTP jitter; no fixed sleep). |
| 23 | +const MAX_POLLS = 12; |
| 24 | +const POLL_MS = 1000; |
| 25 | + |
| 26 | +type LinkEmailHit = { id: string; code: string; userId: string; link: string } | null; |
| 27 | + |
| 28 | +function pollForLink(attempt: number): Cypress.Chainable<{ code: string; userId: string }> { |
| 29 | + return cy.task<LinkEmailHit>('fetchEmailCode', { to: LINK_EMAIL }).then((hit) => { |
| 30 | + if (hit && hit.code && hit.userId) { |
| 31 | + return cy.wrap({ code: hit.code, userId: hit.userId }); |
| 32 | + } |
| 33 | + if (attempt >= MAX_POLLS) { |
| 34 | + throw new Error(`No signup-complete email for ${LINK_EMAIL} after ${MAX_POLLS} polls`); |
| 35 | + } |
| 36 | + return cy.wait(POLL_MS).then(() => pollForLink(attempt + 1)); |
| 37 | + }); |
| 38 | +} |
| 39 | + |
| 40 | +(RUN ? describe : describe.skip)('email-link signup (real Zitadel + Mailpit)', () => { |
| 41 | + it('registers via email link, follows the Mailpit link, and lands authenticated', () => { |
| 42 | + // 1. Collect email on /signup (name parsed from it; field behind the "Email" reveal). |
| 43 | + cy.visit('/id/signup'); |
| 44 | + cy.contains('button', /^Email$/).click(); |
| 45 | + cy.get('input[name="email"]:visible').type(LINK_EMAIL); |
| 46 | + cy.contains('button', /^Continue$/i).click(); |
| 47 | + |
| 48 | + // 2. Method screen → choose the email sign-in link. This registers the user and |
| 49 | + // sends the verification email (link → /id/signup/complete) to Mailpit. |
| 50 | + cy.location('pathname').should('include', '/id/signup/method'); |
| 51 | + cy.contains('button', /email me a sign-in link/i).click(); |
| 52 | + |
| 53 | + // Enumeration-safe terminal: generic "check your email" card (no live redirect). |
| 54 | + cy.contains(/check your email/i); |
| 55 | + |
| 56 | + // 3. Pull the real code + userId from the Mailpit message (bounded poll). |
| 57 | + pollForLink(1).then(({ code, userId }) => { |
| 58 | + expect(code, 'code extracted from Mailpit').to.match(/^[A-Z0-9]{4,8}$/); |
| 59 | + expect(userId, 'userId extracted from the emailed link').to.match(/^\d+$/); |
| 60 | + |
| 61 | + // 4. Follow the emailed link onto OUR /signup/complete. The loader verifies the |
| 62 | + // email, enrolls otpEmail, self-authenticates, and redirects to the skippable |
| 63 | + // passkey nudge. (next=passkey is carried by signupCompleteUrlTemplate.) |
| 64 | + cy.visit( |
| 65 | + `/id/signup/complete?code=${code}&userId=${userId}&next=passkey&loginName=${encodeURIComponent(LINK_EMAIL)}` |
| 66 | + ); |
| 67 | + |
| 68 | + // 5. Authenticated session → skippable passkey setup (or straight signed-in). |
| 69 | + cy.location('pathname', { timeout: 10000 }).should( |
| 70 | + 'match', |
| 71 | + /\/(setup\/passkey|signed-in|authorize)/ |
| 72 | + ); |
| 73 | + }); |
| 74 | + }); |
| 75 | +}); |
| 76 | + |
| 77 | +// Module scope: prevents top-level const collisions across acceptance specs under tsc. |
| 78 | +export {}; |
0 commit comments