341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import process from 'node:process';
|
|
|
|
const { chromium } = await import(
|
|
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
|
|
);
|
|
const browser = await chromium.launch({ channel: 'chrome', headless: true });
|
|
const base = 'http://127.0.0.1:4177';
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(10000);
|
|
const errors = [];
|
|
const submissions = [];
|
|
page.on('pageerror', (error) => errors.push(error.message));
|
|
page.on('request', (request) => {
|
|
if (request.method() === 'POST') {
|
|
submissions.push({
|
|
path: new URL(request.url()).pathname,
|
|
data: request.postDataJSON(),
|
|
});
|
|
}
|
|
});
|
|
const outcome = (value) =>
|
|
context.addCookies([{ name: 'auth_test_result', value, url: base }]);
|
|
const open = async (path) => {
|
|
await page.goto(base + path);
|
|
await page.locator('main h1').waitFor();
|
|
};
|
|
const state = (value) =>
|
|
page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' });
|
|
const credentials = async () => {
|
|
await page
|
|
.getByLabel('Email address', { exact: true })
|
|
.fill('alex@example.test');
|
|
await page
|
|
.getByLabel('Password', { exact: true })
|
|
.fill('fixture-private-password');
|
|
};
|
|
const noStoredSecrets = async () => {
|
|
const persisted = await page.evaluate(() =>
|
|
JSON.stringify({
|
|
local: { ...localStorage },
|
|
session: { ...sessionStorage },
|
|
history: history.state,
|
|
}),
|
|
);
|
|
for (const secret of [
|
|
'fixture-private-password',
|
|
'012345',
|
|
'fixture-recovery-code',
|
|
]) {
|
|
assert.ok(
|
|
!persisted.includes(secret),
|
|
'Credentials must not be remembered in browser storage',
|
|
);
|
|
}
|
|
};
|
|
|
|
try {
|
|
for (const width of [320, 390, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
for (const theme of ['light', 'dark']) {
|
|
await page.emulateMedia({ colorScheme: theme });
|
|
for (const path of [
|
|
'/login',
|
|
'/email/verify',
|
|
'/two-factor-challenge',
|
|
]) {
|
|
await open(path);
|
|
await page
|
|
.getByRole('radio', {
|
|
name: theme === 'light' ? /^Light / : /^Dark /,
|
|
})
|
|
.press('Space');
|
|
await page
|
|
.getByRole('radio', {
|
|
name: theme === 'light' ? /^Light / : /^Dark /,
|
|
checked: true,
|
|
})
|
|
.waitFor();
|
|
assert.equal(
|
|
await page
|
|
.locator('html')
|
|
.evaluate((el) => el.classList.contains('dark')),
|
|
theme === 'dark',
|
|
);
|
|
assert.equal(
|
|
await page.evaluate(
|
|
() =>
|
|
document.documentElement.scrollWidth <= innerWidth,
|
|
),
|
|
true,
|
|
`${path} must fit at ${width}`,
|
|
);
|
|
const controls = await page
|
|
.locator(
|
|
'main button:not([role="checkbox"]), main label[for="remember"], main input:not([type="checkbox"]):not([aria-hidden="true"])',
|
|
)
|
|
.evaluateAll((els) =>
|
|
els
|
|
.filter(
|
|
(el) => el.getBoundingClientRect().width > 0,
|
|
)
|
|
.map((el) => el.getBoundingClientRect().height),
|
|
);
|
|
assert.ok(
|
|
controls.every((height) => height >= 44),
|
|
`${path} has touch-sized controls`,
|
|
);
|
|
await page.screenshot({
|
|
path: `/private/tmp/kan21-${path.split('/').at(-1)}-${width}-${theme}.png`,
|
|
fullPage: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 480 });
|
|
await open('/login');
|
|
await page.getByRole('radio', { name: /^System / }).press('Space');
|
|
await page.emulateMedia({ colorScheme: 'light' });
|
|
await page.waitForFunction(
|
|
() => !document.documentElement.classList.contains('dark'),
|
|
);
|
|
await page.emulateMedia({ colorScheme: 'dark' });
|
|
await page.waitForFunction(() =>
|
|
document.documentElement.classList.contains('dark'),
|
|
);
|
|
await credentials();
|
|
const password = page.locator('#password');
|
|
assert.equal(
|
|
await password.getAttribute('autocomplete'),
|
|
'current-password',
|
|
);
|
|
assert.equal(
|
|
await page.locator('#email').getAttribute('autocomplete'),
|
|
'username',
|
|
);
|
|
await password.focus();
|
|
await page.keyboard.press('Tab');
|
|
assert.equal(
|
|
await page
|
|
.getByRole('button', { name: 'Show password' })
|
|
.evaluate((el) => el === document.activeElement),
|
|
true,
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
assert.equal(await password.getAttribute('type'), 'text');
|
|
await page.getByRole('button', { name: 'Hide password' }).click();
|
|
await page.getByRole('checkbox', { name: 'Remember me' }).check();
|
|
await outcome('validation');
|
|
await password.press('Enter');
|
|
await state('validation');
|
|
assert.equal(await password.inputValue(), 'fixture-private-password');
|
|
assert.equal(
|
|
await page.locator('#email').inputValue(),
|
|
'alex@example.test',
|
|
);
|
|
assert.equal(
|
|
await page.locator('#email').getAttribute('aria-invalid'),
|
|
'true',
|
|
);
|
|
assert.equal(submissions.at(-1).data.remember, true);
|
|
assert.equal(
|
|
await page
|
|
.locator('[data-form-state="validation"]')
|
|
.evaluate((el) => el === document.activeElement),
|
|
true,
|
|
);
|
|
await noStoredSecrets();
|
|
|
|
await page.route('**/login', (route) =>
|
|
route.request().method() === 'POST'
|
|
? route.abort('failed')
|
|
: route.continue(),
|
|
);
|
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
await state('network');
|
|
assert.equal(await password.inputValue(), 'fixture-private-password');
|
|
await page.unroute('**/login');
|
|
await outcome('limited');
|
|
await page.getByRole('button', { name: 'Try again' }).click();
|
|
await page.getByText('Too many attempts', { exact: true }).waitFor();
|
|
await outcome('expired');
|
|
await page.getByRole('button', { name: 'Try again' }).click();
|
|
await page.getByText('Your session has expired', { exact: true }).waitFor();
|
|
assert.equal(
|
|
await page.getByRole('button', { name: 'Try again' }).count(),
|
|
0,
|
|
);
|
|
await page.getByRole('link', { name: 'Return to sign in' }).click();
|
|
await page.waitForFunction(
|
|
() => document.querySelector('#password')?.value === '',
|
|
);
|
|
|
|
await outcome('success');
|
|
await credentials();
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await page.route('**/login', async (route) => {
|
|
await held;
|
|
await route.continue();
|
|
});
|
|
const before = submissions.length;
|
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
await state('loading');
|
|
assert.equal(await password.isDisabled(), true);
|
|
assert.equal(
|
|
await page.getByRole('button', { name: 'Show password' }).isDisabled(),
|
|
true,
|
|
);
|
|
await page
|
|
.locator('form')
|
|
.evaluate((el) =>
|
|
el.dispatchEvent(
|
|
new Event('submit', { bubbles: true, cancelable: true }),
|
|
),
|
|
);
|
|
assert.equal(submissions.length, before + 1);
|
|
release();
|
|
await page
|
|
.getByRole('heading', { name: 'Signed in destination' })
|
|
.waitFor();
|
|
await page.unroute('**/login');
|
|
await noStoredSecrets();
|
|
await page.goBack();
|
|
await page.locator('#password').waitFor();
|
|
assert.equal(await password.inputValue(), '');
|
|
|
|
await open('/login?no-reset=1');
|
|
assert.equal(
|
|
await page.getByRole('link', { name: 'Forgot password?' }).count(),
|
|
0,
|
|
);
|
|
await credentials();
|
|
await outcome('challenge');
|
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
await page.locator('#code').waitFor();
|
|
assert.equal(
|
|
await page.locator('#code').getAttribute('inputmode'),
|
|
'numeric',
|
|
);
|
|
assert.equal(
|
|
await page.locator('#code').getAttribute('autocomplete'),
|
|
'one-time-code',
|
|
);
|
|
await page.locator('#code').evaluate((el) => {
|
|
const clipboardData = new DataTransfer();
|
|
clipboardData.setData('text', '012 345');
|
|
el.dispatchEvent(
|
|
new ClipboardEvent('paste', {
|
|
clipboardData,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}),
|
|
);
|
|
});
|
|
assert.equal(await page.locator('#code').inputValue(), '012345');
|
|
await outcome('validation');
|
|
await page.locator('#code').press('Enter');
|
|
await state('validation');
|
|
assert.deepEqual(submissions.at(-1).data, { code: '012345' });
|
|
assert.equal(await page.locator('#code').inputValue(), '012345');
|
|
await page
|
|
.getByRole('button', { name: 'Use a recovery code instead' })
|
|
.click();
|
|
assert.equal(
|
|
await page
|
|
.locator('#recovery_code')
|
|
.evaluate((el) => el === document.activeElement),
|
|
true,
|
|
);
|
|
assert.equal(await page.getByRole('alert').count(), 0);
|
|
await page.locator('#recovery_code').fill('fixture-recovery-code');
|
|
await page.locator('#recovery_code').press('Enter');
|
|
await state('validation');
|
|
assert.deepEqual(submissions.at(-1).data, {
|
|
recovery_code: 'fixture-recovery-code',
|
|
});
|
|
assert.equal(
|
|
await page.locator('#recovery_code').getAttribute('aria-invalid'),
|
|
'true',
|
|
);
|
|
assert.equal(
|
|
await page.locator('#recovery_code').inputValue(),
|
|
'fixture-recovery-code',
|
|
);
|
|
await noStoredSecrets();
|
|
await page
|
|
.getByRole('button', { name: 'Use an authenticator code instead' })
|
|
.click();
|
|
assert.equal(await page.locator('#code').inputValue(), '');
|
|
await page
|
|
.getByRole('button', { name: 'Use a recovery code instead' })
|
|
.click();
|
|
assert.equal(await page.locator('#recovery_code').inputValue(), '');
|
|
await page.locator('#recovery_code').fill('fixture-recovery-code');
|
|
await outcome('server');
|
|
await page.getByRole('button', { name: 'Verify and sign in' }).click();
|
|
await state('server');
|
|
assert.equal(
|
|
await page.locator('#recovery_code').inputValue(),
|
|
'fixture-recovery-code',
|
|
);
|
|
await outcome('success');
|
|
await page.getByRole('button', { name: 'Try again' }).click();
|
|
await page
|
|
.getByRole('heading', { name: 'Signed in destination' })
|
|
.waitFor();
|
|
await noStoredSecrets();
|
|
|
|
await open('/email/verify');
|
|
await page.getByText('alex.morgan@example.test', { exact: true }).waitFor();
|
|
await page
|
|
.getByRole('button', { name: 'Resend verification email', exact: true })
|
|
.click();
|
|
await page.getByText('Verification link sent', { exact: true }).waitFor();
|
|
assert.equal(submissions.at(-1).path, '/email/verification-notification');
|
|
await outcome('limited');
|
|
await page
|
|
.getByRole('button', { name: 'Resend verification email', exact: true })
|
|
.click();
|
|
await page.getByText('Too many attempts', { exact: true }).waitFor();
|
|
await outcome('success');
|
|
await page.getByRole('button', { name: 'Continue to workspace' }).click();
|
|
await page
|
|
.getByRole('heading', { name: 'Signed in destination' })
|
|
.waitFor();
|
|
await open('/email/verify');
|
|
await page.getByRole('button', { name: 'Sign out', exact: true }).click();
|
|
await page.locator('#email').waitFor();
|
|
assert.equal(submissions.at(-1).path, '/logout');
|
|
assert.deepEqual(errors, []);
|
|
console.log(
|
|
'Auth browser checks passed: themes, responsive layout, keyboard, autofill, retention, retry, expiry, throttling, 2FA, recovery, verification and logout.',
|
|
);
|
|
} finally {
|
|
await browser.close();
|
|
}
|