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();