mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user