diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts index f07c90b0d..a93e750b8 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.spec.ts @@ -12,9 +12,8 @@ describe('StalkerAccountInfoService', () => { let service: StalkerAccountInfoService; let dataService: { sendIpcEvent: jest.Mock }; let stalkerSession: { - authenticate: jest.Mock; + refreshAccountProfile: jest.Mock; makeAuthenticatedRequest: jest.Mock; - setCachedToken: jest.Mock; }; const portalPlaylist = { @@ -37,9 +36,8 @@ describe('StalkerAccountInfoService', () => { beforeEach(() => { dataService = { sendIpcEvent: jest.fn() }; stalkerSession = { - authenticate: jest.fn(), + refreshAccountProfile: jest.fn(), makeAuthenticatedRequest: jest.fn(), - setCachedToken: jest.fn(), }; TestBed.configureTestingModule({ @@ -60,32 +58,23 @@ describe('StalkerAccountInfoService', () => { } as PlaylistMeta) ).resolves.toBeNull(); expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); - expect(stalkerSession.authenticate).not.toHaveBeenCalled(); + expect(stalkerSession.refreshAccountProfile).not.toHaveBeenCalled(); }); it('re-authenticates full portals and maps the profile account block', async () => { - stalkerSession.authenticate.mockResolvedValue({ - token: 'token-1', - accountInfo: { - login: 'user-77', - expire_date: 1_790_000_000, - tariff_plan_name: 'Premium', - status: '1', - }, + stalkerSession.refreshAccountProfile.mockResolvedValue({ + login: 'user-77', + expire_date: 1_790_000_000, + tariff_plan_name: 'Premium', + status: '1', }); const snapshot = await service.fetchAccountInfo(fullPortalPlaylist); - expect(stalkerSession.authenticate).toHaveBeenCalledWith( - fullPortalPlaylist.portalUrl, - fullPortalPlaylist.macAddress, - expect.any(Object) - ); - // The fresh token must land in the managed session cache — strict - // portals invalidate the previous token on each handshake. - expect(stalkerSession.setCachedToken).toHaveBeenCalledWith( - fullPortalPlaylist._id, - 'token-1' + // Routed through the session service so the handshake serializes + // with ensureToken() and the new token replaces the cached one. + expect(stalkerSession.refreshAccountProfile).toHaveBeenCalledWith( + expect.objectContaining({ _id: fullPortalPlaylist._id }) ); expect(snapshot).toEqual({ login: 'user-77', @@ -98,7 +87,7 @@ describe('StalkerAccountInfoService', () => { }); it('returns null when the full-portal profile has no account block', async () => { - stalkerSession.authenticate.mockResolvedValue({ token: 'token-1' }); + stalkerSession.refreshAccountProfile.mockResolvedValue(undefined); await expect( service.fetchAccountInfo(fullPortalPlaylist) diff --git a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts index 785e18cea..1dcedb193 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-account-info.service.ts @@ -1,7 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { DataService } from '@iptvnator/services'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; -import { getStalkerPortalIdentityFromPlaylist } from './stalker-identity.utils'; import { StalkerSessionService } from './stalker-session.service'; import { executeStalkerRequest, @@ -82,19 +81,14 @@ export class StalkerAccountInfoService { private async fetchViaProfile( playlist: PlaylistMeta ): Promise { - const identity = getStalkerPortalIdentityFromPlaylist( + // Goes through the session service rather than calling + // authenticate() directly: it serializes with any in-flight + // ensureToken() and republishes the resulting token, so the + // dialog cannot strand an active portal session on a token its + // own handshake invalidated. + const accountInfo = await this.stalkerSession.refreshAccountProfile( toStalkerSessionPlaylist(playlist) ); - const { token, accountInfo } = await this.stalkerSession.authenticate( - playlist.portalUrl as string, - playlist.macAddress as string, - identity - ); - - // Strict portals invalidate the previous token on every handshake. - // Publish the fresh one into the managed session cache so an active - // portal session keeps working after the dialog re-authenticated. - this.stalkerSession.setCachedToken(playlist._id, token); if (!accountInfo) { return null; diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts index 8ed39a0de..04c9a1491 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.spec.ts @@ -166,6 +166,68 @@ describe('StalkerSessionService identity payloads', () => { expect(authenticate).toHaveBeenCalledWith(portalUrl, macAddress, {}); }); + it('serializes refreshAccountProfile behind an in-flight ensureToken', async () => { + const playlist = { + _id: 'playlist-1', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + let releaseFirst: (value: { token: string }) => void = () => undefined; + const authenticate = jest + .spyOn(service, 'authenticate') + .mockImplementationOnce( + () => + new Promise((resolve) => { + releaseFirst = resolve; + }) + ) + .mockResolvedValueOnce({ + token: 'profile-token', + accountInfo: { login: 'user-1' }, + }); + + const pending = service.ensureToken(playlist); + const refresh = service.refreshAccountProfile(playlist); + + // Two handshakes must never overlap: on strict portals the second + // would invalidate the first one's token. + await Promise.resolve(); + expect(authenticate).toHaveBeenCalledTimes(1); + + releaseFirst({ token: 'session-token' }); + await pending; + const accountInfo = await refresh; + + expect(authenticate).toHaveBeenCalledTimes(2); + expect(accountInfo).toEqual({ login: 'user-1' }); + // The refreshed token replaces the one its own handshake killed. + expect(service.getCachedToken(playlist._id)).toBe('profile-token'); + }); + + it('refreshes the account profile even when a pending authentication fails', async () => { + const playlist = { + _id: 'playlist-2', + portalUrl, + macAddress, + isFullStalkerPortal: true, + } as Playlist; + + jest.spyOn(service, 'authenticate') + .mockRejectedValueOnce(new Error('handshake refused')) + .mockResolvedValueOnce({ + token: 'profile-token', + accountInfo: { login: 'user-2' }, + }); + + const pending = service.ensureToken(playlist).catch(() => undefined); + const accountInfo = await service.refreshAccountProfile(playlist); + await pending; + + expect(accountInfo).toEqual({ login: 'user-2' }); + }); + it('passes an explicit serial into the initial handshake request', async () => { dataService.sendIpcEvent .mockResolvedValueOnce({ diff --git a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts index 91f1ea741..daeadfde4 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-session.service.ts @@ -520,6 +520,63 @@ export class StalkerSessionService { return authPromise; } + /** + * Re-runs handshake + get_profile to read the portal's current account + * block, sharing `ensureToken()`'s per-playlist serialization. + * + * A handshake invalidates the previous token on strict portals, so two + * overlapping ones leave whichever finishes first holding a dead token. + * Waiting for any in-flight authentication (and registering this one so + * later callers wait for it) keeps handshakes sequential; the resulting + * token replaces the cached one, so catalog and playback requests keep + * working afterwards. + */ + async refreshAccountProfile( + playlist: Playlist + ): Promise { + if (!playlist.portalUrl || !playlist.macAddress) { + throw new Error('Portal URL and MAC address are required'); + } + + const portalUrl = playlist.portalUrl; + const macAddress = playlist.macAddress; + const identity = getStalkerPortalIdentityFromPlaylist(playlist); + + const inFlight = this.pendingAuth.get(playlist._id); + if (inFlight) { + this.logger.debug('Waiting for pending authentication...'); + // A failed pending auth must not abort the refresh; this call + // performs its own handshake either way. + await inFlight.catch(() => undefined); + } + + let accountInfo: StalkerProfileResponse['js']['account_info']; + const authPromise = (async () => { + try { + const result = await this.authenticate( + portalUrl, + macAddress, + identity + ); + accountInfo = result.accountInfo; + this.setCachedToken(playlist._id, result.token); + return { + token: result.token, + serialNumber: identity.serialNumber, + }; + } finally { + if (this.pendingAuth.get(playlist._id) === authPromise) { + this.pendingAuth.delete(playlist._id); + } + } + })(); + + this.pendingAuth.set(playlist._id, authPromise); + await authPromise; + + return accountInfo; + } + /** * Checks if a response or error indicates an authorization failure */