diff --git a/backend/internal/httpapi/dns_api_test.go b/backend/internal/httpapi/dns_api_test.go index 9505138..d6f2bfc 100644 --- a/backend/internal/httpapi/dns_api_test.go +++ b/backend/internal/httpapi/dns_api_test.go @@ -55,10 +55,10 @@ func TestAdminDNSTestReturnsVerificationResult(t *testing.T) { t.Fatalf("DNS 在线测试失败:%d %s", response.Code, response.Body.String()) } var payload struct { - Verified bool `json:"verified"` - CredentialRef string `json:"credential_ref"` - VerificationToken string `json:"verification_token"` - TestReport setup.DNSVerificationReport `json:"test_report"` + Verified bool `json:"verified"` + CredentialRef string `json:"credential_ref"` + VerificationToken string `json:"verification_token"` + TestReport setup.DNSVerificationReport `json:"test_report"` VerificationTimeoutSecs int `json:"verification_timeout_secs"` } if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { @@ -80,4 +80,7 @@ func TestAdminDNSTestRejectsUnavailableService(t *testing.T) { if response.Code != http.StatusServiceUnavailable { t.Fatalf("DNS 服务未初始化时应返回 503,实际为 %d:%s", response.Code, response.Body.String()) } + if !strings.Contains(response.Body.String(), `"verification_timeout_secs":930`) { + t.Fatalf("DNS 服务未初始化响应应包含当前最大等待时间:%s", response.Body.String()) + } } diff --git a/backend/internal/httpapi/setup.go b/backend/internal/httpapi/setup.go index 287b465..eef940f 100644 --- a/backend/internal/httpapi/setup.go +++ b/backend/internal/httpapi/setup.go @@ -159,12 +159,12 @@ func (s *Server) testAdminDNS(c *gin.Context) { func (s *Server) testDNS(c *gin.Context) { verificationTimeoutSecs := int(s.dnsVerificationTimeout().Seconds()) if s.setupDNS == nil { - s.writeError(c, http.StatusServiceUnavailable, "DNS 在线验证服务尚未初始化", nil) + s.writeDNSTestError(c, http.StatusServiceUnavailable, "DNS 在线验证服务尚未初始化", nil, verificationTimeoutSecs) return } var request dnsTestRequest if err := bindJSON(c, &request, setupRequestLimit); err != nil { - s.writeError(c, http.StatusBadRequest, "DNS 测试请求参数无效", err) + s.writeDNSTestError(c, http.StatusBadRequest, "DNS 测试请求参数无效", err, verificationTimeoutSecs) return } ctx, cancel := context.WithTimeout(c.Request.Context(), s.dnsVerificationTimeout()) @@ -206,6 +206,16 @@ func (s *Server) testDNS(c *gin.Context) { }) } +func (s *Server) writeDNSTestError(c *gin.Context, status int, message string, err error, verificationTimeoutSecs int) { + if s.logger != nil { + s.logger.Error(message, "error", err) + } + c.JSON(status, gin.H{ + "error": message, + "verification_timeout_secs": verificationTimeoutSecs, + }) +} + func (s *Server) dnsVerificationTimeout() time.Duration { s.configMu.RLock() cfg := s.config diff --git a/backend/internal/httpapi/setup_test.go b/backend/internal/httpapi/setup_test.go index 065e6b8..7057546 100644 --- a/backend/internal/httpapi/setup_test.go +++ b/backend/internal/httpapi/setup_test.go @@ -527,6 +527,9 @@ func TestSetupDNSTestRejectsRemoteAndOversizedRequests(t *testing.T) { if oversizedResponse.Code != http.StatusBadRequest { t.Fatalf("超大 DNS 测试请求应被拒绝:%d %s", oversizedResponse.Code, oversizedResponse.Body.String()) } + if !strings.Contains(oversizedResponse.Body.String(), `"verification_timeout_secs":930`) { + t.Fatalf("DNS 参数错误响应应包含当前最大等待时间:%s", oversizedResponse.Body.String()) + } } func TestSetupDNSTestReturnsNotImplementedForUnavailableProvider(t *testing.T) { @@ -544,6 +547,9 @@ func TestSetupDNSTestReturnsNotImplementedForUnavailableProvider(t *testing.T) { if strings.Contains(response.Body.String(), "access_key_secret") || strings.Contains(response.Body.String(), "secret") { t.Fatal("未开放 Provider 响应不应包含提交的凭据") } + if !strings.Contains(response.Body.String(), `"verification_timeout_secs":930`) { + t.Fatalf("未开放 Provider 响应应包含当前最大等待时间:%s", response.Body.String()) + } } func TestSetupValidationFailureDoesNotConsumeDNSToken(t *testing.T) { diff --git a/frontend/src/test-support/mount.js b/frontend/src/test-support/mount.js index 9a6f337..348ff90 100644 --- a/frontend/src/test-support/mount.js +++ b/frontend/src/test-support/mount.js @@ -30,6 +30,7 @@ export function verifiedResponse(token = "test-token") { export async function mountComposable(t, factory, fetcher) { const intervals = new Map(); let nextIntervalID = 1; + let currentTime = Date.now(); const originals = new Map(); for (const [key, value] of Object.entries({ window: { @@ -49,6 +50,7 @@ export async function mountComposable(t, factory, fetcher) { originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); } + t.mock.method(Date, "now", () => currentTime); t.mock.method(globalThis, "fetch", fetcher); const renderer = createRenderer({ createComment: () => ({}), @@ -85,5 +87,8 @@ export async function mountComposable(t, factory, fetcher) { runIntervals() { for (const callback of [...intervals.values()]) callback(); }, + advanceTime(milliseconds) { + currentTime += milliseconds; + }, }; } diff --git a/frontend/src/views/Dashboard/Dashboard.test.js b/frontend/src/views/Dashboard/Dashboard.test.js index 60f2027..b6b56d7 100644 --- a/frontend/src/views/Dashboard/Dashboard.test.js +++ b/frontend/src/views/Dashboard/Dashboard.test.js @@ -5,15 +5,16 @@ import { useDashboard } from "./Dashboard.js"; import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js"; async function setup(t, request) { - const { state } = await mountComposable(t, useDashboard, async (path) => { + const mounted = await mountComposable(t, useDashboard, async (path) => { if (path === "/api/v1/dns/test") return request(); if (path.endsWith("/dns/providers")) return Response.json({ items: [aliyunProvider] }); if (path.endsWith("/settings")) return Response.json({ dns: { provider: "aliyun", credential_configured: true, verification_state: "verified" } }); return Response.json({ items: [] }); }); + const { state } = mounted; state.openSettings(); await nextTick(); - return state; + return mounted; } async function fill(state) { @@ -23,7 +24,7 @@ async function fill(state) { } test("已保存凭据不变可保存,输入新凭据必须重新验证", async (t) => { - const state = await setup(t, () => verifiedResponse()); + const { state } = await setup(t, () => verifiedResponse()); assert.equal(state.dnsReadyToSave.value, true); await fill(state); assert.equal(state.dnsReadyToSave.value, false); @@ -35,7 +36,7 @@ test("已保存凭据不变可保存,输入新凭据必须重新验证", async test("管理设置不接受旧输入的在途 DNS 响应", async (t) => { const pending = deferred(); - const state = await setup(t, () => pending.promise); + const { state } = await setup(t, () => pending.promise); await fill(state); const task = state.testDNS(); state.settingsForm.value.dns.test_domain = "other.test"; @@ -47,14 +48,14 @@ test("管理设置不接受旧输入的在途 DNS 响应", async (t) => { }); test("管理设置拒绝过期验证令牌", async (t) => { - const state = await setup(t, () => Response.json({ credential_ref: "secret://test/only", verification_token: "expired", expires_at: "2000-01-01T00:00:00Z" })); + const { state } = await setup(t, () => Response.json({ credential_ref: "secret://test/only", verification_token: "expired", expires_at: "2000-01-01T00:00:00Z" })); await fill(state); await state.testDNS(); assert.equal(state.dnsReadyToSave.value, false); }); test("管理设置变更托管区不能复用验证令牌", async (t) => { - const state = await setup(t, () => verifiedResponse()); + const { state } = await setup(t, () => verifiedResponse()); await fill(state); state.settingsForm.value.dns.credentials.managed_zones = "example.test"; state.settingsForm.value.dns.credentials.allowed_domains = "example.test"; @@ -67,7 +68,7 @@ test("管理设置变更托管区不能复用验证令牌", async (t) => { test("关闭设置后不显示旧 DNS 请求的成功提示", async (t) => { const pending = deferred(); - const state = await setup(t, () => pending.promise); + const { state } = await setup(t, () => pending.promise); await fill(state); const task = state.testDNS(); state.closeSettings(); @@ -81,7 +82,7 @@ test("管理设置新请求成功后旧失败不能删除令牌", async (t) => { const first = deferred(); const second = deferred(); let count = 0; - const state = await setup(t, () => (++count === 1 ? first.promise : second.promise)); + const { state } = await setup(t, () => (++count === 1 ? first.promise : second.promise)); await fill(state); const oldTask = state.testDNS(); const newTask = state.testDNS(); @@ -93,3 +94,52 @@ test("管理设置新请求成功后旧失败不能删除令牌", async (t) => { assert.equal(state.dnsTestError.value, ""); assert.equal(state.dnsReadyToSave.value, true); }); + +test("管理设置 DNS 验证显示等待进度,并在结束后停止计时", async (t) => { + const pending = deferred(); + const { state, runIntervals, advanceTime } = await setup(t, () => pending.promise); + await fill(state); + const task = state.testDNS(); + + assert.equal(state.dnsTesting.value, true); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + assert.equal(state.dnsVerificationTimeoutSeconds.value, 930); + advanceTime(3200); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 3); + + pending.resolve(Response.json({ + verified: true, + credential_ref: "secret://test/only", + verification_token: "test-token", + expires_at: new Date(Date.now() + 60000).toISOString(), + verification_timeout_secs: 31, + })); + await task; + assert.equal(state.dnsTesting.value, false); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + assert.equal(state.dnsVerificationTimeoutSeconds.value, 31); + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); +}); + +test("关闭管理设置会停止 DNS 等待计时器", async (t) => { + const pending = deferred(); + const { state, runIntervals, advanceTime } = await setup(t, () => pending.promise); + await fill(state); + const task = state.testDNS(); + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 1); + + state.closeSettings(); + assert.equal(state.dnsTesting.value, false); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + + pending.resolve(verifiedResponse()); + await task; +}); diff --git a/frontend/src/views/SetupWizard/SetupWizard.test.js b/frontend/src/views/SetupWizard/SetupWizard.test.js index 00901c0..b42a573 100644 --- a/frontend/src/views/SetupWizard/SetupWizard.test.js +++ b/frontend/src/views/SetupWizard/SetupWizard.test.js @@ -18,14 +18,45 @@ test("已有初始化服务商选择保持兼容", async (t) => { }); async function setup(t, request) { - const { state, unmount } = await mountComposable(t, () => useSetupWizard({ status: { dns_provider: "aliyun" } }, () => {}), + const mounted = await mountComposable(t, () => useSetupWizard({ status: { dns_provider: "aliyun" } }, () => {}), async (path) => path.endsWith("/providers") ? Response.json({ items: [aliyunProvider] }) : request()); + const { state } = mounted; state.dnsCredentials.value = { access_key_id: "fake-key" }; state.dnsTestDomain.value = "example.test"; await nextTick(); - return { state, unmount }; + return mounted; } +test("初始化 DNS 验证显示等待进度,并采用服务端返回的最长等待时间", async (t) => { + const pending = deferred(); + const { state, runIntervals, advanceTime } = await setup(t, () => pending.promise); + const task = state.testDNS(); + + assert.equal(state.dnsTesting.value, true); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + assert.equal(state.dnsVerificationTimeoutSeconds.value, 930); + + advanceTime(2500); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 2); + + pending.resolve(Response.json({ + verified: true, + credential_ref: "secret://test/only", + verification_token: "test-token", + expires_at: new Date(Date.now() + 60000).toISOString(), + verification_timeout_secs: 31, + })); + await task; + assert.equal(state.dnsTesting.value, false); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + assert.equal(state.dnsVerificationTimeoutSeconds.value, 31); + + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); +}); + test("初始化不接受输入修改前的在途 DNS 响应", async (t) => { const pending = deferred(); const { state } = await setup(t, () => pending.promise); @@ -61,6 +92,25 @@ test("初始化当前 DNS 成功有效,修改域名立即失效", async (t) => assert.equal(state.dnsVerificationValid.value, false); }); +test("初始化修改 DNS 输入后会停止等待计时器", async (t) => { + const pending = deferred(); + const { state, runIntervals, advanceTime } = await setup(t, () => pending.promise); + const task = state.testDNS(); + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 1); + + state.dnsTestDomain.value = "other.test"; + assert.equal(state.dnsTesting.value, false); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + + pending.resolve(verifiedResponse()); + await task; +}); + test("初始化修改显式授权范围立即作废旧验证", async (t) => { const { state } = await setup(t, () => verifiedResponse()); state.dnsCredentials.value.managed_zones = "example.test"; @@ -72,11 +122,18 @@ test("初始化修改显式授权范围立即作废旧验证", async (t) => { assert.equal(state.dnsVerification.value, null); }); -test("初始化卸载后不恢复旧 DNS 验证结果", async (t) => { +test("初始化卸载后不恢复旧 DNS 验证结果或继续等待计时", async (t) => { const pending = deferred(); - const { state, unmount } = await setup(t, () => pending.promise); + const { state, unmount, runIntervals, advanceTime } = await setup(t, () => pending.promise); const task = state.testDNS(); + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 1); unmount(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); + advanceTime(1000); + runIntervals(); + assert.equal(state.dnsVerificationElapsedSeconds.value, 0); pending.resolve(verifiedResponse()); await task; assert.equal(state.dnsVerification.value, null);