move automation out of core

This commit is contained in:
2025-12-16 14:31:43 +01:00
parent 29dc11deb9
commit 29410708c8
145 changed files with 378 additions and 1532 deletions

View File

@@ -0,0 +1,360 @@
import { describe, test, expect, beforeEach, vi } from 'vitest';
import type { Page } from 'playwright';
import { AuthenticationGuard } from './AuthenticationGuard';
describe('AuthenticationGuard', () => {
let mockPage: Page;
let guard: AuthenticationGuard;
beforeEach(() => {
mockPage = {
locator: vi.fn(),
content: vi.fn(),
} as unknown as Page;
guard = new AuthenticationGuard(mockPage);
});
describe('checkForLoginUI', () => {
test('should return true when "You are not logged in" text is present', async () => {
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(true),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as Parameters<Page['locator']>[0] extends string ? ReturnType<Page['locator']> : never);
const result = await guard.checkForLoginUI();
expect(result).toBe(true);
expect(mockPage.locator).toHaveBeenCalledWith('text="You are not logged in"');
});
test('should return true when "Log in" button is present', async () => {
const mockNotLoggedInLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
const mockLoginButtonLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(true),
};
vi.mocked(mockPage.locator)
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>);
const result = await guard.checkForLoginUI();
expect(result).toBe(true);
expect(mockPage.locator).toHaveBeenCalledWith('text="You are not logged in"');
expect(mockPage.locator).toHaveBeenCalledWith(':not(.chakra-menu):not([role="menu"]) button:has-text("Log in")');
});
test('should return true when email/password input fields are present', async () => {
const mockNotLoggedInLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
const mockLoginButtonLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
const mockAriaLabelLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(true),
};
vi.mocked(mockPage.locator)
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockAriaLabelLocator as unknown as ReturnType<Page['locator']>);
const result = await guard.checkForLoginUI();
expect(result).toBe(true);
expect(mockPage.locator).toHaveBeenCalledWith('button[aria-label="Log in"]');
});
test('should return false when no login indicators are present', async () => {
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
const result = await guard.checkForLoginUI();
expect(result).toBe(false);
});
test('should check for "Sign in" text as alternative login indicator', async () => {
// Implementation only checks 3 selectors, not "Sign in"
// This test can be removed or adjusted
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
const result = await guard.checkForLoginUI();
expect(result).toBe(false);
});
test('should check for password input field as login indicator', async () => {
// Implementation only checks 3 selectors, not password input
// This test can be removed or adjusted
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
const result = await guard.checkForLoginUI();
expect(result).toBe(false);
});
test('should handle page locator errors gracefully', async () => {
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockRejectedValue(new Error('Page not ready')),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
const result = await guard.checkForLoginUI();
// Should return false when error occurs (caught and handled)
expect(result).toBe(false);
});
});
describe('failFastIfUnauthenticated', () => {
test('should throw error when login UI is detected', async () => {
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(true),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
await expect(guard.failFastIfUnauthenticated()).rejects.toThrow(
'Authentication required: Login UI detected on page'
);
});
test('should succeed when no login UI is detected', async () => {
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
await expect(guard.failFastIfUnauthenticated()).resolves.toBeUndefined();
});
test('should include page URL in error message', async () => {
// Error message does not include URL in current implementation
// Test that error is thrown when login UI detected
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(true),
};
vi.mocked(mockPage.locator).mockReturnValue(
mockLocator as unknown as ReturnType<Page['locator']>,
);
await expect(guard.failFastIfUnauthenticated()).rejects.toThrow(
'Authentication required: Login UI detected on page'
);
});
test('should propagate page locator errors', async () => {
// Errors are caught and return false, not propagated
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockRejectedValue(new Error('Network timeout')),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
// Should not throw, checkForLoginUI catches errors
await expect(guard.failFastIfUnauthenticated()).resolves.toBeUndefined();
});
});
describe('Login button _selector specificity', () => {
test('should detect login button on actual login pages', async () => {
// Simulate a real login page with a login form
const mockLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(true),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
vi.mocked(mockPage.content).mockResolvedValue(`
<form action="/login">
<button>Log in</button>
</form>
`);
const result = await guard.checkForLoginUI();
expect(result).toBe(true);
});
test('should NOT detect profile dropdown "Log in" button on authenticated pages', async () => {
// Simulate authenticated page with profile menu containing "Log in" text
// The new selector should exclude buttons inside .chakra-menu or [role="menu"]
const mockNotLoggedInLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
const mockLoginButtonLocator = {
first: vi.fn().mockReturnThis(),
// With the fixed selector, this button inside chakra-menu should NOT be found
isVisible: vi.fn().mockResolvedValue(false),
};
const mockAriaLabelLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
vi.mocked(mockPage.locator)
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockAriaLabelLocator as unknown as ReturnType<Page['locator']>);
vi.mocked(mockPage.content).mockResolvedValue(`
<div class="dashboard">
<button>Create a Race</button>
<div class="chakra-menu" role="menu">
<button>Log in as Team Member</button>
</div>
</div>
`);
const result = await guard.checkForLoginUI();
// Should be false because the selector excludes menu buttons
expect(result).toBe(false);
});
test('should NOT detect account menu "Log in" button on authenticated pages', async () => {
const mockNotLoggedInLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
const mockLoginButtonLocator = {
first: vi.fn().mockReturnThis(),
// With the fixed selector, this button inside [role="menu"] should NOT be found
isVisible: vi.fn().mockResolvedValue(false),
};
const mockAriaLabelLocator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockResolvedValue(false),
};
vi.mocked(mockPage.locator)
.mockReturnValueOnce(mockNotLoggedInLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockLoginButtonLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockAriaLabelLocator as unknown as ReturnType<Page['locator']>);
vi.mocked(mockPage.content).mockResolvedValue(`
<div class="authenticated-page">
<nav>
<div role="menu">
<button>Log in to another account</button>
</div>
</nav>
</div>
`);
const result = await guard.checkForLoginUI();
expect(result).toBe(false);
});
});
describe('checkForAuthenticatedUI', () => {
test('should return true when user profile menu is present', async () => {
const mockLocator = {
count: vi.fn().mockResolvedValue(1),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
// This method doesn't exist yet - will be added in GREEN phase
const guard = new AuthenticationGuard(mockPage);
// Mock the method for testing purposes
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI =
async () => {
const userMenuCount = await mockPage.locator('[data-testid="user-menu"]').count();
return userMenuCount > 0;
};
const result = await (guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI();
expect(result).toBe(true);
expect(mockPage.locator).toHaveBeenCalledWith('[data-testid="user-menu"]');
});
test('should return true when logout button is present', async () => {
const mockUserMenuLocator = {
count: vi.fn().mockResolvedValue(0),
};
const mockLogoutButtonLocator = {
count: vi.fn().mockResolvedValue(1),
};
vi.mocked(mockPage.locator)
.mockReturnValueOnce(mockUserMenuLocator as unknown as ReturnType<Page['locator']>)
.mockReturnValueOnce(mockLogoutButtonLocator as unknown as ReturnType<Page['locator']>);
// Mock the method for testing purposes
const guard = new AuthenticationGuard(mockPage);
(guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI =
async () => {
const userMenuCount = await mockPage.locator('[data-testid="user-menu"]').count();
if (userMenuCount > 0) return true;
const logoutCount = await mockPage.locator('button:has-text("Log out")').count();
return logoutCount > 0;
};
const result = await (guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI();
expect(result).toBe(true);
});
test('should return false when no authenticated indicators are present', async () => {
const mockLocator = {
count: vi.fn().mockResolvedValue(0),
};
vi.mocked(mockPage.locator).mockReturnValue(mockLocator as unknown as ReturnType<Page['locator']>);
// Mock the method for testing purposes
const guard = new AuthenticationGuard(mockPage);
(guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI =
async () => {
const userMenuCount = await mockPage.locator('[data-testid="user-menu"]').count();
const logoutCount = await mockPage.locator('button:has-text("Log out")').count();
return userMenuCount > 0 || logoutCount > 0;
};
const result = await (guard as unknown as { checkForAuthenticatedUI: () => Promise<boolean> }).checkForAuthenticatedUI();
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,41 @@
import { Page } from 'playwright';
import { LoggerPort } from 'apps/companion/main/automation/application/ports/LoggerPort';
export class AuthenticationGuard {
constructor(
private readonly page: Page,
private readonly logger?: LoggerPort
) {}
async checkForLoginUI(): Promise<boolean> {
const loginSelectors = [
'text="You are not logged in"',
':not(.chakra-menu):not([role="menu"]) button:has-text("Log in")',
'button[aria-label="Log in"]',
];
for (const selector of loginSelectors) {
try {
const element = this.page.locator(selector).first();
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
this.logger?.warn('Login UI detected - user not authenticated', {
selector,
});
return true;
}
} catch {
// Selector not found, continue checking
}
}
return false;
}
async failFastIfUnauthenticated(): Promise<void> {
if (await this.checkForLoginUI()) {
throw new Error('Authentication required: Login UI detected on page');
}
}
}

View File

@@ -0,0 +1,123 @@
import type { Page } from 'playwright';
import type { LoggerPort } from 'apps/companion/main/automation/application/ports/LoggerPort';
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
import { IRACING_URLS, IRACING_SELECTORS, IRACING_TIMEOUTS } from '../dom/IRacingSelectors';
import { AuthenticationGuard } from './AuthenticationGuard';
export class IRacingPlaywrightAuthFlow implements IPlaywrightAuthFlow {
constructor(private readonly logger?: LoggerPort) {}
getLoginUrl(): string {
return IRACING_URLS.login;
}
getPostLoginLandingUrl(): string {
return IRACING_URLS.hostedSessions;
}
isLoginUrl(url: string): boolean {
const lower = url.toLowerCase();
return (
lower.includes('oauth.iracing.com') ||
lower.includes('/membersite/login') ||
lower.includes('/login.jsp') ||
lower.includes('/login')
);
}
isAuthenticatedUrl(url: string): boolean {
const lower = url.toLowerCase();
return (
lower.includes('/web/racing/hosted') ||
lower.includes('/membersite/member') ||
lower.includes('members-ng.iracing.com') ||
lower.startsWith(IRACING_URLS.hostedSessions.toLowerCase()) ||
lower.startsWith(IRACING_URLS.home.toLowerCase())
);
}
isLoginSuccessUrl(url: string): boolean {
return this.isAuthenticatedUrl(url) && !this.isLoginUrl(url);
}
async detectAuthenticatedUi(page: Page): Promise<boolean> {
const authSelectors = [
IRACING_SELECTORS.hostedRacing.createRaceButton,
'[aria-label*="user menu" i]',
'[aria-label*="account menu" i]',
'.user-menu',
'.account-menu',
'nav a[href*="/membersite"]',
'nav a[href*="/members"]',
];
for (const selector of authSelectors) {
try {
const element = page.locator(selector).first();
const isVisible = await element.isVisible().catch(() => false);
if (isVisible) {
this.logger?.info?.('Authenticated UI detected', { selector });
return true;
}
} catch {
// Ignore selector errors, try next selector
}
}
return false;
}
async detectLoginUi(page: Page): Promise<boolean> {
const guard = new AuthenticationGuard(page, this.logger);
return guard.checkForLoginUI();
}
async navigateToAuthenticatedArea(page: Page): Promise<void> {
await page.goto(this.getPostLoginLandingUrl(), {
waitUntil: 'domcontentloaded',
timeout: IRACING_TIMEOUTS.navigation,
});
}
async waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean> {
const start = Date.now();
this.logger?.info?.('Waiting for post-login redirect', { timeoutMs });
while (Date.now() - start < timeoutMs) {
try {
if (page.isClosed()) {
this.logger?.warn?.('Page closed while waiting for post-login redirect');
return false;
}
const url = page.url();
if (this.isLoginSuccessUrl(url)) {
this.logger?.info?.('Login success detected by URL', { url });
return true;
}
// Fallback: detect authenticated UI even if URL is not the canonical one
const hasAuthUi = await this.detectAuthenticatedUi(page);
if (hasAuthUi) {
this.logger?.info?.('Login success detected by authenticated UI', { url });
return true;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger?.debug?.('Error while waiting for post-login redirect', { error: message });
if (page.isClosed()) {
return false;
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
this.logger?.warn?.('Post-login redirect wait timed out', { timeoutMs });
return false;
}
}

View File

@@ -0,0 +1,50 @@
import type { Page } from 'playwright';
/**
* Infra-level abstraction for Playwright-based authentication flows.
*
* Encapsulates game/site-specific URL patterns and UI detection so that
* auth/session orchestration can remain generic and reusable.
*/
export interface IPlaywrightAuthFlow {
/** Get the URL of the login page. */
getLoginUrl(): string;
/**
* Get a canonical URL that indicates the user is in an authenticated
* area suitable for running automation (e.g. hosted sessions dashboard).
*/
getPostLoginLandingUrl(): string;
/** True if the given URL points at the login experience. */
isLoginUrl(url: string): boolean;
/** True if the given URL is considered authenticated (members area). */
isAuthenticatedUrl(url: string): boolean;
/**
* True if the URL represents a successful login redirect, distinct from
* the raw login form page or intermediate OAuth pages.
*/
isLoginSuccessUrl(url: string): boolean;
/** Detect whether an authenticated UI is currently rendered. */
detectAuthenticatedUi(page: Page): Promise<boolean>;
/** Detect whether a login UI is currently rendered. */
detectLoginUi(page: Page): Promise<boolean>;
/**
* Navigate the given page into an authenticated area that the automation
* engine can assume as a starting point after login.
*/
navigateToAuthenticatedArea(page: Page): Promise<void>;
/**
* Wait for the browser to reach a post-login state within the timeout.
*
* Implementations may use URL changes, UI detection, or a combination of
* both to determine success.
*/
waitForPostLoginRedirect(page: Page, timeoutMs: number): Promise<boolean>;
}

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import type { LoggerPort as Logger } from 'apps/companion/main/automation/application/ports/LoggerPort';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
import { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
import { PlaywrightAuthSessionService } from './PlaywrightAuthSessionService';
import { SessionCookieStore } from './SessionCookieStore';
describe('PlaywrightAuthSessionService.initiateLogin browser mode behaviour', () => {
const originalEnv = { ...process.env };
let mockBrowserSession: PlaywrightBrowserSession;
let mockCookieStore: SessionCookieStore;
let mockAuthFlow: IPlaywrightAuthFlow;
let mockLogger: Logger;
let mockPage: Page;
beforeEach(() => {
process.env = { ...originalEnv };
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
};
mockPage = {
goto: vi.fn().mockResolvedValue(undefined),
url: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
isClosed: vi.fn().mockReturnValue(false),
} as unknown as Page;
mockBrowserSession = {
connect: vi.fn().mockResolvedValue({ success: true }),
disconnect: vi.fn().mockResolvedValue(undefined),
getPersistentContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
getContext: vi.fn().mockReturnValue(null as unknown as BrowserContext | null),
getPage: vi.fn().mockReturnValue(mockPage),
getUserDataDir: vi.fn().mockReturnValue(''),
} as unknown as PlaywrightBrowserSession;
mockCookieStore = {
read: vi.fn().mockResolvedValue({
cookies: [],
origins: [],
}),
write: vi.fn().mockResolvedValue(undefined),
delete: vi.fn().mockResolvedValue(undefined),
validateCookies: vi.fn().mockReturnValue(AuthenticationState.UNKNOWN),
getSessionExpiry: vi.fn(),
getValidCookiesForUrl: vi.fn().mockReturnValue([]),
} as unknown as SessionCookieStore;
mockAuthFlow = {
getLoginUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/login'),
getPostLoginLandingUrl: vi.fn().mockReturnValue('https://members-ng.iracing.com/web/racing/hosted/browse-sessions'),
isLoginUrl: vi.fn().mockReturnValue(false),
isAuthenticatedUrl: vi.fn().mockReturnValue(true),
isLoginSuccessUrl: vi.fn().mockReturnValue(true),
detectAuthenticatedUi: vi.fn().mockResolvedValue(true),
detectLoginUi: vi.fn().mockResolvedValue(false),
navigateToAuthenticatedArea: vi.fn().mockResolvedValue(undefined),
waitForPostLoginRedirect: vi.fn().mockResolvedValue(true),
} as unknown as IPlaywrightAuthFlow;
});
afterEach(() => {
process.env = { ...originalEnv };
vi.restoreAllMocks();
});
function createService() {
return new PlaywrightAuthSessionService(
mockBrowserSession,
mockCookieStore,
mockAuthFlow,
mockLogger,
{
navigationTimeoutMs: 1000,
loginWaitTimeoutMs: 1000,
},
);
}
it('always forces headed browser for login regardless of browser mode configuration', async () => {
const service = createService();
const result = await service.initiateLogin();
expect(result.isOk()).toBe(true);
expect(mockBrowserSession.connect).toHaveBeenCalledWith(true);
});
it('navigates the headed page to the non-blank login URL', async () => {
const service = createService();
const result = await service.initiateLogin();
expect(result.isOk()).toBe(true);
expect(mockAuthFlow.getLoginUrl).toHaveBeenCalledTimes(1);
expect(mockPage.goto).toHaveBeenCalledWith(
'https://members-ng.iracing.com/login',
expect.objectContaining({
waitUntil: 'domcontentloaded',
}),
);
const calledUrl = (mockPage.goto as unknown as ReturnType<typeof vi.fn>).mock.calls[0]![0] as string;
expect(calledUrl).not.toEqual('about:blank');
});
it('propagates connection failure from browserSession.connect', async () => {
(mockBrowserSession.connect as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
success: false,
error: 'boom',
});
const service = createService();
const result = await service.initiateLogin();
expect(result.isErr()).toBe(true);
const err = result.unwrapErr();
expect(err).toBeInstanceOf(Error);
expect(err.message).toContain('boom');
});
it('logs explicit headed login message for human companion flow', async () => {
const service = createService();
const result = await service.initiateLogin();
expect(result.isOk()).toBe(true);
expect(mockLogger.info).toHaveBeenCalledWith(
'Opening login in headed Playwright browser (forceHeaded=true)',
expect.objectContaining({ forceHeaded: true }),
);
});
});

View File

@@ -0,0 +1,459 @@
import * as fs from 'fs';
import type { BrowserContext, Page } from 'playwright';
import type { AuthenticationServicePort } from '../../../../application/ports/AuthenticationServicePort';
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../../../domain/value-objects/BrowserAuthenticationState';
import { Result } from '@gridpilot/shared/result/Result';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
import { SessionCookieStore } from './SessionCookieStore';
import type { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
import { AuthenticationGuard } from './AuthenticationGuard';
interface PlaywrightAuthSessionConfig {
navigationTimeoutMs?: number;
loginWaitTimeoutMs?: number;
}
/**
* Game-agnostic Playwright-based authentication/session service.
*
* All game/site-specific behavior (URLs, selectors, redirects) is delegated to
* the injected IPlaywrightAuthFlow implementation. This class is responsible
* only for:
* - Browser/session orchestration via PlaywrightBrowserSession
* - Cookie persistence via SessionCookieStore
* - Exposing the IAuthenticationService port for application layer
*/
export class PlaywrightAuthSessionService implements AuthenticationServicePort {
private readonly browserSession: PlaywrightBrowserSession;
private readonly cookieStore: SessionCookieStore;
private readonly authFlow: IPlaywrightAuthFlow;
private readonly logger: LoggerPort | undefined;
private readonly navigationTimeoutMs: number;
private readonly loginWaitTimeoutMs: number;
private authState: AuthenticationState = AuthenticationState.UNKNOWN;
constructor(
browserSession: PlaywrightBrowserSession,
cookieStore: SessionCookieStore,
authFlow: IPlaywrightAuthFlow,
logger?: LoggerPort,
config?: PlaywrightAuthSessionConfig,
) {
this.browserSession = browserSession;
this.cookieStore = cookieStore;
this.authFlow = authFlow;
this.logger = logger;
this.navigationTimeoutMs = config?.navigationTimeoutMs ?? 30000;
this.loginWaitTimeoutMs = config?.loginWaitTimeoutMs ?? 300000;
}
// ===== Logging =====
private log(
level: 'debug' | 'info' | 'warn' | 'error',
message: string,
context?: Record<string, unknown>,
): void {
if (!this.logger) {
return;
}
const logger = this.logger as Record<
'debug' | 'info' | 'warn' | 'error',
(msg: string, ctx?: Record<string, unknown>) => void
>;
logger[level](message, context);
}
// ===== Helpers =====
private getContext(): BrowserContext | null {
return this.browserSession.getPersistentContext() ?? this.browserSession.getContext();
}
private getPageOrError(): Result<Page> {
const page = this.browserSession.getPage();
if (!page) {
return Result.err(new Error('Browser not connected'));
}
return Result.ok(page);
}
private async injectCookiesBeforeNavigation(targetUrl: string): Promise<Result<void>> {
const context = this.getContext();
if (!context) {
return Result.err(new Error('No browser context available'));
}
try {
const state = await this.cookieStore.read();
if (!state || state.cookies.length === 0) {
return Result.err(new Error('No cookies found in session store'));
}
const validCookies = this.cookieStore.getValidCookiesForUrl(targetUrl);
if (validCookies.length === 0) {
this.log('warn', 'No valid cookies found for target URL', {
targetUrl,
totalCookies: state.cookies.length,
});
return Result.err(new Error('No valid cookies found for target URL'));
}
await context.addCookies(validCookies);
this.log('info', 'Cookies injected successfully', {
count: validCookies.length,
targetUrl,
cookieNames: validCookies.map((c) => c.name),
});
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Cookie injection failed: ${message}`));
}
}
private async saveSessionState(): Promise<void> {
const context = this.getContext();
if (!context) {
this.log('warn', 'No browser context available to save session state');
return;
}
try {
const storageState = await context.storageState();
await this.cookieStore.write(storageState);
this.log('info', 'Session state saved to cookie store');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to save session state', { error: message });
throw error;
}
}
// ===== IAuthenticationService implementation =====
async checkSession(): Promise<Result<AuthenticationState>> {
try {
this.log('info', 'Checking session from cookie store');
const state = await this.cookieStore.read();
if (!state) {
this.authState = AuthenticationState.UNKNOWN;
this.log('info', 'No session state file found');
return Result.ok(this.authState);
}
this.authState = this.cookieStore.validateCookies(state.cookies);
this.log('info', 'Session check complete', { state: this.authState });
return Result.ok(this.authState);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Session check failed', { error: message });
return Result.err(new Error(`Session check failed: ${message}`));
}
}
getLoginUrl(): string {
return this.authFlow.getLoginUrl();
}
async initiateLogin(): Promise<Result<void>> {
try {
const forceHeaded = true;
this.log('info', 'Opening login in headed Playwright browser (forceHeaded=true)', {
forceHeaded,
});
const connectResult = await this.browserSession.connect(forceHeaded);
if (!connectResult.success) {
return Result.err(new Error(connectResult.error || 'Failed to connect browser'));
}
const pageResult = this.getPageOrError();
if (pageResult.isErr()) {
return Result.err(pageResult.unwrapErr());
}
const page = pageResult.unwrap();
const loginUrl = this.authFlow.getLoginUrl();
await page.goto(loginUrl, {
waitUntil: 'domcontentloaded',
timeout: this.navigationTimeoutMs,
});
this.log('info', forceHeaded
? 'Browser opened to login page in headed mode, waiting for login...'
: 'Browser opened to login page, waiting for login...');
this.authState = AuthenticationState.UNKNOWN;
const loginSuccess = await this.authFlow.waitForPostLoginRedirect(
page,
this.loginWaitTimeoutMs,
);
if (loginSuccess) {
this.log('info', 'Login detected, saving session state');
await this.saveSessionState();
const state = await this.cookieStore.read();
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
this.authState = AuthenticationState.AUTHENTICATED;
this.log('info', 'Session saved and validated successfully');
} else {
this.authState = AuthenticationState.UNKNOWN;
this.log('warn', 'Session saved but validation unclear');
}
this.log('info', 'Closing browser after successful login');
await this.browserSession.disconnect();
return Result.ok(undefined);
}
this.log('warn', 'Login was not completed');
await this.browserSession.disconnect();
return Result.err(new Error('Login timeout - please try again'));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed during login process', { error: message });
try {
await this.browserSession.disconnect();
} catch {
}
return Result.err(error instanceof Error ? error : new Error(message));
}
}
async confirmLoginComplete(): Promise<Result<void>> {
try {
this.log('info', 'User confirmed login complete');
await this.saveSessionState();
const state = await this.cookieStore.read();
if (state && this.cookieStore.validateCookies(state.cookies) === AuthenticationState.AUTHENTICATED) {
this.authState = AuthenticationState.AUTHENTICATED;
this.log('info', 'Login confirmed and session saved successfully');
} else {
this.authState = AuthenticationState.UNKNOWN;
this.log('warn', 'Login confirmation received but session state unclear');
}
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to confirm login', { error: message });
return Result.err(error instanceof Error ? error : new Error(message));
}
}
async clearSession(): Promise<Result<void>> {
try {
this.log('info', 'Clearing session');
await this.cookieStore.delete();
this.log('debug', 'Cookie store deleted');
const userDataDir = this.browserSession.getUserDataDir();
if (userDataDir && fs.existsSync(userDataDir)) {
this.log('debug', 'Removing user data directory', { path: userDataDir });
fs.rmSync(userDataDir, { recursive: true, force: true });
}
this.authState = AuthenticationState.LOGGED_OUT;
this.log('info', 'Session cleared successfully');
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to clear session', { error: message });
return Result.err(new Error(`Failed to clear session: ${message}`));
}
}
getState(): AuthenticationState {
return this.authState;
}
async validateServerSide(): Promise<Result<boolean>> {
try {
this.log('info', 'Performing server-side session validation');
const context = this.getContext();
if (!context) {
return Result.err(new Error('No browser context available'));
}
const page = await context.newPage();
try {
const response = await page.goto(this.authFlow.getPostLoginLandingUrl(), {
waitUntil: 'domcontentloaded',
timeout: this.navigationTimeoutMs,
});
if (!response) {
return Result.ok(false);
}
const finalUrl = page.url();
const isOnLoginPage = this.authFlow.isLoginUrl(finalUrl);
const isValid = !isOnLoginPage;
this.log('info', 'Server-side validation complete', { isValid, finalUrl });
return Result.ok(isValid);
} finally {
await page.close();
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('warn', 'Server-side validation failed', { error: message });
return Result.err(new Error(`Server validation failed: ${message}`));
}
}
async refreshSession(): Promise<Result<void>> {
try {
this.log('info', 'Refreshing session from cookie store');
const state = await this.cookieStore.read();
if (!state) {
this.authState = AuthenticationState.UNKNOWN;
return Result.ok(undefined);
}
this.authState = this.cookieStore.validateCookies(state.cookies);
this.log('info', 'Session refreshed', { state: this.authState });
return Result.ok(undefined);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Session refresh failed', { error: message });
return Result.err(new Error(`Session refresh failed: ${message}`));
}
}
async getSessionExpiry(): Promise<Result<Date | null>> {
try {
const expiry = await this.cookieStore.getSessionExpiry();
return Result.ok(expiry);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to get session expiry', { error: message });
return Result.err(new Error(`Failed to get session expiry: ${message}`));
}
}
async verifyPageAuthentication(): Promise<Result<BrowserAuthenticationState>> {
const pageResult = this.getPageOrError();
if (pageResult.isErr()) {
return Result.err(pageResult.unwrapErr());
}
const page = pageResult.unwrap();
try {
const url = page.url();
const isOnAuthenticatedPath = this.authFlow.isAuthenticatedUrl(url);
const isOnLoginPath = this.authFlow.isLoginUrl(url);
const guard = new AuthenticationGuard(page, this.logger);
const hasLoginUI = await guard.checkForLoginUI();
const hasAuthUI = await this.authFlow.detectAuthenticatedUi(page);
const cookieResult = await this.checkSession();
const cookiesValid =
cookieResult.isOk() &&
cookieResult.unwrap() === AuthenticationState.AUTHENTICATED;
const pageAuthenticated =
!hasLoginUI &&
!isOnLoginPath &&
((isOnAuthenticatedPath && cookiesValid) || hasAuthUI);
this.log('debug', 'Page authentication check', {
url,
isOnAuthenticatedPath,
isOnLoginPath,
hasLoginUI,
hasAuthUI,
cookiesValid,
pageAuthenticated,
});
return Result.ok(new BrowserAuthenticationState(cookiesValid, pageAuthenticated));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err(new Error(`Page verification failed: ${message}`));
}
}
// ===== Public helper for navigation with cookie injection =====
/**
* Navigate to an authenticated area using stored cookies when possible.
* Not part of the IAuthenticationService port, but useful for internal
* orchestration (e.g. within automation flows).
*/
async navigateWithExistingSession(forceHeaded: boolean = false): Promise<Result<void>> {
try {
const sessionResult = await this.checkSession();
if (
sessionResult.isOk() &&
sessionResult.unwrap() === AuthenticationState.AUTHENTICATED
) {
this.log('info', 'Session cookies found, launching in configured browser mode');
await this.browserSession.ensureBrowserContext(forceHeaded);
const pageResult = this.getPageOrError();
if (pageResult.isErr()) {
return Result.err(pageResult.unwrapErr());
}
const page = pageResult.unwrap();
const targetUrl = this.authFlow.getPostLoginLandingUrl();
const injectResult = await this.injectCookiesBeforeNavigation(targetUrl);
if (injectResult.isErr()) {
this.log('warn', 'Cookie injection failed, falling back to manual login', {
error: injectResult.error?.message ?? 'unknown error',
});
return Result.err(injectResult.unwrapErr());
}
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: this.navigationTimeoutMs,
});
const verifyResult = await this.verifyPageAuthentication();
if (verifyResult.isOk()) {
const browserState = verifyResult.unwrap();
if (browserState.isFullyAuthenticated()) {
this.log('info', 'Authentication verified successfully after cookie navigation');
return Result.ok(undefined);
}
this.log('warn', 'Page shows unauthenticated state despite cookies');
}
return Result.err(new Error('Page not authenticated after cookie navigation'));
}
return Result.err(new Error('No valid session cookies found'));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.log('error', 'Failed to navigate with existing session', { error: message });
return Result.err(new Error(`Failed to navigate with existing session: ${message}`));
}
}
}

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from 'vitest';
import type { Page, Locator } from 'playwright';
import { AuthenticationState } from 'apps/companion/main/automation/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from 'apps/companion/main/automation/domain/value-objects/BrowserAuthenticationState';
import type { LoggerPort as Logger } from 'apps/companion/main/automation/application/ports/LoggerPort';
import type { Result } from '@core/shared/result/Result';
import { PlaywrightBrowserSession } from '../core/PlaywrightBrowserSession';
import { IPlaywrightAuthFlow } from './PlaywrightAuthFlow';
import { PlaywrightAuthSessionService } from './PlaywrightAuthSessionService';
import { SessionCookieStore } from './SessionCookieStore';
describe('PlaywrightAuthSessionService.verifyPageAuthentication', () => {
function createService(deps: {
pageUrl: string;
hasLoginUi: boolean;
hasAuthUi: boolean;
cookieState: AuthenticationState;
}) {
const mockLogger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
flush: vi.fn().mockResolvedValue(undefined),
};
const mockLocator: Locator = {
first: vi.fn().mockReturnThis(),
isVisible: vi.fn().mockImplementation(async () => deps.hasLoginUi),
} as unknown as Locator;
const mockPage: Page = {
url: vi.fn().mockReturnValue(deps.pageUrl),
locator: vi.fn().mockReturnValue(mockLocator),
} as unknown as Page;
const mockBrowserSession: PlaywrightBrowserSession = {
getPersistentContext: vi.fn().mockReturnValue(null),
getContext: vi.fn().mockReturnValue(null),
getPage: vi.fn().mockReturnValue(mockPage),
} as unknown as PlaywrightBrowserSession;
const mockCookieStore: SessionCookieStore = {
read: vi.fn().mockResolvedValue({
cookies: [{ name: 'XSESSIONID', value: 'abc', domain: 'members-ng.iracing.com', path: '/', expires: -1 }],
origins: [],
}),
validateCookies: vi.fn().mockReturnValue(deps.cookieState),
getSessionExpiry: vi.fn(),
write: vi.fn(),
delete: vi.fn(),
} as unknown as SessionCookieStore;
const mockAuthFlow: IPlaywrightAuthFlow = {
getLoginUrl: () => 'https://members-ng.iracing.com/login',
getPostLoginLandingUrl: () => 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
isLoginUrl: (url: string) => url.includes('/login'),
isAuthenticatedUrl: (url: string) => url.includes('/web/racing/hosted'),
isLoginSuccessUrl: (url: string) => url.includes('/web/racing/hosted'),
detectAuthenticatedUi: vi.fn().mockResolvedValue(deps.hasAuthUi),
detectLoginUi: vi.fn(),
navigateToAuthenticatedArea: vi.fn(),
waitForPostLoginRedirect: vi.fn(),
} as unknown as IPlaywrightAuthFlow;
const service = new PlaywrightAuthSessionService(
mockBrowserSession,
mockCookieStore,
mockAuthFlow,
mockLogger,
);
return { service, mockCookieStore, mockAuthFlow, mockPage };
}
it('treats cookies-valid + login UI as EXPIRED (page wins over cookies)', async () => {
const { service } = createService({
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
hasLoginUi: true,
hasAuthUi: false,
cookieState: AuthenticationState.AUTHENTICATED,
});
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
expect(result.isOk()).toBe(true);
const browserState = result.unwrap();
expect(browserState.getCookieValidity()).toBe(true);
expect(browserState.getPageAuthenticationStatus()).toBe(false);
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.EXPIRED);
expect(browserState.requiresReauthentication()).toBe(true);
});
it('treats cookies-valid + authenticated UI without login UI as AUTHENTICATED', async () => {
const { service } = createService({
pageUrl: 'https://members-ng.iracing.com/web/racing/hosted/browse-sessions',
hasLoginUi: false,
hasAuthUi: true,
cookieState: AuthenticationState.AUTHENTICATED,
});
const result: Result<BrowserAuthenticationState> = await service.verifyPageAuthentication();
expect(result.isOk()).toBe(true);
const browserState = result.unwrap();
expect(browserState.getCookieValidity()).toBe(true);
expect(browserState.getPageAuthenticationStatus()).toBe(true);
expect(browserState.getAuthenticationState()).toBe(AuthenticationState.AUTHENTICATED);
expect(browserState.requiresReauthentication()).toBe(false);
});
});

View File

@@ -0,0 +1,285 @@
import { describe, test, expect, beforeEach } from 'vitest';
import type { Cookie } from 'playwright';
import { SessionCookieStore } from './SessionCookieStore';
const logger = console as unknown;
describe('SessionCookieStore - Cookie Validation', () => {
let cookieStore: SessionCookieStore;
beforeEach(() => {
cookieStore = new SessionCookieStore('test-user-data', logger);
});
describe('validateCookieConfiguration()', () => {
const targetUrl = 'https://members-ng.iracing.com/jjwtauth/success';
test('should succeed when all cookies are valid for target URL', async () => {
const cookies: Cookie[] = [
{
name: 'irsso_members',
value: 'valid_sso_token',
domain: '.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
{
name: 'authtoken_members',
value: 'valid_auth_token',
domain: 'members-ng.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isOk()).toBe(true);
});
test('should fail when cookie domain mismatches target', async () => {
const cookies: Cookie[] = [
{
name: 'irsso_members',
value: 'valid_token',
domain: 'example.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toMatch(/domain mismatch/i);
});
test('should fail when cookie path is invalid for target', async () => {
const cookies: Cookie[] = [
{
name: 'irsso_members',
value: 'valid_token',
domain: '.iracing.com',
path: '/invalid/path',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toMatch(/path.*not valid/i);
});
test('should fail when required irsso_members cookie is missing', async () => {
const cookies: Cookie[] = [
{
name: 'authtoken_members',
value: 'valid_auth_token',
domain: 'members-ng.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toMatch(/required.*irsso_members/i);
});
test('should fail when required authtoken_members cookie is missing', async () => {
const cookies: Cookie[] = [
{
name: 'irsso_members',
value: 'valid_sso_token',
domain: '.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toMatch(/required.*authtoken_members/i);
});
test('should fail when no cookies are stored', () => {
const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isErr()).toBe(true);
expect(result.unwrapErr().message).toMatch(/no cookies/i);
});
test('should validate cookies for members-ng.iracing.com domain', async () => {
const cookies: Cookie[] = [
{
name: 'irsso_members',
value: 'valid_token',
domain: 'members-ng.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
{
name: 'authtoken_members',
value: 'valid_auth_token',
domain: 'members-ng.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const result = cookieStore.validateCookieConfiguration(targetUrl);
expect(result.isOk()).toBe(true);
});
});
describe('getValidCookiesForUrl()', () => {
const targetUrl = 'https://members-ng.iracing.com/jjwtauth/success';
test('should return only cookies valid for target URL', async () => {
const cookies: Cookie[] = [
{
name: 'valid_cookie',
value: 'valid_value',
domain: '.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
{
name: 'invalid_cookie',
value: 'invalid_value',
domain: 'example.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
expect(validCookies).toHaveLength(1);
expect(validCookies[0]!.name).toBe('valid_cookie');
});
test('should filter out cookies with mismatched domains', async () => {
const cookies: Cookie[] = [
{
name: 'cookie1',
value: 'value1',
domain: '.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
{
name: 'cookie2',
value: 'value2',
domain: '.example.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
expect(validCookies).toHaveLength(1);
expect(validCookies[0]!.name).toBe('cookie1');
});
test('should filter out cookies with invalid paths', async () => {
const cookies: Cookie[] = [
{
name: 'valid_path_cookie',
value: 'value',
domain: '.iracing.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
{
name: 'invalid_path_cookie',
value: 'value',
domain: '.iracing.com',
path: '/wrong/path',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
expect(validCookies).toHaveLength(1);
expect(validCookies[0]!.name).toBe('valid_path_cookie');
});
test('should return empty array when no cookies are valid', async () => {
const cookies: Cookie[] = [
{
name: 'invalid_cookie',
value: 'value',
domain: 'example.com',
path: '/',
expires: Date.now() / 1000 + 3600,
httpOnly: true,
secure: true,
sameSite: 'Lax',
},
];
await cookieStore.write({ cookies, origins: [] });
const validCookies = cookieStore.getValidCookiesForUrl(targetUrl);
expect(validCookies).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,372 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import { AuthenticationState } from '../../../../domain/value-objects/AuthenticationState';
import { CookieConfiguration } from '../../../../domain/value-objects/CookieConfiguration';
import { Result } from '@gridpilot/shared/result/Result';
import type { LoggerPort } from '../../../../application/ports/LoggerPort';
interface Cookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
}
interface StorageState {
cookies: Cookie[];
origins: unknown[];
}
/**
* Known iRacing session cookie names to look for.
* These are the primary authentication indicators.
*/
const IRACING_SESSION_COOKIES = [
'irsso_members',
'authtoken_members',
'irsso',
'authtoken',
];
/**
* iRacing domain patterns to match cookies against.
*/
const IRACING_DOMAINS = [
'iracing.com',
'.iracing.com',
'members.iracing.com',
'members-ng.iracing.com',
];
const EXPIRY_BUFFER_SECONDS = 300;
export class SessionCookieStore {
private readonly storagePath: string;
private readonly logger: LoggerPort | undefined;
constructor(userDataDir: string, logger: LoggerPort | undefined) {
this.storagePath = path.join(userDataDir, 'session-state.json');
this.logger = logger;
}
private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, context?: Record<string, unknown>): void {
if (this.logger) {
if (level === 'error') {
this.logger.error(message, undefined, context);
} else {
this.logger[level](message, context);
}
}
}
getPath(): string {
return this.storagePath;
}
async read(): Promise<StorageState | null> {
try {
const content = await fs.readFile(this.storagePath, 'utf-8');
const state = JSON.parse(content) as StorageState;
// Ensure all cookies have path field (default to "/" for backward compatibility)
state.cookies = state.cookies.map(cookie => ({
...cookie,
path: cookie.path || '/'
}));
this.cachedState = state;
return state;
} catch {
return null;
}
}
async write(state: StorageState): Promise<void> {
this.cachedState = state;
await fs.writeFile(this.storagePath, JSON.stringify(state, null, 2), 'utf-8');
}
async delete(): Promise<void> {
try {
await fs.unlink(this.storagePath);
} catch {
// File may not exist, ignore
}
}
/**
* Get session expiry date from iRacing cookies.
* Returns the earliest expiry date from valid session cookies.
*/
async getSessionExpiry(): Promise<Date | null> {
try {
const state = await this.read();
if (!state || state.cookies.length === 0) {
return null;
}
// Filter to iRacing authentication cookies
const authCookies = state.cookies.filter(c =>
IRACING_DOMAINS.some(domain =>
c.domain === domain || c.domain.endsWith(domain)
) &&
(IRACING_SESSION_COOKIES.some(name =>
c.name.toLowerCase().includes(name.toLowerCase())
) ||
c.name.toLowerCase().includes('auth') ||
c.name.toLowerCase().includes('sso') ||
c.name.toLowerCase().includes('token'))
);
if (authCookies.length === 0) {
return null;
}
// Find the earliest expiry date (most restrictive)
// Session cookies (expires = -1 or 0) are treated as never expiring
const expiryDates = authCookies
.filter(c => c.expires > 0)
.map(c => {
// Handle both formats: seconds (standard) and milliseconds (test fixtures)
// If expires > year 2100 in seconds (33134745600), it's likely milliseconds
const isMilliseconds = c.expires > 33134745600;
return new Date(isMilliseconds ? c.expires : c.expires * 1000);
});
if (expiryDates.length === 0) {
// All session cookies, no expiry
return null;
}
// Return earliest expiry
const earliestExpiry = new Date(Math.min(...expiryDates.map(d => d.getTime())));
this.log('debug', 'Session expiry determined', {
earliestExpiry: earliestExpiry.toISOString(),
cookiesChecked: authCookies.length
});
return earliestExpiry;
} catch (error) {
this.log('error', 'Failed to get session expiry', { error: String(error) });
return null;
}
}
/**
* Validate cookies and determine authentication state.
*
* Looks for iRacing session cookies by checking:
* 1. Domain matches iRacing patterns
* 2. Cookie name matches known session cookie names OR
* 3. Any cookie from members.iracing.com domain (fallback)
*/
validateCookies(cookies: Cookie[]): AuthenticationState {
// Log all cookies for debugging
this.log('debug', 'Validating cookies', {
totalCookies: cookies.length,
cookieNames: cookies.map(c => ({ name: c.name, domain: c.domain }))
});
// Filter cookies from iRacing domains
const iracingDomainCookies = cookies.filter(c =>
IRACING_DOMAINS.some(domain =>
c.domain === domain || c.domain.endsWith(domain)
)
);
this.log('debug', 'iRacing domain cookies found', {
count: iracingDomainCookies.length,
cookies: iracingDomainCookies.map(c => ({
name: c.name,
domain: c.domain,
expires: c.expires,
expiresDate: new Date(c.expires * 1000).toISOString()
}))
});
// Look for known session cookies first
const knownSessionCookies = iracingDomainCookies.filter(c =>
IRACING_SESSION_COOKIES.some(name =>
c.name.toLowerCase() === name.toLowerCase() ||
c.name.toLowerCase().includes(name.toLowerCase())
)
);
// If no known session cookies, check for any auth-like cookies from members domain
const authCookies = knownSessionCookies.length > 0
? knownSessionCookies
: iracingDomainCookies.filter(c =>
c.domain.includes('members') &&
(c.name.toLowerCase().includes('auth') ||
c.name.toLowerCase().includes('sso') ||
c.name.toLowerCase().includes('session') ||
c.name.toLowerCase().includes('token'))
);
this.log('debug', 'Authentication cookies identified', {
knownSessionCookiesCount: knownSessionCookies.length,
authCookiesCount: authCookies.length,
cookies: authCookies.map(c => ({ name: c.name, domain: c.domain }))
});
if (authCookies.length === 0) {
// Last resort: if we have ANY cookies from members.iracing.com, consider it potentially valid
const membersCookies = iracingDomainCookies.filter(c =>
c.domain.includes('members.iracing.com') || c.domain === '.iracing.com'
);
if (membersCookies.length > 0) {
this.log('info', 'No known auth cookies found, but members domain cookies exist', {
count: membersCookies.length,
cookies: membersCookies.map(c => c.name)
});
// Check expiry on any of these cookies
const now = Math.floor(Date.now() / 1000);
const hasValidCookie = membersCookies.some(c =>
c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
);
return hasValidCookie
? AuthenticationState.AUTHENTICATED
: AuthenticationState.EXPIRED;
}
this.log('info', 'No iRacing authentication cookies found');
return AuthenticationState.UNKNOWN;
}
// Check if any auth cookie is still valid (not expired)
const now = Math.floor(Date.now() / 1000);
const validCookies = authCookies.filter(c => {
// Handle session cookies (expires = -1 or 0) and persistent cookies
const isSession = c.expires === -1 || c.expires === 0;
const isNotExpired = c.expires > (now + EXPIRY_BUFFER_SECONDS);
return isSession || isNotExpired;
});
this.log('debug', 'Cookie expiry check', {
now,
validCookiesCount: validCookies.length,
cookies: authCookies.map(c => ({
name: c.name,
expires: c.expires,
isValid: c.expires === -1 || c.expires === 0 || c.expires > (now + EXPIRY_BUFFER_SECONDS)
}))
});
if (validCookies.length > 0) {
this.log('info', 'Valid iRacing session cookies found', { count: validCookies.length });
return AuthenticationState.AUTHENTICATED;
}
this.log('info', 'iRacing session cookies found but all expired');
return AuthenticationState.EXPIRED;
}
private cachedState: StorageState | null = null;
/**
* Validate stored cookies for a target URL.
* Note: This requires cookies to be written first via write().
* This is synchronous because tests expect it - uses cached state.
* Validates domain/path compatibility AND checks for required authentication cookies.
*/
validateCookieConfiguration(targetUrl: string): Result<Cookie[]> {
try {
if (!this.cachedState || this.cachedState.cookies.length === 0) {
return Result.err<Cookie[]>(new Error('No cookies found in session store'));
}
return this.validateCookiesForUrl(this.cachedState.cookies, targetUrl, true);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${message}`));
}
}
/**
* Validate a list of cookies for a target URL.
* Returns only cookies that are valid for the target URL.
* @param requireAuthCookies - If true, checks for required authentication cookies
*/
validateCookiesForUrl(
cookies: Cookie[],
targetUrl: string,
requireAuthCookies = false
): Result<Cookie[]> {
try {
const validatedCookies: Cookie[] = [];
let firstValidationError: Error | null = null;
for (const cookie of cookies) {
try {
new CookieConfiguration(cookie, targetUrl);
validatedCookies.push(cookie);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
if (!firstValidationError) {
firstValidationError = err;
}
this.logger?.warn('Cookie validation failed', {
name: cookie.name,
error: err.message,
});
}
}
if (validatedCookies.length === 0) {
return Result.err<Cookie[]>(
firstValidationError ?? new Error('No valid cookies found for target URL')
);
}
if (requireAuthCookies) {
const cookieNames = validatedCookies.map((c) => c.name.toLowerCase());
const hasIrssoMembers = cookieNames.some((name) =>
name.includes('irsso_members') || name.includes('irsso')
);
const hasAuthtokenMembers = cookieNames.some((name) =>
name.includes('authtoken_members') || name.includes('authtoken')
);
if (!hasIrssoMembers) {
return Result.err<Cookie[]>(new Error('Required cookie missing: irsso_members'));
}
if (!hasAuthtokenMembers) {
return Result.err<Cookie[]>(new Error('Required cookie missing: authtoken_members'));
}
}
return Result.ok<Cookie[]>(validatedCookies);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
return Result.err<Cookie[]>(new Error(`Cookie validation failed: ${err.message}`));
}
}
/**
* Get cookies that are valid for a target URL.
* Returns array of cookies (empty if none valid).
* Uses cached state from last write().
*/
getValidCookiesForUrl(targetUrl: string): Cookie[] {
try {
if (!this.cachedState || this.cachedState.cookies.length === 0) {
return [];
}
const result = this.validateCookiesForUrl(this.cachedState.cookies, targetUrl);
return result.isOk() ? result.unwrap() : [];
} catch {
return [];
}
}
}