290 lines
9.1 KiB
JavaScript
290 lines
9.1 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { chromium } from 'playwright';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
const webRoot = path.resolve(__dirname, '..');
|
|
const outputDirectory = path.join(webRoot, 'output', 'playwright');
|
|
|
|
const DEFAULT_BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
|
|
const DEFAULT_USERNAME = process.env.STELLAOPS_FRONTDOOR_USERNAME?.trim() || 'admin';
|
|
const DEFAULT_PASSWORD = process.env.STELLAOPS_FRONTDOOR_PASSWORD?.trim() || 'Admin@Stella2026!';
|
|
const DEFAULT_STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json');
|
|
const DEFAULT_REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json');
|
|
|
|
function createLocator(page, selectors) {
|
|
return page.locator(selectors.join(', ')).first();
|
|
}
|
|
|
|
async function clickIfVisible(locator, timeoutMs = 5_000) {
|
|
if (!(await locator.isVisible().catch(() => false))) {
|
|
return false;
|
|
}
|
|
|
|
await locator.click({ timeout: timeoutMs, noWaitAfter: true }).catch(() => {});
|
|
return true;
|
|
}
|
|
|
|
async function fillIfVisible(locator, value) {
|
|
if (!(await locator.isVisible().catch(() => false))) {
|
|
return false;
|
|
}
|
|
|
|
await locator.fill(value);
|
|
return true;
|
|
}
|
|
|
|
async function waitForShell(page) {
|
|
const shellMarkers = [
|
|
page.locator('app-topbar'),
|
|
page.locator('aside.sidebar'),
|
|
page.locator('app-shell'),
|
|
page.locator('app-root'),
|
|
];
|
|
|
|
for (const marker of shellMarkers) {
|
|
if (await marker.first().isVisible().catch(() => false)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
await Promise.race([
|
|
...shellMarkers.map((marker) => marker.first().waitFor({ state: 'visible', timeout: 15_000 }).catch(() => {})),
|
|
page.waitForTimeout(15_000),
|
|
]);
|
|
}
|
|
|
|
async function waitForAuthTransition(page, usernameField, passwordField, timeoutMs = 10_000) {
|
|
await Promise.race([
|
|
page.waitForURL((url) => url.toString().includes('/connect/authorize') || url.toString().includes('/auth/callback'), {
|
|
timeout: timeoutMs,
|
|
}).catch(() => {}),
|
|
usernameField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}),
|
|
passwordField.waitFor({ state: 'visible', timeout: timeoutMs }).catch(() => {}),
|
|
page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, {
|
|
timeout: timeoutMs,
|
|
}).catch(() => {}),
|
|
page.waitForTimeout(timeoutMs),
|
|
]);
|
|
}
|
|
|
|
export async function authenticateFrontdoor(options = {}) {
|
|
const baseUrl = options.baseUrl?.trim() || DEFAULT_BASE_URL;
|
|
const username = options.username?.trim() || DEFAULT_USERNAME;
|
|
const password = options.password?.trim() || DEFAULT_PASSWORD;
|
|
const statePath = options.statePath || DEFAULT_STATE_PATH;
|
|
const reportPath = options.reportPath || DEFAULT_REPORT_PATH;
|
|
const headless = options.headless ?? true;
|
|
|
|
mkdirSync(path.dirname(statePath), { recursive: true });
|
|
mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
|
|
const browser = await chromium.launch({
|
|
headless,
|
|
args: ['--disable-dev-shm-usage'],
|
|
});
|
|
|
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
|
const page = await context.newPage();
|
|
|
|
const events = {
|
|
consoleErrors: [],
|
|
requestFailures: [],
|
|
responseErrors: [],
|
|
};
|
|
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
events.consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
page.on('requestfailed', (request) => {
|
|
const url = request.url();
|
|
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
|
return;
|
|
}
|
|
|
|
events.requestFailures.push({
|
|
method: request.method(),
|
|
url,
|
|
error: request.failure()?.errorText ?? 'unknown',
|
|
page: page.url(),
|
|
});
|
|
});
|
|
|
|
page.on('response', (response) => {
|
|
const url = response.url();
|
|
if (/\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url)) {
|
|
return;
|
|
}
|
|
|
|
if (response.status() >= 400) {
|
|
events.responseErrors.push({
|
|
status: response.status(),
|
|
method: response.request().method(),
|
|
url,
|
|
page: page.url(),
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.goto(`${baseUrl}/welcome`, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 30_000,
|
|
});
|
|
await page.waitForTimeout(1_500);
|
|
|
|
const signInTrigger = createLocator(page, [
|
|
'button:has-text("Sign In")',
|
|
'button:has-text("Sign in")',
|
|
'a:has-text("Sign In")',
|
|
'a:has-text("Sign in")',
|
|
'button.cta',
|
|
]);
|
|
|
|
const usernameField = createLocator(page, [
|
|
'input[name="username"]',
|
|
'input[name="Username"]',
|
|
'input[type="text"]',
|
|
'input[type="email"]',
|
|
]);
|
|
const passwordField = createLocator(page, [
|
|
'input[name="password"]',
|
|
'input[name="Password"]',
|
|
'input[type="password"]',
|
|
]);
|
|
|
|
const signInClicked = await clickIfVisible(signInTrigger);
|
|
if (signInClicked) {
|
|
await waitForAuthTransition(page, usernameField, passwordField);
|
|
} else {
|
|
await page.waitForTimeout(1_500);
|
|
}
|
|
|
|
const hasLoginForm = (await usernameField.count()) > 0 && (await passwordField.count()) > 0;
|
|
if (page.url().includes('/connect/authorize') || hasLoginForm) {
|
|
const filledUser = await fillIfVisible(usernameField, username);
|
|
const filledPassword = await fillIfVisible(passwordField, password);
|
|
|
|
if (!filledUser || !filledPassword) {
|
|
throw new Error(`Authority login form was reached at ${page.url()} but the credentials fields were not interactable.`);
|
|
}
|
|
|
|
const submitButton = createLocator(page, [
|
|
'button[type="submit"]',
|
|
'button:has-text("Sign In")',
|
|
'button:has-text("Sign in")',
|
|
'button:has-text("Log in")',
|
|
'button:has-text("Login")',
|
|
]);
|
|
|
|
await submitButton.click({ timeout: 10_000 });
|
|
|
|
await Promise.race([
|
|
page.waitForURL(
|
|
(url) => !url.toString().includes('/connect/authorize') && !url.toString().includes('/auth/callback'),
|
|
{ timeout: 30_000 },
|
|
).catch(() => {}),
|
|
page.waitForFunction(() => Boolean(sessionStorage.getItem('stellaops.auth.session.full')), null, {
|
|
timeout: 30_000,
|
|
}).catch(() => {}),
|
|
]);
|
|
}
|
|
|
|
await waitForShell(page);
|
|
await page.waitForTimeout(2_500);
|
|
|
|
const sessionStatus = await page.evaluate(() => ({
|
|
hasFullSession: Boolean(sessionStorage.getItem('stellaops.auth.session.full')),
|
|
hasSessionInfo: Boolean(sessionStorage.getItem('stellaops.auth.session.info')),
|
|
}));
|
|
const signInStillVisible = await signInTrigger.isVisible().catch(() => false);
|
|
if (!sessionStatus.hasFullSession || (!page.url().includes('/connect/authorize') && signInStillVisible)) {
|
|
throw new Error(
|
|
`Frontdoor authentication did not establish a Stella Ops session. finalUrl=${page.url()} signInVisible=${signInStillVisible}`,
|
|
);
|
|
}
|
|
|
|
await context.storageState({ path: statePath });
|
|
|
|
const report = {
|
|
authenticatedAtUtc: new Date().toISOString(),
|
|
baseUrl,
|
|
finalUrl: page.url(),
|
|
title: await page.title(),
|
|
cookies: (await context.cookies()).map((cookie) => ({
|
|
name: cookie.name,
|
|
domain: cookie.domain,
|
|
path: cookie.path,
|
|
secure: cookie.secure,
|
|
sameSite: cookie.sameSite,
|
|
})),
|
|
storage: await page.evaluate(() => ({
|
|
localStorageEntries: [...Array(localStorage.length)]
|
|
.map((_, index) => localStorage.key(index))
|
|
.filter(Boolean)
|
|
.map((key) => [key, localStorage.getItem(key)]),
|
|
sessionStorageEntries: [...Array(sessionStorage.length)]
|
|
.map((_, index) => sessionStorage.key(index))
|
|
.filter(Boolean)
|
|
.map((key) => [key, sessionStorage.getItem(key)]),
|
|
})),
|
|
events,
|
|
statePath,
|
|
};
|
|
|
|
writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
await browser.close();
|
|
|
|
return report;
|
|
}
|
|
|
|
export function getSessionStorageEntries(authReport) {
|
|
return Array.isArray(authReport?.storage?.sessionStorageEntries)
|
|
? authReport.storage.sessionStorageEntries.filter(
|
|
(entry) => Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string',
|
|
)
|
|
: [];
|
|
}
|
|
|
|
export async function addSessionStorageInitScript(context, authReport) {
|
|
const sessionEntries = getSessionStorageEntries(authReport);
|
|
await context.addInitScript((entries) => {
|
|
sessionStorage.clear();
|
|
for (const [key, value] of entries) {
|
|
if (typeof key === 'string' && typeof value === 'string') {
|
|
sessionStorage.setItem(key, value);
|
|
}
|
|
}
|
|
}, sessionEntries);
|
|
}
|
|
|
|
export async function createAuthenticatedContext(browser, authReport, options = {}) {
|
|
const context = await browser.newContext({
|
|
ignoreHTTPSErrors: true,
|
|
storageState: options.statePath || DEFAULT_STATE_PATH,
|
|
...options.contextOptions,
|
|
});
|
|
|
|
await addSessionStorageInitScript(context, authReport);
|
|
return context;
|
|
}
|
|
|
|
async function main() {
|
|
const report = await authenticateFrontdoor();
|
|
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
|
main().catch((error) => {
|
|
process.stderr.write(`[live-frontdoor-auth] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exit(1);
|
|
});
|
|
}
|