feat(stalker): protocol-correct auth lifecycle (#1354)

This commit is contained in:
4gray authored and GitHub committed 2026-08-03 23:06:47 +02:00
1 parent e197409b10
commit d2a83164ec
56 files changed
+4807 -709

No files matched your search

+243 -21
View File
@@ -48,6 +48,11 @@ import {
* `--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' });
@@ -82,6 +87,13 @@ const LEGACY_PAGINATION_MAC = '00:1A:79:00:00:06';
*/
const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
/**
* Login-required scenario MAC — get_profile answers status 2 until do_auth
* completes with non-empty credentials (empty credentials return {js:false},
* as the operator billing script would).
*/
const LOGIN_REQUIRED_MAC = '00:1A:79:00:00:08';
/**
* 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
@@ -90,6 +102,25 @@ const STATIC_CMD_MAC = '00:1A:79:00:00:0A';
*/
const AUTH_FLOW_MAC = '00:1A:79:AD:00:01';
const AUTH_REAUTH_MAC = '00:1A:79:AD:00:03';
/**
* 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
* it, so no token is ever adopted and content requests fail permanently.
@@ -138,6 +169,7 @@ const OWNED_MACS = [
EMBEDDED_SERIES_MAC,
LEGACY_PAGINATION_MAC,
STATIC_CMD_MAC,
LOGIN_REQUIRED_MAC,
AUTH_FLOW_MAC,
AUTH_REAUTH_MAC,
AUTH_REJECTED_MAC,
@@ -212,9 +244,21 @@ async function addStalkerPortal(
*/
async function addFullStalkerPortal(
page: Page,
options: { name?: string; mac: string; expectContent?: boolean }
options: {
name?: string;
mac: string;
expectContent?: boolean;
username?: string;
password?: string;
}
): Promise<void> {
const { name = 'Full Stalker Portal', mac, expectContent = true } = options;
const {
name = 'Full Stalker Portal',
mac,
expectContent = true,
username,
password,
} = options;
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
@@ -224,6 +268,12 @@ async function addFullStalkerPortal(
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(dialog.locator('input#macAddress'), mac);
if (username !== undefined) {
await setInputValue(dialog.locator('input#username'), username);
}
if (password !== undefined) {
await setInputValue(dialog.locator('input#password'), password);
}
const addButton = dialog.getByRole('button', { name: 'Add', exact: true });
await expect(addButton).toBeEnabled({ timeout: 10_000 });
@@ -991,12 +1041,171 @@ test.describe('@stalker full portal authentication', () => {
.toBe(true);
});
test('never surfaces the portal plain-text auth failure as content', async ({
test('completes the documented login flow behind get_profile status 2', async ({
page,
}) => {
const requests = recordPortalRequests(page);
// The second auth step must only be claimed on the retry AFTER
// do_auth — a client that always sends auth_second_step=1 works
// against the mock but violates the documented protocol.
const profileSteps: string[] = [];
page.on('request', (request) => {
const url = new URL(request.url());
if (!url.pathname.endsWith('/stalker')) {
return;
}
if (url.searchParams.get('action') !== 'get_profile') {
return;
}
profileSteps.push(url.searchParams.get('auth_second_step') ?? '');
});
await addFullStalkerPortal(page, {
mac: LOGIN_REQUIRED_MAC,
username: 'user',
password: 'secret',
});
// The mock only adopts the token (and answers content) after do_auth
// completed with non-empty credentials, so rendered categories prove
// the whole status 2 -> do_auth -> profile retry chain ran.
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
const actions = requests.map((entry) => entry.action);
expect(actions).toContain('do_auth');
expect(actions.indexOf('get_profile')).toBeLessThan(
actions.indexOf('do_auth')
);
expect(actions.lastIndexOf('get_profile')).toBeGreaterThan(
actions.indexOf('do_auth')
);
expect(profileSteps[0]).toBe('0');
expect(profileSteps).toContain('1');
// Content only flows once the token is adopted, which the mock does
// only after do_auth completed. Look AFTER the last get_profile:
// discovery's classification probe is itself a token-less
// `get_genres`, so the first CONTENT_ACTIONS hit is that probe.
const contentRequest = requests
.slice(actions.lastIndexOf('get_profile') + 1)
.find((entry) => CONTENT_ACTIONS.includes(entry.action));
expect(contentRequest).toBeDefined();
expect(contentRequest?.token).toBeTruthy();
});
test('explains a login-required portal and recovers once credentials are entered', async ({
page,
}) => {
// First attempt without credentials: the import must fail with the
// portal's actual reason, not a generic "check URL and MAC" error.
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'), 'Login Portal');
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(
dialog.locator('input#macAddress'),
LOGIN_REQUIRED_MAC
);
const addButton = dialog.getByRole('button', {
name: 'Add',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(
page.getByText(/requires a login and password/i)
).toBeVisible({ timeout: 15_000 });
// The dialog stays open so the user can add the credentials.
await expect(dialog).toBeVisible();
// Second attempt with credentials in the same dialog succeeds.
await setInputValue(dialog.locator('input#username'), 'user');
await setInputValue(dialog.locator('input#password'), 'secret');
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
await expect(dialog).toBeHidden({ timeout: 30_000 });
await page.waitForURL(/stalker.*vod/, { timeout: 30_000 });
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 30_000,
});
});
test('reuses the persisted token after a reload instead of re-running get_profile', async ({
page,
}, testInfo) => {
const requests = recordPortalRequests(page);
const mac =
AUTH_REUSE_MACS[testInfo.project.name] ?? AUTH_REUSE_FALLBACK_MAC;
await addFullStalkerPortal(page, { mac });
await expect
.poll(
() =>
requests.some(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token
),
{ timeout: 30_000 }
)
.toBe(true);
const sessionToken = requests.find(
(entry) => CONTENT_ACTIONS.includes(entry.action) && entry.token
)?.token;
expect(sessionToken).toBeTruthy();
const requestCountBeforeReload = requests.length;
await page.reload();
// Wait on the UI rather than the request log: a reload restores the
// route, boots the app and re-authenticates before the first content
// request goes out, and rendered categories prove that whole chain
// finished (the portal only answers content for an adopted token).
await expect(page.locator('.category-item').first()).toBeVisible({
timeout: 60_000,
});
const afterReload = requests.slice(requestCountBeforeReload);
// Content came back under the SAME token, negotiated by re-presenting
// the persisted one in the handshake.
expect(
afterReload.filter(
(entry) =>
CONTENT_ACTIONS.includes(entry.action) &&
entry.token === sessionToken
).length
).toBeGreaterThan(0);
// The handshake re-presented the persisted token...
const reloadHandshake = afterReload.find(
(entry) => entry.action === 'handshake'
);
expect(reloadHandshake?.token).toBe(sessionToken);
// ...and because the portal returned it unchanged (idempotent
// handshake, still-adopted session), the profile round trip was
// skipped entirely.
expect(
afterReload.filter((entry) => entry.action === 'get_profile')
).toHaveLength(0);
});
test('refuses a rejected portal at import and never renders its plain-text failure', 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
@@ -1014,25 +1223,38 @@ test.describe('@stalker full portal authentication', () => {
).json();
expect(failureBody.payload).toBe('Authorization failed.');
await addFullStalkerPortal(page, {
mac: AUTH_REJECTED_MAC,
expectContent: false,
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'), 'Rejected Portal');
await setInputValue(dialog.locator('input#portalUrl'), FULL_PORTAL_URL);
await setInputValue(
dialog.locator('input#macAddress'),
AUTH_REJECTED_MAC
);
const addButton = dialog.getByRole('button', {
name: 'Add',
exact: true,
});
await expect(addButton).toBeEnabled({ timeout: 10_000 });
await addButton.click();
// 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);
// `status: 1` means the portal refused this device, so the import must
// stop there instead of persisting a portal that can never load. (It
// used to be treated as success whenever the portal sent no msg text,
// which imported a dead source.)
await expect(page.getByText(/refused access/i)).toBeVisible({
timeout: 15_000,
});
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/stalker/);
// ...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.
// And the raw portal response is never painted as UI. A portal answers
// auth failures with HTTP 200 + plain text, so an app that trusts the
// status code would happily render these strings.
await expect(page.locator('body')).not.toContainText(
'Authorization failed.'
);