diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..3961241ca --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,20 @@ +name: 'IPTVnator CodeQL config' + +# The mock servers are development/E2E fixtures. They bind to loopback by +# default (HOST=0.0.0.0 is an explicit opt-in for pointing a phone/STB at +# them), serve fabricated data, ship in no released artifact, and deliberately +# imitate the quirks of the upstream IPTV protocols — including reading a +# session token from a GET query string, which is what the real Stalker +# backend proxy does and therefore what the app must be tested against. +# +# CodeQL's web-service hygiene rules (missing rate limiting, sensitive data in +# GET requests) assume an internet-facing service and produce only false +# positives here; a rate limiter on a fixture that the E2E suite hammers would +# actively break the tests. paths-ignore is all-or-nothing per path — CodeQL +# has no per-path rule filter — so this deliberately trades away injection/ +# path-traversal coverage for the two fixture apps, which parse no input +# beyond the local test driver. Everything the app itself ships keeps full +# coverage. +paths-ignore: + - apps/stalker-mock-server + - apps/xtream-mock-server diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7f87287f3..003b8b879 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -47,6 +47,9 @@ jobs: uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} + # Excludes the localhost dev/E2E mock servers from analysis; see the + # config file for why. + config-file: ./.github/codeql/codeql-config.yml # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 42abe86d2..6bd9fd898 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -25,9 +25,55 @@ 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. | +| `/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. + +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. Adoption is deliberately stricter than the stock server: + only tokens the mock actually issued (or the already-bound one) are + accepted, so a client with a broken token pipeline fails loudly. +- 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 +86,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` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | ## Configuration @@ -54,7 +101,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 tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal | ## API Coverage @@ -62,8 +110,10 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r | Action | Description | |---|---| -| `handshake` | Returns a mock Bearer token | -| `do_auth` | Returns a mock user profile | +| `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` | Boolean login step: `{js:true}` for non-empty credentials (recorded for the login-required scenario), `{js:false}` otherwise | | `get_categories` | Category list filtered by `type` (itv/vod/series) | | `get_genres` | Genre list (mirrors categories) | | `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons | 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..c33069bcb --- /dev/null +++ b/apps/stalker-mock-server/src/app/auth-store.spec.ts @@ -0,0 +1,194 @@ +import { Request } from 'express'; +import { + adoptToken, + checkRequestAuthorization, + hasCompletedDoAuth, + invalidateSession, + issueHandshakeToken, + markDoAuthCompleted, + 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('refuses to adopt a token the handshake never issued', () => { + issueHandshakeToken(MAC); + + // Stricter than the stock server on purpose: a forged or stale token + // must not become a session, or the mock cannot catch a client whose + // token pipeline is broken. + expect(adoptToken(MAC, 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12')).toBe(false); + expect( + checkRequestAuthorization( + request({ token: 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12' }), + true + ) + ).toBe('Authorization failed.'); + }); + + it('re-adopts the already-bound token', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + + expect(adoptToken(MAC, token)).toBe(true); + }); + + 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('keeps pinned device identity across session invalidation', () => { + pinDeviceIdentity(MAC, 'dev-1', undefined); + invalidateSession(MAC); + + // Losing the token (another device logged in) never unpins device_id + // on a real portal, so a changed identity must still conflict. + expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe( + 'device conflict - device_id mismatch' + ); + }); + + it('tracks do_auth completion per MAC', () => { + expect(hasCompletedDoAuth(MAC)).toBe(false); + + markDoAuthCompleted(MAC); + + expect(hasCompletedDoAuth(MAC)).toBe(true); + expect(hasCompletedDoAuth('00:1A:79:00:00:99')).toBe(false); + }); + + 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..c32486f5f --- /dev/null +++ b/apps/stalker-mock-server/src/app/auth-store.ts @@ -0,0 +1,211 @@ +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; + /** Whether do_auth completed with non-empty credentials for this MAC. */ + didAuth?: boolean; + 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). Deliberately STRICTER than the stock server here: 4.9.35 issues + * handshake tokens statelessly and pins whatever Bearer get_profile presents, + * so a forged token would become a session on a real portal. The mock only + * adopts tokens it actually issued (or the already-bound one), so a client + * with a broken or substituted token pipeline fails loudly in tests instead + * of passing by accident. + */ +export function adoptToken(mac: string, token: string): boolean { + const session = getSession(mac); + if (token !== session.pendingToken && token !== session.accessToken) { + return false; + } + session.accessToken = token; + session.pendingToken = undefined; + return true; +} + +/** Record that do_auth completed with non-empty credentials for this MAC. */ +export function markDoAuthCompleted(mac: string): void { + getSession(mac).didAuth = true; +} + +export function hasCompletedDoAuth(mac: string): boolean { + return getSession(mac).didAuth === true; +} + +export function readBearerToken(req: Request): string | undefined { + const header = req.headers['authorization']; + if (typeof header !== 'string') { + return undefined; + } + // `\s+(.*)` would backtrack polynomially on "bearer" + a long run of + // spaces, so require the token to start with a non-space character. + const match = /^Bearer[ \t]+(\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 tokens so the next request must re-authenticate. Pinned + * device identity survives on purpose: on a real portal a lost token (another + * device logged in) never unpins device_id, so re-authenticating with a + * changed identity must still hit the device-conflict branch. + */ +export function invalidateSession(mac: string): void { + const session = sessions.get(mac.toLowerCase()); + if (!session) { + return; + } + session.accessToken = undefined; + session.pendingToken = undefined; +} + +export function resetAuthState(mac?: string): void { + if (mac) { + sessions.delete(mac.toLowerCase()); + return; + } + sessions.clear(); + tokenCounter = 0; +} diff --git a/apps/stalker-mock-server/src/app/data-store.ts b/apps/stalker-mock-server/src/app/data-store.ts index 52a16a756..4aca56f4f 100644 --- a/apps/stalker-mock-server/src/app/data-store.ts +++ b/apps/stalker-mock-server/src/app/data-store.ts @@ -44,6 +44,17 @@ export function resetFavorites(mac: string): void { favoritesStore.delete(mac.toLowerCase()); } +/** + * Reset everything cached for one MAC. Preferred over `resetAll()` in tests: + * mock state is per-MAC, so a scoped reset cannot disturb a spec that runs + * concurrently against a different MAC. + */ +export function resetMac(mac: string): void { + const key = mac.toLowerCase(); + portalCache.delete(key); + favoritesStore.delete(key); +} + /** Reset all cached data (exposed via /reset endpoint). */ export function resetAll(): void { portalCache.clear(); diff --git a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts new file mode 100644 index 000000000..37d551acd --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts @@ -0,0 +1,207 @@ +import { Request, Response } from 'express'; +import { + checkRequestAuthorization, + resetAuthState, +} from '../auth-store'; +import { handleDoAuth } from './do-auth.handler'; +import { handleGetEvents } from './get-events.handler'; +import { handleGetProfile } from './get-profile.handler'; +import { handleHandshake } from './handshake.handler'; + +/** + * Handler-level coverage for the authentication actions. + * + * The e2e suite drives the app against this server, but the login-required + * flow cannot be reached from the client yet (its `do_auth` path is dormant + * and sends empty credentials), so the scenario is pinned down here rather + * than only asserted in prose. + * + * Handlers are called directly instead of through the dispatcher: the + * dispatcher pulls in every content handler and therefore the faker-based + * data generator, which this project's Jest setup does not transform. + */ + +const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08'; +const PLAIN_MAC = '00:1A:79:00:00:01'; +const BAD_FORMAT_MAC = 'AA:BB:CC:DD:EE:01'; + +type Handler = ( + req: Request, + res: Response, + options?: { enforceMacFormat?: boolean } +) => void; + +function invoke( + handler: Handler, + query: Record, + options: { + mac?: string; + token?: string; + enforceMacFormat?: boolean; + } = {} +): Record { + const headers: Record = { + cookie: `mac=${options.mac ?? PLAIN_MAC}`, + }; + if (options.token) { + headers['authorization'] = `Bearer ${options.token}`; + } + + let body: unknown; + const req = { query, headers, params: {} } as unknown as Request; + const res = { + json: (data: unknown) => { + body = data; + }, + } as unknown as Response; + + handler(req, res, { enforceMacFormat: options.enforceMacFormat ?? true }); + + return (body as { js: Record }).js; +} + +function contentRequest(mac: string, token?: string): Request { + const headers: Record = { cookie: `mac=${mac}` }; + if (token) { + headers['authorization'] = `Bearer ${token}`; + } + return { + headers, + query: { action: 'get_categories' }, + } as unknown as Request; +} + +describe('stalker mock authentication handlers', () => { + beforeEach(() => { + resetAuthState(); + }); + + it('walks the login-required scenario: status 2 -> do_auth -> profile', () => { + const token = invoke(handleHandshake, { action: 'handshake' }, { + mac: LOGIN_REQUIRED_MAC, + })['token'] as string; + expect(token).toMatch(/^[0-9A-F]{32}$/); + + // The app sends auth_second_step=1 on its very first profile request, + // so that parameter alone must not satisfy the scenario. + expect( + invoke( + handleGetProfile, + { action: 'get_profile', auth_second_step: '1' }, + { mac: LOGIN_REQUIRED_MAC, token } + )['status'] + ).toBe(2); + + // Empty credentials are refused, as the operator billing script would. + expect( + invoke( + handleDoAuth, + { action: 'do_auth', login: '', password: '' }, + { mac: LOGIN_REQUIRED_MAC } + ) as unknown + ).toBe(false); + + expect( + invoke( + handleDoAuth, + { action: 'do_auth', login: 'user', password: 'secret' }, + { mac: LOGIN_REQUIRED_MAC } + ) as unknown + ).toBe(true); + + const profile = invoke( + handleGetProfile, + { action: 'get_profile' }, + { mac: LOGIN_REQUIRED_MAC, token } + ); + expect(profile['status']).toBe(0); + expect(profile['watchdog_timeout']).toBe(120); + + // Only now is the token adopted, so protected actions are authorized. + expect( + checkRequestAuthorization( + contentRequest(LOGIN_REQUIRED_MAC, token), + true + ) + ).toBe(null); + }); + + it('rejects a non-Infomir MAC and never adopts its token', () => { + const token = invoke(handleHandshake, { action: 'handshake' }, { + mac: BAD_FORMAT_MAC, + })['token'] as string; + + expect( + invoke( + handleGetProfile, + { action: 'get_profile' }, + { mac: BAD_FORMAT_MAC, token } + ) + ).toEqual({ status: 1 }); + + expect( + checkRequestAuthorization(contentRequest(BAD_FORMAT_MAC, token), true) + ).toBe('Authorization failed.'); + }); + + it('accepts that MAC on the tolerant endpoint, which skips format checks', () => { + const token = invoke(handleHandshake, { action: 'handshake' }, { + mac: BAD_FORMAT_MAC, + })['token'] as string; + + expect( + invoke( + handleGetProfile, + { action: 'get_profile' }, + { mac: BAD_FORMAT_MAC, token, enforceMacFormat: false } + )['status'] + ).toBe(0); + }); + + it('reports a device conflict once an id was pinned', () => { + const token = invoke(handleHandshake, { action: 'handshake' })[ + 'token' + ] as string; + invoke( + handleGetProfile, + { action: 'get_profile', device_id: 'dev-A' }, + { token } + ); + + const conflict = invoke( + handleGetProfile, + { action: 'get_profile', device_id: 'dev-B' }, + { token } + ); + expect(conflict['msg']).toBe('device conflict - device_id mismatch'); + expect(conflict['block_msg']).toContain('Your STB is damaged'); + }); + + it('returns the handshake nonce and re-confirms an adopted token', () => { + const first = invoke(handleHandshake, { action: 'handshake' }); + const token = first['token'] as string; + expect(first['random']).toEqual(expect.any(String)); + expect(first['not_valid']).toBe(0); + + invoke(handleGetProfile, { action: 'get_profile' }, { token }); + + // Presenting the adopted token returns it unchanged — this is what lets + // real clients persist a token across restarts. + const second = invoke(handleHandshake, { + action: 'handshake', + token, + }); + expect(second['token']).toBe(token); + expect(second['not_valid']).toBe(0); + }); + + it('answers the watchdog with an empty event set', () => { + expect( + invoke(handleGetEvents, { + action: 'get_events', + type: 'watchdog', + init: '1', + }) + ).toEqual({ data: { msgs: 0, additional_services_on: '1' } }); + }); +}); 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/do-auth.handler.ts b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts index 51afe2a5b..affa24fd7 100644 --- a/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts @@ -1,25 +1,25 @@ import { Request, Response } from 'express'; +import { markDoAuthCompleted } from '../auth-store.js'; +import { extractMac } from '../request-mac.js'; /** - * Stalker do_auth — returns user profile / account info. + * Stalker do_auth — the login/password step behind `get_profile` status 2. + * + * The real portal never checks a password itself: it proxies the credentials + * to the operator's billing script and answers a bare boolean. The mock + * accepts any non-empty pair, records the completion so `get_profile` can + * stop answering `status: 2` for the login-required scenario, and rejects + * empty credentials the way a billing script would. */ export function handleDoAuth(req: Request, res: Response): void { - res.json({ - js: { - id: '1', - name: 'Mock User', - login: 'mockuser', - password: '', - status: 'active', - tariff_expired_date: '2099-12-31', - phone: '', - ls: '0', - created: new Date().toISOString(), - updated: new Date().toISOString(), - blocked: '0', - acc_enabled: '1', - max_connections: '1', - active_connections: '0', - }, - }); + const login = String(req.query['login'] ?? ''); + const password = String(req.query['password'] ?? ''); + + if (!login || !password) { + res.json({ js: false }); + return; + } + + markDoAuthCompleted(extractMac(req)); + res.json({ js: true }); } 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..32cc4c37a --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-events.handler.ts @@ -0,0 +1,44 @@ +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(mac?: string): void { + if (mac) { + watchdogPings.delete(mac.toLowerCase()); + return; + } + 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..463fb8466 --- /dev/null +++ b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts @@ -0,0 +1,94 @@ +import { Request, Response } from 'express'; +import { + adoptToken, + hasCompletedDoAuth, + 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; + } + + // Gate on the actual do_auth completion, not on auth_second_step: the app + // sends auth_second_step=1 on its very first get_profile, so a parameter + // check would let the login-required flow be bypassed without ever + // exercising status 2 -> do_auth -> profile retry. + if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) { + 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..61b278ec5 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -2,9 +2,11 @@ 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 { resetAll } from './app/data-store.js'; +import { invalidateSession, resetAuthState } from './app/auth-store.js'; +import { resetWatchdogPings } from './app/handlers/get-events.handler.js'; +import { resetAll, resetMac } from './app/data-store.js'; import { SCENARIOS } from './app/scenarios.js'; import { buildRequestOrigin, @@ -12,6 +14,10 @@ import { } from './app/marketing-poster-url.js'; const PORT = parseInt(process.env['PORT'] ?? '3210', 10); +// Loopback by default: the fixture serves fabricated but unauthenticated +// content, so it should not be reachable from other hosts unless a dev +// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it). +const HOST = process.env['HOST'] ?? '127.0.0.1'; const app = express(); const MARKETING_POSTER_DIRECTORY = join( process.cwd(), @@ -76,9 +82,26 @@ 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 endpoints — enforce the Bearer token and the MAC format +// exactly like the real middleware, so the full-portal auth flow is testable. +// Both URL shapes the app classifies as "full" must land on the strict branch. +app.use('/stalker_portal/server/load.php', createPortalRouter(true)); +app.use('/server/load.php', createPortalRouter(true)); + +/** + * Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks + * `/stalker_portal/` or `/server/load.php`; import-time normalization checks + * `/stalker_portal`). Any URL shape the client would authenticate against must + * be enforced by the proxy too, or tests would silently exercise the tolerant + * branch. + */ +function isFullPortalUrlShape(url: string): boolean { + return url.includes('/stalker_portal') || url.includes('/server/load.php'); +} + /** * CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape: * GET /stalker?url=&macAddress=&action=&... @@ -89,27 +112,64 @@ 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 mac = macAddress ?? '00:1a:79:00:00:01'; + const { + macAddress, + url: portalUrl, + ...rest + } = req.query as Record; + // `token` deliberately stays in `rest`: the real backend proxy forwards + // every param except `targetId` to the portal *and* sets the Authorization + // header, and `handshake` reads the presented token from the query. Strip + // it here and the idempotent-handshake path becomes untestable. + const token = rest['token']; + + // A repeated query key arrives as an array, so every value used below must + // be narrowed to a string before it reaches a string API. + const asString = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined; + + const mac = asString(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}` }; + const bearer = asString(token); + if (bearer) { + headers['authorization'] = `Bearer ${bearer}`; + } // 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 direct + // endpoints: every canonical Ministra path shape enforces the token. + enforceAuth: isFullPortalUrlShape(asString(portalUrl) ?? ''), + }); + + // 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 @@ -117,10 +177,56 @@ app.get('/health', (_req: Request, res: Response) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); -// Reset all in-memory data (useful between Playwright test runs) -app.post('/reset', (_req: Request, res: Response) => { - resetAll(); - res.json({ status: 'reset', timestamp: new Date().toISOString() }); +/** + * Reset in-memory state between test runs. + * + * `?macAddress=` scopes the reset to that MAC and is what specs should + * use: mock state is per-MAC, so a scoped reset cannot wipe the session of a + * spec file running concurrently in another Playwright worker. Without the + * parameter everything is cleared, which is only safe when nothing else is + * talking to this server. + */ +app.post('/reset', (req: Request, res: Response) => { + const macParam = req.query['macAddress']; + // Repeated `macAddress` params let a suite clear all of its MACs in one + // request instead of one round trip each. + const macs = (Array.isArray(macParam) ? macParam : [macParam]).filter( + (value): value is string => typeof value === 'string' && value !== '' + ); + + if (macs.length > 0) { + for (const mac of macs) { + resetMac(mac); + resetAuthState(mac); + resetWatchdogPings(mac); + } + } else { + resetAll(); + resetAuthState(); + resetWatchdogPings(); + } + + res.json({ + status: 'reset', + ...(macs.length > 0 ? { macs } : {}), + 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 macParam = req.query['macAddress']; + const mac = typeof macParam === 'string' ? macParam : ''; + if (!mac) { + res.status(400).json({ error: 'macAddress query param is required' }); + return; + } + invalidateSession(mac); + res.json({ status: 'invalidated', mac }); }); // --------------------------------------------------------------------------- @@ -158,7 +264,7 @@ process.on('unhandledRejection', (reason) => { process.stdin.resume(); process.stdin.on('end', () => { /* ignore stdin close */ }); -server.listen(PORT, () => { +server.listen(PORT, HOST, () => { const divider = '─'.repeat(62); console.log(`\n${divider}`); console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`); diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 85dd01470..0a587d1a1 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -1,4 +1,4 @@ -import type { Locator } from '@playwright/test'; +import type { APIRequestContext, Locator } from '@playwright/test'; import { expect } from './fixtures'; export async function setInputValue( @@ -18,3 +18,37 @@ export async function setInputValue( await input.type(value); await expect(input).toHaveValue(value); } + +/** + * POST to a mock-server control endpoint, retrying transport errors. + * + * The mock servers are shared by every spec file and Playwright runs those + * files in parallel workers, so a burst of concurrent control requests can + * occasionally be met with ECONNRESET. That is a transport hiccup, not a + * failure of the test under it. + */ +export async function postWithRetry( + request: APIRequestContext, + url: string, + attempts = 3 +): Promise { + let lastError: unknown; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await request.post(url); + if (response.ok()) { + return; + } + lastError = new Error(`POST ${url} failed: ${response.status()}`); + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => + setTimeout(resolve, 250 * (attempt + 1)) + ); + } + + throw lastError; +} diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index b00a0b5f1..2c1f2f8d9 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -1,5 +1,5 @@ import type { Page } from '@playwright/test'; -import { setInputValue } from './e2e-helpers'; +import { postWithRetry, setInputValue } from './e2e-helpers'; import { expect, test } from './fixtures'; const WEB_BACKEND_URL = 'http://localhost:3333'; @@ -8,7 +8,9 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`; const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`; -const DEFAULT_MAC = '00:1A:79:00:00:01'; +// Dedicated MAC: mock state is per-MAC and stalker.e2e.ts runs in a parallel +// worker, so sharing one would let each suite's reset clear the other's state. +const DEFAULT_MAC = '00:1A:79:5F:00:01'; async function installRuntimeConfig(page: Page): Promise { await page.route('**/assets/app-config.js', async (route) => { @@ -101,8 +103,15 @@ function expectRequestsUseTargetId(requests: string[], path: string): void { } test.beforeEach(async ({ page, request }) => { - await request.post(`${XTREAM_MOCK_SERVER}/reset`); - await request.post(`${STALKER_MOCK_SERVER}/reset`); + await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`); + // Scope the Stalker reset to the MAC this file uses: a global reset would + // wipe the sessions of stalker.e2e.ts running in a parallel worker. + await postWithRetry( + request, + `${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent( + DEFAULT_MAC + )}` + ); await installRuntimeConfig(page); await page.goto('/'); }); diff --git a/apps/web-e2e/src/sources-pwa.helpers.ts b/apps/web-e2e/src/sources-pwa.helpers.ts index 5e8f2d722..5fb476e25 100644 --- a/apps/web-e2e/src/sources-pwa.helpers.ts +++ b/apps/web-e2e/src/sources-pwa.helpers.ts @@ -1,6 +1,6 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'; import { expect } from './fixtures'; -import { setInputValue } from './e2e-helpers'; +import { postWithRetry, setInputValue } from './e2e-helpers'; import { getRegisteredProviderUrl, interceptProviderTargetRegistration, @@ -13,9 +13,11 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; export const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`; export const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`; export const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`; -export const EDITED_MAC = '00:1A:79:00:00:03'; +export const EDITED_MAC = '00:1A:79:5F:00:03'; -const DEFAULT_MAC = '00:1A:79:00:00:01'; +// Dedicated MACs (see EDITED_MAC): never share a MAC with stalker.e2e.ts, +// whose parallel worker would otherwise have its state reset mid-test. +const DEFAULT_MAC = '00:1A:79:5F:00:02'; const M3U_PLAYLIST_URL = `${XTREAM_MOCK_SERVER}/playlist.m3u`; type RuntimeErrors = { @@ -31,11 +33,22 @@ type SourceDialogField = | 'title' | 'username'; +/** + * Reset the fixtures this suite uses. The Stalker reset is scoped to the MACs + * touched here: that mock is shared with stalker.e2e.ts, which runs in a + * parallel Playwright worker, and a global reset would destroy its portal + * sessions mid-test. + */ export async function resetPwaMockServers( request: APIRequestContext ): Promise { - await request.post(`${XTREAM_MOCK_SERVER}/reset`); - await request.post(`${STALKER_MOCK_SERVER}/reset`); + await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`); + for (const mac of [DEFAULT_MAC, EDITED_MAC]) { + await postWithRetry( + request, + `${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent(mac)}` + ); + } } export async function interceptPwaProviderRequests( diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 9129bc0ca..f9c7ee264 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -22,11 +22,35 @@ import { * - 3 seasons × 8 episodes per series item * * Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker" + * + * ISOLATION works on two levels, because one mock-server process is shared by + * every spec file and its state is keyed by MAC: + * + * - ACROSS FILES: `beforeEach` resets only the MACs in `OWNED_MACS`, and the + * sibling files that touch this server (self-hosted, sources-pwa) own a + * disjoint `00:1A:79:5F:*` range, so neither can clear the other's state. + * - WITHIN THIS FILE: tests deliberately share scenario MACs (`default`, + * `minimal`, `embedded-series` — their fixture shapes are what the + * assertions are written against), and every `beforeEach` resets all of + * them. Under the workspace-wide `fullyParallel` preset that would let one + * test wipe another's data or session mid-run, so the file pins itself to a + * single worker. + * + * Giving each test its own MAC instead would mean inventing a scenario per + * test; serializing one file is the cheaper trade. */ +test.describe.configure({ mode: 'serial' }); + const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; const PORTAL_URL = `${MOCK_SERVER}/portal.php`; +/** + * Canonical Ministra path. `PORTAL_URL` above is classified by the app as a + * "simple" portal (no handshake, no token, no watchdog); this shape is the + * authenticated branch, which the mock guards like the real middleware. + */ +const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`; const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; /** Default scenario MAC — balanced catalog, 8 categories, 40 items */ @@ -41,6 +65,20 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05'; /** Legacy pagination MAC — portal without get_all_channels support */ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; +/** + * Dedicated MACs for the full-portal authentication tests. Mock state is keyed + * by MAC, so keeping these distinct from the content scenarios above means an + * auth test can never consume or invalidate a session another test relies on. + * The Infomir OUI matters: the strict endpoint validates the MAC format. + */ +const AUTH_FLOW_MAC = '00:1A:79:AD:00:01'; +const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03'; +/** + * Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for + * it, so no token is ever adopted and content requests fail permanently. + */ +const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01'; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -76,12 +114,32 @@ async function interceptStalkerRequests(page: Page): Promise { }); } +/** Every MAC this file owns; all are cleared in one batched reset request. */ +const OWNED_MACS = [ + DEFAULT_MAC, + MINIMAL_MAC, + EMBEDDED_SERIES_MAC, + LEGACY_PAGINATION_MAC, + AUTH_FLOW_MAC, + AUTH_REAUTH_MAC, + AUTH_REJECTED_MAC, +]; + +/** + * Reset only the MACs this file owns, in a single request. Mock state is + * per-MAC and sibling spec files talk to the same server from parallel + * workers, so a global reset here would wipe their state mid-test — and + * theirs would wipe ours. + */ async function resetMockServer(request: APIRequestContext): Promise { + const query = OWNED_MACS.map( + (mac) => `macAddress=${encodeURIComponent(mac)}` + ).join('&'); let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { try { - const response = await request.post(`${MOCK_SERVER}/reset`); + const response = await request.post(`${MOCK_SERVER}/reset?${query}`); if (response.ok()) { return; } @@ -130,6 +188,89 @@ async function addStalkerPortal( await page.waitForURL(/stalker.*vod/); } +/** + * Add a Stalker portal through the canonical Ministra URL, which the app + * imports as a FULL portal: handshake, Bearer token and watchdog. + */ +async function addFullStalkerPortal( + page: Page, + options: { name?: string; mac: string; expectContent?: boolean } +): Promise { + const { name = 'Full Stalker Portal', mac, expectContent = true } = 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(); + + if (expectContent) { + await page.waitForURL(/stalker.*vod/, { timeout: 30_000 }); + } +} + +/** Portal actions the app sent, in order, with the token each carried. */ +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 }; +} + +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 setup // --------------------------------------------------------------------------- @@ -517,9 +658,12 @@ test('@stalker mock server reset clears cached state', async ({ request }) => { ); expect(before.ok()).toBeTruthy(); - // Reset - const reset = await request.post(`${MOCK_SERVER}/reset`); + // Scoped reset — a global one would clear MACs owned by parallel specs. + const reset = await request.post( + `${MOCK_SERVER}/reset?macAddress=${encodeURIComponent(DEFAULT_MAC)}` + ); expect(reset.ok()).toBeTruthy(); + expect((await reset.json()).macs).toEqual([DEFAULT_MAC]); // Data is regenerated identically (deterministic seed) const after = await request.get( @@ -702,3 +846,193 @@ test('@stalker series — seasons load for a series item', async ({ // Default scenario has 8 episodes per season expect(seasons[0].series.length).toBe(8); }); + +/** + * Full-portal authentication. The tests above import through the tolerant + * `/portal.php` alias (simple portal, no auth); these use the canonical + * Ministra endpoint, which the mock guards 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 + */ +test.describe('@stalker full portal authentication', () => { + // 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. + test.beforeEach(() => { + test.setTimeout(90_000); + }); + + test('handshakes and authenticates before loading content', async ({ + page, + }) => { + const { actions, tokensByAction } = recordPortalActions(page); + + await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC }); + + // 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(); + + // The full-portal workflow must also keep the watchdog alive — an + // authenticated get_events fires immediately (init=1) on activation. + // Without this assertion the suite would stay green if the watchdog + // wiring silently died, because its failures are swallowed by design. + await expect + .poll(() => actions.includes('get_events'), { timeout: 30_000 }) + .toBe(true); + expect(tokensByAction.get('get_events')).toBeTruthy(); + }); + + test('never surfaces the portal plain-text auth failure as content', async ({ + page, + request, + }) => { + const requests = recordPortalRequests(page); + + // A MAC outside the Infomir OUI makes the strict endpoint answer + // get_profile with a bare {status:1}, so no token is ever adopted and + // every content request keeps returning the plain-text failure. Unlike + // an invalidated session this cannot be repaired by the app's retry, + // which is what makes the negative assertion below meaningful instead + // of vacuous. + const failureBody = await ( + await request.get( + `${BACKEND_PROXY}?url=${encodeURIComponent( + FULL_PORTAL_URL + )}&macAddress=${encodeURIComponent( + AUTH_REJECTED_MAC + )}&action=get_categories&type=vod` + ) + ).json(); + expect(failureBody.payload).toBe('Authorization failed.'); + + await addFullStalkerPortal(page, { + mac: AUTH_REJECTED_MAC, + expectContent: false, + }); + + // The app must have actually hit the failing portal... + await expect + .poll( + () => + requests.filter((entry) => + CONTENT_ACTIONS.includes(entry.action) + ).length, + { timeout: 30_000 } + ) + .toBeGreaterThan(0); + + // ...and must never render the raw portal response as content. A + // portal answers auth failures with HTTP 200 + plain text, so an app + // that trusts the status code would happily paint these strings. + 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, + }) => { + const requests = recordPortalRequests(page); + + await addFullStalkerPortal(page, { mac: AUTH_REAUTH_MAC }); + + // `addFullStalkerPortal` only awaits the route change, and on a slow + // runner the first authenticated content request has not necessarily + // gone out by then — poll for it instead of reading the log once. + await expect + .poll( + () => + requests.some( + (entry) => + CONTENT_ACTIONS.includes(entry.action) && + entry.token + ), + { timeout: 30_000 } + ) + .toBe(true); + + // 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. + const invalidated = await request.post( + `${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent( + AUTH_REAUTH_MAC + )}` + ); + 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( + () => + 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.' + ); + }); +}); diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 9e26995b1..0080c5ec0 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -195,7 +195,7 @@ describe('Xtream mock server factory', () => { describe('Xtream mock environment parsing', () => { it('uses safe defaults and enables control only for the exact flag', () => { expect(parseXtreamMockServerEnvironment({})).toEqual({ - host: '0.0.0.0', + host: '127.0.0.1', port: 3211, }); expect( @@ -225,7 +225,7 @@ describe('Xtream mock environment parsing', () => { IPTVNATOR_XTREAM_MOCK_CONTROL: 'true', IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored', }) - ).toEqual({ host: '0.0.0.0', port: 3211 }); + ).toEqual({ host: '127.0.0.1', port: 3211 }); }); it.each([ diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index a5aebc464..576e805cb 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -42,7 +42,10 @@ https://example.channels/path-to-file/4.m3u8 `; const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; const DEFAULT_PORT = 3211; -const DEFAULT_NORMAL_HOST = '0.0.0.0'; +// Loopback by default: the fixtures serve fabricated but unauthenticated +// content, so they should not be reachable from other hosts unless a dev +// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them). +const DEFAULT_NORMAL_HOST = '127.0.0.1'; const DEFAULT_CONTROL_HOST = '127.0.0.1'; const PERFORMANCE_USERNAME = 'performance'; const PERFORMANCE_PASSWORD = 'performance'; diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index 932c610d4..19b4d93df 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -37,7 +37,67 @@ 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 | +| `/server/load.php` | `createPortalRouter(true)` | Strict: the second URL shape `isFullStalkerPortal` recognizes | + +The `/stalker` proxy route applies the same rule through +`isFullPortalUrlShape()` — every URL the client would authenticate against is +enforced, so tests cannot silently fall into the tolerant branch. + +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. Adoption is deliberately + *stricter* than the stock server: 4.9.35 issues handshake tokens statelessly + and pins whatever Bearer `get_profile` presents, so a forged token would + become a session on a real portal — the mock only adopts tokens it actually + issued, so a client with a broken token pipeline fails loudly in tests. +- **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`. + +- **`do_auth` is a boolean login step.** Non-empty credentials answer + `{js:true}` and are recorded; the `login-required` scenario's `get_profile` + keeps answering `status: 2` until that record exists, because the app sends + `auth_second_step=1` on its very first profile request and a parameter check + alone would be trivially bypassed. + +Session state lives in `src/app/auth-store.ts` and is cleared by `/reset`. +`POST /invalidate-session?macAddress=` drops a single MAC's tokens so +tests can assert the client re-handshakes and retries instead of surfacing an +error; pinned device identity survives invalidation, as it does on a real +portal. ## Data Generation Pipeline @@ -308,6 +368,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. diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 8ca8141ba..8d9105db5 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -116,10 +116,12 @@ The control plane is absent by default. It is mounted only when `IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN` and a literal loopback `HOST` (`127.0.0.1` or `::1`). Every `/__control/*` request must carry that exact value in `x-iptvnator-performance-token`, including `OPTIONS` preflight requests. -Configuration is validated before the HTTP listener opens. Normal development -mode preserves the legacy wildcard bind when `HOST` is unset; control mode -instead defaults to `127.0.0.1` and rejects an explicitly configured -non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell +Configuration is validated before the HTTP listener opens. Both modes now +default to `127.0.0.1` when `HOST` is unset — the fixture serves fabricated but +unauthenticated content, so it should not be reachable from other hosts by +accident. Set `HOST=0.0.0.0` explicitly to expose it, which is what you need +when driving the mock from a phone, an STB, a container, or another machine. +Control mode additionally *rejects* an explicitly configured non-loopback host. The Nx serve targets do not pin `PORT`, so an explicit shell value reaches the parser; its no-value default remains `3211`. Use a dedicated port rather than the normal `3211` E2E server: