mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-06-19 07:36:59 +00:00
013634e8ca
* feat(cert): add Status, LastError, LastAttemptAt fields * feat(cert): sweep stale pending certs at startup * feat(cert): invoke SweepStalePending at cron startup * feat(cert): skip non-success status in auto-renew worker * feat(cert): persist draft on issuance entry, status transitions on completion * feat(cert): expose status, last_error, last_attempt_at on Cert type * feat(cert): show Pending/Failed status badges in cert list * feat(cert): add RetryCert component and wire into list actions * feat(cert): inline Retry button on issuance error in wildcard modal * chore(cert): minor cleanups after retry-on-failure review - Remove unused model.FirstOrInit helper (last caller was rewritten in the issuance handler change). - Normalize cleanup_test setupTestDB DSN to ":memory:" for per-test isolation, matching issue_test.go. - Reset errored state in DNSIssueCertificate.open() as a defensive guard against stale state on modal reopen. * refactor(cert): extract IssueCertModal wrapper shared by Renew and Retry Both RenewCert.vue and RetryCert.vue carried near-identical AModal + ObtainCertLive scaffolding (modalVisible/modalClosable refs, template ref, modal props). Lift the shared shell into IssueCertModal.vue and expose a single start() method returning Promise<CertificateResult>. The trigger components now own only the parts that actually differ: button styling, emit name, pre-issuance hook (certStore.save for Renew), and success toast. * chore(cert): fix small bugs with review - shortError now truncates by rune count instead of bytes, so non-ASCII error messages (e.g. localized ACME / DNS provider errors) cannot be split mid-rune. TestShortError gains a CJK case asserting valid UTF-8. - Cert.last_attempt_at is typed string | null on the frontend to reflect that the *time.Time pointer serializes as null for legacy / pre-attempt rows. - Drop redundant ?. on refModal / refObtainCertLive in the three click handlers. The refs are bound to components rendered alongside their trigger button, so they are guaranteed to be mounted by the time the handler fires. * fix(cert): guard certificate issuance ref before retry Co-authored-by: Jacky <me@jackyu.cn> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Jacky <me@jackyu.cn>
122 lines
2.9 KiB
Go
122 lines
2.9 KiB
Go
package cert
|
|
|
|
import (
|
|
stderrors "errors"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/0xJacky/Nginx-UI/model"
|
|
"github.com/uozi-tech/cosy"
|
|
)
|
|
|
|
func TestShouldSkipAutoRenew(t *testing.T) {
|
|
now := time.Date(2026, time.April, 19, 12, 0, 0, 0, time.UTC)
|
|
recentFailureAt := now.Add(-11 * time.Hour)
|
|
expiredFailureAt := now.Add(-13 * time.Hour)
|
|
|
|
tests := []struct {
|
|
name string
|
|
cert *model.Cert
|
|
expected bool
|
|
}{
|
|
{
|
|
name: "skip recent failed renewal",
|
|
cert: &model.Cert{
|
|
LastAutoRenewAt: &recentFailureAt,
|
|
LastAutoRenewError: "challenge error",
|
|
},
|
|
expected: true,
|
|
},
|
|
{
|
|
name: "retry after cooldown window",
|
|
cert: &model.Cert{
|
|
LastAutoRenewAt: &expiredFailureAt,
|
|
LastAutoRenewError: "challenge error",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "do not skip successful renewal state",
|
|
cert: &model.Cert{
|
|
LastAutoRenewAt: &recentFailureAt,
|
|
LastAutoRenewError: "",
|
|
},
|
|
expected: false,
|
|
},
|
|
{
|
|
name: "do not skip without attempt timestamp",
|
|
cert: &model.Cert{
|
|
LastAutoRenewError: "challenge error",
|
|
},
|
|
expected: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if got := shouldSkipAutoRenew(tt.cert, now); got != tt.expected {
|
|
t.Fatalf("shouldSkipAutoRenew() = %v, want %v", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildAutoRenewNotificationDetails(t *testing.T) {
|
|
err := cosy.WrapErrorWithParams(ErrRenewCert, "dns token invalid")
|
|
|
|
details := buildAutoRenewNotificationDetails("example.com", err)
|
|
|
|
if got := details["name"]; got != "example.com" {
|
|
t.Fatalf("unexpected name: %v", got)
|
|
}
|
|
|
|
if got := details["error"]; got != err.Error() {
|
|
t.Fatalf("unexpected error text: %v", got)
|
|
}
|
|
|
|
response, ok := details["response"].(*cosy.Error)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", details["response"])
|
|
}
|
|
|
|
if response.Scope != "cert" || response.Code != 50018 {
|
|
t.Fatalf("unexpected cosy error payload: %+v", response)
|
|
}
|
|
}
|
|
|
|
func TestGetAutoRenewNotificationResponseFallsBackToPlainText(t *testing.T) {
|
|
err := stderrors.New("plain failure")
|
|
|
|
response := getAutoRenewNotificationResponse(err)
|
|
|
|
text, ok := response.(string)
|
|
if !ok {
|
|
t.Fatalf("unexpected response type: %T", response)
|
|
}
|
|
|
|
if text != "plain failure" {
|
|
t.Fatalf("unexpected fallback response: %s", text)
|
|
}
|
|
}
|
|
|
|
func TestShouldSkipAutoCertForNonSuccessStatus(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
status string
|
|
expected bool
|
|
}{
|
|
{"pending is skipped", model.CertStatusPending, true},
|
|
{"failure is skipped", model.CertStatusFailure, true},
|
|
{"success is renewed", model.CertStatusSuccess, false},
|
|
{"empty (legacy) is renewed", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
cert := &model.Cert{Status: tc.status}
|
|
if got := shouldSkipAutoCertByStatus(cert); got != tc.expected {
|
|
t.Fatalf("shouldSkipAutoCertByStatus(%q) = %v, want %v", tc.status, got, tc.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|