Files
Firelink/src/store/useSettingsStore.test.ts
T
NimBold 314f4e2e00 fix(settings): harden Torrent network validation
- Align the DHT message-timeout range with bundled Aria2 1.37.0.
- Validate and canonicalize Torrent network text settings at the native boundary.
- Fence delayed input responses and reject contradictory IPv6 bind state.
- Add regression coverage for malformed settings and cross-field races.
2026-08-13 16:40:55 +03:30

502 lines
18 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
runSettingsPersistenceTransaction,
subscribeToSettingsPersistenceErrors,
useSettingsStore
} from './useSettingsStore';
import * as ipc from '../ipc';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
import {
DEFAULT_TORRENT_MAX_OPEN_FILES,
MAX_TORRENT_MAX_OPEN_FILES
} from '../utils/downloads';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn()
}));
vi.mock('../utils/logger', () => ({
info: vi.fn()
}));
describe('last used download directory preference', () => {
it('is disabled by default', () => {
expect(useSettingsStore.getState().rememberLastUsedDownloadDirectory).toBe(false);
});
});
describe('durable main-window and sidebar preferences', () => {
it('uses safe defaults and persists the current values', async () => {
vi.clearAllMocks();
useSettingsStore.setState({ isFoldersCollapsed: false, mainWindowSize: null });
expect(useSettingsStore.getState()).toMatchObject({
isFoldersCollapsed: false,
mainWindowSize: null
});
useSettingsStore.getState().setFoldersCollapsed(true);
useSettingsStore.getState().setMainWindowSize({ width: 1280, height: 800 });
await vi.waitFor(() => {
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
isFoldersCollapsed: true,
mainWindowSize: { width: 1280, height: 800 }
});
});
});
it('rejects malformed, undersized, and oversized geometry during hydration', () => {
const merge = useSettingsStore.persist.getOptions().merge;
expect(merge).toBeTypeOf('function');
const current = useSettingsStore.getState();
expect(merge?.({ mainWindowSize: { width: 959, height: 800 } }, current).mainWindowSize)
.toBe(current.mainWindowSize);
expect(merge?.({ mainWindowSize: { width: 1280, height: 16_385 } }, current).mainWindowSize)
.toBe(current.mainWindowSize);
expect(merge?.({ mainWindowSize: { width: '1280', height: 800 } }, current).mainWindowSize)
.toBe(current.mainWindowSize);
expect(merge?.({ mainWindowSize: { width: 1440, height: 900 } }, current).mainWindowSize)
.toEqual({ width: 1440, height: 900 });
});
it('uses the legacy localStorage value only when durable state is absent', () => {
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { localStorage: { getItem: () => 'true' } }
});
try {
const merge = useSettingsStore.persist.getOptions().merge;
const current = { ...useSettingsStore.getState(), isFoldersCollapsed: false };
expect(merge?.({}, current).isFoldersCollapsed).toBe(true);
expect(merge?.({ isFoldersCollapsed: false }, current).isFoldersCollapsed).toBe(false);
} finally {
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: originalWindow
});
}
});
});
describe('normal download reliability preferences', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
minimumNormalDownloadSpeedKiB: 0,
retryNotFoundErrors: false,
adaptiveMirrorSelection: true,
});
});
it('uses migration-safe defaults and persists bounded changes', async () => {
expect(useSettingsStore.getState()).toMatchObject({
minimumNormalDownloadSpeedKiB: 0,
retryNotFoundErrors: false,
adaptiveMirrorSelection: true,
});
useSettingsStore.getState().setMinimumNormalDownloadSpeedKiB(64);
useSettingsStore.getState().setRetryNotFoundErrors(true);
useSettingsStore.getState().setAdaptiveMirrorSelection(false);
await vi.waitFor(() => {
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
minimumNormalDownloadSpeedKiB: 64,
retryNotFoundErrors: true,
adaptiveMirrorSelection: false,
});
});
useSettingsStore.getState().setMinimumNormalDownloadSpeedKiB(2_000_000);
expect(useSettingsStore.getState().minimumNormalDownloadSpeedKiB).toBe(1_048_576);
});
});
describe('Torrent peer discovery preferences', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
torrentEnableDht: true,
torrentEnableDht6: false,
torrentEnablePex: true,
torrentEnableLpd: false
});
});
it('clears an IPv6 bind address when IPv6 transport is disabled', () => {
useSettingsStore.setState({
torrentIpv6Enabled: true,
torrentBindAddress: '2001:db8::10'
});
useSettingsStore.getState().setTorrentIpv6Enabled(false);
expect(useSettingsStore.getState()).toMatchObject({
torrentIpv6Enabled: false,
torrentBindAddress: ''
});
});
it('rejects an IPv6 bind address entered after IPv6 transport is disabled', () => {
useSettingsStore.setState({
torrentIpv6Enabled: false,
torrentBindAddress: ''
});
expect(useSettingsStore.getState().setTorrentBindAddress('2001:db8::10')).toBe(false);
expect(useSettingsStore.getState().torrentBindAddress).toBe('');
expect(useSettingsStore.getState().setTorrentBindAddress('192.0.2.10')).toBe(true);
expect(useSettingsStore.getState().torrentBindAddress).toBe('192.0.2.10');
});
it('matches Aria2 defaults and persists explicit changes', async () => {
expect(useSettingsStore.getState().torrentEnableDht).toBe(true);
expect(useSettingsStore.getState().torrentEnableDht6).toBe(false);
expect(useSettingsStore.getState().torrentEnablePex).toBe(true);
expect(useSettingsStore.getState().torrentEnableLpd).toBe(false);
useSettingsStore.getState().setTorrentEnableDht(false);
useSettingsStore.getState().setTorrentEnableDht6(true);
useSettingsStore.getState().setTorrentEnablePex(false);
useSettingsStore.getState().setTorrentEnableLpd(true);
await vi.waitFor(() => {
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({
torrentEnableDht: false,
torrentEnableDht6: true,
torrentEnablePex: false,
torrentEnableLpd: true
});
});
});
});
describe('Torrent open-file limit preference', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ torrentMaxOpenFiles: DEFAULT_TORRENT_MAX_OPEN_FILES });
});
it('applies a bounded global limit before persisting it', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
await useSettingsStore.getState().setTorrentMaxOpenFiles(256);
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_max_open_files', {
max_open_files: 256
});
expect(useSettingsStore.getState().torrentMaxOpenFiles).toBe(256);
});
it('rejects unsafe values without changing the saved limit', async () => {
await expect(useSettingsStore.getState().setTorrentMaxOpenFiles(0)).rejects.toThrow();
await expect(
useSettingsStore.getState().setTorrentMaxOpenFiles(MAX_TORRENT_MAX_OPEN_FILES + 1)
).rejects.toThrow();
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
'set_torrent_max_open_files',
expect.anything()
);
expect(useSettingsStore.getState().torrentMaxOpenFiles)
.toBe(DEFAULT_TORRENT_MAX_OPEN_FILES);
});
it('serializes rapid updates so the native global option cannot reorder', async () => {
let releaseFirst!: () => void;
const firstUpdate = new Promise<void>(resolve => {
releaseFirst = resolve;
});
const events: string[] = [];
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command !== 'set_torrent_max_open_files') return undefined;
const value = (args as { max_open_files: number }).max_open_files;
events.push(`start:${value}`);
if (value === 256) await firstUpdate;
events.push(`finish:${value}`);
return undefined;
});
const first = useSettingsStore.getState().setTorrentMaxOpenFiles(256);
const second = useSettingsStore.getState().setTorrentMaxOpenFiles(512);
await vi.waitFor(() => expect(events).toEqual(['start:256']));
expect(useSettingsStore.getState().torrentMaxOpenFiles)
.toBe(DEFAULT_TORRENT_MAX_OPEN_FILES);
releaseFirst();
await Promise.all([first, second]);
expect(events).toEqual(['start:256', 'finish:256', 'start:512', 'finish:512']);
expect(useSettingsStore.getState().torrentMaxOpenFiles).toBe(512);
});
});
describe('calendar preference', () => {
it('keeps Gregorian as the default and persists explicit calendar choices', async () => {
vi.clearAllMocks();
useSettingsStore.setState({ calendarPreference: 'gregorian' });
expect(useSettingsStore.getState().calendarPreference).toBe('gregorian');
useSettingsStore.getState().setCalendarPreference('persian');
await new Promise(resolve => setTimeout(resolve, 0));
expect(useSettingsStore.getState().calendarPreference).toBe('persian');
const save = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'db_save_settings')
.slice(-1)[0];
expect(save).toBeDefined();
expect(JSON.parse((save?.[1] as { data: string }).data).state.calendarPreference).toBe('persian');
});
});
describe('useSettingsStore global speed limit persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ globalSpeedLimit: '2M' });
});
it('keeps the saved value when the backend rejects a limit change', async () => {
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('aria2 unavailable'));
await expect(useSettingsStore.getState().setGlobalSpeedLimit('3M')).rejects.toThrow('aria2 unavailable');
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
});
it('rejects malformed limits before changing native or local state', async () => {
await expect(useSettingsStore.getState().setGlobalSpeedLimit('not-a-rate'))
.rejects.toThrow('Global speed limit is invalid');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
'set_global_speed_limit',
expect.anything()
);
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
});
});
describe('useSettingsStore Torrent overall upload limit persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ torrentOverallUploadLimit: '2M' });
});
it('applies a normalized limit before updating local state', async () => {
await useSettingsStore.getState().setTorrentOverallUploadLimit('1.5 MB/s');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_overall_upload_limit', {
limit: '1.5M'
});
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('1.5M');
});
it('keeps the saved value when the native global option rejects an update', async () => {
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('aria2 unavailable'));
await expect(
useSettingsStore.getState().setTorrentOverallUploadLimit('3M')
).rejects.toThrow('aria2 unavailable');
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('2M');
});
it('uses null to restore Aria2 unlimited upload', async () => {
await useSettingsStore.getState().setTorrentOverallUploadLimit('');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_torrent_overall_upload_limit', {
limit: null
});
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('');
});
it('rejects malformed limits without clearing the saved value', async () => {
await expect(
useSettingsStore.getState().setTorrentOverallUploadLimit('not-a-rate')
).rejects.toThrow('Torrent overall upload limit is invalid');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
'set_torrent_overall_upload_limit',
expect.anything()
);
expect(useSettingsStore.getState().torrentOverallUploadLimit).toBe('2M');
});
});
describe('useSettingsStore dock badge synchronization', () => {
it('increments the badge sync version for every toggle without issuing out-of-band clears', () => {
vi.clearAllMocks();
const initialVersion = useSettingsStore.getState().dockBadgeSyncVersion;
useSettingsStore.getState().setShowDockBadge(false);
useSettingsStore.getState().setShowDockBadge(true);
expect(useSettingsStore.getState().dockBadgeSyncVersion).toBe(initialVersion + 2);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('update_dock_badge', { count: 0 });
});
});
describe('useSettingsStore credential-store startup flow', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({
extensionPairingToken: '',
isPairingTokenPersistent: false,
keychainAccessGranted: false,
keychainAccessVersion: '',
keychainAccessReady: false,
keychainPromptDismissed: false,
showKeychainModal: false
});
});
it('loads the session pairing token without invoking the credential store', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
token: 'session-token',
tokenChanged: false,
persistent: false,
error: null
});
await useSettingsStore.getState().hydrateSessionPairingToken();
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
expect(useSettingsStore.getState().extensionPairingToken).toBe('session-token');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
});
it('clears the approved startup state when the user defers credential access', () => {
useSettingsStore.setState({ keychainAccessGranted: true });
useSettingsStore.getState().dismissKeychainPrompt('1.0.5');
expect(useSettingsStore.getState().keychainAccessGranted).toBe(false);
expect(useSettingsStore.getState().keychainAccessReady).toBe(false);
expect(useSettingsStore.getState().keychainAccessVersion).toBe('1.0.5');
expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true);
});
it('opens the consent modal instead of regenerating through the credential store', async () => {
await expect(useSettingsStore.getState().regeneratePairingToken())
.rejects.toThrow('Grant credential-store access before regenerating the pairing token.');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('regenerate_pairing_token');
expect(useSettingsStore.getState().showKeychainModal).toBe(true);
});
it('does not apply pairing hydration after startup becomes inactive', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
token: 'stale-token',
tokenChanged: true,
persistent: true,
error: null
});
await expect(useSettingsStore.getState().hydratePairingToken(() => false)).resolves.toBe(false);
expect(ipc.invokeCommand).toHaveBeenCalledWith('hydrate_extension_pairing_token');
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
});
it('does not apply session hydration after startup becomes inactive', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
token: 'stale-session-token',
tokenChanged: false,
persistent: false,
error: null
});
await useSettingsStore.getState().hydrateSessionPairingToken(() => false);
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
});
it('shares a concurrent pairing hydration request', async () => {
let resolveRequest!: (value: PairingTokenHydration) => void;
const request = new Promise<PairingTokenHydration>(resolve => {
resolveRequest = resolve;
});
let hydrationRequestCount = 0;
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'hydrate_extension_pairing_token') {
hydrationRequestCount += 1;
return request;
}
return undefined;
});
const first = useSettingsStore.getState().hydratePairingToken();
const second = useSettingsStore.getState().hydratePairingToken();
expect(hydrationRequestCount).toBe(1);
resolveRequest({
token: 'shared-token',
tokenChanged: false,
persistent: true,
error: null
});
await Promise.all([first, second]);
expect(useSettingsStore.getState().extensionPairingToken).toBe('shared-token');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(true);
});
});
describe('useSettingsStore persistence failures', () => {
it('keeps settings writes queued behind a credential transaction', async () => {
const events: string[] = [];
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'db_save_settings') events.push('settings-write');
return undefined;
});
await runSettingsPersistenceTransaction(async () => {
events.push('transaction-start');
useSettingsStore.setState({ theme: 'dark' });
events.push('transaction-end');
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(events.slice(0, 2)).toEqual(['transaction-start', 'transaction-end']);
expect(events).toContain('settings-write');
});
it('reports a database save failure and retries the next settings update', async () => {
vi.clearAllMocks();
await new Promise(resolve => setTimeout(resolve, 0));
const onPersistenceError = vi.fn();
const unsubscribe = subscribeToSettingsPersistenceErrors(onPersistenceError);
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('database unavailable'));
useSettingsStore.setState({ theme: 'dark' });
await new Promise(resolve => setTimeout(resolve, 0));
expect(onPersistenceError).toHaveBeenCalledTimes(1);
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(undefined);
useSettingsStore.setState({ theme: 'light' });
await new Promise(resolve => setTimeout(resolve, 0));
expect(onPersistenceError).toHaveBeenCalledTimes(1);
unsubscribe();
});
});