562 lines
22 KiB
JavaScript
562 lines
22 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import test from "node:test";
|
|
import vm from "node:vm";
|
|
|
|
const html = readFileSync(new URL("./index.html", import.meta.url), "utf8");
|
|
const source = readFileSync(new URL("./config.js", import.meta.url), "utf8");
|
|
const styles = readFileSync(new URL("./styles.css", import.meta.url), "utf8");
|
|
|
|
class FakeElement {
|
|
constructor(hidden = false) {
|
|
this.hidden = hidden;
|
|
this.disabled = false;
|
|
this.required = false;
|
|
this.checked = false;
|
|
this.value = "";
|
|
this.textContent = "";
|
|
this.listeners = new Map();
|
|
this.attributes = new Map();
|
|
const classes = new Set();
|
|
this.classList = {
|
|
toggle(name, force) {
|
|
const on = force === true || (force === undefined && !classes.has(name));
|
|
if (on) classes.add(name); else classes.delete(name);
|
|
},
|
|
add(name) { classes.add(name); },
|
|
remove(name) { classes.delete(name); },
|
|
contains(name) { return classes.has(name); },
|
|
};
|
|
this.children = new Map();
|
|
this.options = [];
|
|
}
|
|
|
|
addEventListener(type, callback) {
|
|
this.listeners.set(type, callback);
|
|
}
|
|
|
|
setAttribute(name, value) {
|
|
this.attributes.set(name, value);
|
|
}
|
|
|
|
querySelector(selector) {
|
|
if (!this.children.has(selector)) this.children.set(selector, new FakeElement());
|
|
return this.children.get(selector);
|
|
}
|
|
|
|
querySelectorAll() { return []; }
|
|
appendChild(child) { this.options.push(child); }
|
|
focus() {}
|
|
reset() {}
|
|
setCustomValidity() {}
|
|
reportValidity() { return true; }
|
|
}
|
|
|
|
async function startPage(statusResponse) {
|
|
const elements = new Map();
|
|
for (const match of html.matchAll(/<[^>]*\bid="([^"]+)"[^>]*>/gs)) {
|
|
const [, id] = match;
|
|
const hidden = /\shidden(?:\s|=|>)/.test(match[0]);
|
|
elements.set(id, new FakeElement(hidden));
|
|
}
|
|
const document = {
|
|
querySelector(selector) {
|
|
const match = selector.match(/^#(.+)$/);
|
|
if (!match || !elements.has(match[1])) throw new Error(`Missing selector: ${selector}`);
|
|
return elements.get(match[1]);
|
|
},
|
|
getElementById(id) {
|
|
if (!elements.has(id)) throw new Error(`Missing element: ${id}`);
|
|
return elements.get(id);
|
|
},
|
|
querySelectorAll() { return []; },
|
|
createElement() { return new FakeElement(); },
|
|
};
|
|
const window = {
|
|
addEventListener() {},
|
|
setTimeout() { return 1; },
|
|
clearTimeout() {},
|
|
};
|
|
const fetchCalls = [];
|
|
const fetch = async (path, options) => {
|
|
fetchCalls.push({ path, options });
|
|
if (path !== "/api/status") throw new Error(`Unexpected request: ${path}`);
|
|
if (statusResponse instanceof Error) throw statusResponse;
|
|
const httpStatus = statusResponse.httpStatus ?? 200;
|
|
return {
|
|
ok: httpStatus >= 200 && httpStatus < 300,
|
|
status: httpStatus,
|
|
text: async () => JSON.stringify(statusResponse.body),
|
|
};
|
|
};
|
|
|
|
vm.runInNewContext(source, { document, window, fetch, Headers, URL });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await Promise.resolve();
|
|
return { elements, fetchCalls, window };
|
|
}
|
|
|
|
// Objects built inside vm.runInNewContext carry that realm's Object.prototype,
|
|
// so assert.deepEqual — which compares prototypes — rejects them even when they
|
|
// are structurally identical. Re-shape them into host objects, sorted, and then
|
|
// compare.
|
|
function canonical(value) {
|
|
if (typeof value === "bigint") return value.toString();
|
|
if (Array.isArray(value)) return Array.from(value, (item) => canonical(item));
|
|
if (value !== null && typeof value === "object") {
|
|
const out = {};
|
|
for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
|
|
return out;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function assertShape(actual, expected, message) {
|
|
assert.deepEqual(canonical(actual), canonical(expected), message);
|
|
}
|
|
|
|
test("configuration-setup hides all authorization UI and lists only missing variable names", async () => {
|
|
const { elements, fetchCalls } = await startPage({
|
|
body: {
|
|
ready: false,
|
|
mode: "configuration-setup",
|
|
missing: ["REDIS_URL", "CONFIG_ENCRYPTION_KEY", "secret-value-must-not-display"],
|
|
},
|
|
});
|
|
|
|
assert.equal(elements.get("setup-card").hidden, false);
|
|
assert.equal(elements.get("intro").hidden, true);
|
|
assert.equal(elements.get("auth-card").hidden, true);
|
|
assert.equal(elements.get("config-panel").hidden, true);
|
|
assert.equal(elements.get("notice").hidden, true);
|
|
assert.equal(elements.get("privacy-note").hidden, true);
|
|
assert.equal(elements.get("setup-missing").textContent, "REDIS_URL, CONFIG_ENCRYPTION_KEY");
|
|
assert.equal(elements.get("setup-message").textContent.includes("setup mode"), true);
|
|
assert.equal(fetchCalls.length, 1);
|
|
assert.equal(fetchCalls[0].path, "/api/status");
|
|
assert.equal(fetchCalls[0].options.credentials, "omit");
|
|
assert.equal(fetchCalls[0].options.cache, "no-store");
|
|
assert.equal(fetchCalls[0].options.headers.has("authorization"), false);
|
|
});
|
|
|
|
test("ready status preserves the existing admin-session entry and configuration page", async () => {
|
|
const { elements } = await startPage({
|
|
body: { ready: true, mode: "configured", missing: [] },
|
|
});
|
|
|
|
assert.equal(elements.get("setup-card").hidden, true);
|
|
assert.equal(elements.get("intro").hidden, false);
|
|
assert.equal(elements.get("auth-card").hidden, false);
|
|
assert.equal(elements.get("config-panel").hidden, true);
|
|
assert.equal(elements.get("privacy-note").hidden, false);
|
|
assert.equal(elements.get("notice").hidden, false);
|
|
});
|
|
|
|
test("status errors keep the authorization form hidden until readiness is confirmed", async () => {
|
|
const { elements } = await startPage({
|
|
httpStatus: 503,
|
|
body: {},
|
|
});
|
|
|
|
assert.equal(elements.get("setup-card").hidden, false);
|
|
assert.equal(elements.get("auth-card").hidden, true);
|
|
assert.equal(elements.get("config-panel").hidden, true);
|
|
assert.match(elements.get("setup-message").textContent, /authorization entry stays hidden/);
|
|
});
|
|
|
|
test("setup card inherits desktop layout width and follows the existing mobile layout width", () => {
|
|
const setupRules = [...styles.matchAll(/\.setup-panel[^{}]*\{([^}]*)\}/g)].map((match) => match[1]);
|
|
const setupDeclarations = setupRules.join("\n");
|
|
|
|
assert.doesNotMatch(setupDeclarations, /\b(?:width|max-width)\s*:/);
|
|
assert.match(styles, /\.layout\s*\{[^}]*width:\s*min\(960px,\s*calc\(100% - 40px\)\)/);
|
|
assert.match(styles, /@media \(max-width: 720px\)\s*\{[\s\S]*?\.layout\s*\{[^}]*width:\s*calc\(100% - 32px\)/);
|
|
assert.match(styles, /\.auth-panel\s*\{[^}]*padding:\s*27px 29px 25px/);
|
|
});
|
|
|
|
test("i18n defaults to English and keeps the zh/en dictionaries in key parity", async () => {
|
|
const { window } = await startPage({
|
|
body: { ready: true, mode: "configured", missing: [] },
|
|
});
|
|
|
|
const { detectLocale, getLocale, t, messages } = window.__mw;
|
|
assert.equal(detectLocale(), "en");
|
|
assert.equal(getLocale(), "en");
|
|
|
|
const enKeys = Object.keys(messages.en).sort();
|
|
const zhKeys = Object.keys(messages.zh).sort();
|
|
assert.deepEqual(enKeys, zhKeys);
|
|
assert.ok(enKeys.length > 100, `expected a broad message catalog, got ${enKeys.length}`);
|
|
|
|
assert.equal(t("common.required"), "Required");
|
|
assert.equal(
|
|
t("session.expiry", { n: 5 }),
|
|
"Admin session expires in about 5 minutes; kept only in page memory",
|
|
);
|
|
assert.equal(t("missing.key"), "missing.key");
|
|
});
|
|
|
|
test("every data-i18n key in the markup resolves in both dictionaries", async () => {
|
|
const { window } = await startPage({
|
|
body: { ready: true, mode: "configured", missing: [] },
|
|
});
|
|
const { messages } = window.__mw;
|
|
|
|
const keys = new Set();
|
|
for (const match of html.matchAll(/data-i18n(?:-placeholder|-aria)?="([^"]+)"/g)) {
|
|
keys.add(match[1]);
|
|
}
|
|
assert.ok(keys.size > 40, `expected many i18n keys in the markup, got ${keys.size}`);
|
|
|
|
for (const key of keys) {
|
|
assert.ok(key in messages.en, `markup key missing from en dictionary: ${key}`);
|
|
assert.ok(key in messages.zh, `markup key missing from zh dictionary: ${key}`);
|
|
}
|
|
|
|
assert.match(html, /<html lang="en">/);
|
|
assert.match(html, /data-i18n-placeholder="auth\.placeholder"/);
|
|
assert.match(html, /data-i18n-aria="brand\.aria"/);
|
|
});
|
|
|
|
test("timezone catalogue is a duplicate-free, fixed-offset-only set with Asia/Shanghai as the default", async () => {
|
|
const { window } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const { timezones, defaultTimezone } = window.__mw;
|
|
|
|
assert.equal(defaultTimezone, "Asia/Shanghai");
|
|
assert.equal(timezones.length, 16);
|
|
assert.equal(new Set(timezones.map((zone) => zone.iana)).size, 16);
|
|
|
|
const expected = [
|
|
"Etc/UTC",
|
|
"Africa/Cairo",
|
|
"Europe/Istanbul",
|
|
"Africa/Nairobi",
|
|
"Asia/Dubai",
|
|
"Asia/Karachi",
|
|
"Asia/Kolkata",
|
|
"Asia/Bangkok",
|
|
"Asia/Ho_Chi_Minh",
|
|
"Asia/Shanghai",
|
|
"Asia/Hong_Kong",
|
|
"Asia/Taipei",
|
|
"Asia/Singapore",
|
|
"Asia/Manila",
|
|
"Asia/Tokyo",
|
|
"Asia/Seoul",
|
|
];
|
|
assert.deepEqual([...timezones.map((zone) => zone.iana)].sort(), [...expected].sort());
|
|
assert.ok(timezones.some((zone) => zone.iana === defaultTimezone));
|
|
|
|
for (const zone of timezones) {
|
|
assert.match(zone.offset, /^\+\d{1,2}(:30)?$/);
|
|
assert.equal(zone.label.en.includes(zone.offset), true);
|
|
assert.equal(zone.label.zh.includes(zone.offset), true);
|
|
}
|
|
});
|
|
|
|
test("timezone select is filled with 16 options and pre-selects Asia/Shanghai", async () => {
|
|
const { elements, window } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const { timezones, defaultTimezone, getLocale } = window.__mw;
|
|
const timezoneSelect = elements.get("timezone");
|
|
|
|
assert.equal(timezoneSelect.options.length, 16);
|
|
assert.deepEqual(timezoneSelect.options.map((option) => option.value), Array.from(timezones, (zone) => zone.iana));
|
|
|
|
const selected = timezoneSelect.options.filter((option) => option.selected);
|
|
assert.equal(selected.length, 1);
|
|
assert.equal(selected[0].value, defaultTimezone);
|
|
|
|
const defaultZone = timezones.find((zone) => zone.iana === defaultTimezone);
|
|
assert.equal(defaultZone.label.en, "Shanghai, China (UTC+8)");
|
|
assert.equal(defaultZone.label.zh, "中国上海 (UTC+8)");
|
|
assert.equal(selected[0].textContent, defaultZone.label[getLocale()]);
|
|
// Option labels live per zone and must exist in both languages (test 5 covers dictionary parity).
|
|
for (const zone of timezones) {
|
|
assert.equal(zone.label.en.length > 0, true);
|
|
assert.equal(zone.label.zh.length > 0, true);
|
|
}
|
|
});
|
|
|
|
test("readBusinessConfig carries the selected time zone", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
|
|
elements.get("telegram-bot-token").value = "bot-token";
|
|
elements.get("telegram-chat-id").value = "123456789";
|
|
elements.get("chat-allowlist").value = "123456789";
|
|
elements.get("telegram-webhook-secret").value = "webhook-secret";
|
|
elements.get("jmap-session-url").value = "https://mail.example.com/jmap";
|
|
elements.get("jmap-username").value = "bot@example.com";
|
|
elements.get("jmap-password").value = "jmap-password";
|
|
elements.get("reconcile-token").value = "reconcile-token";
|
|
elements.get("worker-token").value = "worker-token";
|
|
elements.get("timezone").value = "Asia/Tokyo";
|
|
|
|
const payload = pageWindow.__mw.readBusinessConfig();
|
|
|
|
assert.equal(payload.timezone, "Asia/Tokyo");
|
|
assert.equal(payload.llm_enabled, false);
|
|
assert.equal(payload.llm_base_url, null);
|
|
assert.equal(payload.llm_model, null);
|
|
assert.equal(payload.llm_api_key, null);
|
|
assert.equal(typeof payload.bot_token, "string");
|
|
assert.equal(typeof payload.reconcile_token, "string");
|
|
});
|
|
|
|
test("readback pre-fills stored values, blanks secrets, and marks which secrets exist", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
|
|
mw.applyBusinessReadback({
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
llm_base_url: "https://llm.example.com/v1",
|
|
llm_model: "gpt-4o",
|
|
chat_allowlist: ["123456789", "987654321"],
|
|
telegram_chat_id: "123456789",
|
|
llm_enabled: true,
|
|
llm_allow_net: true,
|
|
},
|
|
secrets_present: {
|
|
bot_token: true,
|
|
jmap_password: true,
|
|
telegram_webhook_secret: true,
|
|
reconcile_token: true,
|
|
worker_token: false,
|
|
llm_api_key: true,
|
|
},
|
|
});
|
|
|
|
// Non-secret values are filled in so the operator can change only what matters.
|
|
assert.equal(elements.get("jmap-session-url").value, "https://mail.example.com/jmap");
|
|
assert.equal(elements.get("jmap-username").value, "bot@example.com");
|
|
assert.equal(elements.get("timezone").value, "Asia/Tokyo");
|
|
assert.equal(elements.get("llm-base-url").value, "https://llm.example.com/v1");
|
|
assert.equal(elements.get("llm-model").value, "gpt-4o");
|
|
assert.equal(elements.get("llm-enabled").checked, true);
|
|
assert.equal(elements.get("llm-allow-net").checked, true);
|
|
// The allowlist is re-serialised comma-separated so parseAllowlist round-trips it.
|
|
assert.equal(elements.get("chat-allowlist").value, "123456789, 987654321");
|
|
assert.equal(elements.get("telegram-chat-id").value, "123456789");
|
|
|
|
// Secrets are never echoed: every secret input is blank and no longer required,
|
|
// because a blank secret now means "keep the stored value".
|
|
const secrets = mw.fields.secrets;
|
|
for (const field of secrets) {
|
|
assert.equal(elements.get(field.id).value, "", `secret ${field.key} must stay blank`);
|
|
assert.equal(elements.get(field.id).required, false, `secret ${field.key} must not be required`);
|
|
}
|
|
|
|
// Presence markers are driven by secrets_present, never by the stored value.
|
|
const marker = (key) => elements.get(`secret-marker-${key}`);
|
|
const marked = (key) => marker(key).textContent;
|
|
assert.equal(marker("jmap_password").classList.contains("is-unset"), false);
|
|
assert.equal(marker("worker_token").classList.contains("is-unset"), true);
|
|
assert.notEqual(marked("jmap_password"), marked("worker_token"));
|
|
assert.equal(marked("jmap_password").includes("Set"), true);
|
|
assert.equal(marked("worker_token").includes("Not set"), true);
|
|
|
|
assert.equal(mw.businessBaseline.jmap_session_url, "https://mail.example.com/jmap");
|
|
assert.equal(mw.businessSecretPresence.worker_token, false);
|
|
});
|
|
|
|
test("no saved configuration leaves the baseline null so full submission is used", async () => {
|
|
const { window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
assert.equal(pageWindow.__mw.businessBaseline, null);
|
|
assert.equal(pageWindow.__mw.businessSecretPresence, null);
|
|
});
|
|
|
|
test("an untouched form produces an empty patch", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
mw.applyBusinessReadback({
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
chat_allowlist: ["123456789"],
|
|
telegram_chat_id: "123456789",
|
|
llm_enabled: false,
|
|
llm_allow_net: false,
|
|
},
|
|
secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false },
|
|
});
|
|
|
|
const patch = mw.businessPatchFromForm();
|
|
assertShape(patch, {}, "an unchanged form must not resubmit anything");
|
|
});
|
|
|
|
test("only changed text fields enter the patch", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
mw.applyBusinessReadback({
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
chat_allowlist: ["123456789"],
|
|
telegram_chat_id: "123456789",
|
|
llm_enabled: false,
|
|
llm_allow_net: false,
|
|
},
|
|
secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false },
|
|
});
|
|
|
|
elements.get("timezone").value = "America/New_York";
|
|
const patch = mw.businessPatchFromForm();
|
|
assertShape(patch, { timezone: "America/New_York" });
|
|
});
|
|
|
|
test("blank secrets are omitted and typed secrets replace the stored value", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
mw.applyBusinessReadback({
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
chat_allowlist: ["123456789"],
|
|
telegram_chat_id: "123456789",
|
|
llm_enabled: false,
|
|
llm_allow_net: false,
|
|
},
|
|
secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false },
|
|
});
|
|
|
|
// Every secret is blank after readback, so nothing is submitted for them.
|
|
assertShape(mw.businessPatchFromForm(), {});
|
|
|
|
// Typing a new value replaces the stored one. Assigning .value directly does
|
|
// not fire the input event the app listens to, so re-render the marker by hand.
|
|
elements.get("jmap-password").value = "new-password";
|
|
mw.renderSecretMarkers();
|
|
assertShape(mw.businessPatchFromForm(), { jmap_password: "new-password" });
|
|
|
|
// The presence marker flips to "will replace" while the input is non-blank.
|
|
assert.equal(elements.get("secret-marker-jmap_password").textContent.includes("replace"), true);
|
|
|
|
// Clearing the input again restores "keep the stored value".
|
|
elements.get("jmap-password").value = "";
|
|
mw.renderSecretMarkers();
|
|
assertShape(mw.businessPatchFromForm(), {});
|
|
assert.equal(elements.get("secret-marker-jmap_password").textContent.includes("stays"), true);
|
|
});
|
|
|
|
test("clearing an optional LLM field submits null", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
mw.applyBusinessReadback({
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
chat_allowlist: ["123456789"],
|
|
telegram_chat_id: "123456789",
|
|
llm_base_url: "https://llm.example.com/v1",
|
|
llm_model: "gpt-4o",
|
|
llm_enabled: true,
|
|
llm_allow_net: true,
|
|
},
|
|
secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: true },
|
|
});
|
|
|
|
elements.get("llm-model").value = "";
|
|
const patch = mw.businessPatchFromForm();
|
|
assertShape(patch, { llm_model: null });
|
|
});
|
|
|
|
test("the allowlist is compared as an unordered set", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
mw.applyBusinessReadback({
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
chat_allowlist: ["123456789", "987654321"],
|
|
telegram_chat_id: "123456789",
|
|
llm_enabled: false,
|
|
llm_allow_net: false,
|
|
},
|
|
secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false },
|
|
});
|
|
|
|
// Reordering the same ids is not a change.
|
|
elements.get("chat-allowlist").value = "987654321 123456789";
|
|
assertShape(mw.businessPatchFromForm(), {});
|
|
|
|
// Adding an id is. The patch carries the operator's current order, not the
|
|
// stored order, and plain strings rather than BigInt objects.
|
|
elements.get("chat-allowlist").value = "987654321, 123456789, 555000000";
|
|
const patch = mw.businessPatchFromForm();
|
|
assertShape(patch, { chat_allowlist: ["987654321", "123456789", "555000000"] });
|
|
|
|
// A blank textarea keeps the stored list rather than deleting it.
|
|
elements.get("chat-allowlist").value = "";
|
|
assertShape(mw.businessPatchFromForm(), {});
|
|
});
|
|
|
|
test("flipping a boolean enters the patch and resetBusinessReadback clears the baseline", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
mw.applyBusinessReadback({
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
chat_allowlist: ["123456789"],
|
|
telegram_chat_id: "123456789",
|
|
llm_enabled: true,
|
|
llm_allow_net: true,
|
|
},
|
|
secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: true },
|
|
});
|
|
|
|
elements.get("llm-allow-net").checked = false;
|
|
assertShape(mw.businessPatchFromForm(), { llm_allow_net: false });
|
|
|
|
mw.resetBusinessReadback();
|
|
assert.equal(mw.businessBaseline, null);
|
|
assert.equal(mw.businessSecretPresence, null);
|
|
// Markers fall back to "not set", so the operator knows the page has no view.
|
|
assert.equal(elements.get("secret-marker-bot_token").textContent.includes("Not set"), true);
|
|
});
|
|
|
|
|
|
test("a partial submit carries the revision the baseline was read at", async () => {
|
|
const { elements, window: pageWindow } = await startPage({ body: { ready: true, mode: "configured", missing: [] } });
|
|
const mw = pageWindow.__mw;
|
|
mw.applyBusinessReadback({
|
|
revision: 7,
|
|
values: {
|
|
jmap_session_url: "https://mail.example.com/jmap",
|
|
jmap_username: "bot@example.com",
|
|
timezone: "Asia/Tokyo",
|
|
chat_allowlist: ["123456789"],
|
|
telegram_chat_id: "123456789",
|
|
llm_enabled: false,
|
|
llm_allow_net: false,
|
|
},
|
|
secrets_present: { bot_token: true, jmap_password: true, telegram_webhook_secret: true, reconcile_token: true, worker_token: true, llm_api_key: false },
|
|
});
|
|
|
|
// The control field is carried on every partial submit.
|
|
assert.equal(mw.businessRevision, 7);
|
|
assertShape(mw.businessPatchFromForm(), { revision: 7 });
|
|
|
|
// but it never makes the page count as having changed settings, so the
|
|
// no-op guard still blocks a submit that only echoes the stored values.
|
|
elements.get("timezone").value = "Etc/UTC";
|
|
assertShape(mw.businessPatchFromForm(), { revision: 7, timezone: "Etc/UTC" });
|
|
|
|
// A readback without a revision does not invent one, so the submit stays on
|
|
// last-write-wins instead of locking on a value the backend never sent.
|
|
mw.applyBusinessReadback({ values: {}, secrets_present: {} });
|
|
assert.equal(mw.businessRevision, null);
|
|
assert.equal(mw.businessPatchFromForm().revision, undefined);
|
|
|
|
mw.resetBusinessReadback();
|
|
assert.equal(mw.businessRevision, null);
|
|
});
|