接通域名 DNS 账户选择并记录三库复审结果
This commit is contained in:
parent
122291fe43
commit
de2ce3ae8a
@ -1,5 +1,25 @@
|
||||
# DNS 多账户整改任务清单
|
||||
|
||||
## 2026年10月4日06:51 域名账户选择与 Linux 复审结果
|
||||
|
||||
- [✓] 第一台干净 `412c51f` 的 `0630` 单元 06:45:57 结束,inactive/success、退出码零;Linux 全量、vet 通过。单轮竞态仓储 17.076 秒、HTTP 23.331 秒;SQLite、MySQL、PostgreSQL 项目目录均实际运行通过,PostgreSQL 非跳过。
|
||||
- [x] 域名表单读取实时绑定和账户目录,显示账户、服务商、用户归属;只选同项目已启用、验证通过且凭据可用的独立账户。原账户不可用时保留真实绑定,不擅自清空。
|
||||
- [x] 新建与编辑依次保存资料、显式 DNS 绑定、服务器关联;绑定使用真实旧令牌和选定账户版本。不同服务商切换由绑定事务更新元数据,解除必须显式选择,切换 HTTP-01 需先解除保存。
|
||||
- [x] 绑定失败停止后续保存,409 或网络不确定不自动重放、不使用系统凭据兜底;已创建的域名或成功绑定不因后续服务器关联失败重复创建、绑定。重新读取后核对真实状态再操作。
|
||||
- [✓] 新增 24 项逻辑、2 项渲染回归;相关 46 项通过,前端全量 336 项零失败零跳过,生产构建 103 模块及差异检查通过。覆盖固定目标、跨项目、异常响应、迟到响应、卸载和保存中重复操作。
|
||||
- [~] 最新域名页面尚未上线及浏览器验收。执行租约、真实签发/续期/重试、旧账户幂等导入、安全删除回收与生产门禁仍未完成,P1-03 保持进行中。
|
||||
|
||||
## 2026年10月4日06:44 域名表单接入任务
|
||||
|
||||
沿用上方总目标及下方已确认需求,不缩减独立账户签发、续期和重试范围。
|
||||
|
||||
- [✓] 修改前检查点 `122291f` 已提交推送;首次鉴权失败后重推成功,未跟踪文件 `=` 保留。
|
||||
- [~] 第一台干净 `412c51f` 的 `scert-dns-ui-20261004-0630.service` 已实际启动,执行 Linux 全量、vet 和三库目录及接口竞态;观察时 active/running,不提前计为通过。
|
||||
- [~] 域名表单读取真实 DNS 绑定、按项目选择已启用且验证通过的独立账户,并保留历史配置兼容入口。
|
||||
- [ ] 接通新建及编辑保存,固定目标、绑定令牌和账户版本;部分成功不重复创建,冲突或结果不确定时必须刷新,禁止静默降级。
|
||||
- [ ] 覆盖迟到响应、卸载、跨项目、轮换、禁用、绑定失败、服务器关联失败后的重试及页面渲染。
|
||||
- [ ] 继续执行租约、旧账户导入、安全删除回收与真实签发续期;当前不发布过渡版本,不关闭 P1-03。
|
||||
|
||||
## 2026年10月4日06:28 页面生命周期开发结果
|
||||
|
||||
- [✓] 第一台 `0610` 单元已 inactive/success、退出码零;`c80530f` 单轮三库竞态通过:数据库 81.666 秒、仓储 37.304 秒、证书 1.032 秒、HTTP 22.561 秒。包含历史排序规则与错误列排序规则用例,修复获得实库证据。不是原 `0555` 三轮通过。
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
# Linux 生产流程测试记录
|
||||
|
||||
## 2026年10月4日06:51 DNS 页面权限干净源码复审
|
||||
|
||||
- 第一台 `8.137.23.95`,源码 `/var/tmp/scert-dns-ui-20261004-0630/source`,真实 Git 干净签出 `412c51f5f0cf466f9d1ff01a09e23dd02e116385`;运行后工作区仍干净。
|
||||
- 最后增量 bundle 本机及第一台 SHA-256 一致:`fc7441a3792813358c12b690ba5eb0a1b0d4cfcfee9ab2c3ed0bb3cb970cd47c`。原等待密码的 SCP 连接关闭,重新密码认证上传成功,未据观察超时重启测试。
|
||||
- `scert-dns-ui-20261004-0630.service` 从 06:39:22 至 06:45:57,终态 inactive/dead、Result=success、ExecMainStatus=0。使用 nobody、内存 1200M、CPU 150%、本机离线工具链,不创建辅助脚本。
|
||||
- `go test -p 1 -count=1 -timeout=10m ./...` 及 `go vet -p 1 ./...` 通过。该全量未启用外部数据库,不能据此声称所有三库用例执行。
|
||||
- 随后开启隔离 MySQL/PostgreSQL,以 `-race -p 1 -count=1 -timeout=10m -v` 执行仓储和 HTTP 的 `Test(ProjectDirectory|ManagedDNSAccount|AuthSessionsSupportOptionalEmail|AdminAPIRejectsAuthenticatedRegularUser)` 专项;仓储 17.076 秒、HTTP 23.331 秒。SQLite 目录 1.26 秒、MySQL 12.88 秒、PostgreSQL 1.90 秒均 PASS,不是跳过或三轮结果。
|
||||
- 完整日志 `/var/tmp/scert-dns-ui-20261004-0630/review.log`;本轮不清理业务数据库、不升级正式服务、不操作第二台及发信配置。
|
||||
- 后续域名选择前端增量:本地相关 46 项、全量 336 项及 Vite 构建 103 模块通过;尚无最新页面的正式部署与浏览器证据,不能将本节后端结果套用为新增页面的实机验收。
|
||||
|
||||
## 2026年10月4日06:28 磁盘故障终态、绑定复跑和页面开发
|
||||
|
||||
### 第一台绑定修复实库结果
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
# Scert 开发任务清单
|
||||
|
||||
## 2026年10月4日06:51 开发与复审进展
|
||||
|
||||
- [x] 独立 DNS 账户选择已接入域名新建和编辑,显示服务商及归属;显式解除、实时绑定读取、版本冲突、跨项目过滤和部分成功恢复完成编码。
|
||||
- [✓] 前端新增 26 项、全量 336 项及构建通过;第一台 `412c51f` 后端全量、vet 和单轮三库目录及接口竞态均结束通过。
|
||||
- [~] 执行租约、签发续期重试、旧账户导入、安全删除回收与实际浏览器验收尚未完成;完整开发和生产验收仍未完成,不发布过渡功能为最终版本。
|
||||
|
||||
## 2026年10月4日06:28 DNS 页面开发进度
|
||||
|
||||
- [x] 独立账户页面生命周期及项目权限提示已接入,表格内滚动、原生弹窗和双列字段;凭据无持久化及回填,写入冲突不自动重试。
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
# Scert 整改任务执行清单
|
||||
|
||||
## 2026年10月4日06:51 DNS 域名配置接入
|
||||
|
||||
- [✓] 第一台 `412c51f` 干净源码全量、vet 和三库目录及接口单轮竞态通过,`0630` 单元退出零;不是三轮结果。
|
||||
- [x] 域名表单账户选择及显式绑定、解除完成,保留原不可用绑定;冲突和不确定结果要求重新读取,部分成功不重复创建或绑定。
|
||||
- [✓] 前端 336 项与构建通过,新增 26 项逻辑及渲染回归;最新页面未发布,浏览器验收待完成。
|
||||
- [~] 执行租约、签发续期重试、历史导入及安全回收继续开发;P1-03 和总体生产验收不关闭。
|
||||
|
||||
## 2026年10月4日06:28 DNS 页面生命周期
|
||||
|
||||
- [✓] 第一台 `0610` 修复版单轮三库竞态四包通过;原 `0555` 磁盘不足失败已记录,清理范围仅已停止的隔离 MySQL 数据,日志保留。
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
# Scert 整改、合并与优化清单
|
||||
|
||||
## 2026年10月4日06:51 最新执行进度
|
||||
|
||||
- [✓] 第一台干净 `412c51f` Linux 全量、vet 和单轮三库项目目录及 HTTP 权限竞态全部结束通过;`0630` 单元 success、退出零,PostgreSQL 新增目录用例实际执行通过。
|
||||
- [x] P1-03 域名配置已接入独立 DNS 账户选择、用户归属展示、版本与绑定令牌校验、显式解除及部分保存失败恢复;新建后不重复创建,绑定不确定时不静默改用系统凭据。
|
||||
- [✓] 最新前端全量 336 项与构建通过;26 项新增逻辑及渲染回归。真实浏览器和最新正式产物尚未验证,不以单元测试代替。
|
||||
- [~] 继续执行租约及实际签发、自动续期和重试接入;旧账户导入、安全删除回收、三库首次 Web、Smail 发信与重置、公共域名等生产门禁仍未完成。
|
||||
|
||||
第一台仍承担主程序测试,第二台只做证书推送。未升级正式服务,未改变域名解析、发信配置或业务数据库;本轮不处理 Windows。详细证据见《DNS多账户整改任务清单》和《Linux生产流程测试记录》。
|
||||
|
||||
## 2026年10月4日06:28 DNS 页面与实库进度
|
||||
|
||||
- [✓] `c80530f` 第一台 `0610` 单轮三库竞态四包通过,MySQL 历史排序规则修复获得实库证据。`0555` 先前因磁盘不足失败;仅清理已终止隔离 MySQL 数据目录,保留日志和生产数据,不改写失败记录。
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
# 核心能力任务规格
|
||||
|
||||
## 2026年10月4日06:51 域名 DNS 账户交互契约
|
||||
|
||||
- 新建及编辑域名读取 `/api/v1/dns/accounts`;编辑还读取 `/api/v1/domains/:id/dns-account`,列表缓存不能替代真实绑定令牌。
|
||||
- 账户筛选要求当前项目、独立账户、启用、验证通过、凭据可用及合法版本;已有不可用账户保留绑定并提示,不自动变更。
|
||||
- 保存顺序为域名资料、显式账户绑定、服务器关联;账户绑定请求包含 `dns_account_id`、`expected_binding_id`、`account_revision`,固定打开的域名与提交瞬间账户版本。
|
||||
- 绑定冲突、异常响应或网络结果不明禁止自动重放,先重新读取核对;已成功创建或绑定不因后续步骤失败重复操作。HTTP-01 切换前须显式解除并保存,避免静默降级。
|
||||
- 前端全量 336 项及构建通过,仅证明交互契约回归;独立账户执行租约尚未接入,不据此宣称实际签发、续期或生产验收完成。
|
||||
|
||||
## 2026年10月4日06:28 DNS 页面权限契约
|
||||
|
||||
- `GET /api/v1/projects` 增加 `can_manage_dns`:超级管理员、当前项目管理员或 DNS 管理员为真;这是界面提示,不替代每次写入的实时授权。
|
||||
|
||||
@ -6,6 +6,7 @@ import "./DashboardLogout.test.js";
|
||||
import "./SettingsAccess.test.js";
|
||||
import "./DomainModal.test.js";
|
||||
import "./DomainForm.test.js";
|
||||
import "./DomainDNSAccount.test.js";
|
||||
import "../DNSAccounts/DNSAccounts.test.js";
|
||||
import test from "node:test";
|
||||
import { nextTick, ref } from "vue";
|
||||
@ -26,7 +27,7 @@ test("管理页面使用拆分后的域名表单与同项目服务器候选", as
|
||||
if (path === "/api/v1/servers") return Response.json({ items: [server, { id: "other", project_id: "other" }] });
|
||||
return Response.json({ items: [] });
|
||||
});
|
||||
state.openCreate();
|
||||
await state.openCreate();
|
||||
assert.deepEqual(state.domainServerOptions.value, [server]);
|
||||
state.form.value.name = item.name;
|
||||
state.form.value.server_ids = [server.id];
|
||||
|
||||
187
frontend/src/views/Dashboard/DomainDNSAccount.js
Normal file
187
frontend/src/views/Dashboard/DomainDNSAccount.js
Normal file
@ -0,0 +1,187 @@
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
|
||||
const validID = (value) => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
|
||||
|
||||
function validBinding(item, domainID, projectID) {
|
||||
return item && item.domain_id === domainID && item.project_id === projectID &&
|
||||
((item.id === "" && item.dns_account_id === "") ||
|
||||
(validID(item.id) && validID(item.dns_account_id)));
|
||||
}
|
||||
|
||||
export function useDomainDNSAccount({ fetchAdmin, form, saving }) {
|
||||
const dnsAccounts = ref([]);
|
||||
const dnsAccountID = ref("");
|
||||
const dnsBindingLoading = ref(false);
|
||||
const dnsBindingReady = ref(false);
|
||||
const dnsBindingError = ref("");
|
||||
const dnsBound = ref(false);
|
||||
let generation = 0;
|
||||
let controller;
|
||||
let target = null;
|
||||
let binding = null;
|
||||
let boundProvider = "";
|
||||
|
||||
const domainDNSAccountOptions = computed(() => dnsAccounts.value.filter((account) =>
|
||||
account.project_id === target?.projectID && account.managed === true &&
|
||||
validID(account.id) && validID(account.provider) &&
|
||||
Number.isInteger(account.revision) && account.revision > 0 && account.revision <= 2147483647 &&
|
||||
account.status === "active" && account.verification_state === "verified" &&
|
||||
account.credential_state === "available",
|
||||
));
|
||||
const dnsUnavailableSelection = computed(() => Boolean(dnsAccountID.value) &&
|
||||
!domainDNSAccountOptions.value.some((account) => account.id === dnsAccountID.value));
|
||||
|
||||
function cancel() {
|
||||
generation += 1;
|
||||
controller?.abort();
|
||||
controller = null;
|
||||
dnsBindingLoading.value = false;
|
||||
dnsBindingReady.value = false;
|
||||
dnsAccounts.value = [];
|
||||
dnsAccountID.value = "";
|
||||
dnsBindingError.value = "";
|
||||
dnsBound.value = false;
|
||||
target = null;
|
||||
binding = null;
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetchAdmin(path, { cache: "no-store", ...options });
|
||||
} catch {
|
||||
throw new Error("网络读取失败,请重新读取 DNS 账户后核对");
|
||||
}
|
||||
if (!response.ok) {
|
||||
const messages = {
|
||||
401: "登录已失效,请重新登录",
|
||||
403: "无权读取或变更 DNS 账户绑定,请刷新权限后重新打开",
|
||||
409: "绑定、账户版本或任务状态已变化,请重新读取后核对,不会自动重试",
|
||||
422: "当前验证方式或账户状态不允许绑定,请重新读取后核对",
|
||||
};
|
||||
throw new Error(messages[response.status] || "DNS 账户请求失败,请重新读取后核对");
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw new Error("DNS 账户响应格式不正确,请重新读取后核对");
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadDomainDNS() {
|
||||
if (saving.value || !target) return;
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const current = ++generation;
|
||||
const expected = { ...target };
|
||||
dnsBindingLoading.value = true;
|
||||
dnsBindingReady.value = false;
|
||||
dnsBindingError.value = "";
|
||||
dnsAccounts.value = [];
|
||||
try {
|
||||
const [accounts, result] = await Promise.all([
|
||||
request("/api/v1/dns/accounts", { signal }),
|
||||
expected.domainID
|
||||
? request(`/api/v1/domains/${expected.domainID}/dns-account`, { signal })
|
||||
: Promise.resolve({ id: "", dns_account_id: "", domain_id: "", project_id: expected.projectID }),
|
||||
]);
|
||||
if (current !== generation) return;
|
||||
if (!Array.isArray(accounts?.items) || !validBinding(result, expected.domainID, expected.projectID)) {
|
||||
throw new Error("DNS 账户或绑定响应不完整,请重新读取");
|
||||
}
|
||||
binding = { ...result };
|
||||
dnsAccounts.value = accounts.items.filter((item) => item && typeof item === "object");
|
||||
dnsAccountID.value = binding.dns_account_id;
|
||||
dnsBound.value = Boolean(binding.id);
|
||||
const account = dnsAccounts.value.find((item) => item.id === binding.dns_account_id);
|
||||
// 重新读取后以实际绑定服务商为准;不把用户尚未保存的选择当成现有绑定。
|
||||
if (account && validID(account.provider)) boundProvider = account.provider;
|
||||
if (binding.id) form.value.dns_provider = boundProvider;
|
||||
dnsBindingReady.value = true;
|
||||
} catch (error) {
|
||||
if (current !== generation) return;
|
||||
binding = null;
|
||||
dnsBindingError.value = error instanceof SyntaxError || error?.name === "TypeError"
|
||||
? "读取 DNS 账户失败,请重新读取;不会清空原绑定"
|
||||
: error.message || "读取 DNS 账户失败,请重新读取";
|
||||
} finally {
|
||||
if (current === generation) dnsBindingLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function open(item = {}) {
|
||||
cancel();
|
||||
target = { domainID: item.id || "", projectID: item.project_id ?? form.value.project_id };
|
||||
boundProvider = item.dns_provider || "";
|
||||
return reloadDomainDNS();
|
||||
}
|
||||
|
||||
function prepare(payload) {
|
||||
if (!dnsBindingReady.value || dnsBindingLoading.value || !binding) {
|
||||
throw new Error("请先成功读取 DNS 账户和绑定,再保存配置");
|
||||
}
|
||||
if (form.value.project_id !== target.projectID) {
|
||||
throw new Error("域名项目已变化,请关闭后重新打开表单");
|
||||
}
|
||||
if (payload.challenge_type === "http-01" && (binding.id || dnsAccountID.value)) {
|
||||
throw new Error("请先在 DNS 验证方式下解除账户绑定并保存,再切换 HTTP-01");
|
||||
}
|
||||
const changed = dnsAccountID.value !== binding.dns_account_id;
|
||||
const account = domainDNSAccountOptions.value.find((item) => item.id === dnsAccountID.value);
|
||||
if (changed && dnsAccountID.value && !account) {
|
||||
throw new Error("请选择当前项目内已启用且验证通过的 DNS 账户");
|
||||
}
|
||||
if (binding.id) payload.dns_provider = boundProvider;
|
||||
else if (account) payload.dns_provider = account.provider;
|
||||
return Object.freeze({
|
||||
generation, domainID: target.domainID, projectID: target.projectID, changed,
|
||||
accountID: dnsAccountID.value, revision: account?.revision || 0,
|
||||
expectedID: binding.id, provider: account?.provider || "",
|
||||
});
|
||||
}
|
||||
|
||||
async function persist(snapshot, domainID) {
|
||||
if (snapshot.generation !== generation) return null;
|
||||
if (!validID(domainID) || (snapshot.domainID && domainID !== snapshot.domainID)) {
|
||||
throw new Error("域名保存目标不一致,请关闭表单后核对");
|
||||
}
|
||||
target.domainID = domainID;
|
||||
if (!snapshot.changed) return null;
|
||||
try {
|
||||
const result = await request(`/api/v1/domains/${domainID}/dns-account`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
dns_account_id: snapshot.accountID,
|
||||
expected_binding_id: snapshot.expectedID,
|
||||
account_revision: snapshot.revision,
|
||||
}),
|
||||
});
|
||||
if (snapshot.generation !== generation) return null;
|
||||
if (!validBinding(result, domainID, snapshot.projectID) || result.dns_account_id !== snapshot.accountID ||
|
||||
(snapshot.accountID && result.id === snapshot.expectedID)) {
|
||||
throw new Error("DNS 绑定保存响应不完整,请重新读取后核对");
|
||||
}
|
||||
binding = { ...result };
|
||||
dnsAccountID.value = result.dns_account_id;
|
||||
dnsBound.value = Boolean(result.id);
|
||||
boundProvider = snapshot.provider;
|
||||
form.value.dns_provider = boundProvider;
|
||||
return { dns_account_id: result.dns_account_id, dns_binding_id: result.id, dns_provider: boundProvider };
|
||||
} catch {
|
||||
if (snapshot.generation !== generation) return null;
|
||||
// 写入结果可能已提交,不能用旧令牌自动重试,也不能退回系统账户继续保存。
|
||||
dnsBindingReady.value = false;
|
||||
dnsBindingError.value = "DNS 绑定保存未确认,可能存在权限、版本或在途任务冲突;请重新读取后核对,不会自动重试";
|
||||
throw new Error(dnsBindingError.value);
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(cancel);
|
||||
return {
|
||||
dnsAccountID, dnsBindingLoading, dnsBindingReady, dnsBindingError, dnsBound,
|
||||
domainDNSAccountOptions, dnsUnavailableSelection, reloadDomainDNS,
|
||||
open, cancel, prepare, persist,
|
||||
};
|
||||
}
|
||||
261
frontend/src/views/Dashboard/DomainDNSAccount.test.js
Normal file
261
frontend/src/views/Dashboard/DomainDNSAccount.test.js
Normal file
@ -0,0 +1,261 @@
|
||||
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);
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import { computed, onBeforeUnmount, ref } from "vue";
|
||||
import { useDomainDNSAccount } from "./DomainDNSAccount.js";
|
||||
|
||||
function emptyForm() {
|
||||
return {
|
||||
@ -30,6 +31,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
const form = ref(emptyForm());
|
||||
const editingId = ref("");
|
||||
const saving = ref(false);
|
||||
const dnsAccount = useDomainDNSAccount({ fetchAdmin, form, saving });
|
||||
const showForm = ref(false);
|
||||
const formError = ref("");
|
||||
const domainBindingsLoading = ref(false);
|
||||
@ -43,6 +45,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
let version = 0;
|
||||
let savedPayload = "";
|
||||
let createdInForm = false;
|
||||
let targetID = "";
|
||||
|
||||
onBeforeUnmount(() => { version += 1; });
|
||||
|
||||
@ -50,6 +53,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
if (saving.value) return;
|
||||
version += 1;
|
||||
editingId.value = "";
|
||||
targetID = "";
|
||||
form.value = emptyForm();
|
||||
formError.value = "";
|
||||
savedPayload = "";
|
||||
@ -58,12 +62,14 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
domainBindingsReady.value = true;
|
||||
domainSaveUncertain.value = false;
|
||||
showForm.value = true;
|
||||
return dnsAccount.open();
|
||||
}
|
||||
|
||||
async function openEdit(item) {
|
||||
if (saving.value) return;
|
||||
version += 1;
|
||||
editingId.value = item.id;
|
||||
targetID = item.id;
|
||||
form.value = {
|
||||
...emptyForm(),
|
||||
project_id: item.project_id || "",
|
||||
@ -82,12 +88,13 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
domainBindingsReady.value = false;
|
||||
domainSaveUncertain.value = false;
|
||||
showForm.value = true;
|
||||
await reloadDomainBindings();
|
||||
await Promise.all([reloadDomainBindings(), dnsAccount.open(item)]);
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
if (saving.value) return;
|
||||
version += 1;
|
||||
dnsAccount.cancel();
|
||||
domainBindingsLoading.value = false;
|
||||
showForm.value = false;
|
||||
}
|
||||
@ -95,7 +102,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
async function reloadDomainBindings() {
|
||||
if (saving.value || domainBindingsLoading.value || !editingId.value) return;
|
||||
const currentVersion = version;
|
||||
const id = editingId.value;
|
||||
const id = targetID;
|
||||
domainBindingsLoading.value = true;
|
||||
domainBindingsReady.value = false;
|
||||
formError.value = "";
|
||||
@ -152,13 +159,21 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
formError.value = "请选择当前项目内可用的服务器,每个域名最多关联一百台";
|
||||
return;
|
||||
}
|
||||
const serialized = JSON.stringify(payload);
|
||||
let dnsSnapshot;
|
||||
try {
|
||||
dnsSnapshot = dnsAccount.prepare(payload);
|
||||
} catch (err) {
|
||||
formError.value = err.message;
|
||||
return;
|
||||
}
|
||||
let serialized = JSON.stringify(payload);
|
||||
const currentVersion = version;
|
||||
let saveStage = "资料";
|
||||
saving.value = true;
|
||||
try {
|
||||
// 资料与关联不是同一事务;已确认保存的资料不因关联失败而重复创建。
|
||||
if (savedPayload !== serialized) {
|
||||
const existingId = editingId.value;
|
||||
const existingId = targetID;
|
||||
let response;
|
||||
try {
|
||||
response = await fetchAdmin(existingId ? `/api/v1/domains/${existingId}` : "/api/v1/domains", {
|
||||
@ -181,11 +196,12 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
: result?.error || "保存域名失败");
|
||||
}
|
||||
if (!result || !/^[A-Za-z0-9_-]{1,128}$/.test(result.id || "") ||
|
||||
!result.project_id || (existingId && result.id !== existingId)) {
|
||||
result.project_id !== dnsSnapshot.projectID || (existingId && result.id !== existingId)) {
|
||||
domainSaveUncertain.value = true;
|
||||
throw new Error("域名保存响应不完整,请关闭表单并刷新列表确认,暂不重复提交");
|
||||
}
|
||||
editingId.value = result.id;
|
||||
targetID = result.id;
|
||||
form.value.project_id = result.project_id;
|
||||
createdInForm ||= !existingId;
|
||||
savedPayload = serialized;
|
||||
@ -194,11 +210,21 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
else domains.value = domains.value.map((item) => item.id === result.id ? result : item);
|
||||
}
|
||||
if (currentVersion !== version) return;
|
||||
saveStage = "DNS 账户绑定";
|
||||
const dnsResult = await dnsAccount.persist(dnsSnapshot, targetID);
|
||||
if (currentVersion !== version) return;
|
||||
if (dnsResult) {
|
||||
domains.value = domains.value.map((item) => item.id === targetID ? { ...item, ...dnsResult } : item);
|
||||
payload.dns_provider = dnsResult.dns_provider;
|
||||
serialized = JSON.stringify(payload);
|
||||
savedPayload = serialized;
|
||||
}
|
||||
saveStage = "服务器关联";
|
||||
const currentIds = new Set(domainServerOptions.value.map((server) => server.id));
|
||||
if (serverIds.some((id) => !currentIds.has(id))) {
|
||||
throw new Error("服务器所属项目或可见范围已变化,请重新选择服务器");
|
||||
}
|
||||
const id = editingId.value;
|
||||
const id = targetID;
|
||||
let response;
|
||||
try {
|
||||
response = await fetchAdmin(`/api/v1/domains/${id}/servers`, {
|
||||
@ -215,6 +241,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
throw new Error(result?.error || "保存服务器关联结果未确认,可保留选择后重试");
|
||||
}
|
||||
domainServers.value = { ...domainServers.value, [id]: result.items };
|
||||
dnsAccount.cancel();
|
||||
showForm.value = false;
|
||||
notice.value = createdInForm ? "域名已添加,等待申请证书" : "域名配置已更新";
|
||||
try {
|
||||
@ -225,7 +252,7 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
} catch (err) {
|
||||
if (currentVersion === version) {
|
||||
const prefix = !savedPayload ? "" : savedPayload === serialized
|
||||
? "域名资料已保存;服务器关联尚未确认。"
|
||||
? `域名资料已保存;${saveStage}尚未确认。`
|
||||
: "此前域名资料已保存;本次修改尚未完成。";
|
||||
formError.value = prefix + (err.message || "保存域名失败");
|
||||
}
|
||||
@ -235,6 +262,14 @@ export function useDomainForm({ fetchAdmin, domains, servers, domainServers, not
|
||||
}
|
||||
|
||||
return {
|
||||
dnsAccountID: dnsAccount.dnsAccountID,
|
||||
dnsBindingLoading: dnsAccount.dnsBindingLoading,
|
||||
dnsBindingReady: dnsAccount.dnsBindingReady,
|
||||
dnsBindingError: dnsAccount.dnsBindingError,
|
||||
dnsBound: dnsAccount.dnsBound,
|
||||
domainDNSAccountOptions: dnsAccount.domainDNSAccountOptions,
|
||||
dnsUnavailableSelection: dnsAccount.dnsUnavailableSelection,
|
||||
reloadDomainDNS: dnsAccount.reloadDomainDNS,
|
||||
form, editingId, saving, showForm, formError, isEditing, formTitle,
|
||||
domainServerOptions, domainBindingsLoading, domainBindingsReady, domainSaveUncertain,
|
||||
openCreate, openEdit, closeForm, saveDomain, reloadDomainBindings,
|
||||
|
||||
@ -22,12 +22,18 @@ async function mountForm(t, fetcher) {
|
||||
notice: ref(""),
|
||||
loadData: t.mock.fn(async () => {}),
|
||||
};
|
||||
const mounted = await mountComposable(t, () => useDomainForm(deps), fetcher);
|
||||
const mounted = await mountComposable(t, () => useDomainForm(deps), (path, options) => {
|
||||
if (path === "/api/v1/dns/accounts") return Promise.resolve(Response.json({ items: [] }));
|
||||
if (path.endsWith("/dns-account")) return Promise.resolve(Response.json({
|
||||
id: "", dns_account_id: "", domain_id: path.split("/")[4], project_id: "default",
|
||||
}));
|
||||
return fetcher(path, options);
|
||||
});
|
||||
return { ...mounted, deps };
|
||||
}
|
||||
|
||||
function startCreate(state) {
|
||||
state.openCreate();
|
||||
async function startCreate(state) {
|
||||
await state.openCreate();
|
||||
state.form.value.name = firstDomain.name;
|
||||
state.form.value.server_ids = [firstServer.id, secondServer.id];
|
||||
}
|
||||
@ -39,7 +45,7 @@ test("HTTP 验证保存方式且不携带 DNS 服务商", async (t) => {
|
||||
if (path.endsWith("/servers")) return Response.json({ items: [] });
|
||||
return Response.json({ ...firstDomain, ...JSON.parse(options.body) });
|
||||
});
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
state.form.value.challenge_type = "http-01";
|
||||
state.form.value.dns_provider = "aliyun";
|
||||
await state.saveDomain();
|
||||
@ -51,7 +57,7 @@ test("HTTP 验证拒绝主泛域名和附加泛域名,不提交请求", async
|
||||
let calls = 0;
|
||||
const { state } = await mountForm(t, async () => { calls += 1; return Response.json({}); });
|
||||
for (const type of ["wildcard", "san"]) {
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
state.form.value.challenge_type = "http-01";
|
||||
state.form.value.certificate_type = type;
|
||||
state.form.value.sans = "*.example.com";
|
||||
@ -80,7 +86,7 @@ test("创建成功但关联冲突后只重试关联,不重复创建且不改
|
||||
? Response.json({ error: "共享路径冲突" }, { status: 409 })
|
||||
: Response.json({ items: [firstServer, secondServer] });
|
||||
});
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
await state.saveDomain();
|
||||
assert.equal(state.editingId.value, firstDomain.id);
|
||||
assert.equal(state.showForm.value, true);
|
||||
@ -106,7 +112,7 @@ test("部分成功后修改资料使用已有标识更新,取消也保留已
|
||||
if (path.endsWith("/servers")) return Response.json({ error: "关联未保存" }, { status: 403 });
|
||||
return Response.json({ ...firstDomain, ...JSON.parse(options.body) });
|
||||
});
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
await state.saveDomain();
|
||||
state.form.value.auto_renew = false;
|
||||
await state.saveDomain();
|
||||
@ -142,7 +148,7 @@ test("编辑先读取真实关联,明确清空只提交当前域名", async (t
|
||||
test("服务器按域名项目过滤,不限制其他域名共享,缺归属不默认为默认项目", async (t) => {
|
||||
const { state, deps } = await mountForm(t, async () => Response.json({ items: [] }));
|
||||
deps.servers.value.push({ id: "unknown" });
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
assert.deepEqual(state.domainServerOptions.value.map((server) => server.id), [firstServer.id, secondServer.id]);
|
||||
await state.openEdit({ ...firstDomain, project_id: "other" });
|
||||
assert.deepEqual(state.domainServerOptions.value, [otherServer]);
|
||||
@ -201,7 +207,7 @@ test("保存期间重复点击、关闭和切换表单不创建额外请求", as
|
||||
calls += 1;
|
||||
return path.endsWith("/servers") ? Response.json({ items: [firstServer, secondServer] }) : pending.promise;
|
||||
});
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
const saving = state.saveDomain();
|
||||
await state.saveDomain();
|
||||
state.openCreate();
|
||||
@ -224,7 +230,7 @@ for (const failure of ["network", "server", "malformed", "missing-project"]) {
|
||||
if (failure === "server") return Response.json({}, { status: 500 });
|
||||
return Response.json(failure === "missing-project" ? { id: firstDomain.id } : {});
|
||||
});
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
await state.saveDomain();
|
||||
assert.equal(state.domainSaveUncertain.value, true);
|
||||
assert.match(state.formError.value, /刷新列表确认/);
|
||||
@ -240,7 +246,7 @@ test("卸载后不继续保存关联,不改写列表", async (t) => {
|
||||
calls += 1;
|
||||
return pending.promise;
|
||||
});
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
const saving = state.saveDomain();
|
||||
unmount();
|
||||
pending.resolve(Response.json(firstDomain));
|
||||
@ -258,7 +264,7 @@ test("关联网络失败可重试,但刷新失败不能被误报为保存失
|
||||
return Response.json({ items: [firstServer, secondServer] });
|
||||
});
|
||||
deps.loadData.mock.mockImplementation(async () => { throw new Error("offline"); });
|
||||
startCreate(state);
|
||||
await startCreate(state);
|
||||
await state.saveDomain();
|
||||
assert.match(state.formError.value, /关联保存结果未确认/);
|
||||
assert.equal(state.domainSaveUncertain.value, false);
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
.domain-modal .form-grid,
|
||||
.domain-modal .switch-field,
|
||||
.domain-modal .server-picker,
|
||||
.domain-modal .domain-dns-status,
|
||||
.domain-modal .form-error,
|
||||
.domain-modal .modal-actions {
|
||||
grid-column: 1 / -1;
|
||||
@ -42,6 +43,20 @@
|
||||
padding: 8px 11px;
|
||||
}
|
||||
|
||||
.domain-modal .form-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.domain-modal .field select {
|
||||
width: 100%;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.domain-modal .domain-dns-status {
|
||||
margin: 0 0 8px;
|
||||
color: #52657d;
|
||||
}
|
||||
|
||||
.domain-modal .field textarea {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
@ -35,6 +35,14 @@ async function renderModal(overrides = {}) {
|
||||
server_ids: ["server-one", "server-two"],
|
||||
},
|
||||
dnsProviders: [],
|
||||
dnsAccountID: "",
|
||||
dnsBindingLoading: false,
|
||||
dnsBindingReady: true,
|
||||
dnsBindingError: "",
|
||||
dnsBound: false,
|
||||
domainDNSAccountOptions: [],
|
||||
dnsUnavailableSelection: false,
|
||||
reloadDomainDNS() {},
|
||||
domainServerOptions: [
|
||||
{ id: "server-one", name: "共享服务器一", address: "192.0.2.1", web_server: "nginx" },
|
||||
{ id: "server-two", name: "共享服务器二", address: "192.0.2.2", web_server: "nginx" },
|
||||
@ -73,6 +81,31 @@ test("域名表单使用复选框同时显示两台已关联服务器并提供
|
||||
assert.match(html, /当前域名所属项目/);
|
||||
});
|
||||
|
||||
test("域名账户显示归属与服务商,原绑定不可用不被静默清空", async () => {
|
||||
const html = await renderModal({
|
||||
dnsAccountID: "old-account", dnsBound: true, dnsUnavailableSelection: true,
|
||||
domainDNSAccountOptions: [{ id: "new-account", name: "生产账户", provider_name: "阿里云 DNS", owner_username: "甲用户" }],
|
||||
});
|
||||
assert.match(html, /当前绑定账户不可用/);
|
||||
assert.match(html, /生产账户 · 阿里云 DNS · 甲用户/);
|
||||
assert.match(html, /解除独立绑定/);
|
||||
assert.doesNotMatch(html, /id="dns-provider"/);
|
||||
const challenge = html.match(/<select\b[^>]*id="challenge-type"[^>]*>/)?.[0];
|
||||
assert.match(challenge, /disabled/);
|
||||
});
|
||||
|
||||
test("DNS 绑定读取中或失败时禁止提交并提供重新读取", async () => {
|
||||
for (const overrides of [
|
||||
{ dnsBindingLoading: true, dnsBindingReady: false },
|
||||
{ dnsBindingReady: false, dnsBindingError: "绑定读取失败" },
|
||||
]) {
|
||||
const html = await renderModal(overrides);
|
||||
const submit = html.match(/<button\b[^>]*type="submit"[^>]*>/)?.[0];
|
||||
assert.match(submit, /disabled/);
|
||||
assert.match(html, /重新读取 DNS 绑定/);
|
||||
}
|
||||
});
|
||||
|
||||
test("关联读取失败或结果未确认时禁用提交,读取失败提供重试入口", async () => {
|
||||
for (const overrides of [
|
||||
{ domainBindingsReady: false },
|
||||
|
||||
@ -9,6 +9,14 @@ const {
|
||||
saveDomain,
|
||||
form,
|
||||
dnsProviders,
|
||||
dnsAccountID,
|
||||
dnsBindingLoading,
|
||||
dnsBindingReady,
|
||||
dnsBindingError,
|
||||
dnsBound,
|
||||
domainDNSAccountOptions,
|
||||
dnsUnavailableSelection,
|
||||
reloadDomainDNS,
|
||||
domainServerOptions,
|
||||
domainBindingsLoading,
|
||||
domainBindingsReady,
|
||||
@ -102,14 +110,29 @@ const {
|
||||
id="challenge-type"
|
||||
name="challenge_type"
|
||||
v-model="form.challenge_type"
|
||||
:disabled="saving || domainSaveUncertain"
|
||||
:disabled="saving || domainSaveUncertain || dnsBound || Boolean(dnsAccountID) || !dnsBindingReady"
|
||||
>
|
||||
<option value="dns-01">DNS-01(DNS 验证)</option>
|
||||
<option value="http-01">HTTP-01(网站验证)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.challenge_type !== 'http-01'" class="field" for="dns-provider"
|
||||
><span>DNS 验证服务商</span
|
||||
<label v-if="form.challenge_type !== 'http-01'" class="field" for="domain-dns-account">
|
||||
<span>DNS 账户 <HelpTip>只可选择本项目内已启用且验证通过的账户。更换账户会校验当前绑定和账户版本;解除后保存,再切换验证方式。历史系统配置尚未导入独立账户。</HelpTip></span>
|
||||
<select
|
||||
id="domain-dns-account"
|
||||
name="dns_account_id"
|
||||
v-model="dnsAccountID"
|
||||
:disabled="saving || domainSaveUncertain || dnsBindingLoading || !dnsBindingReady"
|
||||
>
|
||||
<option value="">{{ dnsBound ? "解除独立绑定(恢复历史配置)" : "历史系统配置 / 暂不绑定" }}</option>
|
||||
<option v-if="dnsUnavailableSelection" :value="dnsAccountID" disabled>当前绑定账户不可用(可保留或更换)</option>
|
||||
<option v-for="account in domainDNSAccountOptions" :key="account.id" :value="account.id">
|
||||
{{ account.name }} · {{ account.provider_name || account.provider }} · {{ account.owner_username }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="form.challenge_type !== 'http-01' && !dnsAccountID && !dnsBound" class="field" for="dns-provider"
|
||||
><span>历史 DNS 服务商 <HelpTip>仅兼容尚未迁移的系统配置;独立账户选择后由账户决定服务商。</HelpTip></span
|
||||
><select
|
||||
id="dns-provider"
|
||||
name="dns_provider"
|
||||
@ -127,6 +150,8 @@ const {
|
||||
</select></label
|
||||
>
|
||||
</div>
|
||||
<p v-if="dnsBindingLoading" class="domain-dns-status" role="status">正在读取 DNS 账户和现有绑定……</p>
|
||||
<p v-else-if="dnsBindingError" class="form-error" role="alert">{{ dnsBindingError }}</p>
|
||||
<label class="switch-field" for="auto-renew"
|
||||
><input
|
||||
id="auto-renew"
|
||||
@ -173,6 +198,15 @@ const {
|
||||
</fieldset>
|
||||
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
||||
<div class="modal-actions">
|
||||
<button
|
||||
v-if="!dnsBindingReady"
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="saving || dnsBindingLoading || domainSaveUncertain"
|
||||
@click="reloadDomainDNS"
|
||||
>
|
||||
重新读取 DNS 绑定
|
||||
</button>
|
||||
<button
|
||||
v-if="!domainBindingsReady"
|
||||
class="secondary-button"
|
||||
@ -192,7 +226,7 @@ const {
|
||||
><button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
:disabled="saving || domainBindingsLoading || !domainBindingsReady || domainSaveUncertain"
|
||||
:disabled="saving || domainBindingsLoading || !domainBindingsReady || domainSaveUncertain || dnsBindingLoading || !dnsBindingReady"
|
||||
>
|
||||
{{ saving ? "保存中……" : "保存配置" }}
|
||||
</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user