mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-24 21:30:58 +02:00
chore: add cross-platform webdriver tests
This commit is contained in:
@@ -0,0 +1,577 @@
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user