[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
908
infrastructure/services/cloudSync/syncAllStorageMethods.test.ts
Normal file
908
infrastructure/services/cloudSync/syncAllStorageMethods.test.ts
Normal file
@@ -0,0 +1,908 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { EncryptionService } from "../EncryptionService.ts";
|
||||
import {
|
||||
clearProviderMergeStateImpl,
|
||||
commitRemoteInspectionImpl,
|
||||
} from "./authMethods.ts";
|
||||
import {
|
||||
selectConvergentSyncToProviderResult,
|
||||
syncToProviderImpl,
|
||||
uploadToProviderImpl,
|
||||
} from "./providerSyncMethods.ts";
|
||||
import {
|
||||
clearSyncBaseImpl,
|
||||
loadSyncSnapshotsImpl,
|
||||
saveSyncBaseImpl,
|
||||
syncAllProvidersImpl,
|
||||
} from "./syncAllStorageMethods.ts";
|
||||
import type {
|
||||
CloudProvider,
|
||||
SyncedFile,
|
||||
SyncPayload,
|
||||
SyncResult,
|
||||
} from "../../../domain/sync.ts";
|
||||
import { setConvergentSyncLocalConfig } from "../convergentSyncConfig.ts";
|
||||
|
||||
function payload(hostId: string): SyncPayload {
|
||||
return payloadWithHosts([hostId]);
|
||||
}
|
||||
|
||||
function payloadWithHosts(hostIds: string[]): SyncPayload {
|
||||
return {
|
||||
hosts: hostIds.map((hostId) => ({
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
hostname: `${hostId}.example.com`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
})),
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function remoteFile(provider: CloudProvider, version: number, updatedAt: number): SyncedFile {
|
||||
return {
|
||||
meta: {
|
||||
version,
|
||||
updatedAt,
|
||||
deviceId: `${provider}-device`,
|
||||
deviceName: provider,
|
||||
appVersion: "0.0.0",
|
||||
iv: "",
|
||||
salt: "",
|
||||
algorithm: "AES-256-GCM",
|
||||
kdf: "PBKDF2",
|
||||
kdfIterations: 1,
|
||||
},
|
||||
payload: provider,
|
||||
};
|
||||
}
|
||||
|
||||
test("provider identity changes clear v1 base, v2 baseline, and remote anchor together", () => {
|
||||
const removed: string[] = [];
|
||||
const manager = {
|
||||
syncBaseKey: (provider: CloudProvider) => `base:${provider}`,
|
||||
convergentProviderBaselineKey: (provider: CloudProvider) => `convergent:${provider}`,
|
||||
removeFromStorage: (key: string) => removed.push(key),
|
||||
clearSyncAnchor: (provider: CloudProvider) => removed.push(`anchor:${provider}`),
|
||||
};
|
||||
|
||||
clearProviderMergeStateImpl.call(manager, "github");
|
||||
|
||||
assert.deepEqual(removed, ["base:github", "convergent:github", "anchor:github"]);
|
||||
});
|
||||
|
||||
test("clearing all merge bases also removes every convergent provider baseline", () => {
|
||||
const removed = new Set<string>();
|
||||
const providers: CloudProvider[] = ["github", "google", "onedrive", "webdav", "s3"];
|
||||
const manager = {
|
||||
removeFromStorage: (key: string) => removed.add(key),
|
||||
syncBaseKey: (provider?: CloudProvider) => `base:${provider ?? "default"}`,
|
||||
syncSnapshotsKey: (provider?: CloudProvider) => `snapshots:${provider ?? "default"}`,
|
||||
convergentProviderBaselineKey: (provider: CloudProvider) => `convergent:${provider}`,
|
||||
clearSyncAnchor: () => {},
|
||||
};
|
||||
|
||||
clearSyncBaseImpl.call(manager);
|
||||
|
||||
for (const provider of providers) {
|
||||
assert.equal(removed.has(`convergent:${provider}`), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("syncAllProviders uses the newest cloud payload without merging other remotes when cloud wins", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
|
||||
const githubRemote = remoteFile("github", 3, 300);
|
||||
const googleRemote = remoteFile("google", 2, 200);
|
||||
const githubPayload = payload("github-winner");
|
||||
const localPayload = payload("local");
|
||||
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
|
||||
const committed: CloudProvider[] = [];
|
||||
|
||||
EncryptionService.decryptPayload = async (file: SyncedFile) => {
|
||||
if (file === githubRemote) return githubPayload;
|
||||
return payload("google-loser");
|
||||
};
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: true, connected: true, status: "connected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "preferCloud",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async (provider: CloudProvider) => ({
|
||||
conflict: true,
|
||||
remoteFile: provider === "github" ? githubRemote : googleRemote,
|
||||
}),
|
||||
loadSyncBase: async () => payload("base"),
|
||||
commitRemoteInspection: async (provider: CloudProvider) => {
|
||||
committed.push(provider);
|
||||
},
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploaded.push({ provider, payload: outgoing });
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
|
||||
assert.equal(results.get("github")?.action, "download");
|
||||
assert.deepEqual(results.get("github")?.mergedPayload, githubPayload);
|
||||
assert.equal(results.get("github")?.remoteFile, githubRemote);
|
||||
assert.equal(uploaded.length, 1);
|
||||
assert.equal(uploaded[0].provider, "google");
|
||||
assert.equal(uploaded[0].payload.hosts[0]?.id, "github-winner");
|
||||
assert.equal(uploaded[0].payload.syncMeta?.schemaVersion, 1);
|
||||
assert.deepEqual(committed, []);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider uses the checked remote as metadata base when no stored base exists", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const checkedRemote = remoteFile("github", 3, 300);
|
||||
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
|
||||
const localPayload = payload("kept");
|
||||
let uploadedPayload: SyncPayload | undefined;
|
||||
|
||||
EncryptionService.decryptPayload = async (file: SyncedFile) => {
|
||||
assert.equal(file, checkedRemote);
|
||||
return remotePayload;
|
||||
};
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploadedPayload = outgoing;
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
};
|
||||
|
||||
const result = await syncToProviderImpl.call(manager, "github", localPayload);
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "deleted-on-local",
|
||||
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
|
||||
deviceId: "local-device",
|
||||
}]);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider refuses to downgrade a checked convergent remote", async () => {
|
||||
const checkedRemote = remoteFile("github", 3, 300);
|
||||
checkedRemote.meta.syncSchemaVersion = 2;
|
||||
let encrypted = false;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
EncryptionService.encryptPayload = async () => {
|
||||
encrypted = true;
|
||||
return checkedRemote;
|
||||
};
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: { github: { status: "connected" } },
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
|
||||
addSyncHistoryEntry: () => {},
|
||||
};
|
||||
|
||||
const result = await syncToProviderImpl.call(manager, "github", payload("local"));
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(encrypted, false);
|
||||
assert.match(result.error ?? "", /Enable or migrate convergent sync/);
|
||||
} finally {
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider aborts an upload when the master key changes after encryption", async () => {
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const localPayload = payload("local");
|
||||
let generation = 0;
|
||||
let uploaded = false;
|
||||
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => {
|
||||
generation += 1;
|
||||
return {
|
||||
...remoteFile("github", 2, 200),
|
||||
payload: JSON.stringify(outgoing),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "old-master-password",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getSyncSecurityGeneration: () => 0,
|
||||
assertSyncSecurityGeneration: (expected: number) => {
|
||||
if (generation !== expected) {
|
||||
throw new Error("Sync cancelled because master key changed");
|
||||
}
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider) => {
|
||||
uploaded = true;
|
||||
return { success: true, provider, action: "upload" as const, version: 2 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
addSyncHistoryEntry: () => {},
|
||||
};
|
||||
|
||||
const result = await syncToProviderImpl.call(manager, "github", localPayload);
|
||||
|
||||
assert.equal(uploaded, false);
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error ?? "", /master key changed/);
|
||||
} finally {
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("uploadToProvider skips local commits when the master key changes during upload", async () => {
|
||||
let generation = 0;
|
||||
let savedAnchor = false;
|
||||
let savedBase = false;
|
||||
let savedProvider = false;
|
||||
const file = remoteFile("github", 2, 200);
|
||||
|
||||
const manager = {
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "syncing" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "SYNCING",
|
||||
localVersion: 1,
|
||||
localUpdatedAt: 100,
|
||||
remoteVersion: 1,
|
||||
remoteUpdatedAt: 100,
|
||||
deviceName: "Local",
|
||||
},
|
||||
assertSyncSecurityGeneration: (expected: number) => {
|
||||
if (generation !== expected) {
|
||||
throw new Error("Sync cancelled because master key changed");
|
||||
}
|
||||
},
|
||||
saveSyncConfig: () => {},
|
||||
saveSyncBase: async () => {
|
||||
savedBase = true;
|
||||
},
|
||||
saveSyncAnchor: async () => {
|
||||
savedAnchor = true;
|
||||
},
|
||||
saveProviderConnection: async () => {
|
||||
savedProvider = true;
|
||||
},
|
||||
notifyStateChange: () => {},
|
||||
addSyncHistoryEntry: () => {},
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
};
|
||||
|
||||
const adapter = {
|
||||
upload: async () => {
|
||||
generation += 1;
|
||||
return "resource-id";
|
||||
},
|
||||
};
|
||||
|
||||
const result = await uploadToProviderImpl.call(
|
||||
manager,
|
||||
"github",
|
||||
adapter,
|
||||
file,
|
||||
payload("local"),
|
||||
0,
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.match(result.error ?? "", /master key changed/);
|
||||
assert.equal(savedBase, false);
|
||||
assert.equal(savedAnchor, false);
|
||||
assert.equal(savedProvider, false);
|
||||
});
|
||||
|
||||
test("syncAllProviders uses the checked remote as metadata base when provider base is missing", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const checkedRemote = remoteFile("github", 3, 300);
|
||||
const remotePayload = payloadWithHosts(["kept", "deleted-on-local"]);
|
||||
const localPayload = payload("kept");
|
||||
let uploadedPayload: SyncPayload | undefined;
|
||||
|
||||
EncryptionService.decryptPayload = async (file: SyncedFile) => {
|
||||
assert.equal(file, checkedRemote);
|
||||
return remotePayload;
|
||||
};
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: false, connected: false, status: "disconnected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async () => ({ provider: "github" }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploadedPayload = outgoing;
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
|
||||
assert.equal(results.get("github")?.success, true);
|
||||
assert.deepEqual(uploadedPayload?.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "deleted-on-local",
|
||||
deletedAt: uploadedPayload?.syncMeta?.generatedAt,
|
||||
deviceId: "local-device",
|
||||
}]);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("commitRemoteInspection saves the comparison base before advancing the remote anchor", async () => {
|
||||
const calls: string[] = [];
|
||||
const file = remoteFile("github", 5, 500);
|
||||
const incoming = payload("cloud");
|
||||
const manager = {
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
providers: {
|
||||
github: { resourceId: "old", lastSync: 0, lastSyncVersion: 0 },
|
||||
},
|
||||
localVersion: 0,
|
||||
localUpdatedAt: 0,
|
||||
remoteVersion: 0,
|
||||
remoteUpdatedAt: 0,
|
||||
},
|
||||
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
|
||||
saveSyncConfig: () => calls.push("config"),
|
||||
saveSyncBase: async () => calls.push("base"),
|
||||
saveSyncAnchor: async () => calls.push("anchor"),
|
||||
saveProviderConnection: async () => calls.push("connection"),
|
||||
addSyncHistoryEntry: () => calls.push("history"),
|
||||
notifyStateChange: () => calls.push("notify"),
|
||||
};
|
||||
|
||||
await commitRemoteInspectionImpl.call(manager, "github", file, incoming, {
|
||||
recordDownload: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, ["base", "config", "anchor", "connection", "history", "notify"]);
|
||||
});
|
||||
|
||||
test("commitRemoteInspection does not advance the remote anchor when saving the base fails", async () => {
|
||||
const calls: string[] = [];
|
||||
const manager = {
|
||||
providerDecryptSeq: { github: 0 },
|
||||
state: {
|
||||
providers: {
|
||||
github: { resourceId: "remote-resource", lastSync: 0, lastSyncVersion: 0 },
|
||||
},
|
||||
localVersion: 0,
|
||||
localUpdatedAt: 0,
|
||||
remoteVersion: 0,
|
||||
remoteUpdatedAt: 0,
|
||||
},
|
||||
getConnectedAdapter: async () => ({ resourceId: "remote-resource" }),
|
||||
saveSyncConfig: () => calls.push("config"),
|
||||
saveSyncBase: async () => {
|
||||
calls.push("base");
|
||||
throw new Error("base failed");
|
||||
},
|
||||
saveSyncAnchor: async () => calls.push("anchor"),
|
||||
saveProviderConnection: async () => calls.push("connection"),
|
||||
addSyncHistoryEntry: () => calls.push("history"),
|
||||
notifyStateChange: () => calls.push("notify"),
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => commitRemoteInspectionImpl.call(manager, "github", remoteFile("github", 5, 500), payload("cloud")),
|
||||
/base failed/,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["base"]);
|
||||
});
|
||||
|
||||
test("saveSyncBase reports storage failures so callers do not advance anchors", async () => {
|
||||
const originalWarn = console.warn;
|
||||
const manager = {
|
||||
state: {
|
||||
unlockedKey: {
|
||||
derivedKey: await crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
),
|
||||
},
|
||||
},
|
||||
syncBaseKey: () => "sync-base",
|
||||
saveToStorage: () => {
|
||||
throw new Error("storage full");
|
||||
},
|
||||
};
|
||||
|
||||
console.warn = () => {};
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
|
||||
/storage full/,
|
||||
);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
});
|
||||
|
||||
test("saveSyncBase reports a missing local encryption key", async () => {
|
||||
const manager = {
|
||||
state: { unlockedKey: null },
|
||||
syncBaseKey: () => "sync-base",
|
||||
saveToStorage: () => {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => saveSyncBaseImpl.call(manager, payload("cloud"), "github"),
|
||||
/Sync base encryption key is unavailable/,
|
||||
);
|
||||
});
|
||||
|
||||
test("saveSyncBase keeps a bounded encrypted snapshot history before replacing the base", async () => {
|
||||
const stored = new Map<string, string>();
|
||||
const key = await crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
true,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
const manager = {
|
||||
state: { unlockedKey: { derivedKey: key } },
|
||||
syncBaseKey: (provider?: CloudProvider) => `base-${provider ?? "default"}`,
|
||||
syncSnapshotsKey: (provider?: CloudProvider) => `snapshots-${provider ?? "default"}`,
|
||||
saveToStorage: (storageKey: string, value: string) => stored.set(storageKey, value),
|
||||
loadFromStorage: (storageKey: string) => stored.get(storageKey),
|
||||
};
|
||||
|
||||
await saveSyncBaseImpl.call(manager, payload("base-0"), "github");
|
||||
for (let i = 1; i <= 7; i += 1) {
|
||||
await saveSyncBaseImpl.call(manager, payload(`base-${i}`), "github");
|
||||
}
|
||||
|
||||
const snapshots = await loadSyncSnapshotsImpl.call(manager, "github");
|
||||
|
||||
assert.equal(snapshots.length, 5);
|
||||
assert.deepEqual(
|
||||
snapshots.map((snapshot) => snapshot.payload.hosts[0]?.id),
|
||||
["base-6", "base-5", "base-4", "base-3", "base-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("syncAllProviders builds provider-specific sync metadata from each provider base", async () => {
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
|
||||
const baseByProvider = {
|
||||
github: payload("shared"),
|
||||
google: payload("deleted-on-local"),
|
||||
} as Partial<Record<CloudProvider, SyncPayload>>;
|
||||
const localPayload = payload("shared");
|
||||
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 4, 400),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: true, connected: true, status: "connected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: false, remoteFile: null }),
|
||||
loadSyncBase: async (provider: CloudProvider) => baseByProvider[provider] ?? null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploaded.push({ provider, payload: outgoing });
|
||||
return { success: true, provider, action: "upload" as const, version: 4 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
await syncAllProvidersImpl.call(manager, localPayload);
|
||||
|
||||
assert.equal(uploaded.length, 2);
|
||||
assert.deepEqual(uploaded.find((entry) => entry.provider === "github")?.payload.syncMeta?.deletions, []);
|
||||
assert.deepEqual(uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.deletions, [{
|
||||
entityType: "hosts",
|
||||
id: "deleted-on-local",
|
||||
deletedAt: uploaded.find((entry) => entry.provider === "google")?.payload.syncMeta?.generatedAt,
|
||||
deviceId: "local-device",
|
||||
}]);
|
||||
} finally {
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("syncAllProviders upload-local override overwrites remote without decrypting when password differs", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const checkedRemote = remoteFile("github", 5, 500);
|
||||
const localPayload = payload("local-after-reinstall");
|
||||
const uploaded: Array<{ provider: CloudProvider; payload: SyncPayload }> = [];
|
||||
const encryptBaseVersions: number[] = [];
|
||||
let decryptCalls = 0;
|
||||
|
||||
EncryptionService.decryptPayload = async () => {
|
||||
decryptCalls += 1;
|
||||
throw new Error("OperationError: unable to authenticate data");
|
||||
};
|
||||
EncryptionService.encryptPayload = async (
|
||||
outgoing: SyncPayload,
|
||||
_password: string,
|
||||
_deviceId: string,
|
||||
_deviceName: string,
|
||||
_appVersion: string,
|
||||
existingVersion?: number,
|
||||
) => {
|
||||
encryptBaseVersions.push(existingVersion ?? 0);
|
||||
return {
|
||||
...remoteFile("github", (existingVersion ?? 0) + 1, 600),
|
||||
payload: JSON.stringify(outgoing),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "new-master-password",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: false, connected: false, status: "disconnected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: true, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => null,
|
||||
uploadToProvider: async (provider: CloudProvider, _adapter: unknown, _file: SyncedFile, outgoing: SyncPayload) => {
|
||||
uploaded.push({ provider, payload: outgoing });
|
||||
return { success: true, provider, action: "upload" as const, version: 6 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const conflicted = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
assert.equal(conflicted.get("github")?.success, false);
|
||||
assert.equal(conflicted.get("github")?.conflictDetected, true);
|
||||
assert.equal(conflicted.get("github")?.error, undefined);
|
||||
assert.equal(uploaded.length, 0);
|
||||
|
||||
const forced = await syncAllProvidersImpl.call(manager, localPayload, {
|
||||
conflictActionOverride: "upload-local",
|
||||
overrideShrink: true,
|
||||
});
|
||||
assert.equal(forced.get("github")?.success, true);
|
||||
assert.equal(forced.get("github")?.action, "upload");
|
||||
assert.equal(uploaded.length, 1);
|
||||
assert.equal(uploaded[0]?.payload.hosts[0]?.id, "local-after-reinstall");
|
||||
// Keep-local under smartMerge must base on the conflicting remote version
|
||||
// (v5 → encrypted as v6), matching single-provider upload-local.
|
||||
assert.deepEqual(encryptBaseVersions, [5]);
|
||||
// Shrink-guard may attempt decrypt and ignore failure; merge path must not run.
|
||||
assert.ok(decryptCalls >= 1);
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
|
||||
test("an initialized but paused v2 replica cannot fall through to legacy provider writes", async () => {
|
||||
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
const values = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
},
|
||||
});
|
||||
try {
|
||||
setConvergentSyncLocalConfig({ enabled: false, initialized: true });
|
||||
let adapterRequested = false;
|
||||
const manager = {
|
||||
state: {
|
||||
providers: {
|
||||
github: { provider: "github", status: "connected" },
|
||||
},
|
||||
},
|
||||
getConnectedAdapter: async () => {
|
||||
adapterRequested = true;
|
||||
throw new Error("legacy path must not run");
|
||||
},
|
||||
};
|
||||
|
||||
const all = await syncAllProvidersImpl.call(manager, payload("local"));
|
||||
const one = await syncToProviderImpl.call(manager, "github", payload("local"));
|
||||
|
||||
assert.equal(all.get("github")?.success, false);
|
||||
assert.match(all.get("github")?.error ?? "", /paused/i);
|
||||
assert.equal(one.success, false);
|
||||
assert.match(one.error ?? "", /paused/i);
|
||||
assert.equal(adapterRequested, false);
|
||||
} finally {
|
||||
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
});
|
||||
|
||||
test("syncToProvider preserves a merged payload discovered by a non-target provider", () => {
|
||||
const mergedPayload = payload("remote-merged");
|
||||
const results = new Map<CloudProvider, SyncResult>([
|
||||
["github", {
|
||||
success: false,
|
||||
provider: "github",
|
||||
action: "none",
|
||||
error: "github unavailable",
|
||||
}],
|
||||
["google", {
|
||||
success: true,
|
||||
provider: "google",
|
||||
action: "merge",
|
||||
mergedPayload,
|
||||
}],
|
||||
]);
|
||||
|
||||
const selected = selectConvergentSyncToProviderResult("github", results);
|
||||
|
||||
assert.equal(selected.success, false);
|
||||
assert.equal(selected.provider, "github");
|
||||
assert.equal(selected.error, "github unavailable");
|
||||
assert.equal(selected.mergedPayload, mergedPayload);
|
||||
assert.equal(selected.remoteFile, undefined);
|
||||
});
|
||||
|
||||
test("syncAllProviders smart-merge strips device-bound enc:v1 secrets before upload", async () => {
|
||||
const originalDecryptPayload = EncryptionService.decryptPayload;
|
||||
const originalEncryptPayload = EncryptionService.encryptPayload;
|
||||
const completeBlob = Buffer.alloc(31, 0);
|
||||
Buffer.from("v10", "utf8").copy(completeBlob, 0);
|
||||
const ENC = `enc:v1:${completeBlob.toString("base64")}`;
|
||||
const checkedRemote = remoteFile("github", 5, 500);
|
||||
const localPayload = {
|
||||
...payload("shared"),
|
||||
hosts: [{
|
||||
...payload("shared").hosts[0]!,
|
||||
password: "kept-secret",
|
||||
}],
|
||||
};
|
||||
const remotePoisoned: SyncPayload = {
|
||||
...payload("shared"),
|
||||
hosts: [
|
||||
{
|
||||
...payload("shared").hosts[0]!,
|
||||
label: "remote-label",
|
||||
password: ENC,
|
||||
},
|
||||
],
|
||||
};
|
||||
const uploaded: SyncPayload[] = [];
|
||||
|
||||
EncryptionService.decryptPayload = async () => remotePoisoned;
|
||||
EncryptionService.encryptPayload = async (outgoing: SyncPayload) => ({
|
||||
...remoteFile("github", 6, 600),
|
||||
payload: JSON.stringify(outgoing),
|
||||
});
|
||||
|
||||
try {
|
||||
const manager = {
|
||||
masterPassword: "pw",
|
||||
adapters: new Map(),
|
||||
state: {
|
||||
securityState: "UNLOCKED",
|
||||
providers: {
|
||||
github: { enabled: true, connected: true, status: "connected" },
|
||||
google: { enabled: false, connected: false, status: "disconnected" },
|
||||
onedrive: { enabled: false, connected: false, status: "disconnected" },
|
||||
webdav: { enabled: false, connected: false, status: "disconnected" },
|
||||
s3: { enabled: false, connected: false, status: "disconnected" },
|
||||
},
|
||||
lastError: null,
|
||||
syncState: "IDLE",
|
||||
syncStrategy: "smartMerge",
|
||||
localVersion: 1,
|
||||
deviceId: "local-device",
|
||||
deviceName: "Local",
|
||||
},
|
||||
getConnectedAdapter: async (provider: CloudProvider) => ({ provider }),
|
||||
updateProviderStatus: () => {},
|
||||
emit: () => {},
|
||||
checkProviderConflict: async () => ({ conflict: true, remoteFile: checkedRemote }),
|
||||
loadSyncBase: async () => ({
|
||||
...payload("shared"),
|
||||
hosts: [{
|
||||
...payload("shared").hosts[0]!,
|
||||
password: "kept-secret",
|
||||
}],
|
||||
}),
|
||||
uploadToProvider: async (
|
||||
_provider: CloudProvider,
|
||||
_adapter: unknown,
|
||||
_file: SyncedFile,
|
||||
outgoing: SyncPayload,
|
||||
) => {
|
||||
uploaded.push(outgoing);
|
||||
return { success: true, provider: "github" as const, action: "upload" as const, version: 6 };
|
||||
},
|
||||
exitBlockedState: () => {},
|
||||
notifyStateChange: () => {},
|
||||
};
|
||||
|
||||
const results = await syncAllProvidersImpl.call(manager, localPayload);
|
||||
assert.equal(results.get("github")?.success, true);
|
||||
assert.equal(uploaded.length, 1);
|
||||
const sharedHost = uploaded[0]?.hosts.find((host) => host.id === "shared");
|
||||
assert.ok(sharedHost);
|
||||
// Local/base usable secret must survive; remote non-secret edits can still apply.
|
||||
assert.equal(sharedHost?.password, "kept-secret");
|
||||
assert.equal(sharedHost?.label, "remote-label");
|
||||
} finally {
|
||||
EncryptionService.decryptPayload = originalDecryptPayload;
|
||||
EncryptionService.encryptPayload = originalEncryptPayload;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user