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:
4grayandClaude Fable 5.1 committed 2026-09-26 11:10:48 +02:00
1 parent d4d0754e12
commit 4bd4bf2fc2
12 files changed
+257 -60

No files matched your search

+54 -3
View File
@@ -1,5 +1,5 @@
import { TestBed } from '@angular/core/testing';
import { SettingsStore } from '@iptvnator/services';
import { ParentalLockService, SettingsStore } from '@iptvnator/services';
describe('app routes', () => {
let workspaceRoute: import('@angular/router').Route | undefined;
@@ -67,6 +67,52 @@ describe('app routes', () => {
TestBed.resetTestingModule();
});
it('waits for the parental lock state before activating workspace children', async () => {
let releaseLock!: () => void;
const lockPending = new Promise<void>((resolve) => {
releaseLock = resolve;
});
const initialize = jest.fn(() => lockPending);
TestBed.configureTestingModule({
providers: [
{
provide: SettingsStore,
useValue: {
loadSettings: jest.fn().mockResolvedValue(undefined),
},
},
{ provide: ParentalLockService, useValue: { initialize } },
],
});
const settingsReadyResolver =
workspaceRoute?.resolve?.['settingsReady'];
if (typeof settingsReadyResolver !== 'function') {
throw new Error('resolver missing');
}
let resolved = false;
const resolution = TestBed.runInInjectionContext(() =>
Promise.resolve(
(
settingsReadyResolver as import('@angular/router').ResolveFn<void>
)(
{} as import('@angular/router').ActivatedRouteSnapshot,
{} as import('@angular/router').RouterStateSnapshot
)
)
).then(() => {
resolved = true;
});
await Promise.resolve();
await Promise.resolve();
expect(initialize).toHaveBeenCalled();
expect(resolved).toBe(false);
releaseLock();
await resolution;
expect(resolved).toBe(true);
});
it('waits for settings before activating workspace children', async () => {
let releaseSettings!: () => void;
const settingsPending = new Promise<void>((resolve) => {
@@ -79,6 +125,12 @@ describe('app routes', () => {
provide: SettingsStore,
useValue: { loadSettings },
},
{
provide: ParentalLockService,
useValue: {
initialize: jest.fn().mockResolvedValue(undefined),
},
},
],
});
const settingsReadyResolver =
@@ -120,8 +172,7 @@ describe('app routes', () => {
(route) => route.path === 'playlists/:id'
);
const loadChildren = playlistRoute?.loadChildren as
| (() => Promise<typeof workspaceChildren>)
| undefined;
(() => Promise<typeof workspaceChildren>) | undefined;
const m3uRoutes = (await loadChildren?.()) ?? [];
const defaultRoute = m3uRoutes.find((route) => route.path === '');
const favoritesRoute = m3uRoutes.find(
+16 -2
View File
@@ -1,10 +1,24 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
ParentalLockService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { WorkspaceStartupPreferencesService } from '@iptvnator/workspace/shell/util';
import { settingsUnsavedChangesGuard } from './settings/settings-unsaved-changes.guard';
const settingsReadyResolver = () => inject(SettingsStore).loadSettings();
// The workspace activates only once settings AND the parental lock state
// (PIN, lock store) are known: before that the lock reads as off and a
// slower IndexedDB read would let the catalogs admit protected rows.
const settingsReadyResolver = async () => {
const settingsStore = inject(SettingsStore);
const parentalLock = inject(ParentalLockService);
await Promise.all([
settingsStore.loadSettings(),
parentalLock.initialize(),
]);
};
const workspaceEntryRedirect = async () =>
inject(WorkspaceStartupPreferencesService).resolveInitialWorkspacePath();
+14 -3
View File
@@ -129,7 +129,13 @@ retry the read first, and a write is refused while it still fails, since it
would be built on an empty in-memory store and wipe the persisted locks).
The window before the initial read settles is treated the same way
(`ParentalLockLockStore.readable` is false until then): settings can report
the feature as on before the locks are known. The feature switch itself is
the feature as on before the locks are known — and the workspace route's
`settingsReady` resolver awaits `ParentalLockService.initialize()` next to
the settings load, so no route or catalog activates before the PIN and the
lock store are known (in the PWA the settings read can be the slower one).
Every lock write re-reads a failed store BEFORE building its edit, so a
recovered store is edited, never overwritten by an edit built on the empty
fail-closed one. The feature switch itself is
persisted through one guarded path (`persistEnabled`): `updateSettings`
patches memory before it writes, so a failed write is undone in memory and
`setupPin`/`disable` report false (whether to persist is decided from the
@@ -178,7 +184,8 @@ on either side.
hydration then publishes empty lists in place of the rows it read under
the previous lock state (categories included), and the filtered reload
of categories and content runs as soon as the hydration settles, on
every path that marks the content initialized.
every path that marks the content initialized, under the publish guard
of the request that deferred it.
Both reloads fail closed: a category reload that
rejects empties the three category lists, and a per-type content reload
that rejects empties that type and sets it back to `idle` so the next
@@ -310,7 +317,11 @@ on either side.
M3U group titles travel verbatim (`normalizeParentalLockGroupTitles`, exact
dedup): locks match `channel.group.title` exactly, so the trimming
`uniqueStrings` used for favorites would weaken a lock on a title with
surrounding whitespace.
surrounding whitespace. A backup's lock lists are validated entry by entry
on import (`isWellFormedParentalLock*` in the shared contract): restore
treats them as authoritative and the normalizer drops what it does not
understand, so a damaged list is rejected rather than erasing the
playlist's persisted protection.
`lockedGroupTitles` (M3U), `lockedCategories` (Xtream `{categoryType,
xtreamId}`, Stalker `{categoryType, categoryId}`) travel in each entry's
@@ -146,6 +146,33 @@ describe('withContent parental-lock reloads', () => {
expect(store.isContentInitialized()).toBe(true);
});
it("carries the deferred request's publish guard into the reload", async () => {
const live = createDeferred<unknown[]>();
let calls = 0;
dataSource.getContent.mockImplementation(() => {
calls += 1;
return calls === 1
? live.promise
: Promise.resolve([{ xtream_id: calls }]);
});
const initialization = store.initializeContent();
await waitForCondition(() => calls === 1);
let current = true;
await store.reloadCachedContent(() => current);
// The lock moved on again before the hydration settled.
current = false;
live.resolve([{ xtream_id: 1 }]);
await initialization;
// The reload stops at its first guard check instead of publishing
// the older rows; the superseding apply reads everything again.
expect(calls).toBe(4);
expect(store.liveStreams()).toEqual([]);
expect(store.vodStreams()).toEqual([]);
expect(store.serialStreams()).toEqual([]);
});
it('empties the category lists when their reload fails', async () => {
dataSource.getCategories.mockResolvedValue([{ category_id: 'x' }]);
await store.reloadCategories();
@@ -226,13 +226,20 @@ export function withContent() {
* the reload runs once the hydration has settled.
*/
let reloadAfterInitialization = false;
/** The publish guard of the LATEST deferred request (see below). */
let deferredPublishGuard: () => boolean = () => true;
const runDeferredReload = async (): Promise<void> => {
if (!reloadAfterInitialization) {
return;
}
reloadAfterInitialization = false;
await methods.reloadCategories();
await methods.reloadCachedContent();
// The requester's guard travels with the deferred reload: a
// relock during these reads supersedes them exactly as it
// would an ordinary reload.
const shouldPublish = deferredPublishGuard;
deferredPublishGuard = () => true;
await methods.reloadCategories(shouldPublish);
await methods.reloadCachedContent(shouldPublish);
};
const dataService = inject(DataService);
const databaseService = inject(DatabaseService);
@@ -1598,6 +1605,7 @@ export function withContent() {
// read under the previous lock state: withhold those
// and reload once it has settled.
reloadAfterInitialization = true;
deferredPublishGuard = shouldPublish;
return;
}
const loadStates = store.contentLoadStateByType();
@@ -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))
@@ -152,21 +152,58 @@ export function normalizeParentalLockStalkerCategories(
return result;
}
function isWellFormedList(
value: unknown,
isWellFormedEntry: (entry: unknown) => boolean
/** A list whose every entry the matching normalizer would KEEP. */
export function isWellFormedParentalLockXtreamCategories(
value: unknown
): boolean {
return Array.isArray(value) && value.every(isWellFormedEntry);
return (
Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockXtreamCategoryType
) &&
normalizeNumericId(entry['xtreamId']) !== null
)
);
}
export function isWellFormedParentalLockStalkerCategories(
value: unknown
): boolean {
return (
Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockStalkerCategoryType
) &&
((typeof entry['categoryId'] === 'string' &&
entry['categoryId'].trim() !== '' &&
entry['categoryId'].trim() !== '*') ||
(typeof entry['categoryId'] === 'number' &&
Number.isFinite(entry['categoryId'])))
)
);
}
export function isWellFormedParentalLockGroupTitles(value: unknown): boolean {
return (
Array.isArray(value) &&
value.every((entry) => typeof entry === 'string')
);
}
/**
* Whether a persisted store has the shape `writeLocks` produces: every
* playlist entry an object whose `xtream`/`stalker`/`m3u` lists are all
* present (a missing list is corruption, not "empty") and hold only
* entries the normalizers accept. The normalizers DROP what they
* do not understand, which is right for user-supplied backups but turns a
* corrupted persisted store into "nothing is locked"; a store that fails
* this check is treated as unreadable instead.
* entries the normalizers accept. The normalizers DROP what they do not
* understand; on a persisted store (and on a backup's lock lists, which a
* restore treats as authoritative) that would turn corruption into
* "nothing is locked", so such data is rejected instead.
*/
export function isWellFormedParentalLockStore(value: unknown): boolean {
if (!isRecord(value)) {
@@ -175,29 +212,9 @@ export function isWellFormedParentalLockStore(value: unknown): boolean {
return Object.values(value).every(
(locks) =>
isRecord(locks) &&
isWellFormedList(
locks['xtream'],
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_XTREAM_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockXtreamCategoryType
) &&
normalizeNumericId(entry['xtreamId']) !== null
) &&
isWellFormedList(
locks['stalker'],
(entry) =>
isRecord(entry) &&
PARENTAL_LOCK_STALKER_CATEGORY_TYPES.includes(
entry['categoryType'] as ParentalLockStalkerCategoryType
) &&
((typeof entry['categoryId'] === 'string' &&
entry['categoryId'].trim() !== '' &&
entry['categoryId'].trim() !== '*') ||
(typeof entry['categoryId'] === 'number' &&
Number.isFinite(entry['categoryId'])))
) &&
isWellFormedList(locks['m3u'], (entry) => typeof entry === 'string')
isWellFormedParentalLockXtreamCategories(locks['xtream']) &&
isWellFormedParentalLockStalkerCategories(locks['stalker']) &&
isWellFormedParentalLockGroupTitles(locks['m3u'])
);
}