mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-22 20:31:03 +02:00
578 lines
19 KiB
JavaScript
578 lines
19 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { appFromEnvironment } from "../lib/app.mjs";
|
|
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
|
|
|
const syncUrl = process.env.DONUT_E2E_SYNC_URL;
|
|
const syncToken = process.env.DONUT_E2E_SYNC_TOKEN;
|
|
|
|
async function syncRequest(endpoint, body) {
|
|
const response = await fetch(`${syncUrl}/v1/objects/${endpoint}`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${syncToken}`,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const text = await response.text();
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Sync ${endpoint} failed with HTTP ${response.status}: ${text}`,
|
|
);
|
|
}
|
|
return text ? JSON.parse(text) : null;
|
|
}
|
|
|
|
async function listRemote(prefix = "") {
|
|
const result = await syncRequest("list", {
|
|
prefix,
|
|
maxKeys: 1000,
|
|
continuationToken: null,
|
|
});
|
|
return result.objects;
|
|
}
|
|
|
|
async function downloadRemote(key) {
|
|
const presigned = await syncRequest("presign-download", {
|
|
key,
|
|
expiresIn: 300,
|
|
});
|
|
const response = await fetch(presigned.url);
|
|
assert.equal(response.status, 200, `Could not download remote object ${key}`);
|
|
return Buffer.from(await response.arrayBuffer());
|
|
}
|
|
|
|
async function configureSync(app) {
|
|
const saved = await app.invoke("save_sync_settings", {
|
|
syncServerUrl: syncUrl,
|
|
syncToken,
|
|
});
|
|
assert.equal(saved.sync_server_url, syncUrl);
|
|
assert.equal(saved.sync_token, syncToken);
|
|
assert.deepEqual(await app.invoke("get_sync_settings"), saved);
|
|
await app.invoke("restart_sync_service");
|
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
}
|
|
|
|
async function createProfile(app, name) {
|
|
return app.invoke("create_browser_profile_new", {
|
|
name,
|
|
browserStr: "wayfern",
|
|
version: "150.0.7871.100",
|
|
releaseType: "stable",
|
|
proxyId: null,
|
|
vpnId: null,
|
|
// Keep sync tests deterministic and network-free; browser.test.mjs covers
|
|
// generation through the real Wayfern binary.
|
|
wayfernConfig: { fingerprint: "{}" },
|
|
groupId: null,
|
|
ephemeral: false,
|
|
dnsBlocklist: null,
|
|
launchHook: null,
|
|
});
|
|
}
|
|
|
|
async function waitFor(app, callback, description, timeoutMs = 45_000) {
|
|
return app.waitFor(callback, { description, timeoutMs, intervalMs: 250 });
|
|
}
|
|
|
|
test("two real app devices reconcile profile files and every config entity with last-write-wins", async () => {
|
|
assert.ok(syncUrl && syncToken, "Sync infrastructure was not started");
|
|
const deviceA = appFromEnvironment("sync-regular-a");
|
|
const deviceB = appFromEnvironment("sync-regular-b");
|
|
try {
|
|
await Promise.all([deviceA.start(), deviceB.start()]);
|
|
await Promise.all([configureSync(deviceA), configureSync(deviceB)]);
|
|
|
|
const group = await deviceA.invoke("create_profile_group", {
|
|
name: "Synced Group A",
|
|
});
|
|
const proxy = await deviceA.invoke("create_stored_proxy", {
|
|
name: "Synced Proxy A",
|
|
proxySettings: {
|
|
proxy_type: "http",
|
|
host: "127.0.0.1",
|
|
port: 8089,
|
|
username: null,
|
|
password: null,
|
|
},
|
|
});
|
|
const vpn = await deviceA.invoke("create_vpn_config_manual", {
|
|
name: "Synced VPN A",
|
|
vpnType: "WireGuard",
|
|
configData: wireGuardFixture(),
|
|
});
|
|
const extension = await deviceA.invoke("add_extension", {
|
|
name: "Synced Extension A",
|
|
fileName: "synced-fixture.zip",
|
|
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
|
});
|
|
const extensionGroup = await deviceA.invoke("create_extension_group", {
|
|
name: "Synced Extension Group A",
|
|
});
|
|
await deviceA.invoke("add_extension_to_group", {
|
|
groupId: extensionGroup.id,
|
|
extensionId: extension.id,
|
|
});
|
|
|
|
await Promise.all([
|
|
deviceA.invoke("set_group_sync_enabled", {
|
|
groupId: group.id,
|
|
enabled: true,
|
|
}),
|
|
deviceA.invoke("set_proxy_sync_enabled", {
|
|
proxyId: proxy.id,
|
|
enabled: true,
|
|
}),
|
|
deviceA.invoke("set_vpn_sync_enabled", { vpnId: vpn.id, enabled: true }),
|
|
deviceA.invoke("set_extension_sync_enabled", {
|
|
extensionId: extension.id,
|
|
enabled: true,
|
|
}),
|
|
deviceA.invoke("set_extension_group_sync_enabled", {
|
|
extensionGroupId: extensionGroup.id,
|
|
enabled: true,
|
|
}),
|
|
]);
|
|
|
|
const profile = await createProfile(deviceA, "Synced Profile A");
|
|
const profileData = path.join(
|
|
deviceA.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
profile.id,
|
|
"profile",
|
|
"Default",
|
|
);
|
|
await mkdir(profileData, { recursive: true });
|
|
await writeFile(
|
|
path.join(profileData, "Preferences"),
|
|
JSON.stringify({ donutE2E: "regular-profile-payload" }),
|
|
);
|
|
await deviceA.invoke("update_profile_tags", {
|
|
profileId: profile.id,
|
|
tags: ["sync", "device-a"],
|
|
});
|
|
await deviceA.invoke("update_profile_note", {
|
|
profileId: profile.id,
|
|
note: "regular sync metadata",
|
|
});
|
|
await deviceA.invoke("set_profile_sync_mode", {
|
|
profileId: profile.id,
|
|
syncMode: "Regular",
|
|
});
|
|
await deviceA.invoke("request_profile_sync", { profileId: profile.id });
|
|
assert.equal(
|
|
await deviceA.invoke("cancel_profile_sync", {
|
|
profileId: "not-running-sync",
|
|
}),
|
|
false,
|
|
);
|
|
|
|
await waitFor(
|
|
deviceA,
|
|
async () => {
|
|
const keys = (await listRemote("")).map((object) => object.key);
|
|
return [
|
|
`groups/${group.id}.json`,
|
|
`proxies/${proxy.id}.json`,
|
|
`vpns/${vpn.id}.json`,
|
|
`extensions/${extension.id}.json`,
|
|
`extension_groups/${extensionGroup.id}.json`,
|
|
`profiles/${profile.id}/manifest.json`,
|
|
`profiles/${profile.id}/files/profile/Default/Preferences`,
|
|
].every((key) => keys.includes(key));
|
|
},
|
|
"all regular entities uploaded",
|
|
);
|
|
|
|
await deviceB.invoke("restart_sync_service");
|
|
await waitFor(
|
|
deviceB,
|
|
async () => {
|
|
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
|
await Promise.all([
|
|
deviceB.invoke("list_browser_profiles"),
|
|
deviceB.invoke("get_profile_groups"),
|
|
deviceB.invoke("get_stored_proxies"),
|
|
deviceB.invoke("list_vpn_configs"),
|
|
deviceB.invoke("list_extensions"),
|
|
deviceB.invoke("list_extension_groups"),
|
|
]);
|
|
return (
|
|
profiles.some((item) => item.id === profile.id) &&
|
|
groups.some((item) => item.id === group.id) &&
|
|
proxies.some((item) => item.id === proxy.id) &&
|
|
vpns.some((item) => item.id === vpn.id) &&
|
|
extensions.some((item) => item.id === extension.id) &&
|
|
extensionGroups.some((item) => item.id === extensionGroup.id)
|
|
);
|
|
},
|
|
"device B receives every entity",
|
|
);
|
|
const downloadedPreferences = path.join(
|
|
deviceB.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
profile.id,
|
|
"profile",
|
|
"Default",
|
|
"Preferences",
|
|
);
|
|
await waitFor(
|
|
deviceB,
|
|
async () =>
|
|
(
|
|
await readFile(downloadedPreferences, "utf8").catch(() => "")
|
|
).includes("regular-profile-payload"),
|
|
"device B receives profile browser files",
|
|
);
|
|
|
|
// updated_at has one-second resolution. Make the device-B edits
|
|
// unambiguously newer, then verify last-write-wins in both directions.
|
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
|
await deviceB.invoke("update_stored_proxy", {
|
|
proxyId: proxy.id,
|
|
name: "Synced Proxy B Wins",
|
|
proxySettings: null,
|
|
});
|
|
await deviceB.invoke("rename_profile", {
|
|
profileId: profile.id,
|
|
newName: "Synced Profile B Wins",
|
|
});
|
|
await deviceB.invoke("request_profile_sync", { profileId: profile.id });
|
|
await deviceA.invoke("restart_sync_service");
|
|
await waitFor(
|
|
deviceA,
|
|
async () => {
|
|
const proxies = await deviceA.invoke("get_stored_proxies");
|
|
const profiles = await deviceA.invoke("list_browser_profiles");
|
|
return (
|
|
proxies.find((item) => item.id === proxy.id)?.name ===
|
|
"Synced Proxy B Wins" &&
|
|
profiles.find((item) => item.id === profile.id)?.name ===
|
|
"Synced Profile B Wins"
|
|
);
|
|
},
|
|
"newer device-B edits win on device A",
|
|
);
|
|
|
|
assert.equal(
|
|
await deviceA.invoke("is_proxy_in_use_by_synced_profile", {
|
|
proxyId: proxy.id,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
await deviceA.invoke("is_group_in_use_by_synced_profile", {
|
|
groupId: group.id,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
await deviceA.invoke("is_vpn_in_use_by_synced_profile", {
|
|
vpnId: vpn.id,
|
|
}),
|
|
false,
|
|
);
|
|
const counts = await deviceA.invoke("get_unsynced_entity_counts");
|
|
assert.equal(typeof counts.proxies, "number");
|
|
await deviceA.invoke("enable_sync_for_all_entities");
|
|
|
|
await Promise.all([
|
|
deviceB.invoke("delete_extension_group", {
|
|
groupId: extensionGroup.id,
|
|
}),
|
|
deviceB.invoke("delete_extension", { extensionId: extension.id }),
|
|
deviceB.invoke("delete_vpn_config", { vpnId: vpn.id }),
|
|
deviceB.invoke("delete_profile_group", { groupId: group.id }),
|
|
deviceB.invoke("delete_stored_proxy", { proxyId: proxy.id }),
|
|
deviceB.invoke("delete_profile", { profileId: profile.id }),
|
|
]);
|
|
await waitFor(
|
|
deviceB,
|
|
async () => {
|
|
const keys = (await listRemote("")).map((object) => object.key);
|
|
return [
|
|
`tombstones/groups/${group.id}.json`,
|
|
`tombstones/proxies/${proxy.id}.json`,
|
|
`tombstones/vpns/${vpn.id}.json`,
|
|
`tombstones/extensions/${extension.id}.json`,
|
|
`tombstones/extension_groups/${extensionGroup.id}.json`,
|
|
`tombstones/profiles/${profile.id}.json`,
|
|
].every((key) => keys.includes(key));
|
|
},
|
|
"deletions create every remote tombstone",
|
|
);
|
|
await waitFor(
|
|
deviceA,
|
|
async () => {
|
|
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
|
await Promise.all([
|
|
deviceA.invoke("list_browser_profiles"),
|
|
deviceA.invoke("get_profile_groups"),
|
|
deviceA.invoke("get_stored_proxies"),
|
|
deviceA.invoke("list_vpn_configs"),
|
|
deviceA.invoke("list_extensions"),
|
|
deviceA.invoke("list_extension_groups"),
|
|
]);
|
|
return (
|
|
!profiles.some((item) => item.id === profile.id) &&
|
|
!groups.some((item) => item.id === group.id) &&
|
|
!proxies.some((item) => item.id === proxy.id) &&
|
|
!vpns.some((item) => item.id === vpn.id) &&
|
|
!extensions.some((item) => item.id === extension.id) &&
|
|
!extensionGroups.some((item) => item.id === extensionGroup.id)
|
|
);
|
|
},
|
|
"remote tombstones delete every entity from device A",
|
|
);
|
|
} catch (error) {
|
|
await Promise.all([deviceA.capture("failure"), deviceB.capture("failure")]);
|
|
throw error;
|
|
} finally {
|
|
await Promise.all([deviceA.close(), deviceB.close()]);
|
|
}
|
|
});
|
|
|
|
test("global config sealing and encrypted profile sync reject a wrong password, round-trip with the right one, and roll over", async () => {
|
|
const source = appFromEnvironment("sync-encrypted-source");
|
|
const receiver = appFromEnvironment("sync-encrypted-receiver");
|
|
const rolloverReceiver = appFromEnvironment(
|
|
"sync-encrypted-rollover-receiver",
|
|
);
|
|
try {
|
|
await Promise.all([source.start(), receiver.start()]);
|
|
await Promise.all([configureSync(source), configureSync(receiver)]);
|
|
await source.invoke("set_e2e_password", {
|
|
password: "shared encryption password",
|
|
});
|
|
await receiver.invoke("set_e2e_password", {
|
|
password: "intentionally wrong password",
|
|
});
|
|
assert.equal(await source.invoke("check_has_e2e_password"), true);
|
|
assert.equal(
|
|
await source.invoke("verify_e2e_password", {
|
|
password: "shared encryption password",
|
|
}),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
await source.invoke("verify_e2e_password", { password: "wrong" }),
|
|
false,
|
|
);
|
|
|
|
const sealedProxy = await source.invoke("create_stored_proxy", {
|
|
name: "SECRET-CONFIG-MARKER",
|
|
proxySettings: {
|
|
proxy_type: "http",
|
|
host: "secret-proxy.invalid",
|
|
port: 8443,
|
|
username: "secret-user",
|
|
password: "secret-password",
|
|
},
|
|
});
|
|
await source.invoke("set_proxy_sync_enabled", {
|
|
proxyId: sealedProxy.id,
|
|
enabled: true,
|
|
});
|
|
|
|
const encryptedProfile = await createProfile(source, "Encrypted Profile");
|
|
const encryptedData = path.join(
|
|
source.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
encryptedProfile.id,
|
|
"profile",
|
|
);
|
|
await mkdir(encryptedData, { recursive: true });
|
|
await writeFile(
|
|
path.join(encryptedData, "Local State"),
|
|
"SECRET-PROFILE-MARKER that must never appear remotely",
|
|
);
|
|
await source.invoke("set_profile_sync_mode", {
|
|
profileId: encryptedProfile.id,
|
|
syncMode: "Encrypted",
|
|
});
|
|
await source.invoke("request_profile_sync", {
|
|
profileId: encryptedProfile.id,
|
|
});
|
|
|
|
const proxyKey = `proxies/${sealedProxy.id}.json`;
|
|
const profileMetadataKey = `profiles/${encryptedProfile.id}/metadata.json`;
|
|
const profileFileKey = `profiles/${encryptedProfile.id}/files/profile/Local State`;
|
|
await waitFor(
|
|
source,
|
|
async () => {
|
|
const keys = (await listRemote("")).map((object) => object.key);
|
|
return (
|
|
keys.includes(proxyKey) &&
|
|
keys.includes(profileMetadataKey) &&
|
|
keys.includes(profileFileKey)
|
|
);
|
|
},
|
|
"sealed config and encrypted profile uploaded",
|
|
);
|
|
const sealedBefore = await downloadRemote(proxyKey);
|
|
const metadataBefore = await downloadRemote(profileMetadataKey);
|
|
const encryptedFile = await downloadRemote(profileFileKey);
|
|
assert.equal(
|
|
sealedBefore.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
|
false,
|
|
);
|
|
assert.equal(sealedBefore.includes(Buffer.from("secret-password")), false);
|
|
assert.equal(
|
|
encryptedFile.includes(Buffer.from("SECRET-PROFILE-MARKER")),
|
|
false,
|
|
);
|
|
const envelope = JSON.parse(sealedBefore.toString("utf8"));
|
|
assert.equal(envelope.v, 1);
|
|
assert.ok(envelope.salt && envelope.ct);
|
|
|
|
await receiver.invoke("restart_sync_service");
|
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
|
assert.equal(
|
|
(await receiver.invoke("get_stored_proxies")).some(
|
|
(item) => item.id === sealedProxy.id,
|
|
),
|
|
false,
|
|
"wrong password must not materialize sealed config",
|
|
);
|
|
assert.equal(
|
|
(await receiver.invoke("list_browser_profiles")).some(
|
|
(item) => item.id === encryptedProfile.id,
|
|
),
|
|
false,
|
|
"wrong password must not materialize encrypted profiles",
|
|
);
|
|
|
|
await receiver.invoke("set_e2e_password", {
|
|
password: "shared encryption password",
|
|
});
|
|
await receiver.invoke("restart_sync_service");
|
|
await waitFor(
|
|
receiver,
|
|
async () =>
|
|
(await receiver.invoke("get_stored_proxies")).some(
|
|
(item) =>
|
|
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
|
) &&
|
|
(await receiver.invoke("list_browser_profiles")).some(
|
|
(item) => item.id === encryptedProfile.id,
|
|
),
|
|
"correct password decrypts config and profile metadata",
|
|
);
|
|
const receiverFile = path.join(
|
|
receiver.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
encryptedProfile.id,
|
|
"profile",
|
|
"Local State",
|
|
);
|
|
await waitFor(
|
|
receiver,
|
|
async () =>
|
|
(await readFile(receiverFile, "utf8").catch(() => "")).includes(
|
|
"SECRET-PROFILE-MARKER",
|
|
),
|
|
"correct password decrypts profile browser file",
|
|
);
|
|
|
|
await source.invoke("set_e2e_password", {
|
|
password: "rolled encryption password",
|
|
});
|
|
await source.invoke("rollover_encryption_for_all_entities");
|
|
await waitFor(
|
|
source,
|
|
async () => {
|
|
const [proxy, metadata] = await Promise.all([
|
|
downloadRemote(proxyKey),
|
|
downloadRemote(profileMetadataKey),
|
|
]);
|
|
return !proxy.equals(sealedBefore) && !metadata.equals(metadataBefore);
|
|
},
|
|
"password rollover rewrites sealed config and profile metadata",
|
|
);
|
|
const sealedAfter = await downloadRemote(proxyKey);
|
|
assert.equal(
|
|
sealedAfter.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
|
false,
|
|
);
|
|
await receiver.invoke("set_e2e_password", {
|
|
password: "rolled encryption password",
|
|
});
|
|
await receiver.invoke("restart_sync_service");
|
|
await waitFor(
|
|
receiver,
|
|
async () =>
|
|
(await receiver.invoke("get_stored_proxies")).some(
|
|
(item) =>
|
|
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
|
),
|
|
"receiver accepts rolled password",
|
|
);
|
|
|
|
await rolloverReceiver.start();
|
|
await rolloverReceiver.invoke("set_e2e_password", {
|
|
password: "rolled encryption password",
|
|
});
|
|
await configureSync(rolloverReceiver);
|
|
await waitFor(
|
|
rolloverReceiver,
|
|
async () =>
|
|
(await rolloverReceiver.invoke("get_stored_proxies")).some(
|
|
(item) =>
|
|
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
|
) &&
|
|
(await rolloverReceiver.invoke("list_browser_profiles")).some(
|
|
(item) => item.id === encryptedProfile.id,
|
|
),
|
|
"fresh receiver decrypts rolled config and profile metadata",
|
|
);
|
|
const rolloverFile = path.join(
|
|
rolloverReceiver.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
encryptedProfile.id,
|
|
"profile",
|
|
"Local State",
|
|
);
|
|
await waitFor(
|
|
rolloverReceiver,
|
|
async () =>
|
|
(await readFile(rolloverFile, "utf8").catch(() => "")).includes(
|
|
"SECRET-PROFILE-MARKER",
|
|
),
|
|
"fresh receiver decrypts rolled profile browser file",
|
|
);
|
|
|
|
await source.invoke("set_profile_sync_mode", {
|
|
profileId: encryptedProfile.id,
|
|
syncMode: "Disabled",
|
|
});
|
|
await source.invoke("delete_e2e_password");
|
|
assert.equal(await source.invoke("check_has_e2e_password"), false);
|
|
const missingPassword = await source.invokeError("verify_e2e_password", {
|
|
password: "rolled encryption password",
|
|
});
|
|
assert.match(missingPassword, /NO_E2E_PASSWORD_SET/);
|
|
} catch (error) {
|
|
await Promise.all([
|
|
source.capture("failure"),
|
|
receiver.capture("failure"),
|
|
rolloverReceiver.capture("failure"),
|
|
]);
|
|
throw error;
|
|
} finally {
|
|
await Promise.all([
|
|
source.close(),
|
|
receiver.close(),
|
|
rolloverReceiver.close(),
|
|
]);
|
|
}
|
|
});
|