Files
iptvnator/docs/architecture/stalker-mock-server.md
T
4grayandClaude Fable 5 eeda703849 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 <noreply@anthropic.com>
2026-08-01 12:30:26 +02:00

13 KiB
Raw Blame History

Stalker Mock Server Architecture

This document describes the design decisions, data flow, and extension points of the stalker-mock-server development tool.

Purpose

The mock server enables:

  1. Local development without access to a real Stalker portal
  2. Playwright E2E testing with predictable, deterministic data
  3. Scenario-based testing via predefined MAC addresses that map to specific data shapes
  4. Screenshot-safe marketing capture with committed fictional posters shared with the Xtream mock

Key Design Decisions

Seeded Determinism (Not Per-Request Random)

Per-request random data would break navigation: if category IDs change between calls, content fetched under a category ID won't match the category list. Instead:

  • Data is generated once per MAC address on first request, then cached in memory.
  • @faker-js/faker is seeded with the scenario's seed value before generation: predefined scenario MACs use fixed seeds from scenarios.ts; unknown MACs derive the seed from the MAC via macToSeed().
  • Same MAC → identical data on every server restart.
  • Restart the server to reshuffle all data.

MAC Address as Identity

Stalker portals use MAC address as the primary credential. The mock server follows the same model:

  • Each unique MAC gets its own isolated dataset.
  • Predefined MACs map to specific ScenarioConfig shapes (see src/app/scenarios.ts).
  • Unknown MACs use the sum of their byte values as a seed, producing unique but deterministic data.

In-Memory Only

No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.

Two Endpoints With Different Strictness

The app decides how to talk to a portal from the shape of its URL: a URL containing /stalker_portal is imported as a full portal (handshake, Authorization: Bearer, watchdog), anything else as a simple portal with no authentication at all. The mock therefore serves the same action set at two paths:

Path Router Behaviour
/portal.php createPortalRouter(false) Tolerant: ignores the token and the MAC format, like most reseller panels
/stalker_portal/server/load.php createPortalRouter(true) Strict: enforces both, like the real middleware

Keeping the tolerant path is what lets the pre-existing e2e suite (which imports portal.php) stay meaningful — it covers the simple-portal branch — while the strict path finally covers the authenticated branch that had no coverage at all.

The strict behaviours mirror the plaintext Stalker 4.9.35 middleware (server/lib/stb.class.php), the last openly readable ancestor of the encoded 5.x core:

  • Plain-text auth failures. Authorization failed. / Unauthorized request. are returned with HTTP 200 and a text/html body, because the real server exits before the JSON envelope is built. A client checking only status codes sees "success" and renders nothing. The /stalker proxy route still wraps the body in the { payload } envelope, matching what apps/web-backend does.
  • A handshake is not a session. The token only authorizes requests once get_profile has adopted it for that MAC.
  • Idempotent handshake. Presenting the MAC's current token returns that same token, which is what allows real clients to persist tokens across restarts.
  • Device-id pinning. device_id/device_id2 are stored on first non-empty value; any later change — including reverting to empty — is a permanent device conflict carrying the "Your STB is damaged." block message. This is the only identity check the stock server actually enforces.
  • signature, metrics, prehash are ignored, exactly as upstream ignores them; they exist for portals with a custom access_filter.php.
  • MAC format validation. Non-Infomir MACs (00:1A:79:XX:XX:XX) get a bare { status: 1 } from get_profile.

Session state lives in src/app/auth-store.ts and is cleared by /reset. POST /invalidate-session?macAddress=<mac> drops a single session so tests can assert the client re-handshakes and retries instead of surfacing an error.

Data Generation Pipeline

faker.seed(config.seed)   // scenario seed; unknown MACs: macToSeed(mac)
│
├── generateCategories('itv', N)  → itvCategories[]
│     └── generateChannels()      → channels Map<categoryId, channel[]>
│           └── generateEpg()     → epg Map<channelId, program[]>
│
├── generateCategories('radio', N) → radioCategories[]
│     └── generateRadioStations()  → radio Map<categoryId, station[]>
│
├── generateCategories('vod', N)  → vodCategories[]
│     └── generateVodItems()      → vod Map<categoryId, item[]>
│           ├── normal VOD items
│           ├── is_series=1 items (fraction, Ministra flow)
│           └── embedded series[] items (fraction)
│
├── marketingFixture?             → shared curated VOD catalog
│     ├── @iptvnator/shared/marketing-fixtures
│     ├── vod Map<categoryId, item[]>
│     └── vodOrder[] preserves screenshot catalog order
│
└── generateCategories('series', N) → seriesCategories[]
      └── generateSeriesItems()    → series Map<categoryId, item[]>
            └── generateSeasons()  → seasons Map<seriesItemId, season[]>

Response Shapes

All responses follow the Stalker portal.php envelope:

{ "js": <action-specific payload> }

get_categories

{
  "js": [
    { "id": "2001", "title": "Action", "alias": "action" },
    ...
  ]
}

get_ordered_list (content)

{
  "js": {
    "data": [
      {
        "id": "20001",
        "name": "...",
        "cmd": "ffrt4://vod/20001/index.m3u8",
        "screenshot_uri": "https://picsum.photos/seed/vod-20001/300/200",
        "cover": "https://picsum.photos/seed/vod-cover-20001/300/450",
        "description": "...",
        "actors": "...",
        "director": "...",
        "year": "2019",
        "rating_imdb": "7.3",
        "category_id": "2001",
        "is_series": 0,
        "has_files": 1
      }
    ],
    "total_items": 40,
    "max_page_items": 14,
    "cur_page": 1,
    "total_pages": 3
  }
}

