mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-11 05:30:29 +02:00
refactor: profile imports
This commit is contained in:
+456
-238
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user