diff --git a/apps/stalker-mock-server/README.md b/apps/stalker-mock-server/README.md index fa72541fc..ed222f3ce 100644 --- a/apps/stalker-mock-server/README.md +++ b/apps/stalker-mock-server/README.md @@ -82,7 +82,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` with non-empty credentials. The app cannot finish this flow yet (its `do_auth` path is dormant and sends empty credentials), so the scenario is exercised at the HTTP level only — it exists to receive the upcoming client-side `do_auth` work | +| `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: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` | | `` | **auto** | MAC bytes used as seed → deterministic unique dataset | diff --git a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts index 37d551acd..f33f57b58 100644 --- a/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts +++ b/apps/stalker-mock-server/src/app/handlers/auth-handlers.spec.ts @@ -11,10 +11,9 @@ import { handleHandshake } from './handshake.handler'; /** * Handler-level coverage for the authentication actions. * - * The e2e suite drives the app against this server, but the login-required - * flow cannot be reached from the client yet (its `do_auth` path is dormant - * and sends empty credentials), so the scenario is pinned down here rather - * than only asserted in prose. + * The e2e suite drives the app through this flow end to end; these handler + * tests pin the SERVER half of the contract independently, so a client-side + * regression and a mock-side one cannot mask each other. * * Handlers are called directly instead of through the dispatcher: the * dispatcher pulls in every content handler and therefore the faker-based @@ -82,8 +81,8 @@ describe('stalker mock authentication handlers', () => { })['token'] as string; expect(token).toMatch(/^[0-9A-F]{32}$/); - // The app sends auth_second_step=1 on its very first profile request, - // so that parameter alone must not satisfy the scenario. + // A client could mislabel its first profile as the second auth step; + // that parameter alone must not satisfy the scenario. expect( invoke( handleGetProfile, 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 463fb8466..ef5ff3b95 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 @@ -33,10 +33,11 @@ export function handleGetProfile( return; } - // 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. + // Gate on the actual do_auth completion, not on auth_second_step. The + // client now sends `auth_second_step=0` first and `1` only on the retry + // after do_auth, so a parameter check would happen to work — but it would + // also silently pass for any client that mislabels its first profile, + // which is exactly the protocol mistake this scenario exists to catch. if (scenario.requiresLogin && !hasCompletedDoAuth(mac)) { res.json({ js: { diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts index b59a12839..906afa722 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.spec.ts @@ -3,9 +3,11 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; import { + stalkerSessionFingerprint, StalkerPortalDiscoveryService, StalkerPortalError, } from '@iptvnator/portal/stalker/data-access'; +import { Playlist } from '@iptvnator/shared/interfaces'; import { StalkerPortalImportComponent } from './stalker-portal-import.component'; describe('StalkerPortalImportComponent identity handling', () => { @@ -193,6 +195,41 @@ describe('StalkerPortalImportComponent identity handling', () => { expect(playlist.stalkerSessionIdentity).toEqual(expect.any(String)); }); + it('records credentials in the imported session fingerprint', async () => { + // Otherwise the first runtime ensureToken() computes a fingerprint + // WITH the credentials, mismatches the imported one, and discards the + // session the import just established — silently defeating reuse for + // exactly the login portals this flow exists for. + portalDiscovery.discover.mockResolvedValue({ + status: 'resolved', + portalUrl: 'https://portal.example.com/stalker_portal/server/load.php', + isFullStalkerPortal: true, + token: 'token-1', + }); + component.form.patchValue({ + _id: 'playlist-login-fp', + title: 'Login Portal', + macAddress: '00:1A:79:00:00:08', + portalUrl: 'https://portal.example.com/stalker_portal/c', + username: 'user', + password: 'secret', + importDate: '2026-05-15T00:00:00.000Z', + }); + + await component.addPlaylist(); + + const playlist = store.dispatch.mock.calls[0][0].playlist; + expect(playlist.stalkerSessionIdentity).toBe( + stalkerSessionFingerprint({ + portalUrl: + 'https://portal.example.com/stalker_portal/server/load.php', + macAddress: '00:1A:79:00:00:08', + username: 'user', + password: 'secret', + } as Playlist) + ); + }); + it('records the effective cadence when the portal advertises none', async () => { portalDiscovery.discover.mockResolvedValue({ status: 'resolved', diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index a57137c5e..86888fb92 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -245,14 +245,19 @@ export class StalkerPortalImportComponent { portalUrl, isFullStalkerPortal, stalkerToken, - // The identity this token was negotiated for. Reuse is - // refused when it no longer matches, so an edited MAC cannot - // inherit the previous session. + // What this token was negotiated for: endpoint, identity AND + // credentials. Reuse is refused when any of them no longer + // matches — and the credentials must be included here, or the + // first runtime `ensureToken()` would compute a fingerprint + // WITH them, mismatch this one, and throw away the session + // the import just established. ...(stalkerToken ? { stalkerSessionIdentity: stalkerSessionFingerprint({ portalUrl, macAddress: formValue.macAddress ?? '', + username: formValue.username ?? '', + password: formValue.password ?? '', ...this.toPlaylistIdentityFields(stalkerIdentity), } as Playlist), }