Files
git.stella-ops.org/src/Web/StellaOps.Web/screenshots/auth/capture.mjs
2026-02-15 12:00:34 +02:00

648 lines
24 KiB
JavaScript

/**
* Playwright script to capture authenticated layout screenshots of StellaOps.
*
* Strategy:
* 1. Navigate to stella-ops.local
* 2. Try the real OAuth flow first (click Sign in, fill credentials)
* 3. If OAuth fails (Authority 500), inject mock auth state into Angular
* to force the authenticated shell layout with sidebar
* 4. Capture screenshots of various pages and states
*/
import { chromium } from 'playwright';
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const SCREENSHOT_DIR = __dirname;
const BASE_URL = 'http://stella-ops.local';
const VIEWPORT_DESKTOP = { width: 1440, height: 900 };
const VIEWPORT_MOBILE = { width: 390, height: 844 };
// Mock session data to inject into Angular's AuthSessionStore
const MOCK_SESSION = {
tokens: {
accessToken: 'mock-access-token-for-screenshot',
expiresAtEpochMs: Date.now() + 3600000, // 1 hour from now
refreshToken: 'mock-refresh-token',
tokenType: 'Bearer',
scope: 'openid profile email ui.read ui.admin authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read'
},
identity: {
subject: 'admin-user-001',
name: 'Admin',
email: 'admin@stella-ops.local',
roles: ['admin', 'security-analyst'],
},
dpopKeyThumbprint: 'mock-dpop-thumbprint-sha256',
issuedAtEpochMs: Date.now(),
tenantId: 'tenant-default',
scopes: [
'openid', 'profile', 'email',
'ui.read', 'ui.admin',
'authority:tenants.read', 'authority:users.read', 'authority:roles.read',
'authority:clients.read', 'authority:tokens.read', 'authority:audit.read',
'graph:read', 'graph:write', 'graph:simulate', 'graph:export',
'sbom:read',
'policy:read', 'policy:evaluate', 'policy:simulate', 'policy:author',
'policy:edit', 'policy:review', 'policy:submit', 'policy:approve',
'policy:operate', 'policy:activate', 'policy:run', 'policy:audit',
'scanner:read',
'exception:read', 'exception:write',
'release:read',
'aoc:verify',
'orch:read',
'analytics.read',
'findings:read',
],
audiences: ['stella-ops-api'],
authenticationTimeEpochMs: Date.now(),
freshAuthActive: true,
freshAuthExpiresAtEpochMs: Date.now() + 300000,
};
const PERSISTED_METADATA = {
subject: MOCK_SESSION.identity.subject,
expiresAtEpochMs: MOCK_SESSION.tokens.expiresAtEpochMs,
issuedAtEpochMs: MOCK_SESSION.issuedAtEpochMs,
dpopKeyThumbprint: MOCK_SESSION.dpopKeyThumbprint,
tenantId: MOCK_SESSION.tenantId,
};
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function screenshot(page, name, description) {
const filePath = join(SCREENSHOT_DIR, `${name}.png`);
await page.screenshot({ path: filePath, fullPage: false });
console.log(` [SCREENSHOT] ${name}.png - ${description}`);
return filePath;
}
async function tryRealLogin(page) {
console.log('\n[STEP] Attempting real OAuth login flow...');
// Navigate to homepage
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
await sleep(2000);
// Look for Sign in button
const signInButton = page.locator('button.app-auth__signin, button:has-text("Sign in")');
const hasSignIn = await signInButton.count() > 0;
if (!hasSignIn) {
console.log(' No Sign in button found (may already be authenticated or shell layout active)');
// Check if already in shell layout
const shell = page.locator('app-shell, .shell');
if (await shell.count() > 0) {
console.log(' Shell layout detected - already authenticated!');
return true;
}
return false;
}
console.log(' Sign in button found, clicking...');
await screenshot(page, '01-landing-unauthenticated', 'Landing page before sign-in');
// Click sign in - this will redirect to Authority
try {
// Listen for navigation to authority
const [response] = await Promise.all([
page.waitForNavigation({ timeout: 10000 }).catch(() => null),
signInButton.click(),
]);
await sleep(2000);
// Check if we're on an authority login page
const currentUrl = page.url();
console.log(` Redirected to: ${currentUrl}`);
if (currentUrl.includes('authority') || currentUrl.includes('authorize') || currentUrl.includes('login')) {
await screenshot(page, '02-authority-login-page', 'Authority login page');
// Try to find username/password fields
const usernameField = page.locator('input[name="username"], input[type="email"], input[name="login"], #username, #email');
const passwordField = page.locator('input[type="password"], input[name="password"], #password');
if (await usernameField.count() > 0 && await passwordField.count() > 0) {
console.log(' Login form found, entering credentials...');
await usernameField.first().fill('admin');
await passwordField.first().fill('Admin@Stella2026!');
const submitButton = page.locator('button[type="submit"], input[type="submit"], button:has-text("Login"), button:has-text("Sign in"), button:has-text("Log in")');
if (await submitButton.count() > 0) {
await submitButton.first().click();
await sleep(3000);
// Check if login was successful
if (page.url().includes(BASE_URL) || page.url().includes('callback')) {
console.log(' Login successful!');
await page.waitForLoadState('networkidle');
await sleep(2000);
return true;
}
}
}
}
// If we ended up with an error
if (currentUrl.includes('error') || page.url().includes('error')) {
console.log(' OAuth flow resulted in error page');
await screenshot(page, '02-oauth-error', 'OAuth error page');
}
} catch (error) {
console.log(` OAuth flow navigation failed: ${error.message}`);
}
return false;
}
async function injectMockAuth(page) {
console.log('\n[STEP] Injecting mock authentication state...');
// Navigate to base URL first
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
await sleep(1000);
// Inject session storage and manipulate Angular internals
const injected = await page.evaluate((mockData) => {
const { session, persisted } = mockData;
// Set session storage for persistence
sessionStorage.setItem('stellaops.auth.session.info', JSON.stringify(persisted));
// Try to access Angular's dependency injection to set the session store
// Angular stores component references in debug elements
try {
const appRoot = document.querySelector('app-root');
if (!appRoot) return { success: false, reason: 'app-root not found' };
// Access Angular's internal component instance
const ngContext = appRoot['__ngContext__'];
if (!ngContext) return { success: false, reason: 'no Angular context' };
return { success: true, reason: 'session storage set, need reload' };
} catch (e) {
return { success: false, reason: e.message };
}
}, { session: MOCK_SESSION, persisted: PERSISTED_METADATA });
console.log(` Injection result: ${JSON.stringify(injected)}`);
// The most reliable approach: intercept the silent-refresh to return a successful response,
// and intercept API calls to prevent errors. Then reload.
// Actually, let's take a simpler approach: route intercept to mock the OIDC flow.
// Set up route interception
await page.route('**/connect/authorize**', async (route) => {
// Redirect back to callback with a mock code
const url = new URL(route.request().url());
const state = url.searchParams.get('state') || 'mock-state';
const redirectUri = url.searchParams.get('redirect_uri') || `${BASE_URL}/callback`;
const callbackUrl = `${redirectUri}?code=mock-auth-code-12345&state=${state}`;
await route.fulfill({
status: 302,
headers: { 'Location': callbackUrl },
});
});
await page.route('**/token', async (route) => {
// Return a mock token response
const accessPayload = btoa(JSON.stringify({ alg: 'RS256', typ: 'at+jwt' }))
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const accessClaims = btoa(JSON.stringify({
sub: 'admin-user-001',
name: 'Admin',
email: 'admin@stella-ops.local',
'stellaops:tenant': 'tenant-default',
role: ['admin', 'security-analyst'],
scp: MOCK_SESSION.scopes,
aud: ['stella-ops-api'],
auth_time: Math.floor(Date.now() / 1000),
'stellaops:fresh_auth': true,
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
iss: 'http://authority.stella-ops.local',
})).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const mockSig = 'mock-signature';
const accessToken = `${accessPayload}.${accessClaims}.${mockSig}`;
const idPayload = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const idClaims = btoa(JSON.stringify({
sub: 'admin-user-001',
name: 'Admin',
email: 'admin@stella-ops.local',
role: ['admin', 'security-analyst'],
nonce: 'mock-nonce',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
iss: 'http://authority.stella-ops.local',
})).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
const idToken = `${idPayload}.${idClaims}.${mockSig}`;
await route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'DPoP-Nonce': 'mock-dpop-nonce' },
body: JSON.stringify({
access_token: accessToken,
token_type: 'DPoP',
expires_in: 3600,
scope: MOCK_SESSION.scopes.join(' '),
refresh_token: 'mock-refresh-token',
id_token: idToken,
}),
});
});
// Intercept console-context API calls
await page.route('**/console/context**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
tenants: [{
tenantId: 'tenant-default',
displayName: 'Default Tenant',
role: 'admin'
}],
selectedTenant: 'tenant-default',
features: {},
}),
});
});
// Intercept any API calls that would fail without auth
await page.route('**/api/**', async (route) => {
// Let it pass through but don't fail
try {
await route.continue();
} catch {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [], message: 'mock response' }),
});
}
});
// Intercept branding
await page.route('**/console/branding**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
logoUrl: null,
title: 'Stella Ops',
theme: 'dark',
}),
});
});
// Now navigate fresh and click Sign in to trigger the mocked OAuth flow
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
await sleep(2000);
// Click Sign in
const signInButton = page.locator('button.app-auth__signin, button:has-text("Sign in")');
if (await signInButton.count() > 0) {
console.log(' Clicking Sign in with intercepted OAuth flow...');
// The sign in click will call beginLogin() which calls window.location.assign(authorizeUrl)
// Our route intercept will redirect back to /callback with a mock code
// Then completeLoginFromRedirect will exchange the code at /token (also intercepted)
// But beginLogin uses window.location.assign which navigates away,
// and our route intercept handles the authorize redirect.
// However, the DPoP proof generation might fail...
// Let's try a different approach: directly manipulate the Angular service
const authInjected = await page.evaluate((sessionData) => {
try {
// Get Angular's injector from the app root
const appRoot = document.querySelector('app-root');
if (!appRoot) return { success: false, reason: 'no app-root' };
// Use ng.getComponent and ng.getInjector for debugging
const ng = (window).__ng_debug__ || (window).ng;
// Try getAllAngularRootElements approach
const rootElements = (window).getAllAngularRootElements?.() ?? [appRoot];
// Access __ngContext__ to find the component
const ctx = appRoot.__ngContext__;
if (!ctx) return { success: false, reason: 'no __ngContext__' };
// Walk the injector tree to find AuthSessionStore
// In Angular 19+, the LView is the context array
// The injector is typically at index 9 or via the directive flags
// Alternative approach: use the debugging API if available
if (typeof (window).ng !== 'undefined') {
const component = (window).ng.getComponent(appRoot);
if (component) {
// Access sessionStore through the component's injected dependencies
const sessionStore = component.sessionStore;
if (sessionStore && typeof sessionStore.setSession === 'function') {
sessionStore.setSession(sessionData);
return { success: true, method: 'ng.getComponent -> sessionStore.setSession' };
}
// Try via the auth property path
if (component.auth && component.sessionStore) {
component.sessionStore.setSession(sessionData);
return { success: true, method: 'component.sessionStore' };
}
}
}
return { success: false, reason: 'could not access Angular internals' };
} catch (e) {
return { success: false, reason: e.message };
}
}, MOCK_SESSION);
console.log(` Direct Angular injection result: ${JSON.stringify(authInjected)}`);
if (!authInjected.success) {
// Last resort: navigate with mocked OAuth - click Sign in and let interceptors handle it
// The DPoP createProof will be called which uses WebCrypto - should work in Playwright
console.log(' Attempting OAuth flow with route interception...');
try {
await Promise.all([
page.waitForURL('**/callback**', { timeout: 10000 }).catch(() => null),
signInButton.click(),
]);
await sleep(3000);
console.log(` After sign-in click, URL: ${page.url()}`);
// Wait for the app to process the callback
await page.waitForLoadState('networkidle').catch(() => {});
await sleep(2000);
} catch (e) {
console.log(` OAuth interception flow error: ${e.message}`);
}
}
}
// Check if we're authenticated now
const isAuthenticated = await page.evaluate(() => {
const appRoot = document.querySelector('app-root');
const shell = document.querySelector('app-shell, .shell');
const sidebar = document.querySelector('app-sidebar, .sidebar');
return {
hasShell: !!shell,
hasSidebar: !!sidebar,
hasAppRoot: !!appRoot,
url: window.location.href,
sessionStorage: sessionStorage.getItem('stellaops.auth.session.info'),
};
});
console.log(` Auth check: ${JSON.stringify(isAuthenticated)}`);
return isAuthenticated.hasShell || isAuthenticated.hasSidebar;
}
async function captureAuthenticatedScreenshots(page) {
console.log('\n[STEP] Capturing authenticated layout screenshots...');
await sleep(2000);
// 1. Main dashboard with sidebar
await screenshot(page, '03-dashboard-sidebar-expanded', 'Dashboard with sidebar visible (1440x900)');
// 2. Wait for sidebar to be visible and capture it expanded
const sidebar = page.locator('app-sidebar, .sidebar');
if (await sidebar.count() > 0) {
console.log(' Sidebar component found');
// Try to expand nav groups
const navGroups = page.locator('.sidebar__group-header, .nav-group__header, [class*="group-toggle"], [class*="nav-group"]');
const groupCount = await navGroups.count();
console.log(` Found ${groupCount} nav group elements`);
// Click to expand some groups
for (let i = 0; i < Math.min(groupCount, 5); i++) {
try {
await navGroups.nth(i).click();
await sleep(300);
} catch (e) {
// Some may not be clickable
}
}
await sleep(500);
await screenshot(page, '04-sidebar-groups-expanded', 'Sidebar with navigation groups expanded');
}
// 3. Navigate to different pages
const pages = [
{ route: '/findings', name: '05-findings-page', desc: 'Scans & Findings page' },
{ route: '/vulnerabilities', name: '06-vulnerabilities-page', desc: 'Vulnerabilities page' },
{ route: '/triage/artifacts', name: '07-triage-page', desc: 'Triage page' },
{ route: '/policy-studio/packs', name: '08-policy-studio', desc: 'Policy Studio page' },
{ route: '/evidence', name: '09-evidence-page', desc: 'Evidence page' },
{ route: '/ops/health', name: '10-operations-health', desc: 'Operations health page' },
{ route: '/settings', name: '11-settings-page', desc: 'Settings page' },
{ route: '/console/admin/tenants', name: '12-admin-tenants', desc: 'Admin tenants page' },
];
for (const pg of pages) {
try {
await page.goto(`${BASE_URL}${pg.route}`, { waitUntil: 'domcontentloaded', timeout: 10000 });
await sleep(1500);
await page.waitForLoadState('networkidle').catch(() => {});
await screenshot(page, pg.name, pg.desc);
} catch (e) {
console.log(` Failed to capture ${pg.name}: ${e.message}`);
}
}
// 4. Collapsed sidebar
console.log('\n Toggling sidebar to collapsed state...');
const collapseToggle = page.locator('[class*="collapse-toggle"], [class*="sidebar-toggle"], button[aria-label*="collapse"], button[aria-label*="toggle"]');
if (await collapseToggle.count() > 0) {
await collapseToggle.first().click();
await sleep(500);
await screenshot(page, '13-sidebar-collapsed', 'Sidebar in collapsed state');
} else {
// Try the sidebar component's collapse button
const sidebarButtons = page.locator('app-sidebar button, .sidebar button');
const btnCount = await sidebarButtons.count();
for (let i = 0; i < btnCount; i++) {
const text = await sidebarButtons.nth(i).textContent().catch(() => '');
const ariaLabel = await sidebarButtons.nth(i).getAttribute('aria-label').catch(() => '');
if (text?.includes('collapse') || text?.includes('Collapse') ||
ariaLabel?.includes('collapse') || ariaLabel?.includes('Collapse') ||
text?.includes('<<') || text?.includes('toggle')) {
await sidebarButtons.nth(i).click();
await sleep(500);
break;
}
}
await screenshot(page, '13-sidebar-collapsed', 'Sidebar collapsed (or toggle not found)');
}
// 5. Mobile viewport
console.log('\n Switching to mobile viewport...');
await page.setViewportSize(VIEWPORT_MOBILE);
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 10000 });
await sleep(2000);
await screenshot(page, '14-mobile-viewport', 'Mobile viewport (390px)');
// Try to open mobile menu
const menuToggle = page.locator('[class*="menu-toggle"], [class*="hamburger"], button[aria-label*="menu"], button[aria-label*="Menu"]');
if (await menuToggle.count() > 0) {
await menuToggle.first().click();
await sleep(500);
await screenshot(page, '15-mobile-menu-open', 'Mobile viewport with menu open');
}
}
async function captureUnauthenticatedScreenshots(page) {
console.log('\n[STEP] Capturing what is visible on the live site...');
await page.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
await sleep(3000);
// Check what's actually rendered
const pageState = await page.evaluate(() => {
const elements = {
appRoot: !!document.querySelector('app-root'),
appShell: !!document.querySelector('app-shell'),
sidebar: !!document.querySelector('app-sidebar'),
topbar: !!document.querySelector('app-topbar'),
header: !!document.querySelector('.app-header'),
signIn: !!document.querySelector('.app-auth__signin'),
splash: !!document.querySelector('#stella-splash'),
shell: !!document.querySelector('.shell'),
navigation: !!document.querySelector('app-navigation-menu'),
breadcrumb: !!document.querySelector('app-breadcrumb'),
};
return elements;
});
console.log(` Page element state: ${JSON.stringify(pageState, null, 2)}`);
await screenshot(page, '01-landing-page', 'Landing page state');
}
async function main() {
console.log('=== StellaOps Authenticated Layout Screenshot Capture ===\n');
console.log(`Target: ${BASE_URL}`);
console.log(`Output: ${SCREENSHOT_DIR}\n`);
const browser = await chromium.launch({
headless: true,
args: ['--ignore-certificate-errors', '--allow-insecure-localhost'],
});
const context = await browser.newContext({
viewport: VIEWPORT_DESKTOP,
ignoreHTTPSErrors: true,
bypassCSP: true,
});
const page = await context.newPage();
// Enable console log forwarding
page.on('console', msg => {
if (msg.type() === 'error') {
console.log(` [BROWSER ERROR] ${msg.text()}`);
}
});
try {
// Step 1: Capture current state
await captureUnauthenticatedScreenshots(page);
// Step 2: Try real login
const realLoginSuccess = await tryRealLogin(page);
if (realLoginSuccess) {
console.log('\n Real login succeeded!');
await captureAuthenticatedScreenshots(page);
} else {
console.log('\n Real login failed. Attempting mock auth injection...');
// Close and create new context for clean state
await page.close();
const newPage = await context.newPage();
page.on('console', msg => {
if (msg.type() === 'error') {
console.log(` [BROWSER ERROR] ${msg.text()}`);
}
});
const mockSuccess = await injectMockAuth(newPage);
if (mockSuccess) {
console.log('\n Mock auth injection succeeded!');
await captureAuthenticatedScreenshots(newPage);
} else {
console.log('\n Mock auth injection did not produce shell layout.');
console.log(' Capturing available state with additional details...');
// Capture whatever we can see
await newPage.goto(BASE_URL, { waitUntil: 'networkidle', timeout: 15000 });
await sleep(3000);
// Get detailed DOM info
const domInfo = await newPage.evaluate(() => {
const getTextContent = (selector) => {
const el = document.querySelector(selector);
return el ? el.textContent?.trim()?.substring(0, 200) : null;
};
const getInnerHTML = (selector) => {
const el = document.querySelector(selector);
return el ? el.innerHTML?.substring(0, 500) : null;
};
return {
title: document.title,
bodyClasses: document.body.className,
appRootHTML: getInnerHTML('app-root'),
mainContent: getTextContent('.app-content') || getTextContent('main'),
allComponents: Array.from(document.querySelectorAll('*'))
.filter(el => el.tagName.includes('-'))
.map(el => el.tagName.toLowerCase())
.filter((v, i, a) => a.indexOf(v) === i)
.slice(0, 30),
};
});
console.log('\n DOM Info:');
console.log(` Title: ${domInfo.title}`);
console.log(` Body classes: ${domInfo.bodyClasses}`);
console.log(` Custom elements: ${domInfo.allComponents.join(', ')}`);
if (domInfo.appRootHTML) {
console.log(` app-root HTML (first 500 chars): ${domInfo.appRootHTML}`);
}
await screenshot(newPage, '99-final-state', 'Final page state after all attempts');
await newPage.close();
}
}
} catch (error) {
console.error(`\nFatal error: ${error.message}`);
console.error(error.stack);
try {
await screenshot(page, '99-error-state', 'Error state');
} catch {}
} finally {
await browser.close();
}
console.log('\n=== Screenshot capture complete ===');
}
main().catch(console.error);