fix(stalker): serialize account-profile refresh with session auth

The dialog's direct authenticate() call bypassed the pendingAuth map
ensureToken() uses, so a refresh could run a second handshake while a
catalog or watchdog request was still authenticating. On strict portals
each handshake invalidates the other's token, and the later
setCachedToken() could publish an already-dead one.

Move the refresh into StalkerSessionService.refreshAccountProfile(): it
waits for any in-flight authentication, registers its own so later
callers wait for it, and republishes the resulting token. A failed
pending auth no longer aborts the refresh, and the pendingAuth entry is
only cleared when it is still this call's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-08-02 09:16:00 +02:00
1 parent 59693da057
commit ec263022db
4 files changed
+138 -36

No files matched your search

@@ -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)
@@ -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<StalkerAccountSnapshot | null> {
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;
@@ -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({
@@ -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<StalkerProfileResponse['js']['account_info']> {
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
*/