434 lines
15 KiB
TypeScript
434 lines
15 KiB
TypeScript
import { expect, test, type Page } from '@playwright/test';
|
|
import { loginUser, type TestUser } from '../../fixtures/auth-fixtures';
|
|
import { readFileSync } from 'fs';
|
|
import { STORAGE_STATE } from '../../constants';
|
|
|
|
const IMPORT_PAGE_PATH = '/tasks/import/caddyfile';
|
|
const SETUP_TEST_EMAIL = process.env.E2E_TEST_EMAIL || 'e2e-test@example.com';
|
|
const SETUP_TEST_PASSWORD = process.env.E2E_TEST_PASSWORD || 'TestPassword123!';
|
|
const IMPORT_BLOCKING_STATUS_CODES = new Set([401, 403, 302, 429]);
|
|
const IMPORT_ERROR_PATTERNS = /(cors|cross-origin|same-origin|cookie|csrf|forbidden|unauthorized|security|host)/i;
|
|
|
|
type ImportDiagnosticsCleanup = () => void;
|
|
|
|
function diagnosticLog(message: string): void {
|
|
if (process.env.PLAYWRIGHT_IMPORT_DIAGNOSTICS === '0') {
|
|
return;
|
|
}
|
|
console.log(message);
|
|
}
|
|
|
|
async function readCurrentPath(page: Page): Promise<string> {
|
|
return page.evaluate(() => window.location.pathname).catch(() => '');
|
|
}
|
|
|
|
export async function getImportAuthMarkers(page: Page): Promise<{
|
|
currentUrl: string;
|
|
currentPath: string;
|
|
loginRoute: boolean;
|
|
setupRoute: boolean;
|
|
hasLoginForm: boolean;
|
|
hasSetupForm: boolean;
|
|
hasPendingSessionBanner: boolean;
|
|
hasTextarea: boolean;
|
|
}> {
|
|
const currentUrl = page.url();
|
|
const currentPath = await readCurrentPath(page);
|
|
|
|
const [hasLoginForm, hasSetupForm, hasPendingSessionBanner, hasTextarea] = await Promise.all([
|
|
page.locator('form').filter({ has: page.getByRole('button', { name: /sign in|login/i }) }).first().isVisible().catch(() => false),
|
|
page.getByRole('button', { name: /create admin|finish setup|setup/i }).first().isVisible().catch(() => false),
|
|
page.getByText(/pending import session/i).first().isVisible().catch(() => false),
|
|
page.locator('textarea').first().isVisible().catch(() => false),
|
|
]);
|
|
|
|
return {
|
|
currentUrl,
|
|
currentPath,
|
|
loginRoute: currentUrl.includes('/login') || currentPath.includes('/login'),
|
|
setupRoute: currentUrl.includes('/setup') || currentPath.includes('/setup'),
|
|
hasLoginForm,
|
|
hasSetupForm,
|
|
hasPendingSessionBanner,
|
|
hasTextarea,
|
|
};
|
|
}
|
|
|
|
export async function assertNoAuthRedirect(page: Page, context: string): Promise<void> {
|
|
const markers = await getImportAuthMarkers(page);
|
|
if (markers.loginRoute || markers.setupRoute || markers.hasLoginForm || markers.hasSetupForm) {
|
|
throw new Error(
|
|
`${context}: blocked by auth/setup state (url=${markers.currentUrl}, path=${markers.currentPath}, ` +
|
|
`loginRoute=${markers.loginRoute}, setupRoute=${markers.setupRoute}, ` +
|
|
`hasLoginForm=${markers.hasLoginForm}, hasSetupForm=${markers.hasSetupForm})`
|
|
);
|
|
}
|
|
}
|
|
|
|
export function attachImportDiagnostics(page: Page, scope: string): ImportDiagnosticsCleanup {
|
|
if (process.env.PLAYWRIGHT_IMPORT_DIAGNOSTICS === '0') {
|
|
return () => {};
|
|
}
|
|
|
|
const onResponse = (response: { status: () => number; url: () => string }): void => {
|
|
const status = response.status();
|
|
if (!IMPORT_BLOCKING_STATUS_CODES.has(status)) {
|
|
return;
|
|
}
|
|
|
|
const url = response.url();
|
|
if (!/\/api\/v1\/(auth|import)|\/login|\/setup/i.test(url)) {
|
|
return;
|
|
}
|
|
|
|
diagnosticLog(`[Diag:${scope}] blocking-status=${status} url=${url}`);
|
|
};
|
|
|
|
const onConsole = (msg: { type: () => string; text: () => string }): void => {
|
|
const text = msg.text();
|
|
if (!IMPORT_ERROR_PATTERNS.test(text)) {
|
|
return;
|
|
}
|
|
|
|
diagnosticLog(`[Diag:${scope}] console.${msg.type()} ${text}`);
|
|
};
|
|
|
|
const onPageError = (error: Error): void => {
|
|
const text = error.message || String(error);
|
|
if (!IMPORT_ERROR_PATTERNS.test(text)) {
|
|
return;
|
|
}
|
|
|
|
diagnosticLog(`[Diag:${scope}] pageerror ${text}`);
|
|
};
|
|
|
|
page.on('response', onResponse);
|
|
page.on('console', onConsole);
|
|
page.on('pageerror', onPageError);
|
|
|
|
return () => {
|
|
page.off('response', onResponse);
|
|
page.off('console', onConsole);
|
|
page.off('pageerror', onPageError);
|
|
};
|
|
}
|
|
|
|
export async function logImportFailureContext(page: Page, scope: string): Promise<void> {
|
|
const markers = await getImportAuthMarkers(page);
|
|
diagnosticLog(
|
|
`[Diag:${scope}] failure-context url=${markers.currentUrl} path=${markers.currentPath} ` +
|
|
`loginRoute=${markers.loginRoute} setupRoute=${markers.setupRoute} ` +
|
|
`hasLoginForm=${markers.hasLoginForm} hasSetupForm=${markers.hasSetupForm} ` +
|
|
`hasPendingSessionBanner=${markers.hasPendingSessionBanner} hasTextarea=${markers.hasTextarea}`
|
|
);
|
|
}
|
|
|
|
export async function waitForSuccessfulImportResponse(
|
|
page: Page,
|
|
triggerAction: () => Promise<void>,
|
|
scope: string,
|
|
expectedPath: RegExp = /\/api\/v1\/import\/(upload|upload-multi)/i
|
|
): Promise<import('@playwright/test').Response> {
|
|
await assertNoAuthRedirect(page, `${scope} pre-trigger`);
|
|
|
|
try {
|
|
const [response] = await Promise.all([
|
|
page.waitForResponse((r) => expectedPath.test(r.url()) && r.ok(), { timeout: 15000 }),
|
|
triggerAction(),
|
|
]);
|
|
return response;
|
|
} catch (error) {
|
|
await logImportFailureContext(page, scope);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function extractTokenFromState(rawState: unknown): string | null {
|
|
if (!rawState || typeof rawState !== 'object') {
|
|
return null;
|
|
}
|
|
|
|
const state = rawState as { origins?: Array<{ localStorage?: Array<{ name?: string; value?: string }> }> };
|
|
const origins = Array.isArray(state.origins) ? state.origins : [];
|
|
for (const origin of origins) {
|
|
const entries = Array.isArray(origin.localStorage) ? origin.localStorage : [];
|
|
const tokenEntry = entries.find((item) => item?.name === 'charon_auth_token' && typeof item.value === 'string');
|
|
if (tokenEntry?.value) {
|
|
return tokenEntry.value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function restoreAuthFromStorageState(page: Page): Promise<boolean> {
|
|
try {
|
|
const state = JSON.parse(readFileSync(STORAGE_STATE, 'utf-8')) as {
|
|
cookies?: Array<{
|
|
name: string;
|
|
value: string;
|
|
domain?: string;
|
|
path?: string;
|
|
expires?: number;
|
|
httpOnly?: boolean;
|
|
secure?: boolean;
|
|
sameSite?: 'Lax' | 'None' | 'Strict';
|
|
}>;
|
|
};
|
|
const token = extractTokenFromState(state);
|
|
const cookies = Array.isArray(state.cookies) ? state.cookies : [];
|
|
|
|
if (!token && cookies.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
if (cookies.length > 0) {
|
|
await page.context().addCookies(cookies);
|
|
}
|
|
|
|
if (token) {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate((authToken: string) => {
|
|
localStorage.setItem('charon_auth_token', authToken);
|
|
}, token);
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
}
|
|
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function loginWithSetupCredentials(page: Page): Promise<void> {
|
|
if (!page.url().includes('/login')) {
|
|
await page.goto('/login', { waitUntil: 'domcontentloaded' });
|
|
}
|
|
|
|
await page.locator('input[type="email"]').first().fill(SETUP_TEST_EMAIL);
|
|
await page.locator('input[type="password"]').first().fill(SETUP_TEST_PASSWORD);
|
|
|
|
const [loginResponse] = await Promise.all([
|
|
page.waitForResponse((response) => response.url().includes('/api/v1/auth/login'), { timeout: 15000 }),
|
|
page.getByRole('button', { name: /sign in|login/i }).first().click(),
|
|
]);
|
|
|
|
if (!loginResponse.ok()) {
|
|
const body = await loginResponse.text().catch(() => '');
|
|
throw new Error(`Setup-credential login failed: ${loginResponse.status()} ${body}`);
|
|
}
|
|
|
|
const payload = (await loginResponse.json().catch(() => ({}))) as { token?: string };
|
|
if (payload.token) {
|
|
await page.evaluate((authToken: string) => {
|
|
localStorage.setItem('charon_auth_token', authToken);
|
|
}, payload.token);
|
|
}
|
|
|
|
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15000 });
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
}
|
|
|
|
export async function resetImportSession(page: Page): Promise<void> {
|
|
try {
|
|
if (!page.url().includes(IMPORT_PAGE_PATH)) {
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
}
|
|
} catch {
|
|
// Best-effort navigation only
|
|
}
|
|
|
|
try {
|
|
const statusResponse = await page.request.get('/api/v1/import/status');
|
|
if (statusResponse.ok()) {
|
|
const statusBody = await statusResponse.json();
|
|
if (statusBody?.has_pending) {
|
|
await page.request.post('/api/v1/import/cancel');
|
|
}
|
|
}
|
|
} catch {
|
|
// Best-effort cleanup only
|
|
}
|
|
|
|
try {
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
} catch {
|
|
// Best-effort return to import page only
|
|
}
|
|
}
|
|
|
|
export async function ensureImportFormReady(page: Page): Promise<void> {
|
|
await assertNoAuthRedirect(page, 'ensureImportFormReady initial check');
|
|
|
|
const headingByRole = page.getByRole('heading', { name: /import|caddyfile/i }).first();
|
|
const headingLike = page
|
|
.locator('h1, h2, [data-testid="page-title"], [aria-label*="import" i], [aria-label*="caddyfile" i]')
|
|
.first();
|
|
|
|
if (await headingByRole.count()) {
|
|
await expect(headingByRole).toBeVisible();
|
|
} else if (await headingLike.count()) {
|
|
await expect(headingLike).toBeVisible();
|
|
} else {
|
|
await expect(page.locator('main, body').first()).toContainText(/import|caddyfile/i);
|
|
}
|
|
|
|
const textarea = page.locator('textarea').first();
|
|
const textareaVisible = await textarea.isVisible().catch(() => false);
|
|
if (!textareaVisible) {
|
|
const pendingSessionVisible = await page.getByText(/pending import session/i).first().isVisible().catch(() => false);
|
|
if (pendingSessionVisible) {
|
|
diagnosticLog('[Diag:import-ready] pending import session detected, canceling to restore textarea');
|
|
|
|
const browserCancelStatus = await page
|
|
.evaluate(async () => {
|
|
const token = localStorage.getItem('charon_auth_token');
|
|
const commonHeaders = token ? { Authorization: `Bearer ${token}` } : {};
|
|
|
|
const statusResponse = await fetch('/api/v1/import/status', {
|
|
method: 'GET',
|
|
credentials: 'include',
|
|
headers: commonHeaders,
|
|
});
|
|
let sessionId = '';
|
|
if (statusResponse.ok) {
|
|
const statusBody = (await statusResponse.json()) as { session?: { id?: string } };
|
|
sessionId = statusBody?.session?.id || '';
|
|
}
|
|
|
|
const cancelUrl = sessionId
|
|
? `/api/v1/import/cancel?session_uuid=${encodeURIComponent(sessionId)}`
|
|
: '/api/v1/import/cancel';
|
|
|
|
const response = await fetch(cancelUrl, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
headers: commonHeaders,
|
|
});
|
|
return response.status;
|
|
})
|
|
.catch(() => null);
|
|
diagnosticLog(`[Diag:import-ready] browser cancel status=${browserCancelStatus ?? 'n/a'}`);
|
|
|
|
const cancelButton = page.getByRole('button', { name: /^cancel$/i }).first();
|
|
const cancelButtonVisible = await cancelButton.isVisible().catch(() => false);
|
|
|
|
if (cancelButtonVisible) {
|
|
await Promise.all([
|
|
page.waitForResponse((response) => response.url().includes('/api/v1/import/cancel'), { timeout: 10000 }).catch(() => null),
|
|
cancelButton.click(),
|
|
]);
|
|
}
|
|
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
await assertNoAuthRedirect(page, 'ensureImportFormReady after pending-session reset');
|
|
}
|
|
}
|
|
|
|
await expect(textarea).toBeVisible();
|
|
await expect(page.getByRole('button', { name: /parse|review/i }).first()).toBeVisible();
|
|
}
|
|
|
|
async function hasLoginUiMarkers(page: Page): Promise<boolean> {
|
|
const currentUrl = page.url();
|
|
const currentPath = await readCurrentPath(page);
|
|
if (currentUrl.includes('/login') || currentPath.includes('/login')) {
|
|
return true;
|
|
}
|
|
|
|
const signInHeading = page.getByRole('heading', { name: /sign in|login/i }).first();
|
|
const signInButton = page.getByRole('button', { name: /sign in|login/i }).first();
|
|
const emailTextbox = page.getByRole('textbox', { name: /email/i }).first();
|
|
|
|
const [headingVisible, buttonVisible, emailVisible] = await Promise.all([
|
|
signInHeading.isVisible().catch(() => false),
|
|
signInButton.isVisible().catch(() => false),
|
|
emailTextbox.isVisible().catch(() => false),
|
|
]);
|
|
|
|
return headingVisible || buttonVisible || emailVisible;
|
|
}
|
|
|
|
export async function ensureAuthenticatedImportFormReady(page: Page, adminUser?: TestUser): Promise<void> {
|
|
const recoverIfNeeded = async (): Promise<boolean> => {
|
|
const loginDetected = await test.step('Auth precheck: detect login redirect or sign-in controls', async () => {
|
|
return hasLoginUiMarkers(page);
|
|
});
|
|
if (!loginDetected) {
|
|
return false;
|
|
}
|
|
|
|
if (!adminUser) {
|
|
throw new Error('Import auth recovery failed: login UI detected but no admin user fixture was provided.');
|
|
}
|
|
|
|
return test.step('Auth recovery: perform one deterministic login and return to import page', async () => {
|
|
try {
|
|
await loginUser(page, adminUser);
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
|
|
if (await hasLoginUiMarkers(page) && adminUser.token) {
|
|
await test.step('Auth recovery fallback: restore fixture token and reload import page', async () => {
|
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
|
await page.evaluate((token: string) => {
|
|
localStorage.setItem('charon_auth_token', token);
|
|
}, adminUser.token);
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
await page.waitForLoadState('networkidle').catch(() => {});
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
});
|
|
}
|
|
|
|
if (await hasLoginUiMarkers(page)) {
|
|
await test.step('Auth recovery fallback: restore auth from setup storage state', async () => {
|
|
const restored = await restoreAuthFromStorageState(page);
|
|
if (!restored) {
|
|
throw new Error(`Unable to restore auth from ${STORAGE_STATE}`);
|
|
}
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
});
|
|
}
|
|
|
|
if (await hasLoginUiMarkers(page)) {
|
|
await test.step('Auth recovery fallback: UI login with setup credentials', async () => {
|
|
await loginWithSetupCredentials(page);
|
|
});
|
|
}
|
|
|
|
await ensureImportFormReady(page);
|
|
return true;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
throw new Error(`Import auth recovery failed after one re-auth attempt: ${message}`);
|
|
}
|
|
});
|
|
};
|
|
|
|
if (await recoverIfNeeded()) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureImportFormReady(page);
|
|
} catch (error) {
|
|
if (await recoverIfNeeded()) {
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function ensureImportUiPreconditions(page: Page, adminUser?: TestUser): Promise<void> {
|
|
await test.step('Precondition: open Caddy import page', async () => {
|
|
await page.goto(IMPORT_PAGE_PATH, { waitUntil: 'domcontentloaded' });
|
|
});
|
|
|
|
await ensureAuthenticatedImportFormReady(page, adminUser);
|
|
|
|
await test.step('Precondition: verify import textarea is visible', async () => {
|
|
await expect(page.locator('textarea')).toBeVisible();
|
|
});
|
|
}
|