mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-19 22:17:28 +02:00
2484 lines
85 KiB
JavaScript
2484 lines
85 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { existsSync } from "node:fs";
|
|
import {
|
|
mkdir,
|
|
open,
|
|
readdir,
|
|
readFile,
|
|
realpath,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import test from "node:test";
|
|
import { withApp } from "../lib/app.mjs";
|
|
import {
|
|
CRX_EXTENSION_NAME,
|
|
CRX_EXTENSION_VERSION,
|
|
extensionIconPngBase64,
|
|
extensionZipBase64,
|
|
wireGuardFixture,
|
|
writeChromiumCookies,
|
|
writeChromiumHistory,
|
|
writeUnpackedExtension,
|
|
} from "../lib/fixtures.mjs";
|
|
|
|
async function createProfile(app, name = "Entity Profile") {
|
|
return app.invoke("create_browser_profile_new", {
|
|
name,
|
|
browserStr: "wayfern",
|
|
version: "150.0.7871.100",
|
|
releaseType: "stable",
|
|
proxyId: null,
|
|
vpnId: null,
|
|
// CRUD-focused suites use a deterministic stored fingerprint. The browser
|
|
// suite separately exercises real Wayfern fingerprint generation.
|
|
wayfernConfig: { fingerprint: "{}" },
|
|
groupId: null,
|
|
ephemeral: false,
|
|
dnsBlocklist: null,
|
|
launchHook: null,
|
|
});
|
|
}
|
|
|
|
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
|
|
// Profile import derives its browser version from the downloaded-browsers
|
|
// registry, so without an entry every import fails with
|
|
// BROWSER_NOT_DOWNLOADED before it touches a single file.
|
|
await withApp(
|
|
"entities-core",
|
|
async (app) => {
|
|
const group = await app.invoke("create_profile_group", {
|
|
name: "Research",
|
|
});
|
|
assert.equal(group.name, "Research");
|
|
const renamedGroup = await app.invoke("update_profile_group", {
|
|
groupId: group.id,
|
|
name: "Research Team",
|
|
});
|
|
assert.equal(renamedGroup.name, "Research Team");
|
|
|
|
const duplicateError = await app.invokeError("create_profile_group", {
|
|
name: "Research Team",
|
|
});
|
|
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
|
|
|
|
const proxy = await app.invoke("create_stored_proxy", {
|
|
name: "Local Dead Proxy",
|
|
proxySettings: {
|
|
proxy_type: "http",
|
|
host: "127.0.0.1",
|
|
port: 9,
|
|
username: "e2e-user",
|
|
password: "e2e-pass",
|
|
},
|
|
});
|
|
assert.equal(proxy.proxy_settings.password, "e2e-pass");
|
|
const updatedProxy = await app.invoke("update_stored_proxy", {
|
|
proxyId: proxy.id,
|
|
name: "Updated Proxy",
|
|
proxySettings: {
|
|
proxy_type: "socks5",
|
|
host: "127.0.0.1",
|
|
port: 9,
|
|
username: null,
|
|
password: null,
|
|
},
|
|
});
|
|
assert.equal(updatedProxy.name, "Updated Proxy");
|
|
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
|
|
|
|
// The provider's change-IP link: checked when set, called on request.
|
|
assert.match(
|
|
await app.invokeError("rotate_proxy_ip", { proxyId: proxy.id }),
|
|
/PROXY_ROTATION_URL_MISSING/,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("set_proxy_rotation_url", {
|
|
proxyId: proxy.id,
|
|
rotationUrl: "ftp://provider.example/change-ip",
|
|
}),
|
|
/PROXY_ROTATION_URL_INVALID/,
|
|
);
|
|
const rotationUrl = `${process.env.DONUT_E2E_FIXTURE_URL}/change-ip?key=e2e`;
|
|
const linked = await app.invoke("set_proxy_rotation_url", {
|
|
proxyId: proxy.id,
|
|
rotationUrl: ` ${rotationUrl} `,
|
|
});
|
|
assert.equal(linked.rotation_url, rotationUrl);
|
|
const rotated = await app.invoke("rotate_proxy_ip", {
|
|
proxyId: proxy.id,
|
|
});
|
|
assert.equal(rotated.status, 200);
|
|
assert.equal(rotated.error, null);
|
|
const rotations = await app.invoke("rotate_proxy_ips", {
|
|
proxyIds: [proxy.id, proxy.id, "missing-proxy"],
|
|
});
|
|
assert.deepEqual(
|
|
rotations.map((result) => [result.proxy_id, result.status]),
|
|
[
|
|
[proxy.id, 200],
|
|
["missing-proxy", null],
|
|
],
|
|
"duplicates are called once and each proxy gets its own result",
|
|
);
|
|
assert.match(rotations[1].error, /PROXY_NOT_FOUND/);
|
|
const unlinked = await app.invoke("set_proxy_rotation_url", {
|
|
proxyId: proxy.id,
|
|
rotationUrl: "",
|
|
});
|
|
assert.equal(unlinked.rotation_url, null);
|
|
|
|
const parsed = await app.invoke("parse_txt_proxies", {
|
|
content: [
|
|
"http://one.example:8080",
|
|
"two.example:1080:user:pass",
|
|
"not a proxy",
|
|
].join("\n"),
|
|
});
|
|
assert.equal(parsed.length, 3);
|
|
assert.ok(parsed.some((result) => result.status === "parsed"));
|
|
assert.ok(parsed.some((result) => result.status === "invalid"));
|
|
const parsedProxy = parsed.find((result) => result.status === "parsed");
|
|
const { status: _status, ...parsedProxyFields } = parsedProxy;
|
|
const parsedImport = await app.invoke("import_proxies_from_parsed", {
|
|
parsedProxies: [parsedProxyFields],
|
|
namePrefix: "Parsed",
|
|
});
|
|
assert.equal(parsedImport.imported_count, 1);
|
|
|
|
assert.deepEqual(
|
|
await app.invoke("get_proxy_check_history", { proxyId: proxy.id }),
|
|
[],
|
|
"a proxy nobody has checked has no trail",
|
|
);
|
|
const validityError = await app.invokeError("check_proxy_validity", {
|
|
proxyId: proxy.id,
|
|
proxySettings: null,
|
|
});
|
|
assert.match(validityError, /Proxy check failed|Could not connect/i);
|
|
const cachedValidity = await app.invoke("get_cached_proxy_check", {
|
|
proxyId: proxy.id,
|
|
});
|
|
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
|
|
|
|
// A check that failed is still a check, and it is recorded as one. The
|
|
// proxy above was edited to SOCKS5 on a closed port, so the UDP probe
|
|
// could not reach it: the honest verdict is "unknown", never "no".
|
|
const trail = await app.invoke("get_proxy_check_history", {
|
|
proxyId: proxy.id,
|
|
});
|
|
assert.equal(trail.length, 1);
|
|
assert.equal(trail[0].ok, false);
|
|
assert.equal(trail[0].ip, null);
|
|
assert.equal(trail[0].udp, "unknown");
|
|
assert.ok(
|
|
typeof trail[0].latency_ms === "number" && trail[0].latency_ms >= 0,
|
|
);
|
|
assert.ok(trail[0].timestamp > 0);
|
|
|
|
// Deleting the proxy takes the trail with it; it names exit addresses.
|
|
const doomed = await app.invoke("create_stored_proxy", {
|
|
name: "Trail Owner",
|
|
proxySettings: {
|
|
proxy_type: "http",
|
|
host: "127.0.0.1",
|
|
port: 9,
|
|
username: null,
|
|
password: null,
|
|
},
|
|
});
|
|
await app.invokeError("check_proxy_validity", {
|
|
proxyId: doomed.id,
|
|
proxySettings: null,
|
|
});
|
|
const doomedTrail = await app.invoke("get_proxy_check_history", {
|
|
proxyId: doomed.id,
|
|
});
|
|
assert.equal(doomedTrail.length, 1);
|
|
// An HTTP proxy cannot carry a datagram at all, which is answered from
|
|
// the protocol without dialling anything.
|
|
assert.equal(doomedTrail[0].udp, "no");
|
|
await app.invoke("delete_stored_proxy", { proxyId: doomed.id });
|
|
assert.deepEqual(
|
|
await app.invoke("get_proxy_check_history", { proxyId: doomed.id }),
|
|
[],
|
|
);
|
|
|
|
// The form uses this to tell the user WHICH part of their setup is
|
|
// unsupported instead of implying they mistyped, so the reason must
|
|
// survive the IPC hop. A link Donut can use comes back as the type and
|
|
// endpoint the form shows, VMess included, which hides them in base64.
|
|
const goodVless =
|
|
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
|
|
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
|
|
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
|
|
assert.deepEqual(
|
|
await app.invoke("validate_vless_uri", { uri: goodVless }),
|
|
{ proxy_type: "vless", host: "example.com", port: 443 },
|
|
);
|
|
const vmess = `vmess://${Buffer.from(
|
|
JSON.stringify({
|
|
add: "vm.example.com",
|
|
port: "8443",
|
|
id: "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4",
|
|
net: "ws",
|
|
path: "/vm",
|
|
tls: "tls",
|
|
}),
|
|
).toString("base64")}`;
|
|
assert.deepEqual(await app.invoke("validate_vless_uri", { uri: vmess }), {
|
|
proxy_type: "vmess",
|
|
host: "vm.example.com",
|
|
port: 8443,
|
|
});
|
|
|
|
for (const [uri, reason] of [
|
|
[goodVless.replace("security=reality", "security=xtls"), "security"],
|
|
[goodVless.replace("type=tcp", "type=kcp"), "transport"],
|
|
[
|
|
goodVless.replace("flow=xtls-rprx-vision", "flow=xtls-rprx-direct"),
|
|
"flow",
|
|
],
|
|
["hysteria2://pw@hy.example.com:443/?obfs=gost", "obfs"],
|
|
]) {
|
|
// invokeError returns the command's error wrapped in a message, so match
|
|
// rather than JSON.parse the whole string.
|
|
const error = await app.invokeError("validate_vless_uri", { uri });
|
|
assert.match(error, /VLESS_CONFIG_INVALID/);
|
|
assert.match(
|
|
error,
|
|
new RegExp(`"reason":"${reason}"`),
|
|
`expected reason ${reason} for ${uri}, got: ${error}`,
|
|
);
|
|
}
|
|
|
|
const exported = JSON.parse(
|
|
await app.invoke("export_proxies", { format: "json" }),
|
|
);
|
|
assert.equal(exported.proxies.length, 2);
|
|
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
|
|
assert.ok(
|
|
exported.proxies.some((item) => item.name === "Parsed Proxy 1"),
|
|
);
|
|
const importResult = await app.invoke("import_proxies_json", {
|
|
content: JSON.stringify({
|
|
version: "1",
|
|
source: "Donut Browser",
|
|
exported_at: new Date().toISOString(),
|
|
proxies: [
|
|
{
|
|
name: "Imported Proxy",
|
|
type: "http",
|
|
host: "127.0.0.1",
|
|
port: 8081,
|
|
},
|
|
],
|
|
}),
|
|
});
|
|
assert.equal(importResult.imported_count, 1);
|
|
|
|
// A second batch under the same prefix continues the numbering. It used
|
|
// to restart at 1, collide with every name of the first batch, and be
|
|
// skipped whole as duplicates.
|
|
const secondBatch = await app.invoke("import_proxies_from_parsed", {
|
|
parsedProxies: [
|
|
{ ...parsedProxyFields, port: parsedProxyFields.port + 1 },
|
|
],
|
|
namePrefix: "Parsed",
|
|
});
|
|
assert.equal(secondBatch.imported_count, 1);
|
|
assert.equal(secondBatch.skipped_count, 0);
|
|
assert.equal(secondBatch.proxies[0].name, "Parsed Proxy 2");
|
|
|
|
const profile = await createProfile(app);
|
|
assert.equal(profile.name, "Entity Profile");
|
|
assert.equal(
|
|
(
|
|
await app.invoke("update_profile_proxy", {
|
|
profileId: profile.id,
|
|
proxyId: proxy.id,
|
|
})
|
|
).proxy_id,
|
|
proxy.id,
|
|
);
|
|
await app.invoke("assign_profiles_to_group", {
|
|
profileIds: [profile.id],
|
|
groupId: group.id,
|
|
});
|
|
await app.invoke("rename_profile", {
|
|
profileId: profile.id,
|
|
newName: "Renamed Profile",
|
|
});
|
|
// Its own name, another case, or extra spaces are not a conflict.
|
|
for (const [newName, stored] of [
|
|
["Renamed Profile", "Renamed Profile"],
|
|
[" renamed profile ", "renamed profile"],
|
|
["Renamed Profile ", "Renamed Profile"],
|
|
]) {
|
|
assert.equal(
|
|
(
|
|
await app.invoke("rename_profile", {
|
|
profileId: profile.id,
|
|
newName,
|
|
})
|
|
).name,
|
|
stored,
|
|
);
|
|
}
|
|
await app.invoke("update_profile_tags", {
|
|
profileId: profile.id,
|
|
tags: ["alpha", "automation"],
|
|
});
|
|
await app.invoke("update_profile_note", {
|
|
profileId: profile.id,
|
|
note: "Extensive E2E metadata",
|
|
});
|
|
await app.invoke("update_profile_window_color", {
|
|
profileId: profile.id,
|
|
windowColor: "#123456",
|
|
});
|
|
await app.invoke("update_profile_launch_hook", {
|
|
profileId: profile.id,
|
|
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
|
|
});
|
|
const invalidHook = await app.invokeError("update_profile_launch_hook", {
|
|
profileId: profile.id,
|
|
launchHook: "file:///etc/passwd",
|
|
});
|
|
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
|
|
await app.invoke("update_profile_proxy_bypass_rules", {
|
|
profileId: profile.id,
|
|
rules: ["localhost", "*.internal.example"],
|
|
});
|
|
await app.invoke("update_profile_dns_blocklist", {
|
|
profileId: profile.id,
|
|
dnsBlocklist: "light",
|
|
});
|
|
// A plain address of a known resolver is described as its encrypted
|
|
// endpoint, blank means the proxy resolves, and junk is refused.
|
|
assert.deepEqual(
|
|
await app.invoke("describe_dns_server", { value: "1.1.1.1" }),
|
|
{
|
|
transport: "https",
|
|
server: "cloudflare-dns.com",
|
|
encrypted: true,
|
|
upgraded: true,
|
|
},
|
|
);
|
|
assert.deepEqual(
|
|
await app.invoke("describe_dns_server", { value: "tcp://10.0.0.53" }),
|
|
{
|
|
transport: "tcp",
|
|
server: "10.0.0.53",
|
|
encrypted: false,
|
|
upgraded: false,
|
|
},
|
|
);
|
|
assert.equal(
|
|
await app.invoke("describe_dns_server", { value: " " }),
|
|
null,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("describe_dns_server", {
|
|
value: "udp://1.1.1.1",
|
|
}),
|
|
/DNS_SERVER_INVALID/,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("update_profile_dns_server", {
|
|
profileId: profile.id,
|
|
dnsServer: "udp://9.9.9.9",
|
|
}),
|
|
/DNS_SERVER_INVALID/,
|
|
);
|
|
const withDns = await app.invoke("update_profile_dns_server", {
|
|
profileId: profile.id,
|
|
dnsServer: " https://dns.quad9.net/dns-query ",
|
|
});
|
|
assert.equal(withDns.dns_server, "https://dns.quad9.net/dns-query");
|
|
|
|
// Statuses: a set the person defines, one per profile, removed from
|
|
// every profile when deleted.
|
|
const warming = await app.invoke("create_profile_status", {
|
|
name: " Warming up ",
|
|
color: "#EAB308",
|
|
});
|
|
assert.equal(warming.name, "Warming up");
|
|
assert.equal(warming.color, "#eab308");
|
|
const banned = await app.invoke("create_profile_status", {
|
|
name: "Banned",
|
|
color: "#ef4444",
|
|
});
|
|
assert.match(
|
|
await app.invokeError("create_profile_status", {
|
|
name: "banned",
|
|
color: "#000000",
|
|
}),
|
|
/PROFILE_STATUS_EXISTS/,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("create_profile_status", {
|
|
name: "Paused",
|
|
color: "red",
|
|
}),
|
|
/PROFILE_STATUS_COLOR_INVALID/,
|
|
);
|
|
const renamed = await app.invoke("update_profile_status", {
|
|
statusId: banned.id,
|
|
name: "Suspended",
|
|
color: "#ef4444",
|
|
});
|
|
assert.equal(renamed.name, "Suspended");
|
|
assert.deepEqual(
|
|
(await app.invoke("list_profile_statuses")).map((item) => item.name),
|
|
["Warming up", "Suspended"],
|
|
);
|
|
assert.match(
|
|
await app.invokeError("set_profiles_status", {
|
|
profileIds: [profile.id],
|
|
statusId: "missing",
|
|
}),
|
|
/PROFILE_STATUS_NOT_FOUND/,
|
|
);
|
|
await app.invoke("set_profiles_status", {
|
|
profileIds: [profile.id],
|
|
statusId: banned.id,
|
|
});
|
|
assert.equal(
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === profile.id,
|
|
).status_id,
|
|
banned.id,
|
|
);
|
|
await app.invoke("delete_profile_status", { statusId: banned.id });
|
|
assert.equal(
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === profile.id,
|
|
).status_id,
|
|
null,
|
|
"deleting a status takes it off its profiles",
|
|
);
|
|
await app.invoke("set_profiles_status", {
|
|
profileIds: [profile.id],
|
|
statusId: warming.id,
|
|
});
|
|
|
|
// Start pages: tidied, web pages only, at most twenty.
|
|
assert.match(
|
|
await app.invokeError("update_profile_start_urls", {
|
|
profileId: profile.id,
|
|
startUrls: ["file:///etc/passwd"],
|
|
}),
|
|
/START_URL_INVALID/,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("update_profile_start_urls", {
|
|
profileId: profile.id,
|
|
startUrls: Array.from(
|
|
{ length: 21 },
|
|
(_, i) => `https://${i}.example/`,
|
|
),
|
|
}),
|
|
/START_URLS_TOO_MANY/,
|
|
);
|
|
const withStartPages = await app.invoke("update_profile_start_urls", {
|
|
profileId: profile.id,
|
|
startUrls: [" https://mail.example/inbox ", "", "shop.example"],
|
|
});
|
|
assert.deepEqual(withStartPages.start_urls, [
|
|
"https://mail.example/inbox",
|
|
"https://shop.example/",
|
|
]);
|
|
await app.invoke("update_profile_clear_on_close", {
|
|
profileId: profile.id,
|
|
clearOnClose: true,
|
|
});
|
|
|
|
const profiles = await app.invoke("list_browser_profiles");
|
|
const changed = profiles.find((item) => item.id === profile.id);
|
|
assert.deepEqual(changed.tags, ["alpha", "automation"]);
|
|
assert.equal(changed.note, "Extensive E2E metadata");
|
|
assert.equal(changed.window_color, "#123456");
|
|
assert.equal(changed.group_id, group.id);
|
|
assert.deepEqual(changed.proxy_bypass_rules, [
|
|
"localhost",
|
|
"*.internal.example",
|
|
]);
|
|
assert.equal(changed.dns_blocklist, "light");
|
|
assert.equal(changed.dns_server, "https://dns.quad9.net/dns-query");
|
|
assert.equal(changed.status_id, warming.id);
|
|
assert.deepEqual(changed.start_urls, [
|
|
"https://mail.example/inbox",
|
|
"https://shop.example/",
|
|
]);
|
|
assert.equal(changed.clear_on_close, true);
|
|
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
|
|
"alpha",
|
|
"automation",
|
|
]);
|
|
|
|
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
|
|
const importRoot = path.join(app.root, "profile-import-fixture");
|
|
const importProfile = path.join(importRoot, "Default");
|
|
await mkdir(importProfile, { recursive: true });
|
|
await writeFile(
|
|
path.join(importProfile, "Preferences"),
|
|
JSON.stringify({
|
|
profile: { name: "Imported fixture", exit_type: "Crashed" },
|
|
download: { default_directory: "/Users/someone-else/Downloads" },
|
|
}),
|
|
);
|
|
// A Secure Preferences with MACs that can never validate under Wayfern,
|
|
// one real (relative-path) extension and one component extension that
|
|
// belongs to the source browser's bundle.
|
|
await writeFile(
|
|
path.join(importProfile, "Secure Preferences"),
|
|
JSON.stringify({
|
|
protection: { super_mac: "deadbeef", macs: { extensions: {} } },
|
|
extensions: {
|
|
settings: {
|
|
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {
|
|
path: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0",
|
|
},
|
|
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: {
|
|
path: "/Applications/Chromium.app/Contents/Resources/component",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
// Caches must not be copied, and site data must be.
|
|
await mkdir(path.join(importProfile, "Cache"), { recursive: true });
|
|
await writeFile(path.join(importProfile, "Cache", "data_0"), "junk");
|
|
await mkdir(path.join(importProfile, "Local Storage", "leveldb"), {
|
|
recursive: true,
|
|
});
|
|
await writeFile(
|
|
path.join(importProfile, "Local Storage", "leveldb", "000003.log"),
|
|
"site-data",
|
|
);
|
|
writeChromiumHistory(path.join(importProfile, "History"), [
|
|
"https://example.com/",
|
|
"https://example.org/",
|
|
]);
|
|
writeChromiumCookies(path.join(importProfile, "Cookies"), [
|
|
{ host: "example.com", name: "sid", value: "session-token" },
|
|
{ host: "example.org", name: "pref", value: "dark" },
|
|
// Sealed with a key this machine does not have, and stored the way
|
|
// Chromium's own v23->v24 migration stores it (TEXT in a BLOB column).
|
|
// It must be reported as unrecoverable, never silently blanked and
|
|
// counted as migrated.
|
|
{
|
|
host: "sealed.example",
|
|
name: "sid",
|
|
encryptedValueText: "v10\u0001\u0002\u0003unopenable-ciphertext",
|
|
},
|
|
]);
|
|
|
|
const scanned = await app.invoke("scan_folder_for_profiles", {
|
|
folderPath: importRoot,
|
|
});
|
|
assert.equal(scanned.length, 1);
|
|
assert.equal(scanned[0].mapped_browser, "wayfern");
|
|
const importBatch = await app.invoke("import_browser_profiles", {
|
|
items: [
|
|
{
|
|
source_path: scanned[0].path,
|
|
browser_type: scanned[0].browser,
|
|
new_profile_name: "Imported Profile",
|
|
proxy_id: null,
|
|
vpn_id: null,
|
|
},
|
|
],
|
|
groupId: null,
|
|
duplicateStrategy: "rename",
|
|
// A stored fingerprint, as elsewhere in this suite: generating a real
|
|
// one shells out to the Wayfern binary, which no CRUD suite installs.
|
|
wayfernConfig: { fingerprint: "{}" },
|
|
});
|
|
assert.equal(
|
|
importBatch.imported_count,
|
|
1,
|
|
`import must succeed: ${JSON.stringify(importBatch.results)}`,
|
|
);
|
|
|
|
const imported = importBatch.results[0];
|
|
// The assertion whose absence let the layout bug ship: an import that
|
|
// carries nothing used to be indistinguishable from a successful one.
|
|
assert.ok(
|
|
imported.report,
|
|
"an imported profile must report what it carried",
|
|
);
|
|
assert.equal(imported.report.cookies_migrated, 2);
|
|
assert.equal(
|
|
imported.report.cookies_unrecoverable,
|
|
1,
|
|
"a cookie no key can open must be counted, not silently emptied",
|
|
);
|
|
assert.equal(imported.report.history_entries, 2);
|
|
assert.equal(imported.report.extensions_migrated, 1);
|
|
assert.ok(imported.report.local_storage_origins > 0);
|
|
|
|
const importedDir = path.join(
|
|
app.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
imported.profile_id,
|
|
"profile",
|
|
);
|
|
// Chromium reads <user-data-dir>/Default/, so anything at the root is
|
|
// invisible to the browser no matter how faithfully it was copied.
|
|
assert.ok(
|
|
existsSync(path.join(importedDir, "Default", "Preferences")),
|
|
"profile content must land under Default/",
|
|
);
|
|
assert.ok(
|
|
!existsSync(path.join(importedDir, "Preferences")),
|
|
"nothing profile-scoped may sit at the user-data-dir root",
|
|
);
|
|
assert.ok(
|
|
existsSync(path.join(importedDir, "os_crypt_key")),
|
|
"Wayfern reads its key from the user-data-dir root",
|
|
);
|
|
assert.ok(
|
|
!existsSync(path.join(importedDir, "Default", "Cache")),
|
|
"caches are pure waste and must not be copied",
|
|
);
|
|
assert.ok(
|
|
existsSync(
|
|
path.join(
|
|
importedDir,
|
|
"Default",
|
|
"Local Storage",
|
|
"leveldb",
|
|
"000003.log",
|
|
),
|
|
),
|
|
"site data must survive",
|
|
);
|
|
|
|
const importedCookies = path.join(
|
|
importedDir,
|
|
"Default",
|
|
process.platform === "win32"
|
|
? path.join("Network", "Cookies")
|
|
: "Cookies",
|
|
);
|
|
assert.ok(
|
|
existsSync(importedCookies),
|
|
"cookies must sit where this platform's Chromium reads them",
|
|
);
|
|
// Chromium drops any row where both value and encrypted_value are set, so
|
|
// a "migrated" cookie that kept its plaintext would never load.
|
|
const cookieDb = new DatabaseSync(importedCookies, { readOnly: true });
|
|
const rows = cookieDb
|
|
.prepare(
|
|
"SELECT host_key, value, length(encrypted_value) AS enc FROM cookies ORDER BY host_key",
|
|
)
|
|
.all();
|
|
cookieDb.close();
|
|
assert.equal(
|
|
rows.length,
|
|
2,
|
|
"the unrecoverable row is dropped, not kept empty",
|
|
);
|
|
for (const row of rows) {
|
|
assert.equal(row.value, "", `${row.host_key} kept a plaintext value`);
|
|
assert.ok(row.enc > 0, `${row.host_key} was not re-encrypted`);
|
|
}
|
|
|
|
const securePrefs = JSON.parse(
|
|
await readFile(
|
|
path.join(importedDir, "Default", "Secure Preferences"),
|
|
"utf8",
|
|
),
|
|
);
|
|
assert.equal(
|
|
securePrefs.protection,
|
|
undefined,
|
|
"MACs from another machine can never validate and must be stripped",
|
|
);
|
|
assert.ok(
|
|
securePrefs.extensions.settings.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
|
|
"the user's own extension must survive",
|
|
);
|
|
assert.equal(
|
|
securePrefs.extensions.settings.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,
|
|
undefined,
|
|
"a component extension pointing into the source browser must be dropped",
|
|
);
|
|
|
|
const prefs = JSON.parse(
|
|
await readFile(
|
|
path.join(importedDir, "Default", "Preferences"),
|
|
"utf8",
|
|
),
|
|
);
|
|
assert.equal(prefs.profile.exit_type, "Normal");
|
|
assert.equal(prefs.download.default_directory, undefined);
|
|
assert.equal(prefs.profile.name, "Imported fixture");
|
|
|
|
// A Gecko profile must say why it cannot be imported instead of silently
|
|
// producing an empty one.
|
|
const firefoxRoot = path.join(app.root, "firefox-profile-fixture");
|
|
await mkdir(firefoxRoot, { recursive: true });
|
|
await writeFile(path.join(firefoxRoot, "prefs.js"), "// prefs");
|
|
await writeFile(path.join(firefoxRoot, "places.sqlite"), "");
|
|
const geckoBatch = await app.invoke("import_browser_profiles", {
|
|
items: [
|
|
{
|
|
source_path: firefoxRoot,
|
|
browser_type: "firefox",
|
|
new_profile_name: "Gecko Profile",
|
|
proxy_id: null,
|
|
vpn_id: null,
|
|
},
|
|
],
|
|
groupId: null,
|
|
duplicateStrategy: "rename",
|
|
wayfernConfig: { fingerprint: "{}" },
|
|
});
|
|
assert.equal(geckoBatch.failed_count, 1);
|
|
assert.match(
|
|
geckoBatch.results[0].error,
|
|
/IMPORT_SOURCE_NOT_CHROMIUM/,
|
|
"a Firefox folder must be rejected by name, not imported empty",
|
|
);
|
|
const archivePath = path.join(app.root, "profile-import-fixture.zip");
|
|
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
|
|
const archiveScan = await app.invoke("scan_profile_archive", {
|
|
archivePath,
|
|
});
|
|
assert.ok(Array.isArray(archiveScan.profiles));
|
|
await app.invoke("cleanup_profile_import_scratch", {
|
|
extractedDir: archiveScan.extracted_dir,
|
|
});
|
|
|
|
const clone = await app.invoke("clone_profile", {
|
|
profileId: profile.id,
|
|
name: "Cloned Profile",
|
|
});
|
|
assert.notEqual(clone.id, profile.id);
|
|
assert.equal(clone.name, "Cloned Profile");
|
|
assert.equal(clone.dns_server, "https://dns.quad9.net/dns-query");
|
|
assert.equal(clone.status_id, warming.id, "a clone keeps the status");
|
|
const counts = await app.invoke("get_groups_with_profile_counts");
|
|
assert.equal(counts.find((item) => item.id === group.id).count, 2);
|
|
assert.equal((await app.invoke("get_profile_groups")).length, 1);
|
|
|
|
const createDnsArgs = {
|
|
name: "DNS Profile",
|
|
browserStr: "wayfern",
|
|
version: "150.0.7871.100",
|
|
releaseType: "stable",
|
|
proxyId: null,
|
|
vpnId: null,
|
|
wayfernConfig: { fingerprint: "{}" },
|
|
groupId: null,
|
|
ephemeral: false,
|
|
dnsBlocklist: null,
|
|
launchHook: null,
|
|
};
|
|
assert.match(
|
|
await app.invokeError("create_browser_profile_new", {
|
|
...createDnsArgs,
|
|
dnsServer: "quic://dns.example",
|
|
}),
|
|
/DNS_SERVER_INVALID/,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("create_browser_profile_new", {
|
|
...createDnsArgs,
|
|
startUrls: ["chrome://settings"],
|
|
}),
|
|
/START_URL_INVALID/,
|
|
);
|
|
assert.ok(
|
|
!(await app.invoke("list_browser_profiles")).some(
|
|
(item) => item.name === "DNS Profile",
|
|
),
|
|
"a refused DNS server must not leave a profile behind",
|
|
);
|
|
const dnsProfile = await app.invoke("create_browser_profile_new", {
|
|
...createDnsArgs,
|
|
dnsServer: "9.9.9.9",
|
|
startUrls: ["https://start.example/"],
|
|
});
|
|
assert.equal(dnsProfile.dns_server, "9.9.9.9");
|
|
assert.deepEqual(dnsProfile.start_urls, ["https://start.example/"]);
|
|
const cleared = await app.invoke("update_profile_dns_server", {
|
|
profileId: dnsProfile.id,
|
|
dnsServer: "",
|
|
});
|
|
assert.equal(cleared.dns_server, null);
|
|
|
|
await app.invoke("delete_selected_profiles", {
|
|
profileIds: [profile.id, clone.id, imported.profile_id, dnsProfile.id],
|
|
});
|
|
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
|
|
await app.invoke("delete_profile_group", { groupId: group.id });
|
|
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
|
|
for (const importedProxy of (
|
|
await app.invoke("get_stored_proxies")
|
|
).filter(
|
|
(item) =>
|
|
item.name === "Imported Proxy" ||
|
|
item.name.startsWith("Parsed Proxy"),
|
|
)) {
|
|
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
|
|
}
|
|
},
|
|
{ seedDownloadedBrowser: true },
|
|
);
|
|
});
|
|
|
|
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
|
|
await withApp("entities-network-extension", async (app) => {
|
|
const profile = await createProfile(app, "Assignment Profile");
|
|
const extension = await app.invoke("add_extension", {
|
|
name: "E2E Fixture Extension",
|
|
fileName: "fixture.zip",
|
|
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
|
});
|
|
assert.equal(extension.name, "Donut E2E Fixture");
|
|
assert.equal(extension.version, "1.0.0");
|
|
const extensionGroup = await app.invoke("create_extension_group", {
|
|
name: "Automation Extensions",
|
|
});
|
|
const populated = await app.invoke("add_extension_to_group", {
|
|
groupId: extensionGroup.id,
|
|
extensionId: extension.id,
|
|
});
|
|
assert.deepEqual(populated.extension_ids, [extension.id]);
|
|
await app.invoke("assign_extension_group_to_profile", {
|
|
profileId: profile.id,
|
|
extensionGroupId: extensionGroup.id,
|
|
});
|
|
assert.equal(
|
|
(
|
|
await app.invoke("get_extension_group_for_profile", {
|
|
profileId: profile.id,
|
|
})
|
|
).id,
|
|
extensionGroup.id,
|
|
);
|
|
const renamed = await app.invoke("update_extension", {
|
|
extensionId: extension.id,
|
|
name: "Renamed Fixture Extension",
|
|
fileName: null,
|
|
fileData: null,
|
|
});
|
|
assert.equal(renamed.name, "Renamed Fixture Extension");
|
|
assert.equal(
|
|
await app.invoke("get_extension_icon", { extensionId: extension.id }),
|
|
null,
|
|
);
|
|
const changedGroup = await app.invoke("update_extension_group", {
|
|
groupId: extensionGroup.id,
|
|
name: "Renamed Extension Group",
|
|
extensionIds: [extension.id],
|
|
});
|
|
assert.equal(changedGroup.name, "Renamed Extension Group");
|
|
assert.equal((await app.invoke("list_extensions")).length, 1);
|
|
assert.equal((await app.invoke("list_extension_groups")).length, 1);
|
|
await app.invoke("remove_extension_from_group", {
|
|
groupId: extensionGroup.id,
|
|
extensionId: extension.id,
|
|
});
|
|
await app.invoke("assign_extension_group_to_profile", {
|
|
profileId: profile.id,
|
|
extensionGroupId: null,
|
|
});
|
|
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
|
|
await app.invoke("delete_extension", { extensionId: extension.id });
|
|
|
|
// Folder imports, the "Load unpacked" flow. Copying packs the folder into
|
|
// the store; linking loads it from where the user keeps it, which only
|
|
// exists on this machine and therefore never syncs.
|
|
const unpackedDir = await writeUnpackedExtension(
|
|
path.join(app.root, "fixtures", "unpacked-extension"),
|
|
);
|
|
const copied = await app.invoke("add_unpacked_extension", {
|
|
name: "Overridden By The Manifest",
|
|
path: unpackedDir,
|
|
link: false,
|
|
});
|
|
assert.equal(copied.source_kind, "unpacked");
|
|
assert.equal(copied.linked_path, null);
|
|
assert.equal(copied.file_type, "zip");
|
|
assert.equal(copied.file_name, "unpacked-extension.zip");
|
|
assert.equal(copied.name, "Donut E2E Unpacked");
|
|
assert.equal(copied.version, "1.0.0");
|
|
// The folder declares icons, so packing it must carry one through into the
|
|
// store rather than dropping it the way the icon-less ZIP fixture does.
|
|
assert.equal(
|
|
await app.invoke("get_extension_icon", { extensionId: copied.id }),
|
|
`data:image/png;base64,${extensionIconPngBase64()}`,
|
|
);
|
|
|
|
const linked = await app.invoke("add_unpacked_extension", {
|
|
name: "Linked Fixture",
|
|
path: unpackedDir,
|
|
link: true,
|
|
});
|
|
assert.equal(linked.source_kind, "unpacked");
|
|
assert.equal(linked.file_type, "unpacked");
|
|
assert.equal(linked.linked_path, await realpath(unpackedDir));
|
|
assert.equal(
|
|
linked.sync_enabled,
|
|
false,
|
|
"a linked extension has no payload to upload, so it must never be synced",
|
|
);
|
|
|
|
const repackedDir = await writeUnpackedExtension(
|
|
path.join(app.root, "fixtures", "unpacked-extension-v2"),
|
|
{ name: "Donut E2E Unpacked v2", version: "2.0.0" },
|
|
);
|
|
const repacked = await app.invoke("update_extension_from_path", {
|
|
extensionId: copied.id,
|
|
name: "Repacked Fixture Extension",
|
|
path: repackedDir,
|
|
link: false,
|
|
});
|
|
assert.equal(repacked.name, "Repacked Fixture Extension");
|
|
assert.equal(repacked.version, "2.0.0");
|
|
assert.equal(repacked.file_name, "unpacked-extension-v2.zip");
|
|
assert.deepEqual(
|
|
await readdir(
|
|
path.join(app.dataRoot, "data", "extensions", copied.id, "file"),
|
|
),
|
|
["unpacked-extension-v2.zip"],
|
|
"re-importing replaces the stored payload instead of stacking a second one",
|
|
);
|
|
|
|
// Re-importing a linked extension as a copy ends the link, which is what
|
|
// makes it portable again. With no explicit name the manifest names it.
|
|
const unlinked = await app.invoke("update_extension_from_path", {
|
|
extensionId: linked.id,
|
|
name: null,
|
|
path: repackedDir,
|
|
link: false,
|
|
});
|
|
assert.equal(unlinked.linked_path, null);
|
|
assert.equal(unlinked.source_kind, "unpacked");
|
|
assert.equal(unlinked.name, "Donut E2E Unpacked v2");
|
|
|
|
assert.match(
|
|
await app.invokeError("add_unpacked_extension", {
|
|
name: "Not An Extension",
|
|
path: app.root,
|
|
link: false,
|
|
}),
|
|
/EXTENSION_MANIFEST_MISSING/,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("update_extension_from_path", {
|
|
extensionId: unlinked.id,
|
|
name: null,
|
|
path: path.join(app.root, "fixtures", "absent"),
|
|
link: false,
|
|
}),
|
|
/EXTENSION_DIR_NOT_FOUND/,
|
|
);
|
|
|
|
for (const id of [copied.id, unlinked.id]) {
|
|
await app.invoke("delete_extension", { extensionId: id });
|
|
}
|
|
assert.deepEqual(await app.invoke("list_extensions"), []);
|
|
assert.ok(
|
|
existsSync(path.join(unpackedDir, "manifest.json")),
|
|
"importing a folder must never move or consume the user's copy of it",
|
|
);
|
|
|
|
// Importing from a link. The fixture server answers with a real CRX3
|
|
// container, so this proves the importer unwraps the signed container to
|
|
// the ZIP the store keeps rather than filing the container itself.
|
|
const fixtureBase = process.env.DONUT_E2E_FIXTURE_URL;
|
|
assert.ok(fixtureBase, "the fixture server URL has to reach the suite");
|
|
const fetched = await app.invoke("fetch_extension_from_url", {
|
|
url: `${fixtureBase}/extension.crx`,
|
|
});
|
|
assert.equal(fetched.name, CRX_EXTENSION_NAME);
|
|
assert.equal(fetched.version, CRX_EXTENSION_VERSION);
|
|
assert.equal(fetched.from_web_store, false);
|
|
assert.equal(
|
|
fetched.file_name,
|
|
"extension.zip",
|
|
"the stored payload is the ZIP, so it must not still be called a .crx",
|
|
);
|
|
assert.deepEqual(
|
|
fetched.file_data.slice(0, 4),
|
|
[0x50, 0x4b, 0x03, 0x04],
|
|
"the CRX3 header has to be stripped, not stored",
|
|
);
|
|
|
|
const fromLink = await app.invoke("add_extension", {
|
|
name: "Overridden By The Manifest",
|
|
fileName: fetched.file_name,
|
|
fileData: fetched.file_data,
|
|
});
|
|
assert.equal(fromLink.name, CRX_EXTENSION_NAME);
|
|
assert.equal(fromLink.version, CRX_EXTENSION_VERSION);
|
|
assert.equal(fromLink.source_kind, "archive");
|
|
assert.equal(fromLink.file_type, "zip");
|
|
|
|
// Assignable like any other extension: the link is only how it arrived.
|
|
const linkGroup = await app.invoke("create_extension_group", {
|
|
name: "Downloaded Extensions",
|
|
});
|
|
assert.deepEqual(
|
|
(
|
|
await app.invoke("add_extension_to_group", {
|
|
groupId: linkGroup.id,
|
|
extensionId: fromLink.id,
|
|
})
|
|
).extension_ids,
|
|
[fromLink.id],
|
|
);
|
|
await app.invoke("assign_extension_group_to_profile", {
|
|
profileId: profile.id,
|
|
extensionGroupId: linkGroup.id,
|
|
});
|
|
assert.equal(
|
|
(
|
|
await app.invoke("get_extension_group_for_profile", {
|
|
profileId: profile.id,
|
|
})
|
|
).id,
|
|
linkGroup.id,
|
|
);
|
|
|
|
// A body that is not an extension is refused with the code, and nothing
|
|
// is stored for it.
|
|
assert.match(
|
|
await app.invokeError("fetch_extension_from_url", {
|
|
url: `${fixtureBase}/not-an-extension.zip`,
|
|
}),
|
|
/EXTENSION_NOT_AN_EXTENSION/,
|
|
);
|
|
for (const rejected of [
|
|
"not a link at all",
|
|
"https://example.invalid/downloads",
|
|
"https://example.invalid/installer.exe",
|
|
// 32 characters, but an extension id only uses a-p.
|
|
"abcdefghijklmnopabcdefghijklmnoz",
|
|
// Plain HTTP off loopback never crosses the wire, whatever it points at.
|
|
"http://files.example.invalid/pack.crx",
|
|
]) {
|
|
assert.match(
|
|
await app.invokeError("fetch_extension_from_url", { url: rejected }),
|
|
/EXTENSION_URL_INVALID/,
|
|
rejected,
|
|
);
|
|
}
|
|
assert.match(
|
|
await app.invokeError("fetch_extension_from_url", {
|
|
url: `${fixtureBase}/absent-extension.crx`,
|
|
}),
|
|
/EXTENSION_NOT_AN_EXTENSION|EXTENSION_DOWNLOAD_FAILED/,
|
|
);
|
|
assert.equal((await app.invoke("list_extensions")).length, 1);
|
|
|
|
await app.invoke("assign_extension_group_to_profile", {
|
|
profileId: profile.id,
|
|
extensionGroupId: null,
|
|
});
|
|
await app.invoke("delete_extension_group", { groupId: linkGroup.id });
|
|
await app.invoke("delete_extension", { extensionId: fromLink.id });
|
|
assert.deepEqual(await app.invoke("list_extensions"), []);
|
|
|
|
const vpn = await app.invoke("create_vpn_config_manual", {
|
|
name: "E2E WireGuard",
|
|
vpnType: "WireGuard",
|
|
configData: wireGuardFixture(),
|
|
});
|
|
assert.equal(vpn.name, "E2E WireGuard");
|
|
assert.equal(
|
|
(await app.invoke("get_vpn_config", { vpnId: vpn.id })).id,
|
|
vpn.id,
|
|
);
|
|
assert.equal((await app.invoke("list_vpn_configs")).length, 1);
|
|
const updatedVpn = await app.invoke("update_vpn_config", {
|
|
vpnId: vpn.id,
|
|
name: "Updated WireGuard",
|
|
});
|
|
assert.equal(updatedVpn.name, "Updated WireGuard");
|
|
assert.equal(
|
|
(await app.invoke("get_vpn_status", { vpnId: vpn.id })).connected,
|
|
false,
|
|
);
|
|
assert.equal(
|
|
(
|
|
await app.invoke("update_profile_vpn", {
|
|
profileId: profile.id,
|
|
vpnId: vpn.id,
|
|
})
|
|
).vpn_id,
|
|
vpn.id,
|
|
);
|
|
assert.deepEqual(await app.invoke("list_active_vpn_connections"), []);
|
|
await app.invoke("disconnect_vpn", { vpnId: vpn.id });
|
|
const unknownVpnError = await app.invokeError("check_vpn_validity", {
|
|
vpnId: "missing-vpn",
|
|
});
|
|
const normalizedVpnError = unknownVpnError.toLowerCase();
|
|
assert.ok(
|
|
normalizedVpnError.includes("not found") ||
|
|
normalizedVpnError.includes("failed to start vpn worker"),
|
|
);
|
|
const importedVpn = await app.invoke("import_vpn_config", {
|
|
content: wireGuardFixture(),
|
|
filename: "imported.conf",
|
|
name: "Imported WireGuard",
|
|
});
|
|
assert.equal(importedVpn.success, true);
|
|
await app.invoke("delete_vpn_config", { vpnId: importedVpn.vpn_id });
|
|
await app.invoke("delete_vpn_config", { vpnId: vpn.id });
|
|
|
|
const dns = await app.invoke("set_custom_dns_config", {
|
|
sources: [`${process.env.DONUT_E2E_FIXTURE_URL}/dns.txt`],
|
|
blockDomains: [" Ads.Example.com ", "tracker.example"],
|
|
allowDomains: ["safe.example"],
|
|
allowlistMode: false,
|
|
});
|
|
assert.deepEqual(dns.block_domains, ["ads.example.com", "tracker.example"]);
|
|
assert.deepEqual(dns.allow_domains, ["safe.example"]);
|
|
const textExport = await app.invoke("export_custom_dns_rules", {
|
|
format: "txt",
|
|
});
|
|
assert.equal(
|
|
textExport,
|
|
[
|
|
`! source: ${process.env.DONUT_E2E_FIXTURE_URL}/dns.txt`,
|
|
"@@safe.example",
|
|
"ads.example.com",
|
|
"tracker.example",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
await app.invoke("import_custom_dns_rules", {
|
|
format: "txt",
|
|
content: "||malware.example^\n@@||allowed.example^\n",
|
|
});
|
|
const importedDns = await app.invoke("get_custom_dns_config");
|
|
assert.ok(importedDns.block_domains.includes("malware.example"));
|
|
assert.ok(importedDns.allow_domains.includes("allowed.example"));
|
|
await app.invoke("refresh_dns_blocklists");
|
|
const blocklistStatus = await app.invoke("get_dns_blocklist_cache_status");
|
|
assert.equal(blocklistStatus.length, 5);
|
|
assert.ok(
|
|
blocklistStatus.every(
|
|
(entry) => entry.is_cached && entry.is_fresh && entry.entry_count === 2,
|
|
),
|
|
);
|
|
|
|
await app.invoke("delete_profile", { profileId: profile.id });
|
|
});
|
|
});
|
|
|
|
test("cookie import/copy/export, profile encryption, and traffic-stat read/clear paths", async () => {
|
|
await withApp("entities-cookies-password", async (app) => {
|
|
const source = await createProfile(app, "Cookie Source");
|
|
const target = await createProfile(app, "Cookie Target");
|
|
const cookieJson = JSON.stringify([
|
|
{
|
|
name: "session",
|
|
value: "isolated-secret-cookie",
|
|
domain: "fixture.local",
|
|
path: "/",
|
|
secure: false,
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
expirationDate: 2_000_000_000,
|
|
},
|
|
]);
|
|
const imported = await app.invoke("import_pasted_cookies", {
|
|
profileId: source.id,
|
|
content: cookieJson,
|
|
site: null,
|
|
mode: "merge",
|
|
includeExpired: false,
|
|
});
|
|
assert.equal(imported.added, 1);
|
|
assert.equal(imported.overwritten, 0);
|
|
assert.equal(imported.deleted, 0);
|
|
const cookies = await app.invoke("read_profile_cookies", {
|
|
profileId: source.id,
|
|
});
|
|
assert.equal(cookies.total_count, 1);
|
|
assert.equal(cookies.domains[0].cookies[0].value, "isolated-secret-cookie");
|
|
const stats = await app.invoke("get_profile_cookie_stats", {
|
|
profileId: source.id,
|
|
});
|
|
assert.equal(stats.total_count, 1);
|
|
const copied = await app.invoke("copy_profile_cookies", {
|
|
request: {
|
|
source_profile_id: source.id,
|
|
target_profile_ids: [target.id],
|
|
selected_cookies: [{ domain: "fixture.local", name: "session" }],
|
|
},
|
|
});
|
|
assert.equal(copied[0].cookies_copied, 1);
|
|
assert.match(
|
|
await app.invoke("export_profile_cookies", {
|
|
profileId: target.id,
|
|
format: "json",
|
|
}),
|
|
/isolated-secret-cookie/,
|
|
);
|
|
assert.match(
|
|
await app.invoke("export_profile_cookies", {
|
|
profileId: target.id,
|
|
format: "netscape",
|
|
}),
|
|
/fixture\.local/,
|
|
);
|
|
|
|
const paste = [
|
|
"# Netscape HTTP Cookie File",
|
|
"#HttpOnly_.fixture.local\tTRUE\t/\tFALSE\t2000000000\tpasted\tpasted-value",
|
|
].join("\n");
|
|
const analysis = await app.invoke("analyze_pasted_cookies", {
|
|
profileId: target.id,
|
|
content: paste,
|
|
site: null,
|
|
});
|
|
assert.equal(analysis.format, "netscape");
|
|
assert.equal(analysis.cookies.length, 1);
|
|
assert.equal(analysis.cookies[0].name, "pasted");
|
|
assert.equal(analysis.cookies[0].isHttpOnly, true);
|
|
assert.equal(
|
|
analysis.cookies[0].value,
|
|
undefined,
|
|
"the preview must never carry the cookie value",
|
|
);
|
|
assert.equal(analysis.siteRequired, false);
|
|
assert.equal(analysis.expiredCount, 0);
|
|
assert.equal(analysis.blockedBy, null);
|
|
// The copied fixture.local cookie is the one row replace mode would clear.
|
|
assert.equal(analysis.replaceDeleteCount, 1);
|
|
|
|
const merged = await app.invoke("import_pasted_cookies", {
|
|
profileId: target.id,
|
|
content: paste,
|
|
site: null,
|
|
mode: "merge",
|
|
includeExpired: false,
|
|
});
|
|
assert.equal(merged.added, 1);
|
|
assert.equal(merged.deleted, 0);
|
|
assert.equal(merged.skipped, 0);
|
|
assert.equal(
|
|
(await app.invoke("get_profile_cookie_stats", { profileId: target.id }))
|
|
.total_count,
|
|
2,
|
|
);
|
|
|
|
// Both spellings of the pasted site go, and only they do.
|
|
const replacedPaste = await app.invoke("import_pasted_cookies", {
|
|
profileId: target.id,
|
|
content: paste,
|
|
site: null,
|
|
mode: "replaceMatchingSites",
|
|
includeExpired: false,
|
|
});
|
|
assert.equal(replacedPaste.deleted, 2);
|
|
assert.equal(replacedPaste.added, 1);
|
|
const afterReplace = await app.invoke("read_profile_cookies", {
|
|
profileId: target.id,
|
|
});
|
|
assert.equal(afterReplace.total_count, 1);
|
|
assert.equal(afterReplace.domains[0].cookies[0].name, "pasted");
|
|
|
|
await app.invoke("set_profile_password", {
|
|
profileId: source.id,
|
|
password: "correct horse battery staple",
|
|
});
|
|
assert.equal(
|
|
await app.invoke("is_profile_locked", { profileId: source.id }),
|
|
false,
|
|
);
|
|
const wrong = await app.invokeError("verify_profile_password", {
|
|
profileId: source.id,
|
|
password: "wrong password",
|
|
});
|
|
assert.match(wrong, /INCORRECT_PASSWORD/);
|
|
await app.invoke("verify_profile_password", {
|
|
profileId: source.id,
|
|
password: "correct horse battery staple",
|
|
});
|
|
await app.invoke("change_profile_password", {
|
|
profileId: source.id,
|
|
oldPassword: "correct horse battery staple",
|
|
newPassword: "new correct horse battery staple",
|
|
});
|
|
await app.invoke("lock_profile", { profileId: source.id });
|
|
assert.equal(
|
|
await app.invoke("is_profile_locked", { profileId: source.id }),
|
|
true,
|
|
);
|
|
await app.invoke("unlock_profile", {
|
|
profileId: source.id,
|
|
password: "new correct horse battery staple",
|
|
});
|
|
await app.invoke("remove_profile_password", {
|
|
profileId: source.id,
|
|
password: "new correct horse battery staple",
|
|
});
|
|
assert.equal(
|
|
await app.invoke("is_profile_locked", { profileId: source.id }),
|
|
false,
|
|
);
|
|
|
|
assert.deepEqual(await app.invoke("get_all_traffic_snapshots"), []);
|
|
assert.equal(
|
|
await app.invoke("get_profile_traffic_snapshot", {
|
|
profileId: source.id,
|
|
}),
|
|
null,
|
|
);
|
|
assert.equal(
|
|
await app.invoke("get_traffic_stats_for_period", {
|
|
profileId: source.id,
|
|
seconds: 3600,
|
|
}),
|
|
null,
|
|
);
|
|
await app.invoke("clear_profile_traffic_stats", { profileId: source.id });
|
|
await app.invoke("clear_all_traffic_stats");
|
|
|
|
await app.invoke("delete_selected_profiles", {
|
|
profileIds: [source.id, target.id],
|
|
});
|
|
});
|
|
});
|
|
|
|
test("2FA keys stay sealed on this device, hand out codes, and go with their profile", async () => {
|
|
await withApp("entities-two-factor", async (app) => {
|
|
const secret = "JBSWY3DPEHPK3PXP";
|
|
const quick = await app.invoke("generate_totp_code", {
|
|
key: ` ${secret.toLowerCase()} `,
|
|
});
|
|
assert.match(quick.code, /^\d{6}$/);
|
|
assert.equal(quick.valid_until % 30, 0);
|
|
assert.match(
|
|
await app.invokeError("generate_totp_code", { key: "not a key" }),
|
|
/TOTP_KEY_INVALID/,
|
|
);
|
|
|
|
const profile = await createProfile(app, "Two Factor Profile");
|
|
assert.match(
|
|
await app.invokeError("add_profile_totp", {
|
|
profileId: profile.id,
|
|
label: "",
|
|
key: "otpauth://hotp/x?secret=JBSWY3DPEHPK3PXP&counter=1",
|
|
}),
|
|
/TOTP_KEY_INVALID/,
|
|
);
|
|
const entry = await app.invoke("add_profile_totp", {
|
|
profileId: profile.id,
|
|
label: "",
|
|
key: `otpauth://totp/Shop:alice@example.com?secret=${secret}&issuer=Shop&digits=8`,
|
|
});
|
|
assert.equal(entry.label, "Shop", "the issuer names an unlabelled key");
|
|
assert.equal(entry.account, "alice@example.com");
|
|
assert.equal(entry.digits, 8);
|
|
const listed = await app.invoke("list_profile_totp", {
|
|
profileId: profile.id,
|
|
});
|
|
assert.equal(listed.length, 1);
|
|
assert.ok(
|
|
!JSON.stringify(listed).includes(secret),
|
|
"the window never receives the key",
|
|
);
|
|
const codes = await app.invoke("get_profile_totp_codes", {
|
|
profileId: profile.id,
|
|
});
|
|
assert.equal(codes[0].id, entry.id);
|
|
assert.match(codes[0].code, /^\d{8}$/);
|
|
|
|
const sealed = path.join(
|
|
app.dataRoot,
|
|
"data",
|
|
"data",
|
|
"totp",
|
|
`${profile.id}.dat`,
|
|
);
|
|
const onDisk = await readFile(sealed);
|
|
assert.ok(
|
|
!onDisk.toString("latin1").includes(secret) &&
|
|
!onDisk.toString("latin1").includes("alice"),
|
|
"the key file is sealed",
|
|
);
|
|
const metadata = await readFile(
|
|
path.join(app.dataRoot, "data", "profiles", profile.id, "metadata.json"),
|
|
"utf8",
|
|
);
|
|
assert.ok(
|
|
!metadata.includes(secret),
|
|
"profile metadata, which syncs, has no key",
|
|
);
|
|
|
|
await app.invoke("delete_profile_totp", {
|
|
profileId: profile.id,
|
|
entryId: entry.id,
|
|
});
|
|
assert.deepEqual(
|
|
await app.invoke("list_profile_totp", { profileId: profile.id }),
|
|
[],
|
|
);
|
|
assert.match(
|
|
await app.invokeError("delete_profile_totp", {
|
|
profileId: profile.id,
|
|
entryId: entry.id,
|
|
}),
|
|
/TOTP_ENTRY_NOT_FOUND/,
|
|
);
|
|
|
|
await app.invoke("add_profile_totp", {
|
|
profileId: profile.id,
|
|
label: "Mail",
|
|
key: secret,
|
|
});
|
|
assert.ok(existsSync(sealed));
|
|
await app.invoke("delete_profile", { profileId: profile.id });
|
|
await app.invoke("purge_trashed_profile", { profileId: profile.id });
|
|
assert.ok(
|
|
!existsSync(sealed),
|
|
"a purged profile takes its 2FA keys with it",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("bulk edit changes only the ticked settings, checks every value first, and skips no-ops", async () => {
|
|
await withApp("entities-bulk-edit", async (app) => {
|
|
const first = await createProfile(app, "Bulk One");
|
|
const second = await createProfile(app, "Bulk Two");
|
|
const untouched = await createProfile(app, "Bulk Untouched");
|
|
const group = await app.invoke("create_profile_group", {
|
|
name: "Bulk Group",
|
|
});
|
|
const status = await app.invoke("create_profile_status", {
|
|
name: "Warming",
|
|
color: "#eab308",
|
|
});
|
|
const ids = [first.id, second.id];
|
|
const byId = async () =>
|
|
new Map(
|
|
(await app.invoke("list_browser_profiles")).map((p) => [p.id, p]),
|
|
);
|
|
|
|
for (const [patch, code] of [
|
|
[{ status_id: "missing" }, /PROFILE_STATUS_NOT_FOUND/],
|
|
[{ group_id: "missing" }, /GROUP_NOT_FOUND/],
|
|
[{ webrtc_mode: "leak" }, /WEBRTC_MODE_INVALID/],
|
|
[{ dns_blocklist: "everything" }, /DNS_BLOCKLIST_INVALID/],
|
|
[{ start_urls: ["file:///etc/passwd"] }, /START_URL_INVALID/],
|
|
[{ note: "kept?", nickname: "unknown field" }, /nickname/],
|
|
]) {
|
|
assert.match(
|
|
await app.invokeError("bulk_update_profiles", {
|
|
profileIds: ids,
|
|
patch,
|
|
}),
|
|
code,
|
|
);
|
|
}
|
|
assert.match(
|
|
await app.invokeError("bulk_update_profiles", {
|
|
profileIds: [first.id, "00000000-0000-4000-8000-000000000000"],
|
|
patch: { note: "never written" },
|
|
}),
|
|
/not found/i,
|
|
);
|
|
for (const profile of (await byId()).values()) {
|
|
assert.equal(profile.note ?? null, null, "a refused batch changes none");
|
|
}
|
|
|
|
assert.equal(
|
|
await app.invoke("bulk_update_profiles", {
|
|
profileIds: ids,
|
|
patch: {
|
|
status_id: status.id,
|
|
tags_add: ["bulk", "shop"],
|
|
note: " Bulk note ",
|
|
group_id: group.id,
|
|
start_urls: ["shop.example"],
|
|
dns_blocklist: "light",
|
|
block_images: true,
|
|
webrtc_mode: "block",
|
|
},
|
|
}),
|
|
2,
|
|
);
|
|
let profiles = await byId();
|
|
for (const id of ids) {
|
|
const profile = profiles.get(id);
|
|
assert.equal(profile.status_id, status.id);
|
|
assert.deepEqual(profile.tags, ["bulk", "shop"]);
|
|
assert.equal(profile.note, "Bulk note");
|
|
assert.equal(profile.group_id, group.id);
|
|
assert.deepEqual(profile.start_urls, ["https://shop.example/"]);
|
|
assert.equal(profile.dns_blocklist, "light");
|
|
assert.equal(profile.wayfern_config.block_images, true);
|
|
assert.equal(profile.wayfern_config.webrtc_mode, "block");
|
|
assert.equal(
|
|
profile.wayfern_config.fingerprint,
|
|
"{}",
|
|
"the device stays unless asked",
|
|
);
|
|
}
|
|
const other = profiles.get(untouched.id);
|
|
assert.deepEqual(other.tags ?? [], []);
|
|
assert.equal(other.status_id ?? null, null);
|
|
assert.equal(other.group_id ?? null, null);
|
|
|
|
assert.equal(
|
|
await app.invoke("bulk_update_profiles", {
|
|
profileIds: ids,
|
|
patch: { status_id: status.id, tags_add: ["bulk"] },
|
|
}),
|
|
0,
|
|
"a patch that changes nothing saves nothing",
|
|
);
|
|
|
|
assert.equal(
|
|
await app.invoke("bulk_update_profiles", {
|
|
profileIds: ids,
|
|
patch: {
|
|
tags_remove: ["shop"],
|
|
note: null,
|
|
group_id: null,
|
|
new_device: true,
|
|
},
|
|
}),
|
|
2,
|
|
);
|
|
profiles = await byId();
|
|
for (const id of ids) {
|
|
const profile = profiles.get(id);
|
|
assert.deepEqual(profile.tags, ["bulk"]);
|
|
assert.equal(profile.note ?? null, null);
|
|
assert.equal(profile.group_id ?? null, null);
|
|
assert.equal(profile.status_id, status.id, "left out means kept");
|
|
assert.equal(profile.wayfern_config.fingerprint ?? null, null);
|
|
assert.equal(profile.wayfern_config.block_images, true);
|
|
}
|
|
assert.deepEqual((await app.invoke("get_all_tags")).sort(), ["bulk"]);
|
|
});
|
|
});
|
|
|
|
test("templates keep settings but never a device, and a batch stops at the hourly allowance", async () => {
|
|
await withApp(
|
|
"entities-templates",
|
|
async (app) => {
|
|
const status = await app.invoke("create_profile_status", {
|
|
name: "Fresh",
|
|
color: "#22c55e",
|
|
});
|
|
const profile = await createProfile(app, "Template Source");
|
|
await app.invoke("update_profile_note", {
|
|
profileId: profile.id,
|
|
note: "login for one shop",
|
|
});
|
|
await app.invoke("bulk_update_profiles", {
|
|
profileIds: [profile.id],
|
|
patch: {
|
|
status_id: status.id,
|
|
tags_add: ["shop"],
|
|
start_urls: ["shop.example"],
|
|
block_images: true,
|
|
},
|
|
});
|
|
|
|
const saved = await app.invoke("save_profile_template_from_profile", {
|
|
profileId: profile.id,
|
|
name: " Shop ",
|
|
});
|
|
assert.equal(saved.name, "Shop");
|
|
assert.equal(saved.settings.status_id, status.id);
|
|
assert.deepEqual(saved.settings.tags, ["shop"]);
|
|
assert.deepEqual(saved.settings.start_urls, ["https://shop.example/"]);
|
|
assert.equal(saved.settings.note, null, "a note belongs to its profile");
|
|
assert.equal(saved.settings.wayfern_config.block_images, true);
|
|
assert.equal(
|
|
saved.settings.wayfern_config.fingerprint ?? null,
|
|
null,
|
|
"a template never carries the source profile's device",
|
|
);
|
|
assert.equal(saved.settings.wayfern_config.identity_id ?? null, null);
|
|
|
|
assert.match(
|
|
await app.invokeError("save_profile_template_from_profile", {
|
|
profileId: profile.id,
|
|
name: "shop",
|
|
}),
|
|
/TEMPLATE_EXISTS/,
|
|
);
|
|
for (const [settings, code] of [
|
|
[{ proxy_id: "p", vpn_id: "v" }, /PROXY_AND_VPN_MUTUALLY_EXCLUSIVE/],
|
|
[{ status_id: "missing" }, /PROFILE_STATUS_NOT_FOUND/],
|
|
[{ start_urls: ["file:///etc/passwd"] }, /START_URL_INVALID/],
|
|
]) {
|
|
assert.match(
|
|
await app.invokeError("create_profile_template", {
|
|
name: "Broken",
|
|
settings,
|
|
}),
|
|
code,
|
|
);
|
|
}
|
|
assert.match(
|
|
await app.invokeError("create_profile_template", {
|
|
name: "x".repeat(61),
|
|
settings: {},
|
|
}),
|
|
/TEMPLATE_NAME_TOO_LONG/,
|
|
);
|
|
|
|
const mail = await app.invoke("create_profile_template", {
|
|
name: "Mail",
|
|
settings: {
|
|
name_prefix: " Mail ",
|
|
tags: ["mail", " mail "],
|
|
ephemeral: true,
|
|
clear_on_close: true,
|
|
wayfern_config: { fingerprint: "{}", os: "linux" },
|
|
},
|
|
});
|
|
assert.equal(mail.settings.name_prefix, "Mail");
|
|
assert.deepEqual(mail.settings.tags, ["mail"]);
|
|
assert.equal(mail.settings.clear_on_close, false);
|
|
assert.equal(mail.settings.wayfern_config.fingerprint ?? null, null);
|
|
assert.deepEqual(
|
|
(await app.invoke("list_profile_templates")).map((t) => t.name),
|
|
["Shop", "Mail"],
|
|
);
|
|
|
|
const renamed = await app.invoke("update_profile_template", {
|
|
templateId: mail.id,
|
|
name: "Webmail",
|
|
settings: { ...mail.settings, tags: ["webmail"] },
|
|
});
|
|
assert.equal(renamed.name, "Webmail");
|
|
assert.deepEqual(renamed.settings.tags, ["webmail"]);
|
|
assert.ok(renamed.updated_at >= mail.updated_at);
|
|
assert.match(
|
|
await app.invokeError("update_profile_template", {
|
|
templateId: mail.id,
|
|
name: "SHOP",
|
|
settings: {},
|
|
}),
|
|
/TEMPLATE_EXISTS/,
|
|
);
|
|
|
|
for (const count of [0, 101]) {
|
|
assert.match(
|
|
await app.invokeError("bulk_create_profiles", {
|
|
settings: saved.settings,
|
|
name: "Batch",
|
|
count,
|
|
}),
|
|
/BULK_CREATE_COUNT_INVALID/,
|
|
);
|
|
}
|
|
// Nothing to stop: cancelling with no batch running is harmless.
|
|
await app.invoke("cancel_bulk_create_profiles");
|
|
|
|
// The signed-out allowance is eight an hour. With eight used, one more
|
|
// manual profile would only warn, but a batch never goes past it. The
|
|
// window is read on its first use, and nothing above used it, so the
|
|
// file is read without a restart (a restart would also drop the seeded
|
|
// browser, which has no binary).
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await writeFile(
|
|
path.join(app.dataRoot, "data", "settings", "profile_generations.json"),
|
|
JSON.stringify({
|
|
generations: {
|
|
local: Array.from({ length: 8 }, (_, i) => now - 600 + i),
|
|
},
|
|
}),
|
|
);
|
|
const before = (await app.invoke("list_browser_profiles")).length;
|
|
assert.match(
|
|
await app.invokeError("bulk_create_profiles", {
|
|
settings: saved.settings,
|
|
name: "Batch",
|
|
count: 3,
|
|
}),
|
|
/PROFILE_GENERATION_LIMIT_REACHED/,
|
|
);
|
|
assert.equal(
|
|
(await app.invoke("list_browser_profiles")).length,
|
|
before,
|
|
"a refused batch makes nothing",
|
|
);
|
|
|
|
await app.invoke("delete_profile_template", { templateId: mail.id });
|
|
assert.match(
|
|
await app.invokeError("delete_profile_template", {
|
|
templateId: mail.id,
|
|
}),
|
|
/TEMPLATE_NOT_FOUND/,
|
|
);
|
|
assert.deepEqual(
|
|
(await app.invoke("list_profile_templates")).map((t) => t.id),
|
|
[saved.id],
|
|
);
|
|
},
|
|
{ seedDownloadedBrowser: true },
|
|
);
|
|
});
|
|
|
|
test("deleted profiles land in the trash and come back intact on restore", async () => {
|
|
await withApp("entities-trash", async (app) => {
|
|
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
|
|
|
const group = await app.invoke("create_profile_group", {
|
|
name: "Trash Group",
|
|
});
|
|
const created = await app.invoke("create_browser_profile_new", {
|
|
name: "Recoverable",
|
|
browserStr: "wayfern",
|
|
version: "150.0.7871.100",
|
|
releaseType: "stable",
|
|
proxyId: null,
|
|
vpnId: null,
|
|
wayfernConfig: {
|
|
fingerprint: "{}",
|
|
identity_id: "identity-e2e",
|
|
identity_overrides: JSON.stringify({ userAgent: "Custom UA" }),
|
|
location: JSON.stringify({
|
|
timezone: "Europe/Berlin",
|
|
language: "de-DE",
|
|
}),
|
|
},
|
|
groupId: group.id,
|
|
ephemeral: false,
|
|
dnsBlocklist: null,
|
|
launchHook: null,
|
|
});
|
|
await app.invoke("update_profile_tags", {
|
|
profileId: created.id,
|
|
tags: ["shop", "eu"],
|
|
});
|
|
const before = (await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === created.id,
|
|
);
|
|
assert.equal(before.wayfern_config.identity_id, "identity-e2e");
|
|
assert.deepEqual(before.tags, ["shop", "eu"]);
|
|
assert.equal(before.group_id, group.id);
|
|
|
|
// Real files to carry through the move, plus a cache the trash must drop.
|
|
const profilesDir = path.join(app.dataRoot, "data", "profiles");
|
|
const dataDir = path.join(profilesDir, created.id, "profile");
|
|
await mkdir(path.join(dataDir, "Default"), { recursive: true });
|
|
await writeFile(path.join(dataDir, "Default", "Cookies"), "cookie-db");
|
|
await mkdir(path.join(dataDir, "Cache"), { recursive: true });
|
|
await writeFile(path.join(dataDir, "Cache", "blob"), "cache-bytes");
|
|
|
|
await app.invoke("delete_profile", { profileId: created.id });
|
|
assert.equal(
|
|
(await app.invoke("list_browser_profiles")).some(
|
|
(item) => item.id === created.id,
|
|
),
|
|
false,
|
|
);
|
|
const trashed = await app.invoke("list_trashed_profiles");
|
|
assert.equal(trashed.length, 1);
|
|
assert.equal(trashed[0].id, created.id);
|
|
assert.equal(trashed[0].name, "Recoverable");
|
|
assert.equal(trashed[0].browser, "wayfern");
|
|
assert.equal(trashed[0].version, "150.0.7871.100");
|
|
assert.equal(trashed[0].group_id, group.id);
|
|
assert.equal(trashed[0].password_protected, false);
|
|
assert.equal(trashed[0].sync_enabled, false);
|
|
assert.equal(
|
|
trashed[0].expires_at - trashed[0].deleted_at,
|
|
30 * 24 * 60 * 60,
|
|
);
|
|
assert.ok(trashed[0].size_bytes > 0);
|
|
const trashDir = path.join(app.dataRoot, "data", "trash");
|
|
const entryDir = path.join(trashDir, created.id);
|
|
assert.ok(existsSync(path.join(entryDir, "profile.json")));
|
|
assert.ok(existsSync(path.join(entryDir, "manifest.json")));
|
|
assert.equal(
|
|
await readFile(
|
|
path.join(entryDir, "profile", "Default", "Cookies"),
|
|
"utf8",
|
|
),
|
|
"cookie-db",
|
|
);
|
|
assert.equal(
|
|
existsSync(path.join(entryDir, "profile", "Cache")),
|
|
false,
|
|
"caches are pruned before the move",
|
|
);
|
|
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
|
|
|
|
// A live profile carrying the same name pushes the restored one to a suffix.
|
|
const namesake = await createProfile(app, "Recoverable");
|
|
const restored = await app.invoke("restore_trashed_profile", {
|
|
profileId: created.id,
|
|
});
|
|
assert.equal(restored.id, created.id);
|
|
assert.equal(restored.name, "Recoverable (restored)");
|
|
assert.deepEqual(restored.wayfern_config, before.wayfern_config);
|
|
assert.deepEqual(restored.tags, before.tags);
|
|
assert.equal(restored.group_id, group.id);
|
|
assert.ok(restored.updated_at >= (before.updated_at ?? 0));
|
|
const live = (await app.invoke("list_browser_profiles")).find(
|
|
(item) => item.id === created.id,
|
|
);
|
|
assert.deepEqual(live.wayfern_config, before.wayfern_config);
|
|
assert.deepEqual(live.tags, before.tags);
|
|
assert.equal(
|
|
await readFile(
|
|
path.join(profilesDir, created.id, "profile", "Default", "Cookies"),
|
|
"utf8",
|
|
),
|
|
"cookie-db",
|
|
);
|
|
assert.equal(existsSync(entryDir), false);
|
|
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
|
assert.match(
|
|
await app.invokeError("restore_trashed_profile", {
|
|
profileId: created.id,
|
|
}),
|
|
/TRASH_ENTRY_NOT_FOUND/,
|
|
);
|
|
|
|
// A group deleted while the profile sat in the trash is not resurrected.
|
|
await app.invoke("delete_profile", { profileId: created.id });
|
|
await app.invoke("delete_profile_group", { groupId: group.id });
|
|
const restoredWithoutGroup = await app.invoke("restore_trashed_profile", {
|
|
profileId: created.id,
|
|
});
|
|
assert.equal(restoredWithoutGroup.id, created.id);
|
|
assert.equal(restoredWithoutGroup.group_id, null);
|
|
assert.deepEqual(
|
|
restoredWithoutGroup.wayfern_config,
|
|
before.wayfern_config,
|
|
);
|
|
|
|
// Delete again, then purge: gone for good.
|
|
await app.invoke("delete_profile", { profileId: created.id });
|
|
assert.equal((await app.invoke("list_trashed_profiles")).length, 1);
|
|
await app.invoke("purge_trashed_profile", { profileId: created.id });
|
|
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
|
assert.equal(existsSync(entryDir), false);
|
|
assert.equal(existsSync(path.join(profilesDir, created.id)), false);
|
|
assert.match(
|
|
await app.invokeError("purge_trashed_profile", {
|
|
profileId: created.id,
|
|
}),
|
|
/TRASH_ENTRY_NOT_FOUND/,
|
|
);
|
|
|
|
// An explicit permanent delete never lands in the trash.
|
|
const doomed = await createProfile(app, "Doomed");
|
|
await app.invoke("delete_profile", {
|
|
profileId: doomed.id,
|
|
permanent: true,
|
|
});
|
|
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
|
assert.equal(existsSync(path.join(trashDir, doomed.id)), false);
|
|
assert.equal(existsSync(path.join(profilesDir, doomed.id)), false);
|
|
|
|
// A bulk delete trashes every profile; emptying the trash clears them all.
|
|
const bulkA = await createProfile(app, "Bulk A");
|
|
const bulkB = await createProfile(app, "Bulk B");
|
|
await app.invoke("delete_selected_profiles", {
|
|
profileIds: [bulkA.id, bulkB.id],
|
|
});
|
|
assert.deepEqual(
|
|
(await app.invoke("list_trashed_profiles"))
|
|
.map((entry) => entry.name)
|
|
.sort(),
|
|
["Bulk A", "Bulk B"],
|
|
);
|
|
assert.equal(await app.invoke("empty_trash"), 2);
|
|
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
|
assert.equal(existsSync(path.join(trashDir, bulkA.id)), false);
|
|
|
|
// Restore refuses an entry whose id a live profile already carries.
|
|
const conflictDir = path.join(trashDir, namesake.id);
|
|
await mkdir(conflictDir, { recursive: true });
|
|
await writeFile(
|
|
path.join(conflictDir, "profile.json"),
|
|
JSON.stringify(namesake),
|
|
);
|
|
await writeFile(
|
|
path.join(conflictDir, "manifest.json"),
|
|
JSON.stringify({
|
|
deleted_at: 1,
|
|
expires_at: 4_102_444_800,
|
|
size_bytes: 0,
|
|
original_name: namesake.name,
|
|
}),
|
|
);
|
|
assert.match(
|
|
await app.invokeError("restore_trashed_profile", {
|
|
profileId: namesake.id,
|
|
}),
|
|
/TRASH_RESTORE_CONFLICT/,
|
|
);
|
|
await app.invoke("purge_trashed_profile", { profileId: namesake.id });
|
|
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
|
|
|
await app.invoke("delete_profile", {
|
|
profileId: namesake.id,
|
|
permanent: true,
|
|
});
|
|
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
|
|
});
|
|
});
|
|
|
|
test("Delete at once erases a profile on the spot and keeps nothing in the trash", async () => {
|
|
await withApp("entities-delete-at-once", async (app) => {
|
|
const profilesDir = path.join(app.dataRoot, "data", "profiles");
|
|
const trashDir = path.join(app.dataRoot, "data", "trash");
|
|
const secret = "secret-cookie-db";
|
|
const seed = async (profile) => {
|
|
const dir = path.join(profilesDir, profile.id, "profile", "Default");
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path.join(dir, "Cookies"), secret);
|
|
return path.join(dir, "Cookies");
|
|
};
|
|
|
|
// Switching to "Delete at once" empties what the trash already holds.
|
|
const earlier = await createProfile(app, "Already Trashed");
|
|
await seed(earlier);
|
|
await app.invoke("delete_profile", { profileId: earlier.id });
|
|
assert.equal((await app.invoke("list_trashed_profiles")).length, 1);
|
|
const settings = await app.invoke("get_app_settings");
|
|
await app.invoke("save_app_settings", {
|
|
settings: { ...settings, trash_retention_days: 0 },
|
|
});
|
|
await app.waitFor(
|
|
async () =>
|
|
(await app.invoke("list_trashed_profiles")).length === 0 &&
|
|
!existsSync(path.join(trashDir, earlier.id)),
|
|
{
|
|
description: "the trash empties once it is switched off",
|
|
timeoutMs: 20_000,
|
|
intervalMs: 250,
|
|
},
|
|
);
|
|
|
|
const doomed = await createProfile(app, "Delete At Once");
|
|
const cookies = await seed(doomed);
|
|
// A handle opened before the delete still reads the file after the
|
|
// unlink, so it shows whether the bytes were overwritten first. Windows
|
|
// cannot remove a directory while a file in it is open.
|
|
const handle = process.platform === "win32" ? null : await open(cookies);
|
|
try {
|
|
await app.invoke("delete_profile", { profileId: doomed.id });
|
|
assert.equal(
|
|
existsSync(path.join(profilesDir, doomed.id)),
|
|
false,
|
|
"the profile is erased before the delete returns",
|
|
);
|
|
assert.equal(existsSync(path.join(trashDir, doomed.id)), false);
|
|
assert.deepEqual(await app.invoke("list_trashed_profiles"), []);
|
|
assert.equal(
|
|
(await app.invoke("list_browser_profiles")).some(
|
|
(item) => item.id === doomed.id,
|
|
),
|
|
false,
|
|
);
|
|
if (handle) {
|
|
const { buffer, bytesRead } = await handle.read(
|
|
Buffer.alloc(64),
|
|
0,
|
|
64,
|
|
0,
|
|
);
|
|
assert.equal(bytesRead, secret.length);
|
|
assert.ok(
|
|
buffer.subarray(0, bytesRead).every((byte) => byte === 0),
|
|
"the file is zeroed before it is unlinked",
|
|
);
|
|
}
|
|
} finally {
|
|
await handle?.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
test("proxies distribute one to one, and group bookmarks reach the profile's Bookmarks file", async () => {
|
|
await withApp("entities-distribution-bookmarks", async (app) => {
|
|
const profiles = [];
|
|
for (const name of ["Fleet 1", "Fleet 2", "Fleet 3", "Fleet 4"]) {
|
|
profiles.push(await createProfile(app, name));
|
|
}
|
|
const proxies = [];
|
|
for (const [index, name] of ["Exit A", "Exit B", "Exit C"].entries()) {
|
|
proxies.push(
|
|
await app.invoke("create_stored_proxy", {
|
|
name,
|
|
proxySettings: {
|
|
proxy_type: "http",
|
|
host: "127.0.0.1",
|
|
port: 9001 + index,
|
|
username: null,
|
|
password: null,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const profileIds = profiles.map((profile) => profile.id);
|
|
const proxyIds = proxies.map((proxy) => proxy.id);
|
|
|
|
// Four profiles, three proxies: three pairs and one profile left alone.
|
|
// The fourth must NEVER wrap around onto the first proxy.
|
|
const plan = await app.invoke("plan_proxy_distribution", {
|
|
profileIds,
|
|
proxyIds,
|
|
allowSharing: false,
|
|
});
|
|
assert.deepEqual(
|
|
plan.pairs,
|
|
proxyIds.map((proxyId, index) => ({
|
|
profile_id: profileIds[index],
|
|
proxy_id: proxyId,
|
|
})),
|
|
);
|
|
assert.deepEqual(plan.unpaired_profile_ids, [profileIds[3]]);
|
|
assert.deepEqual(plan.unused_proxy_ids, []);
|
|
assert.deepEqual(plan.shared_proxy_ids, []);
|
|
assert.deepEqual(plan.running_profile_ids, []);
|
|
|
|
const results = await app.invoke("distribute_proxies_to_profiles", {
|
|
pairs: plan.pairs,
|
|
});
|
|
assert.equal(results.length, 3);
|
|
assert.ok(results.every((result) => result.ok));
|
|
|
|
const afterDistribution = await app.invoke("list_browser_profiles");
|
|
const proxyOf = (id) =>
|
|
afterDistribution.find((profile) => profile.id === id).proxy_id;
|
|
assert.equal(proxyOf(profileIds[0]), proxyIds[0]);
|
|
assert.equal(proxyOf(profileIds[1]), proxyIds[1]);
|
|
assert.equal(proxyOf(profileIds[2]), proxyIds[2]);
|
|
assert.equal(proxyOf(profileIds[3]) ?? null, null);
|
|
|
|
// A proxy someone else holds is refused by default and only offered once
|
|
// the caller asks for sharing explicitly.
|
|
const strict = await app.invoke("plan_proxy_distribution", {
|
|
profileIds: [profileIds[3]],
|
|
proxyIds: [proxyIds[0]],
|
|
allowSharing: false,
|
|
});
|
|
assert.deepEqual(strict.pairs, []);
|
|
assert.deepEqual(strict.shared_proxy_ids, [proxyIds[0]]);
|
|
assert.deepEqual(strict.unpaired_profile_ids, [profileIds[3]]);
|
|
|
|
const permissive = await app.invoke("plan_proxy_distribution", {
|
|
profileIds: [profileIds[3]],
|
|
proxyIds: [proxyIds[0]],
|
|
allowSharing: true,
|
|
});
|
|
assert.deepEqual(permissive.pairs, [
|
|
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
|
|
]);
|
|
|
|
// Per-profile failures never break the batch: one good pair still lands.
|
|
const mixed = await app.invoke("distribute_proxies_to_profiles", {
|
|
pairs: [
|
|
{ profile_id: profileIds[3], proxy_id: proxyIds[0] },
|
|
{ profile_id: profileIds[3], proxy_id: proxyIds[1] },
|
|
{
|
|
profile_id: profileIds[0],
|
|
proxy_id: "00000000-0000-4000-8000-000000000000",
|
|
},
|
|
],
|
|
});
|
|
assert.equal(mixed[0].ok, true);
|
|
assert.equal(mixed[1].ok, false);
|
|
assert.match(mixed[1].error, /PROFILE_PAIRED_TWICE/);
|
|
assert.equal(mixed[2].ok, false);
|
|
assert.match(mixed[2].error, /PROXY_NOT_FOUND/);
|
|
assert.equal(
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(profile) => profile.id === profileIds[3],
|
|
).proxy_id,
|
|
proxyIds[0],
|
|
);
|
|
|
|
// --- group bookmarks ---
|
|
const group = await app.invoke("create_profile_group", {
|
|
name: "Client Sites",
|
|
});
|
|
assert.deepEqual(
|
|
await app.invoke("get_group_bookmarks", { groupId: group.id }),
|
|
[],
|
|
);
|
|
|
|
const refused = await app.invokeError("set_group_bookmarks", {
|
|
groupId: group.id,
|
|
bookmarks: [{ title: "Keys", url: "file:///etc/passwd", folder: null }],
|
|
});
|
|
assert.match(refused, /URL_SCHEME_NOT_ALLOWED/);
|
|
const unnamed = await app.invokeError("set_group_bookmarks", {
|
|
groupId: group.id,
|
|
bookmarks: [{ title: " ", url: "https://ok.example", folder: null }],
|
|
});
|
|
assert.match(unnamed, /NAME_CANNOT_BE_EMPTY/);
|
|
|
|
const saved = await app.invoke("set_group_bookmarks", {
|
|
groupId: group.id,
|
|
bookmarks: [
|
|
{ title: "Support", url: "https://support.example", folder: null },
|
|
{ title: "Console", url: "https://console.example", folder: "Ops" },
|
|
],
|
|
});
|
|
assert.equal(saved.length, 2);
|
|
assert.equal(saved[1].folder, "Ops");
|
|
assert.equal(
|
|
(await app.invoke("get_groups_with_profile_counts")).find(
|
|
(item) => item.id === group.id,
|
|
).bookmark_count,
|
|
2,
|
|
);
|
|
|
|
const target = profiles[0];
|
|
await app.invoke("assign_profiles_to_group", {
|
|
profileIds: [target.id],
|
|
groupId: group.id,
|
|
});
|
|
|
|
// Seed the profile's own Bookmarks file the way a real Chromium session
|
|
// would have left it, so the write has something of the user's to preserve.
|
|
const bookmarksFile = path.join(
|
|
app.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
target.id,
|
|
"profile",
|
|
"Default",
|
|
"Bookmarks",
|
|
);
|
|
await mkdir(path.dirname(bookmarksFile), { recursive: true });
|
|
const permanentFolder = (id, name) => ({
|
|
children: [],
|
|
date_added: "13300000000000000",
|
|
date_modified: "13300000000000000",
|
|
guid: `0000000${id}-0000-4000-8000-000000000000`,
|
|
id: String(id),
|
|
name,
|
|
type: "folder",
|
|
});
|
|
await writeFile(
|
|
bookmarksFile,
|
|
JSON.stringify({
|
|
checksum: "0".repeat(32),
|
|
roots: {
|
|
bookmark_bar: {
|
|
...permanentFolder(1, "Bookmarks bar"),
|
|
children: [
|
|
{
|
|
date_added: "13300000000000000",
|
|
guid: "aaaaaaaa-0000-4000-8000-000000000000",
|
|
id: "9",
|
|
name: "My Bank",
|
|
type: "url",
|
|
url: "https://bank.example/",
|
|
},
|
|
],
|
|
},
|
|
other: permanentFolder(2, "Other bookmarks"),
|
|
synced: permanentFolder(3, "Mobile bookmarks"),
|
|
},
|
|
sync_metadata: "Zm9v",
|
|
version: 1,
|
|
}),
|
|
);
|
|
|
|
const readBookmarks = async () =>
|
|
JSON.parse(await readFile(bookmarksFile, "utf8"));
|
|
const managedFolderOf = (document) =>
|
|
document.roots.bookmark_bar.children.filter(
|
|
(child) =>
|
|
child.type === "folder" &&
|
|
child.meta_info?.donut_managed_group_bookmarks === "1",
|
|
);
|
|
|
|
assert.equal(
|
|
await app.invoke("apply_group_bookmarks_to_profile", {
|
|
profileId: target.id,
|
|
}),
|
|
true,
|
|
);
|
|
|
|
let document = await readBookmarks();
|
|
let managed = managedFolderOf(document);
|
|
assert.equal(managed.length, 1);
|
|
assert.equal(managed[0].name, "Donut Group Bookmarks");
|
|
assert.deepEqual(
|
|
managed[0].children.map((child) => child.name),
|
|
["Support", "Ops"],
|
|
);
|
|
assert.deepEqual(
|
|
managed[0].children[1].children.map((child) => child.url),
|
|
["https://console.example"],
|
|
);
|
|
// The user's own bookmark, the other roots and Chromium's opaque state all
|
|
// survive; only the checksum is rewritten to describe the new tree.
|
|
assert.equal(document.roots.bookmark_bar.children[0].name, "My Bank");
|
|
assert.equal(document.sync_metadata, "Zm9v");
|
|
assert.equal(document.version, 1);
|
|
assert.notEqual(document.checksum, "0".repeat(32));
|
|
assert.match(document.checksum, /^[0-9a-f]{32}$/);
|
|
|
|
// Applying again is a no-op: the folder is not duplicated and the file is
|
|
// not even rewritten.
|
|
const firstWrite = await readFile(bookmarksFile, "utf8");
|
|
assert.equal(
|
|
await app.invoke("apply_group_bookmarks_to_profile", {
|
|
profileId: target.id,
|
|
}),
|
|
false,
|
|
);
|
|
assert.equal(await readFile(bookmarksFile, "utf8"), firstWrite);
|
|
|
|
// Removing a bookmark from the group removes it from the folder next time.
|
|
await app.invoke("set_group_bookmarks", {
|
|
groupId: group.id,
|
|
bookmarks: [
|
|
{ title: "Support", url: "https://support.example", folder: null },
|
|
],
|
|
});
|
|
assert.equal(
|
|
await app.invoke("apply_group_bookmarks_to_profile", {
|
|
profileId: target.id,
|
|
}),
|
|
true,
|
|
);
|
|
document = await readBookmarks();
|
|
managed = managedFolderOf(document);
|
|
assert.equal(managed.length, 1);
|
|
assert.deepEqual(
|
|
managed[0].children.map((child) => child.name),
|
|
["Support"],
|
|
);
|
|
|
|
// Emptying the group takes the whole folder away and leaves the user's own.
|
|
await app.invoke("set_group_bookmarks", {
|
|
groupId: group.id,
|
|
bookmarks: [],
|
|
});
|
|
assert.equal(
|
|
await app.invoke("apply_group_bookmarks_to_profile", {
|
|
profileId: target.id,
|
|
}),
|
|
true,
|
|
);
|
|
document = await readBookmarks();
|
|
assert.deepEqual(managedFolderOf(document), []);
|
|
assert.deepEqual(
|
|
document.roots.bookmark_bar.children.map((child) => child.name),
|
|
["My Bank"],
|
|
);
|
|
|
|
// A profile in no group is left entirely alone.
|
|
assert.equal(
|
|
await app.invoke("apply_group_bookmarks_to_profile", {
|
|
profileId: profileIds[1],
|
|
}),
|
|
false,
|
|
);
|
|
|
|
await app.invoke("delete_selected_profiles", { profileIds });
|
|
await app.invoke("delete_profile_group", { groupId: group.id });
|
|
for (const proxy of proxies) {
|
|
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
|
|
}
|
|
});
|
|
});
|
|
|
|
test("custom launch arguments require Pro and can be cleared after access ends", async () => {
|
|
await withApp(
|
|
"entities-launch-args-free",
|
|
async (app) => {
|
|
const profile = await createProfile(app, "Free Launch Arguments");
|
|
const error = await app.invokeError("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: { ...profile.wayfern_config, launch_args: ["--disable-gpu"] },
|
|
});
|
|
assert.match(error, /"code":"WAYFERN_LAUNCH_ARGS_REQUIRES_PRO"/);
|
|
assert.deepEqual(
|
|
(await app.invoke("list_browser_profiles"))[0].wayfern_config
|
|
.launch_args ?? [],
|
|
[],
|
|
);
|
|
const createError = await app.invokeError("create_browser_profile_new", {
|
|
name: "Refused Launch Arguments",
|
|
browserStr: "wayfern",
|
|
version: profile.version,
|
|
releaseType: "stable",
|
|
wayfernConfig: { fingerprint: "{}", launch_args: ["--disable-gpu"] },
|
|
});
|
|
assert.match(createError, /"code":"WAYFERN_LAUNCH_ARGS_REQUIRES_PRO"/);
|
|
assert.equal((await app.invoke("list_browser_profiles")).length, 1);
|
|
|
|
const metadata = path.join(
|
|
app.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
profile.id,
|
|
"metadata.json",
|
|
);
|
|
const stored = JSON.parse(await readFile(metadata, "utf8"));
|
|
stored.wayfern_config.launch_args = ["--disable-gpu"];
|
|
stored.updated_at = 1;
|
|
await writeFile(metadata, JSON.stringify(stored));
|
|
await app.invoke("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: { ...stored.wayfern_config, launch_args: [] },
|
|
});
|
|
const cleared = (await app.invoke("list_browser_profiles"))[0];
|
|
assert.deepEqual(cleared.wayfern_config.launch_args ?? [], []);
|
|
assert.ok(cleared.updated_at > 1);
|
|
assert.equal(
|
|
cleared.wayfern_config.fingerprint,
|
|
profile.wayfern_config.fingerprint,
|
|
);
|
|
assert.match(
|
|
await app.invokeError("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: {
|
|
...cleared.wayfern_config,
|
|
fingerprint: '{"userAgent":"changed"}',
|
|
},
|
|
}),
|
|
/"code":"FINGERPRINT_REQUIRES_PRO"/,
|
|
);
|
|
},
|
|
{ extraEnv: { WAYFERN_TEST_TOKEN: "" } },
|
|
);
|
|
});
|
|
|
|
test("Pro launch arguments persist, clone, validate, and update the edit time", async () => {
|
|
await withApp(
|
|
"entities-launch-args-pro",
|
|
async (app) => {
|
|
const profile = await createProfile(app, "Pro Launch Arguments");
|
|
const metadata = path.join(
|
|
app.dataRoot,
|
|
"data",
|
|
"profiles",
|
|
profile.id,
|
|
"metadata.json",
|
|
);
|
|
const stored = JSON.parse(await readFile(metadata, "utf8"));
|
|
stored.updated_at = 1;
|
|
await writeFile(metadata, JSON.stringify(stored));
|
|
const custom = [
|
|
"--disable-gpu",
|
|
"--autoplay-policy=no-user-gesture-required",
|
|
];
|
|
await app.invoke("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: {
|
|
...stored.wayfern_config,
|
|
launch_args: [" --disable-gpu ", "", custom[1]],
|
|
},
|
|
});
|
|
const saved = JSON.parse(await readFile(metadata, "utf8"));
|
|
assert.deepEqual(saved.wayfern_config.launch_args, custom);
|
|
assert.ok(saved.updated_at > 1);
|
|
const cloned = await app.invoke("clone_profile", {
|
|
profileId: profile.id,
|
|
name: "Cloned Launch Arguments",
|
|
});
|
|
assert.deepEqual(cloned.wayfern_config.launch_args, custom);
|
|
saved.updated_at = 2;
|
|
await writeFile(metadata, JSON.stringify(saved));
|
|
await app.invoke("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: saved.wayfern_config,
|
|
});
|
|
assert.equal(JSON.parse(await readFile(metadata, "utf8")).updated_at, 2);
|
|
for (const [arg, code] of [
|
|
["--user-data-dir=/tmp/other", "WAYFERN_LAUNCH_ARG_RESERVED"],
|
|
["--window-size 800,600", "WAYFERN_LAUNCH_ARG_INVALID"],
|
|
["--renderer-cmd-prefix=/tmp/x", "WAYFERN_LAUNCH_ARG_NOT_ALLOWED"],
|
|
["--renderer-process-limit=999", "WAYFERN_LAUNCH_ARG_VALUE_INVALID"],
|
|
]) {
|
|
assert.match(
|
|
await app.invokeError("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: { ...saved.wayfern_config, launch_args: [arg] },
|
|
}),
|
|
new RegExp(`"code":"${code}"`),
|
|
);
|
|
assert.deepEqual(
|
|
JSON.parse(await readFile(metadata, "utf8")).wayfern_config
|
|
.launch_args,
|
|
custom,
|
|
);
|
|
}
|
|
await app.invoke("update_wayfern_config", {
|
|
profileId: profile.id,
|
|
config: { ...saved.wayfern_config, launch_args: [] },
|
|
});
|
|
assert.deepEqual(
|
|
JSON.parse(await readFile(metadata, "utf8")).wayfern_config
|
|
.launch_args ?? [],
|
|
[],
|
|
);
|
|
assert.deepEqual(
|
|
(await app.invoke("list_browser_profiles")).find(
|
|
(p) => p.id === cloned.id,
|
|
).wayfern_config.launch_args,
|
|
custom,
|
|
);
|
|
},
|
|
{ extraEnv: { WAYFERN_TEST_TOKEN: "e2e-local-launch-args" } },
|
|
);
|
|
});
|
|
|
|
test("the hourly profile allowance counts from a window that survives a restart", async () => {
|
|
await withApp("entities-generation-allowance", async (app) => {
|
|
// Signed out is the free plan: warned past its allowance, never refused,
|
|
// and with no profiles yet the allowance is eight an hour.
|
|
assert.deepEqual(await app.invoke("get_profile_creation_allowance"), {
|
|
enforcement: "soft",
|
|
per_hour: 8,
|
|
used: 0,
|
|
retry_after_secs: null,
|
|
});
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await app.close();
|
|
await writeFile(
|
|
path.join(app.dataRoot, "data", "settings", "profile_generations.json"),
|
|
JSON.stringify({
|
|
generations: {
|
|
local: [
|
|
now - 4_000,
|
|
...Array.from({ length: 8 }, (_, i) => now - 600 + i),
|
|
],
|
|
// Another account's generations and ones older than an hour do
|
|
// not count against this device's signed-out allowance.
|
|
"someone-else": [now - 60],
|
|
},
|
|
}),
|
|
);
|
|
await app.start();
|
|
|
|
const full = await app.invoke("get_profile_creation_allowance");
|
|
assert.equal(full.enforcement, "soft");
|
|
assert.equal(full.per_hour, 8);
|
|
assert.equal(full.used, 8);
|
|
// The oldest of the eight leaves the window about 50 minutes from now.
|
|
assert.ok(
|
|
full.retry_after_secs > 2_900 && full.retry_after_secs <= 3_000,
|
|
`retry_after_secs ${full.retry_after_secs}`,
|
|
);
|
|
});
|
|
});
|