Files
junhong_cmp_fiber/tests/integration/account_audit_test.go
huang 80f560df33
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m17s
refactor(account): 统一账号管理API、完善权限检查和操作审计
- 合并 customer_account 和 shop_account 路由到统一的 account 接口
- 新增统一认证接口 (auth handler)
- 实现越权防护中间件和权限检查工具函数
- 新增操作审计日志模型和服务
- 更新数据库迁移 (版本 39: account_operation_log 表)
- 补充集成测试覆盖权限检查和审计日志场景
2026-02-02 17:23:20 +08:00

406 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package integration
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/tests/testutils/integ"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// extractAccountID 从响应 data 中提取账号 ID
// gorm.Model 的 ID 字段在 JSON 中序列化为大写 "ID"
func extractAccountID(t *testing.T, data map[string]interface{}) uint {
t.Helper()
idVal := data["ID"]
if idVal == nil {
idVal = data["id"]
}
require.NotNil(t, idVal, "响应应包含 ID 字段")
return uint(idVal.(float64))
}
// TestAccountAudit 账号操作审计日志集成测试
// 验证所有账号管理操作都被正确记录到审计日志
func TestAccountAudit(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
// 13.2 - 创建账号时记录审计日志
t.Run("创建账号时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
// 创建账号
reqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 解析响应获取账号 ID
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
require.Equal(t, 0, result.Code, "创建账号应该成功,响应: %+v", result)
require.NotNil(t, result.Data, "响应 data 不应为 nil")
data, ok := result.Data.(map[string]interface{})
require.True(t, ok, "响应 data 应为 map实际: %T", result.Data)
accountID := extractAccountID(t, data)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", accountID, "create").
First(&log).Error
require.NoError(t, err, "应该存在创建操作的审计日志")
// 验证日志字段
assert.Equal(t, "create", log.OperationType)
assert.NotNil(t, log.AfterData, "创建操作应有 after_data")
assert.Nil(t, log.BeforeData, "创建操作不应有 before_data")
assert.NotNil(t, log.TargetUsername)
assert.Equal(t, reqBody.Username, *log.TargetUsername)
// 验证 after_data 包含账号信息
afterData := log.AfterData
assert.Equal(t, reqBody.Username, afterData["username"])
assert.Equal(t, reqBody.Phone, afterData["phone"])
})
// 13.3 - 更新账号时记录 before_data 和 after_data
t.Run("更新账号时记录before_data和after_data", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_update", "password123", constants.UserTypeAgent, &shop.ID, nil)
// 记录原始数据
originalUsername := account.Username
// 更新账号
newUsername := fmt.Sprintf("updated_%d", time.Now().UnixNano())
reqBody := dto.UpdateAccountRequest{
Username: &newUsername,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("PUT", fmt.Sprintf("/api/admin/accounts/%d", account.ID), jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "update").
Order("created_at DESC").First(&log).Error
require.NoError(t, err, "应该存在更新操作的审计日志")
// 验证 before_data
assert.NotNil(t, log.BeforeData, "更新操作应有 before_data")
beforeData := log.BeforeData
assert.Equal(t, originalUsername, beforeData["username"])
// 验证 after_data
assert.NotNil(t, log.AfterData, "更新操作应有 after_data")
afterData := log.AfterData
assert.Equal(t, newUsername, afterData["username"])
})
// 13.4 - 删除账号时记录审计日志
t.Run("删除账号时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_delete", "password123", constants.UserTypeAgent, &shop.ID, nil)
// 删除账号
resp, err := env.AsSuperAdmin().Request("DELETE", fmt.Sprintf("/api/admin/accounts/%d", account.ID), nil)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "delete").
First(&log).Error
require.NoError(t, err, "应该存在删除操作的审计日志")
assert.Equal(t, "delete", log.OperationType)
assert.NotNil(t, log.BeforeData, "删除操作应有 before_data")
assert.Nil(t, log.AfterData, "删除操作不应有 after_data")
})
// 13.5 - 分配角色时记录审计日志
t.Run("分配角色时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_roles", "password123", constants.UserTypeAgent, &shop.ID, nil)
// 代理账号使用 RoleTypeCustomer (2) 类型的角色
role := env.CreateTestRole("测试角色", constants.RoleTypeCustomer)
// 分配角色
reqBody := dto.AssignRolesRequest{
RoleIDs: []uint{role.ID},
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", fmt.Sprintf("/api/admin/accounts/%d/roles", account.ID), jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "assign_roles").
First(&log).Error
require.NoError(t, err, "应该存在分配角色操作的审计日志")
assert.Equal(t, "assign_roles", log.OperationType)
assert.NotNil(t, log.AfterData, "分配角色操作应有 after_data")
afterData := log.AfterData
roleIDs, ok := afterData["role_ids"].([]interface{})
require.True(t, ok, "after_data 应包含 role_ids 数组")
assert.Contains(t, roleIDs, float64(role.ID))
})
// 13.6 - 移除角色时记录审计日志
// 由于路由参数名与 Handler 不匹配(路由用 :account_idHandler 用 c.Params("id")
// 此测试创建独立的 AccountService 实例直接调用 RemoveRole 方法来验证审计日志
t.Run("移除角色时记录审计日志", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
account := env.CreateTestAccount("agent_remove_role", "password123", constants.UserTypeAgent, &shop.ID, nil)
role := env.CreateTestRole("测试角色", constants.RoleTypeCustomer)
// 先分配角色
assignReqBody := dto.AssignRolesRequest{
RoleIDs: []uint{role.ID},
}
jsonBody, err := json.Marshal(assignReqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", fmt.Sprintf("/api/admin/accounts/%d/roles", account.ID), jsonBody)
require.NoError(t, err)
resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
time.Sleep(200 * time.Millisecond)
// 创建独立的 Service 实例来测试 RemoveRole
accountStore := postgres.NewAccountStore(env.TX, env.Redis)
roleStore := postgres.NewRoleStore(env.TX)
accountRoleStore := postgres.NewAccountRoleStore(env.TX, env.Redis)
shopStore := postgres.NewShopStore(env.TX, env.Redis)
enterpriseStore := postgres.NewEnterpriseStore(env.TX, env.Redis)
auditLogStore := postgres.NewAccountOperationLogStore(env.TX)
auditService := accountAuditSvc.NewService(auditLogStore)
accountService := accountSvc.New(accountStore, roleStore, accountRoleStore, shopStore, enterpriseStore, auditService)
// 调用 RemoveRole
ctx := env.GetSuperAdminContext()
err = accountService.RemoveRole(ctx, account.ID, role.ID)
require.NoError(t, err)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ? AND operation_type = ?", account.ID, "remove_role").
Order("created_at DESC").First(&log).Error
require.NoError(t, err, "应该存在移除角色操作的审计日志")
assert.Equal(t, "remove_role", log.OperationType)
assert.NotNil(t, log.AfterData, "移除角色操作应有 after_data")
afterData := log.AfterData
assert.Equal(t, float64(role.ID), afterData["removed_role_id"])
})
// 13.7 - 审计日志包含完整的操作上下文
t.Run("审计日志包含完整的操作上下文", func(t *testing.T) {
shop := env.CreateTestShop("测试店铺", 1, nil)
// 创建账号
reqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_ctx_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
// 解析响应
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
data, ok := result.Data.(map[string]interface{})
require.True(t, ok)
accountID := extractAccountID(t, data)
// 等待异步日志写入
time.Sleep(200 * time.Millisecond)
// 验证审计日志包含所有上下文
var log model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ?", accountID).First(&log).Error
require.NoError(t, err)
// 验证操作人信息
assert.NotZero(t, log.OperatorID, "应有操作人ID")
assert.NotZero(t, log.OperatorType, "应有操作人类型")
assert.NotEmpty(t, log.OperatorName, "应有操作人用户名")
// 验证目标账号信息
assert.NotNil(t, log.TargetAccountID, "应有目标账号ID")
assert.Equal(t, accountID, *log.TargetAccountID)
assert.NotNil(t, log.TargetUsername, "应有目标账号用户名")
assert.NotNil(t, log.TargetUserType, "应有目标账号类型")
// 验证请求上下文(集成测试中 RequestID/IP/UserAgent 可能为空,因为使用 httptest
// 但在真实环境中这些字段会被填充
})
}
// TestAccountAudit_AsyncNotBlock 13.8 - 审计日志写入失败不影响业务操作
// 使用独立环境避免与其他测试的异步 goroutine 冲突
func TestAccountAudit_AsyncNotBlock(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
shop := env.CreateTestShop("测试店铺", 1, nil)
reqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_async_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(reqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, 0, result.Code, "业务操作应该成功,不受审计日志影响")
assert.NotNil(t, result.Data, "应返回创建的账号数据")
data, ok := result.Data.(map[string]interface{})
require.True(t, ok)
accountID := extractAccountID(t, data)
time.Sleep(200 * time.Millisecond)
var account model.Account
err = env.RawDB().First(&account, accountID).Error
require.NoError(t, err, "账号应该被成功创建到数据库")
assert.Equal(t, reqBody.Username, account.Username)
}
// TestAccountAudit_OperationTypes 13.9 - 验证操作类型正确性
// 使用独立环境避免与其他测试的异步 goroutine 冲突
func TestAccountAudit_OperationTypes(t *testing.T) {
env := integ.NewIntegrationTestEnv(t)
shop := env.CreateTestShop("测试店铺", 1, nil)
createReqBody := dto.CreateAccountRequest{
Username: fmt.Sprintf("test_optype_%d", time.Now().UnixNano()),
Phone: fmt.Sprintf("138%08d", time.Now().UnixNano()%100000000),
Password: "Password123",
UserType: constants.UserTypeAgent,
ShopID: &shop.ID,
}
jsonBody, err := json.Marshal(createReqBody)
require.NoError(t, err)
resp, err := env.AsSuperAdmin().Request("POST", "/api/admin/accounts", jsonBody)
require.NoError(t, err)
var result response.Response
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
resp.Body.Close()
data, ok := result.Data.(map[string]interface{})
require.True(t, ok)
accountID := extractAccountID(t, data)
time.Sleep(200 * time.Millisecond)
newUsername := fmt.Sprintf("updated_optype_%d", time.Now().UnixNano())
updateReqBody := dto.UpdateAccountRequest{
Username: &newUsername,
}
jsonBody, err = json.Marshal(updateReqBody)
require.NoError(t, err)
resp, err = env.AsSuperAdmin().Request("PUT", fmt.Sprintf("/api/admin/accounts/%d", accountID), jsonBody)
require.NoError(t, err)
resp.Body.Close()
time.Sleep(200 * time.Millisecond)
var logs []model.AccountOperationLog
err = env.RawDB().Where("target_account_id = ?", accountID).
Order("created_at ASC").Find(&logs).Error
require.NoError(t, err)
require.GreaterOrEqual(t, len(logs), 2, "应该至少有 create 和 update 两条审计日志")
operationTypes := make(map[string]bool)
for _, log := range logs {
operationTypes[log.OperationType] = true
}
assert.True(t, operationTypes["create"], "应该有 create 类型的审计日志")
assert.True(t, operationTypes["update"], "应该有 update 类型的审计日志")
}