wip
This commit is contained in:
429
tests/integration/infrastructure/SessionValidation.test.ts
Normal file
429
tests/integration/infrastructure/SessionValidation.test.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CheckAuthenticationUseCase } from '../../../packages/application/use-cases/CheckAuthenticationUseCase';
|
||||
import { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
|
||||
import { Result } from '../../../packages/shared/result/Result';
|
||||
import { PlaywrightAutomationAdapter } from '../../../packages/infrastructure/adapters/automation/PlaywrightAutomationAdapter';
|
||||
|
||||
const TEST_USER_DATA_DIR = path.join(__dirname, '../../../test-browser-data');
|
||||
const SESSION_FILE_PATH = path.join(TEST_USER_DATA_DIR, 'session-state.json');
|
||||
|
||||
interface SessionData {
|
||||
cookies: Array<{ name: string; value: string; domain: string; path: string; expires: number }>;
|
||||
expiry: string | null;
|
||||
}
|
||||
|
||||
describe('Session Validation After Startup', () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure test directory exists
|
||||
try {
|
||||
await fs.mkdir(TEST_USER_DATA_DIR, { recursive: true });
|
||||
} catch {
|
||||
// Directory already exists
|
||||
}
|
||||
|
||||
// Clean up session file if it exists
|
||||
try {
|
||||
await fs.unlink(SESSION_FILE_PATH);
|
||||
} catch {
|
||||
// File doesn't exist, that's fine
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.unlink(SESSION_FILE_PATH);
|
||||
} catch {
|
||||
// Cleanup best effort
|
||||
}
|
||||
});
|
||||
|
||||
describe('Initial check on app startup', () => {
|
||||
it('should detect valid session on startup', async () => {
|
||||
const validSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'valid-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
it('should detect expired session on startup', async () => {
|
||||
const expiredSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'expired-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() - 3600000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() - 3600000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(expiredSessionData, null, 2));
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('should handle missing session file on startup', async () => {
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(result.value).toBe(AuthenticationState.UNKNOWN);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session expiry during runtime', () => {
|
||||
it('should transition from AUTHENTICATED to EXPIRED after time passes', async () => {
|
||||
// Start with a session that expires in 10 minutes (beyond 5-minute buffer)
|
||||
const initialExpiry = Date.now() + (10 * 60 * 1000);
|
||||
const shortLivedSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'short-lived-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: initialExpiry,
|
||||
},
|
||||
],
|
||||
expiry: new Date(initialExpiry).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(shortLivedSessionData, null, 2));
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
|
||||
const firstCheck = await useCase.execute();
|
||||
expect(firstCheck.value).toBe(AuthenticationState.AUTHENTICATED);
|
||||
|
||||
// Now update the session file to have an expiry in the past
|
||||
const expiredSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'short-lived-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() - 1000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() - 1000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(expiredSessionData, null, 2));
|
||||
|
||||
const secondCheck = await useCase.execute();
|
||||
expect(secondCheck.value).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('should maintain AUTHENTICATED state when session is still valid', async () => {
|
||||
const longLivedSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'long-lived-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(longLivedSessionData, null, 2));
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
|
||||
const firstCheck = await useCase.execute();
|
||||
expect(firstCheck.value).toBe(AuthenticationState.AUTHENTICATED);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const secondCheck = await useCase.execute();
|
||||
expect(secondCheck.value).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Browser connection before auth check', () => {
|
||||
it('should establish browser connection then validate auth', async () => {
|
||||
const validSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'valid-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
|
||||
|
||||
const browserAdapter = createMockBrowserAdapter();
|
||||
await browserAdapter.initialize();
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(browserAdapter.isInitialized()).toBe(true);
|
||||
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
it('should handle auth check when browser connection fails', async () => {
|
||||
const validSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'valid-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() + 3600000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() + 3600000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
|
||||
|
||||
const browserAdapter = createMockBrowserAdapter();
|
||||
browserAdapter.setConnectionFailure(true);
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Authentication detection logic', () => {
|
||||
it('should consider page authenticated when both hasAuthUI=true AND hasLoginUI=true', async () => {
|
||||
// This tests the core bug: when authenticated UI is detected alongside login UI,
|
||||
// authentication should be considered VALID because authenticated UI takes precedence
|
||||
|
||||
// Mock scenario: Dashboard visible (authenticated) but profile menu contains "Log in" text
|
||||
const mockAdapter = {
|
||||
page: {
|
||||
locator: vi.fn(),
|
||||
},
|
||||
logger: undefined,
|
||||
};
|
||||
|
||||
// Setup: Both authenticated UI and login UI detected
|
||||
let callCount = 0;
|
||||
mockAdapter.page.locator.mockImplementation((selector: string) => {
|
||||
callCount++;
|
||||
|
||||
// First call: checkForLoginUI - 'text="You are not logged in"'
|
||||
if (callCount === 1) {
|
||||
return {
|
||||
first: () => ({
|
||||
isVisible: () => Promise.resolve(false),
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Second call: checkForLoginUI - 'button:has-text("Log in")'
|
||||
if (callCount === 2) {
|
||||
return {
|
||||
first: () => ({
|
||||
isVisible: () => Promise.resolve(true), // FALSE POSITIVE from profile menu
|
||||
}),
|
||||
};
|
||||
}
|
||||
// Third call: authenticated UI - 'button:has-text("Create a Race")'
|
||||
if (callCount === 3) {
|
||||
return {
|
||||
first: () => ({
|
||||
isVisible: () => Promise.resolve(true), // Authenticated UI detected
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
first: () => ({
|
||||
isVisible: () => Promise.resolve(false),
|
||||
}),
|
||||
};
|
||||
}) as any;
|
||||
|
||||
// Simulate the logic from PlaywrightAutomationAdapter.verifyPageAuthentication
|
||||
const hasLoginUI = true; // False positive from profile menu
|
||||
const hasAuthUI = true; // Real authenticated UI detected
|
||||
|
||||
// CURRENT BUGGY LOGIC: const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
|
||||
const currentLogic = !hasLoginUI && (hasAuthUI || !hasLoginUI);
|
||||
|
||||
// EXPECTED CORRECT LOGIC: const pageAuthenticated = hasAuthUI || !hasLoginUI;
|
||||
const correctLogic = hasAuthUI || !hasLoginUI;
|
||||
|
||||
expect(currentLogic).toBe(false); // Current buggy behavior
|
||||
expect(correctLogic).toBe(true); // Expected correct behavior
|
||||
});
|
||||
|
||||
it('should consider page authenticated when hasAuthUI=true even if hasLoginUI=true', async () => {
|
||||
// When authenticated UI is present, it should override any login UI detection
|
||||
const hasLoginUI = true;
|
||||
const hasAuthUI = true;
|
||||
|
||||
// Buggy logic
|
||||
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
|
||||
|
||||
// This fails: even though authenticated UI is detected, the result is false
|
||||
// because hasLoginUI=true makes the first condition fail
|
||||
expect(pageAuthenticated).toBe(false); // BUG: Should be true
|
||||
});
|
||||
|
||||
it('should consider page authenticated when hasAuthUI=true and hasLoginUI=false', async () => {
|
||||
// When authenticated UI is present and no login UI, clearly authenticated
|
||||
const hasLoginUI = false;
|
||||
const hasAuthUI = true;
|
||||
|
||||
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
|
||||
|
||||
expect(pageAuthenticated).toBe(true); // This works correctly
|
||||
});
|
||||
|
||||
it('should consider page authenticated when hasAuthUI=false and hasLoginUI=false', async () => {
|
||||
// No login UI and no explicit auth UI - assume authenticated (no login required)
|
||||
const hasLoginUI = false;
|
||||
const hasAuthUI = false;
|
||||
|
||||
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
|
||||
|
||||
expect(pageAuthenticated).toBe(true); // This works correctly
|
||||
});
|
||||
|
||||
it('should consider page unauthenticated when hasAuthUI=false and hasLoginUI=true', async () => {
|
||||
// Clear login UI with no authenticated UI - definitely not authenticated
|
||||
const hasLoginUI = true;
|
||||
const hasAuthUI = false;
|
||||
|
||||
const pageAuthenticated = !hasLoginUI && (hasAuthUI || !hasLoginUI);
|
||||
|
||||
expect(pageAuthenticated).toBe(false); // This works correctly
|
||||
});
|
||||
});
|
||||
|
||||
describe('BDD Scenarios', () => {
|
||||
it('Scenario: App starts with valid session', async () => {
|
||||
const validSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'valid-session-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() + 7200000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() + 7200000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(validSessionData, null, 2));
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.value).toBe(AuthenticationState.AUTHENTICATED);
|
||||
});
|
||||
|
||||
it('Scenario: App starts with expired session', async () => {
|
||||
const expiredSessionData: SessionData = {
|
||||
cookies: [
|
||||
{
|
||||
name: 'irsso_membersv2',
|
||||
value: 'expired-session-token',
|
||||
domain: '.iracing.com',
|
||||
path: '/',
|
||||
expires: Date.now() - 7200000,
|
||||
},
|
||||
],
|
||||
expiry: new Date(Date.now() - 7200000).toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(SESSION_FILE_PATH, JSON.stringify(expiredSessionData, null, 2));
|
||||
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.value).toBe(AuthenticationState.EXPIRED);
|
||||
});
|
||||
|
||||
it('Scenario: App starts without session', async () => {
|
||||
const authService = createRealAuthenticationService();
|
||||
const useCase = new CheckAuthenticationUseCase(authService);
|
||||
const result = await useCase.execute();
|
||||
|
||||
expect(result.value).toBe(AuthenticationState.UNKNOWN);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createRealAuthenticationService() {
|
||||
// Create adapter with test-specific user data directory
|
||||
const adapter = new PlaywrightAutomationAdapter({
|
||||
headless: true,
|
||||
timeout: 5000,
|
||||
mode: 'real',
|
||||
userDataDir: TEST_USER_DATA_DIR,
|
||||
});
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function createMockBrowserAdapter() {
|
||||
// Simple mock that tracks initialization state
|
||||
let initialized = false;
|
||||
let shouldFailConnection = false;
|
||||
|
||||
return {
|
||||
initialize: async () => {
|
||||
if (shouldFailConnection) {
|
||||
throw new Error('Mock connection failure');
|
||||
}
|
||||
initialized = true;
|
||||
},
|
||||
isInitialized: () => initialized,
|
||||
setConnectionFailure: (fail: boolean) => {
|
||||
shouldFailConnection = fail;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user