fix(stalker): fence stale portal modes

This commit is contained in:
4gray committed 2026-08-09 14:20:44 +02:00
1 parent bb474f0f8c
commit 79d3fb9dbd
17 files changed
+247 -57

No files matched your search

+2 -1
View File
@@ -6,4 +6,5 @@ area: stalker
Stalker setup now accepts a host or `/c` address, discovers the working API
endpoint and authentication mode, and rechecks connection details edited
later. Canceled or failed edits leave the saved playlist and active session
unchanged.
unchanged. Completed edits reject requests still holding the previous portal
mode.
+1 -1
View File
@@ -1262,7 +1262,7 @@ engine` (restart required) or
- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one.
- Import requires an explicit HTTP(S) scheme but accepts a bare host, `/c`, or a concrete `.php` address. It probes candidates in order (a pasted `.php` endpoint first, then `<base>/portal.php` → `<base>/server/load.php` → `<base>/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists and displays the proven endpoint and mode. An unreachable panel-style import remains allowed with a warning; a bare host falls back to `<base>/portal.php`, while canonical-shaped unreachable addresses still abort.
- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. It preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; failure releases it without changing the saved or runtime connection. If navigation or another owner closes/destroys the dialog while discovery is in flight, the UI discards a later successful result through `discardResolvedConnection()` and releases both reservations without persisting it. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime session authority may rebase a changed fingerprint only when the persisted row proves that it owns the same playlist ID, so delete/restore and backup merge remain usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
- The playlist-info Edit dialog loads the complete persisted Stalker row before enabling the form, because Electron's startup metadata projection omits payload-only serial/device/signature/mode fields; a summarized row must never render and then persist an empty portal identity. It preserves an unchanged connection byte-for-byte and skips discovery. Changing URL, MAC, credentials, serial, device IDs or signatures blocks duplicate saves, disables dialog closure for the validation window, and runs the existing discovery service through the app-provided `STALKER_PLAYLIST_CONNECTION_EDITOR` token, keeping Stalker data-access out of `playlist-shared-ui`. Before discovery it reserves the playlist ID; a second Edit cannot replace that owner. The reservation blocks every new authentication (including fingerprint-equivalent URL edits) and repair, drains existing work, and rechecks ownership after every asynchronous drain/rebase; failure releases it without changing the saved or runtime connection. If navigation or another owner closes/destroys the dialog while discovery is in flight, the UI discards a later successful result through `discardResolvedConnection()` and releases both reservations without persisting it. Success uses one awaited write to atomically replace endpoint, mode, normalized identity and session metadata, then feeds its complete merged row into the state-only NgRx update and active `StalkerStore`/session/watchdog replacement before another same-route request can use the old connection. This preserves playback headers and other metadata absent from the form. Runtime configuration authority covers the observed full/simple mode as well as the session fingerprint, and both authenticated and direct simple requests cross its guard, so a same-endpoint mode change rejects stale snapshots in either direction. A changed authority may rebase only when the persisted row proves that it owns the same playlist ID, keeping delete/restore and backup merge usable. The transient `PlaylistMetaUpdate.stalkerSessionPatch` preserves on absence, clears on `null`, and fully replaces from an object before storage; it is projected onto existing flat playlist fields and never changes the DB or backup shape.
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. Before an unrecorded repair calls discovery, it verifies that the persisted row still owns the failing source, so a late pre-Edit request cannot authenticate against the old portal after Edit commits and invalidate the newly saved token. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
- Explicit Edit advances the repair generation before installing its resolved session. A lazy repair that started earlier is discarded even if it had already verified its row, so it cannot restore an older endpoint, mode or token after Edit.
- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them.
+17 -6
View File
@@ -222,10 +222,14 @@ before the app adapter replaces the active `StalkerStore` snapshot and
session/watchdog state, so persistence failure cannot expose a partial runtime
edit and metadata absent from the form (such as playback `Referer`/`Origin`)
survives the replacement.
Runtime session authority normally rejects stale playlist objects, but a
different fingerprint may rebase only after the current persisted row proves
that it owns the same playlist ID; this keeps delete/restore and backup merge
flows usable without letting an in-flight stale request overrule Edit.
Runtime configuration authority combines the session fingerprint with the
observed full/simple mode. Both authenticated calls and direct simple-mode
requests cross that guard, so a same-endpoint mode-only Edit rejects stale
playlist objects in either direction before they can authenticate or issue a
token-free portal request. A different authority may rebase only after the
current persisted row proves that it owns the same playlist ID; this keeps
delete/restore and backup merge flows usable without letting an in-flight
stale request overrule Edit.
`PlaylistMetaUpdate` carries the persisted part as a transient
`stalkerSessionPatch` (`undefined` preserves, `null` clears, an object fully
replaces); `PlaylistsService` projects it onto the existing flat playlist
@@ -600,6 +604,13 @@ with no recorded fingerprint (written before this existed) counts as
unverified and is never re-presented — such a row owes a full profile anyway,
and the write-back then records the fingerprint.
The Edit coordinator deliberately keeps a separate, in-run configuration
authority key containing that session fingerprint plus the observed portal
mode. The mode is not added to `stalkerSessionIdentity`, so existing persisted
sessions remain compatible, but a full↔simple Edit at the same endpoint still
retires stale runtime snapshots. The token-free dispatch path calls
`ensureToken()` as a network-free authority guard before its direct IPC request.
Because that reuse skips the only response carrying the watchdog cadence, the
cadence is persisted **with** the token (`Playlist.stalkerWatchdogTimeout` /
`stalkerTimeslot`, payload fields like `stalkerToken` itself — no schema
@@ -909,8 +920,8 @@ Every static return therefore warms first, through one primitive —
`ensureToken` performs handshake + `get_profile` with no link minted, and
validates the identity the cached token was negotiated for — which the raw
`getCachedToken()` cannot. It is cheap where it is not needed: a simple portal
returns immediately and a warm cache with a matching fingerprint resolves
without a request.
runs only the in-memory configuration-authority guard and returns immediately,
while a warm cache with a matching fingerprint resolves without a request.
The store's player feature reads `getCachedToken()` for its header set, which
is safe there because it runs immediately after the warm above populated the
@@ -15,6 +15,7 @@ describe('StalkerAccountInfoService', () => {
let stalkerSession: {
refreshAccountProfile: jest.Mock;
makeAuthenticatedRequest: jest.Mock;
ensureToken: jest.Mock;
};
let portalRepair: {
applyOverride: jest.Mock;
@@ -44,6 +45,7 @@ describe('StalkerAccountInfoService', () => {
stalkerSession = {
refreshAccountProfile: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
};
portalRepair = {
applyOverride: jest.fn((playlist) => playlist),
@@ -176,6 +176,48 @@ describe('Stalker edited-session coordination', () => {
);
});
it('rejects a stale full snapshot after a mode-only edit to simple', async () => {
const simplePlaylist = {
...oldPlaylist,
isFullStalkerPortal: false,
stalkerToken: undefined,
stalkerSessionIdentity: undefined,
} as Playlist;
updatePlaylistMeta.mockReturnValueOnce(of(simplePlaylist));
getPlaylistById.mockReturnValueOnce(of(simplePlaylist));
authenticate.mockReset().mockResolvedValue({
token: 'STALE_FULL_TOKEN',
});
await service.replaceSessionAfterEdit(simplePlaylist);
await expect(service.ensureToken(oldPlaylist)).rejects.toThrow(
/stale/i
);
expect(authenticate).not.toHaveBeenCalled();
});
it('rejects a stale simple snapshot after a mode-only edit to full', async () => {
const simplePlaylist = {
...oldPlaylist,
isFullStalkerPortal: false,
} as Playlist;
const resolvedFullPlaylist = {
...oldPlaylist,
stalkerToken: 'NEW_FULL_TOKEN',
} as Playlist;
updatePlaylistMeta.mockReturnValueOnce(of(resolvedFullPlaylist));
getPlaylistById.mockReturnValueOnce(of(resolvedFullPlaylist));
const fence = await service.beginEditDiscovery(simplePlaylist);
await service.replaceSessionAfterEdit(resolvedFullPlaylist, fence);
await expect(service.ensureToken(simplePlaylist)).rejects.toThrow(
/stale/i
);
expect(authenticate).not.toHaveBeenCalled();
});
it('persists a resolved full connection and session in one metadata write', async () => {
const editedPlaylist = {
...oldPlaylist,
@@ -17,12 +17,12 @@ export interface StalkerEditFence {
interface PendingEdit {
readonly owner: symbol;
readonly fingerprint: string;
readonly configurationFingerprint: string;
}
/** Serializes authoritative Edit results against pre-edit authentication. */
export class StalkerEditedSessionCoordinator {
private readonly authoritativeFingerprints = new Map<string, string>();
private readonly authoritativeConfigurations = new Map<string, string>();
private readonly pendingEdits = new Map<string, PendingEdit>();
private readonly replacements = new Map<string, Promise<Playlist>>();
@@ -33,36 +33,57 @@ export class StalkerEditedSessionCoordinator {
) {}
markAuthoritative(playlist: Playlist): string {
const fingerprint = stalkerSessionFingerprint(playlist);
this.authoritativeFingerprints.set(playlist._id, fingerprint);
return fingerprint;
const sessionFingerprint = stalkerSessionFingerprint(playlist);
this.authoritativeConfigurations.set(
playlist._id,
stalkerConfigurationFingerprint(playlist, sessionFingerprint)
);
return sessionFingerprint;
}
async guard(playlist: Playlist): Promise<string> {
const fingerprint = stalkerSessionFingerprint(playlist);
const sessionFingerprint = stalkerSessionFingerprint(playlist);
const configurationFingerprint = stalkerConfigurationFingerprint(
playlist,
sessionFingerprint
);
this.assertNoPendingEdit(playlist._id);
const authoritative = this.authoritativeFingerprints.get(playlist._id);
if (authoritative && authoritative !== fingerprint) {
await this.rebaseFromPersistedRow(playlist, fingerprint);
const authoritative = this.authoritativeConfigurations.get(
playlist._id
);
if (authoritative && authoritative !== configurationFingerprint) {
await this.rebaseFromPersistedRow(
playlist,
configurationFingerprint
);
}
// The persisted-row lookup yields. An Edit that acquired the ID while
// it was in flight must still fence this request, even if the lookup
// proved that the caller owned the row before Edit began.
this.assertCurrent(playlist._id, fingerprint);
this.assertCurrent(playlist, sessionFingerprint);
const replacement = this.replacements.get(playlist._id);
if (replacement) {
await replacement;
this.assertCurrent(playlist._id, fingerprint);
this.assertCurrent(playlist, sessionFingerprint);
}
return fingerprint;
return sessionFingerprint;
}
assertCurrent(playlistId: string, fingerprint: string): void {
assertCurrent(playlist: Playlist, sessionFingerprint: string): void {
const playlistId = playlist._id;
// A reservation blocks every new authentication, including a
// text-only URL edit whose normalized session fingerprint is equal.
this.assertNoPendingEdit(playlistId);
const authoritative = this.authoritativeFingerprints.get(playlistId);
if (authoritative && authoritative !== fingerprint) {
if (stalkerSessionFingerprint(playlist) !== sessionFingerprint) {
throw new Error('Stale Stalker playlist configuration');
}
const authoritative =
this.authoritativeConfigurations.get(playlistId);
if (
authoritative &&
authoritative !==
stalkerConfigurationFingerprint(playlist, sessionFingerprint)
) {
throw new Error('Stale Stalker playlist configuration');
}
}
@@ -72,20 +93,21 @@ export class StalkerEditedSessionCoordinator {
if (this.pendingEdits.has(playlistId)) {
throw new Error('Stalker playlist edit already in progress');
}
const fingerprint = stalkerSessionFingerprint(playlist);
const configurationFingerprint =
stalkerConfigurationFingerprint(playlist);
const fence = { playlistId, owner: Symbol('stalker-edit') };
this.pendingEdits.set(playlistId, {
owner: fence.owner,
fingerprint,
configurationFingerprint,
});
try {
await this.replacements.get(playlistId)?.catch(() => undefined);
this.assertFenceCurrent(fence, fingerprint);
this.assertFenceCurrent(fence, configurationFingerprint);
await this.tokens
.getPending(playlistId)
?.promise.catch(() => undefined);
this.assertFenceCurrent(fence, fingerprint);
this.assertFenceCurrent(fence, configurationFingerprint);
return fence;
} catch (error) {
this.cancelEdit(fence);
@@ -101,7 +123,11 @@ export class StalkerEditedSessionCoordinator {
replace(playlist: Playlist, fence?: StalkerEditFence): Promise<Playlist> {
const playlistId = playlist._id;
const fingerprint = stalkerSessionFingerprint(playlist);
const sessionFingerprint = stalkerSessionFingerprint(playlist);
const configurationFingerprint = stalkerConfigurationFingerprint(
playlist,
sessionFingerprint
);
const owner = fence?.owner ?? Symbol('stalker-edit');
if (
fence &&
@@ -114,11 +140,21 @@ export class StalkerEditedSessionCoordinator {
}
// Keep the same owner while discovery replaces its input-shaped
// fingerprint with the resolved endpoint/mode fingerprint.
this.pendingEdits.set(playlistId, { owner, fingerprint });
this.pendingEdits.set(playlistId, {
owner,
configurationFingerprint,
});
const previous = this.replacements.get(playlistId) ?? Promise.resolve();
const replacement = previous
.catch(() => undefined)
.then(() => this.commit(playlist, fingerprint, owner));
.then(() =>
this.commit(
playlist,
sessionFingerprint,
configurationFingerprint,
owner
)
);
this.replacements.set(playlistId, replacement);
void replacement
.finally(() => {
@@ -135,15 +171,22 @@ export class StalkerEditedSessionCoordinator {
private async commit(
playlist: Playlist,
fingerprint: string,
sessionFingerprint: string,
configurationFingerprint: string,
owner: symbol
): Promise<Playlist> {
const playlistId = playlist._id;
this.assertFenceCurrent({ playlistId, owner }, fingerprint);
this.assertFenceCurrent(
{ playlistId, owner },
configurationFingerprint
);
await this.tokens
.getPending(playlistId)
?.promise.catch(() => undefined);
this.assertFenceCurrent({ playlistId, owner }, fingerprint);
this.assertFenceCurrent(
{ playlistId, owner },
configurationFingerprint
);
const playlists = this.resolvePlaylistsService();
const isFullPortal = isFullStalkerPortalPlaylist(playlist);
@@ -157,7 +200,7 @@ export class StalkerEditedSessionCoordinator {
}
sessionPatch = {
stalkerToken: token,
stalkerSessionIdentity: fingerprint,
stalkerSessionIdentity: sessionFingerprint,
stalkerWatchdogTimeout: playlist.stalkerWatchdogTimeout,
stalkerTimeslot: playlist.stalkerTimeslot,
stalkerAccountInfo: playlist.stalkerAccountInfo,
@@ -173,8 +216,14 @@ export class StalkerEditedSessionCoordinator {
if (!persistedPlaylist) {
throw new Error('Resolved Stalker playlist could not be persisted');
}
this.assertFenceCurrent({ playlistId, owner }, fingerprint);
this.authoritativeFingerprints.set(playlistId, fingerprint);
this.assertFenceCurrent(
{ playlistId, owner },
configurationFingerprint
);
this.authoritativeConfigurations.set(
playlistId,
configurationFingerprint
);
if (this.pendingEdits.get(playlistId)?.owner === owner) {
this.pendingEdits.delete(playlistId);
}
@@ -184,7 +233,11 @@ export class StalkerEditedSessionCoordinator {
return persistedPlaylist;
}
this.tokens.set(playlistId, sessionPatch.stalkerToken, fingerprint);
this.tokens.set(
playlistId,
sessionPatch.stalkerToken,
sessionFingerprint
);
this.watchdog.applyProfileTiming(playlistId, {
watchdogTimeoutSeconds: playlist.stalkerWatchdogTimeout,
timeslotSeconds: playlist.stalkerTimeslot,
@@ -194,13 +247,13 @@ export class StalkerEditedSessionCoordinator {
private assertFenceCurrent(
fence: StalkerEditFence,
fingerprint: string
configurationFingerprint: string
): void {
const pending = this.pendingEdits.get(fence.playlistId);
if (
!pending ||
pending.owner !== fence.owner ||
pending.fingerprint !== fingerprint
pending.configurationFingerprint !== configurationFingerprint
) {
throw new Error('Stale Stalker playlist configuration');
}
@@ -214,7 +267,7 @@ export class StalkerEditedSessionCoordinator {
private async rebaseFromPersistedRow(
playlist: Playlist,
fingerprint: string
configurationFingerprint: string
): Promise<void> {
try {
const persisted = await firstValueFrom(
@@ -222,9 +275,13 @@ export class StalkerEditedSessionCoordinator {
);
if (
persisted &&
stalkerSessionFingerprint(persisted) === fingerprint
stalkerConfigurationFingerprint(persisted) ===
configurationFingerprint
) {
this.authoritativeFingerprints.set(playlist._id, fingerprint);
this.authoritativeConfigurations.set(
playlist._id,
configurationFingerprint
);
return;
}
} catch {
@@ -234,3 +291,14 @@ export class StalkerEditedSessionCoordinator {
throw new Error('Stale Stalker playlist configuration');
}
}
/** In-run Edit authority also owns the observed full/simple routing mode. */
function stalkerConfigurationFingerprint(
playlist: Playlist,
sessionFingerprint = stalkerSessionFingerprint(playlist)
): string {
return JSON.stringify([
sessionFingerprint,
isFullStalkerPortalPlaylist(playlist),
]);
}
@@ -101,7 +101,12 @@ describe('StalkerItvCacheService', () => {
{ provide: DataService, useValue: { sendIpcEvent } },
{
provide: StalkerSessionService,
useValue: { makeAuthenticatedRequest: jest.fn() },
useValue: {
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({
token: null,
}),
},
},
],
});
@@ -394,7 +394,7 @@ export class StalkerSessionService {
// guard() may have yielded for a persisted-row authority rebase. An
// Edit can reserve the playlist before this continuation claims the
// token slot, so close that final pre-handshake window as well.
this.editedSessions.assertCurrent(playlist._id, fingerprint);
this.editedSessions.assertCurrent(playlist, fingerprint);
// Create the authentication promise and store it to prevent concurrent auth attempts
// Use async/await wrapper to properly clean up on both success and failure
@@ -427,7 +427,7 @@ export class StalkerSessionService {
stored.watchdogTimeoutSeconds !== undefined,
}
);
this.editedSessions.assertCurrent(playlist._id, fingerprint);
this.editedSessions.assertCurrent(playlist, fingerprint);
this.setCachedToken(playlist._id, result.token, playlist);
this.applySessionOutcome(
playlist._id,
@@ -493,7 +493,7 @@ export class StalkerSessionService {
// performs its own handshake either way.
await inFlight.promise.catch(() => undefined);
}
this.editedSessions.assertCurrent(playlist._id, fingerprint);
this.editedSessions.assertCurrent(playlist, fingerprint);
// Publish the slot before the first await so no other waiter can
// observe it as free while this handshake is starting.
@@ -534,7 +534,7 @@ export class StalkerSessionService {
},
}
);
this.editedSessions.assertCurrent(playlist._id, fingerprint);
this.editedSessions.assertCurrent(playlist, fingerprint);
this.setCachedToken(playlist._id, result.token, playlist);
// This path always ran a real get_profile, so the decoded cadence
// is authoritative; the previous values are only the write-back
@@ -27,6 +27,9 @@ describe('StalkerStore API compatibility smoke', () => {
useValue: {
ensureToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({
token: null,
}),
},
},
{
@@ -150,6 +150,9 @@ describe('withStalkerContent failure states', () => {
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({
token: null,
}),
},
},
{
@@ -520,6 +523,9 @@ describe('withStalkerContent full ITV channel list cache', () => {
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({
token: null,
}),
},
},
{
@@ -42,6 +42,7 @@ describe('withStalkerEpg', () => {
let runtimeSupportsEpg: boolean;
let stalkerSessionService: {
makeAuthenticatedRequest: jest.Mock<Promise<unknown>, unknown[]>;
ensureToken: jest.Mock<Promise<unknown>, unknown[]>;
};
let epgBridge: {
supportsEpgMapping: boolean;
@@ -56,6 +57,7 @@ describe('withStalkerEpg', () => {
};
stalkerSessionService = {
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
};
epgBridge = {
supportsEpgMapping: true,
@@ -112,6 +112,9 @@ describe('withStalkerPlayer', () => {
useValue: {
getCachedToken: jest.fn(),
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({
token: null,
}),
},
},
{
@@ -94,6 +94,9 @@ describe('withStalkerSeries serialSeasonsResource gating', () => {
provide: StalkerSessionService,
useValue: {
makeAuthenticatedRequest: jest.fn(),
ensureToken: jest.fn().mockResolvedValue({
token: null,
}),
},
},
{
@@ -59,7 +59,14 @@ describe('withStalkerSnapshotRefresh', () => {
providers: [
TestSnapshotRefreshStore,
{ provide: DataService, useValue: dataService },
{ provide: StalkerSessionService, useValue: {} },
{
provide: StalkerSessionService,
useValue: {
ensureToken: jest.fn().mockResolvedValue({
token: null,
}),
},
},
],
});
@@ -317,7 +317,7 @@ describe('stalker-player-request.utils', () => {
);
});
it('skips the handshake for a simple portal', async () => {
it('guards a simple portal without requiring a token', async () => {
// The command has to be PORTAL-OWNED, or the foreign-host early
// return would skip the handshake by itself and this would pass
// without saying anything about portal mode. `PLAYLIST` is a
@@ -330,7 +330,12 @@ describe('stalker-player-request.utils', () => {
});
expect(streamUrl).toBe('http://demo.example/live/42.m3u8');
expect(stalkerSession.ensureToken).not.toHaveBeenCalled();
expect(stalkerSession.ensureToken).toHaveBeenCalledWith(
expect.objectContaining({
_id: PLAYLIST._id,
isFullStalkerPortal: false,
})
);
expect(dataService.sendIpcEvent).not.toHaveBeenCalled();
});
@@ -17,6 +17,7 @@ function createDeps(): StalkerRequestDeps {
},
stalkerSession: {
makeAuthenticatedRequest: jest.fn().mockResolvedValue({ js: [] }),
ensureToken: jest.fn().mockResolvedValue({ token: null }),
},
} as unknown as StalkerRequestDeps;
}
@@ -75,6 +76,32 @@ describe('executeStalkerRequest', () => {
expect(
deps.stalkerSession.makeAuthenticatedRequest
).not.toHaveBeenCalled();
expect(deps.stalkerSession.ensureToken).toHaveBeenCalledWith(
expect.objectContaining({
...playlist,
lastUsage: '',
})
);
});
it('does not dispatch a token-free request from a stale portal mode', async () => {
const deps = createDeps();
const stalePlaylist = {
_id: 'stalker-stale-simple',
title: 'Stale simple snapshot',
portalUrl: 'https://portal.example.test/server/load.php',
macAddress: 'has-mac-address',
isFullStalkerPortal: false,
} as PlaylistMeta;
(deps.stalkerSession.ensureToken as jest.Mock).mockRejectedValue(
new Error('Stale Stalker playlist configuration')
);
await expect(
executeStalkerRequest(deps, stalePlaylist, CATEGORY_PARAMS)
).rejects.toThrow(/stale/i);
expect(deps.dataService.sendIpcEvent).not.toHaveBeenCalled();
});
});
@@ -79,17 +79,16 @@ export async function ensureStalkerSession(
? deps.portalRepair.applyOverride(playlist)
: playlist;
// A token-free panel needs no session, so it can serve credentialed
// streams as well as it ever could.
if (!isFullStalkerPortalPlaylist(effective)) {
return true;
}
try {
const { token } = await deps.stalkerSession.ensureToken(
toStalkerSessionPlaylist(effective)
);
return Boolean(token);
// `ensureToken` is also the post-Edit configuration guard. For a
// simple portal it returns immediately with no token and no network
// request, but still rejects a snapshot whose observed mode is stale.
return isFullStalkerPortalPlaylist(effective)
? Boolean(token)
: true;
} catch (error) {
logger?.warn('Could not establish the Stalker session', error);
return false;
@@ -115,6 +114,12 @@ async function dispatchStalkerRequest<T>(
);
}
// A direct request has no authentication layer to invoke the Edit
// authority guard. `ensureToken` is network-free in simple mode, and
// prevents a pre-Edit simple snapshot from issuing a token-free request
// after the same endpoint has been reclassified as full.
await deps.stalkerSession.ensureToken(toStalkerSessionPlaylist(playlist));
return deps.dataService.sendIpcEvent<T>(STALKER_REQUEST, {
url: playlist.portalUrl,
macAddress: playlist.macAddress,