Files
worldmonitor/tests/cloud-prefs-migrations.test.mjs
Sebastien Melki 50765023ea feat: Cloud Preferences Sync polish + encryption key rotation (#3067)
* feat: cloud prefs sync polish + encryption key rotation (#2906)

1. Add sync status indicator in Preferences panel — shows current state
   (synced/pending/error/offline) with colored dot, last-sync timestamp,
   and manual "Sync now" button for conflict/error recovery.

2. Support AES key rotation in scripts/lib/crypto.cjs — encrypt() always
   uses the latest ENCRYPTION_KEY_V{n} env var, decrypt() reads the version
   prefix to select the matching key. Fully backwards-compatible with the
   existing NOTIFICATION_ENCRYPTION_KEY single-key setup.

3. Add tests exercising the applyMigrations() schema-version plumbing and
   the new multi-version key rotation (12 tests, all passing).

Closes #2906

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

* fix: address Greptile review feedback

- Raise key version ceiling from 9 to 99 to avoid silent footgun
- Extract SYNC_STATE_LABELS/COLORS to shared constants (no drift risk)
- Gate Cloud Sync UI on isCloudSyncEnabled() so it doesn't render
  when VITE_CLOUD_PREFS_ENABLED is off

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 19:44:25 +04:00

69 lines
2.1 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
/**
* Exercises the applyMigrations() plumbing from cloud-prefs-sync.ts.
*
* The function is not exported (internal), so we replicate the algorithm here
* with a real migration map to prove the loop + fallthrough logic works before
* it's needed in production. (Issue #2906 item 3)
*/
function applyMigrations(data, fromVersion, currentVersion, migrations) {
let result = data;
for (let v = fromVersion + 1; v <= currentVersion; v++) {
result = migrations[v]?.(result) ?? result;
}
return result;
}
describe('applyMigrations (cloud-prefs-sync plumbing)', () => {
const MIGRATIONS = {
2: (data) => {
// Simulate renaming a preference key
const out = { ...data };
if ('oldKey' in out) {
out.newKey = out.oldKey;
delete out.oldKey;
}
return out;
},
3: (data) => {
// Simulate adding a default for a new preference
const out = { ...data };
if (!('addedInV3' in out)) out.addedInV3 = 'default-value';
return out;
},
};
it('no-op when already at current version', () => {
const data = { foo: 'bar' };
const result = applyMigrations(data, 1, 1, MIGRATIONS);
assert.deepEqual(result, { foo: 'bar' });
});
it('applies a single v1 -> v2 migration', () => {
const data = { oldKey: 'hello', keep: 42 };
const result = applyMigrations(data, 1, 2, MIGRATIONS);
assert.deepEqual(result, { newKey: 'hello', keep: 42 });
});
it('chains v1 -> v2 -> v3 migrations', () => {
const data = { oldKey: 'hello' };
const result = applyMigrations(data, 1, 3, MIGRATIONS);
assert.deepEqual(result, { newKey: 'hello', addedInV3: 'default-value' });
});
it('skips missing migration versions gracefully', () => {
const data = { oldKey: 'x' };
const result = applyMigrations(data, 1, 4, MIGRATIONS);
assert.deepEqual(result, { newKey: 'x', addedInV3: 'default-value' });
});
it('handles empty migrations map', () => {
const data = { a: 1 };
const result = applyMigrations(data, 1, 5, {});
assert.deepEqual(result, { a: 1 });
});
});