import { expect, test } from '@playwright/test'; import type { Page } from '@playwright/test'; import { connect } from 'node:tls'; import { readFile, readlink, writeFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { evidence, fixture } from './fixture.ts'; type Fields = Record; type Reply = [string, Fields, string]; async function login(page: Page, name: string): Promise { const host = await readlink('/proc/self/ns/net'); expect(host).toBe(fixture.hostNs); expect(host).not.toBe(fixture.podNs); const children = (await readFile(`/proc/${process.pid}/task/${process.pid}/children`, 'utf8')).trim().split(/\s+/).filter(Boolean); const processes = await Promise.all(children.map(async pid => ({ pid: Number(pid), net: await readlink(`/proc/${pid}/ns/net`) }))); expect(processes.length).toBeGreaterThan(0); expect(processes.every(item => item.net === host)).toBe(true); await writeFile(evidence('native.json'), JSON.stringify({ driver: process.pid, host, pod: fixture.podNs, browser: processes }, null, 2)); let token = ''; page.on('request', (request) => { if (new URL(request.url()).pathname === '/jmap/') token = request.headers().authorization?.replace(/^Bearer /, '') ?? token; }); await page.goto('/account/'); const authorize = page.waitForRequest(request => new URL(request.url()).pathname === '/oauth/v2/authorize'); await page.getByRole('button', { name: 'Sign in with Zitadel' }).click(); const authUrl = new URL((await authorize).url()); expect(authUrl.searchParams.get('response_type')).toBe('code'); expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256'); expect(authUrl.searchParams.get('code_challenge')).toBeTruthy(); expect(authUrl.searchParams.get('state')).toBeTruthy(); expect(authUrl.searchParams.get('nonce')).toBeTruthy(); expect(authUrl.searchParams.has('login_hint')).toBe(false); await page.locator('input[name="loginName"]').fill(`${name}@example.test`); await page.getByRole('button', { name: 'Next', exact: true }).click(); await page.locator('input[name="password"]').fill(`Synthetic-${name}-42!`); await page.getByRole('button', { name: 'Next', exact: true }).click(); await page.getByRole('heading', { name: /^(2-Factor Setup|App passwords)$/ }).waitFor(); const skip = page.getByRole('button', { name: 'Skip', exact: true }); if (await skip.isVisible()) await skip.click(); await expect(page.getByRole('button', { name: 'Sign out', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'App passwords', exact: true })).toBeVisible(); await expect.poll(() => token).not.toBe(''); await expect(page.getByLabel('Description', { exact: true })).toBeEnabled(); await expect(page.getByRole('alert')).toHaveCount(0); await expect(page.getByText(`Signed in as ${name}@example.test`, { exact: true })).toBeVisible(); return token; } async function call(page: Page, token: string, method: string, args: Fields): Promise { const response = await page.request.post('/jmap/', { headers: { Authorization: `Bearer ${token}` }, data: { using: ['urn:stalwart:jmap'], methodCalls: [[method, args, 't']] }, }); expect(response.status(), await response.text()).toBe(200); const value = await response.json() as { methodResponses: Reply[] }; return value.methodResponses[0]; } async function imap(password: string, commands: string[] = []): Promise { const ca = await readFile(evidence('tls.crt')); return new Promise((accept, reject) => { const socket = connect({ port: fixture.ports.imap, host: '127.0.0.1', servername: 'localhost', ca }); let output = ''; socket.setTimeout(5000, () => socket.destroy(new Error(`IMAP exchange timed out:\n${output}`))); let step = 0; let idle = false; const lines = [`t0 LOGIN "alice@example.test" "${password}"`, ...commands.map((command, index) => `t${index + 1} ${command}`), `t${commands.length + 1} LOGOUT`]; function next(): void { const line = lines[step++]; idle = line.endsWith(' IDLE'); socket.write(`${line}\r\n`); } socket.on('error', error => reject(new Error(`${error.message} ${output}`, { cause: error }))); socket.on('data', (chunk: Buffer) => { output += chunk.toString(); if (/\* NO \[AUTHENTICATIONFAILED\]/.test(output)) { socket.end(); return; } if (step === 0 && output.includes('\r\n')) next(); else if (idle && /(?:^|\r\n)\+[^\r]*\r\n/.test(output)) { idle = false; socket.write('DONE\r\n'); } else if (new RegExp(`(?:^|\\r\\n)t${step - 1} (OK|NO|BAD)`).test(output)) { if (step < lines.length) next(); else socket.end(); } }); socket.on('close', () => accept(output)); }); } async function smtp(password: string, send = true): Promise { const ca = await readFile(evidence('tls.crt')); return new Promise((accept, reject) => { const socket = connect({ port: fixture.ports.smtp, host: '127.0.0.1', servername: 'localhost', ca }); socket.setTimeout(10_000, () => socket.destroy(new Error('SMTP exchange timed out'))); const plain = Buffer.from(`\0alice@example.test\0${password}`).toString('base64'); const lines = ['EHLO client.example.test', `AUTH PLAIN ${plain}`, ...(send ? ['MAIL FROM:', 'RCPT TO:', 'DATA', 'From: alice@example.test\r\nTo: alice@example.test\r\nSubject: Synthetic SMTP\r\nMessage-ID: \r\n\r\nSynthetic SMTP body.\r\n.'] : []), 'QUIT']; let output = ''; let buffer = ''; let step = 0; socket.on('error', reject); socket.on('data', (chunk: Buffer) => { output += chunk.toString(); buffer += chunk.toString(); const received = buffer.split('\r\n'); buffer = received.pop() ?? ''; for (const line of received) { if (/^\d{3} /.test(line)) { if (step < lines.length) socket.write(`${lines[step++]}\r\n`); else socket.end(); } } }); socket.on('close', () => accept(output)); }); } test('static image serves SPA and hot config independently of Stalwart', async ({ request }) => { const original = await readFile(evidence('config.json'), 'utf8'); const config = JSON.parse(original) as Fields; const index = await request.get(`${fixture.origins[0]}/account/`); expect(index.status()).toBe(200); const html = await index.text(); const assets = [...html.matchAll(/(?:src|href)="([^" ]+\.(?:js|css))"/g)].map(match => match[1]); expect(assets.length).toBeGreaterThanOrEqual(2); const digest = async (path: string): Promise => { const response = await request.get(new URL(path, fixture.origins[0]).href, { timeout: 5000 }); expect(response.status()).toBe(200); return createHash('sha256').update(await response.body()).digest('hex'); }; const before = await Promise.all(assets.map(digest)); execFileSync('podman', ['pause', `${fixture.pod}-mail`]); try { for (const origin of fixture.origins) { const redirect = await request.get(`${origin}/`, { maxRedirects: 0, timeout: 5000 }); expect(redirect.status()).toBe(302); expect(redirect.headers().location).toBe('/account/'); const response = await request.get(`${origin}/account/config.json`, { timeout: 5000 }); expect(response.headers()['cache-control']).toBe('no-store'); expect(await response.json()).toEqual(config); expect(await (await request.get(`${origin}/account/`, { timeout: 5000 })).text()).toBe(html); expect(await (await request.get(`${origin}/account/oauth/callback`, { timeout: 5000 })).text()).toBe(html); expect(await (await request.get(`${origin}/account/oauth/logout`, { timeout: 5000 })).text()).toBe(html); expect((await request.get(`${origin}/account/licenses.txt`, { timeout: 5000 })).status()).toBe(200); } const revised = { ...config, scope: `${String(config.scope)} offline_access` }; await writeFile(evidence('config.json'), JSON.stringify(revised)); for (const origin of fixture.origins) expect(await (await request.get(`${origin}/account/config.json`, { timeout: 5000 })).json()).toEqual(revised); expect(await Promise.all(assets.map(digest))).toEqual(before); await writeFile(evidence('static.json'), JSON.stringify({ backendPaused: true, image: fixture.frontend, assets, before }, null, 2)); } finally { execFileSync('podman', ['unpause', `${fixture.pod}-mail`]); await writeFile(evidence('config.json'), original); } }); test('rejects a logout callback without state while signed in', async ({ page }) => { await login(page, 'alice'); let endings = 0; page.on('request', request => { if (new URL(request.url()).pathname === '/oidc/v1/end_session') endings++; }); await page.goto('/account/oauth/logout'); await expect(page.getByRole('alert')).toContainText('Sign-out response is missing state.'); await expect(page.getByRole('heading', { name: 'Signed out', exact: true })).toHaveCount(0); expect(endings).toBe(0); await page.getByRole('link', { name: 'Return to sign in' }).click(); await expect(page.getByRole('button', { name: 'Sign out', exact: true })).toBeVisible(); }); test('real OIDC, isolated app-password lifecycle, IMAP, and stable logout', async ({ browser, page }, info) => { const token = await login(page, 'alice'); const origin = new URL(page.url()).origin; const prefix = info.project.name || 'account'; const create = page.waitForResponse(response => response.url().endsWith('/jmap/') && response.request().postData()?.includes('"create"') === true); await page.getByLabel('Description', { exact: true }).fill('Synthetic phone'); await page.getByRole('button', { name: 'Create app password', exact: true }).click(); const reply = await (await create).json() as { methodResponses: Reply[] }; expect(reply.methodResponses[0][1].notCreated).toBeUndefined(); const created = (reply.methodResponses[0][1].created as Record).key; expect(created.id).toBeTruthy(); expect(created.secret).toMatch(/^app_/); await expect(page.getByRole('textbox', { name: 'New app password', exact: true })).toHaveValue(created.secret); const get = await call(page, token, 'x:AppPassword/get', { ids: [created.id] }); expect(get[0]).toBe('x:AppPassword/get'); const row = (get[1].list as Fields[])[0]; expect(row.description).toBe('Synthetic phone'); expect(row.secret).toBe('****'); expect((row.permissions as Fields)['@type']).toBe('Replace'); const permissions = (row.permissions as { permissions: Record }).permissions; const expected = ['authenticate', 'emailSend', 'imapAuthenticate', 'imapAppend', 'imapCapability', 'imapId', 'imapCopy', 'imapMove', 'imapCreate', 'imapDelete', 'imapEnable', 'imapExpunge', 'imapFetch', 'imapIdle', 'imapList', 'imapLsub', 'imapNamespace', 'imapRename', 'imapSearch', 'imapSort', 'imapSelect', 'imapExamine', 'imapStatus', 'imapStore', 'imapSubscribe', 'imapThread']; expect(Object.keys(permissions).sort()).toEqual(expected.sort()); expect(Object.keys(permissions).some(name => name.startsWith('sys') || name === 'impersonate' || name === 'authenticateWithAlias')).toBe(false); const sent = await smtp(created.secret); await writeFile(evidence('smtp.txt'), sent); expect(sent).toMatch(/235 /); expect(sent).toMatch(/250 2\.0\.0/); const message = 'From: alice@example.test\r\nTo: alice@example.test\r\nSubject: Synthetic IMAP\r\n\r\nSynthetic IMAP body.\r\n'; const commands = ['CAPABILITY', 'ID NIL', 'ENABLE CONDSTORE', 'NAMESPACE', 'LIST "" "*"', 'CREATE Synthetic', 'RENAME Synthetic Portable', 'SUBSCRIBE Portable', 'LSUB "" "*"', 'STATUS INBOX (MESSAGES)', 'EXAMINE INBOX', 'SELECT INBOX', `APPEND INBOX {${Buffer.byteLength(message)}+}\r\n${message}`, 'FETCH 1:* (BODY.PEEK[])', 'SEARCH ALL', 'SORT (DATE) UTF-8 ALL', 'THREAD REFERENCES UTF-8 ALL', 'IDLE', 'STORE 1:* +FLAGS (\\Seen)', 'COPY 1:* Portable', 'MOVE 1:* Portable', 'SELECT Portable', 'STORE 1:* +FLAGS (\\Deleted)', 'EXPUNGE', 'SELECT INBOX', 'UNSUBSCRIBE Portable', 'DELETE Portable']; const auth = await imap(created.secret, commands); await writeFile(evidence('imap.txt'), auth); for (let index = 0; index <= commands.length; index++) expect(auth).toMatch(new RegExp(`t${index} OK`)); expect(auth).toContain('Synthetic IMAP body.'); const basic = `Basic ${Buffer.from(`alice@example.test:${created.secret}`).toString('base64')}`; const denied = await page.request.post('/jmap/', { headers: { Authorization: basic }, data: { using: ['urn:stalwart:jmap'], methodCalls: [['x:AppPassword/query', {}, 't']] } }); const denial = await denied.json() as { methodResponses: Reply[] }; expect(denial.methodResponses[0][0]).toBe('error'); expect(denial.methodResponses[0][1].type).toBe('forbidden'); const bob = await browser.newContext({ baseURL: origin, ignoreHTTPSErrors: true }); const bobPage = await bob.newPage(); const bobToken = await login(bobPage, 'bob'); await expect(bobPage.getByRole('button', { name: 'Edit Synthetic phone', exact: true })).toHaveCount(0); const foreign = await call(bobPage, bobToken, 'x:AppPassword/get', { ids: [created.id] }); expect(foreign[1].list).toEqual([]); expect(foreign[1].notFound).toContain(created.id); const update = await call(bobPage, bobToken, 'x:AppPassword/set', { update: { [created.id]: { description: 'Stolen' } } }); expect((update[1].notUpdated as Record)[created.id].type).toBe('notFound'); const destroy = await call(bobPage, bobToken, 'x:AppPassword/set', { destroy: [created.id] }); expect((destroy[1].notDestroyed as Record)[created.id].type).toBe('notFound'); await bob.close(); const restrict = await call(page, token, 'x:AppPassword/set', { update: { [created.id]: { allowedIps: { '198.51.100.8/32': true } } } }); expect(restrict[1].updated).toHaveProperty(created.id); const headers = { Authorization: basic, 'X-Forwarded-For': '198.51.100.8' }; expect((await page.request.get(`${fixture.origins[0]}/jmap/session`, { headers })).status()).toBe(200); expect((await page.request.get(`${fixture.origins[1]}/jmap/session`, { headers })).status()).toBe(403); await call(page, token, 'x:AppPassword/set', { update: { [created.id]: { allowedIps: {} } } }); await page.getByRole('button', { name: 'Edit Synthetic phone', exact: true }).click(); await page.locator('li').filter({ has: page.getByRole('button', { name: 'Save changes', exact: true }) }).getByLabel('Description', { exact: true }).fill('Synthetic laptop'); await page.getByRole('button', { name: 'Save changes', exact: true }).click(); await expect(page.getByRole('button', { name: 'Edit Synthetic laptop', exact: true })).toBeVisible(); await page.reload(); await expect(page.getByRole('button', { name: 'Edit Synthetic laptop', exact: true })).toBeVisible(); await expect(page.getByRole('textbox', { name: 'New app password', exact: true })).toHaveCount(0); await page.setViewportSize({ width: 1280, height: 900 }); await page.screenshot({ path: evidence(`${prefix}-desktop.png`), fullPage: true }); await page.setViewportSize({ width: 390, height: 844 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: evidence(`${prefix}-mobile.png`), fullPage: true }); await page.setViewportSize({ width: 1280, height: 900 }); await page.getByRole('button', { name: 'Delete Synthetic laptop', exact: true }).click(); await page.getByRole('button', { name: 'Confirm delete', exact: true }).click(); await expect(page.getByRole('button', { name: 'Edit Synthetic laptop', exact: true })).toHaveCount(0); const revoked = await imap(created.secret); expect(revoked).toMatch(/\* NO \[AUTHENTICATIONFAILED\]/); const smtpRevoked = await smtp(created.secret, false); expect(smtpRevoked).toMatch(/535 /); const ended = page.waitForRequest(request => new URL(request.url()).pathname === '/oidc/v1/end_session'); let authRequests = 0; page.on('request', request => { if (new URL(request.url()).pathname === '/oauth/v2/authorize') authRequests++; }); await page.getByRole('button', { name: 'Sign out', exact: true }).click(); const endUrl = new URL((await ended).url()); expect(endUrl.searchParams.get('post_logout_redirect_uri')).toBe(`${origin}/account/oauth/logout`); expect(endUrl.searchParams.get('id_token_hint')).toBeTruthy(); expect(endUrl.searchParams.get('state')).toBeTruthy(); await expect(page.getByRole('button', { name: 'Sign in with Zitadel', exact: true })).toBeVisible(); await expect(page).toHaveURL(/\/account\/$/); await page.screenshot({ path: evidence(`${prefix}-signed-out.png`), fullPage: true }); await page.reload(); await expect(page.getByRole('button', { name: 'Sign in with Zitadel', exact: true })).toBeVisible(); await page.waitForTimeout(1500); expect(authRequests).toBe(0); expect(await page.evaluate(() => Object.keys(sessionStorage).filter(key => key.startsWith('oidc.user:')))).toEqual([]); await page.getByRole('button', { name: 'Sign in with Zitadel', exact: true }).click(); await page.getByRole('button', { name: /alice@example\.test Signed out/ }).click(); await expect(page.locator('input[name="password"]')).toBeVisible(); await page.screenshot({ path: evidence(`${prefix}-idp-logout.png`), fullPage: true }); await writeFile(evidence('protocol.txt'), `IMAP before revocation:\n${auth}\nIMAP after revocation:\n${revoked}\nSMTP before revocation:\n${sent}\nSMTP after revocation:\n${smtpRevoked}`); await writeFile(evidence('permissions.json'), JSON.stringify(permissions, null, 2)); const config = JSON.parse(await readFile(evidence('config.json'), 'utf8')) as Fields; expect(config.issuer).toBe(fixture.issuer); }); test('rejects unsolicited OIDC callback state', async ({ page }) => { await page.goto('/account/oauth/callback?code=synthetic-code&state=unknown-state'); await expect(page.getByRole('alert')).toContainText('state'); await page.getByRole('link', { name: 'Return to sign in' }).click(); await expect(page.getByRole('button', { name: 'Sign in with Zitadel', exact: true })).toBeVisible(); }); test('keeps sign-in available when a token expires during a real list request', async ({ page }) => { await login(page, 'alice'); const fetched = Promise.withResolvers(); const release = Promise.withResolvers(); const settled = Promise.withResolvers(); await page.route('**/jmap/', async route => { const response = await route.fetch({ timeout: 5000 }); expect(response.status()).toBe(200); fetched.resolve(); await release.promise; await route.fulfill({ response }); settled.resolve(); }); await page.evaluate(() => { const key = Object.keys(sessionStorage).find(key => key.startsWith('oidc.user:'))!; const user = JSON.parse(sessionStorage.getItem(key)!) as { expires_at: number }; user.expires_at = Math.floor(Date.now() / 1000) + 2; sessionStorage.setItem(key, JSON.stringify(user)); }); try { await page.reload(); await fetched.promise; await expect(page.getByRole('alert')).toContainText('Your session has expired.'); await expect(page.getByRole('button', { name: 'Sign in with Zitadel', exact: true })).toBeEnabled(); await expect(page.getByText('Please wait…', { exact: true })).toHaveCount(0); release.resolve(); await settled.promise; await expect(page.getByRole('button', { name: 'Sign in with Zitadel', exact: true })).toBeEnabled(); await expect(page.getByText('Please wait…', { exact: true })).toHaveCount(0); } finally { release.resolve(); } });