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") } }