From 3dbfefa3d812c6bf327f84d4de4ee8ec3158bafb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 1 Aug 2026 21:52:09 +0200 Subject: [PATCH] test(stalker): enforce portal auth in the mock and cover the full-portal flow (#1324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(stalker): enforce portal auth in the mock and cover the full-portal flow 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 * fix(mock): address CodeQL findings in the new portal auth code Two genuine defects in the code this PR added: - readBearerToken's /Bearer\s+(.*)$/ backtracks polynomially on "bearer" followed by a long run of spaces; require the token to start with a non-space character instead - the /stalker proxy route read query params as strings without narrowing, so a repeated key (?url=a&url=b) arrives as an array and String.prototype.includes silently changes meaning The remaining three alerts (missing rate limiting x2, sensitive data in a GET query) are web-service hygiene rules aimed at internet-facing services. The mock servers bind to localhost, serve fabricated data, ship in no artifact, and deliberately mirror the real backend proxy's token-in-query contract; a rate limiter would break the E2E suite that hammers them. Exclude only those two apps from analysis via a documented CodeQL config; every shipped path keeps full coverage. Co-Authored-By: Claude Fable 5 * fix(mock): tighten portal-auth fidelity per review Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid: - adoptToken only accepts tokens the mock actually issued (or the already-bound one). The stock server pins any presented Bearer — handshake is stateless there — but a fixture that does the same cannot catch a client with a broken token pipeline; documented as a deliberate strictness divergence. - /invalidate-session clears tokens but keeps pinned device identity: losing a token never unpins device_id on a real portal, so changed identity after re-auth must still hit the device-conflict branch. - The login-required scenario gates on actual do_auth completion instead of auth_second_step: the app sends auth_second_step=1 on its very first get_profile, so the parameter check was trivially bypassed and the status-2 flow never exercised. do_auth is now the faithful boolean step (non-empty credentials -> {js:true}, recorded; empty -> {js:false}). - /server/load.php — the second URL shape isFullStalkerPortal recognizes — is now served and enforced, directly and through the /stalker proxy predicate, so full-portal tests cannot silently fall into the tolerant branch. Co-Authored-By: Claude Fable 5 * test(stalker): prove content actually reloads after re-authentication Review follow-up on #1324 (Codex P2, valid — the "passes for the wrong reason" class): the re-auth test only polled for a fresh handshake and a negative body-text assertion, both of which pass even if the original content request is never replayed or stays unauthorized. Capture the content token from the initial import, then assert a post-invalidation CONTENT request goes out under a DIFFERENT token and that the ITV categories actually render — the mock only answers content for an adopted token, so this proves the new token round-tripped through get_profile. Verified against a live mock that the token genuinely rotates (old token -> "Authorization failed.", new token -> content). Also documents the second Codex P2: the mock is deliberately strict on /server/load.php (a real portal enforces auth there); the import dialog vs session predicate divergence is a separate app bug the strict endpoint will let a later PR cover. Co-Authored-By: Claude Fable 5 * test(stalker): serialize the portal specs and bind mocks to loopback Review follow-up on #1324 (Codex, 4xP2): - Parallel-reset race: under the workspace `fullyParallel` preset the new auth file ran concurrently with stalker.e2e.ts against one shared mock process, and each `beforeEach` wiped global state (sessions, favorites) mid-assertion in the other. Reproduced locally: both suites green in isolation, two failures when run together. Merged the auth tests into stalker.e2e.ts and pinned the file to `mode: 'serial'`, which also removes the pre-existing race between that file's own tests. 19/19 green across three consecutive runs. - Watchdog was recorded but never asserted, so the suite would stay green if the full-portal workflow stopped pinging or dropped its token — `sendWatchdogPing` swallows failures. Now polls for an authenticated `get_events`. - Both mock servers listened on every interface (stalker: `listen(PORT)` with no host; xtream: an explicit `0.0.0.0` default), which made the CodeQL exclusion's "binds to localhost" rationale untrue. Both now default to `127.0.0.1` with a `HOST` opt-in, and the config comment states plainly what the directory-wide ignore trades away. - Documented that the login-required scenario is HTTP-level only for now: the client's `do_auth` path is dormant and sends empty credentials, so the fixture is waiting on that client-side work rather than claiming end-to-end coverage. Co-Authored-By: Claude Fable 5 * test(stalker): force a real auth failure before asserting it stays hidden Review follow-up on #1324 (Codex 2xP2 + 1xP3), all valid: - The "never surfaces the plain-text auth failure" test only performed a successful import, so its negative body assertions were vacuous. It now imports with a MAC outside the Infomir OUI: the strict endpoint answers get_profile with a bare {status:1}, no token is ever adopted, and every content request keeps returning "Authorization failed." Unlike an invalidated session this cannot be repaired by the client retry, so the failure is genuinely observed (asserted directly against the proxy) and only then checked for not leaking into the UI. - docs/architecture/xtream-mock-server.md still documented the wildcard bind that 4b31f7167 replaced with a loopback default; it now states the new default and the HOST=0.0.0.0 opt-in needed for phone/STB/container. - Removed a dangling "Known app-side gap: the" fragment left in the stalker mock README. Co-Authored-By: Claude Fable 5 * fix(mock): scope /reset by MAC so parallel specs stop wiping each other The re-authentication test passed locally but failed all three CI attempts: no request carried a token, because self-hosted.e2e.ts issues a GLOBAL `POST /reset` against the same mock from a parallel Playwright worker, destroying the session mid-import. Running only stalker.e2e.ts locally never triggered it. Serializing within one file (4b31f7167) could not fix this — the interference is between files. Mock state is per-MAC, so `/reset` now accepts `?macAddress=` and clears only that MAC's data, favorites, session and watchdog counters; the unscoped form is kept for callers that own the whole server. Both spec files now reset only the MACs they own, so no worker can disturb another. Verified: a scoped reset of one MAC leaves another MAC's session intact (and its own dies), and stalker.e2e.ts + self-hosted.e2e.ts run together 23/23 green — the combination that reproduced the CI failure. Co-Authored-By: Claude Fable 5 * test(e2e): scope the last global Stalker reset in sources-pwa helpers Completes 3a93fef0f: that commit scoped self-hosted.e2e.ts but missed resetPwaMockServers, which still wiped the whole Stalker fixture from a third spec file. Scope it to the two MACs this suite owns. The auth tests use dedicated MACs no sibling touches, so portal sessions — the fragile state — can no longer be cleared by a parallel worker. Content MACs still overlap between files, which is harmless: that data is regenerated deterministically from the same seed. Verified with the full interfering set running together: stalker.e2e.ts + self-hosted.e2e.ts + sources-pwa.e2e.ts, 26/26 green. Co-Authored-By: Claude Fable 5 * test(stalker): await the first authenticated content request The re-auth test kept failing on CI (3/3 attempts) with an undefined token while passing locally. My earlier diagnosis — a sibling spec's global /reset — was wrong: the failure survived the scoped-reset fix. Real cause is a race in the test itself. `addFullStalkerPortal` only awaits the route change, so on a slower runner the first authenticated content request has not been recorded yet when the token is read; the sibling test that passes happens to await `.category-item` first. Poll for a content request carrying a token before capturing it. The scoped-reset work stands on its own merits (cross-file resets were a real hazard), it just was not what broke this test. Co-Authored-By: Claude Fable 5 * test(stalker): drop serial mode, batch resets, cover the auth handlers Review round on a44f8135f plus a stability regression I introduced. Codex, both valid: - The proxy route stripped `token` from the forwarded query, so `handshake` never saw a presented token and the idempotent-handshake behaviour I documented was unreachable through the PWA path. The real backend forwards every param except `targetId` *and* sets the header; match it. Verified through the proxy: re-handshake now returns the same token with not_valid 0. - The login-required scenario had no committed test, so the README claim was unbacked. Added auth-handlers.spec.ts (status 2 -> do_auth -> profile, MAC-format rejection, device conflict, idempotent handshake, watchdog). Handlers are called directly because the dispatcher pulls in the faker-based generator, which this project's Jest cannot transform. - Sibling suites now own disjoint MACs (00:1A:79:5F:*) instead of sharing the Stalker suite's, so no reset can reach another suite's state at all. Stability: a baseline run of master passed 23/23 first try while this branch failed a different test each run, so the flakiness was mine. `mode: 'serial'` was a stand-in for isolation that per-MAC scoping now provides properly, and it amplified every flake by aborting the rest of the file; removed. `beforeEach` also fired seven sequential resets — the endpoint now accepts repeated `macAddress` params so a suite clears all of its MACs in one request. Added a retrying POST helper after an ECONNRESET on a control call. Verified: three consecutive runs of stalker + self-hosted + sources-pwa, 26/26 each; 28 mock unit tests; lint clean. Co-Authored-By: Claude Fable 5 * test(stalker): restore serial mode for the shared-scenario file Review follow-up (Codex P2), valid: the previous commit removed `mode: 'serial'` while every `beforeEach` still resets all OWNED_MACS, so under fullyParallel one test in this file could clear another's data or session mid-run. Of the two suggested fixes, serialize rather than give each test its own MAC: the tests here are written against scenario fixtures (default, minimal, embedded-series) whose shapes the assertions encode, so a MAC per test would mean inventing a scenario per test and rewriting pre-existing assertions. Cross-file isolation stays with the disjoint sibling MAC range, which is what serial was wrongly standing in for before. The header now states both levels explicitly so the next reader does not undo one of them. Verified: three consecutive runs of stalker + self-hosted + sources-pwa, 26/26 each. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/codeql/codeql-config.yml | 20 ++ .github/workflows/codeql-analysis.yml | 3 + apps/stalker-mock-server/README.md | 58 ++- .../src/app/auth-store.spec.ts | 194 ++++++++++ .../stalker-mock-server/src/app/auth-store.ts | 211 +++++++++++ .../stalker-mock-server/src/app/data-store.ts | 11 + .../src/app/handlers/auth-handlers.spec.ts | 207 +++++++++++ .../src/app/handlers/create-link.handler.ts | 2 +- .../src/app/handlers/do-auth.handler.ts | 38 +- .../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 | 44 +++ .../src/app/handlers/get-genres.handler.ts | 2 +- .../app/handlers/get-ordered-list.handler.ts | 2 +- .../src/app/handlers/get-profile.handler.ts | 94 +++++ .../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 | 134 ++++++- apps/web-e2e/src/e2e-helpers.ts | 36 +- apps/web-e2e/src/self-hosted.e2e.ts | 17 +- apps/web-e2e/src/sources-pwa.helpers.ts | 23 +- apps/web-e2e/src/stalker.e2e.ts | 340 +++++++++++++++++- .../xtream-mock-server/src/app/server.spec.ts | 4 +- apps/xtream-mock-server/src/app/server.ts | 5 +- docs/architecture/stalker-mock-server.md | 66 +++- docs/architecture/xtream-mock-server.md | 10 +- 33 files changed, 1564 insertions(+), 91 deletions(-) create mode 100644 .github/codeql/codeql-config.yml 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/auth-handlers.spec.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 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: