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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-01 12:30:26 +02:00
1 parent 9f4e11d6de
commit eeda703849
22 files changed
+905 -44

No files matched your search

+41 -3
View File
@@ -25,9 +25,43 @@ nx run-many --targets=serve --projects=stalker-mock-server,web
Then in IPTVnator, add a new Stalker portal:
- **Portal URL**: `http://localhost:3210/portal.php`
- **Portal URL**: `http://localhost:3210/portal.php` (tolerant panel-style endpoint)
or `http://localhost:3210/stalker_portal/server/load.php` (canonical Ministra
endpoint — see [Two endpoints](#two-endpoints-tolerant-vs-strict) below)
- **MAC Address**: one of the predefined scenarios below (or any MAC for auto-generated data)
## Two endpoints: tolerant vs strict
The same actions are served at two paths with deliberately different strictness,
because the app treats them differently: a URL containing `/stalker_portal` is
imported as a **full portal** (handshake + token + watchdog), anything else as a
**simple portal** (no authentication at all).
| Path | Behaviour |
|---|---|
| `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. |
| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. |
The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can
actually get wrong:
- Every action except `handshake`, `get_profile`, `get_localization` and
`do_auth` requires `Authorization: Bearer <token>`.
- A token only counts once `get_profile` has adopted it — a handshake alone is
not a session.
- Auth failures come back as **HTTP 200 with a plain-text body**
(`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients
that only check status codes will silently render nothing.
- The handshake is **idempotent**: presenting the MAC's current token returns
that same token instead of rotating it.
- `device_id`/`device_id2` are pinned to the MAC on first non-empty value; any
later change — including sending them empty again — is a permanent
`device conflict` with the "Your STB is damaged." block message.
- `signature`, `metrics` and `prehash` are accepted and ignored, exactly as the
stock server does.
- The MAC must match the Infomir OUI format (`00:1A:79:XX:XX:XX`) or
`get_profile` answers with a bare `{ status: 1 }`.
## Predefined Scenario MAC Addresses
| MAC Address | Scenario | Description |
@@ -40,6 +74,7 @@ Then in IPTVnator, add a new Stalker portal:
| `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow |
| `00:1A:79:00:00:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` and retries with `auth_second_step=1` |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
## Configuration
@@ -54,7 +89,8 @@ Then in IPTVnator, add a new Stalker portal:
| Endpoint | Method | Description |
|---|---|---|
| `/health` | `GET` | Health check — returns `{ status: "ok" }` |
| `/reset` | `POST` | Clear all in-memory data and favorites (useful between test runs) |
| `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) |
| `/invalidate-session?macAddress=<mac>` | `POST` | Drop that MAC's session so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries |
## API Coverage
@@ -62,7 +98,9 @@ All endpoints are served at `GET /portal.php?action=<action>&...` matching the r
| Action | Description |
|---|---|
| `handshake` | Returns a mock Bearer token |
| `handshake` | Issues the access token (idempotent) plus the 5.x `random` nonce and `not_valid` flag |
| `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format |
| `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) |
| `do_auth` | Returns a mock user profile |
| `get_categories` | Category list filtered by `type` (itv/vod/series) |
| `get_genres` | Genre list (mirrors categories) |
@@ -0,0 +1,150 @@
import { Request } from 'express';
import {
adoptToken,
checkRequestAuthorization,
invalidateSession,
issueHandshakeToken,
pinDeviceIdentity,
readBearerToken,
resetAuthState,
} from './auth-store';
const MAC = '00:1A:79:AA:BB:CC';
function request(options: {
action?: string;
mac?: string | null;
token?: string;
}): Request {
const headers: Record<string, string> = {};
if (options.mac !== null) {
headers['cookie'] = `mac=${options.mac ?? MAC}; stb_lang=en`;
}
if (options.token) {
headers['authorization'] = `Bearer ${options.token}`;
}
return {
headers,
query: { action: options.action ?? 'get_categories' },
} as unknown as Request;
}
describe('stalker mock auth store', () => {
beforeEach(() => {
resetAuthState();
});
it('issues a 32-char uppercase hex token', () => {
const { token } = issueHandshakeToken(MAC);
expect(token).toMatch(/^[0-9A-F]{32}$/);
});
it('returns the stored token unchanged when it is presented again', () => {
const { token } = issueHandshakeToken(MAC);
adoptToken(MAC, token);
const second = issueHandshakeToken(MAC, token);
expect(second.token).toBe(token);
expect(second.notValid).toBe(false);
});
it('flags not_valid when a stale token is presented', () => {
expect(issueHandshakeToken(MAC, 'STALE').notValid).toBe(true);
});
it('rejects a request without a mac cookie', () => {
expect(checkRequestAuthorization(request({ mac: null }), true)).toBe(
'Unauthorized request.'
);
});
it('rejects an unauthenticated action when enforcement is on', () => {
expect(checkRequestAuthorization(request({}), true)).toBe(
'Authorization failed.'
);
});
it('allows the handshake/profile/do_auth actions without a token', () => {
for (const action of ['handshake', 'get_profile', 'do_auth']) {
expect(checkRequestAuthorization(request({ action }), true)).toBe(
null
);
}
});
it('allows any action once get_profile adopted the token', () => {
const { token } = issueHandshakeToken(MAC);
adoptToken(MAC, token);
expect(checkRequestAuthorization(request({ token }), true)).toBe(null);
});
it('rejects a token that was never adopted by get_profile', () => {
const { token } = issueHandshakeToken(MAC);
expect(checkRequestAuthorization(request({ token }), true)).toBe(
'Authorization failed.'
);
});
it('fails after the session is invalidated so clients must re-authenticate', () => {
const { token } = issueHandshakeToken(MAC);
adoptToken(MAC, token);
invalidateSession(MAC);
expect(checkRequestAuthorization(request({ token }), true)).toBe(
'Authorization failed.'
);
});
it('never enforces the token when enforcement is off', () => {
expect(checkRequestAuthorization(request({}), false)).toBe(null);
});
it('reads the bearer token case-insensitively', () => {
const req = {
headers: { authorization: 'bearer ABC123 ' },
} as unknown as Request;
expect(readBearerToken(req)).toBe('ABC123');
});
describe('device identity pinning', () => {
it('stores the first non-empty values', () => {
expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null);
expect(pinDeviceIdentity(MAC, 'dev-1', 'dev-2')).toBe(null);
});
it('reports a conflict when a pinned device_id changes', () => {
pinDeviceIdentity(MAC, 'dev-1', undefined);
expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe(
'device conflict - device_id mismatch'
);
});
it('reports a conflict when a pinned value is later sent empty', () => {
pinDeviceIdentity(MAC, 'dev-1', undefined);
// This is the real lockout: a client that stops sending the id it
// once pinned is told its STB is damaged.
expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe(
'device conflict - device_id mismatch'
);
});
it('reports the device_id2 conflict separately', () => {
pinDeviceIdentity(MAC, undefined, 'dev-2');
expect(pinDeviceIdentity(MAC, undefined, 'changed')).toBe(
'device conflict - MAC address mismatch'
);
});
it('accepts identity omitted entirely on a fresh MAC', () => {
expect(pinDeviceIdentity(MAC, undefined, undefined)).toBe(null);
});
});
});
@@ -0,0 +1,172 @@
import { Request } from 'express';
import { extractMac } from './request-mac.js';
/**
* Session/identity state of the mocked portal.
*
* Modelled on the plaintext Stalker 4.9.35 middleware (`server/lib/stb.class.php`),
* which is the last openly readable ancestor of the encoded 5.x core:
*
* - the handshake token is random and **idempotent** — re-presenting a valid
* token returns the same one instead of rotating it
* - a token only becomes a session once `get_profile` stores it for the MAC
* - `device_id`/`device_id2` are pinned on first non-empty value and a later
* mismatch is a hard, permanent conflict
* - `signature`, `metrics` and `prehash` are accepted but never verified
*/
interface PortalSession {
/** Token handed out by the last handshake, before get_profile adopts it. */
pendingToken?: string;
/** Token stored for the MAC — what authorizes non-auth actions. */
accessToken?: string;
deviceId?: string;
deviceId2?: string;
}
const sessions = new Map<string, PortalSession>();
/** Actions the portal answers without a valid Bearer token. */
const UNAUTHENTICATED_ACTIONS = new Set([
'handshake',
'get_profile',
'get_localization',
'do_auth',
]);
export type AuthFailure =
| 'Authorization failed.'
| 'Access denied.'
| 'Unauthorized request.';
function getSession(mac: string): PortalSession {
const key = mac.toLowerCase();
if (!sessions.has(key)) {
sessions.set(key, {});
}
return sessions.get(key) as PortalSession;
}
/** 32 uppercase hex chars, like `strtoupper(md5(microtime + uniqid))`. */
function generateToken(mac: string): string {
const entropy = `${mac}:${sessions.size}:${tokenCounter++}`;
let hex = '';
for (let index = 0; index < 32; index += 1) {
const code = entropy.charCodeAt(index % entropy.length) + index * 31;
hex += (code % 16).toString(16).toUpperCase();
}
return hex;
}
let tokenCounter = 0;
/**
* Issue (or re-confirm) a handshake token. Presenting the MAC's current access
* token returns it unchanged, which is what lets real clients persist tokens
* across restarts.
*/
export function issueHandshakeToken(mac: string, presentedToken?: string): {
token: string;
notValid: boolean;
} {
const session = getSession(mac);
if (presentedToken && presentedToken === session.accessToken) {
return { token: session.accessToken, notValid: false };
}
const token = generateToken(mac);
session.pendingToken = token;
// The real server only sets not_valid when an auth_url is configured and a
// stale token was presented; mirroring the flag lets clients exercise it.
return { token, notValid: Boolean(presentedToken) };
}
/** Adopt the handshake token as the MAC's session token (what get_profile does). */
export function adoptToken(mac: string, token: string): void {
const session = getSession(mac);
session.accessToken = token;
session.pendingToken = undefined;
}
export function readBearerToken(req: Request): string | undefined {
const header = req.headers['authorization'];
if (typeof header !== 'string') {
return undefined;
}
const match = /Bearer\s+(.*)$/i.exec(header.trim());
return match?.[1]?.trim() || undefined;
}
/**
* Pin device identity to the MAC. Returns a conflict message when a previously
* stored value is contradicted — including the "blank after pinned" case that
* permanently locks real users out.
*/
export function pinDeviceIdentity(
mac: string,
deviceId: string | undefined,
deviceId2: string | undefined
): string | null {
const session = getSession(mac);
for (const [field, incoming] of [
['deviceId', deviceId],
['deviceId2', deviceId2],
] as const) {
const stored = session[field];
if (!stored) {
if (incoming) {
session[field] = incoming;
}
continue;
}
if (stored !== (incoming ?? '')) {
return field === 'deviceId'
? 'device conflict - device_id mismatch'
: 'device conflict - MAC address mismatch';
}
}
return null;
}
/**
* Decide whether a request may proceed. `enforce` is false for the reseller-style
* `/portal.php` endpoint, which commonly ignores tokens, and true for the
* canonical `/stalker_portal/server/load.php` endpoint.
*/
export function checkRequestAuthorization(
req: Request,
enforce: boolean
): AuthFailure | null {
const action = String(req.query['action'] ?? '');
const hasMacCookie = (req.headers['cookie'] ?? '').includes('mac=');
if (!hasMacCookie) {
return 'Unauthorized request.';
}
if (!enforce || UNAUTHENTICATED_ACTIONS.has(action)) {
return null;
}
const session = getSession(extractMac(req));
const presented = readBearerToken(req);
if (!session.accessToken || presented !== session.accessToken) {
return 'Authorization failed.';
}
return null;
}
/** Drop the MAC's session so the next request must re-authenticate. */
export function invalidateSession(mac: string): void {
sessions.delete(mac.toLowerCase());
}
export function resetAuthState(): void {
sessions.clear();
tokenCounter = 0;
}
@@ -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.
@@ -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,
@@ -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
@@ -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'
);
}
@@ -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.
@@ -0,0 +1,40 @@
import { Request, Response } from 'express';
import { extractMac } from '../request-mac.js';
/** Per-MAC watchdog bookkeeping so tests can assert the client pings at all. */
const watchdogPings = new Map<string, { count: number; lastInit: string }>();
/**
* Stalker watchdog `get_events`. The real portal only uses it for presence
* reporting and event delivery — it never affects authorization — so the mock
* records the ping and returns an empty event set.
*/
export function handleGetEvents(req: Request, res: Response): void {
const mac = extractMac(req).toLowerCase();
const init = String(req.query['init'] ?? '0');
const previous = watchdogPings.get(mac);
watchdogPings.set(mac, {
count: (previous?.count ?? 0) + 1,
lastInit: init,
});
res.json({
js: {
data: {
msgs: 0,
additional_services_on: '1',
},
},
});
}
export function getWatchdogPings(
mac: string
): { count: number; lastInit: string } | undefined {
return watchdogPings.get(mac.toLowerCase());
}
export function resetWatchdogPings(): void {
watchdogPings.clear();
}
@@ -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.
@@ -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,
@@ -0,0 +1,85 @@
import { Request, Response } from 'express';
import { adoptToken, pinDeviceIdentity, readBearerToken } from '../auth-store.js';
import { getScenario } from '../scenarios.js';
import { extractMac } from '../request-mac.js';
/** `00:1A:79` is the Infomir OUI the stock portal requires by default. */
const INFOMIR_MAC = /^00:1A:79:[0-9A-F]{2}:[0-9A-F]{2}:[0-9A-F]{2}$/;
/**
* Stalker get_profile — the step that turns a handshake token into a session.
*
* Reproduces the outcomes of the 4.9.35 middleware: a bare `{status:1}` for a
* malformed MAC, `{status:1, msg, block_msg}` for a device conflict,
* `{status:2}` when the portal wants login/password, and otherwise the profile
* itself. `signature`, `metrics` and `prehash` are accepted and ignored, which
* is exactly what the real server does.
*/
export function handleGetProfile(
req: Request,
res: Response,
options: { enforceMacFormat?: boolean } = {}
): void {
const mac = extractMac(req);
const scenario = getScenario(mac);
if (options.enforceMacFormat && !INFOMIR_MAC.test(mac.toUpperCase())) {
res.json({ js: { status: 1 } });
return;
}
if (scenario.requiresLogin && req.query['auth_second_step'] !== '1') {
res.json({
js: {
status: 2,
template: 'auth',
info: 'Login required',
},
});
return;
}
const conflict = pinDeviceIdentity(
mac,
String(req.query['device_id'] ?? '') || undefined,
String(req.query['device_id2'] ?? '') || undefined
);
if (conflict) {
res.json({
js: {
status: 1,
msg: conflict,
block_msg: 'Your STB is damaged.<br/> 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',
},
});
}
@@ -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.
@@ -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.
@@ -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',
@@ -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'
);
}
@@ -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;
@@ -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=<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);
@@ -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<string, ScenarioConfig> = {
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',
+56 -7
View File
@@ -2,8 +2,10 @@ import http from 'http';
import { join } from 'node:path';
import express, { Request, Response } from 'express';
import cors from 'cors';
import portalRouter from './app/routes/portal.route.js';
import portalRouter, { createPortalRouter } from './app/routes/portal.route.js';
import dispatchPortalAction from './app/routes/dispatch.js';
import { invalidateSession, resetAuthState } from './app/auth-store.js';
import { resetWatchdogPings } from './app/handlers/get-events.handler.js';
import { resetAll } from './app/data-store.js';
import { SCENARIOS } from './app/scenarios.js';
import {
@@ -76,9 +78,13 @@ app.use(
})
);
// Stalker portal.php endpoint (direct portal protocol, Electron mode)
// Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check)
app.use('/portal.php', portalRouter);
// Canonical Ministra endpoint — enforces the Bearer token and the MAC format
// exactly like the real middleware, so the full-portal auth flow is testable.
app.use('/stalker_portal/server/load.php', createPortalRouter(true));
/**
* CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape:
* GET /stalker?url=<portal_url>&macAddress=<mac>&action=<action>&...
@@ -89,27 +95,53 @@ app.use('/portal.php', portalRouter);
* so no app code changes are required.
*/
app.get('/stalker', (req: Request, res: Response) => {
const { macAddress, url: _url, ...rest } = req.query as Record<string, string>;
const {
macAddress,
url: portalUrl,
token,
...rest
} = req.query as Record<string, string>;
const mac = macAddress ?? '00:1a:79:00:00:01';
// The real backend proxy turns the token query param into a Bearer header
// before calling the portal; mirror that so token handling is exercised.
const headers: Record<string, string> = { cookie: `mac=${mac}` };
if (token) {
headers['authorization'] = `Bearer ${token}`;
}
// Build a lightweight synthetic request. We need a fresh object with mutable
// `query` and a Cookie header containing the MAC for the handler helpers.
const syntheticReq = {
query: rest,
headers: { cookie: `mac=${mac}` },
headers,
params: {},
} as unknown as Request;
// Capture the JSON response and wrap it in the proxy envelope { payload: ... }
let captured: unknown;
let plainTextBody: string | undefined;
const syntheticRes = {
json: (data: unknown) => {
captured = data;
},
} as unknown as Response;
status: () => syntheticRes,
type: () => syntheticRes,
send: (body: string) => {
plainTextBody = body;
},
} as unknown as Response & { send: (body: string) => void };
dispatchPortalAction(syntheticReq, syntheticRes);
res.json({ payload: captured });
dispatchPortalAction(syntheticReq, syntheticRes, {
// The proxied portal URL decides strictness, matching the two direct
// endpoints: a canonical Ministra path enforces the token.
enforceAuth: (portalUrl ?? '').includes('/stalker_portal/'),
});
// The portal answers auth failures with a plain-text body; the real backend
// proxy still wraps whatever it got in the { payload } envelope, so the
// renderer sees the raw string there rather than a transport error.
res.json({ payload: plainTextBody ?? captured });
});
// Health check
@@ -120,9 +152,26 @@ app.get('/health', (_req: Request, res: Response) => {
// Reset all in-memory data (useful between Playwright test runs)
app.post('/reset', (_req: Request, res: Response) => {
resetAll();
resetAuthState();
resetWatchdogPings();
res.json({ status: 'reset', timestamp: new Date().toISOString() });
});
/**
* Drop a MAC's session so the next portal request fails with
* `Authorization failed.` — lets e2e assert the client re-handshakes and
* retries instead of surfacing an error.
*/
app.post('/invalidate-session', (req: Request, res: Response) => {
const mac = String(req.query['macAddress'] ?? '');
if (!mac) {
res.status(400).json({ error: 'macAddress query param is required' });
return;
}
invalidateSession(mac);
res.json({ status: 'invalidated', mac });
});
// ---------------------------------------------------------------------------
// Start
// ---------------------------------------------------------------------------
+206
View File
@@ -0,0 +1,206 @@
import { type APIRequestContext, type Page } from '@playwright/test';
import { setInputValue } from './e2e-helpers';
import { expect, test } from './fixtures';
import {
getRegisteredProviderUrl,
interceptProviderTargetRegistration,
} from './provider-target-route';
/**
* Stalker full-portal authentication E2E.
*
* `stalker.e2e.ts` imports the portal through the tolerant `/portal.php` alias,
* which the app classifies as a "simple" portal: no handshake, no token, no
* watchdog. This file covers the other half — the canonical Ministra endpoint
* (`/stalker_portal/server/load.php`), which the mock server guards exactly
* like the real middleware:
*
* - every action except handshake/get_profile/get_localization/do_auth needs
* `Authorization: Bearer <token>`
* - a token only counts once `get_profile` has adopted it
* - auth failures come back as HTTP 200 with a plain-text body, never a 401
*
* Tag: @stalker
*/
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
/** Canonical Ministra path — the app treats this as a full (authenticated) portal. */
const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
const DEFAULT_MAC = '00:1A:79:00:00:01';
async function interceptStalkerRequests(page: Page): Promise<void> {
const providerTargets = await interceptProviderTargetRegistration(page);
await page.route('**/localhost:3000/stalker**', async (route) => {
const originalUrl = new URL(route.request().url());
const mockUrl = new URL(BACKEND_PROXY);
const providerUrl = getRegisteredProviderUrl(
originalUrl,
providerTargets
);
if (providerUrl) {
mockUrl.searchParams.set('url', providerUrl);
}
originalUrl.searchParams.forEach((value, key) => {
if (key === 'targetId') {
return;
}
mockUrl.searchParams.set(key, value);
});
await route.continue({ url: mockUrl.toString() });
});
}
async function resetMockServer(request: APIRequestContext): Promise<void> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const response = await request.post(`${MOCK_SERVER}/reset`);
if (response.ok()) {
return;
}
}
throw new Error('Could not reset the stalker mock server');
}
async function addFullStalkerPortal(
page: Page,
options: { name?: string; mac?: string } = {}
): Promise<void> {
const { name = 'Full Stalker Portal', mac = DEFAULT_MAC } = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('radio', { name: /Stalker portal/i }).click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden();
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
}
/** Portal actions the app sent, in order, as seen on the proxy boundary. */
function recordPortalActions(page: Page): {
actions: string[];
tokensByAction: Map<string, string | null>;
} {
const actions: string[] = [];
const tokensByAction = new Map<string, 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;
}
actions.push(action);
if (!tokensByAction.has(action)) {
tokensByAction.set(action, url.searchParams.get('token'));
}
});
return { actions, tokensByAction };
}
test.beforeEach(async ({ page, request }) => {
// Importing a full portal costs a handshake, a profile call and the first
// content load — on a cold dev server that alone approaches Playwright's
// 30s default budget, so give these tests explicit headroom.
test.setTimeout(90_000);
await resetMockServer(request);
await page.goto('/');
await interceptStalkerRequests(page);
});
test.describe('@stalker full portal authentication', () => {
test('handshakes and authenticates before loading content', async ({
page,
}) => {
const { actions, tokensByAction } = recordPortalActions(page);
await addFullStalkerPortal(page);
// The portal only answers content actions for an adopted token, so
// reaching the VOD categories at all proves the whole chain ran.
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
expect(actions).toContain('handshake');
expect(actions).toContain('get_profile');
expect(actions.indexOf('handshake')).toBeLessThan(
actions.indexOf('get_profile')
);
const contentAction = actions.find((action) =>
['get_categories', 'get_genres'].includes(action)
);
expect(contentAction).toBeDefined();
expect(actions.indexOf('get_profile')).toBeLessThan(
actions.indexOf(contentAction as string)
);
// Content requests must carry the token; the handshake must not.
expect(tokensByAction.get('handshake')).toBeFalsy();
expect(tokensByAction.get(contentAction as string)).toBeTruthy();
});
test('never surfaces the portal plain-text auth failure as content', async ({
page,
}) => {
await addFullStalkerPortal(page);
// A body of "Authorization failed." must never be rendered — if the
// token pipeline breaks, the app has to fail loudly instead.
await expect(page.locator('body')).not.toContainText(
'Authorization failed.'
);
await expect(page.locator('body')).not.toContainText(
'Unauthorized request.'
);
});
test('re-authenticates after the portal drops the session', async ({
page,
request,
}) => {
await addFullStalkerPortal(page);
const { actions } = recordPortalActions(page);
// Server-side session loss is what a real expired/replaced token looks
// like: the next request gets "Authorization failed." with HTTP 200.
const invalidated = await request.post(
`${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent(
DEFAULT_MAC
)}`
);
expect(invalidated.ok()).toBe(true);
// Navigating to another content type forces a fresh portal request.
await page.getByRole('link', { name: /live|itv/i }).click();
await expect
.poll(() => actions.filter((a) => a === 'handshake').length, {
timeout: 30_000,
})
.toBeGreaterThan(0);
await expect(page.locator('body')).not.toContainText(
'Authorization failed.'
);
});
});
+46 -3
View File
@@ -37,7 +37,50 @@ Stalker portals use MAC address as the primary credential. The mock server follo
### In-Memory Only
No files or databases are written. All state (generated content + favorites) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.
No files or databases are written. All state (generated content + favorites + portal sessions) lives in process memory and resets on server restart. This is intentional — tests should not share state across runs.
### Two Endpoints With Different Strictness
The app decides how to talk to a portal from the shape of its URL: a URL
containing `/stalker_portal` is imported as a **full portal** (handshake,
`Authorization: Bearer`, watchdog), anything else as a **simple portal** with no
authentication at all. The mock therefore serves the same action set at two
paths:
| Path | Router | Behaviour |
|---|---|---|
| `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels |
| `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware |
Keeping the tolerant path is what lets the pre-existing e2e suite (which imports
`portal.php`) stay meaningful — it covers the simple-portal branch — while the
strict path finally covers the authenticated branch that had no coverage at all.
The strict behaviours mirror the plaintext Stalker 4.9.35 middleware
(`server/lib/stb.class.php`), the last openly readable ancestor of the encoded
5.x core:
- **Plain-text auth failures.** `Authorization failed.` / `Unauthorized request.`
are returned with **HTTP 200** and a `text/html` body, because the real server
`exit`s before the JSON envelope is built. A client checking only status codes
sees "success" and renders nothing. The `/stalker` proxy route still wraps the
body in the `{ payload }` envelope, matching what `apps/web-backend` does.
- **A handshake is not a session.** The token only authorizes requests once
`get_profile` has adopted it for that MAC.
- **Idempotent handshake.** Presenting the MAC's current token returns that same
token, which is what allows real clients to persist tokens across restarts.
- **Device-id pinning.** `device_id`/`device_id2` are stored on first non-empty
value; any later change — including reverting to empty — is a permanent
`device conflict` carrying the "Your STB is damaged." block message. This is
the only identity check the stock server actually enforces.
- **`signature`, `metrics`, `prehash` are ignored**, exactly as upstream ignores
them; they exist for portals with a custom `access_filter.php`.
- **MAC format validation.** Non-Infomir MACs (`00:1A:79:XX:XX:XX`) get a bare
`{ status: 1 }` from `get_profile`.
Session state lives in `src/app/auth-store.ts` and is cleared by `/reset`.
`POST /invalidate-session?macAddress=<mac>` drops a single session so tests can
assert the client re-handshakes and retries instead of surfacing an error.
## Data Generation Pipeline
@@ -308,6 +351,6 @@ test('browse VOD categories', async ({ page }) => {
- **New content types**: Add a new generator function in `data-generator.ts` and a new handler in `handlers/`.
- **New scenarios**: Add to `SCENARIOS` in `scenarios.ts`.
- **Stateful session tokens**: `handshake.handler.ts` generates a token from the MAC — extend this to track token expiry for testing re-auth flows.
- **Error simulation**: Add a special MAC or query param to trigger error responses (e.g. 401, 500) for testing error handling in the Stalker store.
- **Session behaviour**: `auth-store.ts` owns tokens and device pinning. Add TTLs or a "token replaced by another device" mode there rather than in the handlers.
- **Error simulation**: Add a special MAC or query param to trigger error responses for testing error handling in the Stalker store. Note that portal-level auth errors are *not* HTTP errors — see [Two Endpoints With Different Strictness](#two-endpoints-with-different-strictness).
- **Slow responses**: Add a `MOCK_DELAY_MS` env var and apply it in middleware for testing loading states.