test(stalker): serialize the portal specs and bind mocks to loopback

Review follow-up on #1324 (Codex, 4xP2):

- Parallel-reset race: under the workspace `fullyParallel` preset the new
  auth file ran concurrently with stalker.e2e.ts against one shared mock
  process, and each `beforeEach` wiped global state (sessions, favorites)
  mid-assertion in the other. Reproduced locally: both suites green in
  isolation, two failures when run together. Merged the auth tests into
  stalker.e2e.ts and pinned the file to `mode: 'serial'`, which also
  removes the pre-existing race between that file's own tests. 19/19
  green across three consecutive runs.
- Watchdog was recorded but never asserted, so the suite would stay green
  if the full-portal workflow stopped pinging or dropped its token —
  `sendWatchdogPing` swallows failures. Now polls for an authenticated
  `get_events`.
- Both mock servers listened on every interface (stalker: `listen(PORT)`
  with no host; xtream: an explicit `0.0.0.0` default), which made the
  CodeQL exclusion's "binds to localhost" rationale untrue. Both now
  default to `127.0.0.1` with a `HOST` opt-in, and the config comment
  states plainly what the directory-wide ignore trades away.
- Documented that the login-required scenario is HTTP-level only for now:
  the client's `do_auth` path is dormant and sends empty credentials, so
  the fixture is waiting on that client-side work rather than claiming
  end-to-end coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-01 18:44:28 +02:00
1 parent ad27c06396
commit 4b31f71672
7 files changed
+269 -280

No files matched your search

