262 lines
12 KiB
JavaScript
262 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { ref } from "vue";
|
|
import { useDomainForm } from "./DomainForm.js";
|
|
import { deferred, mountComposable } from "../../test-support/mount.js";
|
|
|
|
const account = {
|
|
id: "account-one", project_id: "default", provider: "aliyun", provider_name: "阿里云 DNS",
|
|
name: "生产账户", owner_username: "甲用户", managed: true, status: "active",
|
|
verification_state: "verified", credential_state: "available", revision: 3,
|
|
};
|
|
const item = {
|
|
id: "domain-one", project_id: "default", name: "one.example.test", sans: [],
|
|
certificate_type: "single", auto_renew: true, acme_environment: "staging",
|
|
challenge_type: "dns-01", dns_provider: "aliyun",
|
|
};
|
|
const emptyBinding = { id: "", domain_id: item.id, project_id: "default", dns_account_id: "" };
|
|
const originalBinding = { ...emptyBinding, id: "binding-old", dns_account_id: account.id };
|
|
|
|
async function mountForm(t, { accounts = [account], binding = emptyBinding, intercept = () => null } = {}) {
|
|
const requests = [];
|
|
let currentBinding = { ...binding };
|
|
const deps = {
|
|
fetchAdmin: (...args) => fetch(...args), domains: ref([]), servers: ref([]),
|
|
domainServers: ref({}), notice: ref(""), loadData: async () => {},
|
|
};
|
|
const mounted = await mountComposable(t, () => useDomainForm(deps), async (path, options = {}) => {
|
|
requests.push({ path, ...options });
|
|
const intercepted = intercept(path, options);
|
|
if (intercepted) return intercepted;
|
|
if (path === "/api/v1/dns/accounts") return Response.json({ items: accounts });
|
|
if (path.endsWith("/dns-account")) {
|
|
if (options.method) {
|
|
const body = JSON.parse(options.body);
|
|
currentBinding = {
|
|
...emptyBinding, id: body.dns_account_id ? "binding-new" : "", dns_account_id: body.dns_account_id,
|
|
};
|
|
}
|
|
return Response.json(currentBinding);
|
|
}
|
|
if (path.endsWith("/servers")) return Response.json({ items: [] });
|
|
return Response.json({ ...item, ...JSON.parse(options.body) });
|
|
});
|
|
return { ...mounted, deps, requests, writes: () => requests.filter((request) => request.method) };
|
|
}
|
|
|
|
async function create(state) {
|
|
await state.openCreate();
|
|
state.form.value.name = item.name;
|
|
state.dnsAccountID.value = account.id;
|
|
}
|
|
|
|
test("新域名先保存资料再显式绑定账户,提交真实版本和空绑定令牌", async (t) => {
|
|
const { state, deps, writes } = await mountForm(t);
|
|
await create(state);
|
|
await state.saveDomain();
|
|
assert.deepEqual(writes().map((request) => request.path), [
|
|
"/api/v1/domains", `/api/v1/domains/${item.id}/dns-account`, `/api/v1/domains/${item.id}/servers`,
|
|
]);
|
|
assert.equal(JSON.parse(writes()[0].body).dns_provider, "aliyun");
|
|
assert.deepEqual(JSON.parse(writes()[1].body), {
|
|
dns_account_id: account.id, expected_binding_id: "", account_revision: 3,
|
|
});
|
|
assert.equal(deps.domains.value[0].dns_binding_id, "binding-new");
|
|
assert.equal(state.showForm.value, false);
|
|
});
|
|
|
|
test("编辑使用真实绑定令牌,更换服务商时先保留旧服务商再事务绑定", async (t) => {
|
|
const second = { ...account, id: "account-two", provider: "cloudflare", revision: 8 };
|
|
const { state, deps, writes } = await mountForm(t, { accounts: [account, second], binding: originalBinding });
|
|
await state.openEdit({ ...item, dns_binding_id: "stale-list" });
|
|
state.dnsAccountID.value = second.id;
|
|
await state.saveDomain();
|
|
assert.equal(JSON.parse(writes()[0].body).dns_provider, "aliyun");
|
|
assert.deepEqual(JSON.parse(writes()[1].body), {
|
|
dns_account_id: second.id, expected_binding_id: originalBinding.id, account_revision: 8,
|
|
});
|
|
assert.equal(deps.domains.value[0].dns_provider, "cloudflare");
|
|
});
|
|
|
|
test("账户按项目、状态、验证结果、凭据可用性及真实独立标识筛选", async (t) => {
|
|
const invalid = [
|
|
{ project_id: "other" }, { managed: false }, { status: "disabled" },
|
|
{ verification_state: "unverified" }, { credential_state: "missing" },
|
|
{ revision: 0 }, { revision: 2147483648 }, { provider: "../bad" }, { id: "../bad" },
|
|
].map((overrides, index) => ({ ...account, id: `invalid-${index}`, ...overrides }));
|
|
const { state, writes } = await mountForm(t, { accounts: [null, ...invalid, account] });
|
|
await create(state);
|
|
assert.deepEqual(state.domainDNSAccountOptions.value.map((entry) => entry.id), [account.id]);
|
|
for (const entry of invalid) {
|
|
state.dnsAccountID.value = entry.id;
|
|
await state.saveDomain();
|
|
assert.match(state.formError.value, /当前项目内/);
|
|
}
|
|
assert.equal(writes().length, 0);
|
|
});
|
|
|
|
test("停用的原账户保留绑定且不重新使用旧验证结果,可以显式解除", async (t) => {
|
|
const { state, writes } = await mountForm(t, { accounts: [{ ...account, status: "disabled" }], binding: originalBinding });
|
|
await state.openEdit(item);
|
|
assert.equal(state.dnsAccountID.value, account.id);
|
|
assert.equal(state.dnsUnavailableSelection.value, true);
|
|
state.dnsAccountID.value = "";
|
|
await state.saveDomain();
|
|
assert.deepEqual(JSON.parse(writes()[1].body), {
|
|
dns_account_id: "", expected_binding_id: originalBinding.id, account_revision: 0,
|
|
});
|
|
});
|
|
|
|
test("已有绑定切换 HTTP 验证必须先解除,不能在同次保存中静默回退", async (t) => {
|
|
const { state, writes } = await mountForm(t, { binding: originalBinding });
|
|
await state.openEdit(item);
|
|
state.dnsAccountID.value = "";
|
|
state.form.value.challenge_type = "http-01";
|
|
await state.saveDomain();
|
|
assert.match(state.formError.value, /先.*解除账户绑定/);
|
|
assert.equal(writes().length, 0);
|
|
});
|
|
|
|
test("无绑定 HTTP 验证保留兼容路径,不调用绑定写入", async (t) => {
|
|
const { state, writes } = await mountForm(t);
|
|
await state.openCreate();
|
|
state.form.value.name = item.name;
|
|
state.form.value.challenge_type = "http-01";
|
|
await state.saveDomain();
|
|
assert.equal(writes().length, 2);
|
|
assert.equal(JSON.parse(writes()[0].body).dns_provider, "");
|
|
});
|
|
|
|
for (const failure of ["network", "invalid-json", "wrong-domain", "wrong-project", "half-binding", "forbidden"]) {
|
|
test(`绑定读取失败不清空、不允许保存:${failure}`, async (t) => {
|
|
const { state, writes } = await mountForm(t, { intercept: (path) => {
|
|
if (!path.endsWith("/dns-account")) return null;
|
|
if (failure === "network") return Promise.reject(new Error("secret-test-only"));
|
|
if (failure === "invalid-json") return new Response("secret-test-only");
|
|
if (failure === "forbidden") return Response.json({ error: "secret-test-only" }, { status: 403 });
|
|
return Response.json({ ...originalBinding,
|
|
...(failure === "wrong-domain" ? { domain_id: "other-domain" } : {}),
|
|
...(failure === "wrong-project" ? { project_id: "other" } : {}),
|
|
...(failure === "half-binding" ? { dns_account_id: "" } : {}),
|
|
});
|
|
} });
|
|
await state.openEdit(item);
|
|
assert.equal(state.dnsBindingReady.value, false);
|
|
assert.doesNotMatch(state.dnsBindingError.value, /secret-test-only/);
|
|
await state.saveDomain();
|
|
assert.equal(writes().length, 0);
|
|
});
|
|
}
|
|
|
|
for (const failure of ["network", "conflict", "forbidden", "server", "invalid-json", "wrong-domain", "wrong-account"]) {
|
|
test(`绑定写入失败停止后续关联且禁止盲目重放:${failure}`, async (t) => {
|
|
const { state, writes } = await mountForm(t, { intercept: (path, options) => {
|
|
if (!path.endsWith("/dns-account") || !options.method) return null;
|
|
if (failure === "network") return Promise.reject(new Error("secret-test-only"));
|
|
if (failure === "invalid-json") return new Response("secret-test-only");
|
|
if (["conflict", "forbidden", "server"].includes(failure)) {
|
|
return Response.json({ error: "secret-test-only" }, { status: { conflict: 409, forbidden: 403, server: 503 }[failure] });
|
|
}
|
|
return Response.json({ ...originalBinding,
|
|
...(failure === "wrong-domain" ? { domain_id: "other-domain" } : { dns_account_id: "other-account" }),
|
|
});
|
|
} });
|
|
await create(state);
|
|
await state.saveDomain();
|
|
assert.equal(writes().length, 2);
|
|
assert.equal(state.dnsBindingReady.value, false);
|
|
assert.match(state.formError.value, /域名资料已保存.*DNS 账户绑定/);
|
|
assert.doesNotMatch(state.formError.value, /secret-test-only/);
|
|
await state.saveDomain();
|
|
assert.equal(writes().length, 2);
|
|
});
|
|
}
|
|
|
|
test("绑定已成功而服务器关联失败时只重试服务器,不重新创建或绑定", async (t) => {
|
|
let calls = 0;
|
|
const { state, writes } = await mountForm(t, { intercept: (path, options) => {
|
|
if (!path.endsWith("/servers") || !options.method) return null;
|
|
calls += 1;
|
|
return calls === 1 ? Response.json({ error: "关联冲突" }, { status: 409 }) : null;
|
|
} });
|
|
await create(state);
|
|
await state.saveDomain();
|
|
assert.equal(writes().length, 3);
|
|
assert.equal(state.showForm.value, true);
|
|
await state.saveDomain();
|
|
assert.equal(writes().length, 4);
|
|
assert.ok(writes()[3].path.endsWith("/servers"));
|
|
assert.equal(state.showForm.value, false);
|
|
});
|
|
|
|
test("冲突后重新读取实际绑定,确认状态而不是自动重放旧选择", async (t) => {
|
|
let conflict = true;
|
|
const { state, writes } = await mountForm(t, { intercept: (path, options) => {
|
|
if (!path.endsWith("/dns-account")) return null;
|
|
if (options.method && conflict) {
|
|
conflict = false;
|
|
return Response.json({}, { status: 409 });
|
|
}
|
|
if (!options.method) return Response.json(originalBinding);
|
|
return null;
|
|
} });
|
|
await create(state);
|
|
await state.saveDomain();
|
|
await state.reloadDomainDNS();
|
|
assert.equal(state.dnsBindingReady.value, true);
|
|
assert.equal(state.dnsAccountID.value, account.id);
|
|
await state.saveDomain();
|
|
assert.equal(writes().filter((request) => request.path.endsWith("/dns-account")).length, 1);
|
|
assert.equal(writes().filter((request) => request.method === "POST").length, 1);
|
|
});
|
|
|
|
test("切换或卸载后迟到的账户响应不能恢复旧表单状态", async (t) => {
|
|
const pending = deferred();
|
|
let reads = 0;
|
|
const { state, unmount, writes } = await mountForm(t, { intercept: (path) => {
|
|
if (path !== "/api/v1/dns/accounts") return null;
|
|
reads += 1;
|
|
return reads === 1 ? pending.promise : null;
|
|
} });
|
|
const opening = state.openEdit(item);
|
|
assert.equal(state.dnsBindingLoading.value, true);
|
|
state.closeForm();
|
|
await state.openCreate();
|
|
pending.resolve(Response.json({ items: [{ ...account, id: "late-account" }] }));
|
|
await opening;
|
|
assert.deepEqual(state.domainDNSAccountOptions.value.map((entry) => entry.id), [account.id]);
|
|
assert.equal(state.dnsAccountID.value, "");
|
|
unmount();
|
|
assert.equal(state.dnsBindingReady.value, false);
|
|
assert.equal(writes().length, 0);
|
|
});
|
|
|
|
test("保存中固定目标账户和版本,拒绝双击、关闭、重读和切换", async (t) => {
|
|
const pending = deferred();
|
|
const { state, writes } = await mountForm(t, { intercept: (path) => path === "/api/v1/domains" ? pending.promise : null });
|
|
await create(state);
|
|
const saving = state.saveDomain();
|
|
state.dnsAccountID.value = "forged-account";
|
|
state.editingId.value = "forged-domain";
|
|
state.closeForm();
|
|
await state.openCreate();
|
|
await state.reloadDomainDNS();
|
|
await state.saveDomain();
|
|
assert.equal(writes().length, 1);
|
|
pending.resolve(Response.json(item));
|
|
await saving;
|
|
assert.equal(writes()[1].path, `/api/v1/domains/${item.id}/dns-account`);
|
|
assert.equal(JSON.parse(writes()[1].body).dns_account_id, account.id);
|
|
});
|
|
|
|
test("卸载后返回的资料响应不继续写 DNS 绑定或服务器", async (t) => {
|
|
const pending = deferred();
|
|
const { state, unmount, writes } = await mountForm(t, { intercept: (path) => path === "/api/v1/domains" ? pending.promise : null });
|
|
await create(state);
|
|
const saving = state.saveDomain();
|
|
unmount();
|
|
pending.resolve(Response.json(item));
|
|
await saving;
|
|
assert.equal(writes().length, 1);
|
|
});
|