fix(backup): merge restored redacted credentials

This commit is contained in:
4gray committed 2026-07-27 13:02:50 +02:00
1 parent 830405f9d7
commit 09469afbae
4 files changed
+652 -24

No files matched your search

@@ -0,0 +1,320 @@
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type {
PlaylistBackupManifestV1,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import {
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
launchElectronApp,
openSettings,
resetMockServers,
test,
xtreamMockServer,
} from './electron-test-fixtures';
import {
exportBackupThroughUi,
makeLocalXtreamCredentialsIncomplete,
readAllPlaylists,
readManifest,
readPlaylist,
requireStalkerEntry,
requireXtreamEntry,
seedProviderPlaylists,
SKIPPED_XTREAM_PLAYLIST_ID,
STALKER_LEGACY_DEVICE_ID_1,
STALKER_LEGACY_DEVICE_ID_2,
STALKER_LEGACY_SERIAL,
STALKER_LEGACY_SIGNATURE_1,
STALKER_LEGACY_SIGNATURE_2,
STALKER_PASSWORD,
STALKER_PLAYLIST_ID,
STALKER_STRUCTURED_SERIAL,
STALKER_USERNAME,
XTREAM_PLAYLIST_ID,
} from './support/backup-security';
test.describe('Electron playlist backup security acceptance', () => {
test('@backup @electron redacts exports and safely resolves redacted Xtream credentials on import', async ({
dataDir,
request,
}) => {
test.setTimeout(120000);
await resetMockServers(request, ['xtream']);
const redactedExportPath = join(
dataDir,
'backup-security-redacted.json'
);
const secretsExportPath = join(
dataDir,
'backup-security-with-secrets.json'
);
const importPath = join(dataDir, 'backup-security-import.json');
const app = await launchElectronApp(dataDir);
try {
await seedProviderPlaylists(app.mainWindow);
await openSettings(app.mainWindow);
const backupSection = app.mainWindow.locator('#backup');
const redactedWarning = backupSection.locator(
'#backup-redacted-warning'
);
const secretsWarning = backupSection.locator(
'#backup-secrets-warning'
);
const includeSecretsHost = backupSection.getByTestId(
'backup-include-secrets'
);
const includeSecretsCheckbox = backupSection.getByRole('checkbox', {
name: 'Include portal credentials and Stalker device identity',
});
await expect(redactedWarning).toBeVisible();
await expect(redactedWarning).toContainText('MAC addresses');
await expect(includeSecretsCheckbox).not.toBeChecked();
await expect(secretsWarning).toHaveCount(0);
await exportBackupThroughUi(
app.electronApp,
backupSection,
redactedExportPath
);
const redactedManifest = readManifest(redactedExportPath);
const redactedXtream = requireXtreamEntry(
redactedManifest,
XTREAM_PLAYLIST_ID
);
const redactedStalker = requireStalkerEntry(
redactedManifest,
STALKER_PLAYLIST_ID
);
expect(redactedManifest.includeSecrets).toBe(false);
expect(redactedXtream.connection).toEqual({
credentialsOmitted: true,
serverUrl: xtreamMockServer,
});
expect(redactedStalker.connection).toMatchObject({
macAddress: '00:1A:79:AA:BB:CC',
portalUrl: 'https://stalker-backup.test/server/load.php',
});
expect(redactedStalker.connection).not.toHaveProperty('username');
expect(redactedStalker.connection).not.toHaveProperty('password');
expect(redactedStalker.connection).not.toHaveProperty(
'identityOverrides'
);
expect(redactedStalker.connection).not.toHaveProperty(
'stalkerSerialNumber'
);
expect(redactedStalker.connection).not.toHaveProperty(
'stalkerDeviceId1'
);
expect(redactedStalker.connection).not.toHaveProperty(
'stalkerDeviceId2'
);
expect(redactedStalker.connection).not.toHaveProperty(
'stalkerSignature1'
);
expect(redactedStalker.connection).not.toHaveProperty(
'stalkerSignature2'
);
expect(JSON.stringify(redactedManifest)).not.toContain(
defaultXtreamPassword
);
expect(JSON.stringify(redactedManifest)).not.toContain(
STALKER_PASSWORD
);
expect(JSON.stringify(redactedManifest)).not.toContain(
STALKER_STRUCTURED_SERIAL
);
expect(JSON.stringify(redactedManifest)).not.toContain(
'stale-stalker-token'
);
await includeSecretsCheckbox.check();
await expect(includeSecretsCheckbox).toBeChecked();
await expect(redactedWarning).toBeVisible();
await expect(secretsWarning).toBeVisible();
await expect(secretsWarning).toContainText(
'Store the backup securely'
);
await expect(includeSecretsHost).toHaveAttribute(
'aria-describedby',
'backup-redacted-warning backup-secrets-warning'
);
await exportBackupThroughUi(
app.electronApp,
backupSection,
secretsExportPath
);
const secretsManifest = readManifest(secretsExportPath);
const secretsXtream = requireXtreamEntry(
secretsManifest,
XTREAM_PLAYLIST_ID
);
const secretsStalker = requireStalkerEntry(
secretsManifest,
STALKER_PLAYLIST_ID
);
expect(secretsManifest.includeSecrets).toBe(true);
expect(secretsXtream.connection).toEqual({
password: defaultXtreamPassword,
serverUrl: xtreamMockServer,
username: defaultXtreamUsername,
});
expect(secretsStalker.connection).toMatchObject({
identityOverrides: {
deviceId1: 'STRUCTURED-BACKUP-DEVICE-1',
serialNumber: STALKER_STRUCTURED_SERIAL,
},
password: STALKER_PASSWORD,
stalkerDeviceId1: STALKER_LEGACY_DEVICE_ID_1,
stalkerDeviceId2: STALKER_LEGACY_DEVICE_ID_2,
stalkerSerialNumber: STALKER_LEGACY_SERIAL,
stalkerSignature1: STALKER_LEGACY_SIGNATURE_1,
stalkerSignature2: STALKER_LEGACY_SIGNATURE_2,
username: STALKER_USERNAME,
});
expect(JSON.stringify(secretsManifest)).not.toContain(
'stale-stalker-token'
);
const skippedXtream: XtreamPlaylistBackupEntry = {
...redactedXtream,
exportedId: SKIPPED_XTREAM_PLAYLIST_ID,
title: 'Redacted Xtream To Skip',
connection: { ...redactedXtream.connection },
userState: {
favorites: [],
hiddenCategories: [],
playbackPositions: [],
recentlyViewed: [],
},
};
const importManifest: PlaylistBackupManifestV1 = {
...redactedManifest,
playlists: [
{
...redactedXtream,
connection: { ...redactedXtream.connection },
userState: {
favorites: [],
hiddenCategories: [],
playbackPositions: [],
recentlyViewed: [],
},
},
skippedXtream,
],
};
writeFileSync(
importPath,
JSON.stringify(importManifest, null, 2),
'utf-8'
);
await makeLocalXtreamCredentialsIncomplete(app.mainWindow);
const incompleteLocal = await readPlaylist(
app.mainWindow,
XTREAM_PLAYLIST_ID
);
expect(incompleteLocal).toMatchObject({
_id: XTREAM_PLAYLIST_ID,
password: '',
serverUrl: xtreamMockServer,
username: '',
});
const fileChooserPromise =
app.mainWindow.waitForEvent('filechooser');
await backupSection
.getByRole('button', { name: 'Import', exact: true })
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(importPath);
const importDialog = app.mainWindow
.locator('mat-dialog-container')
.filter({ hasText: redactedXtream.title });
await expect(importDialog).toBeVisible({ timeout: 15000 });
await expect(
importDialog.getByRole('heading', {
name: 'Xtream credentials required',
})
).toBeVisible();
await expect(importDialog).toContainText('localhost:3211');
await importDialog
.getByLabel('Username')
.fill(defaultXtreamUsername);
await importDialog
.getByLabel('Password')
.fill(defaultXtreamPassword);
await importDialog
.getByRole('button', {
name: 'Import playlist',
exact: true,
})
.click();
await expect(importDialog).toBeHidden({ timeout: 15000 });
const skipDialog = app.mainWindow
.locator('mat-dialog-container')
.filter({ hasText: skippedXtream.title });
await expect(skipDialog).toBeVisible({ timeout: 15000 });
await skipDialog
.getByRole('button', {
name: 'Skip playlist',
exact: true,
})
.click();
await expect(skipDialog).toBeHidden();
await expect(
app.mainWindow.getByText(
'Backup import finished: 0 imported, 1 merged, 1 skipped, 0 failed.'
)
).toBeVisible({ timeout: 15000 });
const restoredXtream = await readPlaylist(
app.mainWindow,
XTREAM_PLAYLIST_ID
);
expect(restoredXtream).toMatchObject({
_id: XTREAM_PLAYLIST_ID,
count: 42,
importDate: '2026-07-01T00:00:00.000Z',
lastUsage: '2026-07-20T00:00:00.000Z',
origin: 'https://local-display.test',
password: defaultXtreamPassword,
referrer: 'https://local-display.test/player',
serverTimezone: 'Europe/Berlin',
serverUrl: xtreamMockServer,
updateDate: 1784505600000,
userAgent: 'Local Xtream Presentation/9.9',
username: defaultXtreamUsername,
});
expect(
await readPlaylist(app.mainWindow, SKIPPED_XTREAM_PLAYLIST_ID)
).toBeNull();
const allPlaylists = await readAllPlaylists(app.mainWindow);
expect(allPlaylists).toHaveLength(2);
expect(
allPlaylists.some(
(playlist) =>
playlist._id === SKIPPED_XTREAM_PLAYLIST_ID ||
playlist.title === skippedXtream.title
)
).toBe(false);
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,240 @@
import { existsSync, readFileSync } from 'node:fs';
import type { ElectronApplication, Locator, Page } from '@playwright/test';
import { expect } from '@playwright/test';
import type {
Playlist,
PlaylistBackupManifestV1,
StalkerPlaylistBackupEntry,
XtreamPlaylistBackupEntry,
} from '@iptvnator/shared/interfaces';
import {
defaultXtreamPassword,
defaultXtreamUsername,
xtreamMockServer,
} from '../electron-test-fixtures';
export const XTREAM_PLAYLIST_ID = 'backup-security-xtream';
export const STALKER_PLAYLIST_ID = 'backup-security-stalker';
export const SKIPPED_XTREAM_PLAYLIST_ID = 'backup-security-xtream-skipped';
export const STALKER_USERNAME = 'stalker-backup-user';
export const STALKER_PASSWORD = 'stalker-backup-password';
export const STALKER_STRUCTURED_SERIAL = 'STRUCTURED-BACKUP-SERIAL';
export const STALKER_LEGACY_SERIAL = 'LEGACY-BACKUP-SERIAL';
export const STALKER_LEGACY_DEVICE_ID_1 = 'LEGACY-BACKUP-DEVICE-1';
export const STALKER_LEGACY_DEVICE_ID_2 = 'LEGACY-BACKUP-DEVICE-2';
export const STALKER_LEGACY_SIGNATURE_1 = 'LEGACY-BACKUP-SIGNATURE-1';
export const STALKER_LEGACY_SIGNATURE_2 = 'LEGACY-BACKUP-SIGNATURE-2';
export async function seedProviderPlaylists(page: Page): Promise<void> {
await waitForDatabaseReady(page);
const playlists: Playlist[] = [
{
_id: XTREAM_PLAYLIST_ID,
autoRefresh: true,
count: 42,
importDate: '2026-07-01T00:00:00.000Z',
lastUsage: '2026-07-20T00:00:00.000Z',
origin: 'https://local-display.test',
password: defaultXtreamPassword,
position: 7,
referrer: 'https://local-display.test/player',
serverTimezone: 'Europe/Berlin',
serverUrl: xtreamMockServer,
title: 'Backup Security Xtream',
updateDate: 1784505600000,
userAgent: 'Local Xtream Presentation/9.9',
username: defaultXtreamUsername,
},
{
_id: STALKER_PLAYLIST_ID,
autoRefresh: false,
count: 0,
importDate: '2026-07-02T00:00:00.000Z',
isFullStalkerPortal: true,
lastUsage: '2026-07-21T00:00:00.000Z',
macAddress: '00:1A:79:AA:BB:CC',
origin: 'https://stalker-backup.test',
password: STALKER_PASSWORD,
portalUrl: 'https://stalker-backup.test/server/load.php',
referrer: 'https://stalker-backup.test/c/',
stalkerAccountInfo: {
login: STALKER_USERNAME,
status: 1,
},
stalkerDeviceId1: STALKER_LEGACY_DEVICE_ID_1,
stalkerDeviceId2: STALKER_LEGACY_DEVICE_ID_2,
stalkerIdentityOverrides: {
deviceId1: 'STRUCTURED-BACKUP-DEVICE-1',
serialNumber: STALKER_STRUCTURED_SERIAL,
},
stalkerProfilePreset: {
id: 'mag250-public-5_1-minimal-v1',
version: 1,
},
stalkerSerialNumber: STALKER_LEGACY_SERIAL,
stalkerSignature1: STALKER_LEGACY_SIGNATURE_1,
stalkerSignature2: STALKER_LEGACY_SIGNATURE_2,
stalkerSourceUrl: 'https://stalker-backup.test/c/',
stalkerToken: 'stale-stalker-token',
title: 'Backup Security Stalker',
userAgent: 'Mozilla/5.0 (QtEmbedded; U; Linux; C) MAG250',
username: STALKER_USERNAME,
},
];
await page.evaluate(async (seed) => {
const upsert = window.electron?.dbUpsertAppPlaylists;
if (!upsert) {
throw new Error('playlist-bulk-upsert-unavailable');
}
const result = await upsert(seed);
if (!result.success || result.count !== seed.length) {
throw new Error(
`playlist-bulk-upsert-failed:${JSON.stringify(result)}`
);
}
}, playlists);
}
async function waitForDatabaseReady(page: Page): Promise<void> {
await expect
.poll(
() =>
page.evaluate(async () => {
const electron = window.electron;
if (!electron) {
return false;
}
try {
await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists?.());
return true;
} catch {
return false;
}
}),
{ timeout: 30000 }
)
.toBe(true);
}
export async function exportBackupThroughUi(
electronApp: ElectronApplication,
backupSection: Locator,
exportPath: string
): Promise<void> {
await electronApp.evaluate(({ dialog }, path) => {
dialog.showSaveDialog = async () => ({
canceled: false,
filePath: path,
});
}, exportPath);
await backupSection
.getByRole('button', { name: 'Export', exact: true })
.click();
await expect
.poll(() => isCompleteJsonFile(exportPath), {
message: `Backup was not written to ${exportPath}`,
timeout: 15000,
})
.toBe(true);
await expect(
backupSection.getByRole('button', { name: 'Export', exact: true })
).toBeEnabled();
}
function isCompleteJsonFile(filePath: string): boolean {
if (!existsSync(filePath)) {
return false;
}
try {
JSON.parse(readFileSync(filePath, 'utf-8'));
return true;
} catch {
return false;
}
}
export function readManifest(filePath: string): PlaylistBackupManifestV1 {
return JSON.parse(
readFileSync(filePath, 'utf-8')
) as PlaylistBackupManifestV1;
}
export function requireXtreamEntry(
manifest: PlaylistBackupManifestV1,
playlistId: string
): XtreamPlaylistBackupEntry {
const entry = manifest.playlists.find(
(candidate): candidate is XtreamPlaylistBackupEntry =>
candidate.portalType === 'xtream' &&
candidate.exportedId === playlistId
);
if (!entry) {
throw new Error(`xtream-backup-entry-missing:${playlistId}`);
}
return entry;
}
export function requireStalkerEntry(
manifest: PlaylistBackupManifestV1,
playlistId: string
): StalkerPlaylistBackupEntry {
const entry = manifest.playlists.find(
(candidate): candidate is StalkerPlaylistBackupEntry =>
candidate.portalType === 'stalker' &&
candidate.exportedId === playlistId
);
if (!entry) {
throw new Error(`stalker-backup-entry-missing:${playlistId}`);
}
return entry;
}
export async function makeLocalXtreamCredentialsIncomplete(
page: Page
): Promise<void> {
await page.evaluate(async (playlistId) => {
const read = window.electron?.dbGetAppPlaylist;
const update = window.electron?.dbUpdatePlaylist;
if (!read || !update) {
throw new Error('playlist-read-write-unavailable');
}
const playlist = await read(playlistId);
if (!playlist) {
throw new Error(`playlist-missing:${playlistId}`);
}
const result = await update(playlistId, {
password: '',
username: '',
});
if (!result.success) {
throw new Error(`playlist-update-failed:${playlistId}`);
}
}, XTREAM_PLAYLIST_ID);
}
export async function readPlaylist(
page: Page,
playlistId: string
): Promise<Playlist | null> {
return page.evaluate(async (id) => {
const read = window.electron?.dbGetAppPlaylist;
if (!read) {
throw new Error('playlist-read-unavailable');
}
return read(id);
}, playlistId);
}
export async function readAllPlaylists(page: Page): Promise<Playlist[]> {
return page.evaluate(async () => {
const read = window.electron?.dbGetAppPlaylists;
if (!read) {
throw new Error('playlist-list-unavailable');
}
return read();
});
}
@@ -76,6 +76,11 @@ type PlaylistPortalType = 'm3u' | 'xtream' | 'stalker';
type XtreamContentType = XtreamBackupContentType;
type XtreamCategoryType = XtreamBackupCategoryType;
interface ResolvedPlaylistBackupImportEntry {
entry: PlaylistBackupEntry;
exactLocalMatch?: Playlist;
}
@Injectable({
providedIn: 'root',
})
@@ -148,21 +153,25 @@ export class PlaylistBackupService {
entry.title || entry.exportedId || entry.portalType;
try {
const importEntry = await this.resolveImportEntry(
const resolvedImportEntry = await this.resolveImportEntry(
entry,
existingPlaylists,
options
);
if (importEntry === null) {
if (resolvedImportEntry === null) {
summary.skipped += 1;
continue;
}
const existingMatch = await this.findExistingMatch(
importEntry,
existingPlaylists,
manifest.includeSecrets
);
const { entry: importEntry, exactLocalMatch } =
resolvedImportEntry;
const existingMatch =
exactLocalMatch ??
(await this.findExistingMatch(
importEntry,
existingPlaylists,
manifest.includeSecrets
));
const targetId = this.resolveTargetPlaylistId(
importEntry,
existingMatch?._id,
@@ -633,25 +642,27 @@ export class PlaylistBackupService {
entry: PlaylistBackupEntry,
existingPlaylists: readonly Playlist[],
options: PlaylistBackupImportOptions
): Promise<PlaylistBackupEntry | null> {
): Promise<ResolvedPlaylistBackupImportEntry | null> {
if (
entry.portalType !== 'xtream' ||
entry.connection.credentialsOmitted !== true
) {
return entry;
return { entry };
}
const exactLocalMatch = existingPlaylists.some(
const exactLocalMatch = existingPlaylists.find(
(playlist) =>
playlist._id === entry.exportedId &&
this.getPlaylistPortalType(playlist) === 'xtream' &&
this.normalizeUrlIdentity(playlist.serverUrl ?? '') ===
this.normalizeUrlIdentity(entry.connection.serverUrl) &&
this.normalizeIdentityValue(playlist.username ?? '') !== '' &&
this.normalizeIdentityValue(playlist.password ?? '') !== ''
this.normalizeUrlIdentity(entry.connection.serverUrl)
);
if (exactLocalMatch) {
return entry;
const hasUsableLocalCredentials =
exactLocalMatch !== undefined &&
this.normalizeIdentityValue(exactLocalMatch.username ?? '') !==
'' &&
this.normalizeIdentityValue(exactLocalMatch.password ?? '') !== '';
if (hasUsableLocalCredentials) {
return { entry, exactLocalMatch };
}
const credentials = await options.resolveXtreamCredentials?.({
@@ -683,12 +694,15 @@ export class PlaylistBackupService {
}
return {
...entry,
connection: {
password,
serverUrl: entry.connection.serverUrl,
username,
entry: {
...entry,
connection: {
password,
serverUrl: entry.connection.serverUrl,
username,
},
},
...(exactLocalMatch ? { exactLocalMatch } : {}),
};
}
@@ -92,9 +92,7 @@ describe('PlaylistBackupService redacted Xtream credential validation', () => {
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
};
const resolveXtreamCredentials = jest
.fn()
.mockResolvedValue(null);
const resolveXtreamCredentials = jest.fn().mockResolvedValue(null);
const service = createPlaylistBackupService({
playlistsService,
});
@@ -119,4 +117,60 @@ describe('PlaylistBackupService redacted Xtream credential validation', () => {
expect(playlistsService.addPlaylist).not.toHaveBeenCalled();
}
);
it('merges an exact incomplete local row after validating replacement credentials', async () => {
const local = {
_id: 'xtream-redacted',
title: 'Incomplete local Xtream',
count: 7,
importDate: '2026-07-01T00:00:00.000Z',
lastUsage: '2026-07-02T00:00:00.000Z',
autoRefresh: false,
serverUrl: 'https://portal.test/base',
username: '',
password: '',
userAgent: 'Local presentation/1.0',
serverTimezone: 'Europe/Berlin',
} as Playlist;
const playlistsService = {
addPlaylist: jest.fn((playlist: Playlist) => of(playlist)),
getAllData: jest.fn(() => of([local])),
getRawPlaylistById: jest.fn(() => of('#EXTM3U')),
handlePlaylistParsing: jest.fn(),
};
const portalStatusService = {
checkPortalStatus: jest.fn().mockResolvedValue('active'),
};
const service = createPlaylistBackupService({
playlistsService,
portalStatusService,
});
const summary = await service.importBackup(
JSON.stringify(redactedManifest()),
{
resolveXtreamCredentials: async () => ({
username: 'replacement-user',
password: 'replacement-password',
}),
}
);
expect(summary).toEqual({
imported: 0,
merged: 1,
skipped: 0,
failed: 0,
errors: [],
});
expect(playlistsService.addPlaylist).toHaveBeenCalledWith(
expect.objectContaining({
_id: local._id,
password: 'replacement-password',
serverTimezone: 'Europe/Berlin',
userAgent: 'Local presentation/1.0',
username: 'replacement-user',
})
);
});
});