+11 -7
View File
@@ -1,16 +1,20 @@
name: 'IPTVnator CodeQL config' name: 'IPTVnator CodeQL config'
# The mock servers are development/E2E fixtures. They bind to localhost, serve # The mock servers are development/E2E fixtures. They bind to loopback by
# fabricated data, ship in no released artifact, and deliberately imitate the # default (HOST=0.0.0.0 is an explicit opt-in for pointing a phone/STB at
# quirks of the upstream IPTV protocols — including reading a session token # them), serve fabricated data, ship in no released artifact, and deliberately
# from a GET query string, which is what the real Stalker backend proxy does # imitate the quirks of the upstream IPTV protocols — including reading a
# and therefore what the app must be tested against. # session token from a GET query string, which is what the real Stalker
# backend proxy does and therefore what the app must be tested against.
# #
# CodeQL's web-service hygiene rules (missing rate limiting, sensitive data in # CodeQL's web-service hygiene rules (missing rate limiting, sensitive data in
# GET requests) assume an internet-facing service and produce only false # GET requests) assume an internet-facing service and produce only false
# positives here; a rate limiter on a fixture that the E2E suite hammers would # positives here; a rate limiter on a fixture that the E2E suite hammers would
# actively break the tests. Injection, path-traversal and similar rules still # actively break the tests. paths-ignore is all-or-nothing per path — CodeQL
# apply to everything the app itself ships. # has no per-path rule filter — so this deliberately trades away injection/
# path-traversal coverage for the two fixture apps, which parse no input
# beyond the local test driver. Everything the app itself ships keeps full
# coverage.
paths-ignore: paths-ignore:
- apps/stalker-mock-server - apps/stalker-mock-server
- apps/xtream-mock-server - apps/xtream-mock-server
+1 -1
View File
@@ -88,7 +88,7 @@ actually get wrong:
| `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: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: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:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials | | `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work |
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset | | `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
## Configuration ## Configuration
+5 -1
View File
@@ -14,6 +14,10 @@ import {
} from './app/marketing-poster-url.js'; } from './app/marketing-poster-url.js';
const PORT = parseInt(process.env['PORT'] ?? '3210', 10); const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
// Loopback by default: the fixture serves fabricated but unauthenticated
// content, so it should not be reachable from other hosts unless a dev
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it).
const HOST = process.env['HOST'] ?? '127.0.0.1';
const app = express(); const app = express();
const MARKETING_POSTER_DIRECTORY = join( const MARKETING_POSTER_DIRECTORY = join(
process.cwd(), process.cwd(),
@@ -228,7 +232,7 @@ process.on('unhandledRejection', (reason) => {
process.stdin.resume(); process.stdin.resume();
process.stdin.on('end', () => { /* ignore stdin close */ }); process.stdin.on('end', () => { /* ignore stdin close */ });
server.listen(PORT, () => { server.listen(PORT, HOST, () => {
const divider = '─'.repeat(62); const divider = '─'.repeat(62);
console.log(`\n${divider}`); console.log(`\n${divider}`);
console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`); console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`);
-268
View File
@@ -1,268 +0,0 @@
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 };
}
const CONTENT_ACTIONS = [
'get_categories',
'get_genres',
'get_ordered_list',
'get_all_channels',
];
/** Every portal request in order, with the token it carried. */
function recordPortalRequests(
page: Page
): Array<{ action: string; token: string | null }> {
const requests: Array<{ action: string; token: string | null }> = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
const action = url.searchParams.get('action');
if (!action) {
return;
}
requests.push({ action, token: url.searchParams.get('token') });
});
return requests;
}
test.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,
}) => {
const requests = recordPortalRequests(page);
await addFullStalkerPortal(page);
// The token the initial import authenticated with — recovery must end
// up on a DIFFERENT one, or nothing was actually re-negotiated.
const tokenBeforeInvalidation = requests.find(
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
)?.token;
expect(tokenBeforeInvalidation).toBeTruthy();
// Server-side session loss is what a real expired/replaced token looks
// like: the next request gets "Authorization failed." with HTTP 200.
const invalidated = await request.post(
`${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent(
DEFAULT_MAC
)}`
);
expect(invalidated.ok()).toBe(true);
const requestCountBeforeNavigation = requests.length;
// Navigating to another content type forces a fresh portal request.
await page.getByRole('link', { name: /live|itv/i }).click();
// Recovery is only proven end to end when a CONTENT request goes out
// under a freshly negotiated token — a re-handshake alone could still
// leave the original request unreplayed or unauthorized. The mock only
// answers content for an adopted token, so this doubles as proof the
// new token was adopted via get_profile.
await expect
.poll(
() =>
requests
.slice(requestCountBeforeNavigation)
.filter(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token &&
entry.token !== tokenBeforeInvalidation
).length,
{ timeout: 30_000 }
)
.toBeGreaterThan(0);
const recovered = requests.slice(requestCountBeforeNavigation);
expect(
recovered.filter((entry) => entry.action === 'handshake').length
).toBeGreaterThan(0);
// And the recovered session must actually render: the ITV categories
// can only come from an authorized request against the new token.
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 15_000,
});
await expect(page.locator('body')).not.toContainText(
'Authorization failed.'
);
});
});
+246
View File
@@ -22,11 +22,27 @@ import {
* - 3 seasons × 8 episodes per series item * - 3 seasons × 8 episodes per series item
* *
* Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker" * Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker"
*
* SERIAL BY DESIGN: every test here shares one mock-server process whose state
* (generated data, favorites, portal sessions) is global, and `beforeEach`
* wipes it with `POST /reset`. Under the workspace-wide `fullyParallel` preset
* those resets would race each other — and any sibling spec file — so this file
* pins itself to a single worker. Keep the full-portal authentication tests
* below in THIS file for the same reason: split across files they would run
* concurrently again and reset each other's sessions mid-assertion.
*/ */
test.describe.configure({ mode: 'serial' });
const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210'; const MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
const MOCK_SERVER = `http://localhost:${MOCK_PORT}`; const MOCK_SERVER = `http://localhost:${MOCK_PORT}`;
const PORTAL_URL = `${MOCK_SERVER}/portal.php`; const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
/**
* Canonical Ministra path. `PORTAL_URL` above is classified by the app as a
* "simple" portal (no handshake, no token, no watchdog); this shape is the
* authenticated branch, which the mock guards like the real middleware.
*/
const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`; const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */ /** Default scenario MAC — balanced catalog, 8 categories, 40 items */
@@ -41,6 +57,16 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
/** Legacy pagination MAC — portal without get_all_channels support */ /** Legacy pagination MAC — portal without get_all_channels support */
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06'; const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
/**
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
* by MAC, so keeping these distinct from the content scenarios above means an
* auth test can never consume or invalidate a session another test relies on.
* The Infomir OUI matters: the strict endpoint validates the MAC format.
*/
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
const AUTH_TEXT_MAC = '00:1A:79:AD:00:02';
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -130,6 +156,86 @@ async function addStalkerPortal(
await page.waitForURL(/stalker.*vod/); await page.waitForURL(/stalker.*vod/);
} }
/**
* Add a Stalker portal through the canonical Ministra URL, which the app
* imports as a FULL portal: handshake, Bearer token and watchdog.
*/
async function addFullStalkerPortal(
page: Page,
options: { name?: string; mac: string }
): Promise<void> {
const { name = 'Full Stalker Portal', 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, with the token each carried. */
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 };
}
const CONTENT_ACTIONS = [
'get_categories',
'get_genres',
'get_ordered_list',
'get_all_channels',
];
/** Every portal request in order, with the token it carried. */
function recordPortalRequests(
page: Page
): Array<{ action: string; token: string | null }> {
const requests: Array<{ action: string; token: string | null }> = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
const action = url.searchParams.get('action');
if (!action) {
return;
}
requests.push({ action, token: url.searchParams.get('token') });
});
return requests;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test setup // Test setup
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -702,3 +808,143 @@ test('@stalker series — seasons load for a series item', async ({
// Default scenario has 8 episodes per season // Default scenario has 8 episodes per season
expect(seasons[0].series.length).toBe(8); expect(seasons[0].series.length).toBe(8);
}); });
/**
* Full-portal authentication. The tests above import through the tolerant
* `/portal.php` alias (simple portal, no auth); these use the canonical
* Ministra endpoint, which the mock guards like the real middleware:
*
* - every action except handshake/get_profile/get_localization/do_auth needs
* `Authorization: Bearer <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
*/
test.describe('@stalker full portal authentication', () => {
// Importing a full portal costs a handshake, a profile call and the first
// content load — on a cold dev server that alone approaches Playwright's
// 30s default budget.
test.beforeEach(() => {
test.setTimeout(90_000);
});
test('handshakes and authenticates before loading content', async ({
page,
}) => {
const { actions, tokensByAction } = recordPortalActions(page);
await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC });
// The portal only answers content actions for an adopted token, so
// reaching the VOD categories at all proves the whole chain ran.
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
expect(actions).toContain('handshake');
expect(actions).toContain('get_profile');
expect(actions.indexOf('handshake')).toBeLessThan(
actions.indexOf('get_profile')
);
const contentAction = actions.find((action) =>
['get_categories', 'get_genres'].includes(action)
);
expect(contentAction).toBeDefined();
expect(actions.indexOf('get_profile')).toBeLessThan(
actions.indexOf(contentAction as string)
);
// Content requests must carry the token; the handshake must not.
expect(tokensByAction.get('handshake')).toBeFalsy();
expect(tokensByAction.get(contentAction as string)).toBeTruthy();
// The full-portal workflow must also keep the watchdog alive — an
// authenticated get_events fires immediately (init=1) on activation.
// Without this assertion the suite would stay green if the watchdog
// wiring silently died, because its failures are swallowed by design.
await expect
.poll(() => actions.includes('get_events'), { timeout: 30_000 })
.toBe(true);
expect(tokensByAction.get('get_events')).toBeTruthy();
});
test('never surfaces the portal plain-text auth failure as content', async ({
page,
}) => {
await addFullStalkerPortal(page, { mac: AUTH_TEXT_MAC });
// 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,
}) => {
const requests = recordPortalRequests(page);
await addFullStalkerPortal(page, { mac: AUTH_REAUTH_MAC });
// The token the initial import authenticated with — recovery must end
// up on a DIFFERENT one, or nothing was actually re-negotiated.
const tokenBeforeInvalidation = requests.find(
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
)?.token;
expect(tokenBeforeInvalidation).toBeTruthy();
// Server-side session loss is what a real expired/replaced token looks
// like: the next request gets "Authorization failed." with HTTP 200.
const invalidated = await request.post(
`${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent(
AUTH_REAUTH_MAC
)}`
);
expect(invalidated.ok()).toBe(true);
const requestCountBeforeNavigation = requests.length;
// Navigating to another content type forces a fresh portal request.
await page.getByRole('link', { name: /live|itv/i }).click();
// Recovery is only proven end to end when a CONTENT request goes out
// under a freshly negotiated token — a re-handshake alone could still
// leave the original request unreplayed or unauthorized. The mock only
// answers content for an adopted token, so this doubles as proof the
// new token was adopted via get_profile.
await expect
.poll(
() =>
requests
.slice(requestCountBeforeNavigation)
.filter(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token &&
entry.token !== tokenBeforeInvalidation
).length,
{ timeout: 30_000 }
)
.toBeGreaterThan(0);
const recovered = requests.slice(requestCountBeforeNavigation);
expect(
recovered.filter((entry) => entry.action === 'handshake').length
).toBeGreaterThan(0);
// And the recovered session must actually render: the ITV categories
// can only come from an authorized request against the new token.
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 15_000,
});
await expect(page.locator('body')).not.toContainText(
'Authorization failed.'
);
});
});
@@ -195,7 +195,7 @@ describe('Xtream mock server factory', () => {
describe('Xtream mock environment parsing', () => { describe('Xtream mock environment parsing', () => {
it('uses safe defaults and enables control only for the exact flag', () => { it('uses safe defaults and enables control only for the exact flag', () => {
expect(parseXtreamMockServerEnvironment({})).toEqual({ expect(parseXtreamMockServerEnvironment({})).toEqual({
host: '0.0.0.0', host: '127.0.0.1',
port: 3211, port: 3211,
}); });
expect( expect(
@@ -225,7 +225,7 @@ describe('Xtream mock environment parsing', () => {
IPTVNATOR_XTREAM_MOCK_CONTROL: 'true', IPTVNATOR_XTREAM_MOCK_CONTROL: 'true',
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored', IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored',
}) })
).toEqual({ host: '0.0.0.0', port: 3211 }); ).toEqual({ host: '127.0.0.1', port: 3211 });
}); });
it.each([ it.each([
+4 -1
View File
@@ -42,7 +42,10 @@ https://example.channels/path-to-file/4.m3u8
`; `;
const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
const DEFAULT_PORT = 3211; const DEFAULT_PORT = 3211;
const DEFAULT_NORMAL_HOST = '0.0.0.0'; // Loopback by default: the fixtures serve fabricated but unauthenticated
// content, so they should not be reachable from other hosts unless a dev
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them).
const DEFAULT_NORMAL_HOST = '127.0.0.1';
const DEFAULT_CONTROL_HOST = '127.0.0.1'; const DEFAULT_CONTROL_HOST = '127.0.0.1';
const PERFORMANCE_USERNAME = 'performance'; const PERFORMANCE_USERNAME = 'performance';
const PERFORMANCE_PASSWORD = 'performance'; const PERFORMANCE_PASSWORD = 'performance';