mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
9f4e11d6de
commit
eeda703849
22 files changed
+905
-44
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user