mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
fix(settings): gate the workspace on parental lock init, edit only a readable lock store, guard the deferred reload, validate backup lock entries
- The workspace route resolver awaits ParentalLockService.initialize() next to the settings load, so no route or catalog activates before the PIN and lock store are known. - Every lock write re-reads a failed store before building its edit, so a recovered store is edited rather than overwritten. - The deferred hydration reload runs under the publish guard of the request that deferred it. - Backup import validates every parental lock entry and rejects a damaged list instead of erasing the persisted locks on restore. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4d0754e12
commit
4bd4bf2fc2
12 files changed
+257
-60
No files matched your search
@@ -80,6 +80,21 @@ describe('ParentalLockLockStore', () => {
|
||||
expect(store.readable()).toBe(true);
|
||||
});
|
||||
|
||||
it('builds an edit on the recovered store, never on the empty fail-closed one', async () => {
|
||||
storage.readLocks.mockResolvedValueOnce(null);
|
||||
await store.load();
|
||||
|
||||
await expect(store.setM3uLocks('pl-1', ['Adult'])).resolves.toBe(true);
|
||||
|
||||
expect(storage.writeLocks).toHaveBeenLastCalledWith({
|
||||
'pl-1': {
|
||||
xtream: [{ categoryType: 'live', xtreamId: 7 }],
|
||||
stalker: [],
|
||||
m3u: ['Adult'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('re-derives the index from a store recovered after a failed read', async () => {
|
||||
storage.readLocks.mockResolvedValueOnce(null);
|
||||
await store.load();
|
||||
|
||||
@@ -182,11 +182,20 @@ export class ParentalLockLockStore {
|
||||
return this.locks()[playlistId]?.m3u ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every mutation re-reads a store that failed to read BEFORE building
|
||||
* the edit: `locksFor` on the empty fail-closed store would otherwise
|
||||
* turn one type's edit into the playlist's entire lock set once the
|
||||
* write goes through, discarding its other locks.
|
||||
*/
|
||||
async setXtreamLocks(
|
||||
playlistId: string,
|
||||
categoryType: ParentalLockXtreamCategoryType,
|
||||
xtreamIds: number[]
|
||||
): Promise<boolean> {
|
||||
if (!(await this.ensureReadable())) {
|
||||
return false;
|
||||
}
|
||||
const previous = this.locksFor(playlistId);
|
||||
const next = withXtreamLocks(previous, categoryType, xtreamIds);
|
||||
return this.commitLocks(playlistId, previous, next, [categoryType]);
|
||||
@@ -197,6 +206,9 @@ export class ParentalLockLockStore {
|
||||
categoryType: ParentalLockStalkerCategoryType,
|
||||
categoryIds: string[]
|
||||
): Promise<boolean> {
|
||||
if (!(await this.ensureReadable())) {
|
||||
return false;
|
||||
}
|
||||
return this.persistPlaylistLocks(
|
||||
playlistId,
|
||||
withStalkerLocks(
|
||||
@@ -211,6 +223,9 @@ export class ParentalLockLockStore {
|
||||
playlistId: string,
|
||||
groupTitles: string[]
|
||||
): Promise<boolean> {
|
||||
if (!(await this.ensureReadable())) {
|
||||
return false;
|
||||
}
|
||||
return this.persistPlaylistLocks(
|
||||
playlistId,
|
||||
withM3uLocks(this.locksFor(playlistId), groupTitles)
|
||||
@@ -222,6 +237,9 @@ export class ParentalLockLockStore {
|
||||
playlistId: string,
|
||||
locks: ParentalLockPlaylistLocks
|
||||
): Promise<boolean> {
|
||||
if (!(await this.ensureReadable())) {
|
||||
return false;
|
||||
}
|
||||
return this.commitLocks(
|
||||
playlistId,
|
||||
this.locksFor(playlistId),
|
||||
|
||||
@@ -69,8 +69,7 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
|
||||
const backup = await service.exportBackup();
|
||||
|
||||
const entry = backup.manifest
|
||||
.playlists[0] as XtreamPlaylistBackupEntry;
|
||||
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
|
||||
// Without this every "main source" choice vanishes on restore, with
|
||||
// nothing in the archive to say it was ever made.
|
||||
expect(entry.userState.sourcePins).toEqual([
|
||||
@@ -161,7 +160,8 @@ describe('PlaylistBackupService Xtream source pins', () => {
|
||||
await Promise.all([firstImport, secondImport]);
|
||||
expect(
|
||||
replacePins.mock.calls.map(
|
||||
([, pins]) => (pins as Array<{ contentId: number }>)[0].contentId
|
||||
([, pins]) =>
|
||||
(pins as Array<{ contentId: number }>)[0].contentId
|
||||
)
|
||||
).toEqual([501, 502]);
|
||||
});
|
||||
|
||||
@@ -229,9 +229,11 @@ describe('PlaylistBackupService export → import round-trip', () => {
|
||||
}),
|
||||
]);
|
||||
expect(state.epgUrls).toEqual(['https://epg.example.com/guide.xml']);
|
||||
expect(
|
||||
state.playlists.map((playlist) => playlist._id)
|
||||
).toEqual(['m3u-1', 'xtream-1', 'stalker-1']);
|
||||
expect(state.playlists.map((playlist) => playlist._id)).toEqual([
|
||||
'm3u-1',
|
||||
'xtream-1',
|
||||
'stalker-1',
|
||||
]);
|
||||
|
||||
// Exporting the restored state must reproduce the original
|
||||
// manifest byte for byte (modulo the export timestamp): any field
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
XtreamBackupSourcePin,
|
||||
XtreamPendingRestoreState,
|
||||
createRandomId,
|
||||
isWellFormedParentalLockGroupTitles,
|
||||
isWellFormedParentalLockStalkerCategories,
|
||||
isWellFormedParentalLockXtreamCategories,
|
||||
normalizeParentalLockGroupTitles,
|
||||
normalizeParentalLockStalkerCategories,
|
||||
normalizeParentalLockXtreamCategories,
|
||||
@@ -525,6 +528,19 @@ export class PlaylistBackupService {
|
||||
`M3U backup "${entry.title}" is missing raw playlist data.`
|
||||
);
|
||||
}
|
||||
// Restore replaces the playlist's locks with the archive's,
|
||||
// and the normalizer drops what it does not understand — a
|
||||
// damaged lock list would erase the persisted protection.
|
||||
if (
|
||||
entry.userState?.lockedGroupTitles !== undefined &&
|
||||
!isWellFormedParentalLockGroupTitles(
|
||||
entry.userState.lockedGroupTitles
|
||||
)
|
||||
) {
|
||||
throw new PlaylistBackupError(
|
||||
`M3U backup "${entry.title}" has invalid parental locks.`
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'xtream':
|
||||
if (
|
||||
@@ -554,7 +570,9 @@ export class PlaylistBackupService {
|
||||
|
||||
if (
|
||||
entry.userState.lockedCategories !== undefined &&
|
||||
!Array.isArray(entry.userState.lockedCategories)
|
||||
!isWellFormedParentalLockXtreamCategories(
|
||||
entry.userState.lockedCategories
|
||||
)
|
||||
) {
|
||||
throw new PlaylistBackupError(
|
||||
`Xtream backup "${entry.title}" has invalid parental locks.`
|
||||
@@ -581,6 +599,16 @@ export class PlaylistBackupService {
|
||||
`Stalker backup "${entry.title}" is missing connection metadata.`
|
||||
);
|
||||
}
|
||||
if (
|
||||
entry.userState?.lockedCategories !== undefined &&
|
||||
!isWellFormedParentalLockStalkerCategories(
|
||||
entry.userState.lockedCategories
|
||||
)
|
||||
) {
|
||||
throw new PlaylistBackupError(
|
||||
`Stalker backup "${entry.title}" has invalid parental locks.`
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new PlaylistBackupError(
|
||||
|
||||
@@ -41,8 +41,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
|
||||
|
||||
const backup = await service.exportBackup();
|
||||
|
||||
const entry = backup.manifest
|
||||
.playlists[0] as XtreamPlaylistBackupEntry;
|
||||
const entry = backup.manifest.playlists[0] as XtreamPlaylistBackupEntry;
|
||||
const expectedHiddenCategories = [
|
||||
{ categoryType: 'live', xtreamId: 101 },
|
||||
{ categoryType: 'movies', xtreamId: 201 },
|
||||
@@ -92,9 +91,7 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
state: expect.objectContaining({
|
||||
hiddenCategories: [
|
||||
{ categoryType: 'live', xtreamId: 101 },
|
||||
],
|
||||
hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }],
|
||||
}),
|
||||
}),
|
||||
expect.any(Function)
|
||||
@@ -107,11 +104,21 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a damaged parental lock list instead of erasing the persisted locks', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
const service = createPlaylistBackupService(collaborators);
|
||||
const manifest = createXtreamManifest([]);
|
||||
(
|
||||
manifest.playlists[0].userState as { lockedCategories?: unknown }
|
||||
).lockedCategories = [{}];
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await expect(
|
||||
service.importBackup(JSON.stringify(manifest))
|
||||
).rejects.toThrow(/invalid parental locks/);
|
||||
expect(
|
||||
collaborators.databaseService.updateCategoryVisibility
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects entries with missing user-state collections instead of wiping user data', async () => {
|
||||
const collaborators = createRestoreCollaborators();
|
||||
@@ -121,9 +128,8 @@ describe('PlaylistBackupService Xtream hidden categories (issue #1017)', () => {
|
||||
// treated as an authoritative "empty" state: the merge path would
|
||||
// unhide every category and delete favorites/recent/positions.
|
||||
const manifest = createXtreamManifest([]);
|
||||
delete (
|
||||
manifest.playlists[0] as unknown as { userState?: unknown }
|
||||
).userState;
|
||||
delete (manifest.playlists[0] as unknown as { userState?: unknown })
|
||||
.userState;
|
||||
|
||||
await expect(
|
||||
service.importBackup(JSON.stringify(manifest))
|
||||
|
||||
Reference in new issue
Block a user