Files
syncdb/tests/auth-errors.test.cjs
2026-09-27 16:38:27 +08:00

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);
}
}
});