mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
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:
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
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user