diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index 2225803e4..6b37e4b04 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -41,6 +41,7 @@ imported as a **full portal** (handshake + token + watchdog), anything else as a |---|---| | `/portal.php` | Tolerant. Ignores the Bearer token and the MAC format, like most reseller panels in the wild. | | `/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. | The strict endpoint reproduces the parts of Stalker 4.9.35 that a client can actually get wrong: @@ -48,7 +49,9 @@ actually get wrong: - Every action except `handshake`, `get_profile`, `get_localization` and `do_auth` requires `Authorization: Bearer `. - A token only counts once `get_profile` has adopted it — a handshake alone is - not a session. + not a session. Adoption is deliberately stricter than the stock server: + only tokens the mock actually issued (or the already-bound one) are + accepted, so a client with a broken token pipeline fails loudly. - Auth failures come back as **HTTP 200 with a plain-text body** (`Authorization failed.`, `Unauthorized request.`), never a 401/403. Clients that only check status codes will silently render nothing. @@ -74,7 +77,7 @@ actually get wrong: | `00:1A:79:00:00:05` | **embedded-series** | 50% of VOD items have embedded `series[]` arrays — tests the embedded series flow | | `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:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` and retries with `auth_second_step=1` | +| `00:1A:79:00:00:08` | **login-required** | `get_profile` answers `status: 2` until the client completes `do_auth` with non-empty credentials | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | ## Configuration @@ -90,7 +93,7 @@ actually get wrong: |---|---|---| | `/health` | `GET` | Health check — returns `{ status: "ok" }` | | `/reset` | `POST` | Clear all in-memory data, favorites, sessions and watchdog counters (useful between test runs) | -| `/invalidate-session?macAddress=` | `POST` | Drop that MAC's session so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries | +| `/invalidate-session?macAddress=` | `POST` | Drop that MAC's tokens so the next portal call fails with `Authorization failed.` — lets tests assert the client re-handshakes and retries. Pinned device identity survives, as on a real portal | ## API Coverage @@ -101,7 +104,7 @@ All endpoints are served at `GET /portal.php?action=&...` matching the r | `handshake` | Issues the access token (idempotent) plus the 5.x `random` nonce and `not_valid` flag | | `get_profile` | Turns the handshake token into a session; enforces device-id pinning, and on the strict endpoint the MAC format | | `get_events` | Watchdog ping; records the call and returns an empty event set (never affects authorization, as on a real portal) | -| `do_auth` | Returns a mock user profile | +| `do_auth` | Boolean login step: `{js:true}` for non-empty credentials (recorded for the login-required scenario), `{js:false}` otherwise | | `get_categories` | Category list filtered by `type` (itv/vod/series) | | `get_genres` | Genre list (mirrors categories) | | `get_ordered_list` | Paginated content list; if `movie_id` is present → returns seasons | diff --git a/apps/stalker-mock-server/src/app/auth-store.spec.ts b/apps/stalker-mock-server/src/app/auth-store.spec.ts index c3eeac35b..c33069bcb 100644 --- a/apps/stalker-mock-server/src/app/auth-store.spec.ts +++ b/apps/stalker-mock-server/src/app/auth-store.spec.ts @@ -2,8 +2,10 @@ import { Request } from 'express'; import { adoptToken, checkRequestAuthorization, + hasCompletedDoAuth, invalidateSession, issueHandshakeToken, + markDoAuthCompleted, pinDeviceIdentity, readBearerToken, resetAuthState, @@ -89,6 +91,28 @@ describe('stalker mock auth store', () => { ); }); + it('refuses to adopt a token the handshake never issued', () => { + issueHandshakeToken(MAC); + + // Stricter than the stock server on purpose: a forged or stale token + // must not become a session, or the mock cannot catch a client whose + // token pipeline is broken. + expect(adoptToken(MAC, 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12')).toBe(false); + expect( + checkRequestAuthorization( + request({ token: 'F0RGEDF0RGEDF0RGEDF0RGEDF0RGED12' }), + true + ) + ).toBe('Authorization failed.'); + }); + + it('re-adopts the already-bound token', () => { + const { token } = issueHandshakeToken(MAC); + adoptToken(MAC, token); + + expect(adoptToken(MAC, token)).toBe(true); + }); + it('fails after the session is invalidated so clients must re-authenticate', () => { const { token } = issueHandshakeToken(MAC); adoptToken(MAC, token); @@ -99,6 +123,26 @@ describe('stalker mock auth store', () => { ); }); + it('keeps pinned device identity across session invalidation', () => { + pinDeviceIdentity(MAC, 'dev-1', undefined); + invalidateSession(MAC); + + // Losing the token (another device logged in) never unpins device_id + // on a real portal, so a changed identity must still conflict. + expect(pinDeviceIdentity(MAC, 'other', undefined)).toBe( + 'device conflict - device_id mismatch' + ); + }); + + it('tracks do_auth completion per MAC', () => { + expect(hasCompletedDoAuth(MAC)).toBe(false); + + markDoAuthCompleted(MAC); + + expect(hasCompletedDoAuth(MAC)).toBe(true); + expect(hasCompletedDoAuth('00:1A:79:00:00:99')).toBe(false); + }); + it('never enforces the token when enforcement is off', () => { expect(checkRequestAuthorization(request({}), false)).toBe(null); }); diff --git a/apps/stalker-mock-server/src/app/auth-store.ts b/apps/stalker-mock-server/src/app/auth-store.ts index 28680de9e..a69b5d7db 100644 --- a/apps/stalker-mock-server/src/app/auth-store.ts +++ b/apps/stalker-mock-server/src/app/auth-store.ts @@ -20,6 +20,8 @@ interface PortalSession { pendingToken?: string; /** Token stored for the MAC — what authorizes non-auth actions. */ accessToken?: string; + /** Whether do_auth completed with non-empty credentials for this MAC. */ + didAuth?: boolean; deviceId?: string; deviceId2?: string; } @@ -83,11 +85,32 @@ export function issueHandshakeToken(mac: string, presentedToken?: string): { return { token, notValid: Boolean(presentedToken) }; } -/** Adopt the handshake token as the MAC's session token (what get_profile does). */ -export function adoptToken(mac: string, token: string): void { +/** + * Adopt the handshake token as the MAC's session token (what get_profile + * does). Deliberately STRICTER than the stock server here: 4.9.35 issues + * handshake tokens statelessly and pins whatever Bearer get_profile presents, + * so a forged token would become a session on a real portal. The mock only + * adopts tokens it actually issued (or the already-bound one), so a client + * with a broken or substituted token pipeline fails loudly in tests instead + * of passing by accident. + */ +export function adoptToken(mac: string, token: string): boolean { const session = getSession(mac); + if (token !== session.pendingToken && token !== session.accessToken) { + return false; + } session.accessToken = token; session.pendingToken = undefined; + return true; +} + +/** Record that do_auth completed with non-empty credentials for this MAC. */ +export function markDoAuthCompleted(mac: string): void { + getSession(mac).didAuth = true; +} + +export function hasCompletedDoAuth(mac: string): boolean { + return getSession(mac).didAuth === true; } export function readBearerToken(req: Request): string | undefined { @@ -163,9 +186,19 @@ export function checkRequestAuthorization( return null; } -/** Drop the MAC's session so the next request must re-authenticate. */ +/** + * Drop the MAC's tokens so the next request must re-authenticate. Pinned + * device identity survives on purpose: on a real portal a lost token (another + * device logged in) never unpins device_id, so re-authenticating with a + * changed identity must still hit the device-conflict branch. + */ export function invalidateSession(mac: string): void { - sessions.delete(mac.toLowerCase()); + const session = sessions.get(mac.toLowerCase()); + if (!session) { + return; + } + session.accessToken = undefined; + session.pendingToken = undefined; } export function resetAuthState(): void { diff --git a/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts index 51afe2a5b..affa24fd7 100644 --- a/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/do-auth.handler.ts @@ -1,25 +1,25 @@ import { Request, Response } from 'express'; +import { markDoAuthCompleted } from '../auth-store.js'; +import { extractMac } from '../request-mac.js'; /** - * Stalker do_auth — returns user profile / account info. + * Stalker do_auth — the login/password step behind `get_profile` status 2. + * + * The real portal never checks a password itself: it proxies the credentials + * to the operator's billing script and answers a bare boolean. The mock + * accepts any non-empty pair, records the completion so `get_profile` can + * stop answering `status: 2` for the login-required scenario, and rejects + * empty credentials the way a billing script would. */ export function handleDoAuth(req: Request, res: Response): void { - res.json({ - js: { - id: '1', - name: 'Mock User', - login: 'mockuser', - password: '', - status: 'active', - tariff_expired_date: '2099-12-31', - phone: '', - ls: '0', - created: new Date().toISOString(), - updated: new Date().toISOString(), - blocked: '0', - acc_enabled: '1', - max_connections: '1', - active_connections: '0', - }, - }); + const login = String(req.query['login'] ?? ''); + const password = String(req.query['password'] ?? ''); + + if (!login || !password) { + res.json({ js: false }); + return; + } + + markDoAuthCompleted(extractMac(req)); + res.json({ js: true }); } diff --git a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts index 0e6f15cb2..463fb8466 100644 --- a/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts +++ b/apps/stalker-mock-server/src/app/handlers/get-profile.handler.ts @@ -1,5 +1,10 @@ import { Request, Response } from 'express'; -import { adoptToken, pinDeviceIdentity, readBearerToken } from '../auth-store.js'; +import { + adoptToken, + hasCompletedDoAuth, + pinDeviceIdentity, + readBearerToken, +} from '../auth-store.js'; import { getScenario } from '../scenarios.js'; import { extractMac } from '../request-mac.js'; @@ -28,7 +33,11 @@ export function handleGetProfile( return; } - if (scenario.requiresLogin && req.query['auth_second_step'] !== '1') { + // Gate on the actual do_auth completion, not on auth_second_step: the app + // sends auth_second_step=1 on its very first get_profile, so a parameter + // check would let the login-required flow be bypassed without ever + // exercising status 2 -> do_auth -> profile retry. + if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) { res.json({ js: { status: 2, diff --git a/apps/stalker-mock-server/src/main.ts b/apps/stalker-mock-server/src/main.ts index a98a2bdde..e5b5c9a81 100644 --- a/apps/stalker-mock-server/src/main.ts +++ b/apps/stalker-mock-server/src/main.ts @@ -81,9 +81,22 @@ app.use( // Stalker portal.php endpoint (reseller-panel alias — tolerant, no token check) app.use('/portal.php', portalRouter); -// Canonical Ministra endpoint — enforces the Bearer token and the MAC format +// Canonical Ministra endpoints — enforce the Bearer token and the MAC format // exactly like the real middleware, so the full-portal auth flow is testable. +// Both URL shapes the app classifies as "full" must land on the strict branch. app.use('/stalker_portal/server/load.php', createPortalRouter(true)); +app.use('/server/load.php', createPortalRouter(true)); + +/** + * Mirror of the app's full-portal predicates (`isFullStalkerPortal` checks + * `/stalker_portal/` or `/server/load.php`; import-time normalization checks + * `/stalker_portal`). Any URL shape the client would authenticate against must + * be enforced by the proxy too, or tests would silently exercise the tolerant + * branch. + */ +function isFullPortalUrlShape(url: string): boolean { + return url.includes('/stalker_portal') || url.includes('/server/load.php'); +} /** * CORS proxy compatibility endpoint — mirrors the IPTVnator backend API shape: @@ -140,9 +153,9 @@ app.get('/stalker', (req: Request, res: Response) => { } as unknown as Response & { send: (body: string) => void }; dispatchPortalAction(syntheticReq, syntheticRes, { - // The proxied portal URL decides strictness, matching the two direct - // endpoints: a canonical Ministra path enforces the token. - enforceAuth: (asString(portalUrl) ?? '').includes('/stalker_portal/'), + // The proxied portal URL decides strictness, matching the direct + // endpoints: every canonical Ministra path shape enforces the token. + enforceAuth: isFullPortalUrlShape(asString(portalUrl) ?? ''), }); // The portal answers auth failures with a plain-text body; the real backend diff --git a/docs/architecture/stalker-mock-server.md b/docs/architecture/stalker-mock-server.md index f1106cc51..19b4d93df 100644 --- a/docs/architecture/stalker-mock-server.md +++ b/docs/architecture/stalker-mock-server.md @@ -51,6 +51,11 @@ paths: |---|---|---| | `/portal.php` | `createPortalRouter(false)` | Tolerant: ignores the token and the MAC format, like most reseller panels | | `/stalker_portal/server/load.php` | `createPortalRouter(true)` | Strict: enforces both, like the real middleware | +| `/server/load.php` | `createPortalRouter(true)` | Strict: the second URL shape `isFullStalkerPortal` recognizes | + +The `/stalker` proxy route applies the same rule through +`isFullPortalUrlShape()` — every URL the client would authenticate against is +enforced, so tests cannot silently fall into the tolerant branch. Keeping the tolerant path is what lets the pre-existing e2e suite (which imports `portal.php`) stay meaningful — it covers the simple-portal branch — while the @@ -66,7 +71,11 @@ The strict behaviours mirror the plaintext Stalker 4.9.35 middleware sees "success" and renders nothing. The `/stalker` proxy route still wraps the body in the `{ payload }` envelope, matching what `apps/web-backend` does. - **A handshake is not a session.** The token only authorizes requests once - `get_profile` has adopted it for that MAC. + `get_profile` has adopted it for that MAC. Adoption is deliberately + *stricter* than the stock server: 4.9.35 issues handshake tokens statelessly + and pins whatever Bearer `get_profile` presents, so a forged token would + become a session on a real portal — the mock only adopts tokens it actually + issued, so a client with a broken token pipeline fails loudly in tests. - **Idempotent handshake.** Presenting the MAC's current token returns that same token, which is what allows real clients to persist tokens across restarts. - **Device-id pinning.** `device_id`/`device_id2` are stored on first non-empty @@ -78,9 +87,17 @@ The strict behaviours mirror the plaintext Stalker 4.9.35 middleware - **MAC format validation.** Non-Infomir MACs (`00:1A:79:XX:XX:XX`) get a bare `{ status: 1 }` from `get_profile`. +- **`do_auth` is a boolean login step.** Non-empty credentials answer + `{js:true}` and are recorded; the `login-required` scenario's `get_profile` + keeps answering `status: 2` until that record exists, because the app sends + `auth_second_step=1` on its very first profile request and a parameter check + alone would be trivially bypassed. + Session state lives in `src/app/auth-store.ts` and is cleared by `/reset`. -`POST /invalidate-session?macAddress=` drops a single session so tests can -assert the client re-handshakes and retries instead of surfacing an error. +`POST /invalidate-session?macAddress=` drops a single MAC's tokens so +tests can assert the client re-handshakes and retries instead of surfacing an +error; pinned device identity survives invalidation, as it does on a real +portal. ## Data Generation Pipeline