mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
ad27c06396
commit
4b31f71672
7 files changed
+269
-280
No files matched your search
@@ -1,16 +1,20 @@
|
||||
name: 'IPTVnator CodeQL config'
|
||||
|
||||
# The mock servers are development/E2E fixtures. They bind to localhost, serve
|
||||
# fabricated data, ship in no released artifact, and deliberately imitate the
|
||||
# quirks of the upstream IPTV protocols — including reading a session token
|
||||
# from a GET query string, which is what the real Stalker backend proxy does
|
||||
# and therefore what the app must be tested against.
|
||||
# The mock servers are development/E2E fixtures. They bind to loopback by
|
||||
# default (HOST=0.0.0.0 is an explicit opt-in for pointing a phone/STB at
|
||||
# them), serve fabricated data, ship in no released artifact, and deliberately
|
||||
# imitate the quirks of the upstream IPTV protocols — including reading a
|
||||
# session token from a GET query string, which is what the real Stalker
|
||||
# backend proxy does and therefore what the app must be tested against.
|
||||
#
|
||||
# CodeQL's web-service hygiene rules (missing rate limiting, sensitive data in
|
||||
# GET requests) assume an internet-facing service and produce only false
|
||||
# positives here; a rate limiter on a fixture that the E2E suite hammers would
|
||||
# actively break the tests. Injection, path-traversal and similar rules still
|
||||
# apply to everything the app itself ships.
|
||||
# actively break the tests. paths-ignore is all-or-nothing per path — CodeQL
|
||||
# has no per-path rule filter — so this deliberately trades away injection/
|
||||
# path-traversal coverage for the two fixture apps, which parse no input
|
||||
# beyond the local test driver. Everything the app itself ships keeps full
|
||||
# coverage.
|
||||
paths-ignore:
|
||||
- apps/stalker-mock-server
|
||||
- apps/xtream-mock-server
|
||||
@@ -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:06` | **legacy-pagination** | No `get_all_channels` support — tests the paginated `get_ordered_list` crawl fallback for the full ITV channel list |
|
||||
| `00:1A:79:00:00:07` | **marketing-demo** | 35 original poster movies with the newest 20 first — safe for screenshots and marketing |
|
||||
| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials |
|
||||
| `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 |
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from './app/marketing-poster-url.js';
|
||||
|
||||
const PORT = parseInt(process.env['PORT'] ?? '3210', 10);
|
||||
// Loopback by default: the fixture serves fabricated but unauthenticated
|
||||
// content, so it should not be reachable from other hosts unless a dev
|
||||
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at it).
|
||||
const HOST = process.env['HOST'] ?? '127.0.0.1';
|
||||
const app = express();
|
||||
const MARKETING_POSTER_DIRECTORY = join(
|
||||
process.cwd(),
|
||||
@@ -228,7 +232,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
process.stdin.resume();
|
||||
process.stdin.on('end', () => { /* ignore stdin close */ });
|
||||
|
||||
server.listen(PORT, () => {
|
||||
server.listen(PORT, HOST, () => {
|
||||
const divider = '─'.repeat(62);
|
||||
console.log(`\n${divider}`);
|
||||
console.log(` 🎬 Stalker Mock Server → http://localhost:${PORT}`);
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -22,11 +22,27 @@ import {
|
||||
* - 3 seasons × 8 episodes per series item
|
||||
*
|
||||
* 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_SERVER = `http://localhost:${MOCK_PORT}`;
|
||||
const PORTAL_URL = `${MOCK_SERVER}/portal.php`;
|
||||
/**
|
||||
* Canonical Ministra path. `PORTAL_URL` above is classified by the app as a
|
||||
* "simple" portal (no handshake, no token, no watchdog); this shape is the
|
||||
* authenticated branch, which the mock guards like the real middleware.
|
||||
*/
|
||||
const FULL_PORTAL_URL = `${MOCK_SERVER}/stalker_portal/server/load.php`;
|
||||
const BACKEND_PROXY = `${MOCK_SERVER}/stalker`;
|
||||
|
||||
/** Default scenario MAC — balanced catalog, 8 categories, 40 items */
|
||||
@@ -41,6 +57,16 @@ const EMBEDDED_SERIES_MAC = '00:1A:79:00:00:05';
|
||||
/** Legacy pagination MAC — portal without get_all_channels support */
|
||||
const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
||||
|
||||
/**
|
||||
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
|
||||
* by MAC, so keeping these distinct from the content scenarios above means an
|
||||
* auth test can never consume or invalidate a session another test relies on.
|
||||
* The Infomir OUI matters: the strict endpoint validates the MAC format.
|
||||
*/
|
||||
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
|
||||
const AUTH_TEXT_MAC = '00:1A:79:AD:00:02';
|
||||
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -130,6 +156,86 @@ async function addStalkerPortal(
|
||||
await page.waitForURL(/stalker.*vod/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Stalker portal through the canonical Ministra URL, which the app
|
||||
* imports as a FULL portal: handshake, Bearer token and watchdog.
|
||||
*/
|
||||
async function addFullStalkerPortal(
|
||||
page: Page,
|
||||
options: { name?: string; mac: string }
|
||||
): 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -702,3 +808,143 @@ test('@stalker series — seasons load for a series item', async ({
|
||||
// Default scenario has 8 episodes per season
|
||||
expect(seasons[0].series.length).toBe(8);
|
||||
});
|
||||
|
||||
/**
|
||||
* Full-portal authentication. The tests above import through the tolerant
|
||||
* `/portal.php` alias (simple portal, no auth); these use the canonical
|
||||
* Ministra endpoint, which the mock guards like the real middleware:
|
||||
*
|
||||
* - every action except handshake/get_profile/get_localization/do_auth needs
|
||||
* `Authorization: Bearer <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', () => {
|
||||
it('uses safe defaults and enables control only for the exact flag', () => {
|
||||
expect(parseXtreamMockServerEnvironment({})).toEqual({
|
||||
host: '0.0.0.0',
|
||||
host: '127.0.0.1',
|
||||
port: 3211,
|
||||
});
|
||||
expect(
|
||||
@@ -225,7 +225,7 @@ describe('Xtream mock environment parsing', () => {
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL: 'true',
|
||||
IPTVNATOR_XTREAM_MOCK_CONTROL_TOKEN: 'ignored',
|
||||
})
|
||||
).toEqual({ host: '0.0.0.0', port: 3211 });
|
||||
).toEqual({ host: '127.0.0.1', port: 3211 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -42,7 +42,10 @@ https://example.channels/path-to-file/4.m3u8
|
||||
`;
|
||||
const HLS_STUB = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
|
||||
const DEFAULT_PORT = 3211;
|
||||
const DEFAULT_NORMAL_HOST = '0.0.0.0';
|
||||
// Loopback by default: the fixtures serve fabricated but unauthenticated
|
||||
// content, so they should not be reachable from other hosts unless a dev
|
||||
// explicitly opts in with HOST=0.0.0.0 (e.g. to point a phone or STB at them).
|
||||
const DEFAULT_NORMAL_HOST = '127.0.0.1';
|
||||
const DEFAULT_CONTROL_HOST = '127.0.0.1';
|
||||
const PERFORMANCE_USERNAME = 'performance';
|
||||
const PERFORMANCE_PASSWORD = 'performance';
|
||||
|
||||
Reference in new issue
Block a user