From 830405f9d7da3c37068303fb9abd296489a4affd Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:59:20 +0200 Subject: [PATCH] test(stalker): expand compatibility replay matrix --- .../replay/classifiers/ambiguous-403.json | 81 ++++ .../classifiers/authorization-failed-200.json | 81 ++++ .../classifiers/authorization-failed-403.json | 81 ++++ .../authorization-failed-near-miss-200.json | 81 ++++ .../classifiers/profile-status-strings.json | 194 +++++++++ .../replay/classifiers/wrong-mime-json.json | 77 ++++ .../replay/resolver/downgrade-protection.json | 376 +++++++++++++++++ .../app/replay/replay-fixture-corpus.spec.ts | 386 +++++++++++++++++- 8 files changed, 1356 insertions(+), 1 deletion(-) create mode 100644 apps/stalker-mock-server/fixtures/replay/classifiers/ambiguous-403.json create mode 100644 apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-200.json create mode 100644 apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-403.json create mode 100644 apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-near-miss-200.json create mode 100644 apps/stalker-mock-server/fixtures/replay/classifiers/profile-status-strings.json create mode 100644 apps/stalker-mock-server/fixtures/replay/classifiers/wrong-mime-json.json create mode 100644 apps/stalker-mock-server/fixtures/replay/resolver/downgrade-protection.json diff --git a/apps/stalker-mock-server/fixtures/replay/classifiers/ambiguous-403.json b/apps/stalker-mock-server/fixtures/replay/classifiers/ambiguous-403.json new file mode 100644 index 000000000..7b5079dac --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/classifiers/ambiguous-403.json @@ -0,0 +1,81 @@ +{ + "description": "Synthetic ambiguous HTTP 403 is incompatible and neither token-rejection nor portal-protection evidence.", + "entry": { + "origin": "portal", + "path": "/portal.php" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "ambiguous-forbidden", + "method": "GET", + "operation": "ambiguous-forbidden", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_profile", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "error": "Forbidden" + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 403 + } + } + ], + "mode": "ordered", + "name": "ambiguous-forbidden", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "classifier-ambiguous-403", + "schemaVersion": 1, + "symbols": [], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-200.json b/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-200.json new file mode 100644 index 000000000..b6c64e933 --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-200.json @@ -0,0 +1,81 @@ +{ + "description": "Synthetic canonical HTTP 200 authorization failure is explicit token-rejection evidence.", + "entry": { + "origin": "portal", + "path": "/portal.php" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "authorization-failed", + "method": "GET", + "operation": "authorization-failed", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_ordered_list", + "type": "vod" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "error": "Authorization failed" + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "authorization-failed", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "classifier-authorization-failed-200", + "schemaVersion": 1, + "symbols": [], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-403.json b/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-403.json new file mode 100644 index 000000000..f154b5ab5 --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-403.json @@ -0,0 +1,81 @@ +{ + "description": "Synthetic canonical HTTP 403 authorization failure is token-rejection evidence rather than an ambiguous forbidden response.", + "entry": { + "origin": "portal", + "path": "/portal.php" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "authorization-failed-forbidden", + "method": "GET", + "operation": "authorization-failed-forbidden", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_ordered_list", + "type": "vod" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "error": "Authorization failed" + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 403 + } + } + ], + "mode": "ordered", + "name": "authorization-failed-forbidden", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "classifier-auth-failed-403", + "schemaVersion": 1, + "symbols": [], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-near-miss-200.json b/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-near-miss-200.json new file mode 100644 index 000000000..d27a8ae23 --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/classifiers/authorization-failed-near-miss-200.json @@ -0,0 +1,81 @@ +{ + "description": "Synthetic noncanonical HTTP 200 authorization text is incompatible rather than token-rejection evidence.", + "entry": { + "origin": "portal", + "path": "/portal.php" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "authorization-failed-near-miss", + "method": "GET", + "operation": "authorization-failed-near-miss", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_ordered_list", + "type": "vod" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "error": "Authorization failed." + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "authorization-failed-near-miss", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "classifier-authorization-failed-near-miss-200", + "schemaVersion": 1, + "symbols": [], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/fixtures/replay/classifiers/profile-status-strings.json b/apps/stalker-mock-server/fixtures/replay/classifiers/profile-status-strings.json new file mode 100644 index 000000000..4c88bb6f7 --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/classifiers/profile-status-strings.json @@ -0,0 +1,194 @@ +{ + "description": "Synthetic string profile statuses preserve the same ready, blocked, and credential-required meanings as numeric statuses.", + "entry": { + "origin": "portal", + "path": "/portal.php" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "profile-string-ready", + "method": "GET", + "operation": "profile-string-ready", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_profile", + "auth_second_step": "0", + "case": "string-zero", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "id": 1, + "status": "0", + "store_auth_data_on_stb": true + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "profile-string-blocked", + "method": "GET", + "operation": "profile-string-blocked", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_profile", + "auth_second_step": "0", + "case": "string-one", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "block_msg": "Synthetic blocked profile", + "status": "1", + "store_auth_data_on_stb": false + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "profile-string-credentials", + "method": "GET", + "operation": "profile-string-credentials", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_profile", + "auth_second_step": "0", + "case": "string-two", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "credentials_required": true, + "status": "2" + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "profile-status-strings", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "classifier-profile-status-strings", + "schemaVersion": 1, + "symbols": [], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/fixtures/replay/classifiers/wrong-mime-json.json b/apps/stalker-mock-server/fixtures/replay/classifiers/wrong-mime-json.json new file mode 100644 index 000000000..5d810a51f --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/classifiers/wrong-mime-json.json @@ -0,0 +1,77 @@ +{ + "description": "Synthetic valid JSON labeled as HTML fails closed instead of being compatibility-sniffed.", + "entry": { + "origin": "portal", + "path": "/portal.php" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "wrong-mime-json", + "method": "GET", + "operation": "wrong-mime-json", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [], + "exact": {}, + "present": [] + }, + "query": { + "absent": [], + "exact": { + "action": "get_profile", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "text", + "value": "{\"js\":{\"status\":0}}" + }, + "headers": { + "content-type": [ + "text/html" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "wrong-mime-json", + "nextState": "complete", + "state": "start" + } + ], + "scenarioId": "classifier-wrong-mime-json", + "schemaVersion": 1, + "symbols": [], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/fixtures/replay/resolver/downgrade-protection.json b/apps/stalker-mock-server/fixtures/replay/resolver/downgrade-protection.json new file mode 100644 index 000000000..99b87bcf5 --- /dev/null +++ b/apps/stalker-mock-server/fixtures/replay/resolver/downgrade-protection.json @@ -0,0 +1,376 @@ +{ + "description": "Synthetic early stateless evidence cannot be selected after a later candidate exposes portal-protection evidence.", + "entry": { + "origin": "portal", + "path": "/" + }, + "expectedEndpoint": { + "origin": "portal", + "path": "/portal.php" + }, + "failOnUnexpectedRequest": true, + "initialState": "start", + "origins": { + "portal": {} + }, + "phases": [ + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "root-probe", + "method": "GET", + "operation": "root-probe", + "origin": "portal", + "path": "/", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "mac", + "session" + ], + "attributes": {}, + "exact": {}, + "present": [] + }, + "headers": { + "absent": [ + "authorization", + "cookie" + ], + "exact": {}, + "present": [] + }, + "query": { + "absent": [ + "device_id", + "device_id2", + "mac", + "password", + "signature", + "signature2", + "sn", + "token", + "username" + ], + "exact": {}, + "present": [] + } + }, + "response": { + "body": { + "kind": "empty" + }, + "headers": {}, + "status": 204 + } + } + ], + "mode": "ordered", + "name": "root-probe", + "nextState": "stateless-candidate", + "state": "start" + }, + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "unsupported-handshake", + "method": "GET", + "operation": "unsupported-handshake", + "origin": "portal", + "path": "/server/load.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + } + }, + "present": [] + }, + "headers": { + "absent": [ + "authorization" + ], + "exact": {}, + "present": [] + }, + "query": { + "absent": [ + "password", + "token", + "username" + ], + "exact": { + "action": "handshake", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "error": "synthetic-unsupported" + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 404 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "stateless-capability", + "method": "GET", + "operation": "stateless-capability", + "origin": "portal", + "path": "/server/load.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + } + }, + "present": [] + }, + "headers": { + "absent": [ + "authorization" + ], + "exact": {}, + "present": [] + }, + "query": { + "absent": [ + "password", + "token", + "username" + ], + "exact": { + "action": "get_genres", + "type": "itv" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": [ + { + "id": "1", + "title": "Synthetic" + } + ] + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + } + ], + "mode": "ordered", + "name": "stateless-candidate", + "nextState": "protected-candidate", + "state": "stateless-candidate" + }, + { + "expectations": [ + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "authenticated-handshake", + "method": "GET", + "operation": "authenticated-handshake", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + } + }, + "present": [] + }, + "headers": { + "absent": [ + "authorization" + ], + "exact": {}, + "present": [] + }, + "query": { + "absent": [ + "password", + "token", + "username" + ], + "exact": { + "action": "handshake", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "json", + "value": { + "js": { + "token": { + "kind": "generate", + "symbol": "authenticated-token", + "valueKind": "token" + } + } + } + }, + "headers": { + "content-type": [ + "application/json" + ] + }, + "status": 200 + } + }, + { + "cardinality": { + "max": 1, + "min": 1 + }, + "id": "protected-first-profile", + "method": "GET", + "operation": "protected-first-profile", + "origin": "portal", + "path": "/portal.php", + "request": { + "body": { + "kind": "absent" + }, + "cookies": { + "absent": [ + "session" + ], + "attributes": {}, + "exact": { + "mac": { + "kind": "ref", + "symbol": "mac" + } + }, + "present": [] + }, + "headers": { + "absent": [], + "exact": { + "authorization": { + "kind": "parts", + "parts": [ + { + "kind": "literal", + "value": "Bearer " + }, + { + "kind": "ref", + "symbol": "authenticated-token" + } + ] + } + }, + "present": [] + }, + "query": { + "absent": [ + "password", + "username" + ], + "exact": { + "action": "get_profile", + "auth_second_step": "0", + "type": "stb" + }, + "present": [] + } + }, + "response": { + "body": { + "kind": "text", + "value": "Synthetic Web Application Firewall challenge" + }, + "headers": { + "content-type": [ + "text/html" + ] + }, + "status": 403 + } + } + ], + "mode": "ordered", + "name": "protected-candidate", + "nextState": "complete", + "state": "protected-candidate" + } + ], + "scenarioId": "resolver-downgrade-protection", + "schemaVersion": 1, + "symbols": [ + { + "kind": "generate", + "symbol": "mac", + "valueKind": "mac" + } + ], + "terminalState": "complete" +} diff --git a/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts index 62a98a64a..100dff5af 100644 --- a/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts +++ b/apps/stalker-mock-server/src/app/replay/replay-fixture-corpus.spec.ts @@ -1,7 +1,12 @@ /* eslint-disable max-lines -- The committed corpus matrix and its typed deterministic driver form one auditable contract. */ import { readdirSync, readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { classifyStalkerDoAuth } from '@iptvnator/portal/stalker/protocol'; +import { + classifyStalkerDoAuth, + classifyStalkerProfile, + classifyStalkerResponseFailure, + parseStalkerResponseEnvelope, +} from '@iptvnator/portal/stalker/protocol'; import { createReplayRun, type ReplayRun } from './replay-run.js'; import { parseReplayFixtureText } from './replay-schema.js'; import type { @@ -32,10 +37,16 @@ const EXPECTED_SCENARIOS = [ 'authentication-status2-second-step', 'authentication-three-attempt-limit', 'classifier-access-denied-200', + 'classifier-ambiguous-403', + 'classifier-auth-failed-403', + 'classifier-authorization-failed-200', + 'classifier-authorization-failed-near-miss-200', 'classifier-oversized-response', + 'classifier-profile-status-strings', 'classifier-rate-limit-429', 'classifier-service-unavailable-503', 'classifier-waf-403', + 'classifier-wrong-mime-json', 'cookies-managed-shadow', 'cookies-session-isolation', 'e2e-concurrent-catalog-refresh', @@ -50,6 +61,7 @@ const EXPECTED_SCENARIOS = [ 'resolver-custom-prefix-full', 'resolver-direct-load-jsonp', 'resolver-direct-portal-statusless', + 'resolver-downgrade-protection', 'resolver-learned-rediscovery', 'resolver-root-full-wins', 'resolver-root-landing', @@ -68,6 +80,31 @@ const RESERVED_EARLY_QUERY_FIELDS = [ ] as const; const RESERVED_EARLY_HEADERS = ['authorization', 'cookie'] as const; const RESERVED_EARLY_COOKIES = ['mac', 'session'] as const; +const UNIT_OWNED_COOKIE_MATRIX = [ + { + contract: + 'RFC Domain, Path, Secure, HttpOnly, and duplicate-name selection', + evidence: + 'applies RFC domain, path, secure, HttpOnly, and duplicate-name rules', + spec: 'apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts', + }, + { + contract: 'cookie expiry against a deterministic clock', + evidence: 'honors expiry against an injected clock', + spec: 'apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts', + }, + { + contract: 'public-suffix Domain rejection', + evidence: 'rejects public-suffix cookies without retaining them', + spec: 'apps/electron-backend/src/app/services/stalker-session/stalker-cookie-jar.spec.ts', + }, + { + contract: 'redirect-hop cookie collection and rotation', + evidence: + 'prepares and collects the cookie jar on every same-origin hop without flattening Set-Cookie arrays', + spec: 'apps/electron-backend/src/app/services/stalker-session/stalker-http-session.spec.ts', + }, +] as const; function collectFixturePaths(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }) @@ -594,6 +631,57 @@ function assertAnonymousRequestHasNoReservedSecrets( ); } +function loadFixture(scenarioId: string): ReplayFixtureV1 { + for (const fixturePath of collectFixturePaths(FIXTURE_ROOT)) { + const fixture = parseReplayFixtureText( + readFileSync(fixturePath, 'utf8') + ); + if (fixture.scenarioId === scenarioId) { + return fixture; + } + } + throw new Error(`Replay matrix fixture missing: ${scenarioId}.`); +} + +function findExpectation( + fixture: ReplayFixtureV1, + operation: string +): ReplayExpectation { + const expectation = fixture.phases + .flatMap((phase) => phase.expectations) + .find((candidate) => candidate.operation === operation); + if (expectation === undefined) { + throw new Error( + `Replay matrix operation missing: ${fixture.scenarioId}/${operation}.` + ); + } + return expectation; +} + +function jsonResponseValue(expectation: ReplayExpectation): unknown { + if (expectation.response.body.kind !== 'json') { + throw new Error( + `Replay matrix response is not JSON: ${expectation.operation}.` + ); + } + return expectation.response.body.value; +} + +function responseFailureInput(expectation: ReplayExpectation): { + readonly httpStatus: number; + readonly rawBody?: string; + readonly value?: unknown; +} { + const body = expectation.response.body; + return { + httpStatus: expectation.response.status, + ...(body.kind === 'json' ? { value: body.value } : {}), + ...(body.kind === 'text' && typeof body.value === 'string' + ? { rawBody: body.value } + : {}), + }; +} + describe('committed replay fixture corpus', () => { it('contains the complete Stage-1 scenario matrix', () => { const scenarioIds = collectFixturePaths(FIXTURE_ROOT) @@ -645,6 +733,302 @@ describe('committed replay fixture corpus', () => { } }); + it('binds numeric and string profile statuses to the production classifier matrix', () => { + const cases = [ + { + expected: { kind: 'ready', status: 0 }, + operation: 'full-profile', + wireStatus: 0, + scenarioId: 'resolver-root-full-wins', + }, + { + expected: { kind: 'blocked', status: 1 }, + operation: 'blocked-profile', + wireStatus: 1, + scenarioId: 'authentication-blocked-profile', + }, + { + expected: { kind: 'credentials-required', status: 2 }, + operation: 'profile-first', + wireStatus: 2, + scenarioId: 'authentication-status2-second-step', + }, + { + expected: { kind: 'ready', status: 0 }, + operation: 'profile-string-ready', + wireStatus: '0', + scenarioId: 'classifier-profile-status-strings', + }, + { + expected: { kind: 'blocked', status: 1 }, + operation: 'profile-string-blocked', + wireStatus: '1', + scenarioId: 'classifier-profile-status-strings', + }, + { + expected: { kind: 'credentials-required', status: 2 }, + operation: 'profile-string-credentials', + wireStatus: '2', + scenarioId: 'classifier-profile-status-strings', + }, + ] as const; + + for (const current of cases) { + const response = jsonResponseValue( + findExpectation( + loadFixture(current.scenarioId), + current.operation + ) + ) as { readonly js?: Readonly> }; + expect(response.js?.['status']).toBe(current.wireStatus); + expect(classifyStalkerProfile(response)).toMatchObject( + current.expected + ); + } + + const stringFixture = loadFixture( + 'classifier-profile-status-strings' + ); + const ready = jsonResponseValue( + findExpectation(stringFixture, 'profile-string-ready') + ) as { readonly js: Readonly> }; + const blocked = jsonResponseValue( + findExpectation(stringFixture, 'profile-string-blocked') + ) as { readonly js: Readonly> }; + expect([ + ready.js['store_auth_data_on_stb'], + blocked.js['store_auth_data_on_stb'], + ]).toEqual([true, false]); + }); + + it('binds canonical do_auth success, rejection, and near misses to production rules', () => { + const cases = [ + { + expected: { kind: 'success' }, + operation: 'do-auth', + scenarioId: 'authentication-status2-second-step', + }, + { + expected: { kind: 'credentials-rejected' }, + operation: 'saved-do-auth', + scenarioId: 'authentication-saved-rejected-fresh', + }, + { + expected: { + kind: 'failure', + reason: 'incompatible-response', + }, + operation: 'do-auth-noncanonical', + scenarioId: 'authentication-noncanonical-do-auth', + }, + { + expected: { + kind: 'failure', + reason: 'incompatible-response', + }, + operation: 'do-auth-noncanonical-string', + scenarioId: 'authentication-noncanonical-do-auth-string', + }, + ] as const; + + for (const current of cases) { + expect( + classifyStalkerDoAuth( + jsonResponseValue( + findExpectation( + loadFixture(current.scenarioId), + current.operation + ) + ) + ) + ).toEqual(current.expected); + } + }); + + it('binds token, denial, protection, and ambiguous bodies to the production failure taxonomy', () => { + const cases = [ + { + expected: { kind: 'token-rejected' }, + operation: 'authorization-failed', + scenarioId: 'classifier-authorization-failed-200', + }, + { + expected: { kind: 'token-rejected' }, + operation: 'authorization-failed-forbidden', + scenarioId: 'classifier-auth-failed-403', + }, + { + expected: { + kind: 'failure', + reason: 'incompatible-response', + }, + operation: 'authorization-failed-near-miss', + scenarioId: + 'classifier-authorization-failed-near-miss-200', + }, + { + expected: { + kind: 'failure', + reason: 'account-access-denied', + }, + operation: 'body-denial', + scenarioId: 'classifier-access-denied-200', + }, + { + expected: { + kind: 'failure', + reason: 'portal-protection-blocked', + }, + operation: 'waf-response', + scenarioId: 'classifier-waf-403', + }, + { + expected: { + kind: 'failure', + reason: 'incompatible-response', + }, + operation: 'ambiguous-forbidden', + scenarioId: 'classifier-ambiguous-403', + }, + { + expected: { + kind: 'failure', + reason: 'rate-limited', + }, + operation: 'rate-limit', + scenarioId: 'classifier-rate-limit-429', + }, + { + expected: { + kind: 'failure', + reason: 'portal-unavailable', + }, + operation: 'service-unavailable', + scenarioId: 'classifier-service-unavailable-503', + }, + ] as const; + + for (const current of cases) { + const expectation = findExpectation( + loadFixture(current.scenarioId), + current.operation + ); + expect( + classifyStalkerResponseFailure( + responseFailureInput(expectation) + ) + ).toEqual(current.expected); + } + }); + + it('fails closed when valid replay JSON carries an HTML media type', () => { + const expectation = findExpectation( + loadFixture('classifier-wrong-mime-json'), + 'wrong-mime-json' + ); + const body = expectation.response.body; + const contentType = + expectation.response.headers['content-type']?.[0]; + if ( + body.kind !== 'text' || + typeof body.value !== 'string' || + typeof contentType !== 'string' + ) { + throw new Error('Wrong-MIME matrix fixture is malformed.'); + } + + expect( + parseStalkerResponseEnvelope({ + body: body.value, + contentType, + maxBodyBytes: 1024, + }) + ).toEqual({ + kind: 'failure', + reason: 'incompatible-response', + }); + }); + + it('records portal protection after stateless evidence as a no-downgrade terminal', () => { + const fixture = loadFixture('resolver-downgrade-protection'); + const operations = fixture.phases.flatMap((phase) => + phase.expectations.map((expectation) => expectation.operation) + ); + const unsupported = findExpectation( + fixture, + 'unsupported-handshake' + ); + const stateless = jsonResponseValue( + findExpectation(fixture, 'stateless-capability') + ) as { readonly js?: unknown }; + const authenticatedHandshake = jsonResponseValue( + findExpectation(fixture, 'authenticated-handshake') + ) as { + readonly js?: Readonly>; + }; + const protectedProfile = findExpectation( + fixture, + 'protected-first-profile' + ); + + expect(operations).toEqual([ + 'root-probe', + 'unsupported-handshake', + 'stateless-capability', + 'authenticated-handshake', + 'protected-first-profile', + ]); + expect( + classifyStalkerResponseFailure( + responseFailureInput(unsupported) + ) + ).toEqual({ kind: 'none' }); + expect(Array.isArray(stateless.js)).toBe(true); + expect(authenticatedHandshake.js?.['token']).toMatchObject({ + kind: 'generate', + valueKind: 'token', + }); + expect( + classifyStalkerResponseFailure( + responseFailureInput(protectedProfile) + ) + ).toEqual({ + kind: 'failure', + reason: 'portal-protection-blocked', + }); + expect(fixture.phases.at(-1)?.nextState).toBe( + fixture.terminalState + ); + }); + + it('keeps mutable RFC cookie semantics in production unit coverage while replay owns wire rotation', () => { + const rotationFixture = loadFixture( + 'e2e-full-session-catalog-playback' + ); + const rotatingOperations = ['handshake', 'do-auth', 'catalog-page']; + for (const operation of rotatingOperations) { + expect( + findExpectation(rotationFixture, operation).response.headers[ + 'set-cookie' + ] + ).toHaveLength(1); + } + + for (const current of UNIT_OWNED_COOKIE_MATRIX) { + const specSource = readFileSync( + resolve(process.cwd(), current.spec), + 'utf8' + ); + expect({ + contract: current.contract, + covered: specSource.includes(current.evidence), + }).toEqual({ + contract: current.contract, + covered: true, + }); + } + }); + it('terminates both noncanonical do_auth near misses before a second profile', () => { const cases = [ {