This commit is contained in:
2025-12-01 19:28:49 +01:00
parent 98a09a3f2b
commit 086fdc1ea1
8 changed files with 406 additions and 86 deletions

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Page, BrowserContext } from 'playwright';
import { PlaywrightAuthSessionService } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
import type { PlaywrightBrowserSession } from '../../../packages/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
import type { SessionCookieStore } from '../../../packages/infrastructure/adapters/automation/auth/SessionCookieStore';
import type { IPlaywrightAuthFlow } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
import type { ILogger } from '../../../packages/application/ports/ILogger';
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
import { Result } from '../../../packages/shared/result/Result';
describe('PlaywrightAuthSessionService.initiateLogin browser mode behaviour', () => {
const originalEnv = { ...process.env };
let mockBrowserSession: PlaywrightBrowserSession;
let mockCookieStore: SessionCookieStore;
let mockAuthFlow: IPlaywrightAuthFlow;
let mockLogger: ILogger;
let mockPage: Page;
beforeEach(() => {
process.env = { ...originalEnv };
mockLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
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(2);
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,110 @@
import { describe, it, expect, vi } from 'vitest';
import type { Page, Locator } from 'playwright';
import { PlaywrightAuthSessionService } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthSessionService';
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
import { BrowserAuthenticationState } from '../../../packages/domain/value-objects/BrowserAuthenticationState';
import type { ILogger } from '../../../packages/application/ports/ILogger';
import type { Result } from '../../../packages/shared/result/Result';
import type { PlaywrightBrowserSession } from '../../../packages/infrastructure/adapters/automation/core/PlaywrightBrowserSession';
import type { SessionCookieStore } from '../../../packages/infrastructure/adapters/automation/auth/SessionCookieStore';
import type { IPlaywrightAuthFlow } from '../../../packages/infrastructure/adapters/automation/auth/PlaywrightAuthFlow';
describe('PlaywrightAuthSessionService.verifyPageAuthentication', () => {
function createService(deps: {
pageUrl: string;
hasLoginUi: boolean;
hasAuthUi: boolean;
cookieState: AuthenticationState;
}) {
const mockLogger: ILogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
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);
});
});