fix(backup): invalidate restored stalker state

This commit is contained in:
4gray committed 2026-07-27 14:00:51 +02:00
1 parent f5090d623e
commit 8f7c4761ea
4 files changed
+138 -1

No files matched your search

@@ -212,6 +212,14 @@ test.describe('Electron playlist backup security acceptance', () => {
},
},
skippedXtream,
{
...redactedStalker,
connection: { ...redactedStalker.connection },
userState: {
favorites: [],
recentlyViewed: [],
},
},
],
};
writeFileSync(
@@ -278,7 +286,7 @@ test.describe('Electron playlist backup security acceptance', () => {
await expect(
app.mainWindow.getByText(
'Backup import finished: 0 imported, 1 merged, 1 skipped, 0 failed.'
'Backup import finished: 0 imported, 2 merged, 1 skipped, 0 failed.'
)
).toBeVisible({ timeout: 15000 });
@@ -304,6 +312,32 @@ test.describe('Electron playlist backup security acceptance', () => {
await readPlaylist(app.mainWindow, SKIPPED_XTREAM_PLAYLIST_ID)
).toBeNull();
const restoredStalker = await readPlaylist(
app.mainWindow,
STALKER_PLAYLIST_ID
);
expect(restoredStalker).toMatchObject({
_id: STALKER_PLAYLIST_ID,
password: STALKER_PASSWORD,
stalkerIdentityOverrides: {
deviceId1: 'STRUCTURED-BACKUP-DEVICE-1',
serialNumber: STALKER_STRUCTURED_SERIAL,
},
username: STALKER_USERNAME,
});
expect(restoredStalker).not.toHaveProperty('stalkerAccountInfo');
expect(restoredStalker).not.toHaveProperty('stalkerLandingUrl');
expect(restoredStalker).not.toHaveProperty(
'stalkerLastVerifiedAt'
);
expect(restoredStalker).not.toHaveProperty(
'stalkerRecipeClassifierVersion'
);
expect(restoredStalker).not.toHaveProperty(
'stalkerRequestRecipe'
);
expect(restoredStalker).not.toHaveProperty('stalkerToken');
const allPlaylists = await readAllPlaylists(app.mainWindow);
expect(allPlaylists).toHaveLength(2);
expect(
@@ -72,6 +72,10 @@ export async function seedProviderPlaylists(page: Page): Promise<void> {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
stalkerLandingUrl: 'https://stalker-backup.test/c/',
stalkerLastVerifiedAt: '2026-07-27T10:00:00.000Z',
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'stateless-mac',
stalkerSerialNumber: STALKER_LEGACY_SERIAL,
stalkerSignature1: STALKER_LEGACY_SIGNATURE_1,
stalkerSignature2: STALKER_LEGACY_SIGNATURE_2,
@@ -0,0 +1,95 @@
import { of } from 'rxjs';
import {
Playlist,
PlaylistBackupManifestV1,
PLAYLIST_BACKUP_KIND,
PLAYLIST_BACKUP_VERSION,
StalkerPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import { createPlaylistBackupService } from './playlist-backup.service.test-helpers';
describe('PlaylistBackupService Stalker learned-state restore', () => {
it.each([false, true])(
'clears excluded learned connection state when merging a %s-secret backup',
async (includeSecrets) => {
const local: Playlist = {
_id: 'stalker-backup',
title: 'Local Stalker',
count: 1,
importDate: '2026-07-01T00:00:00.000Z',
lastUsage: '2026-07-01T00:00:00.000Z',
autoRefresh: false,
portalUrl: 'https://portal.test/server/load.php',
stalkerSourceUrl: 'https://portal.test/c/',
macAddress: '00:1A:79:AA:BB:CC',
stalkerProfilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
username: 'local-user',
password: 'local-password',
stalkerIdentityOverrides: {
serialNumber: 'LOCAL-SERIAL',
},
stalkerLandingUrl: 'https://portal.test/c/',
stalkerLastVerifiedAt: '2026-07-27T10:00:00.000Z',
stalkerRecipeClassifierVersion: 1,
stalkerRequestRecipe: 'stateless-mac',
} as Playlist;
const backupEntry: StalkerPlaylistBackupEntry = {
portalType: 'stalker',
exportedId: local._id,
title: 'Restored Stalker',
autoRefresh: false,
connection: {
portalUrl: local.portalUrl as string,
sourceUrl: local.stalkerSourceUrl,
macAddress: local.macAddress as string,
profilePreset: local.stalkerProfilePreset,
...(includeSecrets
? {
identityOverrides:
local.stalkerIdentityOverrides,
username: local.username,
password: 'replacement-password',
}
: {}),
},
userState: {
favorites: [],
recentlyViewed: [],
},
};
const manifest: PlaylistBackupManifestV1 = {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt: '2026-07-27T00:00:00.000Z',
includeSecrets,
playlists: [backupEntry],
};
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of([local])),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
};
const service = createPlaylistBackupService({
playlistsService,
});
await expect(
service.importBackup(JSON.stringify(manifest))
).resolves.toMatchObject({ imported: 0, merged: 1, failed: 0 });
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: local._id,
stalkerLandingUrl: undefined,
stalkerLastVerifiedAt: undefined,
stalkerRecipeClassifierVersion: undefined,
stalkerRequestRecipe: undefined,
})
);
}
);
});
@@ -1215,6 +1215,10 @@ export class PlaylistBackupService {
...(hasOwn(entry.connection, 'stalkerSignature2')
? { stalkerSignature2: entry.connection.stalkerSignature2 }
: {}),
stalkerLandingUrl: undefined,
stalkerLastVerifiedAt: undefined,
stalkerRecipeClassifierVersion: undefined,
stalkerRequestRecipe: undefined,
stalkerToken: undefined,
stalkerAccountInfo: undefined,
};