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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 14:43:54 +02:00
1 parent b5ba77b67b
commit 4ddacab663
7 files changed
+143 -18

No files matched your search

+3 -1
View File
@@ -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
@@ -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();
});
});
@@ -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;
}
@@ -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);
}
);
});
@@ -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<CatalogTitleMatch[]> {
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 [];
}
@@ -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', {
@@ -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, {