diff --git a/apps/electron-backend-e2e/src/backup-security.e2e.ts b/apps/electron-backend-e2e/src/backup-security.e2e.ts new file mode 100644 index 000000000..84d1c53ca --- /dev/null +++ b/apps/electron-backend-e2e/src/backup-security.e2e.ts @@ -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); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/support/backup-security.ts b/apps/electron-backend-e2e/src/support/backup-security.ts new file mode 100644 index 000000000..c2ed44a54 --- /dev/null +++ b/apps/electron-backend-e2e/src/support/backup-security.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return page.evaluate(async () => { + const read = window.electron?.dbGetAppPlaylists; + if (!read) { + throw new Error('playlist-list-unavailable'); + } + return read(); + }); +} diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index a952911e1..b5a80eaa0 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -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 { + ): Promise { 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 } : {}), }; } diff --git a/libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts b/libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts index bfe1cba7d..96a675f29 100644 --- a/libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.xtream-credential-validation.spec.ts @@ -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', + }) + ); + }); });