359 lines
10 KiB
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")
|
|
}
|
|
}
|