区分证书导入与部署结果并阻止重复导入
This commit is contained in:
parent
4798d24f4b
commit
9ac222d6db
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 || "导入证书失败";
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) }}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user