feat(M4): 生命周期 + 审计 — 到期锁定/回收 cron、审计查询/CSV 导出/每日归档、settings 动态策略
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user