diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 6b37e4b04..0b4d8f6ef 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -43,6 +43,17 @@ imported as a **full portal** (handshake + token + watchdog), anything else as a | `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. | | `/server/load.php` | Strict. The second full-portal URL shape the app recognizes; enforced identically. | +> **Known app inconsistency:** `StalkerSessionService.isFullStalkerPortal` +> classifies `/server/load.php` as a full portal, but the import dialog's +> `isFullStalkerPortalUrl` checks only for `/stalker_portal`, so importing a bare +> `…/server/load.php` URL persists `isFullStalkerPortal: false` and the app skips +> the handshake. The mock is deliberately faithful to a **real** portal here +> (that path enforces auth), which makes it the right fixture to drive the +> upcoming fix that unifies those two predicates. Until then, import full +> portals through a `/stalker_portal/...` URL. + +> **Known app-side gap:** the + The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can actually get wrong: diff --git a/apps/web-e2e/src/stalker-auth.e2e.ts b/apps/web-e2e/src/stalker-auth.e2e.ts index 3b52f4f0d..4c82413aa 100644 --- a/apps/web-e2e/src/stalker-auth.e2e.ts +++ b/apps/web-e2e/src/stalker-auth.e2e.ts @@ -114,6 +114,34 @@ function recordPortalActions(page: Page): { return { actions, tokensByAction }; } +const CONTENT_ACTIONS = [ + 'get_categories', + 'get_genres', + 'get_ordered_list', + 'get_all_channels', +]; + +/** Every portal request in order, with the token it carried. */ +function recordPortalRequests( + page: Page +): Array<{ action: string; token: string | null }> { + const requests: Array<{ action: string; token: string | null }> = []; + + page.on('request', (request) => { + const url = new URL(request.url()); + if (!url.pathname.endsWith('/stalker')) { + return; + } + const action = url.searchParams.get('action'); + if (!action) { + return; + } + requests.push({ action, token: url.searchParams.get('token') }); + }); + + return requests; +} + test.beforeEach(async ({ page, request }) => { // Importing a full portal costs a handshake, a profile call and the first // content load — on a cold dev server that alone approaches Playwright's @@ -177,9 +205,16 @@ test.describe('@stalker full portal authentication', () => { page, request, }) => { + const requests = recordPortalRequests(page); + await addFullStalkerPortal(page); - const { actions } = recordPortalActions(page); + // The token the initial import authenticated with — recovery must end + // up on a DIFFERENT one, or nothing was actually re-negotiated. + const tokenBeforeInvalidation = requests.find( + (entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token + )?.token; + expect(tokenBeforeInvalidation).toBeTruthy(); // Server-side session loss is what a real expired/replaced token looks // like: the next request gets "Authorization failed." with HTTP 200. @@ -190,15 +225,42 @@ test.describe('@stalker full portal authentication', () => { ); expect(invalidated.ok()).toBe(true); + const requestCountBeforeNavigation = requests.length; + // Navigating to another content type forces a fresh portal request. await page.getByRole('link', { name: /live|itv/i }).click(); + // Recovery is only proven end to end when a CONTENT request goes out + // under a freshly negotiated token — a re-handshake alone could still + // leave the original request unreplayed or unauthorized. The mock only + // answers content for an adopted token, so this doubles as proof the + // new token was adopted via get_profile. await expect - .poll(() => actions.filter((a) => a === 'handshake').length, { - timeout: 30_000, - }) + .poll( + () => + requests + .slice(requestCountBeforeNavigation) + .filter( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token && + entry.token !== tokenBeforeInvalidation + ).length, + { timeout: 30_000 } + ) .toBeGreaterThan(0); + const recovered = requests.slice(requestCountBeforeNavigation); + expect( + recovered.filter((entry) => entry.action === 'handshake').length + ).toBeGreaterThan(0); + + // And the recovered session must actually render: the ITV categories + // can only come from an authorized request against the new token. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 15_000, + }); + await expect(page.locator('body')).not.toContainText( 'Authorization failed.' );