Files
nginx-ui/internal/cert/auto_cert_test.go
T
Hintay 013634e8ca feat(cert): preserve config and add retry on issuance failure (#1694)
* 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>
2026-05-23 20:34:52 +08:00

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)
}
})
}
}