ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
6 changed files with 145336 additions and 0 deletions
325
services/mail-keys/tests/account.spec.ts
Normal file
325
services/mail-keys/tests/account.spec.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
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<string, unknown>;
|
||||
type Reply = [string, Fields, string];
|
||||
|
||||
async function login(page: Page, name: string): Promise<string> {
|
||||
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<Reply> {
|
||||
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<string> {
|
||||
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 "[email protected]" "${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<string> {
|
||||
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(`\[email protected]\0${password}`).toString('base64');
|
||||
const lines = ['EHLO client.example.test', `AUTH PLAIN ${plain}`,
|
||||
...(send ? ['MAIL FROM:<[email protected]>', 'RCPT TO:<[email protected]>', 'DATA',
|
||||
'From: [email protected]\r\nTo: [email protected]\r\nSubject: Synthetic SMTP\r\nMessage-ID: <[email protected]>\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<string> => {
|
||||
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<string, { id: string; secret: string }>).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<string, boolean> }).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: [email protected]\r\nTo: [email protected]\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(`[email protected]:${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<string, Fields>)[created.id].type).toBe('notFound');
|
||||
const destroy = await call(bobPage, bobToken, 'x:AppPassword/set', { destroy: [created.id] });
|
||||
expect((destroy[1].notDestroyed as Record<string, Fields>)[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<void>();
|
||||
const release = Promise.withResolvers<void>();
|
||||
const settled = Promise.withResolvers<void>();
|
||||
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();
|
||||
}
|
||||
});
|
||||
16
services/mail-keys/tests/fixture.ts
Normal file
16
services/mail-keys/tests/fixture.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
export interface Fixture {
|
||||
runtime: string;
|
||||
pod: string;
|
||||
ports: { http: number; https: number; management: number; imap: number; smtp: number };
|
||||
issuer: string;
|
||||
origins: string[];
|
||||
hostNs: string;
|
||||
podNs: string;
|
||||
frontend: string;
|
||||
}
|
||||
|
||||
export const fixture = JSON.parse(await readFile(resolve(process.env.MAIL_RUN!, 'fixture.json'), 'utf8')) as Fixture;
|
||||
export const evidence = (name: string): string => resolve(fixture.runtime, name);
|
||||
27
services/mail-keys/tests/playwright.ts
Normal file
27
services/mail-keys/tests/playwright.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { defineConfig } from '@playwright/test';
|
||||
import { evidence, fixture } from './fixture.ts';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: '.',
|
||||
testMatch: 'account.spec.ts',
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 10_000 },
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
maxFailures: 1,
|
||||
reporter: 'list',
|
||||
outputDir: evidence('results'),
|
||||
projects: [
|
||||
{ name: 'gateway', use: { baseURL: fixture.origins[0] } },
|
||||
{ name: 'direct', use: { baseURL: fixture.origins[1] } },
|
||||
],
|
||||
use: {
|
||||
browserName: 'firefox',
|
||||
baseURL: fixture.origins[0],
|
||||
ignoreHTTPSErrors: true,
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 10_000,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
});
|
||||
188
services/mail-keys/tests/run.ts
Normal file
188
services/mail-keys/tests/run.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import { chmod, mkdir, mkdtemp, readFile, readlink, writeFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { bootstrap } from './setup.ts';
|
||||
|
||||
const root = resolve(import.meta.dirname, '..');
|
||||
const serve = process.argv.includes('--serve');
|
||||
const frontend = process.env.MAIL_IMAGE;
|
||||
if (!frontend) throw new Error('MAIL_IMAGE must identify the built static frontend image');
|
||||
const ports = serve
|
||||
? { http: 8082, https: 8443, issuer: 18881, management: 18882, imap: 18143, smtp: 18587 }
|
||||
: { http: 18082, https: 18443, issuer: 19881, management: 19882, imap: 19143, smtp: 19587 };
|
||||
const issuer = `http://localhost:${ports.issuer}`;
|
||||
const origins = [`http://localhost:${ports.http}`, `https://localhost:${ports.https}`];
|
||||
const runRoot = resolve(root, '../../scratchpad/mail-keys/runs');
|
||||
await mkdir(runRoot, { recursive: true });
|
||||
const runtime = await mkdtemp(`${runRoot}/${serve ? 'preview' : 'run'}-`);
|
||||
await chmod(runtime, 0o755);
|
||||
const pod = `${serve ? 'mail-view' : 'mail-keys'}-${process.pid}`;
|
||||
const images = {
|
||||
db: 'docker.io/library/postgres:17.11-alpine@sha256:266d3c257594e195b309ae9d1805e54c788f1d7b375f56bf6c367aac6c917e7b',
|
||||
idp: 'ghcr.io/zitadel/zitadel:v4.20.0@sha256:9ea0e9ecfdc43115da871242e40a0320f2ece864c7cd8b5d41a863a7f4902499',
|
||||
mail: 'ghcr.io/stalwartlabs/stalwart:v0.16.15@sha256:4f926193e5dd9ceb1e24ba48160702310381b12e51972c2fb0cc9de020388136',
|
||||
frontend,
|
||||
};
|
||||
await writeFile(`${runtime}/images.json`, JSON.stringify(images, null, 2));
|
||||
|
||||
async function command(args: string[], quiet = false): Promise<string> {
|
||||
return invoke('podman', args, quiet);
|
||||
}
|
||||
|
||||
async function invoke(program: string, args: string[], quiet = false, env = process.env): Promise<string> {
|
||||
return new Promise((accept, reject) => {
|
||||
const child = spawn(program, args, { cwd: root, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let output = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); if (!quiet) process.stdout.write(chunk); });
|
||||
child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); if (!quiet) process.stdout.write(chunk); });
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) accept(output.trim());
|
||||
else reject(new Error(`${program} ${args[0]} failed (${code})${quiet ? `: ${output}` : ''}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function certificate(): Promise<void> {
|
||||
await new Promise<void>((accept, reject) => {
|
||||
const child = spawn('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
|
||||
'-subj', '/CN=localhost', '-addext', 'subjectAltName=DNS:localhost',
|
||||
'-keyout', `${runtime}/tls.key`, '-out', `${runtime}/tls.crt`], { stdio: 'ignore' });
|
||||
child.on('error', reject);
|
||||
child.on('close', code => code === 0 ? accept() : reject(new Error(`Synthetic certificate failed (${code})`)));
|
||||
});
|
||||
await chmod(`${runtime}/tls.key`, 0o644);
|
||||
}
|
||||
|
||||
async function ready(name: string, check: () => Promise<unknown>): Promise<void> {
|
||||
const deadline = Date.now() + 90_000;
|
||||
while (Date.now() < deadline) {
|
||||
try { await check(); return; } catch { }
|
||||
if ((await command(['inspect', '--format', '{{.State.Running}}', `${pod}-${name}`], true)) !== 'true') {
|
||||
throw new Error(`${name} exited during initialization`);
|
||||
}
|
||||
await setTimeout(500);
|
||||
}
|
||||
throw new Error(`${name} did not become ready in 90 seconds`);
|
||||
}
|
||||
|
||||
const store = {
|
||||
'@type': 'PostgreSql', host: '127.0.0.1', port: 5432, database: 'stalwart',
|
||||
authUsername: 'postgres', authSecret: { '@type': 'EnvironmentVariable', variableName: 'SYNTHETIC_DB' },
|
||||
useTls: false, allowInvalidCerts: false,
|
||||
};
|
||||
await writeFile(`${runtime}/store.json`, JSON.stringify(store));
|
||||
await writeFile(`${runtime}/zitadel.yaml`, `
|
||||
Port: ${ports.issuer}
|
||||
ExternalDomain: localhost
|
||||
ExternalPort: ${ports.issuer}
|
||||
ExternalSecure: false
|
||||
TLS:
|
||||
Enabled: false
|
||||
Database:
|
||||
postgres:
|
||||
Host: 127.0.0.1
|
||||
Port: 5432
|
||||
Database: zitadel
|
||||
User:
|
||||
Username: zitadel
|
||||
Password: synthetic-zitadel
|
||||
SSL:
|
||||
Mode: disable
|
||||
Admin:
|
||||
Username: postgres
|
||||
Password: synthetic-db
|
||||
SSL:
|
||||
Mode: disable
|
||||
DefaultInstance:
|
||||
Features:
|
||||
LoginV2:
|
||||
Required: false
|
||||
FirstInstance:
|
||||
PatPath: /fixture/bootstrap.pat
|
||||
Org:
|
||||
Name: Synthetic
|
||||
Machine:
|
||||
Machine:
|
||||
Username: bootstrap
|
||||
Name: Synthetic fixture setup
|
||||
Pat:
|
||||
ExpirationDate: 2030-01-01T00:00:00Z
|
||||
Human:
|
||||
UserName: admin@synthetic.test
|
||||
Password: Synthetic-Admin-42!
|
||||
PasswordChangeRequired: false
|
||||
`);
|
||||
|
||||
const mount = `${runtime}:/fixture:Z`;
|
||||
let keep = false;
|
||||
try {
|
||||
const publish = [[ports.issuer, ports.issuer], [ports.management, 18882], [ports.http, 8082], [ports.https, 8443], [ports.imap, 18143], [ports.smtp, 18587]].flatMap(([host, internal]) => ['-p', `127.0.0.1:${host}:${internal}`]);
|
||||
await command(['pod', 'create', '--name', pod, ...publish]);
|
||||
await command(['run', '-d', '--pod', pod, '--name', `${pod}-db`, '-e', 'POSTGRES_PASSWORD=synthetic-db', images.db]);
|
||||
await ready('db', () => command(['exec', `${pod}-db`, 'pg_isready', '-h', '127.0.0.1', '-U', 'postgres'], true));
|
||||
await command(['exec', `${pod}-db`, 'createdb', '-U', 'postgres', 'stalwart']);
|
||||
await command(['run', '-d', '--pod', pod, '--name', `${pod}-idp`, '--user', '0', '-v', mount, images.idp,
|
||||
'start-from-init', '--masterkey', '01234567890123456789012345678901', '--tlsMode', 'disabled',
|
||||
'--config', '/fixture/zitadel.yaml', '--steps', '/fixture/zitadel.yaml']);
|
||||
await command(['run', '-d', '--pod', pod, '--name', `${pod}-mail`, '-v', mount,
|
||||
'-e', 'SYNTHETIC_DB=synthetic-db', '-e', 'STALWART_RECOVERY_MODE=true',
|
||||
'-e', 'STALWART_RECOVERY_MODE_PORT=18882', '-e', 'STALWART_RECOVERY_ADMIN=synthetic-admin:synthetic-admin',
|
||||
images.mail, '--config', '/fixture/store.json']);
|
||||
await ready('idp', async () => {
|
||||
const response = await fetch(`${issuer}/debug/ready`);
|
||||
if (!response.ok) throw new Error('Zitadel not ready');
|
||||
const token = (await readFile(`${runtime}/bootstrap.pat`, 'utf8')).trim();
|
||||
const management = await fetch(`${issuer}/management/v1/orgs/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!management.ok) throw new Error('Zitadel management not ready');
|
||||
});
|
||||
await ready('mail', async () => {
|
||||
const response = await fetch(`http://localhost:${ports.management}/healthz/live`);
|
||||
if (!response.ok) throw new Error('Stalwart not ready');
|
||||
});
|
||||
await certificate();
|
||||
await bootstrap(runtime, store, { issuer, origins, management: ports.management });
|
||||
await command(['stop', '--time', '5', `${pod}-mail`]);
|
||||
await command(['rm', `${pod}-mail`]);
|
||||
await command(['run', '-d', '--pod', pod, '--name', `${pod}-mail`, '-v', mount,
|
||||
'-e', 'SYNTHETIC_DB=synthetic-db', images.mail, '--config', '/fixture/store.json']);
|
||||
await ready('mail', async () => {
|
||||
const response = await fetch(`http://localhost:${ports.management}/healthz/live`);
|
||||
if (!response.ok) throw new Error('Stalwart not ready');
|
||||
});
|
||||
await command(['run', '-d', '--pod', pod, '--name', `${pod}-frontend`,
|
||||
'-v', `${runtime}:/run/config:ro`, '-v', `${runtime}:/run/tls:ro`, images.frontend]);
|
||||
await ready('frontend', async () => { const response = await fetch(`${origins[0]}/account/config.json`); if (!response.ok) throw new Error('Frontend not ready'); });
|
||||
const hostNs = await readlink('/proc/self/ns/net');
|
||||
const podNs = await command(['exec', `${pod}-mail`, '/bin/sh', '-c', 'readlink /proc/self/ns/net'], true);
|
||||
await writeFile(`${runtime}/fixture.json`, JSON.stringify({ runtime, pod, ports, issuer, origins, hostNs, podNs, frontend }, null, 2));
|
||||
if (serve) {
|
||||
const url = `${origins[0]}/account/`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Preview endpoint returned HTTP ${response.status}`);
|
||||
const preview = { url, pod, runtime, stop: `podman pod rm --force --time 2 ${pod}` };
|
||||
await writeFile(`${runtime}/preview.json`, JSON.stringify(preview, null, 2));
|
||||
keep = true;
|
||||
console.log(JSON.stringify(preview, null, 2));
|
||||
} else {
|
||||
const cache = resolve(root, '../../scratchpad/mail-keys/cache');
|
||||
await mkdir(`${runtime}/tmp`, { recursive: true });
|
||||
const result = await invoke(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', '--config', 'tests/playwright.ts'], false, { ...process.env, MAIL_RUN: runtime, TMPDIR: `${runtime}/tmp`, XDG_CACHE_HOME: `${cache}/xdg`, PLAYWRIGHT_BROWSERS_PATH: `${cache}/browsers` });
|
||||
await writeFile(`${runtime}/playwright.log`, result);
|
||||
}
|
||||
} finally {
|
||||
for (const name of ['idp', 'mail', 'db', 'frontend']) {
|
||||
try { await writeFile(`${runtime}/${name}.log`, await command(['logs', '--tail', '150', `${pod}-${name}`], true)); } catch { }
|
||||
}
|
||||
if (keep) {
|
||||
console.log(`Preview remains running; evidence: ${runtime}`);
|
||||
} else {
|
||||
await command(['pod', 'rm', '--force', '--time', '2', pod]);
|
||||
const names = await command(['ps', '-a', '--filter', `pod=${pod}`, '--format', '{{.Names}}'], true);
|
||||
if (names) throw new Error(`Fixture containers remain: ${names}`);
|
||||
console.log(`Fixture stopped; evidence: ${runtime}`);
|
||||
}
|
||||
}
|
||||
93
services/mail-keys/tests/setup.ts
Normal file
93
services/mail-keys/tests/setup.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
type Fields = Record<string, unknown>;
|
||||
type Method = [string, Fields, string];
|
||||
type Result = { methodResponses: Method[] };
|
||||
|
||||
export async function bootstrap(runtime: string, store: Fields, fixture: { issuer: string; origins: string[]; management: number }): Promise<void> {
|
||||
const token = (await readFile(`${runtime}/bootstrap.pat`, 'utf8')).trim();
|
||||
async function idp(path: string, body?: Fields, method = 'POST'): Promise<Fields> {
|
||||
const response = await fetch(`${fixture.issuer}${path}`, {
|
||||
method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const value = await response.json() as Fields;
|
||||
if (!response.ok) throw new Error(`${path}: ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
async function mail(method: string, args: Fields): Promise<Fields> {
|
||||
const response = await fetch(`http://localhost:${fixture.management}/jmap/`, {
|
||||
method: 'POST', headers: { Authorization: `Basic ${Buffer.from('synthetic-admin:synthetic-admin').toString('base64')}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using: ['urn:stalwart:jmap'], methodCalls: [[method, args, 't']] }),
|
||||
});
|
||||
const value = await response.json() as Result;
|
||||
const result = value.methodResponses[0];
|
||||
if (result[0] === 'error' || result[1].notCreated || result[1].notUpdated) throw new Error(`${method}: ${JSON.stringify(value)}`);
|
||||
return result[1];
|
||||
}
|
||||
async function create(name: string, value: Fields): Promise<string> {
|
||||
const result = await mail(`x:${name}/set`, { create: { test: value } });
|
||||
return (result.created as Record<string, { id: string }>).test.id;
|
||||
}
|
||||
async function singleton(name: string, value: Fields): Promise<void> {
|
||||
await mail(`x:${name}/set`, { update: { singleton: value } });
|
||||
}
|
||||
const project = await idp('/management/v1/projects', { name: 'Mail keys', projectRoleAssertion: true, projectRoleCheck: true, hasProjectCheck: true });
|
||||
const projectId = String(project.id);
|
||||
await idp(`/management/v1/projects/${projectId}/roles`, { roleKey: 'mail_user', displayName: 'Mail user', group: 'Mail' });
|
||||
const app = await idp(`/management/v1/projects/${projectId}/apps/oidc`, {
|
||||
name: 'Mail keys', redirectUris: fixture.origins.map(origin => `${origin}/account/oauth/callback`),
|
||||
responseTypes: ['OIDC_RESPONSE_TYPE_CODE'], grantTypes: ['OIDC_GRANT_TYPE_AUTHORIZATION_CODE'],
|
||||
appType: 'OIDC_APP_TYPE_USER_AGENT', authMethodType: 'OIDC_AUTH_METHOD_TYPE_NONE',
|
||||
postLogoutRedirectUris: fixture.origins.map(origin => `${origin}/account/oauth/logout`),
|
||||
version: 'OIDC_VERSION_1_0', devMode: true, accessTokenType: 'OIDC_TOKEN_TYPE_BEARER',
|
||||
idTokenUserinfoAssertion: true,
|
||||
});
|
||||
const users: Record<string, { id: string; password: string }> = {};
|
||||
for (const name of ['alice', 'bob']) {
|
||||
const password = `Synthetic-${name}-42!`;
|
||||
const user = await idp('/management/v1/users/human', {
|
||||
userName: `${name}@example.test`, profile: { firstName: name, lastName: 'Synthetic', preferredLanguage: 'en' },
|
||||
email: { email: `${name}@example.test`, isEmailVerified: true },
|
||||
initialPassword: password,
|
||||
});
|
||||
const id = String(user.userId);
|
||||
await idp(`/management/v1/users/${id}/password`, { password, noChangeRequired: true });
|
||||
await idp(`/management/v1/users/${id}/grants`, { projectId, roleKeys: ['mail_user'] });
|
||||
users[name] = { id, password };
|
||||
}
|
||||
const domain = await create('Domain', { name: 'example.test', certificateManagement: { '@type': 'Manual' }, dkimManagement: { '@type': 'Manual' }, dnsManagement: { '@type': 'Manual' } });
|
||||
const authDomain = await create('Domain', { name: 'auth.example.test', certificateManagement: { '@type': 'Manual' }, dkimManagement: { '@type': 'Manual' }, dnsManagement: { '@type': 'Manual' } });
|
||||
const protocolPerms = ['authenticate', 'emailSend', 'emailReceive', 'imapAuthenticate', 'imapAppend', 'imapCapability', 'imapId',
|
||||
'imapCopy', 'imapMove', 'imapCreate', 'imapDelete', 'imapEnable', 'imapExpunge', 'imapFetch', 'imapIdle',
|
||||
'imapList', 'imapLsub', 'imapNamespace', 'imapRename', 'imapSearch', 'imapSort', 'imapSelect', 'imapExamine',
|
||||
'imapStatus', 'imapStore', 'imapSubscribe', 'imapThread'];
|
||||
const permissions = Object.fromEntries([...protocolPerms, 'authenticateWithAlias', 'sysAppPasswordGet', 'sysAppPasswordCreate', 'sysAppPasswordUpdate', 'sysAppPasswordDestroy', 'sysAppPasswordQuery'].map(name => [name, true]));
|
||||
for (const [name, user] of Object.entries(users)) {
|
||||
await create('Account', { '@type': 'User', name, domainId: domain,
|
||||
aliases: { '0': { name: user.id, domainId: authDomain } }, credentials: {},
|
||||
roles: { '@type': 'User' }, permissions: { '@type': 'Replace', enabledPermissions: permissions, disabledPermissions: {} },
|
||||
memberGroupIds: {}, encryptionAtRest: { '@type': 'Disabled' }, quotas: {},
|
||||
});
|
||||
}
|
||||
const directory = await create('Directory', { '@type': 'Oidc', description: 'Synthetic Zitadel',
|
||||
issuerUrl: fixture.issuer, requireAudience: projectId, requireScopes: {},
|
||||
claimUsername: 'sub', usernameDomain: 'auth.example.test', claimName: 'sub', claimGroups: null,
|
||||
});
|
||||
await singleton('Authentication', { directoryId: directory });
|
||||
await singleton('DataStore', store);
|
||||
await singleton('SearchStore', store);
|
||||
await singleton('BlobStore', { '@type': 'Default' });
|
||||
await singleton('Http', { useXForwarded: true });
|
||||
await singleton('SystemSettings', { defaultHostname: 'mail.example.test', defaultDomainId: domain });
|
||||
await create('Certificate', { certificate: { '@type': 'File', filePath: '/fixture/tls.crt' },
|
||||
privateKey: { '@type': 'File', filePath: '/fixture/tls.key' } });
|
||||
await create('NetworkListener', { name: 'http', bind: { '0.0.0.0:18882': true }, protocol: 'http', useTls: false, tlsImplicit: false });
|
||||
await create('NetworkListener', { name: 'imap', bind: { '0.0.0.0:18143': true }, protocol: 'imap', useTls: true, tlsImplicit: true });
|
||||
await create('NetworkListener', { name: 'submission', bind: { '0.0.0.0:18587': true }, protocol: 'smtp', useTls: true, tlsImplicit: true });
|
||||
await create('NetworkListener', { name: 'account', bind: { '127.0.0.1:8081': true }, protocol: 'http', useTls: false, tlsImplicit: false });
|
||||
await create('Tracer', { '@type': 'Stdout', buffered: false, ansi: false, multiline: false, enable: true, level: 'info', events: {}, eventsPolicy: 'exclude' });
|
||||
await writeFile(`${runtime}/config.json`, JSON.stringify({ issuer: fixture.issuer, clientId: app.clientId, scope: `openid profile email urn:zitadel:iam:org:project:id:${projectId}:aud` }));
|
||||
await writeFile(`${runtime}/users.json`, JSON.stringify(users));
|
||||
console.log('Synthetic Zitadel users and least-privilege Stalwart accounts configured.');
|
||||
}
|
||||
7
services/mail-keys/tests/tsconfig.json
Normal file
7
services/mail-keys/tests/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
Loading…
Reference in a new issue