fix(settings): build bulk lock drafts only from a readable lock store

The Xtream and M3U management dialogs offer lock toggles, and the
Stalker lock dialog opens, only once the lock store has been read. A
draft built from the empty fail-closed snapshot would otherwise replace
the real locks with nothing on Save if storage recovered in between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-27 15:44:41 +02:00
1 parent ceb0653415
commit ed48e7fd2b
7 files changed
+94 -5

No files matched your search

+6 -1
View File
@@ -375,7 +375,12 @@ on either side.
playlists can share a group name). The M3U management dialog is bound
the same way: `GroupsViewComponent` captures its `playlistId` input
before the PIN and drops the dialog's result once another playlist is
open. Bulk actions stay in the dialog, and while the session is locked
open. Every bulk lock editor (Xtream and M3U management dialogs, the
Stalker lock dialog) builds its draft only from a lock store that was
read (`ensureLocksReadable`): a draft taken from the empty fail-closed
snapshot would erase the real locks on Save if storage recovered in
between. The M3U and Xtream dialogs then open without lock toggles; the
Stalker dialog does not open. Bulk actions stay in the dialog, and while the session is locked
a locked category is not in the rail, so unlocking always goes through
the "N locked · Enter PIN to show" row or the dialog. The M3U dialog
hands its lock list back to `ChannelListContainerComponent`, which
@@ -42,6 +42,7 @@ describe('CategoryManagementDialogComponent', () => {
active: signal(false),
lockedXtreamIds: jest.fn(() => [] as number[]),
setXtreamLocks: jest.fn(),
ensureLocksReadable: jest.fn(async () => true),
};
const data: CategoryManagementDialogData = {
playlistId: 'mock-playlist',
@@ -210,6 +211,7 @@ describe('CategoryManagementDialogComponent', () => {
it('drops the lock draft when the session relocks during a pending save', async () => {
parentalLock.enabled.set(true);
component.showLocks.set(true);
fixture.detectChanges();
let finishVisibility: () => void = () => undefined;
db.updateCategoryVisibility.mockImplementationOnce(
@@ -225,6 +227,18 @@ describe('CategoryManagementDialogComponent', () => {
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
});
it('offers no lock toggles, and saves no locks, while the lock store is unreadable', async () => {
parentalLock.enabled.set(true);
parentalLock.ensureLocksReadable.mockResolvedValueOnce(false);
await component.ngOnInit();
expect(component.showLocks()).toBe(false);
await component.save();
expect(parentalLock.setXtreamLocks).not.toHaveBeenCalled();
expect(dialogRef.close).toHaveBeenCalledWith(true);
});
it('discards pending bulk changes on cancel', () => {
component.searchTerm.set('FR');
component.selectAll();
@@ -74,7 +74,12 @@ export class CategoryManagementDialogComponent implements OnInit {
private readonly logger = createLogger('CategoryManagementDialog');
/** Lock toggles exist only while the parental lock feature is on. */
readonly showLocks = this.parentalLock.enabled;
/**
* Lock toggles, only once the lock store has been read: a draft built
* from the empty fail-closed snapshot would, if storage recovered by
* Save, replace the real locks with nothing.
*/
readonly showLocks = signal(false);
readonly lockedCount = computed(
() => this.categories().filter((c) => c.lockedDraft).length
);
@@ -126,6 +131,10 @@ export class CategoryManagementDialogComponent implements OnInit {
private async loadCategories(): Promise<void> {
try {
const type = this.getDbType();
this.showLocks.set(
this.parentalLock.enabled() &&
(await this.parentalLock.ensureLocksReadable())
);
const allCategories = await this.waitForCategories(type);
// The renderer's lock store is authoritative; the row's `locked`
// column is only its SQLite mirror.
@@ -47,7 +47,11 @@ describe('GroupsViewComponent', () => {
let fixture: ComponentFixture<GroupsViewComponent>;
let component: GroupsViewComponent;
let dialog: { open: jest.Mock };
let parentalLock: { requestUnlock: jest.Mock };
let parentalLock: {
requestUnlock: jest.Mock;
ensureLocksReadable: jest.Mock;
lockedGroupTitles: jest.Mock;
};
const sportsCenter = createChannel(
'sports-1',
@@ -104,7 +108,11 @@ describe('GroupsViewComponent', () => {
beforeEach(async () => {
localStorage.removeItem(GROUP_CHANNEL_SORT_STORAGE_KEY);
parentalLock = { requestUnlock: jest.fn().mockResolvedValue(true) };
parentalLock = {
requestUnlock: jest.fn().mockResolvedValue(true),
ensureLocksReadable: jest.fn().mockResolvedValue(true),
lockedGroupTitles: jest.fn(() => ['News']),
};
dialog = {
open: jest.fn(),
@@ -537,6 +545,19 @@ describe('GroupsViewComponent', () => {
]);
});
it('opens the manage-groups dialog without lock toggles while the lock store is unreadable', async () => {
fixture.componentRef.setInput('playlistId', 'playlist-a');
fixture.componentRef.setInput('lockedGroupTitles', []);
fixture.detectChanges();
parentalLock.ensureLocksReadable.mockResolvedValue(false);
dialog.open.mockReturnValue({ afterClosed: () => of(undefined) });
await component.openGroupManagement();
const data = dialog.open.mock.calls[0][1].data;
expect(data.lockedGroupTitles).toBeUndefined();
});
it('drops the manage-groups result once another playlist is open', async () => {
const hiddenGroupTitlesChanged = jest.fn();
const lockedGroupTitlesChanged = jest.fn();
@@ -556,7 +556,17 @@ export class GroupsViewComponent {
count,
})
);
const lockedGroupTitles = this.lockedGroupTitles();
// Lock toggles only from a lock store that was read: a draft built
// from the empty fail-closed snapshot would, if storage recovered by
// Save, replace the real locks with nothing. Hidden groups stay
// editable either way.
const lockedGroupTitles =
this.lockedGroupTitles() !== null &&
playlistId &&
(await this.parentalLock.ensureLocksReadable()) &&
this.playlistId() === playlistId
? this.parentalLock.lockedGroupTitles(playlistId)
: null;
const dialogRef = this.dialog.open(GroupManagementDialogComponent, {
data: {
groups,
@@ -967,6 +967,9 @@ describe('WorkspaceContextPanelComponent', () => {
value: signal(true),
});
jest.spyOn(parentalLock, 'requestUnlock').mockResolvedValue(true);
jest.spyOn(parentalLock, 'ensureLocksReadable').mockResolvedValue(
true
);
}
it('opens with the categories snapshotted before the lazy import', async () => {
@@ -1027,6 +1030,28 @@ describe('WorkspaceContextPanelComponent', () => {
expect(dialog.open).not.toHaveBeenCalled();
});
it('opens nothing while the lock store cannot be read', async () => {
enableLock();
jest.spyOn(
TestBed.inject(ParentalLockService),
'ensureLocksReadable'
).mockResolvedValue(false);
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'itv');
fixture.detectChanges();
const component = fixture.componentInstance;
component.loadStalkerLockDialog = jest.fn(
async () => class DialogStub {}
);
await component.openManageStalkerCategories();
expect(dialog.open).not.toHaveBeenCalled();
});
it('opens nothing when the route changed during the lazy import', async () => {
enableLock();
fixture.componentRef.setInput('context', {
@@ -548,6 +548,11 @@ export class WorkspaceContextPanelComponent {
if (!contentType || !(await this.parentalLock.requestUnlock())) {
return;
}
// The dialog's draft is the lock store's list: built from the empty
// fail-closed snapshot it would erase the real locks on Save.
if (!unchanged() || !(await this.parentalLock.ensureLocksReadable())) {
return;
}
if (!unchanged()) {
return;
}