From 4ddacab66381ac3e0d5125b7e4ba73a55a0ff719 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 14:43:54 +0200 Subject: [PATCH] fix(settings): fail closed for direct worker title lookups and capture the Stalker lock dialog context before the PIN Catalog title matching and multi-source discovery query the SQLite worker directly; they now return nothing while the parental lock withholds everything (unreadable store or a bridge without the worker filter). The Stalker lock dialog captures its playlist, provider and section before the PIN prompt and re-checks them after it. Co-Authored-By: Claude Opus 5.5 --- docs/architecture/parental-lock.md | 4 +- .../vod-source-discovery.service.spec.ts | 43 ++++++++++++++++++- .../vod-source-discovery.service.ts | 13 +++++- .../lib/catalog-title-match.service.spec.ts | 32 ++++++++++++++ .../src/lib/catalog-title-match.service.ts | 14 +++++- .../workspace-context-panel.component.spec.ts | 29 +++++++++++++ .../workspace-context-panel.component.ts | 26 ++++++----- 7 files changed, 143 insertions(+), 18 deletions(-) diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index a53df232e..aaa94ed98 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -142,7 +142,9 @@ through the SQLite worker (`supportsXtreamSqliteDataSource`) but the bridge lacks the worker filter (`supportsParentalLockSqliteFilter`: `setParentalLockState` and `dbSetCategoryLocks`, e.g. a partial or older preload): the worker would never learn the lock state, so it cannot -withhold locked rows itself. +withhold locked rows itself. The direct worker consumers +(`CatalogTitleMatchService`, `VodSourceDiscoveryService`) ask the worker +nothing while `withholdsEverything` is true, in either case. 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 — and the workspace route's diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts index b71d9e418..04add8fdc 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.spec.ts @@ -1,5 +1,23 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ParentalLockService } from '@iptvnator/services'; import { VodSourceDiscoveryService } from './vod-source-discovery.service'; +const withholdsEverything = signal(false); + +function createService(): VodSourceDiscoveryService { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { + provide: ParentalLockService, + useValue: { withholdsEverything }, + }, + ], + }); + return TestBed.inject(VodSourceDiscoveryService); +} + /** * Discovery talks to a foreign playlist, and Xtream carries the account in the * URL — so anything it fails on has to go through the redacting logger before @@ -17,7 +35,7 @@ describe('VodSourceDiscoveryService — failure logging', () => { warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { /* captured */ }); - service = new VodSourceDiscoveryService(); + service = createService(); }); afterEach(() => { @@ -85,7 +103,7 @@ describe('VodSourceDiscoveryService — candidate mapping', () => { row(4, undefined), ]), }; - const service = new VodSourceDiscoveryService(); + const service = createService(); const result = await service.discover({ title: 'Dune', @@ -111,3 +129,24 @@ describe('VodSourceDiscoveryService — candidate mapping', () => { }; } }); + +describe('VodSourceDiscoveryService — parental lock', () => { + afterEach(() => { + withholdsEverything.set(false); + delete (window as { electron?: unknown }).electron; + }); + + it('asks the worker nothing while the lock withholds everything', async () => { + const dbFindTitleSources = jest.fn().mockResolvedValue([]); + (window as { electron?: unknown }).electron = { dbFindTitleSources }; + withholdsEverything.set(true); + + const result = await createService().discover({ + title: 'Dune', + currentPlaylistId: 'playlist-0', + }); + + expect(result.sources).toEqual([]); + expect(dbFindTitleSources).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts index 6e85c7165..664d8f134 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; +import { ParentalLockService } from '@iptvnator/services'; import { unambiguousCategoryLanguage, type VodSourceCandidate, @@ -43,6 +44,7 @@ export interface VodSourceDiscoveryResult { @Injectable({ providedIn: 'root' }) export class VodSourceDiscoveryService { private readonly logger = createLogger('VodSourceDiscovery'); + private readonly parentalLock = inject(ParentalLockService); get isAvailable(): boolean { return ( @@ -59,7 +61,14 @@ export class VodSourceDiscoveryService { matchKind: 'title-year', }; - if (!this.isAvailable || !request.title?.trim()) { + // The worker withholds locked rows through its own lock state and + // index; while the lock withholds everything (store unreadable, or + // a bridge without the worker filter) neither can be trusted. + if ( + !this.isAvailable || + !request.title?.trim() || + this.parentalLock.withholdsEverything() + ) { return empty; } diff --git a/libs/services/src/lib/catalog-title-match.service.spec.ts b/libs/services/src/lib/catalog-title-match.service.spec.ts index eb93acb16..4ae8a18c9 100644 --- a/libs/services/src/lib/catalog-title-match.service.spec.ts +++ b/libs/services/src/lib/catalog-title-match.service.spec.ts @@ -1,8 +1,12 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; import { CatalogTitleMatch } from '@iptvnator/shared/interfaces'; import { + CatalogTitleMatchService, groupTitleMatchesByKey, pickTitleMatch, } from './catalog-title-match.service'; +import { ParentalLockService } from './parental-lock/parental-lock.service'; describe('catalog title matching', () => { const match = ( @@ -188,3 +192,31 @@ describe('catalog title matching', () => { }); }); }); + +describe('CatalogTitleMatchService parental lock', () => { + afterEach(() => { + delete (window as { electron?: unknown }).electron; + }); + + it.each([false, true])( + 'asks the worker only while the lock does not withhold everything (%s)', + async (withholds) => { + const dbMatchTitles = jest.fn().mockResolvedValue([]); + (window as { electron?: unknown }).electron = { dbMatchTitles }; + TestBed.configureTestingModule({ + providers: [ + { + provide: ParentalLockService, + useValue: { withholdsEverything: signal(withholds) }, + }, + ], + }); + + await TestBed.inject(CatalogTitleMatchService).matchTitles([ + 'Dune', + ]); + + expect(dbMatchTitles).toHaveBeenCalledTimes(withholds ? 0 : 1); + } + ); +}); diff --git a/libs/services/src/lib/catalog-title-match.service.ts b/libs/services/src/lib/catalog-title-match.service.ts index f8cab5a60..e15d090d9 100644 --- a/libs/services/src/lib/catalog-title-match.service.ts +++ b/libs/services/src/lib/catalog-title-match.service.ts @@ -1,9 +1,10 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { CatalogTitleMatch, normalizeTitleKeys, titleYearsCompatible, } from '@iptvnator/shared/interfaces'; +import { ParentalLockService } from './parental-lock/parental-lock.service'; /** What one caller is looking for, in catalog-match terms */ export interface CatalogTitleLookup { @@ -99,6 +100,8 @@ export function pickTitleMatch( */ @Injectable({ providedIn: 'root' }) export class CatalogTitleMatchService { + private readonly parentalLock = inject(ParentalLockService); + get isAvailable(): boolean { return ( typeof window !== 'undefined' && @@ -107,7 +110,14 @@ export class CatalogTitleMatchService { } async matchTitles(titles: string[]): Promise { - if (!this.isAvailable || titles.length === 0) { + // The worker filters locked rows by its own lock state and index; + // while the lock withholds everything (store unreadable, or a + // bridge without the worker filter) neither can be trusted. + if ( + !this.isAvailable || + titles.length === 0 || + this.parentalLock.withholdsEverything() + ) { return []; } diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts index c5983f4dd..8ab581370 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.spec.ts @@ -966,6 +966,35 @@ describe('WorkspaceContextPanelComponent', () => { ); }); + it('opens nothing when the route changed while the PIN was asked', async () => { + enableLock(); + fixture.componentRef.setInput('context', { + provider: 'stalker', + playlistId: 'stalker-1', + }); + fixture.componentRef.setInput('section', 'itv'); + fixture.detectChanges(); + const parentalLock = TestBed.inject(ParentalLockService); + jest.spyOn(parentalLock, 'requestUnlock').mockImplementation( + async () => { + fixture.componentRef.setInput('context', { + provider: 'xtreams', + playlistId: 'xtream-2', + }); + return true; + } + ); + const component = fixture.componentInstance; + component.loadStalkerLockDialog = jest.fn( + async () => class DialogStub {} + ); + + await component.openManageStalkerCategories(); + + expect(component.loadStalkerLockDialog).not.toHaveBeenCalled(); + expect(dialog.open).not.toHaveBeenCalled(); + }); + it('opens nothing when the route changed during the lazy import', async () => { enableLock(); fixture.componentRef.setInput('context', { diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts index 4ce930b78..70476b794 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-context-panel.component.ts @@ -533,26 +533,30 @@ export class WorkspaceContextPanelComponent { if (!this.canManageStalkerCategories()) { return; } - if (!(await this.parentalLock.requestUnlock())) { - return; - } + // Captured before any await (the PIN prompt loads lazily on first + // use) and re-checked after each: the store follows the CURRENT + // route, and another portal's categories must not reach a dialog + // that saves them under this playlist. const context = this.context(); const section = this.section(); const contentType = toParentalLockStalkerCategoryType(section); - if (!contentType) { + const unchanged = (): boolean => + this.context().playlistId === context.playlistId && + this.context().provider === context.provider && + this.section() === section && + this.canManageStalkerCategories(); + if (!contentType || !(await this.parentalLock.requestUnlock())) { return; } - // Snapshot before the lazy import: the store follows the CURRENT - // route, so reading it afterwards could hand another portal's - // categories to a dialog that saves them under this playlist. + if (!unchanged()) { + return; + } + // Snapshot before the lazy import, for the same reason. const categories = this.stalkerStore .getAllCategoriesForSelectedType() .filter((category) => String(category.category_id) !== '*'); const dialogComponent = await this.loadStalkerLockDialog(); - if ( - this.context().playlistId !== context.playlistId || - this.section() !== section - ) { + if (!unchanged()) { return; } this.dialog.open(dialogComponent, {