super-productivity/electron/sync-folder-store.test.cjs
johannesjo 0c21649fde feat(electron): own sync folder main-side and take only relative paths
Phase 2 of #8228. Closes the renderer-supplied-absolute-path hole: the
file-sync IPCs now take a relative path; main resolves it against a
sync folder it owns and never trusts the renderer's copy of.

Main side
- sync-folder-store: persists the path in simple-store (still inside
  userData, still renderer-unreachable via assertPathOutside) with an
  in-memory cache so per-IPC lookup doesn't stat the file every call.
  initSyncFolderStore() is fire-and-forget at adapter init; each handler
  awaits it defensively because it's idempotent via _loadOnce.
- FILE_SYNC_SAVE/LOAD/REMOVE: accept { relativePath, ... }, pass through
  resolveSyncPath, write/read/delete the vetted absolute path.
- CHECK_DIR_EXISTS and FILE_SYNC_LIST_FILES: accept an optional
  relativePath; default to the sync root itself so the existing
  "is the sync folder reachable?" check still works.
- PICK_DIRECTORY: now persists the chosen folder via setSyncFolderPath
  before returning the display string to the renderer. The renderer
  receiving the string is no longer load-bearing — it's display only.
- New GET_SYNC_FOLDER_PATH IPC returns the current value for display
  (settings form, "sync folder: …" labels).
- TO_FILE_URL: pulls in the assertPathOutside(userData) backstop that
  was missing on master, so a userData path cannot be laundered into a
  file:// URL that later flows through READ_LOCAL_IMAGE_AS_DATA_URL or
  the navigation guard.

Package (@sp/sync-providers/local-file)
- LocalFileSyncElectron.isReady now asks main (getMainSyncFolderPath
  dep) instead of reading the renderer's privateCfg. This drives the
  migration UX: an existing install with a legacy privateCfg value but
  no main-side path lands on "not configured" and gets re-prompted by
  the picker. A one-cycle "please pick again" is the migration cost; we
  decided against silently promoting the renderer's persisted value
  because it could be tampered with.
- getFilePath returns the *relative* path (no absolute folder prefix).
  The leading-slash strip is preserved so existing call sites that
  prepend '/' still work.
- pickDirectory no longer writes to renderer privateCfg — main owns
  persistence. The dep contract still returns the display string.
- The privateCfg.syncFolderPath field is marked @deprecated; the
  field is kept for migration of older configs and may be removed in a
  later pass once the migration has rolled out.

Renderer
- ElectronFileAdapter forwards the relative path verbatim to the IPC.
- The renderer's LocalFileSyncElectron deps wire pickDirectory and the
  new getMainSyncFolderPath to the bridge.

Tests
- electron/local-file-sync.test.cjs rewritten for the new signatures:
  happy path, traversal escapes, absolute-relativePath rejection,
  sync-folder-equals-userData rejection (grant-file forgery defense),
  PICK persists main-side, GET returns the configured path, TO_FILE_URL
  refuses userData.
- electron/sync-folder-store.test.cjs covers persistence,
  init-before-get throw, null/empty handling, idempotent set.
- The package's local-file-sync-electron.spec.ts is updated for the new
  deps shape and asserts the package no longer writes to the renderer
  credential store.

All 56 electron-side tests pass; 374 sync-providers tests pass.
2026-06-09 22:40:50 +02:00

102 lines
3.4 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const { promises: fs } = require('node:fs');
const Module = require('node:module');
require('ts-node/register/transpile-only');
const originalModuleLoad = Module._load;
let userDataDir;
const simpleStoreModulePath = path.resolve(__dirname, 'simple-store.ts');
const syncFolderStoreModulePath = path.resolve(__dirname, 'sync-folder-store.ts');
const installMocks = () => {
Module._load = function patchedLoad(request, parent, isMain) {
if (request === 'electron') {
return { app: { getPath: () => userDataDir } };
}
if (request === 'electron-log/main') {
return { log: () => {}, error: () => {} };
}
return originalModuleLoad.call(this, request, parent, isMain);
};
};
const resetModules = () => {
delete require.cache[simpleStoreModulePath];
delete require.cache[syncFolderStoreModulePath];
};
const loadStore = () => {
resetModules();
return require(syncFolderStoreModulePath);
};
test.beforeEach(async () => {
userDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'sp-sync-folder-store-'));
installMocks();
});
test.afterEach(async () => {
Module._load = originalModuleLoad;
resetModules();
await fs.rm(userDataDir, { recursive: true, force: true });
});
test('getSyncFolderPath returns null on first init (no persisted value)', async () => {
const store = loadStore();
await store.initSyncFolderStore();
assert.equal(store.getSyncFolderPath(), null);
});
test('setSyncFolderPath persists and is readable across reloads', async () => {
const store = loadStore();
await store.initSyncFolderStore();
await store.setSyncFolderPath('/Users/me/sync');
assert.equal(store.getSyncFolderPath(), '/Users/me/sync');
store.__resetSyncFolderCacheForTests();
await store.initSyncFolderStore();
assert.equal(store.getSyncFolderPath(), '/Users/me/sync');
});
test('setSyncFolderPath(null) clears the persisted value', async () => {
const store = loadStore();
await store.initSyncFolderStore();
await store.setSyncFolderPath('/Users/me/sync');
await store.setSyncFolderPath(null);
assert.equal(store.getSyncFolderPath(), null);
store.__resetSyncFolderCacheForTests();
await store.initSyncFolderStore();
assert.equal(store.getSyncFolderPath(), null);
});
test('setSyncFolderPath(empty string) is treated as null', async () => {
const store = loadStore();
await store.initSyncFolderStore();
await store.setSyncFolderPath('/some/path');
await store.setSyncFolderPath('');
assert.equal(store.getSyncFolderPath(), null);
});
test('getSyncFolderPath throws when called before init', async () => {
const store = loadStore();
assert.throws(() => store.getSyncFolderPath(), /initSyncFolderStore/);
});
test('setSyncFolderPath is idempotent (no persist if value unchanged)', async () => {
// Indirectly verified: a no-op set must not corrupt cache state on the
// next read. (We can't observe the file write count without monkey-patching
// simple-store; this guards against the regression where idempotence
// accidentally inverts the cache.)
const store = loadStore();
await store.initSyncFolderStore();
await store.setSyncFolderPath('/x');
await store.setSyncFolderPath('/x');
await store.setSyncFolderPath('/x');
assert.equal(store.getSyncFolderPath(), '/x');
});