672 lines
23 KiB
Go
672 lines
23 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/ssh"
|
|
"gorm.io/gorm"
|
|
|
|
"ws_usernode/internal/api"
|
|
"ws_usernode/internal/auth"
|
|
"ws_usernode/internal/config"
|
|
"ws_usernode/internal/model"
|
|
"ws_usernode/internal/router"
|
|
"ws_usernode/internal/service"
|
|
"ws_usernode/internal/system"
|
|
)
|
|
|
|
// recordingMailer 捕获邮件,用于从重置邮件提取 token 等。
|
|
type recordingMailer struct {
|
|
lastTo string
|
|
lastSubject string
|
|
lastBody string
|
|
}
|
|
|
|
func (m *recordingMailer) Send(_ context.Context, to, subject, body string) error {
|
|
m.lastTo = to
|
|
m.lastSubject = subject
|
|
m.lastBody = body
|
|
return nil
|
|
}
|
|
|
|
// recordingSys 记录 authorized_keys 同步内容,用于断言上传/吊销/禁用即时生效。
|
|
type recordingSys struct {
|
|
mu sync.Mutex
|
|
keys map[string][]system.Key // username -> 最后一次同步的密钥
|
|
}
|
|
|
|
func newRecordingSys() *recordingSys { return &recordingSys{keys: map[string][]system.Key{}} }
|
|
|
|
func (s *recordingSys) CreateUser(_ context.Context, _ system.Account) error { return nil }
|
|
func (s *recordingSys) RemoveUser(_ context.Context, _ string) error { return nil }
|
|
func (s *recordingSys) SetLock(_ context.Context, _ string, _ bool) error { return nil }
|
|
func (s *recordingSys) Exists(_ context.Context, _ string) (bool, error) { return true, nil }
|
|
func (s *recordingSys) SyncAuthorizedKeys(_ context.Context, username string, keys []system.Key) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.keys[username] = keys
|
|
return nil
|
|
}
|
|
|
|
func (s *recordingSys) synced(username string) []system.Key {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.keys[username]
|
|
}
|
|
|
|
// testApp 完整组装的应用(SQLite 内存库 + 可注入系统层)。
|
|
type testApp struct {
|
|
r http.Handler
|
|
db *gorm.DB
|
|
captchas auth.CaptchaStore
|
|
otps auth.OTPStore
|
|
mailer *recordingMailer
|
|
}
|
|
|
|
func setupTestApp(t *testing.T) *testApp {
|
|
return setupTestAppWithSys(t, system.New(config.Default().System, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
|
}
|
|
|
|
// setupTestAppWithSys 允许注入 system.Manager(观察 authorized_keys 同步等)。
|
|
func setupTestAppWithSys(t *testing.T, sys system.Manager) *testApp {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
db, err := model.Open("sqlite", ":memory:", false)
|
|
if err != nil {
|
|
t.Fatalf("open db: %v", err)
|
|
}
|
|
if err := model.Migrate(db); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
cfg := config.Default()
|
|
cfg.System.DryRun = true // 集成测试走 dry-run,不触碰真实系统账号
|
|
|
|
adminSvc := service.NewAdminService(db)
|
|
userSvc := service.NewUserService(db, sys, cfg)
|
|
keySvc := service.NewKeyService(db, sys, cfg)
|
|
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)
|
|
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, settingsSvc)
|
|
r := router.New(cfg, h, sessions, log)
|
|
|
|
if _, err := adminSvc.Create(context.Background(), "root", "Passw0rd", "root@example.com"); err != nil {
|
|
t.Fatalf("seed admin: %v", err)
|
|
}
|
|
return &testApp{r: r, db: db, captchas: captchas, otps: otps, mailer: mailer}
|
|
}
|
|
|
|
// doJSON 发起 JSON 请求,返回 recorder。
|
|
func (a *testApp) doJSON(method, path string, body any, cookies ...*http.Cookie) *httptest.ResponseRecorder {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req := httptest.NewRequest(method, path, rdr)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
for _, c := range cookies {
|
|
req.AddCookie(c)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
a.r.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
func decodeBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
|
t.Helper()
|
|
var m map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
|
|
t.Fatalf("decode response %q: %v", w.Body.String(), err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func sessionCookie(t *testing.T, w *httptest.ResponseRecorder) *http.Cookie {
|
|
t.Helper()
|
|
for _, c := range w.Result().Cookies() {
|
|
if c.Name == api.SessionCookieName {
|
|
return c
|
|
}
|
|
}
|
|
t.Fatalf("no session cookie in response")
|
|
return nil
|
|
}
|
|
|
|
func TestAPICaptcha(t *testing.T) {
|
|
app := setupTestApp(t)
|
|
w := app.doJSON(http.MethodGet, "/api/v1/auth/captcha", nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("captcha status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
m := decodeBody(t, w)
|
|
data := m["data"].(map[string]any)
|
|
if data["captcha_id"] == "" || data["image"] == "" {
|
|
t.Fatalf("captcha response missing fields: %v", data)
|
|
}
|
|
}
|
|
|
|
func TestAPIAdminLoginRequiresSession(t *testing.T) {
|
|
app := setupTestApp(t)
|
|
// 未登录访问 /users 应 401
|
|
w := app.doJSON(http.MethodGet, "/api/v1/users", nil)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauth users status = %d, want 401", w.Code)
|
|
}
|
|
// 错误密码 401
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "bad"})
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("bad login status = %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAPIAdminUserLifecycle(t *testing.T) {
|
|
app := setupTestApp(t)
|
|
|
|
// 管理员登录
|
|
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("admin login status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
ck := sessionCookie(t, w)
|
|
|
|
// me
|
|
w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("me status = %d", w.Code)
|
|
}
|
|
if m := decodeBody(t, w); m["data"].(map[string]any)["username"] != "root" {
|
|
t.Fatalf("me body = %s", w.Body.String())
|
|
}
|
|
|
|
// 创建用户(dry-run 系统层)
|
|
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{
|
|
"username": "zhangsan", "email": "zs@example.com", "supervisor": "prof.li", "purpose": "科研", "ttl_days": 90,
|
|
}, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
created := decodeBody(t, w)["data"].(map[string]any)
|
|
id := uint(created["id"].(float64))
|
|
if created["username"] != "ext_zhangsan" {
|
|
t.Fatalf("created username = %v", created["username"])
|
|
}
|
|
|
|
// 列表
|
|
w = app.doJSON(http.MethodGet, "/api/v1/users?status=active&page=1&page_size=10", nil, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list status = %d", w.Code)
|
|
}
|
|
list := decodeBody(t, w)["data"].(map[string]any)
|
|
if list["total"].(float64) != 1 {
|
|
t.Fatalf("list total = %v, want 1", list["total"])
|
|
}
|
|
|
|
// 详情
|
|
w = app.doJSON(http.MethodGet, "/api/v1/users/"+itoa(id), nil, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("get status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 更新(改邮箱)
|
|
email := "zs-new@example.com"
|
|
w = app.doJSON(http.MethodPatch, "/api/v1/users/"+itoa(id), map[string]any{"email": email}, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("update status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 禁用 → 启用 → 延期
|
|
for _, action := range []string{"disable", "enable", "extend"} {
|
|
body := any(nil)
|
|
if action == "extend" {
|
|
body = map[string]any{"days": 30}
|
|
}
|
|
w = app.doJSON(http.MethodPost, "/api/v1/users/"+itoa(id)+"/"+action, body, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s status = %d, body=%s", action, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// 删除
|
|
w = app.doJSON(http.MethodDelete, "/api/v1/users/"+itoa(id), nil, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("delete status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 审计应已写入
|
|
var n int64
|
|
if err := app.db.Model(&model.AuditLog{}).Count(&n).Error; err != nil {
|
|
t.Fatalf("audit count: %v", err)
|
|
}
|
|
if n < 7 {
|
|
t.Fatalf("audit entries = %d, want >= 7", n)
|
|
}
|
|
}
|
|
|
|
func TestAPIUserOTPLogin(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)
|
|
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{"username": "lisi", "email": "ls@example.com"}, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 生成图形验证码(直接经 store,模拟用户看到验证码)
|
|
cap, err := app.captchas.New()
|
|
if err != nil {
|
|
t.Fatalf("captcha new: %v", err)
|
|
}
|
|
|
|
// 发送 OTP
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]any{
|
|
"username": "ext_lisi", "captcha_id": cap.ID, "captcha_code": cap.Text,
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("otp send status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// CLI 通道取同一验证码
|
|
code, err := app.otps.Current(context.Background(), "ext_lisi")
|
|
if err != nil {
|
|
t.Fatalf("otp current: %v", err)
|
|
}
|
|
|
|
// OTP 登录
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "ext_lisi", "code": code})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("otp login status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
userCk := sessionCookie(t, w)
|
|
|
|
// 外部用户 me
|
|
w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, userCk)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("user me status = %d", w.Code)
|
|
}
|
|
if m := decodeBody(t, w); m["data"].(map[string]any)["user_type"] != "user" {
|
|
t.Fatalf("user me body = %s", w.Body.String())
|
|
}
|
|
|
|
// 外部用户访问 admin 路由应 403
|
|
w = app.doJSON(http.MethodGet, "/api/v1/users", nil, userCk)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("user access admin status = %d, want 403", w.Code)
|
|
}
|
|
|
|
// 登出
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/logout", nil, userCk)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("logout status = %d", w.Code)
|
|
}
|
|
w = app.doJSON(http.MethodGet, "/api/v1/auth/me", nil, userCk)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("me after logout status = %d, want 401", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAPIAdminForgotReset(t *testing.T) {
|
|
app := setupTestApp(t)
|
|
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/forgot", map[string]string{"username": "root"})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("forgot status = %d", w.Code)
|
|
}
|
|
if app.mailer.lastTo != "root@example.com" {
|
|
t.Fatalf("reset mail to = %q", app.mailer.lastTo)
|
|
}
|
|
idx := bytes.Index([]byte(app.mailer.lastBody), []byte("token="))
|
|
if idx < 0 {
|
|
t.Fatalf("reset link missing token: %s", app.mailer.lastBody)
|
|
}
|
|
token := app.mailer.lastBody[idx+6:]
|
|
token = token[:bytes.IndexByte([]byte(token), '\n')]
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/reset", map[string]string{"token": token, "new_password": "NewPassw0rd"})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("reset status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
// 新密码可登录
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "NewPassw0rd"})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("login with new password status = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func itoa(u uint) string {
|
|
return strconv.FormatUint(uint64(u), 10)
|
|
}
|
|
|
|
func TestAPIKeyLifecycle(t *testing.T) {
|
|
rec := newRecordingSys()
|
|
app := setupTestAppWithSys(t, rec)
|
|
|
|
// 管理员创建外部用户
|
|
w := app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
|
ck := sessionCookie(t, w)
|
|
w = app.doJSON(http.MethodPost, "/api/v1/users", map[string]any{"username": "wangwu", "email": "ww@example.com"}, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("create user status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
userID := uint(decodeBody(t, w)["data"].(map[string]any)["id"].(float64))
|
|
|
|
// 外部用户 OTP 登录
|
|
cap, err := app.captchas.New()
|
|
if err != nil {
|
|
t.Fatalf("captcha new: %v", err)
|
|
}
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/send", map[string]any{"username": "ext_wangwu", "captcha_id": cap.ID, "captcha_code": cap.Text})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("otp send status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
code, err := app.otps.Current(context.Background(), "ext_wangwu")
|
|
if err != nil {
|
|
t.Fatalf("otp current: %v", err)
|
|
}
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/otp/login", map[string]string{"username": "ext_wangwu", "code": code})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("otp login status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
userCk := sessionCookie(t, w)
|
|
|
|
// 未登录访问 /me/keys → 401
|
|
w = app.doJSON(http.MethodGet, "/api/v1/me/keys", nil)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauth me/keys status = %d, want 401", w.Code)
|
|
}
|
|
|
|
// 上传公钥 → 同步到 authorized_keys
|
|
pub := testSSHPubKey(t)
|
|
w = app.doJSON(http.MethodPost, "/api/v1/me/keys", map[string]any{"name": "workstation", "public_key": pub}, userCk)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("create key status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
key := decodeBody(t, w)["data"].(map[string]any)
|
|
keyID := uint(key["id"].(float64))
|
|
if key["fingerprint"] == "" || key["status"] != "active" {
|
|
t.Fatalf("key fields: %v", key)
|
|
}
|
|
if synced := rec.synced("ext_wangwu"); len(synced) != 1 {
|
|
t.Fatalf("after create synced = %+v, want 1 key", synced)
|
|
}
|
|
|
|
// 重复上传同一公钥 → 409
|
|
w = app.doJSON(http.MethodPost, "/api/v1/me/keys", map[string]any{"name": "dup", "public_key": pub}, userCk)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("duplicate key status = %d, want 409", w.Code)
|
|
}
|
|
|
|
// 列表
|
|
w = app.doJSON(http.MethodGet, "/api/v1/me/keys", nil, userCk)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list keys status = %d", w.Code)
|
|
}
|
|
if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 {
|
|
t.Fatalf("list items = %d, want 1", len(items))
|
|
}
|
|
|
|
// 重命名
|
|
w = app.doJSON(http.MethodPatch, "/api/v1/me/keys/"+itoa(keyID), map[string]any{"name": "home-laptop"}, userCk)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("rename status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if name := decodeBody(t, w)["data"].(map[string]any)["name"]; name != "home-laptop" {
|
|
t.Fatalf("renamed = %v", name)
|
|
}
|
|
|
|
// 管理员查看用户密钥;外部用户访问 admin 接口 → 403
|
|
w = app.doJSON(http.MethodGet, "/api/v1/users/"+itoa(userID)+"/keys", nil, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("admin list keys status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 {
|
|
t.Fatalf("admin items = %d, want 1", len(items))
|
|
}
|
|
w = app.doJSON(http.MethodGet, "/api/v1/users/"+itoa(userID)+"/keys", nil, userCk)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("user access admin keys status = %d, want 403", w.Code)
|
|
}
|
|
|
|
// 吊销 → authorized_keys 清空(立即失效),状态 revoked;再次吊销幂等
|
|
w = app.doJSON(http.MethodDelete, "/api/v1/me/keys/"+itoa(keyID), nil, userCk)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("revoke status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "revoked" {
|
|
t.Fatalf("revoked status = %v", status)
|
|
}
|
|
if synced := rec.synced("ext_wangwu"); len(synced) != 0 {
|
|
t.Fatalf("after revoke synced = %+v, want empty", synced)
|
|
}
|
|
w = app.doJSON(http.MethodDelete, "/api/v1/me/keys/"+itoa(keyID), nil, userCk)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("revoke again status = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
// testSSHPubKey 生成一条合法的 ed25519 公钥行。
|
|
func testSSHPubKey(t *testing.T) string {
|
|
t.Helper()
|
|
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatalf("gen ed25519: %v", err)
|
|
}
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
if err != nil {
|
|
t.Fatalf("ssh key: %v", err)
|
|
}
|
|
return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPub)))
|
|
}
|
|
|
|
func TestAPIApprovalFlow(t *testing.T) {
|
|
app := setupTestApp(t)
|
|
|
|
// 公开提交申请(无需登录)
|
|
w := app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
|
"username": "zhaoliu", "email": "zl@example.com", "supervisor": "prof.wang", "purpose": "课程实验",
|
|
})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("submit status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
appr := decodeBody(t, w)["data"].(map[string]any)
|
|
apprID := uint(appr["id"].(float64))
|
|
if appr["status"] != "pending" {
|
|
t.Fatalf("approval status = %v", appr["status"])
|
|
}
|
|
|
|
// 同名待审批申请 → 409
|
|
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{
|
|
"username": "zhaoliu", "email": "zl2@example.com",
|
|
})
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("duplicate submit status = %d, want 409", w.Code)
|
|
}
|
|
|
|
// 未登录访问列表 → 401;外部用户访问 → 403
|
|
w = app.doJSON(http.MethodGet, "/api/v1/approvals", nil)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauth list status = %d, want 401", w.Code)
|
|
}
|
|
|
|
// 管理员登录 → 列表 1 条
|
|
w = app.doJSON(http.MethodPost, "/api/v1/auth/admin/login", map[string]string{"username": "root", "password": "Passw0rd"})
|
|
ck := sessionCookie(t, w)
|
|
w = app.doJSON(http.MethodGet, "/api/v1/approvals?status=pending", nil, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if items := decodeBody(t, w)["data"].(map[string]any)["items"].([]any); len(items) != 1 {
|
|
t.Fatalf("pending items = %d, want 1", len(items))
|
|
}
|
|
|
|
// 拒绝必须填理由 → 400
|
|
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false}, ck)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("reject without reason status = %d, want 400", w.Code)
|
|
}
|
|
|
|
// 通过 → 自动建号 + 通知邮件
|
|
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": true}, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("approve status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "approved" {
|
|
t.Fatalf("approved status = %v", status)
|
|
}
|
|
if app.mailer.lastTo != "zl@example.com" || !strings.Contains(app.mailer.lastBody, "ext_zhaoliu") {
|
|
t.Fatalf("approval mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody)
|
|
}
|
|
var u model.User
|
|
if err := app.db.First(&u, "username = ?", "ext_zhaoliu").Error; err != nil {
|
|
t.Fatalf("user not created after approve: %v", err)
|
|
}
|
|
if u.Status != model.UserStatusActive {
|
|
t.Fatalf("created user status = %s", u.Status)
|
|
}
|
|
|
|
// 重复审批 → 409
|
|
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID)+"/review", map[string]any{"approve": false, "reason": "x"}, ck)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("re-review status = %d, want 409", w.Code)
|
|
}
|
|
|
|
// 新申请 → 拒绝 + 理由 → 通知;被拒后可重新提交
|
|
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq@example.com"})
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("second submit status = %d", w.Code)
|
|
}
|
|
apprID2 := uint(decodeBody(t, w)["data"].(map[string]any)["id"].(float64))
|
|
w = app.doJSON(http.MethodPost, "/api/v1/approvals/"+itoa(apprID2)+"/review", map[string]any{"approve": false, "reason": "用途不明确"}, ck)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("reject status = %d, body=%s", w.Code, w.Body.String())
|
|
}
|
|
if status := decodeBody(t, w)["data"].(map[string]any)["status"]; status != "rejected" {
|
|
t.Fatalf("rejected status = %v", status)
|
|
}
|
|
if app.mailer.lastTo != "qq@example.com" || !strings.Contains(app.mailer.lastBody, "用途不明确") {
|
|
t.Fatalf("reject mail = %s / %s", app.mailer.lastTo, app.mailer.lastBody)
|
|
}
|
|
// 被拒后可重新提交同名申请
|
|
w = app.doJSON(http.MethodPost, "/api/v1/approvals", map[string]any{"username": "qianqi", "email": "qq2@example.com"})
|
|
if w.Code != http.StatusOK {
|
|
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)
|
|
}
|
|
}
|