区分证书导入与部署结果并阻止重复导入

This commit is contained in:
xuans 2026-09-27 01:05:19 +08:00
parent 4798d24f4b
commit 9ac222d6db
6 changed files with 149 additions and 7 deletions

View File

@ -139,12 +139,19 @@ func (s *Server) importCertificateFiles(c *gin.Context) {
defer cleanup()
result, report, err := s.importCertificate(c.Request.Context(), request)
report = sanitizeDeploymentReport(report)
response := gin.H{"certificate": result, "deployment": report}
// 导入保存与远端部署是不同阶段;部署失败不能让用户误以为证书尚未保存。
imported := err == nil || (result.DomainID == request.DomainID &&
result.Saved.CertificatePath != "" && result.Saved.FullChainPath != "" && result.Saved.PrivateKeyPath != "")
response := gin.H{"certificate": result, "deployment": report, "imported": imported}
if err != nil {
if s.logger != nil {
s.logger.Error("证书导入失败", "error", err)
message := "证书导入失败"
if imported {
message = "证书已导入,但部署未完成。请修正服务器配置后重试部署,无需重新导入。"
}
response["error"] = "证书导入失败"
if s.logger != nil {
s.logger.Error(message, "error", err)
}
response["error"] = message
c.JSON(http.StatusBadGateway, response)
return
}

View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
@ -145,6 +146,51 @@ func TestCertificateImportAPIHidesWorkflowErrors(t *testing.T) {
t.Fatalf("certificate import did not include the safe error message: %s", responseBody)
}
}
func TestCertificateImportAPIReportsSavedCertificateOnDeploymentFailure(t *testing.T) {
for _, saved := range []bool{false, true} {
t.Run(fmt.Sprintf("saved=%t", saved), func(t *testing.T) {
server := New(Dependencies{
Config: readyTestConfig(t.TempDir()), Repository: &stubDomainRepository{},
ImportCertificate: func(_ context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {
result := certificate.IssueResult{}
if saved {
result = certificate.IssueResult{DomainID: request.DomainID, Saved: certificate.SavedResource{
CertificatePath: "/ssl/cert.pem", FullChainPath: "/ssl/fullchain.pem", PrivateKeyPath: "/ssl/key.pem",
}}
}
return result, deployment.Report{Total: 1, Failed: 1, Status: "failed", Results: []deployment.Result{
{ServerID: "server-1", Server: "验收服务器", Status: "failed", Message: "secret-diagnostic"},
}}, errors.New("secret-diagnostic")
},
})
request := httptest.NewRequest(http.MethodPost, "/api/v1/domains/domain-1/certificate/import", strings.NewReader(`{"certificate_path":"/ssl/cert.pem","private_key_path":"/ssl/key.pem"}`))
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
serveAdminHTTP(server, response, request)
var result struct {
Imported bool `json:"imported"`
Error string `json:"error"`
Deployment deployment.Report `json:"deployment"`
}
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if response.Code != http.StatusBadGateway || result.Imported != saved || len(result.Deployment.Results) != 1 {
t.Fatalf("导入和部署阶段未正确区分:%d %s", response.Code, response.Body.String())
}
if saved && (!strings.Contains(result.Error, "证书已导入") || !strings.Contains(result.Error, "无需重新导入")) {
t.Fatalf("缺少部署重试提示:%s", result.Error)
}
if !saved && result.Error != "证书导入失败" {
t.Fatalf("未保存证书却报告导入成功:%s", result.Error)
}
if strings.Contains(response.Body.String(), "secret-diagnostic") {
t.Fatal("部署诊断泄露到响应")
}
})
}
}
func TestCertificateImportAPIAcceptsMultipartFiles(t *testing.T) {
var received certificate.ImportRequest
importer := func(_ context.Context, request certificate.ImportRequest) (certificate.IssueResult, deployment.Report, error) {

View File

@ -1013,6 +1013,7 @@ export function useDashboard() {
}
async function importCertificate() {
if (importSaving.value || importResult.value) return;
importError.value = "";
importResult.value = null;
if (!importFiles.value.certificate || !importFiles.value.private_key) {
@ -1038,11 +1039,21 @@ export function useDashboard() {
{ method: "POST", body },
);
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (!response.ok && result.imported !== true) {
throw new Error(result.error || "导入证书失败");
}
importResult.value = result;
notice.value = `域名 ${importDomain.value.name} 的证书已导入`;
if (!response.ok || result.deployment?.total > 0) {
showCertificateImport.value = false;
deploymentResult.value = {
domain: importDomain.value.name,
running: false,
certificateImported: true,
report: result.deployment || null,
error: response.ok ? "" : result.error || "证书已导入,但部署未完成。请重试部署,无需重新导入。",
};
}
await loadData();
} catch (err) {
importError.value = err.message || "导入证书失败";

View File

@ -163,6 +163,83 @@ test("certificateImport.submitsSelectedFilesAndResetsOnReopen", async (t) => {
assert.ok(state.importError.value);
});
test("证书已导入但部署失败时展示服务器结果并刷新状态,不能重复导入", async (t) => {
let imports = 0;
let refreshes = 0;
const report = {
total: 1, success: 0, failed: 1, status: "failed",
results: [{ server_id: "server-1", server: "验收服务器", status: "failed", message: "连接服务器失败" }],
};
const message = "证书已导入,但部署未完成。请修正服务器配置后重试部署,无需重新导入。";
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path.endsWith("/certificate/import")) {
imports++;
return Response.json({ imported: true, deployment: report, error: message }, { status: 502 });
}
if (path === "/api/v1/domains") refreshes++;
return Response.json({ items: [] });
});
const before = refreshes;
state.openCertificateImport({ id: "domain-import", name: "example.test" });
for (const kind of ["certificate", "private_key"]) {
state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } });
}
await state.importCertificate();
assert.equal(state.importResult.value?.imported, true);
assert.equal(state.importError.value, "");
assert.equal(state.showCertificateImport.value, false);
assert.equal(state.deploymentResult.value.certificateImported, true);
assert.equal(state.deploymentResult.value.error, message);
assert.deepEqual(state.deploymentResult.value.report, report);
assert.ok(refreshes > before);
assert.match(state.notice.value, /证书已导入/);
await state.importCertificate();
assert.equal(imports, 1);
});
test("证书未保存时保留导入表单并显示真正的导入失败", async (t) => {
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path.endsWith("/certificate/import")) {
return Response.json({ imported: false, error: "证书导入失败" }, { status: 502 });
}
return Response.json({ items: [] });
});
state.openCertificateImport({ id: "domain-import", name: "example.test" });
for (const kind of ["certificate", "private_key"]) {
state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } });
}
await state.importCertificate();
assert.equal(state.importResult.value, null);
assert.equal(state.deploymentResult.value, null);
assert.equal(state.showCertificateImport.value, true);
assert.equal(state.importError.value, "证书导入失败");
});
test("导入执行中阻止重复提交,成功后展示实际部署结果", async (t) => {
const pending = deferred();
let imports = 0;
const report = { total: 1, success: 1, failed: 0, status: "success", results: [{ server_id: "server-1", status: "success" }] };
const { state } = await mountComposable(t, useDashboard, async (path) => {
if (path.endsWith("/certificate/import")) {
imports++;
return pending.promise;
}
return Response.json({ items: [] });
});
state.openCertificateImport({ id: "domain-import", name: "example.test" });
for (const kind of ["certificate", "private_key"]) {
state.selectCertificateImportFile(kind, { target: { files: [new File([kind], `${kind}.pem`)] } });
}
const action = state.importCertificate();
const duplicate = state.importCertificate();
assert.equal(imports, 1);
pending.resolve(Response.json({ imported: true, deployment: report }));
await Promise.all([action, duplicate]);
assert.equal(state.showCertificateImport.value, false);
assert.equal(state.deploymentResult.value.error, "");
assert.deepEqual(state.deploymentResult.value.report, report);
});
async function setup(t, request) {
const mounted = await mountComposable(t, useDashboard, async (path) => {
if (path === "/api/v1/dns/test") return request();

View File

@ -544,7 +544,7 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
</div>
<div class="modal-actions">
<button class="secondary-button" type="button" :disabled="importSaving" @click="closeCertificateImport">关闭</button>
<button class="primary-button" type="submit" :disabled="importSaving">{{ importSaving ? "导入中……" : "导入证书" }}</button>
<button class="primary-button" type="submit" :disabled="importSaving || !!importResult">{{ importSaving ? "导入中……" : importResult ? "已导入" : "导入证书" }}</button>
</div>
</form>
</section>

View File

@ -16,7 +16,7 @@ defineEmits(["close"]);
>
<div class="modal-heading">
<div>
<h2 id="deployment-result-title">证书部署结果</h2>
<h2 id="deployment-result-title">{{ result.certificateImported ? "证书导入与部署结果" : "证书部署结果" }}</h2>
<p class="muted">{{ result.domain }}</p>
</div>
<button
@ -29,6 +29,7 @@ defineEmits(["close"]);
</div>
<p v-if="result.running" role="status">正在连接服务器、推送证书并校验重载,请稍候……</p>
<template v-else>
<p v-if="result.certificateImported" role="status">证书已导入并保存。</p>
<p v-if="result.error" class="form-error" role="alert">{{ result.error }}</p>
<p v-if="result.report" class="deployment-summary" role="status">
{{ deploymentSummary(result.report) }}