mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
test(stalker): enforce portal auth in the mock and cover the full-portal flow (#1324)
* 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>
* fix(mock): address CodeQL findings in the new portal auth code
Two genuine defects in the code this PR added:
- readBearerToken's /Bearer\s+(.*)$/ backtracks polynomially on
"bearer" followed by a long run of spaces; require the token to start
with a non-space character instead
- the /stalker proxy route read query params as strings without
narrowing, so a repeated key (?url=a&url=b) arrives as an array and
String.prototype.includes silently changes meaning
The remaining three alerts (missing rate limiting x2, sensitive data in
a GET query) are web-service hygiene rules aimed at internet-facing
services. The mock servers bind to localhost, serve fabricated data,
ship in no artifact, and deliberately mirror the real backend proxy's
token-in-query contract; a rate limiter would break the E2E suite that
hammers them. Exclude only those two apps from analysis via a documented
CodeQL config; every shipped path keeps full coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): tighten portal-auth fidelity per review
Review follow-up on #1324 (Greptile 2xP1, Codex 3xP2), all valid:
- adoptToken only accepts tokens the mock actually issued (or the
already-bound one). The stock server pins any presented Bearer —
handshake is stateless there — but a fixture that does the same
cannot catch a client with a broken token pipeline; documented as a
deliberate strictness divergence.
- /invalidate-session clears tokens but keeps pinned device identity:
losing a token never unpins device_id on a real portal, so changed
identity after re-auth must still hit the device-conflict branch.
- The login-required scenario gates on actual do_auth completion
instead of auth_second_step: the app sends auth_second_step=1 on its
very first get_profile, so the parameter check was trivially
bypassed and the status-2 flow never exercised. do_auth is now the
faithful boolean step (non-empty credentials -> {js:true}, recorded;
empty -> {js:false}).
- /server/load.php — the second URL shape isFullStalkerPortal
recognizes — is now served and enforced, directly and through the
/stalker proxy predicate, so full-portal tests cannot silently fall
into the tolerant branch.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): prove content actually reloads after re-authentication
Review follow-up on #1324 (Codex P2, valid — the "passes for the wrong
reason" class): the re-auth test only polled for a fresh handshake and
a negative body-text assertion, both of which pass even if the original
content request is never replayed or stays unauthorized. Capture the
content token from the initial import, then assert a post-invalidation
CONTENT request goes out under a DIFFERENT token and that the ITV
categories actually render — the mock only answers content for an
adopted token, so this proves the new token round-tripped through
get_profile. Verified against a live mock that the token genuinely
rotates (old token -> "Authorization failed.", new token -> content).
Also documents the second Codex P2: the mock is deliberately strict on
/server/load.php (a real portal enforces auth there); the import dialog
vs session predicate divergence is a separate app bug the strict
endpoint will let a later PR cover.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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>
* test(stalker): force a real auth failure before asserting it stays hidden
Review follow-up on #1324 (Codex 2xP2 + 1xP3), all valid:
- The "never surfaces the plain-text auth failure" test only performed a
successful import, so its negative body assertions were vacuous. It now
imports with a MAC outside the Infomir OUI: the strict endpoint answers
get_profile with a bare {status:1}, no token is ever adopted, and every
content request keeps returning "Authorization failed." Unlike an
invalidated session this cannot be repaired by the client retry, so the
failure is genuinely observed (asserted directly against the proxy) and
only then checked for not leaking into the UI.
- docs/architecture/xtream-mock-server.md still documented the wildcard
bind that 4b31f7167 replaced with a loopback default; it now states the
new default and the HOST=0.0.0.0 opt-in needed for phone/STB/container.
- Removed a dangling "Known app-side gap: the" fragment left in the
stalker mock README.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(mock): scope /reset by MAC so parallel specs stop wiping each other
The re-authentication test passed locally but failed all three CI
attempts: no request carried a token, because self-hosted.e2e.ts issues
a GLOBAL `POST /reset` against the same mock from a parallel Playwright
worker, destroying the session mid-import. Running only stalker.e2e.ts
locally never triggered it.
Serializing within one file (4b31f7167) could not fix this — the
interference is between files. Mock state is per-MAC, so `/reset` now
accepts `?macAddress=` and clears only that MAC's data, favorites,
session and watchdog counters; the unscoped form is kept for callers
that own the whole server. Both spec files now reset only the MACs they
own, so no worker can disturb another.
Verified: a scoped reset of one MAC leaves another MAC's session intact
(and its own dies), and stalker.e2e.ts + self-hosted.e2e.ts run together
23/23 green — the combination that reproduced the CI failure.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(e2e): scope the last global Stalker reset in sources-pwa helpers
Completes 3a93fef0f: that commit scoped self-hosted.e2e.ts but missed
resetPwaMockServers, which still wiped the whole Stalker fixture from a
third spec file. Scope it to the two MACs this suite owns.
The auth tests use dedicated MACs no sibling touches, so portal sessions
— the fragile state — can no longer be cleared by a parallel worker.
Content MACs still overlap between files, which is harmless: that data is
regenerated deterministically from the same seed.
Verified with the full interfering set running together:
stalker.e2e.ts + self-hosted.e2e.ts + sources-pwa.e2e.ts, 26/26 green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): await the first authenticated content request
The re-auth test kept failing on CI (3/3 attempts) with an undefined
token while passing locally. My earlier diagnosis — a sibling spec's
global /reset — was wrong: the failure survived the scoped-reset fix.
Real cause is a race in the test itself. `addFullStalkerPortal` only
awaits the route change, so on a slower runner the first authenticated
content request has not been recorded yet when the token is read; the
sibling test that passes happens to await `.category-item` first. Poll
for a content request carrying a token before capturing it.
The scoped-reset work stands on its own merits (cross-file resets were
a real hazard), it just was not what broke this test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): drop serial mode, batch resets, cover the auth handlers
Review round on a44f8135f plus a stability regression I introduced.
Codex, both valid:
- The proxy route stripped `token` from the forwarded query, so
`handshake` never saw a presented token and the idempotent-handshake
behaviour I documented was unreachable through the PWA path. The real
backend forwards every param except `targetId` *and* sets the header;
match it. Verified through the proxy: re-handshake now returns the
same token with not_valid 0.
- The login-required scenario had no committed test, so the README claim
was unbacked. Added auth-handlers.spec.ts (status 2 -> do_auth ->
profile, MAC-format rejection, device conflict, idempotent handshake,
watchdog). Handlers are called directly because the dispatcher pulls in
the faker-based generator, which this project's Jest cannot transform.
- Sibling suites now own disjoint MACs (00:1A:79:5F:*) instead of
sharing the Stalker suite's, so no reset can reach another suite's
state at all.
Stability: a baseline run of master passed 23/23 first try while this
branch failed a different test each run, so the flakiness was mine.
`mode: 'serial'` was a stand-in for isolation that per-MAC scoping now
provides properly, and it amplified every flake by aborting the rest of
the file; removed. `beforeEach` also fired seven sequential resets — the
endpoint now accepts repeated `macAddress` params so a suite clears all
of its MACs in one request. Added a retrying POST helper after an
ECONNRESET on a control call.
Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each; 28 mock unit tests; lint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(stalker): restore serial mode for the shared-scenario file
Review follow-up (Codex P2), valid: the previous commit removed
`mode: 'serial'` while every `beforeEach` still resets all OWNED_MACS,
so under fullyParallel one test in this file could clear another's data
or session mid-run.
Of the two suggested fixes, serialize rather than give each test its own
MAC: the tests here are written against scenario fixtures (default,
minimal, embedded-series) whose shapes the assertions encode, so a MAC
per test would mean inventing a scenario per test and rewriting
pre-existing assertions. Cross-file isolation stays with the disjoint
sibling MAC range, which is what serial was wrongly standing in for
before.
The header now states both levels explicitly so the next reader does not
undo one of them.
Verified: three consecutive runs of stalker + self-hosted + sources-pwa,
26/26 each.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94efd7d379
commit
3dbfefa3d8
33 files changed
+1564
-91
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import type { Locator } from '@playwright/test';
|
||||
import type { APIRequestContext, Locator } from '@playwright/test';
|
||||
import { expect } from './fixtures';
|
||||
|
||||
export async function setInputValue(
|
||||
@@ -18,3 +18,37 @@ export async function setInputValue(
|
||||
await input.type(value);
|
||||
await expect(input).toHaveValue(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST to a mock-server control endpoint, retrying transport errors.
|
||||
*
|
||||
* The mock servers are shared by every spec file and Playwright runs those
|
||||
* files in parallel workers, so a burst of concurrent control requests can
|
||||
* occasionally be met with ECONNRESET. That is a transport hiccup, not a
|
||||
* failure of the test under it.
|
||||
*/
|
||||
export async function postWithRetry(
|
||||
request: APIRequestContext,
|
||||
url: string,
|
||||
attempts = 3
|
||||
): Promise<void> {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
const response = await request.post(url);
|
||||
if (response.ok()) {
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`POST ${url} failed: ${response.status()}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 250 * (attempt + 1))
|
||||
);
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import { postWithRetry, setInputValue } from './e2e-helpers';
|
||||
import { expect, test } from './fixtures';
|
||||
|
||||
const WEB_BACKEND_URL = 'http://localhost:3333';
|
||||
@@ -8,7 +8,9 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
|
||||
const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`;
|
||||
const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`;
|
||||
const DEFAULT_MAC = '00:1A:79:00:00:01';
|
||||
// Dedicated MAC: mock state is per-MAC and stalker.e2e.ts runs in a parallel
|
||||
// worker, so sharing one would let each suite's reset clear the other's state.
|
||||
const DEFAULT_MAC = '00:1A:79:5F:00:01';
|
||||
|
||||
async function installRuntimeConfig(page: Page): Promise<void> {
|
||||
await page.route('**/assets/app-config.js', async (route) => {
|
||||
@@ -101,8 +103,15 @@ function expectRequestsUseTargetId(requests: string[], path: string): void {
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
await request.post(`${XTREAM_MOCK_SERVER}/reset`);
|
||||
await request.post(`${STALKER_MOCK_SERVER}/reset`);
|
||||
await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`);
|
||||
// Scope the Stalker reset to the MAC this file uses: a global reset would
|
||||
// wipe the sessions of stalker.e2e.ts running in a parallel worker.
|
||||
await postWithRetry(
|
||||
request,
|
||||
`${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent(
|
||||
DEFAULT_MAC
|
||||
)}`
|
||||
);
|
||||
await installRuntimeConfig(page);
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { expect } from './fixtures';
|
||||
import { setInputValue } from './e2e-helpers';
|
||||
import { postWithRetry, setInputValue } from './e2e-helpers';
|
||||
import {
|
||||
getRegisteredProviderUrl,
|
||||
interceptProviderTargetRegistration,
|
||||
@@ -13,9 +13,11 @@ const STALKER_MOCK_PORT = process.env['MOCK_PORT'] ?? '3210';
|
||||
export const XTREAM_MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
|
||||
export const STALKER_MOCK_SERVER = `http://localhost:${STALKER_MOCK_PORT}`;
|
||||
export const STALKER_PORTAL_URL = `${STALKER_MOCK_SERVER}/portal.php`;
|
||||
export const EDITED_MAC = '00:1A:79:00:00:03';
|
||||
export const EDITED_MAC = '00:1A:79:5F:00:03';
|
||||
|
||||
const DEFAULT_MAC = '00:1A:79:00:00:01';
|
||||
// Dedicated MACs (see EDITED_MAC): never share a MAC with stalker.e2e.ts,
|
||||
// whose parallel worker would otherwise have its state reset mid-test.
|
||||
const DEFAULT_MAC = '00:1A:79:5F:00:02';
|
||||
const M3U_PLAYLIST_URL = `${XTREAM_MOCK_SERVER}/playlist.m3u`;
|
||||
|
||||
type RuntimeErrors = {
|
||||
@@ -31,11 +33,22 @@ type SourceDialogField =
|
||||
| 'title'
|
||||
| 'username';
|
||||
|
||||
/**
|
||||
* Reset the fixtures this suite uses. The Stalker reset is scoped to the MACs
|
||||
* touched here: that mock is shared with stalker.e2e.ts, which runs in a
|
||||
* parallel Playwright worker, and a global reset would destroy its portal
|
||||
* sessions mid-test.
|
||||
*/
|
||||
export async function resetPwaMockServers(
|
||||
request: APIRequestContext
|
||||
): Promise<void> {
|
||||
await request.post(`${XTREAM_MOCK_SERVER}/reset`);
|
||||
await request.post(`${STALKER_MOCK_SERVER}/reset`);
|
||||
await postWithRetry(request, `${XTREAM_MOCK_SERVER}/reset`);
|
||||
for (const mac of [DEFAULT_MAC, EDITED_MAC]) {
|
||||
await postWithRetry(
|
||||
request,
|
||||
`${STALKER_MOCK_SERVER}/reset?macAddress=${encodeURIComponent(mac)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function interceptPwaProviderRequests(
|
||||
|
||||
@@ -22,11 +22,35 @@ import {
|
||||
* - 3 seasons × 8 episodes per series item
|
||||
*
|
||||
* Tag: @stalker — run only stalker tests with: nx e2e web-e2e --grep "@stalker"
|
||||
*
|
||||
* ISOLATION works on two levels, because one mock-server process is shared by
|
||||
* every spec file and its state is keyed by MAC:
|
||||
*
|
||||
* - ACROSS FILES: `beforeEach` resets only the MACs in `OWNED_MACS`, and the
|
||||
* sibling files that touch this server (self-hosted, sources-pwa) own a
|
||||
* disjoint `00:1A:79:5F:*` range, so neither can clear the other's state.
|
||||
* - WITHIN THIS FILE: tests deliberately share scenario MACs (`default`,
|
||||
* `minimal`, `embedded-series` — their fixture shapes are what the
|
||||
* assertions are written against), and every `beforeEach` resets all of
|
||||
* them. Under the workspace-wide `fullyParallel` preset that would let one
|
||||
* test wipe another's data or session mid-run, so the file pins itself to a
|
||||
* single worker.
|
||||
*
|
||||
* Giving each test its own MAC instead would mean inventing a scenario per
|
||||
* test; serializing one file is the cheaper trade.
|
||||
*/
|
||||
|
||||
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 +65,20 @@ 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_REAUTH_MAC = '00:1A:79:AD:00:03';
|
||||
/**
|
||||
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
|
||||
* it, so no token is ever adopted and content requests fail permanently.
|
||||
*/
|
||||
const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -76,12 +114,32 @@ async function interceptStalkerRequests(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Every MAC this file owns; all are cleared in one batched reset request. */
|
||||
const OWNED_MACS = [
|
||||
DEFAULT_MAC,
|
||||
MINIMAL_MAC,
|
||||
EMBEDDED_SERIES_MAC,
|
||||
LEGACY_PAGINATION_MAC,
|
||||
AUTH_FLOW_MAC,
|
||||
AUTH_REAUTH_MAC,
|
||||
AUTH_REJECTED_MAC,
|
||||
];
|
||||
|
||||
/**
|
||||
* Reset only the MACs this file owns, in a single request. Mock state is
|
||||
* per-MAC and sibling spec files talk to the same server from parallel
|
||||
* workers, so a global reset here would wipe their state mid-test — and
|
||||
* theirs would wipe ours.
|
||||
*/
|
||||
async function resetMockServer(request: APIRequestContext): Promise<void> {
|
||||
const query = OWNED_MACS.map(
|
||||
(mac) => `macAddress=${encodeURIComponent(mac)}`
|
||||
).join('&');
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
try {
|
||||
const response = await request.post(`${MOCK_SERVER}/reset`);
|
||||
const response = await request.post(`${MOCK_SERVER}/reset?${query}`);
|
||||
if (response.ok()) {
|
||||
return;
|
||||
}
|
||||
@@ -130,6 +188,89 @@ 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; expectContent?: boolean }
|
||||
): Promise<void> {
|
||||
const { name = 'Full Stalker Portal', mac, expectContent = true } = 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();
|
||||
|
||||
if (expectContent) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -517,9 +658,12 @@ test('@stalker mock server reset clears cached state', async ({ request }) => {
|
||||
);
|
||||
expect(before.ok()).toBeTruthy();
|
||||
|
||||
// Reset
|
||||
const reset = await request.post(`${MOCK_SERVER}/reset`);
|
||||
// Scoped reset — a global one would clear MACs owned by parallel specs.
|
||||
const reset = await request.post(
|
||||
`${MOCK_SERVER}/reset?macAddress=${encodeURIComponent(DEFAULT_MAC)}`
|
||||
);
|
||||
expect(reset.ok()).toBeTruthy();
|
||||
expect((await reset.json()).macs).toEqual([DEFAULT_MAC]);
|
||||
|
||||
// Data is regenerated identically (deterministic seed)
|
||||
const after = await request.get(
|
||||
@@ -702,3 +846,193 @@ 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,
|
||||
request,
|
||||
}) => {
|
||||
const requests = recordPortalRequests(page);
|
||||
|
||||
// A MAC outside the Infomir OUI makes the strict endpoint answer
|
||||
// get_profile with a bare {status:1}, so no token is ever adopted and
|
||||
// every content request keeps returning the plain-text failure. Unlike
|
||||
// an invalidated session this cannot be repaired by the app's retry,
|
||||
// which is what makes the negative assertion below meaningful instead
|
||||
// of vacuous.
|
||||
const failureBody = await (
|
||||
await request.get(
|
||||
`${BACKEND_PROXY}?url=${encodeURIComponent(
|
||||
FULL_PORTAL_URL
|
||||
)}&macAddress=${encodeURIComponent(
|
||||
AUTH_REJECTED_MAC
|
||||
)}&action=get_categories&type=vod`
|
||||
)
|
||||
).json();
|
||||
expect(failureBody.payload).toBe('Authorization failed.');
|
||||
|
||||
await addFullStalkerPortal(page, {
|
||||
mac: AUTH_REJECTED_MAC,
|
||||
expectContent: false,
|
||||
});
|
||||
|
||||
// The app must have actually hit the failing portal...
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests.filter((entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action)
|
||||
).length,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// ...and must never render the raw portal response as content. A
|
||||
// portal answers auth failures with HTTP 200 + plain text, so an app
|
||||
// that trusts the status code would happily paint these strings.
|
||||
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 });
|
||||
|
||||
// `addFullStalkerPortal` only awaits the route change, and on a slow
|
||||
// runner the first authenticated content request has not necessarily
|
||||
// gone out by then — poll for it instead of reading the log once.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
requests.some(
|
||||
(entry) =>
|
||||
CONTENT_ACTIONS.includes(entry.action) &&
|
||||
entry.token
|
||||
),
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// 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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user