For type=radio, items use the same paginated response envelope as live TV channels, but each item is generated as a radio station with a radio: true marker and an ffrt4://radio/... command.

get_ordered_list (seasons — when movie_id is present)

{
  "js": [
    {
      "id": "30001-s1",
      "name": "Season 1",
      "cmd": "ffrt4://series/30001/season/1",
      "series": ["1", "2", "3", ...],
      "screenshot_uri": "https://picsum.photos/seed/30001-s1/300/200",
      "director": "...",
      "actors": "...",
      "year": "2021",
      "rating_imdb": "8.1"
    }
  ]
}
{
  "js": {
    "cmd": "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8",
    "streamer_id": "1",
    "load": "",
    "error": ""
  }
}

The stream URL is selected from a pool of 4 real public HLS test streams. The choice is deterministic based on the cmd field's character sum, so the same item always returns the same stream.

get_short_epg

{
  "js": {
    "data": [
      {
        "id": "1",
        "name": "Channel Name: Program Title",
        "start": "2026-02-21T10:00:00.000Z",
        "stop": "2026-02-21T12:00:00.000Z",
        "start_timestamp": 1740128400,
        "stop_timestamp": 1740135600,
        "descr": "...",
        "category": "News"
      }
    ]
  }
}

get_short_epg returns the current program and upcoming items from the generated schedule, limited by the requested size.

get_epg_info

{
  "js": {
    "data": {
      "10000": [
        {
          "id": "1",
          "name": "Channel Name: Program Title",
          "start": "2026-02-21T10:00:00.000Z",
          "stop": "2026-02-21T12:00:00.000Z",
          "start_timestamp": 1740128400,
          "stop_timestamp": 1740135600,
          "descr": "...",
          "category": "News"
        }
      ]
    }
  }
}

get_epg_info returns bulk EPG keyed by channel id and filters the generated 7-day schedule from the current UTC day start through now + period.

EPG programs are generated as 2-hour slots across 7 days for each channel, starting at the current UTC day boundary.

Scenarios

Scenarios are defined in src/app/scenarios.ts. Each scenario is a ScenarioConfig:

interface ScenarioConfig {
  name: string;
  description: string;
  seed: number;
  categoryCount: { itv: number; radio: number; vod: number; series: number };
  itemsPerCategory: number;
  seasonsPerSeries: number;
  episodesPerSeason: number;
  isSeriesFraction: number;      // 0–1: fraction of VOD with is_series=1
  embeddedSeriesFraction: number; // 0–1: fraction of VOD with embedded series[]
  supportsGetAllChannels?: boolean; // default true; false mimics legacy portals
                                    // without the ITV get_all_channels action
  marketingFixture?: true;          // replace generated VOD with shared posters
}

The legacy-pagination scenario (00:1A:79:00:00:06) sets supportsGetAllChannels: false: get_all_channels then answers with an error payload so clients fall back to the paginated get_ordered_list crawl. For supporting scenarios, get_all_channels (get-all-channels.handler.ts, type=itv only) returns the complete ITV channel list in one { js: { data, total_items } } response, excluding channels from censored (adult) genres.

The marketing-demo scenario (00:1A:79:00:00:07) replaces faker-generated VOD with the 35-movie provider-neutral showcase catalog from @iptvnator/shared/marketing-fixtures. Its newest 20 movies are returned first for the wildcard VOD listing. Fixtures keep deployment-neutral poster paths, then the JSON middleware resolves them against the request origin (including forwarded host/protocol) as <portal-origin>/assets/marketing/poster/<slug>.png. main.ts serves the committed PNG directory directly, so the Xtream server does not need to run.

Adding a New Scenario

  1. Add an entry to the SCENARIOS map in src/app/scenarios.ts.
  2. Use any unique MAC address as the key (lowercase, colon-separated).
  3. Document it in README.md and this file.

Favorites

Favorites are stored in a Map<mac, Set<itemId>> in src/app/data-store.ts. They persist for the lifetime of the server process and are shared across all requests for the same MAC.

Call POST /reset to clear all favorites (and regenerated data) between test runs.

Playwright Integration

apps/web-e2e/playwright.config.ts registers the mock server as a second webServer entry:

webServer: [
  {
    command: 'pnpm nx run web:serve',
    url: 'http://localhost:4200',
    reuseExistingServer: !process.env['CI'],
  },
  {
    command: 'pnpm nx run stalker-mock-server:serve',
    url: 'http://localhost:3210/health',
    reuseExistingServer: !process.env['CI'],
  },
]

Playwright waits for both servers to be healthy before starting tests. If either is already running (e.g. in local dev), it reuses the existing instance.

Test Isolation

Each stalker e2e test calls POST http://localhost:3210/reset in beforeEach to clear in-memory state. This ensures tests don't bleed favorites or other mutable state into each other.

resetAll() clears both the generated-content cache and in-memory favorites (data-store.ts). Because generation is seed-deterministic, the next request regenerates identical content, so the observable data does not change across resets.

import { test, expect } from '@playwright/test';

const MOCK_URL = 'http://localhost:3210/portal.php';
const MOCK_MAC = '00:1A:79:00:00:01'; // default scenario

test.beforeEach(async ({ request }) => {
  await request.post('http://localhost:3210/reset');
});

test('browse VOD categories', async ({ page }) => {
  // Add portal via UI or programmatically via IndexedDB
  // Navigate to portal
  // Assert category list matches expected count (8 for default scenario)
});

Extension Points

  • 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.
  • 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.
  • Slow responses: Add a MOCK_DELAY_MS env var and apply it in middleware for testing loading states.