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>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-01 17:40:53 +02:00
1 parent 149df18c32
commit ad27c06396
2 files changed
+77 -4

No files matched your search

+11
View File
@@ -43,6 +43,17 @@ imported as a **full portal** (handshake + token + watchdog), anything else as a
| `/stalker_portal/server/load.php` | Strict. Enforces the token and the MAC format exactly like the real middleware. |
| `/server/load.php` | Strict. The second full-portal URL shape the app recognizes; enforced identically. |
> **Known app inconsistency:** `StalkerSessionService.isFullStalkerPortal`
> classifies `/server/load.php` as a full portal, but the import dialog's
> `isFullStalkerPortalUrl` checks only for `/stalker_portal`, so importing a bare
> `…/server/load.php` URL persists `isFullStalkerPortal: false` and the app skips
> the handshake. The mock is deliberately faithful to a **real** portal here
> (that path enforces auth), which makes it the right fixture to drive the
> upcoming fix that unifies those two predicates. Until then, import full
> portals through a `/stalker_portal/...` URL.
> **Known app-side gap:** the
The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can
actually get wrong:
+66 -4
View File
@@ -114,6 +114,34 @@ function recordPortalActions(page: Page): {
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
@@ -177,9 +205,16 @@ test.describe('@stalker full portal authentication', () => {
page,
request,
}) => {
const requests = recordPortalRequests(page);
await addFullStalkerPortal(page);
const { actions } = recordPortalActions(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.
@@ -190,15 +225,42 @@ test.describe('@stalker full portal authentication', () => {
);
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(() => actions.filter((a) => a === 'handshake').length, {
timeout: 30_000,
})
.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.'
);