yintai2/app/admin/middleware/operate_log_test.go

359 lines
10 KiB
Go

package middleware
import (
"bytes"
"encoding/json"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"unicode/utf8"
commonmw "yintai-api/app/common/middleware"
"yintai-api/app/common/model"
"yintai-api/base/config"
"github.com/gin-gonic/gin"
)
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
m.Run()
}
type savedLog struct {
row *model.AdminOperateLog
}
func captureLog(t *testing.T) *savedLog {
t.Helper()
got := &savedLog{}
oldSave := saveOperateLog
oldLoad := loadOperateUser
saveOperateLog = func(row *model.AdminOperateLog) error {
got.row = row
return nil
}
loadOperateUser = func(token string) *model.UserWithRole {
if token == "good-token-value" {
return &model.UserWithRole{
User: model.User{ID: 7, Account: "editor"},
RoleID: 3,
}
}
return nil
}
t.Cleanup(func() {
saveOperateLog = oldSave
loadOperateUser = oldLoad
})
return got
}
func perform(handler gin.HandlerFunc, req *http.Request) *httptest.ResponseRecorder {
engine := gin.New()
engine.Use(OperateLog(), commonmw.Recovery)
engine.Handle(req.Method, req.URL.Path, handler)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
return recorder
}
func TestOperateLogSkipsSuccessfulQuery(t *testing.T) {
got := captureLog(t)
req := httptest.NewRequest(http.MethodGet, "/yt/page", nil)
perform(func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OK"})
}, req)
if got.row != nil {
t.Fatalf("query was logged: %+v", got.row)
}
}
func TestOperateLogRecordsLoginFailure(t *testing.T) {
got := captureLog(t)
body := `{"account":"admin","password":"Secret123!","captcha_id":"cid","captcha":"W8K2"}`
req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "203.0.113.8")
var seenPassword string
perform(func(c *gin.Context) {
var req struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
t.Errorf("bind body: %v", err)
}
seenPassword = req.Password
panic(config.LoginErr.New())
}, req)
if seenPassword != "Secret123!" {
t.Fatalf("handler password = %q", seenPassword)
}
if got.row == nil {
t.Fatal("login failure was not logged")
}
if got.row.Account != "admin" || got.row.UserID != 0 || got.row.Action != "登录" {
t.Fatalf("identity: %+v", got.row)
}
if got.row.Success != 0 || got.row.Code != 11013 {
t.Fatalf("result code=%d success=%d message=%s", got.row.Code, got.row.Success, got.row.Message)
}
if !strings.Contains(got.row.Request, "Secret123!") || !strings.Contains(got.row.Request, "W8K2") {
t.Fatalf("request = %s", got.row.Request)
}
if got.row.ForwardedFor != "203.0.113.8" {
t.Fatalf("forwarded = %s", got.row.ForwardedFor)
}
}
func TestOperateLogRecordsFailedQuery(t *testing.T) {
got := captureLog(t)
req := httptest.NewRequest(http.MethodGet, "/yt/page", nil)
req.Header.Set("token", "bad-token")
perform(func(c *gin.Context) {
panic(config.ErrNeedLogin.New())
}, req)
if got.row == nil {
t.Fatal("auth failure was not logged")
}
if got.row.Success != 0 || got.row.Code != 11009 || got.row.Action != "查询页面" {
t.Fatalf("row = %+v", got.row)
}
if strings.Contains(got.row.Header, "bad-token") {
t.Fatalf("header = %s", got.row.Header)
}
}
func TestOperateLogUsesSessionUserAndMasksToken(t *testing.T) {
got := captureLog(t)
req := httptest.NewRequest(http.MethodPut, "/yt/page?id=9", strings.NewReader(`{"name":"founder","password":"Secret123!"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("token", "good-token-value")
perform(func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "OK",
"data": gin.H{"token": "abcdefghijklmnopqrstuvwxyz"},
})
}, req)
if got.row == nil {
t.Fatal("update was not logged")
}
if got.row.Account != "editor" || got.row.UserID != 7 || got.row.RoleID != 3 {
t.Fatalf("user = %+v", got.row)
}
if got.row.Success != 1 || got.row.Action != "修改页面" || got.row.Query != "id=9" {
t.Fatalf("row = %+v", got.row)
}
if !strings.Contains(got.row.Request, "Secret123!") || strings.Contains(got.row.Response, "abcdefghijklmnopqrstuvwxyz") {
t.Fatalf("request=%s response=%s", got.row.Request, got.row.Response)
}
if strings.Contains(got.row.Header, "good-token-value") {
t.Fatalf("header = %s", got.row.Header)
}
}
func TestOperateLogCaptchaOmitsImage(t *testing.T) {
got := captureLog(t)
req := httptest.NewRequest(http.MethodGet, "/yt/user/captcha", nil)
perform(func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "OK",
"data": gin.H{"id": "abc", "image": "data:image/png;base64,AAAA"},
})
}, req)
if got.row == nil {
t.Fatal("captcha was not logged")
}
if got.row.Action != "获取验证码" || got.row.Success != 1 {
t.Fatalf("row = %+v", got.row)
}
if strings.Contains(got.row.Response, "base64") || !strings.Contains(got.row.Response, `"id":"abc"`) {
t.Fatalf("response = %s", got.row.Response)
}
}
func TestOperateLogLoginSuccessLoadsUser(t *testing.T) {
got := captureLog(t)
req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(`{"account":"editor","password":"Secret123!"}`))
req.Header.Set("Content-Type", "application/json")
perform(func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "OK",
"data": gin.H{"token": "good-token-value"},
})
}, req)
if got.row == nil {
t.Fatal("login success was not logged")
}
if got.row.Account != "editor" || got.row.UserID != 7 || got.row.RoleID != 3 || got.row.Success != 1 {
t.Fatalf("row = %+v", got.row)
}
if strings.Contains(got.row.Response, "good-token-value") {
t.Fatalf("response = %s", got.row.Response)
}
}
func TestOperateLogUploadKeepsFileMetaOnly(t *testing.T) {
got := captureLog(t)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "founder.png")
if err != nil {
t.Fatal(err)
}
if _, err = part.Write([]byte("file-bytes-secret")); err != nil {
t.Fatal(err)
}
if err = writer.WriteField("password", "Secret123!"); err != nil {
t.Fatal(err)
}
if err = writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/yt/upload", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("token", "good-token-value")
perform(func(c *gin.Context) {
if _, err := c.FormFile("file"); err != nil {
t.Errorf("form file: %v", err)
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OK"})
}, req)
if got.row == nil {
t.Fatal("upload was not logged")
}
if strings.Contains(got.row.Request, "file-bytes-secret") || !strings.Contains(got.row.Request, "Secret123!") {
t.Fatalf("request = %s", got.row.Request)
}
if !strings.Contains(got.row.Request, "founder.png") || got.row.Action != "上传文件" {
t.Fatalf("request = %s action = %s", got.row.Request, got.row.Action)
}
}
func TestOperateLogStopsBeforeWriteAndMatchesExpected(t *testing.T) {
got := captureLog(t)
before := time.Now().Unix()
body := `{"account":"admin","password":"Secret123!","captcha_id":"cid","captcha":"W8K2"}`
req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-For", "203.0.113.8")
req.RemoteAddr = "192.0.2.1:1234"
var seenPassword string
recorder := perform(func(c *gin.Context) {
var login struct {
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&login); err != nil {
t.Errorf("bind body: %v", err)
}
seenPassword = login.Password
panic(config.LoginErr.New())
}, req)
after := time.Now().Unix()
if seenPassword != "Secret123!" {
t.Fatalf("handler saw password %q", seenPassword)
}
if got.row == nil {
t.Fatal("log stopped with no row")
}
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"code":11013`) {
t.Fatalf("http status=%d body=%s", recorder.Code, recorder.Body.String())
}
row := *got.row
if row.CreateTime < before || row.CreateTime > after {
t.Fatalf("create_time %d not in [%d,%d]", row.CreateTime, before, after)
}
if row.CostMs < 0 || row.CostMs > 5000 {
t.Fatalf("cost_ms %d", row.CostMs)
}
if !strings.HasPrefix(row.Message, "请输入正确的账号和密码") {
t.Fatalf("message %q", row.Message)
}
wantResponse, err := json.Marshal(struct {
Code int `json:"code"`
Message string `json:"message"`
}{Code: 11013, Message: row.Message})
if err != nil {
t.Fatal(err)
}
if row.Response != string(wantResponse) {
t.Fatalf("response\n got %s\nwant %s", row.Response, wantResponse)
}
row.CreateTime = 0
row.CostMs = 0
row.Message = ""
row.Response = ""
diffLog(t, row, model.AdminOperateLog{
Account: "admin",
UserID: 0,
RoleID: 0,
IP: "203.0.113.8",
ForwardedFor: "203.0.113.8",
Method: "POST",
Path: "/yt/user/login",
Query: "",
Request: `{"account":"admin","captcha":"W8K2","captcha_id":"cid","password":"Secret123!"}`,
Action: "登录",
Code: 11013,
Success: 0,
Header: `{"Content-Type":"application/json","X-Forwarded-For":"203.0.113.8"}`,
})
}
func diffLog(t *testing.T, got, want model.AdminOperateLog) {
t.Helper()
gv := reflect.ValueOf(got)
wv := reflect.ValueOf(want)
var diffs []string
for i := 0; i < gv.NumField(); i++ {
name := gv.Type().Field(i).Name
gotText := fmt.Sprint(gv.Field(i).Interface())
wantText := fmt.Sprint(wv.Field(i).Interface())
if gotText != wantText {
diffs = append(diffs, fmt.Sprintf("%s:\n got %s\n want %s", name, gotText, wantText))
}
}
if len(diffs) > 0 {
t.Fatalf("row mismatch\n%s", strings.Join(diffs, "\n"))
}
}
func TestOperateLogTruncatesLargeBody(t *testing.T) {
got := captureLog(t)
payload, err := json.Marshal(map[string]any{
"code": 0,
"message": "OK",
"data": map[string]any{"content": strings.Repeat("页", 5000)},
})
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/yt/page", strings.NewReader(`{"name":"founder"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("token", "good-token-value")
perform(func(c *gin.Context) {
c.Data(http.StatusOK, "application/json", payload)
}, req)
if got.row == nil {
t.Fatal("create was not logged")
}
if len(got.row.Response) > operateBodyLimit {
t.Fatalf("response len = %d", len(got.row.Response))
}
if !utf8.ValidString(got.row.Response) {
t.Fatal("truncated response is not valid utf-8")
}
}