Add the React console, strengthen synchronization and retry handling, and export WES MySQL and MongoDB configuration packages.
212 lines
12 KiB
JavaScript
212 lines
12 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const test = require("node:test");
|
|
const vm = require("node:vm");
|
|
|
|
const app = fs.readFileSync(path.join(__dirname, "../cmd/syncdb/web/app.js"), "utf8");
|
|
function section(start, end) {
|
|
const from = app.indexOf(start), to = app.indexOf(end, from);
|
|
assert.ok(from >= 0 && to > from, start);
|
|
return app.slice(from, to);
|
|
}
|
|
const script = [
|
|
section("const translations =", "const pages ="),
|
|
section("function input(", "function binlogPickerField("),
|
|
section("const jsonFields =", "function submitLabel("),
|
|
section("function objectNames(", "function setSubmitting("),
|
|
section("async function pickObjects(", "const detailLabels ="),
|
|
section("async function browseObjects(", "const pendingActions ="),
|
|
section("function validAccountHost(", "function validAccountPassword("),
|
|
section("function openAccountWizard(", "function accountReviewSQL("),
|
|
section("function accountFieldChanged(", 'byId("account-form").addEventListener'),
|
|
section("function editorScopeChanged(", 'byId("editor").addEventListener("input"')
|
|
].join("\n");
|
|
|
|
function harness(kind = "newSource") {
|
|
const elements = new Map();
|
|
const element = id => {
|
|
if (!elements.has(id)) elements.set(id, {
|
|
innerHTML: "", textContent: "", open: true, disabled: false,
|
|
replaceChildren() { this.innerHTML = ""; this.textContent = ""; },
|
|
showModal() { this.open = true; },
|
|
querySelector() { return element("schema-apply"); }
|
|
});
|
|
return elements.get(id);
|
|
};
|
|
element("editor").elements = {};
|
|
element("account-form").elements = { database: { value: "", readOnly: false } };
|
|
const calls = [];
|
|
let reply = async () => [];
|
|
const state = { sources: [
|
|
{ id: "bound", name: "Bound", type: "mysql", environment: "testing", database: "default_db" },
|
|
{ id: "server", name: "Server", type: "mysql", environment: "testing", database: "" },
|
|
{ id: "mongo", name: "Mongo", type: "mongodb", environment: "testing", database: "" }
|
|
] };
|
|
const context = vm.createContext({
|
|
document: { getElementById: element }, window: {}, localStorage: { getItem: () => "en" },
|
|
location: { protocol: "https:" }, state, editorKind: kind, dialogVersion: 1,
|
|
objectRequestVersion: 0, selectedID: "server", submitting: false, schemaPlan: null,
|
|
notice: key => key, button: () => "", table: (_name, _columns, rows) => JSON.stringify(rows),
|
|
isProduction: () => false, sourceName: id => id, exportKind: () => "Local database backup",
|
|
userError: error => error.message,
|
|
listResult: value => { assert.ok(Array.isArray(value)); return value; },
|
|
FormData: class { constructor(form) {
|
|
return Object.entries(form.elements).filter(([, value]) => !value.disabled && value.value !== undefined)
|
|
.map(([name, control]) => [name, control.value]);
|
|
} },
|
|
request: url => { calls.push(url); return reply(url); },
|
|
openDetail: () => ++context.dialogVersion,
|
|
accountInputNames: ["source_id", "action", "role", "username", "host", "database"],
|
|
resetAccountWizard() {}, updateAccountControls() {}, clearAccountPassword() {}, invalidateAccountPlan() {}
|
|
});
|
|
vm.runInContext(script, context);
|
|
return {
|
|
context, element, calls, state,
|
|
reply: callback => { reply = callback; },
|
|
fields(values) { element("editor").elements = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value, checked: false }])); },
|
|
values() { return JSON.parse(JSON.stringify(context.editorValues())); }
|
|
};
|
|
}
|
|
|
|
function inputTag(html, name) {
|
|
const tag = html.match(new RegExp(`<input name="${name}"[^>]*>`));
|
|
assert.ok(tag, `${name} input exists`);
|
|
return tag[0];
|
|
}
|
|
|
|
test("a data source can be saved without a database while preserving an unchanged password", () => {
|
|
const ui = harness("editSource");
|
|
const html = ui.context.sourceFields({ name: "Server", type: "mysql" });
|
|
assert.doesNotMatch(inputTag(html, "database"), /required/);
|
|
assert.match(html, /Default database \(optional\)/);
|
|
ui.fields({ name: "Server", type: "mysql", endpoint: "localhost:3306", username: "reader", database: "", password: "" });
|
|
const values = ui.values();
|
|
assert.equal(values.database, "");
|
|
assert.equal(Object.hasOwn(values, "password"), false);
|
|
});
|
|
|
|
test("task database fields prefer the saved operation scope and otherwise use source defaults", () => {
|
|
const ui = harness("newTask");
|
|
const html = ui.context.taskFields({ source_id: "bound", target_id: "server", source_database: "orders", target_database: "archive" });
|
|
assert.match(inputTag(html, "source_database"), /value="orders".*required/);
|
|
assert.match(inputTag(html, "target_database"), /value="archive".*required/);
|
|
assert.match(inputTag(ui.context.taskFields({ source_id: "bound", target_id: "server" }), "source_database"), /value="default_db"/);
|
|
assert.match(inputTag(ui.context.exportFields(), "database"), /value="default_db".*required/);
|
|
});
|
|
|
|
test("task, export and schema payloads preserve explicit database scopes and require them", () => {
|
|
for (const kind of ["newTask", "editTask", "newExport", "compare"]) {
|
|
const ui = harness(kind);
|
|
const values = kind === "newExport"
|
|
? { source_id: "server", database: "orders", objects: "items", confirm_production: "" }
|
|
: { name: "Copy", source_id: "bound", target_id: "server", source_database: "orders", target_database: "archive",
|
|
...(kind === "compare" ? { source_object: "items", target_object: "items_copy" } : { objects: "items", dry_run: "", enabled: "" }) };
|
|
ui.fields(values);
|
|
const payload = ui.values();
|
|
assert.equal(payload[kind === "newExport" ? "database" : "source_database"], "orders");
|
|
if (kind !== "newExport") assert.equal(payload.target_database, "archive");
|
|
assert.equal(ui.state.sources[0].database, "default_db", "operation scope never changes the connection default");
|
|
ui.element("editor").elements[kind === "newExport" ? "database" : "source_database"].value = "";
|
|
assert.throws(() => ui.values(), { message: "databaseRequired" });
|
|
}
|
|
});
|
|
|
|
test("local backup uses an explicit database and bounded parallel settings", () => {
|
|
const ui = harness("quickBackup");
|
|
const html = ui.context.backupFields("server");
|
|
assert.match(html, /backupHint/);
|
|
assert.match(inputTag(html, "database"), /required/);
|
|
ui.fields({ source_id: "server", database: "orders", workers: "4", shard_rows: "20000" });
|
|
assert.deepEqual(ui.values(), { source_id: "server", database: "orders", workers: 4, shard_rows: 20000, no_lock: true });
|
|
ui.element("editor").elements.no_lock = { checked: false };
|
|
assert.equal(ui.values().no_lock, false);
|
|
ui.element("editor").elements.workers.value = "17";
|
|
assert.throws(() => ui.values(), { message: "invalidConfig" });
|
|
});
|
|
|
|
test("production local backup requires an explicit raw-data confirmation", () => {
|
|
const ui = harness("quickBackup");
|
|
ui.context.isProduction = id => id === "bound";
|
|
ui.fields({ source_id: "bound", database: "orders", workers: "4", shard_rows: "20000", confirm_production: "" });
|
|
assert.throws(() => ui.values(), { message: "required" });
|
|
ui.element("editor").elements.confirm_production.checked = true;
|
|
assert.equal(ui.values().confirm_production, true);
|
|
});
|
|
|
|
test("backup restore requires a new non-production MySQL target and confirmation", () => {
|
|
const ui = harness("restoreBackup");
|
|
const html = ui.context.restoreFields({ source_id: "bound", database: "orders", mode: "backup" });
|
|
assert.match(html, /Local database backup/);
|
|
assert.match(inputTag(html, "target_database"), /value="orders".*required/);
|
|
ui.fields({ target_id: "server", target_database: "orders_restored", prepare_replication: "on", confirm: "on" });
|
|
ui.element("editor").elements.prepare_replication.checked = true;
|
|
ui.element("editor").elements.confirm.checked = true;
|
|
assert.deepEqual(ui.values(), { target_id: "server", target_database: "orders_restored", prepare_replication: true, confirm: true });
|
|
ui.element("editor").elements.confirm.checked = false;
|
|
assert.throws(() => ui.values(), { message: "required" });
|
|
});
|
|
|
|
test("changing source or target resets its database default and clears object selections", () => {
|
|
const ui = harness("editTask");
|
|
ui.fields({ source_id: "bound", target_id: "server", source_database: "orders", target_database: "archive", objects: "old_table" });
|
|
ui.context.editorScopeChanged({ target: { name: "source_id", value: "server" } });
|
|
assert.equal(ui.element("editor").elements.source_database.value, "");
|
|
assert.equal(ui.element("editor").elements.objects.value, "");
|
|
ui.context.editorScopeChanged({ target: { name: "target_id", value: "bound" } });
|
|
assert.equal(ui.element("editor").elements.target_database.value, "default_db");
|
|
});
|
|
|
|
test("object picking uses the selected database and ignores replies from an older scope", async () => {
|
|
const ui = harness("newExport");
|
|
ui.fields({ source_id: "server", database: "sales db", objects: "" });
|
|
let resolve;
|
|
ui.reply(() => new Promise(done => { resolve = done; }));
|
|
const pending = ui.context.pickObjects(true);
|
|
assert.equal(ui.calls[0], "/datasources/server/objects?database=sales%20db");
|
|
ui.element("editor").elements.database.value = "new_db";
|
|
ui.context.editorScopeChanged({ target: { name: "database", value: "new_db" } });
|
|
resolve([{ name: "stale_table", type: "table" }]);
|
|
await pending;
|
|
assert.equal(ui.element("editor").elements.objects.value, "");
|
|
assert.equal(ui.element("editor").elements.objects.readOnly, false);
|
|
assert.equal(ui.element("save").disabled, false);
|
|
});
|
|
|
|
test("browsing an unbound source first offers database selection", async () => {
|
|
const ui = harness("detail");
|
|
ui.reply(async () => ({ databases: ["orders", "archive"] }));
|
|
await ui.context.browseObjects("server");
|
|
assert.deepEqual(ui.calls, ["/datasources/server/databases"]);
|
|
assert.match(ui.element("fields").innerHTML, /name="browse_database"/);
|
|
assert.match(ui.element("fields").innerHTML, /value="orders"/);
|
|
ui.fields({ browse_database: "archive" });
|
|
ui.reply(async () => [{ name: "items", type: "table" }]);
|
|
await ui.context.loadBrowseObjects();
|
|
assert.equal(ui.calls.at(-1), "/datasources/server/objects?database=archive");
|
|
ui.reply(async () => ({ name: "items", columns: [] }));
|
|
await ui.context.objectDetail("items");
|
|
assert.equal(ui.calls.at(-1), "/datasources/server/objects/items?database=archive");
|
|
});
|
|
|
|
test("account grants require an editable scope for unbound sources and keep bound scopes readonly", () => {
|
|
const ui = harness();
|
|
ui.context.openAccountWizard();
|
|
assert.match(inputTag(ui.element("account-fields").innerHTML, "database"), /readonly/);
|
|
ui.state.sources.reverse();
|
|
ui.context.openAccountWizard();
|
|
assert.doesNotMatch(inputTag(ui.element("account-fields").innerHTML, "database"), /readonly/);
|
|
assert.match(inputTag(ui.element("account-fields").innerHTML, "database"), /required/);
|
|
ui.context.accountFieldChanged({ target: { name: "source_id", value: "bound" } });
|
|
assert.equal(ui.element("account-form").elements.database.value, "default_db");
|
|
assert.equal(ui.element("account-form").elements.database.readOnly, true);
|
|
ui.context.accountFieldChanged({ target: { name: "source_id", value: "server" } });
|
|
assert.equal(ui.element("account-form").elements.database.readOnly, false);
|
|
const grant = { source_id: "server", username: "syncdb_reader", host: "127.0.0.1", database: "orders", action: "create", role: "reader" };
|
|
assert.doesNotThrow(() => ui.context.validateAccountInput(grant));
|
|
assert.throws(() => ui.context.validateAccountInput({ ...grant, database: "" }), { message: "accountInvalidInput" });
|
|
assert.throws(() => ui.context.validateAccountInput({ ...grant, source_id: "bound" }), { message: "accountInvalidInput" });
|
|
});
|