mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
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:
1 parent
b5ba77b67b
commit
4ddacab663
7 files changed
+143
-18
No files matched your search
@@ -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
|
||||
|
||||
+41
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
+11
-2
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -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', {
|
||||
|
||||
+15
-11
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user