mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
test(stalker): isolate auth e2e state by worker (#1378)
* test(stalker): isolate auth e2e state by worker * test(stalker): bound auth e2e worker slots
This commit is contained in:
1 parent
fd96b85c19
commit
f40320e42e
5 files changed
+144
-96
No files matched your search
@@ -86,6 +86,7 @@ actually get wrong:
|
|||||||
| `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. The app drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
|
| `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 drives this end to end: enter a username and password in the Stalker import dialog and it runs `do_auth`, then retries `get_profile` with `auth_second_step=1`. Covered by `stalker.e2e.ts` (`@stalker full portal authentication`) |
|
||||||
|
| `00:1A:79:AE:<slot>:02` | **login-required** | Parallel-slot-scoped alias range used by concurrent auth E2E runs; it has the same login contract as `00:1A:79:00:00:08` but independent per-MAC state |
|
||||||
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
| `00:1A:79:00:00:09` | **gated-stream** | `create_link` returns a local `/stream/gated/…` URL that answers 403 without the mac cookie and the MAC's current Bearer token — proves a player's media requests really carry the portal credentials |
|
||||||
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
|
| `00:1A:79:00:00:0A` | **static-channel-cmd** | ITV rows carry a directly playable `cmd` with `use_http_tmp_link`/`use_load_balancing` both `'0'` — a client honouring the flags must play them without calling `create_link` |
|
||||||
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
| `<any other MAC>` | **auto** | MAC bytes used as seed → deterministic unique dataset |
|
||||||
@@ -178,7 +179,11 @@ repeated `macAddress` params in a single request) rather than clearing
|
|||||||
everything. The sibling specs that talk to this server (`self-hosted.e2e.ts`,
|
everything. The sibling specs that talk to this server (`self-hosted.e2e.ts`,
|
||||||
the `sources-pwa` helpers) own a disjoint `00:1A:79:5F:*` range for the same
|
the `sources-pwa` helpers) own a disjoint `00:1A:79:5F:*` range for the same
|
||||||
reason, and `stalker.e2e.ts` runs `mode: 'serial'` because its tests
|
reason, and `stalker.e2e.ts` runs `mode: 'serial'` because its tests
|
||||||
deliberately share scenario MACs. Full contract:
|
deliberately share scenario MACs. Its stateful authentication tests derive a
|
||||||
|
separate `00:1A:79:AE:<slot>:*` range from Playwright's bounded
|
||||||
|
`parallelIndex` and add only that range to the scoped reset, so concurrent
|
||||||
|
browser projects do not clear one another's sessions or pinned device
|
||||||
|
identity; restarted workers retain their slot. Full contract:
|
||||||
[`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker-mock-server.md#test-isolation).
|
[`docs/architecture/stalker-mock-server.md`](../../docs/architecture/stalker-mock-server.md#test-isolation).
|
||||||
|
|
||||||
## EPG Behavior
|
## EPG Behavior
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { handleHandshake } from './handshake.handler';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
|
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
|
||||||
|
const WORKER_LOGIN_REQUIRED_MAC = '00:1A:79:AE:05:02';
|
||||||
const PLAIN_MAC = '00:1A:79:00:00:01';
|
const PLAIN_MAC = '00:1A:79:00:00:01';
|
||||||
const BAD_FORMAT_MAC = 'AA:BB:CC:DD:EE:01';
|
const BAD_FORMAT_MAC = 'AA:BB:CC:DD:EE:01';
|
||||||
|
|
||||||
@@ -125,6 +126,34 @@ describe('stalker mock authentication handlers', () => {
|
|||||||
).toBe(null);
|
).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies the login-required scenario to parallel-slot MACs', () => {
|
||||||
|
const token = invoke(handleHandshake, { action: 'handshake' }, {
|
||||||
|
mac: WORKER_LOGIN_REQUIRED_MAC,
|
||||||
|
})['token'] as string;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
invoke(
|
||||||
|
handleGetProfile,
|
||||||
|
{ action: 'get_profile' },
|
||||||
|
{ mac: WORKER_LOGIN_REQUIRED_MAC, token }
|
||||||
|
)['status']
|
||||||
|
).toBe(2);
|
||||||
|
|
||||||
|
invoke(
|
||||||
|
handleDoAuth,
|
||||||
|
{ action: 'do_auth', login: 'user', password: 'secret' },
|
||||||
|
{ mac: WORKER_LOGIN_REQUIRED_MAC }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
invoke(
|
||||||
|
handleGetProfile,
|
||||||
|
{ action: 'get_profile' },
|
||||||
|
{ mac: WORKER_LOGIN_REQUIRED_MAC, token }
|
||||||
|
)['status']
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects a non-Infomir MAC and never adopts its token', () => {
|
it('rejects a non-Infomir MAC and never adopts its token', () => {
|
||||||
const token = invoke(handleHandshake, { action: 'handshake' }, {
|
const token = invoke(handleHandshake, { action: 'handshake' }, {
|
||||||
mac: BAD_FORMAT_MAC,
|
mac: BAD_FORMAT_MAC,
|
||||||
|
|||||||
@@ -179,6 +179,9 @@ export const SCENARIOS: Record<string, ScenarioConfig> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Worker-scoped aliases used by parallel auth E2E runs. */
|
||||||
|
const WORKER_LOGIN_REQUIRED_MAC = /^00:1a:79:ae:[0-9a-f]{2}:02$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a MAC address string to a numeric seed for unknown MACs.
|
* Convert a MAC address string to a numeric seed for unknown MACs.
|
||||||
* e.g. "AA:BB:CC:DD:EE:FF" -> sum of byte values
|
* e.g. "AA:BB:CC:DD:EE:FF" -> sum of byte values
|
||||||
@@ -196,6 +199,9 @@ export function getScenario(mac: string): ScenarioConfig {
|
|||||||
if (SCENARIOS[normalizedMac]) {
|
if (SCENARIOS[normalizedMac]) {
|
||||||
return SCENARIOS[normalizedMac];
|
return SCENARIOS[normalizedMac];
|
||||||
}
|
}
|
||||||
|
if (WORKER_LOGIN_REQUIRED_MAC.test(normalizedMac)) {
|
||||||
|
return SCENARIOS['00:1a:79:00:00:08'];
|
||||||
|
}
|
||||||
// Unknown MAC: use MAC bytes as seed, default shape
|
// Unknown MAC: use MAC bytes as seed, default shape
|
||||||
return {
|
return {
|
||||||
name: 'auto',
|
name: 'auto',
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
*
|
*
|
||||||
* 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"
|
||||||
*
|
*
|
||||||
* ISOLATION works on two levels, because one mock-server process is shared by
|
* ISOLATION works on three levels, because one mock-server process is shared by
|
||||||
* every spec file and its state is keyed by MAC:
|
* every spec file and its state is keyed by MAC:
|
||||||
*
|
*
|
||||||
* - ACROSS FILES: `beforeEach` resets only the MACs in `OWNED_MACS`, and the
|
* - ACROSS FILES: `beforeEach` resets only the MACs in `OWNED_MACS`, and the
|
||||||
@@ -33,26 +33,17 @@ import {
|
|||||||
* `minimal`, `embedded-series` — their fixture shapes are what the
|
* `minimal`, `embedded-series` — their fixture shapes are what the
|
||||||
* assertions are written against), and every `beforeEach` resets all of
|
* assertions are written against), and every `beforeEach` resets all of
|
||||||
* them. Under the workspace-wide `fullyParallel` preset that would let one
|
* 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
|
* test wipe another's data or session mid-run, so each project/repeat group
|
||||||
* single worker.
|
* runs its tests serially in one worker.
|
||||||
*
|
*
|
||||||
* Giving each test its own MAC instead would mean inventing a scenario per
|
* Giving each test its own MAC instead would mean inventing a scenario per
|
||||||
* test; serializing one file is the cheaper trade.
|
* test; serializing one file is the cheaper trade.
|
||||||
*
|
*
|
||||||
* ACROSS BROWSER PROJECTS this does NOT hold, and it is a local-run hazard
|
* - ACROSS BROWSER PROJECTS/WORKERS: state-sensitive auth tests use one MAC
|
||||||
* only. `mode: 'serial'` orders tests within one project; chromium, firefox
|
* range per Playwright parallel slot. Their `beforeEach` adds only that
|
||||||
* and webkit still run the file concurrently against the SAME mock server, so
|
* slot's MACs to the scoped reset, so concurrent projects cannot clear one
|
||||||
* one project's `beforeEach` reset can drop a session another project is
|
* another's token, invalidated session or pinned device id. A restarted
|
||||||
* mid-test on — the auth specs below are the ones that notice, failing as if
|
* worker retains its bounded `parallelIndex`.
|
||||||
* the portal had dropped them. CI never sees it: the Web E2E job runs
|
|
||||||
* `--project=chromium` alone (`.github/workflows/e2e-tests.yaml`). If a local
|
|
||||||
* all-project run shows a lone auth failure that passes on rerun, this is why;
|
|
||||||
* `--project=chromium` reproduces CI exactly.
|
|
||||||
*
|
|
||||||
* Most tests here tolerate that reset anyway — they re-authenticate, or assert
|
|
||||||
* that a NEW token appears. The token-reuse test cannot: its assertion IS that
|
|
||||||
* the portal session survives, so it uses per-project MACs held outside
|
|
||||||
* `OWNED_MACS`. See `AUTH_REUSE_MACS`.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
test.describe.configure({ mode: 'serial' });
|
test.describe.configure({ mode: 'serial' });
|
||||||
@@ -88,53 +79,55 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
|
|||||||
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
|
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Login-required scenario MAC — get_profile answers status 2 until do_auth
|
* These tests assert state transitions within one portal session, so a reset
|
||||||
* completes with non-empty credentials (empty credentials return {js:false},
|
* from a concurrent browser project or repeat worker would invalidate the
|
||||||
* as the operator billing script would).
|
* assertion itself. Giving every concurrent worker slot its own MAC range
|
||||||
|
* preserves browser parallelism and also keeps `--repeat-each` runs isolated.
|
||||||
*/
|
*/
|
||||||
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
|
interface StatefulAuthMacs {
|
||||||
|
authenticatedFlow: string;
|
||||||
|
loginRequired: string;
|
||||||
|
tokenReuse: string;
|
||||||
|
deviceConflict: string;
|
||||||
|
reauthentication: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
function getStatefulAuthMacs({
|
||||||
* Dedicated MACs for the full-portal authentication tests. Mock state is keyed
|
parallelIndex,
|
||||||
* 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.
|
parallelIndex: number;
|
||||||
* The Infomir OUI matters: the strict endpoint validates the MAC format.
|
}): StatefulAuthMacs {
|
||||||
*/
|
if (
|
||||||
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
|
!Number.isSafeInteger(parallelIndex) ||
|
||||||
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
|
parallelIndex < 0 ||
|
||||||
|
parallelIndex > 255
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported Playwright parallel index: ${parallelIndex}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workerOctet = parallelIndex
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')
|
||||||
|
.toUpperCase();
|
||||||
|
const workerPrefix = `00:1A:79:AE:${workerOctet}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticatedFlow: `${workerPrefix}:01`,
|
||||||
|
loginRequired: `${workerPrefix}:02`,
|
||||||
|
tokenReuse: `${workerPrefix}:03`,
|
||||||
|
deviceConflict: `${workerPrefix}:04`,
|
||||||
|
reauthentication: `${workerPrefix}:05`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The token-reuse test needs the portal session to SURVIVE untouched between
|
|
||||||
* its import and its reload — that persistence is the whole assertion. Every
|
|
||||||
* other test here is reset-tolerant (they either re-authenticate or assert a
|
|
||||||
* new token), so they can share `OWNED_MACS`, which each `beforeEach` clears.
|
|
||||||
*
|
|
||||||
* `mode: 'serial'` only serializes within one project; the browser projects
|
|
||||||
* still run concurrently, so a sibling project's reset would rotate this
|
|
||||||
* session mid-test. Hence one MAC per project, and deliberately NOT in
|
|
||||||
* `OWNED_MACS`: nothing may reset them. Leftover state from an earlier run is
|
|
||||||
* harmless — the import negotiates and adopts its own token first.
|
|
||||||
*/
|
|
||||||
const AUTH_REUSE_MACS: Record<string, string> = {
|
|
||||||
chromium: '00:1A:79:AD:01:01',
|
|
||||||
firefox: '00:1A:79:AD:01:02',
|
|
||||||
webkit: '00:1A:79:AD:01:03',
|
|
||||||
};
|
|
||||||
const AUTH_REUSE_FALLBACK_MAC = '00:1A:79:AD:01:04';
|
|
||||||
/**
|
/**
|
||||||
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
|
* Deliberately NOT an Infomir MAC: the strict endpoint rejects get_profile for
|
||||||
* it, so no token is ever adopted and content requests fail permanently.
|
* it, so no token is ever adopted and content requests fail permanently.
|
||||||
*/
|
*/
|
||||||
const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
|
const AUTH_REJECTED_MAC = 'AA:BB:CC:DD:EE:01';
|
||||||
|
|
||||||
/**
|
|
||||||
* Its own MAC because the test PINS a device id on the portal, and a pin is
|
|
||||||
* the one piece of mock state that outlives an invalidated session (a real
|
|
||||||
* portal never unpins `device_id` either). `beforeEach` clears it through
|
|
||||||
* `OWNED_MACS`, which drops the whole session record including the pin.
|
|
||||||
*/
|
|
||||||
const DEVICE_CONFLICT_MAC = '00:1A:79:00:00:0B';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -177,11 +170,7 @@ const OWNED_MACS = [
|
|||||||
EMBEDDED_SERIES_MAC,
|
EMBEDDED_SERIES_MAC,
|
||||||
LEGACY_PAGINATION_MAC,
|
LEGACY_PAGINATION_MAC,
|
||||||
STATIC_CMD_MAC,
|
STATIC_CMD_MAC,
|
||||||
LOGIN_REQUIRED_MAC,
|
|
||||||
AUTH_FLOW_MAC,
|
|
||||||
AUTH_REAUTH_MAC,
|
|
||||||
AUTH_REJECTED_MAC,
|
AUTH_REJECTED_MAC,
|
||||||
DEVICE_CONFLICT_MAC,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,10 +179,14 @@ const OWNED_MACS = [
|
|||||||
* workers, so a global reset here would wipe their state mid-test — and
|
* workers, so a global reset here would wipe their state mid-test — and
|
||||||
* theirs would wipe ours.
|
* theirs would wipe ours.
|
||||||
*/
|
*/
|
||||||
async function resetMockServer(request: APIRequestContext): Promise<void> {
|
async function resetMockServer(
|
||||||
const query = OWNED_MACS.map(
|
request: APIRequestContext,
|
||||||
(mac) => `macAddress=${encodeURIComponent(mac)}`
|
statefulAuthMacs: StatefulAuthMacs
|
||||||
).join('&');
|
): Promise<void> {
|
||||||
|
const resetMacs = [...OWNED_MACS, ...Object.values(statefulAuthMacs)];
|
||||||
|
const query = resetMacs
|
||||||
|
.map((mac) => `macAddress=${encodeURIComponent(mac)}`)
|
||||||
|
.join('&');
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
@@ -326,9 +319,9 @@ function recordPortalRequests(
|
|||||||
// Test setup
|
// Test setup
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
test.beforeEach(async ({ page, request }) => {
|
test.beforeEach(async ({ page, request }, testInfo) => {
|
||||||
// Reset mock server state (clears in-memory favorites and cache)
|
// Reset mock server state (clears in-memory favorites and cache)
|
||||||
await resetMockServer(request);
|
await resetMockServer(request, getStatefulAuthMacs(testInfo));
|
||||||
|
|
||||||
// Playwright creates a fresh browser context per test, so extra
|
// Playwright creates a fresh browser context per test, so extra
|
||||||
// IndexedDB cleanup here only risks racing with app-managed DB handles.
|
// IndexedDB cleanup here only risks racing with app-managed DB handles.
|
||||||
@@ -349,6 +342,14 @@ test('@stalker health check — mock server is running', async ({ request }) =>
|
|||||||
expect(body.status).toBe('ok');
|
expect(body.status).toBe('ok');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('@stalker auth MAC allocation survives worker process restarts', () => {
|
||||||
|
const restartedWorker = { parallelIndex: 7, workerIndex: 256 };
|
||||||
|
|
||||||
|
expect(getStatefulAuthMacs(restartedWorker).loginRequired).toBe(
|
||||||
|
'00:1A:79:AE:07:02'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('@stalker add a Stalker portal and see it in the playlist list', async ({
|
test('@stalker add a Stalker portal and see it in the playlist list', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -994,11 +995,12 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
|
|
||||||
test('handshakes and authenticates before loading content', async ({
|
test('handshakes and authenticates before loading content', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
const requests = recordPortalRequests(page);
|
const requests = recordPortalRequests(page);
|
||||||
const actionsInOrder = () => requests.map((entry) => entry.action);
|
const actionsInOrder = () => requests.map((entry) => entry.action);
|
||||||
|
const mac = getStatefulAuthMacs(testInfo).authenticatedFlow;
|
||||||
|
|
||||||
await addFullStalkerPortal(page, { mac: AUTH_FLOW_MAC });
|
await addFullStalkerPortal(page, { mac });
|
||||||
|
|
||||||
// The portal only answers content actions for an adopted token, so
|
// The portal only answers content actions for an adopted token, so
|
||||||
// reaching the VOD categories at all proves the whole chain ran.
|
// reaching the VOD categories at all proves the whole chain ran.
|
||||||
@@ -1052,8 +1054,9 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
|
|
||||||
test('completes the documented login flow behind get_profile status 2', async ({
|
test('completes the documented login flow behind get_profile status 2', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
const requests = recordPortalRequests(page);
|
const requests = recordPortalRequests(page);
|
||||||
|
const mac = getStatefulAuthMacs(testInfo).loginRequired;
|
||||||
// The second auth step must only be claimed on the retry AFTER
|
// The second auth step must only be claimed on the retry AFTER
|
||||||
// do_auth — a client that always sends auth_second_step=1 works
|
// do_auth — a client that always sends auth_second_step=1 works
|
||||||
// against the mock but violates the documented protocol.
|
// against the mock but violates the documented protocol.
|
||||||
@@ -1070,7 +1073,7 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await addFullStalkerPortal(page, {
|
await addFullStalkerPortal(page, {
|
||||||
mac: LOGIN_REQUIRED_MAC,
|
mac,
|
||||||
username: 'user',
|
username: 'user',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
});
|
});
|
||||||
@@ -1107,7 +1110,8 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
|
|
||||||
test('explains a login-required portal and recovers once credentials are entered', async ({
|
test('explains a login-required portal and recovers once credentials are entered', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
|
const mac = getStatefulAuthMacs(testInfo).loginRequired;
|
||||||
// First attempt without credentials: the import must fail with the
|
// First attempt without credentials: the import must fail with the
|
||||||
// portal's actual reason, not a generic "check URL and MAC" error.
|
// portal's actual reason, not a generic "check URL and MAC" error.
|
||||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||||
@@ -1117,10 +1121,7 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
|
|
||||||
await setInputValue(dialog.locator('input#title'), 'Login Portal');
|
await setInputValue(dialog.locator('input#title'), 'Login Portal');
|
||||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||||
await setInputValue(
|
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||||
dialog.locator('input#macAddress'),
|
|
||||||
LOGIN_REQUIRED_MAC
|
|
||||||
);
|
|
||||||
|
|
||||||
const addButton = dialog.getByRole('button', {
|
const addButton = dialog.getByRole('button', {
|
||||||
name: 'Add',
|
name: 'Add',
|
||||||
@@ -1152,8 +1153,7 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
const requests = recordPortalRequests(page);
|
const requests = recordPortalRequests(page);
|
||||||
const mac =
|
const mac = getStatefulAuthMacs(testInfo).tokenReuse;
|
||||||
AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC;
|
|
||||||
|
|
||||||
await addFullStalkerPortal(page, { mac });
|
await addFullStalkerPortal(page, { mac });
|
||||||
|
|
||||||
@@ -1275,7 +1275,8 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
test('explains a device conflict instead of relaying "STB is damaged"', async ({
|
test('explains a device conflict instead of relaying "STB is damaged"', async ({
|
||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
|
const mac = getStatefulAuthMacs(testInfo).deviceConflict;
|
||||||
// Pin a device id the way another client (StbEmu, a set-top box)
|
// Pin a device id the way another client (StbEmu, a set-top box)
|
||||||
// would have: the stock server binds the first non-empty `device_id`
|
// would have: the stock server binds the first non-empty `device_id`
|
||||||
// it sees to the MAC and refuses every different one afterwards.
|
// it sees to the MAC and refuses every different one afterwards.
|
||||||
@@ -1284,7 +1285,7 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
`${BACKEND_PROXY}?url=${encodeURIComponent(
|
`${BACKEND_PROXY}?url=${encodeURIComponent(
|
||||||
FULL_PORTAL_URL
|
FULL_PORTAL_URL
|
||||||
)}&macAddress=${encodeURIComponent(
|
)}&macAddress=${encodeURIComponent(
|
||||||
DEVICE_CONFLICT_MAC
|
mac
|
||||||
)}&action=get_profile&type=stb&device_id=PINNED-DEVICE-A`
|
)}&action=get_profile&type=stb&device_id=PINNED-DEVICE-A`
|
||||||
)
|
)
|
||||||
).json();
|
).json();
|
||||||
@@ -1299,10 +1300,7 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
|
|
||||||
await setInputValue(dialog.locator('input#title'), 'Conflict Portal');
|
await setInputValue(dialog.locator('input#title'), 'Conflict Portal');
|
||||||
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
|
||||||
await setInputValue(
|
await setInputValue(dialog.locator('input#macAddress'), mac);
|
||||||
dialog.locator('input#macAddress'),
|
|
||||||
DEVICE_CONFLICT_MAC
|
|
||||||
);
|
|
||||||
await setInputValue(
|
await setInputValue(
|
||||||
dialog.locator('input#deviceId1'),
|
dialog.locator('input#deviceId1'),
|
||||||
'DIFFERENT-DEVICE-B'
|
'DIFFERENT-DEVICE-B'
|
||||||
@@ -1333,10 +1331,11 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
test('re-authenticates after the portal drops the session', async ({
|
test('re-authenticates after the portal drops the session', async ({
|
||||||
page,
|
page,
|
||||||
request,
|
request,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
const requests = recordPortalRequests(page);
|
const requests = recordPortalRequests(page);
|
||||||
|
const mac = getStatefulAuthMacs(testInfo).reauthentication;
|
||||||
|
|
||||||
await addFullStalkerPortal(page, { mac: AUTH_REAUTH_MAC });
|
await addFullStalkerPortal(page, { mac });
|
||||||
|
|
||||||
// `addFullStalkerPortal` only awaits the route change, and on a slow
|
// `addFullStalkerPortal` only awaits the route change, and on a slow
|
||||||
// runner the first authenticated content request has not necessarily
|
// runner the first authenticated content request has not necessarily
|
||||||
@@ -1364,7 +1363,7 @@ test.describe('@stalker full portal authentication', () => {
|
|||||||
// like: the next request gets "Authorization failed." with HTTP 200.
|
// like: the next request gets "Authorization failed." with HTTP 200.
|
||||||
const invalidated = await request.post(
|
const invalidated = await request.post(
|
||||||
`${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent(
|
`${MOCK_SERVER}/invalidate-session?macAddress=${encodeURIComponent(
|
||||||
AUTH_REAUTH_MAC
|
mac
|
||||||
)}`
|
)}`
|
||||||
);
|
);
|
||||||
expect(invalidated.ok()).toBe(true);
|
expect(invalidated.ok()).toBe(true);
|
||||||
|
|||||||
@@ -318,6 +318,11 @@ supporting scenarios, `get_all_channels` (`get-all-channels.handler.ts`,
|
|||||||
`{ js: { data, total_items } }` response, excluding channels from censored
|
`{ js: { data, total_items } }` response, excluding channels from censored
|
||||||
(adult) genres.
|
(adult) genres.
|
||||||
|
|
||||||
|
The login-required scenario also recognizes
|
||||||
|
`00:1A:79:AE:<slot>:02`. This test-only alias range preserves the same
|
||||||
|
`status: 2`/`do_auth` contract while giving concurrent Playwright parallel
|
||||||
|
slots independent per-MAC auth state.
|
||||||
|
|
||||||
The `marketing-demo` scenario (`00:1A:79:00:00:07`) replaces faker-generated
|
The `marketing-demo` scenario (`00:1A:79:00:00:07`) replaces faker-generated
|
||||||
VOD with the 35-movie provider-neutral showcase catalog from
|
VOD with the 35-movie provider-neutral showcase catalog from
|
||||||
`@iptvnator/shared/marketing-fixtures`. Its newest 20 movies are returned first
|
`@iptvnator/shared/marketing-fixtures`. Its newest 20 movies are returned first
|
||||||
@@ -388,16 +393,20 @@ file running in parallel workers, so isolation is per-MAC rather than global:
|
|||||||
MACs. A bare `POST /reset` clears everything and is only safe when nothing
|
MACs. A bare `POST /reset` clears everything and is only safe when nothing
|
||||||
else is talking to the server — a spec that used it would wipe a sibling
|
else is talking to the server — a spec that used it would wipe a sibling
|
||||||
spec's session mid-test.
|
spec's session mid-test.
|
||||||
- `apps/web-e2e/src/stalker.e2e.ts` declares the MACs it owns in `OWNED_MACS`
|
- `apps/web-e2e/src/stalker.e2e.ts` declares its shared scenario MACs in
|
||||||
and clears exactly those in one batched request. The sibling specs that reach
|
`OWNED_MACS` and clears them in one batched request. The sibling specs that
|
||||||
this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a disjoint
|
reach this server (`self-hosted.e2e.ts`, the `sources-pwa` helpers) own a
|
||||||
`00:1A:79:5F:*` range, so neither file can clear the other's state.
|
disjoint `00:1A:79:5F:*` range, so neither file can clear the other's state.
|
||||||
- Within the file, tests deliberately share scenario MACs (their fixture shapes
|
- Within each browser project, tests deliberately share content-scenario MACs
|
||||||
are what the assertions are written against), so it pins itself to one worker
|
(their fixture shapes are what the assertions are written against), so the
|
||||||
with `test.describe.configure({ mode: 'serial' })`.
|
file uses `test.describe.configure({ mode: 'serial' })`.
|
||||||
- A few MACs are deliberately kept OUT of `OWNED_MACS`: the token-reuse test
|
- Authentication tests whose assertions span multiple requests derive a
|
||||||
asserts that a session SURVIVES, so nothing may reset it, and it uses one MAC
|
disjoint `00:1A:79:AE:<slot>:*` range from Playwright's bounded
|
||||||
per browser project.
|
`parallelIndex`. Their `beforeEach` adds only the current parallel slot's
|
||||||
|
range to the batched reset, so browser projects cannot clear one another's
|
||||||
|
token, login completion, invalidated session or pinned device identity; a
|
||||||
|
restarted worker retains the same slot instead of consuming a wider MAC
|
||||||
|
value.
|
||||||
|
|
||||||
A reset drops the generated content cache, favorites (`data-store.ts`), the
|
A reset drops the generated content cache, favorites (`data-store.ts`), the
|
||||||
auth/session record (`auth-store.ts`, including any pinned device identity) and
|
auth/session record (`auth-store.ts`, including any pinned device identity) and
|
||||||
|
|||||||
Reference in new issue
Block a user