Files
NetMesh/electron/plugins/pluginSyncSidecarService.test.cjs

513 lines
17 KiB
JavaScript
Raw Normal View History

"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { PluginDatabase } = require("./database.cjs");
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
function tempDb(context) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sidecar-"));
context.after(() => {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
});
return new PluginDatabase(path.join(dir, "plugins.sqlite"));
}
test("sidecar table has no package cascade and survives removePlugin", (context) => {
const database = tempDb(context);
database.installVersion({
pluginId: "com.example.sync",
version: "1.0.0",
manifest: { id: "com.example.sync", version: "1.0.0" },
archiveSha256: "a".repeat(64),
packageRelativePath: "packages/com.example.sync/1.0.0",
}, { enable: true });
database.setSyncSidecar("com.example.sync", "account_baseline", "account", { id: "acct" }, 10);
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
database.removePlugin("com.example.sync");
assert.deepEqual(database.getSyncSidecar("com.example.sync", "account_baseline", "account"), {
pluginId: "com.example.sync",
kind: "account_baseline",
key: "account",
value: { id: "acct" },
updatedAt: 10,
});
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
"dark",
);
});
test("collectForSync excludes secrets and preserves missing-plugin baselines", (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "replica", { clock: 2 }, 5);
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
database.setSetting("com.example.sync", "com.example.sync.token", "application", "application", "secret");
const contributionService = {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
{ id: "com.example.sync.token", secret: true, sync: true, scope: "application" },
],
}],
};
},
};
const service = new PluginSyncSidecarService({ database, contributionService });
const bundle = service.collectForSync();
assert.equal(bundle.version, 1);
assert.ok(bundle.entries.some((entry) => entry.kind === "crdt_baseline" && entry.pluginId === "com.missing.plugin"));
assert.ok(bundle.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"));
assert.equal(bundle.entries.some((entry) => entry.value === "secret"), false);
// Collected settings must land in the non-cascade table for post-uninstall collect.
const persisted = database.listAllSyncSidecars();
assert.ok(persisted.some((entry) => entry.kind === "settings" && entry.value === "dark"));
assert.ok(persisted.some((entry) => entry.kind === "crdt_baseline"));
});
test("collectForSync re-emits retained sidecars after plugin reinstall without writing settings", (context) => {
const database = tempDb(context);
// Sidecar applied while plugin was missing (no plugin_settings row).
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"from-cloud",
9,
);
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
},
});
const bundle = service.collectForSync();
assert.ok(bundle.entries.some((e) => e.value === "from-cloud"));
// Collect must not bypass schema validation by writing plugin_settings.
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
undefined,
);
});
test("collectForSync re-emits settings after plugin uninstall via non-cascade table", (context) => {
const database = tempDb(context);
database.installVersion({
pluginId: "com.example.sync",
version: "1.0.0",
manifest: { id: "com.example.sync", version: "1.0.0" },
archiveSha256: "b".repeat(64),
packageRelativePath: "packages/com.example.sync/1.0.0",
}, { enable: true });
database.setSetting("com.example.sync", "com.example.sync.theme", "application", "application", "dark");
const contributionService = {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
};
const service = new PluginSyncSidecarService({ database, contributionService });
service.collectForSync();
database.removePlugin("com.example.sync");
const missingService = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
const afterUninstall = missingService.collectForSync();
assert.ok(
afterUninstall.entries.some((entry) => entry.kind === "settings" && entry.value === "dark"),
"settings sidecar must survive uninstall via non-cascade persistence",
);
});
test("collectForSync omits deleted settings for installed plugins but keeps missing-plugin rows", (context) => {
const database = tempDb(context);
// User reset clears both plugin_settings and the matching sidecar row.
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"stale",
5,
);
database.deleteSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
);
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "keep", 5);
// Only store a different setting for the installed plugin.
database.setSetting("com.example.sync", "com.example.sync.other", "application", "application", "ok");
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
{ id: "com.example.sync.other", secret: false, sync: true, scope: "application" },
],
}],
};
},
},
});
const bundle = service.collectForSync();
assert.equal(bundle.entries.some((e) => e.value === "stale"), false);
assert.ok(bundle.entries.some((e) => e.value === "ok"));
assert.ok(bundle.entries.some((e) => e.pluginId === "com.missing.plugin" && e.value === "keep"));
});
test("applyFromSync preserves remote updatedAt when writing settings", async (context) => {
const database = tempDb(context);
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
},
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.sync",
kind: "settings",
key: "com.example.sync.theme\0application\0application",
value: "dark",
updatedAt: 42,
}],
});
const rows = database.listAllSettings();
const theme = rows.find((r) => r.settingId === "com.example.sync.theme");
assert.equal(theme?.value, "dark");
assert.equal(theme?.updatedAt, 42);
});
test("applyFromSync does not drop local baselines for plugins absent remotely", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.local.only", "account_baseline", "account", { id: "local" }, 1);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.remote.plugin",
kind: "settings",
key: "com.remote.plugin.theme\0application\0application",
value: "light",
updatedAt: 2,
}],
});
const all = database.listAllSyncSidecars();
assert.ok(all.some((entry) => entry.pluginId === "com.local.only"));
assert.ok(all.some((entry) => entry.pluginId === "com.remote.plugin" && entry.value === "light"));
});
test("hydrateInstalledPluginSettings applies retained sidecars through contribution service", async (context) => {
const database = tempDb(context);
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"from-cloud",
9,
);
const updates = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
async updateSetting(pluginId, settingId, value, scopeId) {
updates.push({ pluginId, settingId, value, scopeId });
},
},
});
await service.hydrateInstalledPluginSettings("com.example.sync");
assert.equal(updates.length, 1);
assert.equal(updates[0].value, "from-cloud");
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
"from-cloud",
);
});
test("hydrateInstalledPluginSettings skips sidecars older than local settings", async (context) => {
const database = tempDb(context);
database.setSetting(
"com.example.sync",
"com.example.sync.theme",
"application",
"application",
"local-newer",
20,
);
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"from-cloud-stale",
9,
);
const updates = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
async updateSetting(pluginId, settingId, value, scopeId) {
updates.push({ pluginId, settingId, value, scopeId });
},
},
});
await service.hydrateInstalledPluginSettings("com.example.sync");
assert.equal(updates.length, 0);
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
"local-newer",
);
});
test("applyFromSync empty remote clears installed settings without sidecar rows", async (context) => {
const database = tempDb(context);
// Pre-sidecar migration residue: setting exists, sidecar table empty.
database.setSetting(
"com.example.sync",
"com.example.sync.theme",
"application",
"application",
"stale-local",
3,
);
const resets = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "application" },
],
}],
};
},
async resetSetting(pluginId, settingId, scopeId) {
resets.push({ pluginId, settingId, scopeId });
database.deleteSetting(pluginId, settingId, "application", scopeId);
},
},
});
await service.applyFromSync({ version: 1, entries: [] });
assert.equal(resets.length, 1);
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
undefined,
);
});
test("applyFromSync deletes stale-scope settings under stored coordinates", async (context) => {
const database = tempDb(context);
// Sidecar/key still encodes application scope, but declaration moved to device.
database.setSetting(
"com.example.sync",
"com.example.sync.theme",
"application",
"application",
"stale-scope",
3,
);
database.setSyncSidecar(
"com.example.sync",
"settings",
"com.example.sync.theme\0application\0application",
"stale-scope",
3,
);
const resets = [];
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot() {
return {
plugins: [{
id: "com.example.sync",
settings: [
{ id: "com.example.sync.theme", secret: false, sync: true, scope: "device" },
],
}],
};
},
async resetSetting(pluginId, settingId, scopeId) {
// Simulates normalizeScopeId against the *current* device scope.
resets.push({ pluginId, settingId, scopeId });
database.deleteSetting(pluginId, settingId, "device", scopeId === "application" ? "device" : scopeId);
},
},
});
await service.applyFromSync({ version: 1, entries: [] });
assert.equal(
database.getSetting("com.example.sync", "com.example.sync.theme", "application", "application"),
undefined,
"stale application-scope row must be deleted by stored-coordinate fallback",
);
});
test("applyFromSync empty remote bundle wipes missing-plugin retained rows", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({ version: 1, entries: [] });
assert.equal(database.listAllSyncSidecars().length, 0);
});
test("applyFromSync does not resurrect missing-plugin keys deleted on another device", async (context) => {
const database = tempDb(context);
// Local still has a setting that remote deleted, plus we keep another remote key.
database.setSyncSidecar(
"com.missing.plugin",
"settings",
"com.missing.plugin.deleted\0application\0application",
"stale",
1,
);
database.setSyncSidecar(
"com.missing.plugin",
"settings",
"com.missing.plugin.kept\0application\0application",
"local-old",
1,
);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.missing.plugin",
kind: "settings",
key: "com.missing.plugin.kept\0application\0application",
value: "remote-new",
updatedAt: 5,
}],
});
const all = database.listAllSyncSidecars();
assert.equal(
all.some((entry) => String(entry.key).includes("deleted")),
false,
"must not resurrect remote-deleted missing-plugin setting",
);
assert.ok(all.some((entry) => entry.value === "remote-new"));
});
test("applyFromSync empty remote bundle preserves local baselines", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.missing.plugin", "settings", "com.missing.plugin.x\0application\0application", "old", 1);
database.setSyncSidecar("com.missing.plugin", "account_baseline", "account", { id: "a1" }, 2);
database.setSyncSidecar("com.missing.plugin", "crdt_baseline", "crdt", { clock: 9 }, 3);
const service = new PluginSyncSidecarService({
database,
contributionService: { snapshot: () => ({ plugins: [] }) },
});
await service.applyFromSync({ version: 1, entries: [] });
const all = database.listAllSyncSidecars();
assert.equal(all.some((entry) => entry.kind === "settings"), false);
assert.equal(all.some((entry) => entry.kind === "account_baseline"), true);
assert.equal(all.some((entry) => entry.kind === "crdt_baseline"), true);
});
test("applyFromSync keeps newer local baseline over older remote (LWW)", async (context) => {
const database = tempDb(context);
database.setSyncSidecar("com.example.plugin", "crdt_baseline", "crdt", { clock: 100 }, 100);
const service = new PluginSyncSidecarService({
database,
contributionService: {
snapshot: () => ({
plugins: [{
id: "com.example.plugin",
settings: [],
}],
}),
},
});
await service.applyFromSync({
version: 1,
entries: [{
pluginId: "com.example.plugin",
kind: "crdt_baseline",
key: "crdt",
value: { clock: 50 },
updatedAt: 50,
}],
});
const row = database.listAllSyncSidecars().find((entry) => entry.kind === "crdt_baseline");
assert.ok(row);
assert.equal(row.updatedAt, 100);
assert.deepEqual(row.value, { clock: 100 });
});