Skip to content

Commit c4cbf41

Browse files
committed
test: Cypress e2e and live acceptance suites
1 parent 8c568fe commit c4cbf41

33 files changed

Lines changed: 2045 additions & 0 deletions
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Core sign-in acceptance: proves the ZitadelAuthProvider adapter against a REAL
2+
// Zitadel Session API (catches proto drift the fake provider cannot).
3+
//
4+
// Gated: runs only with `--env ACCEPTANCE=1` (the fast fake-provider suite skips it).
5+
// The app server must already be running with AUTH_PROVIDER=zitadel pointed at a
6+
// real instance, e.g. the local stack:
7+
//
8+
// SESSION_SECRET=<32+ chars> NODE_ENV=production AUTH_PROVIDER=zitadel \
9+
// ZITADEL_API_URL=https://auth.localtest.me:30000 \
10+
// ZITADEL_SERVICE_USER_TOKEN=<pat> NODE_EXTRA_CA_CERTS=./dev-ca.crt \
11+
// bunx start-server-and-test 'bun run start' http-get://localhost:3000/healthz \
12+
// 'bunx cypress run --spec acceptance/core-signin.acceptance.cy.ts --env ACCEPTANCE=1'
13+
//
14+
// Credentials default to the LOCAL dev-stack test user (see the INDEX execution
15+
// log, 2026-06-13); override via CYPRESS_ACCEPTANCE_LOGIN_NAME / _PASSWORD.
16+
//
17+
// STATE NOTE: the journey is state-tolerant. A password-only user lands on the
18+
// skippable /setup/mfa prompt the FIRST time (30-day skip policy) and directly on
19+
// /signed-in while a prior skip is fresh — both are correct; the spec handles both.
20+
21+
const RUN = String(Cypress.env('ACCEPTANCE') ?? '') === '1';
22+
23+
const LOGIN_NAME = String(Cypress.env('ACCEPTANCE_LOGIN_NAME') ?? 'zitadel-e2e-user2');
24+
const PASSWORD = String(Cypress.env('ACCEPTANCE_PASSWORD') ?? 'LocalDev-Passw0rd!');
25+
// A standalone (no-authRequest) login now routes to the DEFAULT APP: /id/signed-in
26+
// 302-redirects a non-admin to Zitadel's login-settings defaultRedirectUri (locally the
27+
// cloud-portal) and an admin to /ui/console. That target is CROSS-ORIGIN from this spec's
28+
// baseUrl, so the journey no longer terminates on the /id/signed-in page.
29+
const DEFAULT_APP = String(Cypress.env('ACCEPTANCE_DEFAULT_APP') ?? 'localhost:3001');
30+
31+
(RUN ? describe : describe.skip)('core sign-in (real Zitadel)', () => {
32+
it('identifier → password → (skippable MFA prompt) → routed to the default app', () => {
33+
// Observe the REAL /id/signed-in redirect and neutralize the cross-origin hop: rewrite it
34+
// to a same-origin 200 so Cypress doesn't navigate to (and choke on) the external app,
35+
// while still asserting the server issued a 302 → the default app. This proves the full
36+
// ceremony (password verified by Zitadel, session established) AND the default-destination
37+
// routing, without depending on the external app being up.
38+
cy.intercept('GET', '**/id/signed-in*', (req) => {
39+
req.continue((res) => {
40+
res.headers['x-observed-status'] = String(res.statusCode);
41+
res.headers['x-observed-location'] = String(res.headers['location'] ?? '');
42+
res.statusCode = 200;
43+
res.body = 'redirect-observed';
44+
});
45+
}).as('postLogin');
46+
47+
// Screens are SSR + native form POST — no hydration opt-in needed.
48+
cy.visit('/id/login');
49+
cy.get('input[name="loginName"]').type(LOGIN_NAME);
50+
cy.get('button[type="submit"]').first().click();
51+
52+
cy.location('pathname').should('eq', '/id/login/password');
53+
cy.get('input[name="password"]').type(PASSWORD, { log: false });
54+
cy.get('button[type="submit"]').first().click();
55+
56+
// Real password verified by Zitadel. Tolerate the skippable MFA-enrollment prompt (shown
57+
// the first time per the 30-day skip policy); a fresh skip goes straight through.
58+
cy.location('pathname').then((path) => {
59+
if (path === '/id/setup/mfa') {
60+
cy.contains('button', /skip for now/i).click();
61+
}
62+
});
63+
64+
// The ceremony reached /id/signed-in and the server redirected to the default app.
65+
cy.wait('@postLogin').then(({ response }) => {
66+
expect(response?.headers?.['x-observed-status'], 'standalone login should 302').to.eq('302');
67+
expect(String(response?.headers?.['x-observed-location'])).to.contain(DEFAULT_APP);
68+
});
69+
});
70+
});
71+
72+
// Module scope: prevents top-level const collisions across acceptance specs under tsc.
73+
export {};
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// OAuth Device Authorization Grant (RFC 8628) acceptance (real Zitadel).
2+
// Mints a real device_authorization, drives /id/device → consent → login ceremony →
3+
// post-login return-to-consent → approve, then proves the grant end-to-end by polling
4+
// the device token endpoint until it stops returning authorization_pending and issues
5+
// an access token.
6+
//
7+
// Gated: runs only with `--env ACCEPTANCE=1`. App must run with AUTH_PROVIDER=zitadel
8+
// against the local stack; the cypress process needs NODE_EXTRA_CA_CERTS=./dev-ca.crt so
9+
// cy.request can reach Zitadel over the self-signed dev CA. See the INDEX log (2026-06-13).
10+
11+
const RUN = String(Cypress.env('ACCEPTANCE') ?? '') === '1';
12+
13+
const ZITADEL = String(Cypress.env('ZITADEL_API_URL') ?? 'https://auth.localtest.me:30000');
14+
// Device client `auth-ui-e2e-device` (device grant enabled; codes are throwaway).
15+
const CLIENT_ID = String(Cypress.env('DEVICE_CLIENT_ID') ?? '377158965391327283');
16+
// user2 is the CLEAN password-only ceremony user (user3 has live TOTP enrolled).
17+
const LOGIN_NAME = String(Cypress.env('ACCEPTANCE_LOGIN_NAME') ?? 'zitadel-e2e-user2');
18+
const PASSWORD = String(Cypress.env('ACCEPTANCE_PASSWORD') ?? 'LocalDev-Passw0rd!');
19+
20+
const TOKEN_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
21+
// RFC 8628 §3.5 recommends a 5 s poll interval; we use 1 s against the local
22+
// Zitadel stack (fast, same machine). 10 attempts × 1 s = 10 s budget, which
23+
// comfortably covers slow container startup and consent propagation lag.
24+
const POLL_ATTEMPTS = 10;
25+
const POLL_DELAY_MS = 1000;
26+
27+
/** Poll the device token endpoint until it issues a token (or attempts run out). */
28+
function pollToken(deviceCode: string, attempt: number): void {
29+
cy.request({
30+
method: 'POST',
31+
url: `${ZITADEL}/oauth/v2/token`,
32+
form: true,
33+
body: { grant_type: TOKEN_GRANT, device_code: deviceCode, client_id: CLIENT_ID },
34+
failOnStatusCode: false,
35+
}).then((resp) => {
36+
if (resp.status === 200 && resp.body.access_token) {
37+
expect(resp.body.access_token, 'device grant issued an access token').to.be.a('string');
38+
return;
39+
}
40+
expect(resp.body.error, 'pending until consent lands').to.eq('authorization_pending');
41+
expect(attempt, 'token endpoint never issued a token').to.be.lessThan(POLL_ATTEMPTS);
42+
cy.wait(POLL_DELAY_MS);
43+
pollToken(deviceCode, attempt + 1);
44+
});
45+
}
46+
47+
(RUN ? describe : describe.skip)('Device authorization grant (real Zitadel)', () => {
48+
it('user code → login ceremony → consent approve → token endpoint issues a token', () => {
49+
cy.request({
50+
method: 'POST',
51+
url: `${ZITADEL}/oauth/v2/device_authorization`,
52+
form: true,
53+
body: { client_id: CLIENT_ID, scope: 'openid' },
54+
}).then((mint) => {
55+
expect(mint.status).to.eq(200);
56+
const userCode = String(mint.body.user_code);
57+
const deviceCode = String(mint.body.device_code);
58+
59+
// 1) user enters the code shown on the device
60+
cy.visit('/id/device');
61+
cy.get('input[name="userCode"]').type(userCode);
62+
cy.get('button[type="submit"]').first().click();
63+
64+
// 2) consent screen resolves the device auth; requestId carries the stable user code
65+
cy.location('pathname').should('eq', '/id/device/authorize');
66+
cy.location('search').should('include', `user_code=${userCode}`);
67+
68+
// 3) approving without a session bounces through the login ceremony
69+
cy.get('button[name="decision"][value="authorize"]').click();
70+
cy.location('pathname').should('eq', '/id/login');
71+
cy.location('search').should('include', `requestId=device_${userCode}`);
72+
73+
cy.get('input[name="loginName"]').type(LOGIN_NAME);
74+
cy.get('button[type="submit"]').first().click();
75+
76+
cy.location('pathname').should('eq', '/id/login/password');
77+
cy.get('input[name="password"]').type(PASSWORD, { log: false });
78+
cy.get('button[type="submit"]').first().click();
79+
80+
// 4) post-password: either the skippable MFA prompt or straight back to consent
81+
cy.location('pathname', { timeout: 10000 }).should(
82+
'match',
83+
/\/id\/(setup\/mfa|device\/authorize)$/
84+
);
85+
cy.location('pathname').then((path) => {
86+
if (path === '/id/setup/mfa') {
87+
cy.contains('button', /skip for now/i).click();
88+
}
89+
});
90+
91+
// 5) post-login return-to-consent: the ceremony threads device_<userCode> back
92+
// through /authorize, which re-derives ?user_code= for the consent screen
93+
cy.location('pathname', { timeout: 10000 }).should('eq', '/id/device/authorize');
94+
cy.location('search').should('include', `user_code=${userCode}`);
95+
96+
// 6) approve with the signed-in session → in-page completion state
97+
cy.get('button[name="decision"][value="authorize"]').click();
98+
cy.contains('You may return to your device', { timeout: 10000 }).should('be.visible');
99+
100+
// 7) end-to-end proof: the token endpoint flips from authorization_pending to a token
101+
pollToken(deviceCode, 1);
102+
});
103+
});
104+
});
105+
106+
// Module scope: prevents top-level const collisions across acceptance specs under tsc.
107+
export {};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Skeleton real-Zitadel harness for the acceptance suite (Phase 1 = TODO stub).
2+
# Brings up Zitadel + Postgres; a follow-up task seeds a service user + an OIDC app.
3+
services:
4+
db:
5+
image: postgres:16-alpine
6+
environment: { POSTGRES_USER: zitadel, POSTGRES_PASSWORD: zitadel, POSTGRES_DB: zitadel }
7+
zitadel:
8+
image: ghcr.io/zitadel/zitadel:latest
9+
command: 'start-from-init --masterkey "MasterkeyNeedsToHave32Characters" --tlsMode disabled'
10+
environment:
11+
ZITADEL_DATABASE_POSTGRES_HOST: db
12+
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
13+
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: zitadel
14+
ZITADEL_EXTERNALSECURE: 'false'
15+
depends_on: [db]
16+
ports: ['8080:8080']
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
// LDAP credential sign-in acceptance (real Zitadel with a glauth LDAP IdP).
2+
// Drives the direct-signin surface: /id/sso/ldap?idpId=<ldap-idp> → username/password
3+
// form → real adapter startLdapIntent (live LDAP credential exchange against glauth) →
4+
// the Phase-4 intent→session path (signInWithIdpIntent).
5+
//
6+
// FIXTURE STATE — IMPORTANT: the seeded glauth user `ldap-e2e-user` authenticates
7+
// successfully but is NOT linked to any Zitadel account. Real Zitadel therefore returns
8+
// an idpIntent with userId='' (a 'register' draft, not a sign-in). Direct sign-in only
9+
// supports already-linked accounts; register-and-link via LDAP is deferred (see the /sso
10+
// `ldap-link-unsupported` guard + INDEX log). Provisioning a linked fixture would mutate
11+
// the shared IAM service, so this spec asserts the genuine live outcome for the available
12+
// fixture: a graceful ACCOUNT_NOT_LINKED error (HTTP 403), NOT a 500. This still proves the
13+
// session is exercised via the REAL adapter — a wrong password yields a different
14+
// (INVALID_CREDENTIALS) outcome, and an unhandled error would surface as a 500 error page.
15+
//
16+
// To assert the signed-in green path instead, set Cypress.env('LDAP_EXPECT_SIGNED_IN','1')
17+
// AND point LDAP_USERNAME/LDAP_PASSWORD at a glauth user that IS linked to a Zitadel
18+
// account; the spec then asserts the /id/signed-in landing.
19+
//
20+
// Gated: runs only with `--env ACCEPTANCE=1`. App must run with AUTH_PROVIDER=zitadel
21+
// against the local stack; the cypress process needs NODE_EXTRA_CA_CERTS=./dev-ca.crt so
22+
// requests can reach Zitadel over the self-signed dev CA. See the INDEX log (2026-06-13).
23+
24+
const RUN = String(Cypress.env('ACCEPTANCE') ?? '') === '1';
25+
26+
const IDP_ID = String(Cypress.env('LDAP_IDP_ID') ?? '377159167858704435');
27+
const USERNAME = String(Cypress.env('LDAP_USERNAME') ?? 'ldap-e2e-user');
28+
const PASSWORD = String(Cypress.env('LDAP_PASSWORD') ?? 'LocalDev-Passw0rd!');
29+
const EXPECT_SIGNED_IN = String(Cypress.env('LDAP_EXPECT_SIGNED_IN') ?? '') === '1';
30+
31+
(RUN ? describe : describe.skip)('LDAP credential sign-in (real Zitadel + glauth IdP)', () => {
32+
it('credentials → real startLdapIntent → graceful linked-account outcome (no 500)', () => {
33+
// 1. Enter the LDAP credential screen for the live LDAP IdP.
34+
cy.visit(`/id/sso/ldap?idpId=${IDP_ID}`);
35+
cy.location('pathname').should('eq', '/id/sso/ldap');
36+
37+
// Confirm the real form fields (username / password — NOT loginName).
38+
cy.get('input[name="username"]').should('be.visible').type(USERNAME);
39+
cy.get('input[name="password"]').type(PASSWORD, { log: false });
40+
cy.get('button[type="submit"]').first().click();
41+
42+
if (EXPECT_SIGNED_IN) {
43+
// Green path: a LINKED LDAP user resolves to a real userId, mints a session,
44+
// and lands signed-in. Requires a linked glauth↔Zitadel fixture (see header).
45+
cy.location('pathname', { timeout: 15000 }).should('eq', '/id/signed-in');
46+
} else {
47+
// Default fixture is unlinked: the real adapter exchange succeeds (creds valid)
48+
// but Zitadel returns userId='' → the route surfaces a graceful, typed error and
49+
// re-renders the LDAP form. We assert the form is still shown with the
50+
// not-linked alert — and CRUCIALLY that we did NOT 500 (the prior bug) and did
51+
// NOT wrongly redirect to a signed-in session.
52+
cy.location('pathname').should('eq', '/id/sso/ldap');
53+
cy.get('[role="alert"]').should('contain.text', 'No account is linked');
54+
cy.contains(/Unexpected Server Error/i).should('not.exist');
55+
}
56+
});
57+
58+
it('wrong password → real adapter rejects with INVALID_CREDENTIALS (proves the live exchange)', () => {
59+
// A distinct outcome for a wrong password confirms the assertion above came from a
60+
// genuine live LDAP credential exchange, not a static error page.
61+
cy.visit(`/id/sso/ldap?idpId=${IDP_ID}`);
62+
cy.get('input[name="username"]').type(USERNAME);
63+
cy.get('input[name="password"]').type('definitely-wrong-password', { log: false });
64+
cy.get('button[type="submit"]').first().click();
65+
66+
cy.location('pathname').should('eq', '/id/sso/ldap');
67+
cy.get('[role="alert"]').should('contain.text', 'Invalid username or password');
68+
});
69+
});
70+
71+
// Module scope: prevents top-level const collisions across acceptance specs under tsc.
72+
export {};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// OIDC authorize → callback acceptance (real Zitadel).
2+
// Proves the full code-flow entry: Zitadel /oauth/v2/authorize → our /login?authRequest
3+
// bridge → /authorize orchestrator (real getAuthRequest('oidc')) → identifier → password
4+
// → (skippable MFA) → createCallback → redirect to the client redirect_uri with ?code=.
5+
//
6+
// Gated: runs only with `--env ACCEPTANCE=1`. App must run with AUTH_PROVIDER=zitadel
7+
// AND PUBLIC_ORIGIN=http://localhost:3000 (required-in-prod since the verification-link
8+
// origin is sourced from trusted config, not the request Host header — boot fails closed
9+
// without it) against the local stack; the cypress process needs NODE_EXTRA_CA_CERTS=./dev-ca.crt
10+
// so cy.request can reach Zitadel over the self-signed dev CA. See the INDEX log (2026-06-13).
11+
12+
const RUN = String(Cypress.env('ACCEPTANCE') ?? '') === '1';
13+
14+
const ZITADEL = String(Cypress.env('ZITADEL_API_URL') ?? 'https://auth.localtest.me:30000');
15+
const CLIENT_ID = String(Cypress.env('OIDC_CLIENT_ID') ?? '377158963730317363');
16+
const REDIRECT_URI = String(
17+
Cypress.env('OIDC_REDIRECT_URI') ?? 'http://localhost:3000/auth/callback'
18+
);
19+
const LOGIN_NAME = String(Cypress.env('ACCEPTANCE_LOGIN_NAME') ?? 'zitadel-e2e-user3');
20+
const PASSWORD = String(Cypress.env('ACCEPTANCE_PASSWORD') ?? 'LocalDev-Passw0rd!');
21+
22+
// Fixed PKCE challenge — we only assert a code is issued, never exchange it.
23+
const CODE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
24+
25+
(RUN ? describe : describe.skip)('OIDC authorize → callback (real Zitadel)', () => {
26+
it('completes the code flow and redirects to the client with ?code=', () => {
27+
// 1. Mint a real authRequest at Zitadel's authorize endpoint (no redirect-follow).
28+
const authorizeUrl =
29+
`${ZITADEL}/oauth/v2/authorize?client_id=${CLIENT_ID}` +
30+
`&response_type=code&scope=${encodeURIComponent('openid profile')}` +
31+
`&redirect_uri=${encodeURIComponent(REDIRECT_URI)}` +
32+
`&code_challenge=${CODE_CHALLENGE}&code_challenge_method=S256&state=acc-test`;
33+
34+
cy.request({ url: authorizeUrl, followRedirect: false }).then((resp) => {
35+
expect(resp.status).to.be.oneOf([302, 303]);
36+
const loc = String(resp.headers['location'] ?? '');
37+
expect(loc, 'Zitadel authorize redirects into our login UI').to.contain('authRequest=');
38+
const authRequest = loc.replace(/.*authRequest=([^&]*).*/, '$1');
39+
40+
// 2. Enter our UI exactly where Zitadel sends the browser.
41+
cy.visit(`/id/login?authRequest=${authRequest}`);
42+
43+
// bridge → /authorize → no session → /login?requestId=oidc_<id>
44+
cy.location('pathname').should('eq', '/id/login');
45+
cy.location('search').should('include', 'requestId=oidc_');
46+
47+
cy.get('input[name="loginName"]').type(LOGIN_NAME);
48+
cy.get('button[type="submit"]').first().click();
49+
50+
cy.location('pathname').should('eq', '/id/login/password');
51+
cy.get('input[name="password"]').type(PASSWORD, { log: false });
52+
cy.get('button[type="submit"]').first().click();
53+
54+
// Skippable MFA prompt may appear for a password-only user.
55+
cy.location('pathname').then((path) => {
56+
if (path === '/id/setup/mfa') {
57+
cy.contains('button', /skip for now/i).click();
58+
}
59+
});
60+
61+
// 3. The ceremony resolves the OIDC request → 302 to the client redirect_uri with a code.
62+
cy.location('href', { timeout: 10000 }).should('include', 'code=');
63+
cy.location('pathname').should('eq', '/auth/callback');
64+
});
65+
});
66+
});
67+
68+
// Module scope: prevents top-level const collisions across acceptance specs under tsc.
69+
export {};

0 commit comments

Comments
 (0)