继续修复DNS账户编辑功能

保存当前DNS账户编辑功能的阶段性修改,后续继续完善测试与权限校验。
This commit is contained in:
xuans 2026-10-16 00:12:05 +08:00
parent 030624a1b1
commit 4aca34c16d
8 changed files with 66 additions and 24 deletions

View File

@ -122,8 +122,9 @@ func (s *Server) renameManagedDNSAccount(c *gin.Context) {
return
}
var request struct {
Name string `json:"name"`
Revision int64 `json:"revision"`
Name string `json:"name"`
Revision int64 `json:"revision"`
Credentials map[string]string `json:"credentials"`
}
if err := bindJSON(c, &request, setupRequestLimit); err != nil || request.Revision < 1 {
s.writeError(c, http.StatusBadRequest, "DNS 账户名称或版本无效", nil)
@ -135,12 +136,20 @@ func (s *Server) renameManagedDNSAccount(c *gin.Context) {
s.writeDNSAccountError(c, err)
return
}
item.Name, item.CredentialRef = strings.TrimSpace(request.Name), "secret://dns/validation"
item.Name = strings.TrimSpace(request.Name)
item.CredentialRef = "secret://dns/validation"
if err := item.Validate(); err != nil {
s.writeError(c, http.StatusBadRequest, "DNS 账户名称无效", nil)
return
}
updated, err := repo.RenameDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, item.Name)
reference := ""
if len(request.Credentials) > 0 {
reference = "secret://dns/" + domain.NewID()
if !s.storeManagedDNSCredential(c, item.Provider, reference, request.Credentials) {
return
}
}
updated, err := repo.UpdateDNSAccount(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id"), request.Revision, item.Name, reference)
if err != nil {
s.writeDNSAccountError(c, err)
return

View File

@ -74,13 +74,13 @@ func TestManagedDNSAccountLifecycle(t *testing.T) {
oldReference := stored.CredentialRef
managedDNSRequest(t, f, "manager", http.MethodPut, path, `{"revision":2,"name":"更名后的账户"}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPut, path, `{"revision":2,"name":"旧版本"}`, http.StatusConflict)
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/status", `{"revision":3,"enabled":false}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPost, path+"/verify", `{"revision":4,"test_domain":"example.test"}`, http.StatusForbidden)
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/status", `{"revision":4,"enabled":false}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPost, path+"/verify", `{"revision":5,"test_domain":"example.test"}`, http.StatusForbidden)
if verifications != 1 {
t.Fatal("已禁用账户仍调用服务商")
}
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/credentials", `{"revision":4,"credentials":{"api_token":"rotated-sensitive-token"}}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/status", `{"revision":5,"enabled":true}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/credentials", `{"revision":5,"credentials":{"api_token":"rotated-sensitive-token"}}`, http.StatusOK)
managedDNSRequest(t, f, "manager", http.MethodPut, path+"/status", `{"revision":6,"enabled":true}`, http.StatusOK)
stored, err = f.members.GetDNSAccountForVerification(t.Context(), "manager", item.ID)
if err != nil || stored.CredentialRef == oldReference || stored.VerificationState != domain.DNSAccountUnverified || len(secrets.data) != 2 {
t.Fatal("轮换没有生成新引用、保留旧引用或清除验证状态", err)
@ -91,9 +91,9 @@ func TestManagedDNSAccountLifecycle(t *testing.T) {
}
return dnsverification.Report{}, errors.New("internal-sensitive-error")
})
managedDNSRequest(t, f, "manager", http.MethodPost, path+"/verify", `{"revision":6,"test_domain":"example.test"}`, http.StatusUnprocessableEntity)
managedDNSRequest(t, f, "manager", http.MethodPost, path+"/verify", `{"revision":7,"test_domain":"example.test"}`, http.StatusUnprocessableEntity)
stored, err = f.members.GetVisibleDNSAccount(t.Context(), "manager", item.ID)
if err != nil || stored.VerificationState != domain.DNSAccountVerificationFailed || stored.Revision != 7 {
if err != nil || stored.VerificationState != domain.DNSAccountVerificationFailed || stored.Revision != 8 {
t.Fatal("服务商失败没有记录到对应版本", err)
}
var count int

View File

@ -23,6 +23,7 @@ type DNSAccountRepository interface {
GetVisibleDNSAccount(context.Context, string, string) (domain.DNSAccount, error)
GetDNSAccountForUse(context.Context, string, string, string) (domain.DNSAccount, error)
GetDNSAccountForVerification(context.Context, string, string) (domain.DNSAccount, error)
UpdateDNSAccount(context.Context, string, string, int64, string, string) (domain.DNSAccount, error)
RenameDNSAccount(context.Context, string, string, int64, string) (domain.DNSAccount, error)
SetDNSAccountEnabled(context.Context, string, string, int64, bool) (domain.DNSAccount, error)
RotateDNSAccountCredential(context.Context, string, string, int64, string) (domain.DNSAccount, error)
@ -204,6 +205,17 @@ func (r *SQLProjectMemberRepository) RenameDNSAccount(ctx context.Context, actor
})
}
func (r *SQLProjectMemberRepository) UpdateDNSAccount(ctx context.Context, actorID, id string, revision int64, name, reference string) (domain.DNSAccount, error) {
return r.changeDNSAccount(ctx, actorID, id, revision, "dns_account.update", func(item *domain.DNSAccount) error {
item.Name = name
if reference != "" {
item.CredentialRef = reference
item.VerificationState = domain.DNSAccountUnverified
}
return nil
})
}
// 首次验证允许读取尚未验证的账户,但必须实时拥有管理权限,且账户未被禁用。
// 结果落库仍须核对读取时版本,不能将此快照作为长时间授权或执行租约。
func (r *SQLProjectMemberRepository) GetDNSAccountForVerification(ctx context.Context, actorID, id string) (domain.DNSAccount, error) {

View File

@ -78,6 +78,14 @@ button[type="submit"] {
overflow-wrap: anywhere;
}
.dns-dialog-hint {
grid-column: 1 / -1;
margin: -4px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
}
.dns-dialog-error {
color: #a92e2e;
}

View File

@ -28,7 +28,7 @@ onBeforeUnmount(() => previousFocus?.focus?.());
<button type="button" :disabled="saving" aria-label="关闭账户编辑" @click="emit('close')">关闭</button>
</header>
<fieldset :disabled="saving">
<label v-if="['create', 'rename'].includes(editor.action)">
<label v-if="['create', 'edit', 'rename'].includes(editor.action)">
账户名称
<input v-model="editor.name" autofocus required maxlength="128" aria-label="账户名称" />
</label>
@ -46,19 +46,22 @@ onBeforeUnmount(() => previousFocus?.focus?.());
</select>
</label>
</template>
<template v-if="['create', 'rotate'].includes(editor.action)">
<template v-if="['create', 'edit', 'rotate'].includes(editor.action)">
<label v-for="field in provider?.credential_fields || []" :key="`${editor.provider}-${field.name}`" :title="field.placeholder">
{{ field.label }}{{ field.required ? '(必填)' : '(可选)' }}
<input
v-model="editor.credentials[field.name]"
:type="field.secret ? 'password' : 'text'"
:required="field.required"
:required="field.required && editor.action !== 'edit'"
:aria-label="field.label"
:placeholder="field.placeholder"
autocomplete="off"
spellcheck="false"
/>
</label>
<p v-if="editor.action === 'edit'" class="dns-dialog-hint">
凭据字段留空表示保留当前配置;填写任意字段将替换全部凭据,并需要重新验证。
</p>
</template>
<label v-if="editor.action === 'verify'" title="仅填写你有权管理的域名。验证会创建、查询并清理临时 DNS 记录。">
授权测试域名

View File

@ -2,7 +2,7 @@ import { computed, onBeforeUnmount, ref } from "vue";
import { requestDNS } from "./DNSAccountRequest.js";
const titles = {
create: "新增 DNS 账户", rename: "修改账户名称", rotate: "轮换 DNS 凭据",
create: "新增 DNS 账户", edit: "编辑 DNS 账户", rename: "修改账户名称", rotate: "轮换 DNS 凭据",
verify: "验证 DNS 权限", status: "变更账户状态",
};
@ -60,7 +60,7 @@ export function useDNSAccountEditor(projects, providers, loading, refresh) {
return;
}
const body = form.action === "create" ? {} : { revision: target.revision };
if (["create", "rename"].includes(form.action)) {
if (["create", "edit", "rename"].includes(form.action)) {
const name = form.name.trim();
if (!name || [...name].length > 128 || /[\u0000-\u001f\u007f-\u009f]/u.test(name)) {
mutationError.value = "账户名称必须为不含控制字符的 1~128 个字符";
@ -68,21 +68,22 @@ export function useDNSAccountEditor(projects, providers, loading, refresh) {
}
body.name = name;
}
if (["create", "rotate"].includes(form.action)) {
if (["create", "edit", "rotate"].includes(form.action)) {
const provider = providers.value.find((item) => item.id === (target?.provider || form.provider));
if (!provider?.available || !Array.isArray(provider.credential_fields)) {
mutationError.value = "请选择可用的 DNS 服务商";
return;
}
body.credentials = {};
const credentials = {};
for (const field of provider.credential_fields) {
const value = String(form.credentials[field.name] || "").trim();
if (field.required && !value) {
if (field.required && form.action !== "edit" && !value) {
mutationError.value = `请填写${field.label}`;
return;
}
if (value) body.credentials[field.name] = value;
if (value) credentials[field.name] = value;
}
if (form.action !== "edit" || Object.keys(credentials).length > 0) body.credentials = credentials;
if (form.action === "create") Object.assign(body, { provider: provider.id, project_id: form.project_id });
}
if (form.action === "verify") {
@ -93,7 +94,7 @@ export function useDNSAccountEditor(projects, providers, loading, refresh) {
}
}
if (form.action === "status") body.enabled = target.status !== "active";
const suffix = { rename: "", rotate: "/credentials", verify: "/verify", status: "/status" };
const suffix = { edit: "", rename: "", rotate: "/credentials", verify: "/verify", status: "/status" };
const path = "/api/v1/dns/accounts" + (form.action === "create" ? "" : `/${encodeURIComponent(target.id)}${suffix[form.action]}`);
saving.value = true;
notice.value = "";
@ -104,7 +105,7 @@ export function useDNSAccountEditor(projects, providers, loading, refresh) {
body: JSON.stringify(body),
});
if (!mounted) return;
notice.value = ["create", "rotate"].includes(form.action)
notice.value = ["create", "edit", "rotate"].includes(form.action)
? "账户已保存,请使用有权管理的测试域名执行在线验证" : "账户操作已完成";
} catch (reason) {
if (!mounted) return;

View File

@ -62,6 +62,7 @@ test("DNS 新建只发送白名单字段并清空凭据", async (t) => {
});
for (const [action, suffix, body, method] of [
["edit", "", { revision: 7, name: "新名称", credentials: { api_token: "replacement-token" } }, "PUT"],
["rename", "", { revision: 7, name: "新名称" }, "PUT"],
["rotate", "/credentials", { revision: 7, credentials: { api_token: "replacement-token" } }, "PUT"],
["status", "/status", { revision: 7, enabled: false }, "PUT"],
@ -82,9 +83,18 @@ for (const [action, suffix, body, method] of [
});
}
test("编辑账户时凭据留空表示保留原配置", async (t) => {
const { state, calls } = await setup(t);
state.openEditor("edit", account);
state.editor.value.name = "仅修改名称";
await state.saveEditor();
const write = calls.find((item) => item.method);
assert.deepEqual(JSON.parse(write.body), { revision: 7, name: "仅修改名称" });
});
test("只读项目和历史配置不能发起账户写操作", async (t) => {
const { state, calls } = await setup(t, undefined, { manage: false });
for (const action of ["create", "rename", "rotate", "status", "verify"]) {
for (const action of ["create", "edit", "rename", "rotate", "status", "verify"]) {
state.openEditor(action, account);
assert.equal(state.editor.value, null);
}

View File

@ -63,9 +63,8 @@ watch(() => props.refreshKey, loadAccounts);
</td>
<td>
<div v-if="canManage(account)" class="dns-row-actions">
<button type="button" :disabled="saving" @click="openEditor('rename', account)">改名</button>
<button type="button" :disabled="saving" @click="openEditor('edit', account)">编辑</button>
<button type="button" :disabled="saving || account.status !== 'active'" @click="openEditor('verify', account)">验证</button>
<button type="button" :disabled="saving" title="保存新凭据并使旧验证状态失效" @click="openEditor('rotate', account)">轮换</button>
<button type="button" :disabled="saving" @click="openEditor('status', account)">{{ account.status === 'active' ? '停用' : '启用' }}</button>
</div>
<span v-else class="dns-muted" :title="account.managed ? '当前项目只读,操作权限由服务器实时复核' : '历史系统账户尚未迁移,此处仅展示,不支持独立账户操作'">{{ account.managed ? '只读' : '历史配置' }}</span>