From eeda7038498e2cfe4e064227afbf089193fb0b1f Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 1 Aug 2026 12:30:26 +0200 Subject: [PATCH] test(stalker): enforce portal auth in the mock and cover the full-portal flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mock server implemented neither get_profile nor get_events and validated no auth at all, and the e2e suite imported the portal through /portal.php — which the app classifies as a *simple* portal. The entire authenticated branch (handshake, token, watchdog, re-auth) therefore had zero coverage, right before a series of PRs that reworks exactly that. Mock server: - serve the canonical /stalker_portal/server/load.php endpoint, which enforces the Bearer token and the Infomir MAC format like the real middleware; /portal.php stays tolerant so the existing suite keeps covering the simple-portal branch - auth-store.ts models the parts of Stalker 4.9.35 a client can get wrong: plain-text auth failures with HTTP 200, a handshake that is not yet a session, idempotent token re-presentation, and permanent device_id pinning (including the blank-after-pinned lockout) - add get_profile (status 0/1/2, device conflict, block_msg) and the get_events watchdog; profile advertises watchdog_timeout/timeslot - new login-required scenario MAC and POST /invalidate-session so tests can force a mid-session token loss - the /stalker proxy route now forwards the token as a Bearer header and wraps auth failures in the { payload } envelope, matching web-backend Also moves extractMac into request-mac.ts: importing it from the categories handler dragged the whole data generator into any consumer, which broke unit tests on the workspace alias. E2E: new stalker-auth.e2e.ts asserts handshake precedes get_profile precedes content, that content requests carry the token while the handshake does not, that the plain-text failure body is never rendered, and that the client re-authenticates after the portal drops the session. Co-Authored-By: Claude Fable 5 --- apps/stalker-mock-server/README.md | 44 +++- .../src/app/auth-store.spec.ts | 150 +++++++++++++ .../stalker-mock-server/src/app/auth-store.ts | 172 +++++++++++++++ .../src/app/handlers/create-link.handler.ts | 2 +- .../src/app/handlers/favorites.handler.ts | 2 +- .../app/handlers/get-all-channels.handler.ts | 2 +- .../app/handlers/get-categories.handler.ts | 12 +- .../src/app/handlers/get-epg-info.handler.ts | 2 +- .../src/app/handlers/get-events.handler.ts | 40 ++++ .../src/app/handlers/get-genres.handler.ts | 2 +- .../app/handlers/get-ordered-list.handler.ts | 2 +- .../src/app/handlers/get-profile.handler.ts | 85 ++++++++ .../src/app/handlers/get-seasons.handler.ts | 2 +- .../src/app/handlers/get-short-epg.handler.ts | 2 +- .../src/app/handlers/handshake.handler.ts | 16 +- .../src/app/request-mac.ts | 17 ++ .../src/app/routes/dispatch.ts | 42 +++- .../src/app/routes/portal.route.ts | 22 +- apps/stalker-mock-server/src/app/scenarios.ts | 15 ++ apps/stalker-mock-server/src/main.ts | 63 +++++- apps/web-e2e/src/stalker-auth.e2e.ts | 206 ++++++++++++++++++ docs/architecture/stalker-mock-server.md | 49 ++++- 22 files changed, 905 insertions(+), 44 deletions(-) create mode 100644 apps/stalker-mock-server/src/app/auth-store.spec.ts create mode 100644 apps/stalker-mock-server/src/app/auth-store.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-events.handler.ts create mode 100644 apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts create mode 100644 apps/stalker-mock-server/src/app/request-mac.ts create mode 100644 apps/web-e2e/src/stalker-auth.e2e.ts diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 42abe86d2..2225803e4 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -25,9 +25,43 @@ nx run-many --targets=serve --projects=stalker-mock-server,web Then in IPTVnator, add a new Stalker portal: -- **Portal URL**: `http://localhost:3210/portal.php` +- **Portal URL**: `http://localhost:3210/portal.php` (tolerant panel-style endpoint) + or `http://localhost:3210/stalker_portal/server/load.php` (canonical Ministra + endpoint — see [Two endpoints](#two-endpoints-tolerant-vs-strict) below) - **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data) +## Two endpoints: tolerant vs strict + +The same actions are served at two paths with deliberately different strictness, +because the app treats them differently: a URL containing `/stalker_portal` is +imported as a **full portal** (handshake + token + watchdog), anything else as a +**simple portal** (no authentication at all). + +| Path | Behaviour | +|---|---| +| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. | +| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. | + +The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can +actually get wrong: + +- Every action except `handshake`, `get_profile`, `get_localization` and + `do_auth` requires `Authorization: Bearer `. +- A token only counts once `get_profile` has adopted it — a handshake alone is + not a session. +- Auth failures come back as **HTTP 200 with a plain-text body** + (`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients + that only check status codes will silently render nothing. +- The handshake is **idempotent**: presenting the MAC's current token returns + that same token instead of rotating it. +- `device_id`/`device_id2` are pinned to the MAC on first non-empty value; any + later change — including sending them empty again — is a permanent + `device conflict` with the "Your STB is damaged." block message. +- `signature`, `metrics` and `prehash` are accepted and ignored, exactly as the + stock server does. +- The MAC must match the Infomir OUI format (`00:1A:79:XX:XX:XX`) or + `get_profile` answers with a bare `{ status: 1 }`. + ## Predefined Scenario MAC Addresses | MAC Address | Scenario | Description | @@ -40,6 +74,7 @@ Then in IPTVnator, add a new Stalker portal: | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | | `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list | | `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing | +| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` and retries with `auth_second_step=1` | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | ## Configuration @@ -54,7 +89,8 @@ Then in IPTVnator, add a new Stalker portal: | Endpoint | Method | Description | |---|---|---| | `/health` | `GET` | Health check — returns `{ status: "ok" }` | -| `/reset` | `POST` | Clear all in-memory data and favorites (useful between test runs) | +| `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) | +| `/invalidate-session?macAddress=` | `POST` | Drop that MAC's session so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries | ## API Coverage @@ -62,7 +98,9 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r | Action | Description | |---|---| -| `handshake` | Returns a mock Bearer token | +| `handshake` | Issues the access token (idempotent) plus the 5.x `random` nonce and `not_valid` flag | +| `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format | +| `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) | | `do_auth` | Returns a mock user profile | | `get_categories` | Category list filtered by `type` (itv/vod/series) | | `get_genres` | Genre list (mirrors categories) | diff --git a/apps/stalker-mock-server/src/app/auth-store.spec.ts b/apps/stalker-mock-server/src/app/auth-store.spec.ts new file mode 100644 index 000000000..c3eeac35b --- /dev/null +++ b/apps/stalker-mock-server/src/app/auth-store.spec.ts @@ -0,0 +1,150 @@ +import { Request } from 'express'; +import { + adoptToken, + checkRequestAuthorization, + invalidateSession, + issueHandshakeToken, + pinDeviceIdentity, + readBearerToken, + resetAuthState, +} from './auth-store'; + +const MAC = '00:1A:79:AA:BB:CC'; + +function request(options: { + action?: string; + mac?: string | null; + token?: string; +}): Request { + const headers: Record = {}; + if (options.mac !== null) { + headers['cookie'] = `mac=${options.mac ?? MAC}; stb_lang=en`; + } + if (options.token) { + headers['authorization'] = `Bearer ${options.token}`; + } + return { + headers, + query: { action: options.action ?? 'get_categories' }, + } as unknown as Request; +} + +describe('stalker mock auth store', () => { + beforeEach(() => { + resetAuthState(); + }); + + it('issues a 32-char uppercase hex token', () => { + const { token } = issueHandshakeToken(MAC); + + expect(token).toMatch(/^[0-9A-F]{32}$/); + }); + + it('returns the stored token unchanged when it is presented again', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + + const second = issueHandshakeToken(MAC, token); + + expect(second.token).toBe(token); + expect(second.notValid).toBe(false); + }); + + it('flags not_valid when a stale token is presented', () => { + expect(issueHandshakeToken(MAC, 'STALE').notValid).toBe(true); + }); + + it('rejects a request without a mac cookie', () => { + expect(checkRequestAuthorization(request({ mac: null }), true)).toBe( + 'Unauthorized request.' + ); + }); + + it('rejects an unauthenticated action when enforcement is on', () => { + expect(checkRequestAuthorization(request({}), true)).toBe( + 'Authorization failed.' + ); + }); + + it('allows the handshake/profile/do_auth actions without a token', () => { + for (const action of ['handshake', 'get_profile', 'do_auth']) { + expect(checkRequestAuthorization(request({ action }), true)).toBe( + null + ); + } + }); + + it('allows any action once get_profile adopted the token', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + + expect(checkRequestAuthorization(request({ token }), true)).toBe(null); + }); + + it('rejects a token that was never adopted by get_profile', () => { + const { token } = issueHandshakeToken(MAC); + + expect(checkRequestAuthorization(request({ token }), true)).toBe( + 'Authorization failed.' + ); + }); + + it('fails after the session is invalidated so clients must re-authenticate', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + invalidateSession(MAC); + + expect(checkRequestAuthorization(request({ token }), true)).toBe( + 'Authorization failed.' + ); + }); + + it('never enforces the token when enforcement is off', () => { + expect(checkRequestAuthorization(request({}), false)).toBe(null); + }); + + it('reads the bearer token case-insensitively', () => { + const req = { + headers: { authorization: 'bearer ABC123 ' }, + } as unknown as Request; + + expect(readBearerToken(req)).toBe('ABC123'); + }); + + describe('device identity pinning', () => { + it('stores the first non-empty values', () => { + expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null); + expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null); + }); + + it('reports a conflict when a pinned device_id changes', () => { + pinDeviceIdentity(MAC, 'dev-1', undefined); + + expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe( + 'device conflict - device_id mismatch' + ); + }); + + it('reports a conflict when a pinned value is later sent empty', () => { + pinDeviceIdentity(MAC, 'dev-1', undefined); + + // This is the real lockout: a client that stops sending the id it + // once pinned is told its STB is damaged. + expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe( + 'device conflict - device_id mismatch' + ); + }); + + it('reports the device_id2 conflict separately', () => { + pinDeviceIdentity(MAC, undefined, 'dev-2'); + + expect(pinDeviceIdentity(MAC, undefined, 'changed')).toBe( + 'device conflict - MAC address mismatch' + ); + }); + + it('accepts identity omitted entirely on a fresh MAC', () => { + expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe(null); + }); + }); +}); diff --git a/apps/stalker-mock-server/src/app/auth-store.ts b/apps/stalker-mock-server/src/app/auth-store.ts new file mode 100644 index 000000000..8d2f7d4bf --- /dev/null +++ b/apps/stalker-mock-server/src/app/auth-store.ts @@ -0,0 +1,172 @@ +import { Request } from 'express'; +import { extractMac } from './request-mac.js'; + +/** + * Session/identity state of the mocked portal. + * + * Modelled on the plaintext Stalker 4.9.35 middleware (`server/lib/stb.class.php`), + * which is the last openly readable ancestor of the encoded 5.x core: + * + * - the handshake token is random and **idempotent** — re-presenting a valid + * token returns the same one instead of rotating it + * - a token only becomes a session once `get_profile` stores it for the MAC + * - `device_id`/`device_id2` are pinned on first non-empty value and a later + * mismatch is a hard, permanent conflict + * - `signature`, `metrics` and `prehash` are accepted but never verified + */ + +interface PortalSession { + /** Token handed out by the last handshake, before get_profile adopts it. */ + pendingToken?: string; + /** Token stored for the MAC — what authorizes non-auth actions. */ + accessToken?: string; + deviceId?: string; + deviceId2?: string; +} + +const sessions = new Map(); + +/** Actions the portal answers without a valid Bearer token. */ +const UNAUTHENTICATED_ACTIONS = new Set([ + 'handshake', + 'get_profile', + 'get_localization', + 'do_auth', +]); + +export type AuthFailure = + | 'Authorization failed.' + | 'Access denied.' + | 'Unauthorized request.'; + +function getSession(mac: string): PortalSession { + const key = mac.toLowerCase(); + if (!sessions.has(key)) { + sessions.set(key, {}); + } + return sessions.get(key) as PortalSession; +} + +/** 32 uppercase hex chars, like `strtoupper(md5(microtime + uniqid))`. */ +function generateToken(mac: string): string { + const entropy = `${mac}:${sessions.size}:${tokenCounter++}`; + let hex = ''; + for (let index = 0; index < 32; index += 1) { + const code = entropy.charCodeAt(index % entropy.length) + index * 31; + hex += (code % 16).toString(16).toUpperCase(); + } + return hex; +} + +let tokenCounter = 0; + +/** + * Issue (or re-confirm) a handshake token. Presenting the MAC's current access + * token returns it unchanged, which is what lets real clients persist tokens + * across restarts. + */ +export function issueHandshakeToken(mac: string, presentedToken?: string): { + token: string; + notValid: boolean; +} { + const session = getSession(mac); + + if (presentedToken && presentedToken === session.accessToken) { + return { token: session.accessToken, notValid: false }; + } + + const token = generateToken(mac); + session.pendingToken = token; + + // The real server only sets not_valid when an auth_url is configured and a + // stale token was presented; mirroring the flag lets clients exercise it. + return { token, notValid: Boolean(presentedToken) }; +} + +/** Adopt the handshake token as the MAC's session token (what get_profile does). */ +export function adoptToken(mac: string, token: string): void { + const session = getSession(mac); + session.accessToken = token; + session.pendingToken = undefined; +} + +export function readBearerToken(req: Request): string | undefined { + const header = req.headers['authorization']; + if (typeof header !== 'string') { + return undefined; + } + const match = /Bearer\s+(.*)$/i.exec(header.trim()); + return match?.[1]?.trim() || undefined; +} + +/** + * Pin device identity to the MAC. Returns a conflict message when a previously + * stored value is contradicted — including the "blank after pinned" case that + * permanently locks real users out. + */ +export function pinDeviceIdentity( + mac: string, + deviceId: string | undefined, + deviceId2: string | undefined +): string | null { + const session = getSession(mac); + + for (const [field, incoming] of [ + ['deviceId', deviceId], + ['deviceId2', deviceId2], + ] as const) { + const stored = session[field]; + if (!stored) { + if (incoming) { + session[field] = incoming; + } + continue; + } + if (stored !== (incoming ?? '')) { + return field === 'deviceId' + ? 'device conflict - device_id mismatch' + : 'device conflict - MAC address mismatch'; + } + } + + return null; +} + +/** + * Decide whether a request may proceed. `enforce` is false for the reseller-style + * `/portal.php` endpoint, which commonly ignores tokens, and true for the + * canonical `/stalker_portal/server/load.php` endpoint. + */ +export function checkRequestAuthorization( + req: Request, + enforce: boolean +): AuthFailure | null { + const action = String(req.query['action'] ?? ''); + const hasMacCookie = (req.headers['cookie'] ?? '').includes('mac='); + + if (!hasMacCookie) { + return 'Unauthorized request.'; + } + if (!enforce || UNAUTHENTICATED_ACTIONS.has(action)) { + return null; + } + + const session = getSession(extractMac(req)); + const presented = readBearerToken(req); + + if (!session.accessToken || presented !== session.accessToken) { + return 'Authorization failed.'; + } + + return null; +} + +/** Drop the MAC's session so the next request must re-authenticate. */ +export function invalidateSession(mac: string): void { + sessions.delete(mac.toLowerCase()); +} + +export function resetAuthState(): void { + sessions.clear(); + tokenCounter = 0; +} diff --git a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts index 163ecc225..e3a35d6d2 100644 --- a/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/create-link.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { resolveStreamUrl } from '../data-generator.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker create_link — returns a playable stream URL. diff --git a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts index 10b58dd93..720b5a593 100644 --- a/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/favorites.handler.ts @@ -5,7 +5,7 @@ import { getPortalData, removeFavorite, } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; import { RawChannel, RawRadioStation, diff --git a/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts index 7d37396fb..06588bdfb 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-all-channels.handler.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; import { getScenario } from '../scenarios.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker/Ministra get_all_channels — returns the COMPLETE ITV channel list diff --git a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts index e1b49293a..8692ef3dc 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-categories.handler.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_categories — returns category list filtered by type. @@ -22,14 +23,3 @@ export function handleGetCategories(req: Request, res: Response): void { res.json({ js: categories }); } - -export function extractMac(req: Request): string { - const cookie = req.headers['cookie'] ?? ''; - return ( - cookie - .split(';') - .find((c) => c.trim().startsWith('mac=')) - ?.split('=')[1] - ?.trim() ?? '00:00:00:00:00:00' - ); -} diff --git a/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts index 7c89386fa..4435e233f 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-epg-info.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_epg_info — returns bulk EPG keyed by channel id. diff --git a/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts new file mode 100644 index 000000000..9132ab64a --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts @@ -0,0 +1,40 @@ +import { Request, Response } from 'express'; +import { extractMac } from '../request-mac.js'; + +/** Per-MAC watchdog bookkeeping so tests can assert the client pings at all. */ +const watchdogPings = new Map(); + +/** + * Stalker watchdog `get_events`. The real portal only uses it for presence + * reporting and event delivery — it never affects authorization — so the mock + * records the ping and returns an empty event set. + */ +export function handleGetEvents(req: Request, res: Response): void { + const mac = extractMac(req).toLowerCase(); + const init = String(req.query['init'] ?? '0'); + const previous = watchdogPings.get(mac); + + watchdogPings.set(mac, { + count: (previous?.count ?? 0) + 1, + lastInit: init, + }); + + res.json({ + js: { + data: { + msgs: 0, + additional_services_on: '1', + }, + }, + }); +} + +export function getWatchdogPings( + mac: string +): { count: number; lastInit: string } | undefined { + return watchdogPings.get(mac.toLowerCase()); +} + +export function resetWatchdogPings(): void { + watchdogPings.clear(); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts index bc9e30a9a..5be458b72 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-genres.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_genres — returns genre list for a content type. diff --git a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts index ea6c7bb26..5b1e502ef 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-ordered-list.handler.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; import { RawChannel, RawRadioStation, diff --git a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts new file mode 100644 index 000000000..0e6f15cb2 --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts @@ -0,0 +1,85 @@ +import { Request, Response } from 'express'; +import { adoptToken, pinDeviceIdentity, readBearerToken } from '../auth-store.js'; +import { getScenario } from '../scenarios.js'; +import { extractMac } from '../request-mac.js'; + +/** `00:1A:79` is the Infomir OUI the stock portal requires by default. */ +const INFOMIR_MAC = /^00:1A:79:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$/; + +/** + * Stalker get_profile — the step that turns a handshake token into a session. + * + * Reproduces the outcomes of the 4.9.35 middleware: a bare `{status:1}` for a + * malformed MAC, `{status:1, msg, block_msg}` for a device conflict, + * `{status:2}` when the portal wants login/password, and otherwise the profile + * itself. `signature`, `metrics` and `prehash` are accepted and ignored, which + * is exactly what the real server does. + */ +export function handleGetProfile( + req: Request, + res: Response, + options: { enforceMacFormat?: boolean } = {} +): void { + const mac = extractMac(req); + const scenario = getScenario(mac); + + if (options.enforceMacFormat && !INFOMIR_MAC.test(mac.toUpperCase())) { + res.json({ js: { status: 1 } }); + return; + } + + if (scenario.requiresLogin && req.query['auth_second_step'] !== '1') { + res.json({ + js: { + status: 2, + template: 'auth', + info: 'Login required', + }, + }); + return; + } + + const conflict = pinDeviceIdentity( + mac, + String(req.query['device_id'] ?? '') || undefined, + String(req.query['device_id2'] ?? '') || undefined + ); + + if (conflict) { + res.json({ + js: { + status: 1, + msg: conflict, + block_msg: 'Your STB is damaged.
Call the provider.', + }, + }); + return; + } + + const token = readBearerToken(req); + if (token) { + adoptToken(mac, token); + } + + res.json({ + js: { + id: '1', + name: 'Mock STB', + mac, + status: 0, + blocked: '0', + fname: 'Mock User', + login: 'mockuser', + stb_type: String(req.query['stb_type'] ?? ''), + hd: String(req.query['hd'] ?? '1'), + // Clients should take their watchdog cadence from these two values + // rather than hardcoding one. + watchdog_timeout: 120, + timeslot: 15, + tariff_plan_id: '1', + tariff_expired_date: '2099-12-31', + locale: 'en', + default_locale: 'en', + }, + }); +} diff --git a/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts index 88a6e375a..d109c4521 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-seasons.handler.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { generateSeasons } from '../data-generator.js'; import { getPortalData } from '../data-store.js'; import { getScenario } from '../scenarios.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_ordered_list with type=series for seasons/episodes. diff --git a/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts index 441631f1d..2d172257d 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-short-epg.handler.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { generateEpg } from '../data-generator.js'; import { getPortalData } from '../data-store.js'; -import { extractMac } from './get-categories.handler.js'; +import { extractMac } from '../request-mac.js'; /** * Stalker get_short_epg — returns EPG programs for a channel. diff --git a/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts b/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts index c18354876..6340d09ba 100644 --- a/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/handshake.handler.ts @@ -1,8 +1,13 @@ import { Request, Response } from 'express'; +import { issueHandshakeToken } from '../auth-store.js'; /** - * Stalker handshake — returns a Bearer token. - * Real portals return a JWT; we return a deterministic fake token. + * Stalker handshake — issues the access token. + * + * Like the real middleware the token is opaque and idempotent: presenting the + * MAC's current token returns it unchanged instead of rotating it. `random` is + * the 5.x nonce a real MAG signs into `signature`; the mock returns it so + * clients that read it are exercised. */ export function handleHandshake(req: Request, res: Response): void { const mac = (req.headers['cookie'] ?? '') @@ -11,9 +16,14 @@ export function handleHandshake(req: Request, res: Response): void { ?.split('=')[1] ?.trim() ?? 'unknown'; + const presented = String(req.query['token'] ?? '') || undefined; + const { token, notValid } = issueHandshakeToken(mac, presented); + res.json({ js: { - token: `mock-token-${Buffer.from(mac).toString('base64')}`, + token, + random: `${token.slice(0, 20).toLowerCase()}0123456789abcdef1234`, + not_valid: notValid ? 1 : 0, keep_alive: 180, servertime: Math.floor(Date.now() / 1000), servertimezone: 'Europe/Berlin', diff --git a/apps/stalker-mock-server/src/app/request-mac.ts b/apps/stalker-mock-server/src/app/request-mac.ts new file mode 100644 index 000000000..142e3adb9 --- /dev/null +++ b/apps/stalker-mock-server/src/app/request-mac.ts @@ -0,0 +1,17 @@ +import { Request } from 'express'; + +/** + * Read the MAC the portal identifies the box by. Real Stalker clients send it + * as a `mac=` cookie on every request; the proxy route synthesizes the same + * cookie from its query parameter. + */ +export function extractMac(req: Request): string { + const cookie = req.headers['cookie'] ?? ''; + return ( + cookie + .split(';') + .find((c) => c.trim().startsWith('mac=')) + ?.split('=')[1] + ?.trim() ?? '00:00:00:00:00:00' + ); +} diff --git a/apps/stalker-mock-server/src/app/routes/dispatch.ts b/apps/stalker-mock-server/src/app/routes/dispatch.ts index 4d8aa2420..bc42e9bee 100644 --- a/apps/stalker-mock-server/src/app/routes/dispatch.ts +++ b/apps/stalker-mock-server/src/app/routes/dispatch.ts @@ -1,6 +1,9 @@ import { Request, Response } from 'express'; +import { checkRequestAuthorization } from '../auth-store.js'; import { handleHandshake } from '../handlers/handshake.handler.js'; import { handleDoAuth } from '../handlers/do-auth.handler.js'; +import { handleGetEvents } from '../handlers/get-events.handler.js'; +import { handleGetProfile } from '../handlers/get-profile.handler.js'; import { handleGetAllChannels } from '../handlers/get-all-channels.handler.js'; import { handleGetCategories } from '../handlers/get-categories.handler.js'; import { handleGetOrderedList } from '../handlers/get-ordered-list.handler.js'; @@ -11,17 +14,52 @@ import { handleGetEpgInfo } from '../handlers/get-epg-info.handler.js'; import { handleGetShortEpg } from '../handlers/get-short-epg.handler.js'; import { handleGetGenres } from '../handlers/get-genres.handler.js'; +export interface DispatchOptions { + /** + * Whether the endpoint enforces the Bearer token. The canonical + * `/stalker_portal/server/load.php` path does (like a real portal); the + * reseller-style `/portal.php` alias does not, which is what most panels in + * the wild behave like and what the existing e2e suite relies on. + */ + enforceAuth?: boolean; +} + /** * Shared Stalker action dispatcher. - * Used by both the direct /portal.php route and the /stalker CORS proxy route. + * Used by the direct portal routes and the /stalker CORS proxy route. */ -export default function dispatchPortalAction(req: Request, res: Response): void { +export default function dispatchPortalAction( + req: Request, + res: Response, + options: DispatchOptions = {} +): void { const action = req.query['action'] as string; + const authFailure = checkRequestAuthorization( + req, + options.enforceAuth ?? false + ); + if (authFailure) { + // The real middleware echoes this as a plain-text body with HTTP 200 — + // never a 401/403 — because it exits before the JSON envelope is built. + res.status(200).type('html').send(authFailure); + return; + } + switch (action) { case 'handshake': handleHandshake(req, res); break; + case 'get_profile': + handleGetProfile(req, res, { + // A real portal validates the MAC format by default; the + // tolerant reseller alias does not. + enforceMacFormat: options.enforceAuth ?? false, + }); + break; + case 'get_events': + handleGetEvents(req, res); + break; case 'do_auth': handleDoAuth(req, res); break; diff --git a/apps/stalker-mock-server/src/app/routes/portal.route.ts b/apps/stalker-mock-server/src/app/routes/portal.route.ts index e65f1e413..66b58ee6d 100644 --- a/apps/stalker-mock-server/src/app/routes/portal.route.ts +++ b/apps/stalker-mock-server/src/app/routes/portal.route.ts @@ -1,14 +1,22 @@ import { Router, Request, Response } from 'express'; import dispatchPortalAction from './dispatch.js'; -const router = Router(); - /** * Main Stalker API dispatcher. - * All requests arrive as GET /portal.php?action=&... + * + * Two endpoints are served with the same actions but different strictness: + * `/portal.php` (reseller-panel alias, tolerant) and + * `/stalker_portal/server/load.php` (canonical Ministra path, enforces the + * Bearer token exactly like the real middleware). */ -router.get('/', (req: Request, res: Response) => { - dispatchPortalAction(req, res); -}); +export function createPortalRouter(enforceAuth: boolean): Router { + const router = Router(); -export default router; + router.get('/', (req: Request, res: Response) => { + dispatchPortalAction(req, res, { enforceAuth }); + }); + + return router; +} + +export default createPortalRouter(false); diff --git a/apps/stalker-mock-server/src/app/scenarios.ts b/apps/stalker-mock-server/src/app/scenarios.ts index ec1c4b6b8..cdc156716 100644 --- a/apps/stalker-mock-server/src/app/scenarios.ts +++ b/apps/stalker-mock-server/src/app/scenarios.ts @@ -23,6 +23,8 @@ export interface ScenarioConfig { supportsGetAllChannels?: boolean; /** Replace generated VOD with the shared screenshot-safe poster catalog. */ marketingFixture?: true; + /** Answer `get_profile` with `status: 2` until `auth_second_step=1`. */ + requiresLogin?: true; } /** @@ -109,6 +111,19 @@ export const SCENARIOS: Record = { embeddedSeriesFraction: 0, supportsGetAllChannels: false, }, + '00:1a:79:00:00:08': { + name: 'login-required', + description: + 'Portal answering get_profile with status 2 until do_auth completes', + seed: 8008, + categoryCount: { itv: 4, radio: 4, vod: 4, series: 4 }, + itemsPerCategory: 10, + seasonsPerSeries: 2, + episodesPerSeason: 4, + isSeriesFraction: 0, + embeddedSeriesFraction: 0, + requiresLogin: true, + }, '00:1a:79:00:00:07': { name: 'marketing-demo', description: 'Screenshot-safe portal with 35 original poster movies', diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index 940620f48..2f76cd737 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -2,8 +2,10 @@ import http from 'http'; import { join } from 'node:path'; import express, { Request, Response } from 'express'; import cors from 'cors'; -import portalRouter from './app/routes/portal.route.js'; +import portalRouter, { createPortalRouter } from './app/routes/portal.route.js'; import dispatchPortalAction from './app/routes/dispatch.js'; +import { invalidateSession, resetAuthState } from './app/auth-store.js'; +import { resetWatchdogPings } from './app/handlers/get-events.handler.js'; import { resetAll } from './app/data-store.js'; import { SCENARIOS } from './app/scenarios.js'; import { @@ -76,9 +78,13 @@ app.use( }) ); -// Stalker portal.php endpoint (direct portal protocol, Electron mode) +// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check) app.use('/portal.php', portalRouter); +// Canonical Ministra endpoint — enforces the Bearer token and the MAC format +// exactly like the real middleware, so the full-portal auth flow is testable. +app.use('/stalker_portal/server/load.php', createPortalRouter(true)); + /** * CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape: * GET /stalker?url=&macAddress=&action=&... @@ -89,27 +95,53 @@ app.use('/portal.php', portalRouter); * so no app code changes are required. */ app.get('/stalker', (req: Request, res: Response) => { - const { macAddress, url: _url, ...rest } = req.query as Record; + const { + macAddress, + url: portalUrl, + token, + ...rest + } = req.query as Record; const mac = macAddress ?? '00:1a:79:00:00:01'; + // The real backend proxy turns the token query param into a Bearer header + // before calling the portal; mirror that so token handling is exercised. + const headers: Record = { cookie: `mac=${mac}` }; + if (token) { + headers['authorization'] = `Bearer ${token}`; + } + // Build a lightweight synthetic request. We need a fresh object with mutable // `query` and a Cookie header containing the MAC for the handler helpers. const syntheticReq = { query: rest, - headers: { cookie: `mac=${mac}` }, + headers, params: {}, } as unknown as Request; // Capture the JSON response and wrap it in the proxy envelope { payload: ... } let captured: unknown; + let plainTextBody: string | undefined; const syntheticRes = { json: (data: unknown) => { captured = data; }, - } as unknown as Response; + status: () => syntheticRes, + type: () => syntheticRes, + send: (body: string) => { + plainTextBody = body; + }, + } as unknown as Response & { send: (body: string) => void }; - dispatchPortalAction(syntheticReq, syntheticRes); - res.json({ payload: captured }); + dispatchPortalAction(syntheticReq, syntheticRes, { + // The proxied portal URL decides strictness, matching the two direct + // endpoints: a canonical Ministra path enforces the token. + enforceAuth: (portalUrl ?? '').includes('/stalker_portal/'), + }); + + // The portal answers auth failures with a plain-text body; the real backend + // proxy still wraps whatever it got in the { payload } envelope, so the + // renderer sees the raw string there rather than a transport error. + res.json({ payload: plainTextBody ?? captured }); }); // Health check @@ -120,9 +152,26 @@ app.get('/health', (_req: Request, res: Response) => { // Reset all in-memory data (useful between Playwright test runs) app.post('/reset', (_req: Request, res: Response) => { resetAll(); + resetAuthState(); + resetWatchdogPings(); res.json({ status: 'reset', timestamp: new Date().toISOString() }); }); +/** + * Drop a MAC's session so the next portal request fails with + * `Authorization failed.` — lets e2e assert the client re-handshakes and + * retries instead of surfacing an error. + */ +app.post('/invalidate-session', (req: Request, res: Response) => { + const mac = String(req.query['macAddress'] ?? ''); + if (!mac) { + res.status(400).json({ error: 'macAddress query param is required' }); + return; + } + invalidateSession(mac); + res.json({ status: 'invalidated', mac }); +}); + // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- diff --git a/apps/web-e2e/src/stalker-auth.e2e.ts b/apps/web-e2e/src/stalker-auth.e2e.ts new file mode 100644 index 000000000..3b52f4f0d --- /dev/null +++ b/apps/web-e2e/src/stalker-auth.e2e.ts @@ -0,0 +1,206 @@ +import { type APIRequestContext, type Page } from '@playwright/test'; +import { setInputValue } from './e2e-helpers'; +import { expect, test } from './fixtures'; +import { + getRegisteredProviderUrl, + interceptProviderTargetRegistration, +} from './provider-target-route'; + +/** + * Stalker full-portal authentication E2E. + * + * `stalker.e2e.ts` imports the portal through the tolerant `/portal.php` alias, + * which the app classifies as a "simple" portal: no handshake, no token, no + * watchdog. This file covers the other half — the canonical Ministra endpoint + * (`/stalker_portal/server/load.php`), which the mock server guards exactly + * like the real middleware: + * + * - every action except handshake/get_profile/get_localization/do_auth needs + * `Authorization: Bearer ` + * - a token only counts once `get_profile` has adopted it + * - auth failures come back as HTTP 200 with a plain-text body, never a 401 + * + * Tag: @stalker + */ + +const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; +const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; +/** Canonical Ministra path — the app treats this as a full (authenticated) portal. */ +const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; +const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; + +const DEFAULT_MAC = '00:1A:79:00:00:01'; + +async function interceptStalkerRequests(page: Page): Promise { + const providerTargets = await interceptProviderTargetRegistration(page); + + await page.route('**/localhost:3000/stalker**', async (route) => { + const originalUrl = new URL(route.request().url()); + const mockUrl = new URL(BACKEND_PROXY); + const providerUrl = getRegisteredProviderUrl( + originalUrl, + providerTargets + ); + + if (providerUrl) { + mockUrl.searchParams.set('url', providerUrl); + } + + originalUrl.searchParams.forEach((value, key) => { + if (key === 'targetId') { + return; + } + mockUrl.searchParams.set(key, value); + }); + await route.continue({ url: mockUrl.toString() }); + }); +} + +async function resetMockServer(request: APIRequestContext): Promise { + for (let attempt = 0; attempt < 3; attempt += 1) { + const response = await request.post(`${MOCK_SERVER}/reset`); + if (response.ok()) { + return; + } + } + throw new Error('Could not reset the stalker mock server'); +} + +async function addFullStalkerPortal( + page: Page, + options: { name?: string; mac?: string } = {} +): Promise { + const { name = 'Full Stalker Portal', mac = DEFAULT_MAC } = options; + + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + await dialog.getByRole('radio', { name: /Stalker portal/i }).click(); + + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), mac); + + const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); +} + +/** Portal actions the app sent, in order, as seen on the proxy boundary. */ +function recordPortalActions(page: Page): { + actions: string[]; + tokensByAction: Map; +} { + const actions: string[] = []; + const tokensByAction = new Map(); + + 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; + } + actions.push(action); + if (!tokensByAction.has(action)) { + tokensByAction.set(action, url.searchParams.get('token')); + } + }); + + return { actions, tokensByAction }; +} + +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 + // 30s default budget, so give these tests explicit headroom. + test.setTimeout(90_000); + + await resetMockServer(request); + await page.goto('/'); + await interceptStalkerRequests(page); +}); + +test.describe('@stalker full portal authentication', () => { + test('handshakes and authenticates before loading content', async ({ + page, + }) => { + const { actions, tokensByAction } = recordPortalActions(page); + + await addFullStalkerPortal(page); + + // The portal only answers content actions for an adopted token, so + // reaching the VOD categories at all proves the whole chain ran. + await expect(page.locator('.category-item').first()).toBeVisible({ + timeout: 30_000, + }); + + expect(actions).toContain('handshake'); + expect(actions).toContain('get_profile'); + expect(actions.indexOf('handshake')).toBeLessThan( + actions.indexOf('get_profile') + ); + + const contentAction = actions.find((action) => + ['get_categories', 'get_genres'].includes(action) + ); + expect(contentAction).toBeDefined(); + expect(actions.indexOf('get_profile')).toBeLessThan( + actions.indexOf(contentAction as string) + ); + + // Content requests must carry the token; the handshake must not. + expect(tokensByAction.get('handshake')).toBeFalsy(); + expect(tokensByAction.get(contentAction as string)).toBeTruthy(); + }); + + test('never surfaces the portal plain-text auth failure as content', async ({ + page, + }) => { + await addFullStalkerPortal(page); + + // A body of "Authorization failed." must never be rendered — if the + // token pipeline breaks, the app has to fail loudly instead. + await expect(page.locator('body')).not.toContainText( + 'Authorization failed.' + ); + await expect(page.locator('body')).not.toContainText( + 'Unauthorized request.' + ); + }); + + test('re-authenticates after the portal drops the session', async ({ + page, + request, + }) => { + await addFullStalkerPortal(page); + + const { actions } = recordPortalActions(page); + + // Server-side session loss is what a real expired/replaced token looks + // like: the next request gets "Authorization failed." with HTTP 200. + const invalidated = await request.post( + `${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent( + DEFAULT_MAC + )}` + ); + expect(invalidated.ok()).toBe(true); + + // Navigating to another content type forces a fresh portal request. + await page.getByRole('link', { name: /live|itv/i }).click(); + + await expect + .poll(() => actions.filter((a) => a === 'handshake').length, { + timeout: 30_000, + }) + .toBeGreaterThan(0); + + await expect(page.locator('body')).not.toContainText( + 'Authorization failed.' + ); + }); +}); diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 932c610d4..f1106cc51 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -37,7 +37,50 @@ Stalker portals use MAC address as the primary credential. The mock server follo ### In-Memory Only -No files or databases are written. All state (generated content + favorites) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs. +No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs. + +### Two Endpoints With Different Strictness + +The app decides how to talk to a portal from the shape of its URL: a URL +containing `/stalker_portal` is imported as a **full portal** (handshake, +`Authorization: Bearer`, watchdog), anything else as a **simple portal** with no +authentication at all. The mock therefore serves the same action set at two +paths: + +| Path | Router | Behaviour | +|---|---|---| +| `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels | +| `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware | + +Keeping the tolerant path is what lets the pre-existing e2e suite (which imports +`portal.php`) stay meaningful — it covers the simple-portal branch — while the +strict path finally covers the authenticated branch that had no coverage at all. + +The strict behaviours mirror the plaintext Stalker 4.9.35 middleware +(`server/lib/stb.class.php`), the last openly readable ancestor of the encoded +5.x core: + +- **Plain-text auth failures.** `Authorization failed.` / `Unauthorized request.` + are returned with **HTTP 200** and a `text/html` body, because the real server + `exit`s before the JSON envelope is built. A client checking only status codes + sees "success" and renders nothing. The `/stalker` proxy route still wraps the + body in the `{ payload }` envelope, matching what `apps/web-backend` does. +- **A handshake is not a session.** The token only authorizes requests once + `get_profile` has adopted it for that MAC. +- **Idempotent handshake.** Presenting the MAC's current token returns that same + token, which is what allows real clients to persist tokens across restarts. +- **Device-id pinning.** `device_id`/`device_id2` are stored on first non-empty + value; any later change — including reverting to empty — is a permanent + `device conflict` carrying the "Your STB is damaged." block message. This is + the only identity check the stock server actually enforces. +- **`signature`, `metrics`, `prehash` are ignored**, exactly as upstream ignores + them; they exist for portals with a custom `access_filter.php`. +- **MAC format validation.** Non-Infomir MACs (`00:1A:79:XX:XX:XX`) get a bare + `{ status: 1 }` from `get_profile`. + +Session state lives in `src/app/auth-store.ts` and is cleared by `/reset`. +`POST /invalidate-session?macAddress=` drops a single session so tests can +assert the client re-handshakes and retries instead of surfacing an error. ## Data Generation Pipeline @@ -308,6 +351,6 @@ test('browse VOD categories', async ({ page }) => { - **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`. - **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`. -- **Stateful session tokens**: `handshake.handler.ts` generates a token from the MAC — extend this to track token expiry for testing re-auth flows. -- **Error simulation**: Add a special MAC or query param to trigger error responses (e.g. 401, 500) for testing error handling in the Stalker store. +- **Session behaviour**: `auth-store.ts` owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers. +- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Two Endpoints With Different Strictness](#two-endpoints-with-different-strictness). - **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.