mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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.
102 lines
3.4 KiB
JavaScript
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');
|
|
});
|