diff --git a/apps/electron-backend-e2e/src/stalker-auth-refresh.e2e.ts b/apps/electron-backend-e2e/src/stalker-auth-refresh.e2e.ts new file mode 100644 index 000000000..8135035a5 --- /dev/null +++ b/apps/electron-backend-e2e/src/stalker-auth-refresh.e2e.ts @@ -0,0 +1,199 @@ +import type { Page } from '@playwright/test'; +import type { StalkerSessionConnectionOutcome } from '@iptvnator/shared/interfaces'; + +import { + closeElectronApp, + expect, + launchElectronApp, + test, +} from './electron-test-fixtures'; +import { + StalkerReplayRun, + withStalkerReplayRun, +} from './support/stalker-replay'; + +test.describe('Electron Stalker authenticated session refresh', () => { + test('@stalker @electron refreshes concurrent rejected catalog requests once', async ({ + dataDir, + }) => { + const replay = await withStalkerReplayRun( + 'e2e/concurrent-catalog-refresh', + async (run) => { + const app = await launchElectronApp(dataDir); + + try { + const ready = await openProvisionalSession( + app.mainWindow, + run, + 'e2e-concurrent-refresh' + ); + if ( + ready.kind !== 'ready' || + ready.recipe !== 'full-session' || + ready.connectionMode !== 'provisional' + ) { + throw new Error( + `expected-full-session:${JSON.stringify(ready)}` + ); + } + const committed = await app.mainWindow.evaluate( + async ({ attemptRef }) => { + const control = + window.electron?.stalkerSessionControl; + if (!control) { + throw new Error( + 'stalker-session-control-unavailable' + ); + } + + return control({ + action: 'commit', + attemptRef, + }); + }, + { attemptRef: ready.attemptRef } + ); + expect(committed).toMatchObject({ + action: 'commit', + kind: 'success', + }); + + const outcomes = await app.mainWindow.evaluate( + async ({ leaseRef }) => { + const request = + window.electron?.stalkerSessionRequest; + if (!request) { + throw new Error( + 'stalker-session-request-unavailable' + ); + } + + return Promise.all([ + request({ + leaseRef, + operation: 'get-ordered-list', + parameters: { + category: 'news', + contentType: 'itv', + page: 1, + }, + }), + request({ + leaseRef, + operation: 'get-ordered-list', + parameters: { + category: 'sports', + contentType: 'itv', + page: 2, + }, + }), + ]); + }, + { leaseRef: ready.leaseRef } + ); + + expect(outcomes).toMatchObject([ + { + kind: 'success', + operation: 'get-ordered-list', + payload: { + items: [{ id: 'news-channel' }], + page: 1, + }, + }, + { + kind: 'success', + operation: 'get-ordered-list', + payload: { + items: [{ id: 'sports-channel' }], + page: 2, + }, + }, + ]); + expect( + JSON.stringify({ committed, outcomes, ready }) + ).not.toMatch(/authorization|bearer|cookie|token/i); + } finally { + await closeElectronApp(app); + } + } + ); + + expect(replay.finalization).toMatchObject({ + ledger: { + mismatchCounts: {}, + operationCounts: { + 'anonymous-probe': 1, + 'initial-handshake': 1, + 'initial-profile': 1, + 'refresh-anonymous-probe': 1, + 'refresh-handshake': 1, + 'refresh-profile': 1, + 'reject-news': 1, + 'reject-sports': 1, + 'retry-news': 1, + 'retry-sports': 1, + }, + terminalState: 'complete', + }, + ok: true, + }); + }); +}); + +async function openProvisionalSession( + page: Page, + run: StalkerReplayRun, + playlistRef: string +): Promise { + return page.evaluate( + async ({ + learnedEndpointHint, + macAddress, + playlistRef: rendererPlaylistRef, + sourceUrl, + }) => { + const open = window.electron?.stalkerSessionOpen; + if (!open) { + throw new Error('stalker-session-open-unavailable'); + } + + return open({ + descriptor: { + connectionMode: 'provisional', + learnedEndpointHint, + macAddress, + playlistRef: rendererPlaylistRef, + profilePreset: { + id: 'mag250-public-5_1-minimal-v1', + version: 1, + }, + provisionalReason: 'import', + sourceUrl, + }, + }); + }, + { + learnedEndpointHint: `${requiredOrigin(run, 'portal')}/portal.php`, + macAddress: requiredInput(run, 'mac'), + playlistRef, + sourceUrl: run.entryUrl, + } + ); +} + +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; +} diff --git a/apps/electron-backend-e2e/src/stalker-auth.e2e.ts b/apps/electron-backend-e2e/src/stalker-auth.e2e.ts index c18f3f80f..c3227dd20 100644 --- a/apps/electron-backend-e2e/src/stalker-auth.e2e.ts +++ b/apps/electron-backend-e2e/src/stalker-auth.e2e.ts @@ -246,6 +246,14 @@ test.describe('Electron Stalker authenticated sessions', () => { const app = await launchElectronApp(dataDir); try { + await app.mainWindow.evaluate(async () => { + const readPlaylist = window.electron + ?.dbGetAppPlaylist; + if (!readPlaylist) { + throw new Error('playlist-read-unavailable'); + } + await readPlaylist('e2e-stalker-warmup'); + }); return await Promise.all([ openStatus2Session( app.mainWindow, diff --git a/apps/stalker-mock-server/fixtures/replay/e2e/concurrent-catalog-refresh.json b/apps/stalker-mock-server/fixtures/replay/e2e/concurrent-catalog-refresh.json new file mode 100644 index 000000000..d110ef7de --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/e2e/concurrent-catalog-refresh.json @@ -0,0 +1,886 @@ +{ + "description": "Synthetic concurrent catalog token rejections share one refreshed session and reissue each operation exactly once.", + "entry": { + "origin": "portal", + "path": "/c/" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "initial", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "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": 1, + "min": 1 + }, + "id": "initial-handshake", + "method": "GET", + "operation": "initial-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": { + "user-agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250", + "x-user-agent": "Model: MAG250" + }, + "present": [] + }, + "query": { + "absent": [ + "password", + "token", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "handshake", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "token": { + "kind": "ref", + "symbol": "initial-token" + } + } + } + }, + "headers": { + "content-type": [ + "application/json" + ], + "set-cookie": [ + { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "session=" + }, + { + "kind": "ref", + "symbol": "initial-cookie" + }, + { + "kind": "literal", + "value": "; Path=/; HttpOnly" + } + ] + } + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "initial-profile", + "method": "GET", + "operation": "initial-profile", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "initial-cookie" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "initial-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "password", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_profile", + "auth_second_step": "0", + "type": "stb" + }, + "present": [ + "metrics" + ] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "status": 0 + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "initial-session", + "nextState": "reject", + "state": "initial" + }, + { + "barrier": { + "name": "both-catalog-requests-rejected", + "releaseWhenMatched": 2 + }, + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "reject-news", + "method": "GET", + "operation": "reject-news", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "initial-cookie" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "initial-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "mac", + "password", + "token", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_ordered_list", + "category": "news", + "p": "1", + "type": "itv" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "error": "Authorization failed" + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 401 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "reject-sports", + "method": "GET", + "operation": "reject-sports", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "initial-cookie" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "initial-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "mac", + "password", + "token", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_ordered_list", + "category": "sports", + "p": "2", + "type": "itv" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "error": "Authorization failed" + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 401 + } + } + ], + "mode": "unordered", + "name": "concurrent-rejections", + "nextState": "refresh", + "state": "reject" + }, + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "refresh-anonymous-probe", + "method": "GET", + "operation": "refresh-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": 1, + "min": 1 + }, + "id": "refresh-handshake", + "method": "GET", + "operation": "refresh-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": { + "user-agent": "Mozilla/5.0 (QtEmbedded; U; Linux; C) AppleWebKit/533.3 (KHTML, like Gecko) MAG250", + "x-user-agent": "Model: MAG250" + }, + "present": [] + }, + "query": { + "absent": [ + "password", + "token", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "handshake", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "token": { + "kind": "ref", + "symbol": "refresh-token" + } + } + } + }, + "headers": { + "content-type": [ + "application/json" + ], + "set-cookie": [ + { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "session=" + }, + { + "kind": "ref", + "symbol": "refresh-cookie" + }, + { + "kind": "literal", + "value": "; Path=/; HttpOnly" + } + ] + } + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "refresh-profile", + "method": "GET", + "operation": "refresh-profile", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "refresh-cookie" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "refresh-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "password", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_profile", + "auth_second_step": "0", + "type": "stb" + }, + "present": [ + "metrics" + ] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "status": 0 + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "single-refresh", + "nextState": "retry", + "state": "refresh" + }, + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "retry-news", + "method": "GET", + "operation": "retry-news", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "refresh-cookie" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "refresh-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "mac", + "password", + "token", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_ordered_list", + "category": "news", + "p": "1", + "type": "itv" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "cur_page": 1, + "data": [ + { + "id": "news-channel", + "title": "Synthetic News" + } + ], + "max_page_items": 1, + "total_items": 1, + "total_pages": 1 + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "retry-sports", + "method": "GET", + "operation": "retry-sports", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + }, + "session": { + "kind": "ref", + "symbol": "refresh-cookie" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "refresh-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "mac", + "password", + "token", + "username" + ], + "exact": { + "JsHttpRequest": "1-xml", + "action": "get_ordered_list", + "category": "sports", + "p": "2", + "type": "itv" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "cur_page": 2, + "data": [ + { + "id": "sports-channel", + "title": "Synthetic Sports" + } + ], + "max_page_items": 1, + "total_items": 2, + "total_pages": 2 + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "unordered", + "name": "retried-catalog-requests", + "nextState": "complete", + "state": "retry" + } + ], + "scenarioId": "e2e-concurrent-catalog-refresh", + "schemaVersion": 1, + "symbols": [ + { + "kind": "generate", + "symbol": "mac", + "valueKind": "mac" + }, + { + "kind": "generate", + "symbol": "initial-token", + "valueKind": "token" + }, + { + "kind": "generate", + "symbol": "refresh-token", + "valueKind": "token" + }, + { + "kind": "generate", + "symbol": "initial-cookie", + "valueKind": "cookie" + }, + { + "kind": "generate", + "symbol": "refresh-cookie", + "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 15f6c130f..23e98e27b 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 @@ -36,6 +36,7 @@ const EXPECTED_SCENARIOS = [ 'classifier-waf-403', 'cookies-managed-shadow', 'cookies-session-isolation', + 'e2e-concurrent-catalog-refresh', 'e2e-full-session-catalog-playback', 'redirect-auth-query', 'redirect-identity-pause',