mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16: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'
|
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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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
|
* - 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([
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
Reference in new issue
Block a user