From acabd991073f9763a82d03c2077cc64443f82a9b Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 27 Sep 2026 12:18:33 +0200 Subject: [PATCH] fix(settings): fail closed on relock ahead of the apply queue and judge Xtream selections by the lock store - On relock the synchronous fail-closed steps (M3U channel, Stalker selection, the locked Xtream detail, catalog lists, stored search) run immediately instead of queueing behind an earlier apply that may still wait on a slow or hung read. - The post-reload Xtream checks decide by the lock store through the unfiltered category rows rather than by absence from the reloaded list, which also omits merely hidden categories; unreadable rows fail closed. Co-Authored-By: Claude Opus 5.5 --- .../parental-lock-enforcement.service.spec.ts | 139 ++++++++++------- .../parental-lock-enforcement.service.ts | 140 ++++++++++++------ docs/architecture/parental-lock.md | 9 +- 3 files changed, 183 insertions(+), 105 deletions(-) diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts index 00a6b32d5..33b3df79c 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.spec.ts @@ -3,13 +3,17 @@ import { TestBed } from '@angular/core/testing'; import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { StalkerStore } from '@iptvnator/portal/stalker/data-access'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; import { ParentalLockService } from '@iptvnator/services'; import { ParentalLockEnforcementService } from './parental-lock-enforcement.service'; import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; interface Applier { apply(): Promise; + failClosedNow(): void; applyXtream(version: number): Promise; applyStalker(): Promise; } @@ -59,6 +63,14 @@ describe('ParentalLockEnforcementService', () => { setSelectedItem: jest.fn(), setSelectedCategory: jest.fn(), }; + const xtreamDataSource = { + getAllCategories: jest.fn(async () => [ + { id: 7, xtream_id: 70 }, + { id: 8, xtream_id: 80 }, + { id: 55, xtream_id: 550 }, + { id: 99, xtream_id: 990 }, + ]), + }; let service: Applier; beforeEach(() => { @@ -75,6 +87,7 @@ describe('ParentalLockEnforcementService', () => { providers: [ { provide: ParentalLockService, useValue: parentalLock }, { provide: XtreamStore, useValue: xtreamStore }, + { provide: XTREAM_DATA_SOURCE, useValue: xtreamDataSource }, { provide: StalkerStore, useValue: stalkerStore }, { provide: Router, useValue: router }, { @@ -214,13 +227,25 @@ describe('ParentalLockEnforcementService', () => { }); describe('Xtream', () => { - it('clears a selected item whose category is no longer readable', async () => { + function lockProvider(providerId: number): void { + parentalLock.active.set(true); + parentalLock.isXtreamCategoryLocked.mockImplementation( + (_p: string, _t: string, id: number) => id === providerId + ); + } + + it('clears a selected item whose category the lock store withholds', async () => { router.url = '/workspace/xtreams/xtream-1/vod/42'; + lockProvider(990); xtreamStore.selectedItem.set({ category_id: 99 }); await service.applyXtream(parentalLock.version()); expect(xtreamStore.reloadCategories).toHaveBeenCalled(); + expect(xtreamDataSource.getAllCategories).toHaveBeenCalledWith( + 'xtream-1', + 'movies' + ); expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled(); expect(router.navigate).toHaveBeenCalledWith([ @@ -231,52 +256,56 @@ describe('ParentalLockEnforcementService', () => { ]); }); - it('withholds the catalog and a locked detail before the reload on relock', async () => { + it('keeps a detail from a category that is merely hidden, not locked', async () => { router.url = '/workspace/xtreams/xtream-1/vod/42'; - parentalLock.active.set(true); - parentalLock.isXtreamCategoryLocked.mockImplementation( - (_p: string, _t: string, providerId: number) => - providerId === 70 - ); - xtreamStore.selectedItem.set({ category_id: 7 }); - const order: string[] = []; - xtreamStore.withholdCatalog.mockImplementation(() => - order.push('withhold') - ); - xtreamStore.setSelectedItem.mockImplementation(() => - order.push('step-off') - ); - xtreamStore.reloadCategories.mockImplementation(async () => { - order.push('reload'); - }); + lockProvider(990); + // Row 55 is absent from the (hidden-filtered) visible list but + // exists unlocked in the unfiltered rows. + xtreamStore.selectedItem.set({ category_id: 55 }); await service.applyXtream(parentalLock.version()); - expect(order.slice(0, 3)).toEqual([ - 'step-off', - 'withhold', - 'reload', - ]); - expect(xtreamStore.clearSearchResults).toHaveBeenCalled(); - expect(parentalLock.isXtreamCategoryLocked).toHaveBeenCalledWith( - 'xtream-1', - 'movies', - 70 - ); + expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('steps off a selected locked category', async () => { + router.url = '/workspace/xtreams/xtream-1/live'; + lockProvider(990); + xtreamStore.selectedCategoryId.set(99); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null); expect(router.navigate).toHaveBeenCalledWith([ '/workspace', 'xtreams', 'xtream-1', - 'vod', + 'live', ]); }); - it('does not withhold on unlock, and hands the reloads a publish guard', async () => { + it('fails closed when the category rows cannot be read', async () => { + router.url = '/workspace/xtreams/xtream-1/vod/42'; + parentalLock.active.set(true); + xtreamDataSource.getAllCategories.mockRejectedValueOnce( + new Error('db') + ); + xtreamStore.selectedItem.set({ category_id: 7 }); + + await service.applyXtream(parentalLock.version()); + + expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); + }); + + it('skips the post-reload checks while unlocked and hands the reloads a publish guard', async () => { router.url = '/workspace/xtreams/xtream-1/vod'; await service.applyXtream(parentalLock.version()); expect(xtreamStore.withholdCatalog).not.toHaveBeenCalled(); + expect(xtreamDataSource.getAllCategories).not.toHaveBeenCalled(); const guard = xtreamStore.reloadCategories.mock.calls[0][0] as (() => boolean) | undefined; expect(guard?.()).toBe(true); @@ -292,31 +321,17 @@ describe('ParentalLockEnforcementService', () => { expect(xtreamStore.refreshSearchResults).toHaveBeenCalled(); }); - it('keeps a selected item whose category survived the lock', async () => { + it('fails closed synchronously on relock: detail, catalog and search', () => { router.url = '/workspace/xtreams/xtream-1/vod/42'; - xtreamStore.selectedCategoryId.set(7); + lockProvider(70); xtreamStore.selectedItem.set({ category_id: 7 }); - await service.applyXtream(parentalLock.version()); - - expect(xtreamStore.setSelectedItem).not.toHaveBeenCalled(); - expect(router.navigate).not.toHaveBeenCalled(); - }); - - it('steps off a selected category that vanished', async () => { - router.url = '/workspace/xtreams/xtream-1/live'; - xtreamStore.selectedCategoryId.set(99); - - await service.applyXtream(parentalLock.version()); + service.failClosedNow(); expect(xtreamStore.setSelectedItem).toHaveBeenCalledWith(null); - expect(xtreamStore.setSelectedCategory).toHaveBeenCalledWith(null); - expect(router.navigate).toHaveBeenCalledWith([ - '/workspace', - 'xtreams', - 'xtream-1', - 'live', - ]); + expect(xtreamStore.withholdCatalog).toHaveBeenCalled(); + expect(xtreamStore.clearSearchResults).toHaveBeenCalled(); + expect(xtreamStore.reloadCategories).not.toHaveBeenCalled(); }); }); }); @@ -334,6 +349,8 @@ describe('ParentalLockEnforcementService apply serialization', () => { ), reloadCachedContent: jest.fn(async () => undefined), refreshSearchResults: jest.fn(async () => undefined), + withholdCatalog: jest.fn(), + clearSearchResults: jest.fn(), getCategoriesBySelectedType: jest.fn(() => [] as unknown[]), setSelectedItem: jest.fn(), setSelectedCategory: jest.fn(), @@ -352,13 +369,22 @@ describe('ParentalLockEnforcementService apply serialization', () => { }, }, { provide: XtreamStore, useValue: xtreamStore }, + { + // No rows: the selected category cannot be placed and + // fails closed once an apply gets to judge it. + provide: XTREAM_DATA_SOURCE, + useValue: { getAllCategories: jest.fn(async () => []) }, + }, { provide: StalkerStore, useValue: { currentPlaylist: signal(null) }, }, { provide: Router, - useValue: { url: '/', navigate: jest.fn() }, + useValue: { + url: '/workspace/xtreams/xtream-1/live', + navigate: jest.fn(), + }, }, { provide: Store, @@ -383,9 +409,13 @@ describe('ParentalLockEnforcementService apply serialization', () => { await Promise.resolve(); expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1); - // ...and "Lock now" arrives meanwhile: no second reload starts yet. + // ...and "Lock now" arrives meanwhile: no second reload starts yet, + // but the catalog is withheld at once rather than behind the hung + // read. + xtreamStore.withholdCatalog.mockClear(); version.set(2); TestBed.flushEffects(); + expect(xtreamStore.withholdCatalog).toHaveBeenCalledTimes(1); await Promise.resolve(); expect(xtreamStore.reloadCategories).toHaveBeenCalledTimes(1); @@ -415,6 +445,7 @@ describe('ParentalLockEnforcementService busy probe', () => { }, }, { provide: XtreamStore, useValue: {} }, + { provide: XTREAM_DATA_SOURCE, useValue: {} }, { provide: StalkerStore, useValue: {} }, { provide: Router, useValue: { url: '/' } }, { diff --git a/apps/web/src/app/services/parental-lock-enforcement.service.ts b/apps/web/src/app/services/parental-lock-enforcement.service.ts index 50e37eb63..8bd7c355f 100644 --- a/apps/web/src/app/services/parental-lock-enforcement.service.ts +++ b/apps/web/src/app/services/parental-lock-enforcement.service.ts @@ -8,7 +8,10 @@ import { import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { ChannelActions, selectActive } from '@iptvnator/m3u-state'; -import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + XTREAM_DATA_SOURCE, + XtreamStore, +} from '@iptvnator/portal/xtream/data-access'; import { ParentalLockService } from '@iptvnator/services'; import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces'; import { PlaybackKeepAwakeService } from './playback-keep-awake.service'; @@ -29,6 +32,7 @@ export const STALKER_ROUTE = export class ParentalLockEnforcementService { private readonly parentalLock = inject(ParentalLockService); private readonly xtreamStore = inject(XtreamStore); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); private readonly injector = inject(EnvironmentInjector); private readonly router = inject(Router); private readonly store = inject(Store); @@ -57,9 +61,17 @@ export class ParentalLockEnforcementService { } const first = this.lastVersion === -1; this.lastVersion = version; - if (!first) { - this.scheduleApply(); + if (first) { + return; } + // Fail closed NOW, ahead of the serialized queue: an earlier + // apply may still be waiting on a slow (or hung) read, and + // the catalog, details and playback read while unlocked must + // not stay usable until it settles. + if (this.parentalLock.active()) { + this.failClosedNow(); + } + this.scheduleApply(); }); }); } @@ -82,6 +94,28 @@ export class ParentalLockEnforcementService { }); } + /** + * The synchronous half of a relock: M3U channel, Stalker selection (its + * step is lazy, so it runs as soon as the chunk is there), the Xtream + * detail the lock store already places in a locked category, the + * catalog lists and the stored search. The queued apply then reloads + * the filtered rows and repeats the checks against them. + */ + private failClosedNow(): void { + this.applyM3u(); + void this.applyStalker(); + const playlistId = this.xtreamStore.playlistId?.(); + if (!playlistId) { + return; + } + this.stepOffLockedXtreamSelection( + playlistId, + XTREAM_ROUTE.exec(this.router.url) + ); + this.xtreamStore.withholdCatalog?.(); + this.xtreamStore.clearSearchResults?.(); + } + private async apply(): Promise { const version = this.parentalLock.version(); // The synchronous surfaces first: an M3U channel or a Stalker @@ -100,17 +134,6 @@ export class ParentalLockEnforcementService { } const shouldPublish = (): boolean => this.parentalLock.version() === version; - const match = XTREAM_ROUTE.exec(this.router.url); - if (this.parentalLock.active()) { - // Relock: fail closed NOW, not after the database answers. The - // selected detail is judged against the lock store while the - // pre-reload category list can still map its category; the - // catalog lists and stored search results are emptied and - // refilled by the filtered reads below. - this.stepOffLockedXtreamSelection(playlistId, match); - this.xtreamStore.withholdCatalog?.(); - this.xtreamStore.clearSearchResults?.(); - } await this.xtreamStore.reloadCategories(shouldPublish); await this.xtreamStore.reloadCachedContent(shouldPublish); if (!shouldPublish()) { @@ -119,51 +142,70 @@ export class ParentalLockEnforcementService { // Stored in-portal search results are a separate array the search // page renders directly; re-run the search so it reads filtered. await this.xtreamStore.refreshSearchResults?.(); + if (!shouldPublish() || !this.parentalLock.active()) { + return; + } + await this.stepOffWithheldXtreamSelection(playlistId, shouldPublish); + } + + /** + * Post-reload check of the selected Xtream category and item, judged by + * the LOCK STORE through the unfiltered category rows (hidden and locked + * ones included): the reloaded list also omits categories the user + * merely hid, which are not parental-locked. The item is judged on its + * own category — opened from "All", recently added or search it has no + * selected category to vanish with. Rows that cannot be read fail + * closed. + */ + private async stepOffWithheldXtreamSelection( + playlistId: string, + shouldPublish: () => boolean + ): Promise { + const match = XTREAM_ROUTE.exec(this.router.url); + const categoryType = toParentalLockXtreamCategoryType(match?.[2]); + if (!categoryType) { + return; + } + const rows = await this.xtreamDataSource + .getAllCategories(playlistId, categoryType) + .catch(() => null); if (!shouldPublish()) { return; } - - const categoryType = toParentalLockXtreamCategoryType(match?.[2]); - const categories = this.xtreamStore.getCategoriesBySelectedType(); - const isVisibleCategory = (categoryId: unknown): boolean => - categories.some( - (category) => - Number( - (category as { id?: number | string }).id ?? - (category as { category_id?: string }).category_id - ) === Number(categoryId) + const isWithheld = (categoryId: unknown): boolean => { + const id = Number(categoryId); + if ( + categoryId === null || + categoryId === undefined || + !Number.isFinite(id) + ) { + return false; + } + const row = rows?.find((candidate) => candidate.id === id); + return ( + !row || + this.parentalLock.isXtreamCategoryLocked( + playlistId, + categoryType, + row.xtream_id + ) ); - const selectedCategoryId = this.xtreamStore.selectedCategoryId(); - // The selected ITEM is judged on its own category: opened from - // "All", recently added or search it has no selected category to - // vanish with, yet its detail must not outlive the lock. + }; const selectedItem = this.xtreamStore.selectedItem?.() as { category_id?: string | number; } | null; - const itemWithheld = - selectedItem?.category_id !== undefined && - selectedItem?.category_id !== null && - !isVisibleCategory(selectedItem.category_id); - if (itemWithheld) { - this.xtreamStore.setSelectedItem(null); - } - if ( - selectedCategoryId === null || - isVisibleCategory(selectedCategoryId) - ) { - if (itemWithheld && match && match[1] === playlistId) { - void this.router.navigate([ - '/workspace', - 'xtreams', - match[1], - match[2], - ]); - } + const itemWithheld = isWithheld(selectedItem?.category_id); + const categoryWithheld = isWithheld( + this.xtreamStore.selectedCategoryId() + ); + if (!itemWithheld && !categoryWithheld) { return; } this.xtreamStore.setSelectedItem(null); - this.xtreamStore.setSelectedCategory(null); - if (match && match[1] === playlistId && categoryType) { + if (categoryWithheld) { + this.xtreamStore.setSelectedCategory(null); + } + if (match && match[1] === playlistId) { void this.router.navigate([ '/workspace', 'xtreams', diff --git a/docs/architecture/parental-lock.md b/docs/architecture/parental-lock.md index 72d31eb3f..80decd4cf 100644 --- a/docs/architecture/parental-lock.md +++ b/docs/architecture/parental-lock.md @@ -181,14 +181,19 @@ on either side. `searchContent` call as issued) — `searchResults` is a separate array the search page renders directly and would otherwise keep locked titles until the query changes. A RELOCK fails closed at once rather than after the - database answers: the selected detail is stepped off synchronously when + database answers — ahead of the serialized apply queue, so an earlier + apply still waiting on a slow or hung read cannot delay it: the selected detail is stepped off synchronously when the lock store already names its category (the pre-reload category list maps Electron's row id to the provider id), then `withholdCatalog()` empties every catalog list and `clearSearchResults()` the stored search (retiring a search still in flight, which was issued under the previous lock state) before the filtered reads refill them; both reloads take a publish guard answered before every state patch, so a read issued under an older lock - version is dropped instead of published. A lock change that overtakes + version is dropped instead of published. The post-reload checks of the + selected category and item decide by the LOCK STORE through the + unfiltered category rows (`IXtreamDataSource.getAllCategories`), not by + absence from the reloaded list, which also omits categories the user + merely hid; rows that cannot be read fail closed. A lock change that overtakes the INITIAL hydration (the content is not initialized yet, so the reload has nothing to re-read) sets a deferred-reload flag instead — already when `withholdCatalog()` empties the lists, before the category reload is