feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略

This commit is contained in:
2026-08-30 10:17:22 +08:00
parent 1ea18490e0
commit c0e2ff975a
18 changed files with 1088 additions and 75 deletions
+97 -4
View File
@@ -97,19 +97,21 @@ func setupTestAppWithSys(t *testing.T, sys system.Manager) *testApp {
adminSvc := service.NewAdminService(db)
userSvc := service.NewUserService(db, sys, cfg)
keySvc := service.NewKeyService(db, sys, cfg)
auditSvc := service.NewAuditService(db)
mailer := &recordingMailer{}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
auditSvc := service.NewAuditService(db, log)
settingsSvc := service.NewSettingService(db, cfg)
userSvc.WithSettings(settingsSvc)
captchas := auth.NewMemoryCaptchaStore(cfg.Auth.CaptchaTTL)
otps := auth.NewDBOTPStore(db, auth.DefaultMaxFailures, auth.DefaultFailureWin)
sessions := auth.NewDBSessionStore(db)
resets := auth.NewDBResetTokenStore(db)
limiter := auth.NewRateLimiter(cfg.Auth.MaxLoginFailures, cfg.Auth.LockDuration)
mailer := &recordingMailer{}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
authSvc := service.NewAuthService(db, cfg, otps, captchas, sessions, resets, limiter, mailer, userSvc, adminSvc, auditSvc, log)
approvalSvc := service.NewApprovalService(db, cfg, userSvc, mailer, log)
h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc)
h := api.New(cfg, authSvc, userSvc, keySvc, approvalSvc, auditSvc, settingsSvc)
r := router.New(cfg, h, sessions, log)
if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil {
@@ -576,3 +578,94 @@ func TestAPIApprovalFlow(t *testing.T) {
t.Fatalf("resubmit status = %d, body=%s", w.Code, w.Body.String())
}
}
func TestAPIAuditQueryExport(t *testing.T) {
app := setupTestApp(t)
// 登录后产生若干审计记录(登录本身即审计)
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
ck := sessionCookie(t, w)
// 查询:action 筛选 + 分页
w = app.doJSON(http.MethodGet, "/api/v1/audit?action=admin.login&page=1&page_size=10", nil, ck)
if w.Code != http.StatusOK {
t.Fatalf("audit list status = %d, body=%s", w.Code, w.Body.String())
}
m := decodeBody(t, w)["data"].(map[string]any)
if m["total"].(float64) < 1 {
t.Fatalf("audit total = %v, want >= 1", m["total"])
}
if items := m["items"].([]any); len(items) == 0 {
t.Fatal("audit items empty")
}
// 非法时间参数 → 400
w = app.doJSON(http.MethodGet, "/api/v1/audit?since=not-a-time", nil, ck)
if w.Code != http.StatusBadRequest {
t.Fatalf("bad since status = %d, want 400", w.Code)
}
// 未登录 / 外部用户 → 401 / 403
w = app.doJSON(http.MethodGet, "/api/v1/audit", nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("unauth audit status = %d, want 401", w.Code)
}
// CSV 导出
w = app.doJSON(http.MethodGet, "/api/v1/audit/export", nil, ck)
if w.Code != http.StatusOK {
t.Fatalf("export status = %d, body=%s", w.Code, w.Body.String())
}
body := w.Body.String()
if !strings.HasPrefix(body, "\ufeffid,created_at,") {
t.Fatalf("csv body = %q", body[:min(40, len(body))])
}
if !strings.Contains(body, "admin.login") {
t.Fatalf("csv missing rows: %q", body[:min(200, len(body))])
}
}
func TestAPISettings(t *testing.T) {
app := setupTestApp(t)
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
ck := sessionCookie(t, w)
// 未登录 → 401
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil)
if w.Code != http.StatusUnauthorized {
t.Fatalf("unauth settings status = %d, want 401", w.Code)
}
// 初始值(config 默认)
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil, ck)
if w.Code != http.StatusOK {
t.Fatalf("settings list status = %d", w.Code)
}
items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any)
if len(items) != 3 {
t.Fatalf("settings items = %d, want 3", len(items))
}
// 更新默认有效期
w = app.doJSON(http.MethodPut, "/api/v1/settings", map[string]string{"key": "policy.default_ttl", "value": "720h"}, ck)
if w.Code != http.StatusOK {
t.Fatalf("settings put status = %d, body=%s", w.Code, w.Body.String())
}
// 未知 key → 400
w = app.doJSON(http.MethodPut, "/api/v1/settings", map[string]string{"key": "smtp.host", "value": "x"}, ck)
if w.Code != http.StatusBadRequest {
t.Fatalf("unknown key status = %d, want 400", w.Code)
}
// 再次读取:default_ttl 已覆盖
w = app.doJSON(http.MethodGet, "/api/v1/settings", nil, ck)
items = decodeBody(t, w)["data"].(map[string]any)["items"].([]any)
found := false
for _, it := range items {
item := it.(map[string]any)
if item["key"] == "policy.default_ttl" && item["overridden"] == true && item["value"] == "720h" {
found = true
}
}
if !found {
t.Fatalf("settings after put = %v", items)
}
}