fix(backup): harden portal credential restore

This commit is contained in:
4gray committed 2026-07-27 12:06:56 +02:00
1 parent 9988e506de
commit 171095e091
9 files changed
+332 -24

No files matched your search

@@ -96,7 +96,7 @@ describe('SettingsBackupCredentialsDialogComponent', () => {
expect(text).toContain('Enter a password.');
});
it('returns a trimmed username while preserving the password exactly', () => {
it('returns the username and password exactly as entered', () => {
component.credentialsForm.setValue({
username: ' viewer ',
password: ' secret with spaces ',
@@ -105,7 +105,7 @@ describe('SettingsBackupCredentialsDialogComponent', () => {
component.submit();
expect(dialogRef.close).toHaveBeenCalledWith({
username: 'viewer',
username: ' viewer ',
password: ' secret with spaces ',
});
});
@@ -59,7 +59,7 @@ export class SettingsBackupCredentialsDialogComponent {
const { username, password } = this.credentialsForm.getRawValue();
this.dialogRef.close({
username: username.trim(),
username,
password,
});
}
@@ -175,6 +175,107 @@ describe('PlaylistBackupService Stalker restore safety', () => {
);
});
it('matches a structured backup identity to the equivalent legacy local identity', async () => {
const local = existing({
_id: 'local-id',
stalkerIdentityOverrides: undefined,
stalkerDeviceId1: 'LOCAL-DEVICE',
stalkerSerialNumber: 'LOCAL-SERIAL',
});
const { playlistsService, service } = serviceWith([local]);
const backup = entry({
portalUrl: local.portalUrl as string,
sourceUrl: local.stalkerSourceUrl,
macAddress: local.macAddress as string,
profilePreset: local.stalkerProfilePreset,
identityOverrides: {
deviceId1: 'LOCAL-DEVICE',
serialNumber: 'LOCAL-SERIAL',
},
username: local.username,
password: 'replacement-password',
});
const summary = await service.importBackup(
JSON.stringify(manifest(backup, true))
);
expect(summary).toMatchObject({ imported: 0, merged: 1 });
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: 'local-id',
password: 'replacement-password',
})
);
});
it('does not merge when a legacy explicit device identity differs', async () => {
const local = existing({
_id: 'local-id',
stalkerIdentityOverrides: undefined,
stalkerDeviceId1: 'LOCAL-DEVICE',
});
const { service } = serviceWith([local]);
const backup = entry({
portalUrl: local.portalUrl as string,
sourceUrl: local.stalkerSourceUrl,
macAddress: local.macAddress as string,
profilePreset: local.stalkerProfilePreset,
stalkerDeviceId1: 'OTHER-DEVICE',
username: local.username,
password: 'replacement-password',
});
await expect(
service.importBackup(JSON.stringify(manifest(backup, true)))
).resolves.toMatchObject({ imported: 1, merged: 0 });
});
it('matches equivalent structured and legacy transport, but separates a transport mismatch', async () => {
const local = existing({
_id: 'local-id',
origin: 'https://portal.test',
referrer: 'https://portal.test/c/',
stalkerTransportConfiguration: undefined,
userAgent: 'MAG-UA',
});
const matching = entry({
portalUrl: local.portalUrl as string,
sourceUrl: local.stalkerSourceUrl,
macAddress: local.macAddress as string,
profilePreset: local.stalkerProfilePreset,
identityOverrides: local.stalkerIdentityOverrides,
transportConfiguration: {
origin: 'https://portal.test',
referer: 'https://portal.test/c/',
userAgent: 'MAG-UA',
},
username: local.username,
password: 'replacement-password',
});
const matchingHarness = serviceWith([local]);
await expect(
matchingHarness.service.importBackup(
JSON.stringify(manifest(matching, true))
)
).resolves.toMatchObject({ imported: 0, merged: 1 });
const mismatching = entry({
...matching.connection,
transportConfiguration: {
...matching.connection.transportConfiguration,
userAgent: 'DIFFERENT-UA',
},
});
const mismatchHarness = serviceWith([local]);
await expect(
mismatchHarness.service.importBackup(
JSON.stringify(manifest(mismatching, true))
)
).resolves.toMatchObject({ imported: 1, merged: 0 });
});
it('keeps whitespace-distinct Stalker principals as separate rows', async () => {
const local = existing({
_id: 'local-id',
@@ -61,6 +61,9 @@ export function createPlaylistBackupService(
clearAllPlaybackPositions: jest.fn().mockResolvedValue(undefined),
savePlaybackPosition: jest.fn().mockResolvedValue(undefined),
},
portalStatusService: {
checkPortalStatus: jest.fn().mockResolvedValue('active'),
},
pendingRestoreService: {
set: jest.fn(),
clear: jest.fn(),
@@ -5,6 +5,7 @@ import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { DatabaseService } from './database-electron.service';
import { PlaybackPositionService } from './playback-position.service';
import { PortalStatusService } from './portal-status.service';
import { XtreamPendingRestoreService } from './xtream-pending-restore.service';
import {
isM3uRecentlyViewedItem,
@@ -17,6 +18,9 @@ import {
PlaylistBackupSettings,
PLAYLIST_BACKUP_KIND,
PLAYLIST_BACKUP_VERSION,
LEGACY_DEFAULT_STALKER_SERIAL,
StalkerSessionIdentityOverrides,
StalkerSessionTransportConfiguration,
StalkerPlaylistBackupEntry,
StalkerPortalItem,
XtreamBackupCategoryType,
@@ -80,6 +84,7 @@ export class PlaylistBackupService {
private readonly settingsStore = inject(SettingsStore);
private readonly databaseService = inject(DatabaseService);
private readonly playbackPositionService = inject(PlaybackPositionService);
private readonly portalStatusService = inject(PortalStatusService);
private readonly pendingRestoreService = inject(
XtreamPendingRestoreService
);
@@ -656,14 +661,25 @@ export class PlaylistBackupService {
return null;
}
const username = credentials.username.trim();
const password = credentials.password.trim();
if (!username || !password) {
const { username, password } = credentials;
if (!username.trim() || !password.trim()) {
throw new PlaylistBackupError(
'Xtream credentials must include a username and password.'
);
}
const status = await this.portalStatusService.checkPortalStatus(
entry.connection.serverUrl,
username,
password,
{ skipCache: true }
);
if (status !== 'active') {
throw new PlaylistBackupError(
'Xtream credentials could not be validated as an active account.'
);
}
return {
...entry,
connection: {
@@ -755,9 +771,17 @@ export class PlaylistBackupService {
const identityMatches = candidates.filter(
(playlist) =>
this.hasSameStalkerSourceMacAndProfile(entry, playlist) &&
this.stableObjectIdentity(playlist.stalkerIdentityOverrides) ===
this.stableObjectIdentity(
this.effectiveStalkerIdentityFromPlaylist(playlist)
) ===
this.stableObjectIdentity(
entry.connection.identityOverrides
this.effectiveStalkerIdentityFromBackup(entry)
) &&
this.stableObjectIdentity(
this.effectiveStalkerTransportFromPlaylist(playlist)
) ===
this.stableObjectIdentity(
this.effectiveStalkerTransportFromBackup(entry)
)
);
const exportedIdMatch = identityMatches.find(
@@ -826,13 +850,113 @@ export class PlaylistBackupService {
private isLegacyStalkerBackupEntry(
entry: StalkerPlaylistBackupEntry
): boolean {
const connection = entry.connection;
return (
!hasOwn(entry.connection, 'sourceUrl') &&
!hasOwn(entry.connection, 'profilePreset') &&
!hasOwn(entry.connection, 'identityOverrides')
!hasOwn(connection, 'sourceUrl') &&
!hasOwn(connection, 'profilePreset') &&
!hasOwn(connection, 'identityOverrides') &&
!hasOwn(connection, 'transportConfiguration') &&
!hasOwn(connection, 'username') &&
!hasOwn(connection, 'userAgent') &&
!hasOwn(connection, 'referrer') &&
!hasOwn(connection, 'origin') &&
!hasOwn(connection, 'stalkerSerialNumber') &&
!hasOwn(connection, 'stalkerDeviceId1') &&
!hasOwn(connection, 'stalkerDeviceId2') &&
!hasOwn(connection, 'stalkerSignature1') &&
!hasOwn(connection, 'stalkerSignature2')
);
}
private effectiveStalkerIdentityFromBackup(
entry: StalkerPlaylistBackupEntry
): StalkerSessionIdentityOverrides | undefined {
if (entry.connection.identityOverrides !== undefined) {
return this.presentStringFields(
entry.connection.identityOverrides
) as StalkerSessionIdentityOverrides | undefined;
}
return this.presentStringFields({
deviceId1: entry.connection.stalkerDeviceId1,
deviceId2: entry.connection.stalkerDeviceId2,
serialNumber: this.nonSyntheticLegacySerial(
entry.connection.stalkerSerialNumber
),
signature1: entry.connection.stalkerSignature1,
signature2: entry.connection.stalkerSignature2,
}) as StalkerSessionIdentityOverrides | undefined;
}
private effectiveStalkerIdentityFromPlaylist(
playlist: Playlist
): StalkerSessionIdentityOverrides | undefined {
if (playlist.stalkerIdentityOverrides !== undefined) {
return this.presentStringFields(
playlist.stalkerIdentityOverrides
) as StalkerSessionIdentityOverrides | undefined;
}
return this.presentStringFields({
deviceId1: playlist.stalkerDeviceId1,
deviceId2: playlist.stalkerDeviceId2,
serialNumber: this.nonSyntheticLegacySerial(
playlist.stalkerSerialNumber
),
signature1: playlist.stalkerSignature1,
signature2: playlist.stalkerSignature2,
}) as StalkerSessionIdentityOverrides | undefined;
}
private effectiveStalkerTransportFromBackup(
entry: StalkerPlaylistBackupEntry
): StalkerSessionTransportConfiguration | undefined {
if (entry.connection.transportConfiguration !== undefined) {
return this.presentStringFields(
entry.connection.transportConfiguration
) as StalkerSessionTransportConfiguration | undefined;
}
return this.presentStringFields({
origin: entry.connection.origin,
referer: entry.connection.referrer,
userAgent: entry.connection.userAgent,
}) as StalkerSessionTransportConfiguration | undefined;
}
private effectiveStalkerTransportFromPlaylist(
playlist: Playlist
): StalkerSessionTransportConfiguration | undefined {
if (playlist.stalkerTransportConfiguration !== undefined) {
return this.presentStringFields(
playlist.stalkerTransportConfiguration
) as StalkerSessionTransportConfiguration | undefined;
}
return this.presentStringFields({
origin: playlist.origin,
referer: playlist.referrer,
userAgent: playlist.userAgent,
}) as StalkerSessionTransportConfiguration | undefined;
}
private nonSyntheticLegacySerial(
serialNumber: string | undefined
): string | undefined {
return serialNumber?.trim().toUpperCase() ===
LEGACY_DEFAULT_STALKER_SERIAL
? undefined
: serialNumber;
}
private presentStringFields(
value: Readonly<object>
): Readonly<Record<string, string>> | undefined {
const present: Record<string, string> = {};
for (const [key, field] of Object.entries(value)) {
if (typeof field === 'string' && field.trim().length > 0) {
present[key] = field;
}
}
return Object.keys(present).length > 0 ? present : undefined;
}
private chooseUnambiguousMatch(
candidates: readonly Playlist[],
exportedId: string
@@ -0,0 +1,65 @@
import { of } from 'rxjs';
import {
Playlist,
PlaylistBackupManifestV1,
PLAYLIST_BACKUP_KIND,
PLAYLIST_BACKUP_VERSION,
} from '@iptvnator/shared/interfaces';
import { createPlaylistBackupService } from './playlist-backup.service.test-helpers';
describe('PlaylistBackupService redacted Xtream credential validation', () => {
it('does not persist the entry when the normal connection check rejects the supplied credentials', async () => {
const manifest: PlaylistBackupManifestV1 = {
kind: PLAYLIST_BACKUP_KIND,
version: PLAYLIST_BACKUP_VERSION,
exportedAt: '2026-07-27T00:00:00.000Z',
includeSecrets: false,
playlists: [
{
portalType: 'xtream',
exportedId: 'xtream-redacted',
title: 'Redacted Xtream',
autoRefresh: false,
connection: {
credentialsOmitted: true,
serverUrl: 'https://portal.test/base/',
},
userState: {
hiddenCategories: [],
favorites: [],
recentlyViewed: [],
playbackPositions: [],
},
},
],
};
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of([])),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
};
const portalStatusService = {
checkPortalStatus: jest.fn().mockResolvedValue('inactive'),
};
const service = createPlaylistBackupService({
playlistsService,
portalStatusService,
});
const summary = await service.importBackup(JSON.stringify(manifest), {
resolveXtreamCredentials: async () => ({
username: 'restored-user',
password: 'wrong-password',
}),
});
expect(summary).toEqual(
expect.objectContaining({ failed: 1, imported: 0 })
);
expect(summary.errors[0]).toContain(
'Xtream credentials could not be validated'
);
expect(playlistsService.addPlaylist).not.toHaveBeenCalled();
});
});
@@ -268,7 +268,12 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
};
}
function serviceWith(playlists: Playlist[]) {
function serviceWith(
playlists: Playlist[],
portalStatusService = {
checkPortalStatus: jest.fn().mockResolvedValue('active'),
}
) {
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of(playlists)),
@@ -276,8 +281,12 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
handlePlaylistParsing: jest.fn(),
};
return {
portalStatusService,
playlistsService,
service: createPlaylistBackupService({ playlistsService }),
service: createPlaylistBackupService({
playlistsService,
portalStatusService,
}),
};
}
@@ -334,7 +343,9 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
});
it('creates the row only after the resolver supplies valid credentials', async () => {
const { playlistsService, service } = serviceWith([]);
const { playlistsService, portalStatusService, service } = serviceWith(
[]
);
const summary = await service.importBackup(
JSON.stringify(redactedManifest()),
@@ -347,11 +358,17 @@ describe('PlaylistBackupService redacted Xtream restore', () => {
);
expect(summary.imported).toBe(1);
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
'https://portal.test/base/',
' restored-user ',
' restored-password ',
{ skipCache: true }
);
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
serverUrl: 'https://portal.test/base/',
username: 'restored-user',
password: 'restored-password',
username: ' restored-user ',
password: ' restored-password ',
})
);
});
@@ -79,7 +79,7 @@ describe('PortalStatusService', () => {
consoleErrorSpy.mockRestore();
});
it('normalizes full playlist URLs and trims copied credentials before checking status', async () => {
it('normalizes full playlist URLs while preserving the exact credentials', async () => {
dataService.sendIpcEvent.mockResolvedValue({
payload: {
user_info: {
@@ -103,8 +103,8 @@ describe('PortalStatusService', () => {
url: 'https://example.com',
params: {
action: 'get_account_info',
password: 'pass',
username: 'user',
password: ' pass ',
username: ' user ',
},
})
);
@@ -185,16 +185,14 @@ export class PortalStatusService {
username: string;
} | null {
try {
const normalizedUsername = username.trim();
const normalizedPassword = password.trim();
if (!normalizedUsername || !normalizedPassword) {
if (!username.trim() || !password.trim()) {
return null;
}
return {
serverUrl: normalizeXtreamServerUrl(serverUrl),
username: normalizedUsername,
password: normalizedPassword,
username,
password,
};
} catch {
return null;