diff --git a/apps/electron-backend-e2e/src/stalker-route-auth.e2e.ts b/apps/electron-backend-e2e/src/stalker-route-auth.e2e.ts new file mode 100644 index 000000000..a7622c4ba --- /dev/null +++ b/apps/electron-backend-e2e/src/stalker-route-auth.e2e.ts @@ -0,0 +1,260 @@ +import type { Page } from '@playwright/test'; + +import { + closeElectronApp, + expect, + launchElectronApp, + restartElectronApp, + test, +} from './electron-test-fixtures'; +import { + StalkerReplayRun, + withStalkerReplayRun, +} from './support/stalker-replay'; + +test.describe('Electron Stalker route authentication', () => { + test('@stalker @electron migrates a legacy route through credentials, atomic persistence, and credential-free reopen', async ({ + dataDir, + }) => { + const replay = await withStalkerReplayRun( + 'e2e/legacy-first-open-reopen', + async (run) => { + const playlistId = 'e2e-legacy-route-auth'; + const playlistTitle = 'Legacy authenticated Stalker'; + const endpoint = `${requiredOrigin(run, 'portal')}/portal.php`; + const app = await launchElectronApp(dataDir); + + try { + await seedLegacyPlaylist(app.mainWindow, { + endpoint, + macAddress: requiredInput(run, 'mac'), + playlistId, + playlistTitle, + sourceUrl: run.entryUrl, + }); + await expect( + readPersistedStalkerPlaylist( + app.mainWindow, + playlistId + ) + ).resolves.toMatchObject({ + _id: playlistId, + title: playlistTitle, + }); + + const seededRestart = await restartElectronApp( + app, + dataDir + ); + app.electronApp = seededRestart.electronApp; + app.mainWindow = seededRestart.mainWindow; + await navigateToStalkerRoute( + app.mainWindow, + playlistId + ); + await submitCredentials(app.mainWindow, run); + + await expect + .poll( + () => + readPersistedStalkerPlaylist( + app.mainWindow, + playlistId + ), + { timeout: 15000 } + ) + .toMatchObject({ + isFullStalkerPortal: true, + password: requiredInput(run, 'password'), + portalUrl: endpoint, + stalkerRecipeClassifierVersion: 1, + stalkerRequestRecipe: 'full-session', + stalkerSourceUrl: run.entryUrl, + username: requiredInput(run, 'username'), + }); + const firstPersisted = await readPersistedStalkerPlaylist( + app.mainWindow, + playlistId + ); + expect(firstPersisted).not.toHaveProperty('stalkerToken'); + expect(firstPersisted.stalkerLastVerifiedAt).toEqual( + expect.any(String) + ); + await waitForEmptyStalkerCategories(app.mainWindow); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + await expect( + readPersistedStalkerPlaylist( + app.mainWindow, + playlistId + ) + ).resolves.toMatchObject({ + _id: playlistId, + stalkerRequestRecipe: 'full-session', + title: playlistTitle, + }); + await navigateToStalkerRoute( + app.mainWindow, + playlistId + ); + + await expect + .poll( + async () => + ( + await readPersistedStalkerPlaylist( + app.mainWindow, + playlistId + ) + ).stalkerLastVerifiedAt, + { timeout: 15000 } + ) + .not.toBe(firstPersisted.stalkerLastVerifiedAt); + await expect( + app.mainWindow.locator( + 'app-stalker-credentials-dialog' + ) + ).toHaveCount(0); + await waitForEmptyStalkerCategories(app.mainWindow); + } finally { + await closeElectronApp(app); + } + } + ); + + expect(replay.finalization).toMatchObject({ + ledger: { + mismatchCounts: {}, + operationCounts: { + 'anonymous-probe': 2, + 'catalog-categories': 2, + 'do-auth': 2, + handshake: 2, + 'profile-first': 2, + 'profile-second': 2, + }, + terminalState: 'complete', + }, + ok: true, + }); + }); +}); + +async function seedLegacyPlaylist( + page: Page, + input: { + endpoint: string; + macAddress: string; + playlistId: string; + playlistTitle: string; + sourceUrl: string; + } +): Promise { + await page.evaluate( + async ({ + endpoint, + macAddress, + playlistId, + playlistTitle, + sourceUrl, + }) => { + const upsert = window.electron?.dbUpsertAppPlaylist; + if (!upsert) { + throw new Error('playlist-upsert-unavailable'); + } + const now = new Date().toISOString(); + await upsert({ + _id: playlistId, + autoRefresh: false, + count: 0, + importDate: now, + isFullStalkerPortal: true, + lastUsage: now, + macAddress, + portalUrl: endpoint, + stalkerSourceUrl: sourceUrl, + stalkerToken: 'legacy-renderer-token', + title: playlistTitle, + }); + }, + input + ); +} + +async function navigateToStalkerRoute( + page: Page, + playlistId: string +): Promise { + const route = `/workspace/stalker/${playlistId}/vod`; + await page.evaluate((nextRoute) => { + window.history.pushState({}, '', nextRoute); + window.dispatchEvent(new PopStateEvent('popstate')); + }, route); + await page.waitForURL( + new RegExp(`/workspace/stalker/${escapeRegExp(playlistId)}/`) + ); +} + +async function submitCredentials( + page: Page, + run: StalkerReplayRun +): Promise { + const dialog = page.locator('app-stalker-credentials-dialog').last(); + await expect(dialog).toBeVisible({ timeout: 15000 }); + await dialog + .locator('input[autocomplete="username"]') + .fill(requiredInput(run, 'username')); + await dialog + .locator('input[autocomplete="current-password"]') + .fill(requiredInput(run, 'password')); + await dialog.locator('button[type="submit"]').click(); + await dialog.waitFor({ state: 'detached' }); +} + +async function readPersistedStalkerPlaylist( + page: Page, + playlistId: string +): Promise> { + const playlist = await page.evaluate(async (id) => { + const read = window.electron?.dbGetAppPlaylist; + if (!read) { + throw new Error('playlist-read-unavailable'); + } + return read(id); + }, playlistId); + if (playlist === null || typeof playlist !== 'object') { + throw new Error('persisted-stalker-playlist-missing'); + } + return playlist as Record; +} + +async function waitForEmptyStalkerCategories(page: Page): Promise { + await expect( + page.locator('app-workspace-context-panel .category-empty-state') + ).toBeVisible({ timeout: 15000 }); + await expect( + page.locator('app-workspace-context-panel .context-skeleton') + ).toHaveCount(0); +} + +function requiredInput(run: StalkerReplayRun, name: string): string { + const value = run.inputs[name]; + if (!value) { + throw new Error(`stalker-replay-input-missing:${name}`); + } + return value; +} + +function requiredOrigin(run: StalkerReplayRun, name: string): string { + const value = run.origins[name]; + if (!value) { + throw new Error(`stalker-replay-origin-missing:${name}`); + } + return value; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/apps/stalker-mock-server/fixtures/replay/e2e/legacy-first-open-reopen.json b/apps/stalker-mock-server/fixtures/replay/e2e/legacy-first-open-reopen.json new file mode 100644 index 000000000..b727c40cf --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/e2e/legacy-first-open-reopen.json @@ -0,0 +1,608 @@ +{ + "description": "A legacy full portal is classified through the route UI, persists accepted credentials and its learned recipe, then reopens without asking for credentials again.", + "entry": { + "origin": "portal", + "path": "/c/" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 2, + "min": 2 + }, + "id": "anonymous-probe", + "method": "GET", + "operation": "anonymous-probe", + "origin": "portal", + "path": "/c/", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "mac", + "session" + ], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [ + "authorization", + "cookie" + ], + "exact": {}, + "present": [] + }, + "query": { + "absent": [ + "device_id", + "device_id2", + "mac", + "password", + "signature", + "signature2", + "sn", + "token", + "username" + ], + "exact": {}, + "present": [] + } + }, + "response": { + "body": { + "kind": "empty" + }, + "headers": {}, + "status": 204 + } + }, + { + "cardinality": { + "max": 2, + "min": 2 + }, + "id": "handshake", + "method": "GET", + "operation": "handshake", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "stb_lang": "en", + "timezone": "UTC" + }, + "present": [] + }, + "headers": { + "absent": [ + "authorization" + ], + "exact": { + "accept-language": "en-US", + "user-agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250", + "x-user-agent": "Model: MAG250" + }, + "present": [ + "referer" + ] + }, + "query": { + "absent": [ + "password", + "token", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "handshake", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "random": { + "kind": "ref", + "symbol": "challenge" + }, + "token": { + "kind": "ref", + "symbol": "session-token" + } + } + } + }, + "headers": { + "content-type": [ + "application/json" + ], + "set-cookie": [ + { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "session=" + }, + { + "kind": "ref", + "symbol": "session-cookie-1" + }, + { + "kind": "literal", + "value": "; Path=/; HttpOnly" + } + ] + } + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 2, + "min": 2 + }, + "id": "profile-first", + "method": "GET", + "operation": "profile-first", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "session-cookie-1" + }, + "stb_lang": "en", + "timezone": "UTC" + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "accept-language": "en-US", + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "session-token" + } + ] + }, + "user-agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250", + "x-user-agent": "Model: MAG250" + }, + "present": [ + "referer" + ] + }, + "query": { + "absent": [ + "device_id", + "device_id2", + "password", + "signature", + "signature2", + "sn", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_profile", + "auth_second_step": "0", + "client_type": "STB", + "hd": "1", + "language": "en", + "not_valid_token": "0", + "stb_type": "MAG250", + "timezone": "UTC", + "type": "stb" + }, + "present": [ + "metrics" + ] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "status": 2 + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 2, + "min": 2 + }, + "id": "do-auth", + "method": "GET", + "operation": "do-auth", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "session-cookie-1" + }, + "stb_lang": "en", + "timezone": "UTC" + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "accept-language": "en-US", + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "session-token" + } + ] + }, + "user-agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250", + "x-user-agent": "Model: MAG250" + }, + "present": [ + "referer" + ] + }, + "query": { + "absent": [], + "exact": { + "JsHttpRequest": "1-xml", + "action": "do_auth", + "login": { + "kind": "ref", + "symbol": "username" + }, + "password": { + "kind": "ref", + "symbol": "password" + }, + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": true + } + }, + "headers": { + "content-type": [ + "application/json" + ], + "set-cookie": [ + { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "session=" + }, + { + "kind": "ref", + "symbol": "session-cookie-2" + }, + { + "kind": "literal", + "value": "; Path=/; HttpOnly" + } + ] + } + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 2, + "min": 2 + }, + "id": "profile-second", + "method": "GET", + "operation": "profile-second", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "session-cookie-2" + }, + "stb_lang": "en", + "timezone": "UTC" + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "accept-language": "en-US", + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "session-token" + } + ] + }, + "user-agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250", + "x-user-agent": "Model: MAG250" + }, + "present": [ + "referer" + ] + }, + "query": { + "absent": [ + "device_id", + "device_id2", + "password", + "signature", + "signature2", + "sn", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_profile", + "auth_second_step": "1", + "client_type": "STB", + "hd": "1", + "language": "en", + "not_valid_token": "0", + "stb_type": "MAG250", + "timezone": "UTC", + "type": "stb" + }, + "present": [ + "metrics" + ] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "status": 0 + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 2, + "min": 2 + }, + "id": "vod-categories", + "method": "GET", + "operation": "catalog-categories", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "session-cookie-2" + }, + "stb_lang": "en", + "timezone": "UTC" + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "accept-language": "en-US", + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "session-token" + } + ] + }, + "user-agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250", + "x-user-agent": "Model: MAG250" + }, + "present": [ + "referer" + ] + }, + "query": { + "absent": [ + "password", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_categories", + "type": "vod" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": [] + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "unordered", + "name": "legacy-first-open-and-reopen", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "e2e-legacy-first-open-reopen", + "schemaVersion": 1, + "symbols": [ + { + "kind": "generate", + "symbol": "mac", + "valueKind": "mac" + }, + { + "kind": "generate", + "symbol": "username", + "valueKind": "credential" + }, + { + "kind": "generate", + "symbol": "password", + "valueKind": "credential" + }, + { + "kind": "generate", + "symbol": "challenge", + "valueKind": "random" + }, + { + "kind": "generate", + "symbol": "session-token", + "valueKind": "token" + }, + { + "kind": "generate", + "symbol": "session-cookie-1", + "valueKind": "cookie" + }, + { + "kind": "generate", + "symbol": "session-cookie-2", + "valueKind": "cookie" + } + ], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts index 100dff5af..fc3dcede5 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts @@ -51,6 +51,7 @@ const EXPECTED_SCENARIOS = [ 'cookies-session-isolation', 'e2e-concurrent-catalog-refresh', 'e2e-full-session-catalog-playback', + 'e2e-legacy-first-open-reopen', 'redirect-auth-query', 'redirect-identity-pause', 'redirect-landing-approval',