mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
fix(backup): merge restored redacted credentials
This commit is contained in:
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user