Files
readest/apps/readest-app/src/__tests__/services/sync/replicaSettingsSync.test.ts
T
Huang Xin 6e7c9d1395 feat(sync): bundled settings replica kind for cross-device prefs and credentials (#4094)
* feat(sync): add bundled `settings` replica kind for cross-device prefs and credentials

Adds a single-row `settings` replica that syncs a whitelist of
`SystemSettings` fields across devices via per-field LWW (one entry
per dot-namespaced path). Plaintext for theme / highlight colour /
TTS configuration; encrypted (AES-GCM under the user's sync
passphrase) for kosync / Readwise / Hardcover credentials.

Highlights:
- Push-side diff against an in-memory snapshot for plaintext paths
  and a localStorage SHA-256 hash for encrypted paths, so a refresh
  doesn't re-publish or re-prompt for the passphrase.
- Pull-side cipher-fingerprint dedupe + per-row passphrase gate;
  decryption failures surface as toasts (wrong passphrase / orphan
  cipher) instead of silent drops.
- Auto-recovery for orphaned ciphers: when a row references a
  saltId no longer in `replica_keys`, clear the local hash and
  re-encrypt under the current salt on the next save.
- Single in-flight `/sync/replica-keys` fetch with a value cache
  to coalesce the boot-time burst of concurrent unlock callers.

* fix(sync): guard settings dot-path helpers against prototype-polluting keys

Reject `__proto__`, `constructor`, and `prototype` segments in the
settings adapter's `readPath` / `writePath`. Every caller currently
passes a constant from `SETTINGS_WHITELIST`, so the guard is purely
defensive — but it silences the CodeQL prototype-pollution warning
on PR #4094 and keeps the helpers safe if a future call site ever
forwards an untrusted path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:03:23 +02:00

281 lines
9.6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
const publishMock = vi.fn();
vi.mock('@/services/sync/replicaPublish', () => ({
publishReplicaUpsert: (...args: unknown[]) => publishMock(...args),
}));
let isUnlocked = true;
vi.mock('@/libs/crypto/session', () => ({
cryptoSession: { isUnlocked: () => isUnlocked },
}));
// Default behavior: the gate "succeeds" by flipping isUnlocked to true
// (mimicking a successful passphrase setup/unlock). Individual tests
// override to simulate cancellation or a missing prompter.
const ensurePassphraseMock = vi.fn(async () => {
isUnlocked = true;
});
vi.mock('@/services/sync/passphraseGate', () => ({
ensurePassphraseUnlocked: () => ensurePassphraseMock(),
}));
import {
__resetSettingsSyncForTests,
applyRemoteSettings,
publishSettingsIfChanged,
} from '@/services/sync/replicaSettingsSync';
import { useSettingsStore } from '@/store/settingsStore';
import type { SystemSettings } from '@/types/settings';
import type { EnvConfigType } from '@/services/environment';
const baseHighlight = {
customThemes: [],
customHighlightColors: { yellow: '#ffeb3b' },
userHighlightColors: [],
defaultHighlightLabels: {},
customTtsHighlightColors: [],
};
const makeSettings = (overrides: Partial<SystemSettings> = {}): SystemSettings =>
({
globalReadSettings: { ...baseHighlight },
kosync: { serverUrl: '', username: '', userkey: '', password: '' },
readwise: { accessToken: '' },
hardcover: { accessToken: '' },
...overrides,
}) as unknown as SystemSettings;
const makeEnvConfig = (): EnvConfigType => ({ getAppService: vi.fn() }) as unknown as EnvConfigType;
beforeEach(() => {
publishMock.mockReset();
ensurePassphraseMock.mockReset();
ensurePassphraseMock.mockImplementation(async () => {
isUnlocked = true;
});
__resetSettingsSyncForTests();
isUnlocked = true;
useSettingsStore.setState({
settings: makeSettings(),
setSettings: (s: SystemSettings) => useSettingsStore.setState({ settings: s }),
saveSettings: vi.fn(),
applyUILanguage: vi.fn(),
} as unknown as ReturnType<typeof useSettingsStore.getState>);
});
afterEach(() => {
__resetSettingsSyncForTests();
});
describe('publishSettingsIfChanged', () => {
test('first call publishes every populated whitelisted field', async () => {
await publishSettingsIfChanged(makeSettings());
expect(publishMock).toHaveBeenCalledTimes(1);
const [kind, record, replicaId] = publishMock.mock.calls[0]!;
expect(kind).toBe('settings');
expect(replicaId).toBe('singleton');
const patch = (record as { patch: Partial<SystemSettings> }).patch;
expect(patch.globalReadSettings?.customHighlightColors).toEqual(
baseHighlight.customHighlightColors,
);
});
test('second call with no changes is a no-op', async () => {
await publishSettingsIfChanged(makeSettings());
publishMock.mockReset();
await publishSettingsIfChanged(makeSettings());
expect(publishMock).not.toHaveBeenCalled();
});
test('publishes only changed fields on subsequent calls', async () => {
await publishSettingsIfChanged(makeSettings());
publishMock.mockReset();
const next = makeSettings({
globalReadSettings: {
...baseHighlight,
userHighlightColors: [{ name: 'mint', color: '#a8e6cf' }],
} as unknown as SystemSettings['globalReadSettings'],
});
await publishSettingsIfChanged(next);
expect(publishMock).toHaveBeenCalledTimes(1);
const patch = publishMock.mock.calls[0]![1].patch as Partial<SystemSettings>;
expect(patch.globalReadSettings?.userHighlightColors).toEqual([
{ name: 'mint', color: '#a8e6cf' },
]);
// Unchanged fields stay out of the diff
expect(patch.globalReadSettings?.customHighlightColors).toBeUndefined();
expect(patch.kosync).toBeUndefined();
});
test('detects nested changes (kosync.serverUrl)', async () => {
await publishSettingsIfChanged(makeSettings());
publishMock.mockReset();
await publishSettingsIfChanged(
makeSettings({
kosync: {
serverUrl: 'https://kosync.example',
username: '',
userkey: '',
password: '',
} as SystemSettings['kosync'],
}),
);
expect(publishMock).toHaveBeenCalledTimes(1);
const patch = publishMock.mock.calls[0]![1].patch as Partial<SystemSettings>;
expect(patch.kosync?.serverUrl).toBe('https://kosync.example');
});
test('triggers the passphrase gate when an encrypted field gets meaningful content while locked', async () => {
isUnlocked = false;
await publishSettingsIfChanged(
makeSettings({
kosync: {
serverUrl: '',
username: '',
userkey: '',
password: 'hunter2',
} as SystemSettings['kosync'],
}),
);
expect(ensurePassphraseMock).toHaveBeenCalledTimes(1);
expect(publishMock).toHaveBeenCalledTimes(1);
});
test('empty encrypted credential is dropped from publish entirely (no gate, no patch)', async () => {
isUnlocked = false;
// makeSettings has all kosync credentials as ''. Plaintext changes
// (highlight palette) trigger the publish; encrypted empty fields
// should NOT appear in the patch and should NOT trigger the gate.
await publishSettingsIfChanged(makeSettings());
expect(ensurePassphraseMock).not.toHaveBeenCalled();
expect(publishMock).toHaveBeenCalledTimes(1);
const patch = publishMock.mock.calls[0]![1].patch as Partial<SystemSettings>;
expect(patch.kosync?.password).toBeUndefined();
expect(patch.readwise?.accessToken).toBeUndefined();
expect(patch.hardcover?.accessToken).toBeUndefined();
});
test('does NOT trigger the gate when only plaintext settings change', async () => {
isUnlocked = false;
await publishSettingsIfChanged(
makeSettings({
globalReadSettings: {
...baseHighlight,
userHighlightColors: [{ name: 'mint', color: '#a8e6cf' }],
} as unknown as SystemSettings['globalReadSettings'],
}),
);
expect(ensurePassphraseMock).not.toHaveBeenCalled();
expect(publishMock).toHaveBeenCalledTimes(1);
});
test('user cancels the gate prompt → encrypted hash NOT stored, next save retries', async () => {
isUnlocked = false;
ensurePassphraseMock.mockImplementationOnce(async () => {
throw new Error('user cancelled');
});
await publishSettingsIfChanged(
makeSettings({
kosync: {
serverUrl: '',
username: '',
userkey: '',
password: 'hunter2',
} as SystemSettings['kosync'],
}),
);
// Publish still fires — plaintext fields go through (none new this
// call) plus the encrypted field whose ciphertext the middleware
// will drop on the wire.
expect(publishMock).toHaveBeenCalledTimes(1);
publishMock.mockReset();
// Session still locked, same settings. Hash wasn't stored because
// we never unlocked, so the diff catches kosync.password again.
await publishSettingsIfChanged(
makeSettings({
kosync: {
serverUrl: '',
username: '',
userkey: '',
password: 'hunter2',
} as SystemSettings['kosync'],
}),
);
expect(publishMock).toHaveBeenCalledTimes(1);
});
test('encrypted-field publish while unlocked records the value (no retry next save)', async () => {
isUnlocked = true;
await publishSettingsIfChanged(
makeSettings({
kosync: {
serverUrl: '',
username: '',
userkey: '',
password: 'hunter2',
} as SystemSettings['kosync'],
}),
);
publishMock.mockReset();
await publishSettingsIfChanged(
makeSettings({
kosync: {
serverUrl: '',
username: '',
userkey: '',
password: 'hunter2',
} as SystemSettings['kosync'],
}),
);
expect(publishMock).not.toHaveBeenCalled();
});
});
describe('applyRemoteSettings', () => {
test('merges patch into useSettingsStore and persists', () => {
const env = makeEnvConfig();
const userColors = [{ name: 'mint', color: '#a8e6cf' }];
applyRemoteSettings(env, {
name: 'singleton',
patch: {
globalReadSettings: { userHighlightColors: userColors },
} as unknown as Partial<SystemSettings>,
});
const merged = useSettingsStore.getState().settings;
expect(merged.globalReadSettings.userHighlightColors).toEqual(userColors);
// Existing globalReadSettings fields preserved by the deep merge.
expect(merged.globalReadSettings.customHighlightColors).toEqual(
baseHighlight.customHighlightColors,
);
expect(useSettingsStore.getState().saveSettings).toHaveBeenCalledTimes(1);
});
test('applying remote does NOT echo the remote field back on the next publish', async () => {
await publishSettingsIfChanged(useSettingsStore.getState().settings);
publishMock.mockReset();
const env = makeEnvConfig();
const userColors = [{ name: 'mint', color: '#a8e6cf' }];
applyRemoteSettings(env, {
name: 'singleton',
patch: {
globalReadSettings: { userHighlightColors: userColors },
} as unknown as Partial<SystemSettings>,
});
publishMock.mockReset();
await publishSettingsIfChanged(useSettingsStore.getState().settings);
expect(publishMock).not.toHaveBeenCalled();
});
test('empty patch is a no-op', () => {
const env = makeEnvConfig();
const before = useSettingsStore.getState().settings;
applyRemoteSettings(env, { name: 'singleton', patch: {} });
expect(useSettingsStore.getState().settings).toBe(before);
expect(useSettingsStore.getState().saveSettings).not.toHaveBeenCalled();
});
});