Files
22ee0aa112 修复安全问题 (#9)
* fix:修复权限问题

* fix:修复项目key类型错误问题

* fix:修复rbac权限问题

* fix:修复api密钥未及时失效的问题

* improve:优化渠道调度稳定性

* improve:优化路由

* fix:修复项目预算控制缺陷

* fix:加强限流等安全措施

* feat:添加限流相关字段的前端展示及接口字段

* improve:优化注册时密码要求,只需要字母和数字即可

* feat(setting):添加注册限流设置

* improve:优化前端数字类型转换

* chore:rbac权限放行

---------

Co-authored-by: chengqian <wuchengqian@kkidc.com>
Co-authored-by: qian <wuchengqian5@163.com>
2026-06-23 22:47:54 +08:00

281 lines
8.4 KiB
Go

//go:build integration
package tenant_test
import (
"fmt"
"testing"
"github.com/qianfree/team-api/internal/consts"
admintest "github.com/qianfree/team-api/tests/integration/admin/testinfra"
"github.com/qianfree/team-api/tests/integration/tenant/testinfra"
)
func TestProjectList(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
_, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
resp := client.Get("/api/tenant/projects", map[string]string{
"page": "1",
"page_size": "10",
})
admintest.AssertPaginatedList(t, resp, 1)
}
func TestProjectCRUD(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
// --- Create ---
suffix := testinfra.RandomSuffix()
createResp := client.Post("/api/tenant/projects", map[string]any{
"name": fmt.Sprintf("crud-project-%s", suffix),
"description": "Integration test project",
"budget": 100.0,
})
createResp.AssertSuccess(t)
projectID := createResp.GetID(t)
defer func() {
client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil)
}()
// --- Get detail ---
detailResp := client.Get(fmt.Sprintf("/api/tenant/projects/%d", projectID), nil)
detailResp.AssertSuccess(t)
var detail struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Budget *string `json:"budget"`
Status string `json:"status"`
}
detailResp.DecodeData(t, &detail)
if detail.ID != projectID {
t.Fatalf("expected id=%d, got %d", projectID, detail.ID)
}
// --- Update ---
updateResp := client.Put(fmt.Sprintf("/api/tenant/projects/%d", projectID), map[string]any{
"name": fmt.Sprintf("updated-project-%s", suffix),
"description": "Updated description",
"budget": 200.0,
})
updateResp.AssertSuccess(t)
// Verify update
verifyResp := client.Get(fmt.Sprintf("/api/tenant/projects/%d", projectID), nil)
verifyResp.AssertSuccess(t)
}
func TestProjectArchiveUnarchive(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
projectID, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
// Archive
archiveResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil)
archiveResp.AssertSuccess(t)
// Verify archived via list
listResp := client.Get("/api/tenant/projects", map[string]string{
"page": "1",
"page_size": "50",
})
listResp.AssertSuccess(t)
// Unarchive
unarchiveResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/unarchive", projectID), nil)
unarchiveResp.AssertSuccess(t)
}
func TestProjectApiKeys(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
projectID, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
// Create project API key
createKeyResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/api-keys", projectID), map[string]any{
"name": "project-test-key",
})
createKeyResp.AssertSuccess(t)
// List project API keys
listResp := client.Get(fmt.Sprintf("/api/tenant/projects/%d/api-keys", projectID), map[string]string{
"page": "1",
"page_size": "10",
})
admintest.AssertPaginatedList(t, listResp, 1)
var listData struct {
List []struct {
ID int64 `json:"id"`
} `json:"list"`
}
listResp.DecodeData(t, &listData)
if len(listData.List) == 0 {
t.Fatal("expected at least 1 project API key")
}
// Delete project API key
keyID := listData.List[0].ID
deleteResp := client.Delete(fmt.Sprintf("/api/tenant/projects/%d/api-keys/%d", projectID, keyID))
deleteResp.AssertSuccess(t)
}
func TestProjectApiKeyCreate_ArchivedProject(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
projectID, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
archiveResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil)
archiveResp.AssertSuccess(t)
createKeyResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/api-keys", projectID), map[string]any{
"name": "archived-project-key",
})
createKeyResp.AssertError(t, consts.CodeProjectNotActive)
}
func TestProjectApiKeyCreate_BudgetExhaustedProject(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
suffix := testinfra.RandomSuffix()
createResp := client.Post("/api/tenant/projects", map[string]any{
"name": fmt.Sprintf("exhausted-project-%s", suffix),
"budget": 1.0,
})
createResp.AssertSuccess(t)
projectID := createResp.GetID(t)
defer client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil)
testinfra.MarkProjectBudgetExhausted(t, projectID)
createKeyResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/api-keys", projectID), map[string]any{
"name": "budget-exhausted-project-key",
})
createKeyResp.AssertError(t, consts.CodeProjectNotActive)
}
func TestProjectUsageStats(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
projectID, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
resp := client.Get(fmt.Sprintf("/api/tenant/projects/%d/usage-stats", projectID), nil)
resp.AssertSuccess(t)
}
func TestProjectUsageLogs(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
projectID, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
resp := client.Get(fmt.Sprintf("/api/tenant/projects/%d/usage-logs", projectID), map[string]string{
"page": "1",
"page_size": "10",
})
resp.AssertSuccess(t)
}
// ─── 边界值测试 ────────────────────────────────────────────────────
// TestProjectCreate_EmptyName 验证空项目名称被拒绝
// Business rule: 项目名称不能为空(project.go: 项目名称不能为空)
func TestProjectCreate_EmptyName(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
resp := client.Post("/api/tenant/projects", map[string]any{
"name": "",
})
if resp.Code == 0 {
t.Fatal("empty project name should be rejected")
}
t.Logf("empty project name: code=%d msg=%q", resp.Code, resp.Message)
}
// TestProjectCreate_ZeroBudget 验证零预算被正确处理
// Business rule: budget=0 表示不设预算限制
func TestProjectCreate_ZeroBudget(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
suffix := testinfra.RandomSuffix()
resp := client.Post("/api/tenant/projects", map[string]any{
"name": fmt.Sprintf("zero-budget-%s", suffix),
"budget": 0,
})
resp.AssertSuccess(t)
projectID := resp.GetID(t)
defer client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil)
}
// TestProjectCreate_NegativeBudget 验证负数预算被拒绝
// Business rule: 预算金额不能为负数
func TestProjectCreate_NegativeBudget(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
suffix := testinfra.RandomSuffix()
resp := client.Post("/api/tenant/projects", map[string]any{
"name": fmt.Sprintf("negative-budget-%s", suffix),
"budget": -10.0,
})
if resp.Code == 0 {
projectID := resp.GetID(t)
defer client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil)
t.Logf("server accepted negative budget — may need server-side validation")
} else {
t.Logf("negative budget rejected: code=%d msg=%q", resp.Code, resp.Message)
}
}
// TestProjectUpdate_ArchivedProject 验证归档项目不可编辑
// Business rule: 归档或预算耗尽的项目不能直接编辑
func TestProjectUpdate_ArchivedProject(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
projectID, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
// 先归档
archiveResp := client.Post(fmt.Sprintf("/api/tenant/projects/%d/archive", projectID), nil)
archiveResp.AssertSuccess(t)
// 尝试更新归档项目
resp := client.Put(fmt.Sprintf("/api/tenant/projects/%d", projectID), map[string]any{
"name": "should-not-work",
})
if resp.Code == 0 {
t.Fatal("archived project should not be updatable")
}
t.Logf("archived project update: code=%d msg=%q", resp.Code, resp.Message)
}
// TestProjectList_PaginationBoundary 验证分页边界值
func TestProjectList_PaginationBoundary(t *testing.T) {
client, _ := testinfra.GetAuthedClient(t)
_, cleanup := testinfra.CreateTestProject(t, client)
defer cleanup()
// 超出可用数据的 page
resp := client.Get("/api/tenant/projects", map[string]string{
"page": "999999",
"page_size": "10",
})
resp.AssertSuccess(t)
var data struct {
List []any `json:"list"`
}
resp.DecodeData(t, &data)
if len(data.List) != 0 {
t.Fatalf("page=999999 should return empty list, got %d items", len(data.List))
}
}