160 lines
6.6 KiB
JavaScript
160 lines
6.6 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);
|
|
const to = app.indexOf(end, from);
|
|
assert.ok(from >= 0 && to > from, `app.js section exists: ${start}`);
|
|
return app.slice(from, to);
|
|
}
|
|
// Execute the production translations, error normalization, HTTP client, and
|
|
// sign-in handlers, without booting unrelated application views.
|
|
const script = [
|
|
section("const translations =", "const escapeHTML ="),
|
|
section("const errorAliases =", "function listResult("),
|
|
section("async function submitLogin(", "function closeEditor("),
|
|
section("async function bootstrap(", 'document.addEventListener("DOMContentLoaded"')
|
|
].join("\n");
|
|
|
|
function harness(locale = "zh") {
|
|
const elements = new Map();
|
|
const element = id => {
|
|
if (!elements.has(id)) elements.set(id, { value: "", textContent: "", disabled: false });
|
|
return elements.get(id);
|
|
};
|
|
const logins = [];
|
|
const calls = [];
|
|
const state = { authenticated: true };
|
|
let response = { status: 200, body: {} };
|
|
const context = vm.createContext({
|
|
document: { getElementById: element },
|
|
localStorage: { getItem: () => locale },
|
|
AbortController, setTimeout, clearTimeout, URLSearchParams,
|
|
authVersion: 1,
|
|
fetch: async (url, options) => {
|
|
calls.push({ url, options });
|
|
return { ok: response.status >= 200 && response.status < 300,
|
|
status: response.status, text: async () => JSON.stringify(response.body) };
|
|
},
|
|
showLogin: key => { logins.push(key); state.authenticated = false; },
|
|
authenticated: async () => { state.authenticated = true; },
|
|
location: { hash: "", pathname: "/", search: "" },
|
|
history: { replaceState() {} },
|
|
translateShell() {}, renderFilters() {}, render() {}
|
|
});
|
|
vm.runInContext(script, context);
|
|
return {
|
|
context, element, logins, calls, state,
|
|
respond(status, body) { response = { status, body }; },
|
|
text(key) { return vm.runInContext(`text(${JSON.stringify(key)})`, context); }
|
|
};
|
|
}
|
|
|
|
test("token and database failures retain distinct codes across response formats", async () => {
|
|
const ui = harness();
|
|
const cases = [
|
|
[401, { code: "invalidToken" }, "invalidToken"],
|
|
[502, { code: "mysqlAuthFailed" }, "mysqlAuthFailed"],
|
|
[502, { error: { code: "mongoAuthFailed" } }, "mongoAuthFailed"],
|
|
[502, { error: "mongodb_auth_failed" }, "mongoAuthFailed"],
|
|
[503, { code: "authenticationUnavailable" }, "authenticationUnavailable"]
|
|
];
|
|
for (const [status, body, expected] of cases) {
|
|
ui.respond(status, body);
|
|
await assert.rejects(ui.context.request("/session"), error => {
|
|
assert.equal(error.message, expected);
|
|
assert.equal(error.status, status);
|
|
assert.equal(ui.context.userError(error), ui.text(expected));
|
|
return true;
|
|
});
|
|
}
|
|
assert.deepEqual(ui.logins, []);
|
|
});
|
|
|
|
test("MySQL 502 from the database picker does not log out the application session", async () => {
|
|
const ui = harness();
|
|
ui.respond(502, { code: "mysqlAuthFailed" });
|
|
await assert.rejects(ui.context.request("/datasources/ds-1/databases"), { message: "mysqlAuthFailed" });
|
|
assert.equal(ui.state.authenticated, true);
|
|
assert.deepEqual(ui.logins, []);
|
|
});
|
|
|
|
test("an expired application session still opens sign-in with the session-expired message", async () => {
|
|
const ui = harness();
|
|
ui.respond(401, { code: "authenticationRequired" });
|
|
await assert.rejects(ui.context.request("/datasources"), { message: "sessionExpired" });
|
|
assert.equal(ui.state.authenticated, false);
|
|
assert.deepEqual(ui.logins, ["sessionExpired"]);
|
|
});
|
|
|
|
test("an invalid reauthentication token does not discard the active session", async () => {
|
|
const ui = harness();
|
|
ui.respond(401, { code: "invalidToken" });
|
|
await assert.rejects(ui.context.request("/replications/operations/seed/apply"), { message: "invalidToken" });
|
|
assert.equal(ui.state.authenticated, true);
|
|
assert.deepEqual(ui.logins, []);
|
|
});
|
|
|
|
test("all three locales explain the relevant credential without conflating token and database login", () => {
|
|
for (const locale of ["zh", "en", "ja"]) {
|
|
const ui = harness(locale);
|
|
assert.match(ui.text("invalidToken"), /session-token/);
|
|
assert.doesNotMatch(ui.text("invalidToken"), /MySQL|MongoDB/);
|
|
assert.match(ui.text("mysqlAuthFailed"), /MySQL.*Host/);
|
|
assert.match(ui.text("mongoAuthFailed"), /MongoDB.*authSource/);
|
|
for (const key of ["mysqlAuthFailed", "mongoAuthFailed", "authenticationUnavailable"]) {
|
|
assert.notEqual(ui.text(key), key);
|
|
assert.doesNotMatch(ui.text(key), /session-token|SYNCDB_TOKEN/);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("manual login reports an invalid token and clears the token field", async () => {
|
|
const ui = harness();
|
|
ui.element("login-token").value = "bad-token";
|
|
ui.respond(401, { code: "invalidToken" });
|
|
await ui.context.submitLogin({ preventDefault() {} });
|
|
assert.equal(ui.element("login-error").textContent, ui.text("invalidToken"));
|
|
assert.equal(ui.element("login-token").value, "");
|
|
assert.equal(ui.element("login-submit").disabled, false);
|
|
});
|
|
|
|
test("manual login preserves the service-unavailable diagnosis", async () => {
|
|
const ui = harness();
|
|
ui.element("login-token").value = "a-token";
|
|
ui.respond(503, { code: "authenticationUnavailable" });
|
|
await ui.context.submitLogin({ preventDefault() {} });
|
|
assert.equal(ui.element("login-error").textContent, ui.text("authenticationUnavailable"));
|
|
});
|
|
|
|
test("URL token bootstrap reports token errors rather than database credential errors", async () => {
|
|
const ui = harness();
|
|
ui.context.location.hash = "#token=bad-token";
|
|
ui.respond(401, { code: "invalidToken" });
|
|
await ui.context.bootstrap();
|
|
assert.deepEqual(ui.logins, ["invalidToken"]);
|
|
assert.equal(ui.calls[0].url, "/api/session");
|
|
});
|
|
|
|
test("index manager errors resolve to actionable localized messages", () => {
|
|
const aliases = {
|
|
invalidBinlogIndex: "binlogIndexInvalidConfig", binlogIndexStopping: "binlogIndexBusy",
|
|
binlogIndexNotFound: "binlogIndexRequired", binlogIndexStorageFailed: "binlogIndexUnavailable",
|
|
binlogIndexIdentityUnavailable: "binlogIndexIdentityUnavailable", binlogIndexFilesTruncated: "binlogIndexFilesTruncated"
|
|
};
|
|
for (const locale of ["zh", "en", "ja"]) {
|
|
const ui = harness(locale);
|
|
for (const [code, key] of Object.entries(aliases)) {
|
|
assert.equal(ui.context.errorKey(code), key);
|
|
assert.equal(ui.context.userError({ message: code }), ui.text(key));
|
|
assert.notEqual(ui.text(key), key);
|
|
}
|
|
}
|
|
});
|