feat(KAN-21): rebuild login verification and two-factor flows
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 6s

This commit is contained in:
2026-09-11 14:18:23 +02:00
parent 5e52e4fe78
commit 802294d6a2
17 changed files with 1294 additions and 250 deletions
+342
View File
@@ -0,0 +1,342 @@
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('button', {
name: theme === 'light' ? 'Light' : 'Dark',
exact: true,
})
.click();
await page
.getByRole('button', {
name: theme === 'light' ? 'Light' : 'Dark',
exact: true,
pressed: 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('button', { name: 'System', exact: true }).click();
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();
}
+20
View File
@@ -0,0 +1,20 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Login from '../../resources/js/pages/auth/Login.vue';
import VerifyEmail from '../../resources/js/pages/auth/VerifyEmail.vue';
import TwoFactorChallenge from '../../resources/js/pages/auth/TwoFactorChallenge.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const Destination = { render: () => h('h1', 'Signed in destination') };
createInertiaApp({
resolve: (name) =>
({ Login, VerifyEmail, TwoFactorChallenge, Destination })[name],
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+119
View File
@@ -0,0 +1,119 @@
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
// Isolated Inertia transport fixture; real Fortify sessions are covered by PHPUnit.
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/auth-fixture',
plugins: [vue(), tailwind()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { host: '127.0.0.1', port: 4177, strictPort: true, hmr: false },
appType: 'custom',
});
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
const pages = {
'/login': 'Login',
'/two-factor-challenge': 'TwoFactorChallenge',
'/email/verify': 'VerifyEmail',
'/email/verification-notification': 'VerifyEmail',
'/dashboard': 'Destination',
'/logout': 'Login',
'/forgot-password': 'Destination',
'/': 'Destination',
};
if (!(url.pathname in pages)) {
return next();
}
const mutation = request.method === 'POST';
const result =
request.headers.cookie?.match(/auth_test_result=(\w+)/)?.[1] ??
'success';
if (mutation && ['server', 'expired', 'limited'].includes(result)) {
response.statusCode = { server: 503, expired: 419, limited: 429 }[
result
];
response.setHeader('Content-Type', 'text/html');
response.end('<h1>Simulated failed response</h1>');
return;
}
let body = '';
if (mutation) {
for await (const chunk of request) {
body += chunk;
}
}
const submitted = body ? JSON.parse(body) : {};
let path = url.pathname;
let errors = {};
let status;
if (mutation && result === 'validation') {
path = new URL(request.headers.referer).pathname;
const field =
path === '/login'
? 'email'
: 'recovery_code' in submitted
? 'recovery_code'
: 'code';
errors = { [field]: 'These details could not be verified.' };
} else if (mutation) {
if (path === '/email/verification-notification') {
path = '/email/verify';
status = 'verification-link-sent';
} else if (path === '/logout') {
path = '/login';
} else {
path =
result === 'challenge' ? '/two-factor-challenge' : '/dashboard';
}
}
const page = {
component: pages[path],
url: path,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors,
status,
canResetPassword: !url.searchParams.has('no-reset'),
auth: {
user:
path === '/email/verify'
? {
id: 1,
name: 'Alex Morgan',
email: 'alex.morgan@example.test',
}
: null,
},
},
};
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
const html =
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/auth-preview.mjs"></script></body></html>';
response.setHeader('Content-Type', 'text/html');
response.end(await server.transformIndexHtml(request.url, html));
});
await server.listen();
console.log('Auth fixture ready at http://127.0.0.1:4177/login');
+42
View File
@@ -69,6 +69,12 @@ try {
const { default: InputError } = await server.ssrLoadModule(
'/resources/js/components/InputError.vue',
);
const { default: AuthFormFeedback } = await server.ssrLoadModule(
'/resources/js/components/AuthFormFeedback.vue',
);
const { default: PasswordInput } = await server.ssrLoadModule(
'/resources/js/components/PasswordInput.vue',
);
const localization = createLocalization(
{ locale: 'de' },
{
@@ -76,6 +82,8 @@ try {
...en,
'common.retry': 'Erneut versuchen',
'feedback.empty': 'Noch keine Einträge',
'auth.feedback.expiredTitle': 'Sitzung abgelaufen',
'auth.password.show': 'Passwort anzeigen',
},
},
);
@@ -123,6 +131,40 @@ try {
assert.match(fieldError, /role="alert"/);
assert.match(fieldError, /text-destructive/);
assert.doesNotMatch(fieldError, /<script>/);
const expired = await render(AuthFormFeedback, {
state: 'server',
responseStatus: 419,
});
assert.match(expired, /Sitzung abgelaufen/);
assert.match(expired, /href="\/login"/);
assert.doesNotMatch(expired, /Erneut versuchen/);
assert.match(
await render(AuthFormFeedback, {
state: 'server',
responseStatus: 429,
}),
/Too many attempts/,
);
assert.match(
await render(AuthFormFeedback, {
state: 'loading',
loadingTitle: 'Signing in',
}),
/Signing in/,
);
assert.match(
await render(AuthFormFeedback, { state: 'validation' }),
/Check your details/,
);
const password = await render(PasswordInput, {
disabled: true,
autocomplete: 'current-password',
});
assert.match(password, /Passwort anzeigen/);
assert.match(password, /autocomplete="current-password"/);
assert.match(password, /aria-pressed="false"/);
assert.doesNotMatch(password, /tabindex="-1"/);
assert.match(password, /<button[^>]*disabled/);
console.log('Form feedback behavior checks passed.');
} finally {
await server.close();