Files
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

71 lines
2.3 KiB
Go

package cert
import (
"testing"
"github.com/0xJacky/Nginx-UI/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// setupTestDB creates a per-test private in-memory SQLite DB with the Cert
// schema migrated, wires it into the model package, and returns the *gorm.DB
// for fixtures. Using ":memory:" (no shared cache) gives each gorm.Open call
// a fresh isolated database, preventing cross-test pollution.
func setupTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Cert{}))
model.Use(db)
t.Cleanup(func() { model.Use(nil) })
return db
}
func TestSweepStalePendingConvertsPendingToFailure(t *testing.T) {
db := setupTestDB(t)
pending := model.Cert{
Name: "example.com",
Filename: "example.com",
Status: model.CertStatusPending,
}
require.NoError(t, db.Create(&pending).Error)
require.NoError(t, SweepStalePending())
var got model.Cert
require.NoError(t, db.First(&got, pending.ID).Error)
assert.Equal(t, model.CertStatusFailure, got.Status)
assert.Equal(t, "Server restarted during issuance", got.LastError)
}
func TestSweepStalePendingLeavesSuccessAndFailureAlone(t *testing.T) {
db := setupTestDB(t)
success := model.Cert{Name: "ok.example.com", Filename: "ok.example.com", Status: model.CertStatusSuccess}
failure := model.Cert{Name: "bad.example.com", Filename: "bad.example.com", Status: model.CertStatusFailure, LastError: "DNS timeout"}
empty := model.Cert{Name: "legacy.example.com", Filename: "legacy.example.com"}
require.NoError(t, db.Create(&success).Error)
require.NoError(t, db.Create(&failure).Error)
require.NoError(t, db.Create(&empty).Error)
require.NoError(t, SweepStalePending())
var gotSuccess, gotFailure, gotEmpty model.Cert
require.NoError(t, db.First(&gotSuccess, success.ID).Error)
require.NoError(t, db.First(&gotFailure, failure.ID).Error)
require.NoError(t, db.First(&gotEmpty, empty.ID).Error)
assert.Equal(t, model.CertStatusSuccess, gotSuccess.Status)
assert.Equal(t, model.CertStatusFailure, gotFailure.Status)
assert.Equal(t, "DNS timeout", gotFailure.LastError)
assert.Equal(t, "", gotEmpty.Status)
}
func TestSweepStalePendingNoDBIsNoop(t *testing.T) {
model.Use(nil)
assert.NoError(t, SweepStalePending())
}