From 1961952f480654e82166eec640d461f9d790b775 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 27 Jul 2026 12:25:52 +0200 Subject: [PATCH] fix(backup): reject incomplete local Xtream credentials --- .../src/lib/playlist-backup.service.ts | 4 +- ...rvice.xtream-credential-validation.spec.ts | 73 +++++++++++++++++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index ba434513b..a952911e1 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -646,7 +646,9 @@ export class PlaylistBackupService { playlist._id === entry.exportedId && this.getPlaylistPortalType(playlist) === 'xtream' && this.normalizeUrlIdentity(playlist.serverUrl ?? '') === - this.normalizeUrlIdentity(entry.connection.serverUrl) + this.normalizeUrlIdentity(entry.connection.serverUrl) && + this.normalizeIdentityValue(playlist.username ?? '') !== '' && + this.normalizeIdentityValue(playlist.password ?? '') !== '' ); if (exactLocalMatch) { return entry; 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 36b5fd59f..bfe1cba7d 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 @@ -8,8 +8,8 @@ import { 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 = { + function redactedManifest(): PlaylistBackupManifestV1 { + return { kind: PLAYLIST_BACKUP_KIND, version: PLAYLIST_BACKUP_VERSION, exportedAt: '2026-07-27T00:00:00.000Z', @@ -33,6 +33,9 @@ describe('PlaylistBackupService redacted Xtream credential validation', () => { }, ], }; + } + + it('does not persist the entry when the normal connection check rejects the supplied credentials', async () => { const playlistsService = { addPlaylist: jest.fn((playlist: Playlist) => of(playlist)), getAllData: jest.fn(() => of([])), @@ -47,12 +50,15 @@ describe('PlaylistBackupService redacted Xtream credential validation', () => { portalStatusService, }); - const summary = await service.importBackup(JSON.stringify(manifest), { - resolveXtreamCredentials: async () => ({ - username: 'restored-user', - password: 'wrong-password', - }), - }); + const summary = await service.importBackup( + JSON.stringify(redactedManifest()), + { + resolveXtreamCredentials: async () => ({ + username: 'restored-user', + password: 'wrong-password', + }), + } + ); expect(summary).toEqual( expect.objectContaining({ failed: 1, imported: 0 }) @@ -62,4 +68,55 @@ describe('PlaylistBackupService redacted Xtream credential validation', () => { ); expect(playlistsService.addPlaylist).not.toHaveBeenCalled(); }); + + it.each([ + ['username', '', 'local-password'], + ['password', 'local-user', ' '], + ])( + 'does not treat an exact local row with a blank %s as usable credentials', + async (_field, username, password) => { + const local = { + _id: 'xtream-redacted', + title: 'Incomplete local Xtream', + count: 1, + importDate: '2026-07-01T00:00:00.000Z', + lastUsage: '2026-07-01T00:00:00.000Z', + autoRefresh: false, + serverUrl: 'https://portal.test/base', + username, + password, + } 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 resolveXtreamCredentials = jest + .fn() + .mockResolvedValue(null); + const service = createPlaylistBackupService({ + playlistsService, + }); + + const summary = await service.importBackup( + JSON.stringify(redactedManifest()), + { resolveXtreamCredentials } + ); + + expect(resolveXtreamCredentials).toHaveBeenCalledWith({ + exportedId: 'xtream-redacted', + serverUrl: 'https://portal.test/base/', + title: 'Redacted Xtream', + }); + expect(summary).toEqual({ + imported: 0, + merged: 0, + skipped: 1, + failed: 0, + errors: [], + }); + expect(playlistsService.addPlaylist).not.toHaveBeenCalled(); + } + ); });