fix(settings): close the remaining parental-lock gaps from review

- Detail routes check the item's own category: a locked movie or series
  paired with an unlocked category id in the URL is still refused.
- `requestUnlock()` awaits the settings load before it can answer "not
  active", so a slow startup cannot open a management dialog unguarded.
- `SETTINGS_UPDATE` only persists the `parentalLockEnabled` mirror and
  releases the worker on switch-off; it no longer re-locks the worker on
  every ordinary settings save under a renderer that shows "unlocked".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-25 22:47:30 +02:00
1 parent 7d12d14259
commit d4a1f8ba03
7 files changed
+187 -40

No files matched your search

@@ -38,6 +38,7 @@ const handlers = new Map<string, SettingsUpdateHandler>();
const mockStoreGet = jest.fn();
const mockStoreSet = jest.fn();
const mockUpdateSettings = jest.fn();
const mockApplyParentalLockState = jest.fn();
const mockIpcHandle = jest.fn(
(channel: string, handler: SettingsUpdateHandler): void => {
handlers.set(channel, handler);
@@ -61,12 +62,18 @@ jest.mock('../services/store.service', () => ({
PORTAL_CONNECTIVITY_GUARD: STORE_KEYS.PORTAL_CONNECTIVITY_GUARD,
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
PARENTAL_LOCK_ENABLED: 'PARENTAL_LOCK_ENABLED',
store: {
get: mockStoreGet,
set: mockStoreSet,
},
}));
jest.mock('./parental-lock.events', () => ({
applyParentalLockState: (...args: unknown[]) =>
mockApplyParentalLockState(...args),
}));
jest.mock('../server/http-server', () => ({
httpServer: {
updateSettings: mockUpdateSettings,
@@ -84,6 +91,7 @@ describe('SETTINGS_UPDATE', () => {
mockStoreGet.mockReset();
mockStoreSet.mockReset();
mockUpdateSettings.mockReset();
mockApplyParentalLockState.mockReset().mockResolvedValue(undefined);
mockStoreGet.mockImplementation(
(_key: string, fallbackValue: unknown): unknown => fallbackValue
);
@@ -136,6 +144,35 @@ describe('SETTINGS_UPDATE', () => {
);
});
it('mirrors the parental lock switch and releases the worker only on switch-off', () => {
// Off → on: persist; the renderer announces its own live state.
settingsUpdateHandler({}, { parentalLockEnabled: true });
expect(mockStoreSet).toHaveBeenCalledWith(
'PARENTAL_LOCK_ENABLED',
true
);
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
// An ordinary save carrying the unchanged flag must not re-lock a
// worker the renderer believes is unlocked.
mockStoreGet.mockImplementation((key: string, fallback: unknown) =>
key === 'PARENTAL_LOCK_ENABLED' ? true : fallback
);
settingsUpdateHandler(
{},
{ parentalLockEnabled: true, showCaptions: true }
);
expect(mockApplyParentalLockState).not.toHaveBeenCalled();
// On → off: release at once.
settingsUpdateHandler({}, { parentalLockEnabled: false });
expect(mockStoreSet).toHaveBeenCalledWith(
'PARENTAL_LOCK_ENABLED',
false
);
expect(mockApplyParentalLockState).toHaveBeenCalledWith(false);
});
it('normalizes external-player arguments and preserves explicit false reuse settings', () => {
settingsUpdateHandler(
{},
@@ -45,13 +45,19 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
}
// Mirrored so the database worker and a reloaded renderer start locked
// whenever the feature is on. Turning the feature off also releases the
// worker filter immediately; turning it on locks it — the renderer
// announces an explicit unlock through PARENTAL_LOCK_SET_STATE.
// whenever the feature is on. The LIVE enforcement state is the
// renderer's to announce through PARENTAL_LOCK_SET_STATE: every full
// settings save carries this flag unchanged, so applying it here would
// silently re-lock the worker under a renderer that still shows
// "unlocked". Only a switch-off releases the worker at once — nothing
// may stay withheld once the feature is gone.
if (arg.parentalLockEnabled !== undefined) {
const enabled = arg.parentalLockEnabled === true;
const wasEnabled = store.get(PARENTAL_LOCK_ENABLED, false) === true;
store.set(PARENTAL_LOCK_ENABLED, enabled);
void applyParentalLockState(enabled).catch(() => undefined);
if (wasEnabled && !enabled) {
void applyParentalLockState(false).catch(() => undefined);
}
}
if (arg.mpvPlayerArguments !== undefined) {
+12 -4
View File
@@ -82,7 +82,13 @@ refresh deletes and re-inserts the rows.
The renderer reports `active` to the main process over
`PARENTAL_LOCK_SET_STATE` (`apps/electron-backend/src/app/events/parental-lock.events.ts`),
which forwards it to the worker. Like the playback keep-awake vote, the
which forwards it to the worker. `SETTINGS_UPDATE` only persists the
`parentalLockEnabled` mirror; it never derives the live state from a settings
save (every save carries the flag unchanged and would re-lock the worker
under a renderer that shows "unlocked"). The one exception is a switch-off,
which releases the worker at once. `requestUnlock()` awaits `initialize()`
before it can answer "not active", so a slow settings load cannot open a
gate. Like the playback keep-awake vote, the
renderer's word does not outlive its page: on reload, main-frame navigation or
a dead render process the flag falls back to the mirrored
`PARENTAL_LOCK_ENABLED` setting, i.e. locked while the feature is on. The
@@ -110,9 +116,11 @@ locked default.
`parentalLockStalkerCategoryGuard(section)` on the Stalker vod/series
`:categoryId` routes prompt for the PIN when a locked category is reached
by URL — bookmark, typed address, stale link — and redirect to the section
root on refusal. Electron routes carry SQLite row ids, so the Xtream guard
maps them through the unfiltered category read; Stalker routes already
carry the genre id.
root on refusal. Detail routes also resolve the ITEM's own category through
`getContentByXtreamId`, so a locked movie paired with an unlocked category
id in the URL is still refused. Electron routes carry SQLite row ids, so
the Xtream guard maps them through the unfiltered category read; Stalker
routes already carry the genre id.
- **Stalker:** genres are stored unfiltered; `getCategoryResource` filters
them, `getAllCategoriesForSelectedType` is the raw list for the lock
dialog. `itvFullChannelList` and the content loader drop rows whose
@@ -6,6 +6,7 @@ import {
RouterStateSnapshot,
UrlTree,
} from '@angular/router';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
ParentalLockService,
@@ -13,12 +14,16 @@ import {
} from '@iptvnator/services';
import { parentalLockXtreamCategoryGuard } from './parental-lock-category.guard';
function createRoute(playlistId: string, categoryId: string) {
function createRoute(
playlistId: string,
categoryId: string,
itemParams: Record<string, string> = {}
) {
const parent = {
paramMap: convertToParamMap({ id: playlistId }),
} as ActivatedRouteSnapshot;
const route = {
paramMap: convertToParamMap({ categoryId }),
paramMap: convertToParamMap({ categoryId, ...itemParams }),
} as ActivatedRouteSnapshot;
Object.defineProperty(route, 'pathFromRoot', {
value: [parent, route],
@@ -36,6 +41,7 @@ describe('parentalLockXtreamCategoryGuard', () => {
let databaseService: { getAllXtreamCategories: jest.Mock };
let runtime: { supportsXtreamSqliteDataSource: boolean };
let router: { createUrlTree: jest.Mock };
let dataSource: { getContentByXtreamId: jest.Mock };
beforeEach(() => {
parentalLock = {
@@ -53,8 +59,12 @@ describe('parentalLockXtreamCategoryGuard', () => {
};
runtime = { supportsXtreamSqliteDataSource: true };
router = { createUrlTree: jest.fn(() => ({}) as UrlTree) };
dataSource = {
getContentByXtreamId: jest.fn().mockResolvedValue(null),
};
TestBed.configureTestingModule({
providers: [
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{ provide: ParentalLockService, useValue: parentalLock },
{ provide: DatabaseService, useValue: databaseService },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
@@ -63,15 +73,50 @@ describe('parentalLockXtreamCategoryGuard', () => {
});
});
function run(section: 'live' | 'vod' | 'series', categoryId: string) {
function run(
section: 'live' | 'vod' | 'series',
categoryId: string,
itemParams: Record<string, string> = {}
) {
return TestBed.runInInjectionContext(() =>
parentalLockXtreamCategoryGuard(section)(
createRoute('playlist-1', categoryId),
createRoute('playlist-1', categoryId, itemParams),
{} as RouterStateSnapshot
)
);
}
it('checks a detail item against its own category, not the one in the URL', async () => {
// Category row 12 (provider 900) is locked; row 13 (provider 901) is
// not. The URL names the unlocked one but the movie belongs to the
// locked one.
databaseService.getAllXtreamCategories.mockResolvedValue([
{ id: 12, xtream_id: 900, type: 'movies' },
{ id: 13, xtream_id: 901, type: 'movies' },
]);
parentalLock.isXtreamCategoryLocked.mockImplementation(
(_playlist: string, _type: string, xtreamId: number) =>
xtreamId === 900
);
dataSource.getContentByXtreamId.mockResolvedValue({ category_id: 12 });
const result = await run('vod', '13', { vodId: '555' });
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
555,
'playlist-1',
'movie'
);
expect(parentalLock.requestUnlock).toHaveBeenCalled();
expect(result).not.toBe(true);
// A movie in an unlocked category passes without a prompt.
parentalLock.requestUnlock.mockClear();
dataSource.getContentByXtreamId.mockResolvedValue({ category_id: 13 });
await expect(run('vod', '13', { vodId: '556' })).resolves.toBe(true);
expect(parentalLock.requestUnlock).not.toHaveBeenCalled();
});
it('passes through while the lock is inactive without touching the database', async () => {
parentalLock.active.mockReturnValue(false);
@@ -5,6 +5,7 @@ import {
Router,
UrlTree,
} from '@angular/router';
import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access';
import {
DatabaseService,
ParentalLockService,
@@ -39,8 +40,10 @@ function findPlaylistId(route: ActivatedRouteSnapshot): string | null {
* not through the (already filtered) category rail. The lock is keyed by the
* provider category id; in Electron the route carries the SQLite row id, so
* the guard maps it through the unfiltered category read (which only the
* guard sees). A locked category prompts for the PIN; a refusal redirects
* to the section root instead of rendering the category or its detail.
* guard sees). Detail routes additionally check the ITEM's own category: a
* locked movie paired with an unlocked category id in the URL is still a
* locked movie. A locked target prompts for the PIN; a refusal redirects to
* the section root instead of rendering the category or its detail.
*/
export function parentalLockXtreamCategoryGuard(
section: XtreamCategorySection
@@ -50,6 +53,7 @@ export function parentalLockXtreamCategoryGuard(
const router = inject(Router);
const databaseService = inject(DatabaseService);
const runtime = inject(RuntimeCapabilitiesService);
const dataSource = inject(XTREAM_DATA_SOURCE);
await parentalLock.initialize();
if (!parentalLock.active()) {
@@ -57,37 +61,55 @@ export function parentalLockXtreamCategoryGuard(
}
const playlistId = findPlaylistId(route);
const rawCategoryId = Number(route.paramMap.get('categoryId'));
if (!playlistId || !Number.isFinite(rawCategoryId)) {
if (!playlistId) {
return true;
}
const categoryType = CATEGORY_TYPE_BY_SECTION[section];
let xtreamId = rawCategoryId;
if (runtime.supportsXtreamSqliteDataSource) {
const rows = await databaseService.getAllXtreamCategories(
const rows = runtime.supportsXtreamSqliteDataSource
? await databaseService.getAllXtreamCategories(
playlistId,
categoryType
)
: null;
// Electron routes and content rows carry SQLite category row ids;
// the PWA carries provider ids everywhere.
const toProviderId = (categoryId: number): number | null =>
rows === null
? categoryId
: (rows.find((candidate) => candidate.id === categoryId)
?.xtream_id ?? null);
const isLocked = (categoryId: number): boolean => {
const xtreamId = toProviderId(categoryId);
return (
xtreamId !== null &&
parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
xtreamId
)
);
};
const routeCategoryId = Number(route.paramMap.get('categoryId'));
let locked =
Number.isFinite(routeCategoryId) && isLocked(routeCategoryId);
const itemId = Number(
route.paramMap.get('vodId') ?? route.paramMap.get('serialId')
);
if (!locked && section !== 'live' && Number.isFinite(itemId)) {
const item = await dataSource.getContentByXtreamId(
itemId,
playlistId,
categoryType
section === 'vod' ? 'movie' : 'series'
);
const row = rows.find(
(candidate) => candidate.id === rawCategoryId
);
if (!row) {
return true;
}
xtreamId = row.xtream_id;
const itemCategoryId = Number(item?.category_id);
locked =
Number.isFinite(itemCategoryId) && isLocked(itemCategoryId);
}
if (
!parentalLock.isXtreamCategoryLocked(
playlistId,
categoryType,
xtreamId
)
) {
return true;
}
if (await parentalLock.requestUnlock()) {
if (!locked || (await parentalLock.requestUnlock())) {
return true;
}
return router.createUrlTree([
@@ -156,6 +156,33 @@ describe('ParentalLockService', () => {
expect(setParentalLockState).toHaveBeenLastCalledWith(true);
});
it('does not open a gate before the settings have loaded', async () => {
storage.pinHash = await hashParentalLockPin('1234');
prompt.requestPin.mockResolvedValue(null);
let releaseSettings!: () => void;
const settingsStore = TestBed.inject(SettingsStore) as unknown as {
loadSettings: jest.Mock;
};
settingsStore.loadSettings.mockImplementation(
() =>
new Promise<void>((resolve) => {
releaseSettings = () => {
parentalLockEnabled.set(true);
resolve();
};
})
);
const service = TestBed.inject(ParentalLockService);
void service.initialize();
// Enabled is still the default false here: the answer must wait for
// the load instead of reading that as "feature off".
const gate = service.requestUnlock();
releaseSettings();
await expect(gate).resolves.toBe(false);
expect(prompt.requestPin).toHaveBeenCalledTimes(1);
});
it('stays locked when the prompt is dismissed', async () => {
storage.pinHash = await hashParentalLockPin('1234');
parentalLockEnabled.set(true);
@@ -160,14 +160,17 @@ export class ParentalLockService {
* is unlocked afterwards (already unlocked, feature off, or the PIN was
* accepted). Concurrent callers share one prompt.
*/
requestUnlock(
async requestUnlock(
options: Pick<
ParentalLockPromptRequest,
'titleKey' | 'descriptionKey'
> = {}
): Promise<boolean> {
// `enabled` is false until the settings have loaded; deciding "not
// active" before that would open a gate during a slow startup.
await this.initialize();
if (!this.active()) {
return Promise.resolve(true);
return true;
}
if (this.pendingUnlock) {
return this.pendingUnlock;
@@ -181,7 +184,6 @@ export class ParentalLockService {
private async promptForUnlock(
options: Pick<ParentalLockPromptRequest, 'titleKey' | 'descriptionKey'>
): Promise<boolean> {
await this.initialize();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;