refactor: profile imports

This commit is contained in:
zhom
2026-08-10 09:19:25 +04:00
parent a6b79341b3
commit 32a1728dee
33 changed files with 4917 additions and 387 deletions
+78 -3
View File
@@ -680,6 +680,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
@@ -967,6 +976,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
@@ -1785,7 +1803,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.0"
version = "0.29.1"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1797,7 +1815,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2",
"cbc",
"cbc 0.2.1",
"chrono",
"chrono-tz",
"clap",
@@ -1832,6 +1850,8 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"secret-service",
"security-framework",
"serde",
"serde_json",
"serde_yaml",
@@ -3379,6 +3399,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
@@ -3388,7 +3409,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4085,6 +4106,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4095,6 +4130,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -4121,6 +4165,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -5713,6 +5767,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -7198,6 +7271,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -8985,6 +9059,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
+51
View File
@@ -68,6 +68,7 @@ export class AppSession {
extraEnv = {},
args = [],
seedVersionCache = true,
seedDownloadedBrowser = false,
onboardingCompleted = true,
wayfernTermsAccepted = true,
}) {
@@ -80,6 +81,7 @@ export class AppSession {
this.extraEnv = extraEnv;
this.args = args;
this.seedVersionCache = seedVersionCache;
this.seedDownloadedBrowser = seedDownloadedBrowser;
this.onboardingCompleted = onboardingCompleted;
this.wayfernTermsAccepted = wayfernTermsAccepted;
this.session = null;
@@ -184,6 +186,54 @@ export class AppSession {
}
});
}
if (this.seedDownloadedBrowser) {
// Registers a Wayfern version as "downloaded" without installing a
// binary. Profile import derives its version from this registry and
// fails with BROWSER_NOT_DOWNLOADED otherwise, so suites that exercise
// import but never launch a browser need the entry and nothing else.
const seededVersion =
typeof this.seedDownloadedBrowser === "string"
? this.seedDownloadedBrowser
: "150.0.7871.100";
const installDir = path.join(
this.dataRoot,
"data",
"binaries",
"wayfern",
seededVersion,
);
await mkdir(installDir, { recursive: true });
const registryPath = path.join(
this.dataRoot,
"data",
"data",
"downloaded_browsers.json",
);
await mkdir(path.dirname(registryPath), { recursive: true });
await writeFile(
registryPath,
`${JSON.stringify(
{
browsers: {
wayfern: {
[seededVersion]: {
browser: "wayfern",
version: seededVersion,
file_path: installDir,
},
},
},
},
null,
2,
)}\n`,
{ flag: "wx" },
).catch((error) => {
if (error.code !== "EEXIST") {
throw error;
}
});
}
const env = isolatedEnvironment(this.root, {
DONUT_E2E_DISABLE_STARTUP_NETWORK: "1",
...(process.env.DONUT_E2E_FIXTURE_URL
@@ -525,6 +575,7 @@ export function appFromEnvironment(name, options = {}) {
extraEnv: options.extraEnv,
args: options.args,
seedVersionCache: options.seedVersionCache,
seedDownloadedBrowser: options.seedDownloadedBrowser,
onboardingCompleted: options.onboardingCompleted,
wayfernTermsAccepted: options.wayfernTermsAccepted,
});
+99
View File
@@ -12,6 +12,7 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
export const TEST_BROWSER_VERSION = "150.0.7871.100";
@@ -220,3 +221,101 @@ export function currentHostOs() {
? "windows"
: "linux";
}
/**
* Write a Chromium cookie store at schema version 24 with plaintext values.
*
* Plaintext is deliberate: it is what a store looks like when the source
* browser could not reach its keyring, and it lets the suite assert that
* import seals every row with the target profile's key. Chromium reads a row
* whose `encrypted_value` is empty, and drops any row where both columns are
* set, so "value cleared and encrypted_value populated" is the only shape that
* actually loads.
*/
export function writeChromiumCookies(dbPath, cookies) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL DEFAULT '',
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
last_access_utc INTEGER NOT NULL,
has_expires INTEGER NOT NULL DEFAULT 1,
is_persistent INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
samesite INTEGER NOT NULL DEFAULT -1,
source_scheme INTEGER NOT NULL DEFAULT 0,
source_port INTEGER NOT NULL DEFAULT -1,
last_update_utc INTEGER NOT NULL DEFAULT 0,
source_type INTEGER NOT NULL DEFAULT 0,
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX cookies_unique_index
ON cookies(host_key, top_frame_site_key, name, path);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '24');
INSERT INTO meta VALUES('last_compatible_version', '24');
`);
const insert = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0)`,
);
// `encrypted` cookies are written the way Chromium's v23->v24 migration
// does: BindString into a BLOB column, which leaves the storage class as
// TEXT. Reading that as a strict blob returns empty and silently blanks the
// cookie, so the suite has to reproduce it rather than only binding blobs.
const insertAsText = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, '', CAST(? AS TEXT), '/', 0, 0, 0, 0)`,
);
let creation = 13000000000000000;
for (const cookie of cookies) {
if (cookie.encryptedValueText === undefined) {
insert.run(creation++, cookie.host, cookie.name, cookie.value, "");
} else {
insertAsText.run(
creation++,
cookie.host,
cookie.name,
cookie.encryptedValueText,
);
}
}
db.close();
}
/** Write a Chromium History database holding the given URLs. */
export function writeChromiumHistory(dbPath, urls) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE urls(
id INTEGER PRIMARY KEY AUTOINCREMENT,
url LONGVARCHAR,
title LONGVARCHAR,
visit_count INTEGER DEFAULT 0 NOT NULL,
typed_count INTEGER DEFAULT 0 NOT NULL,
last_visit_time INTEGER NOT NULL,
hidden INTEGER DEFAULT 0 NOT NULL
);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '69');
INSERT INTO meta VALUES('last_compatible_version', '16');
`);
const insert = db.prepare(
"INSERT INTO urls(url, title, visit_count, typed_count, last_visit_time, hidden) VALUES(?, ?, 1, 0, ?, 0)",
);
let visit = 13000000000000000;
for (const url of urls) {
insert.run(url, url, visit++);
}
db.close();
}
+456 -238
View File
@@ -1,9 +1,16 @@
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdir, readFile, 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 { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
import {
extensionZipBase64,
wireGuardFixture,
writeChromiumCookies,
writeChromiumHistory,
} from "../lib/fixtures.mjs";
async function createProfile(app, name = "Entity Profile") {
return app.invoke("create_browser_profile_new", {
@@ -24,257 +31,468 @@ async function createProfile(app, name = "Entity Profile") {
}
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
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");
// 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 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);
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);
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);
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);
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);
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);
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). 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.
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.equal(
await app.invoke("validate_vless_uri", { uri: goodVless }),
null,
);
for (const [uri, reason] of [
[goodVless.replace("security=reality", "security=tls"), "security"],
[goodVless.replace("type=tcp", "type=ws"), "transport"],
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
]) {
// 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}`,
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). 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.
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.equal(
await app.invoke("validate_vless_uri", { uri: goodVless }),
null,
);
}
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: [
for (const [uri, reason] of [
[goodVless.replace("security=reality", "security=tls"), "security"],
[goodVless.replace("type=tcp", "type=ws"), "transport"],
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
]) {
// 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);
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",
});
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",
});
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.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: [
{
name: "Imported Proxy",
type: "http",
host: "127.0.0.1",
port: 8081,
source_path: scanned[0].path,
browser_type: scanned[0].browser,
new_profile_name: "Imported Profile",
proxy_id: null,
vpn_id: null,
},
],
}),
});
assert.equal(importResult.imported_count, 1);
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 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",
});
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",
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
});
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 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.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
"automation",
]);
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",
);
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" } }),
);
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",
wayfernConfig: null,
});
assert.equal(importBatch.imported_count + importBatch.failed_count, 1);
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 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 clone = await app.invoke("clone_profile", {
profileId: profile.id,
name: "Cloned Profile",
});
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
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 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",
);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.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 });
}
});
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");
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);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.id, imported.profile_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 () => {