完善平台、基准模型、定价规则和实时负载的紧凑查询与滚动展示,并固定关键操作列。 新增管理员任务记录、批量计费结算和用户组限流、额度及模型权限维护能力,同时补充任务脱敏、查询索引与 OpenAPI 契约。 验证:pnpm openapi、Go 全量测试、pnpm lint、pnpm test、pnpm build、gofmt 与差异格式检查均通过。
322 lines
11 KiB
Go
322 lines
11 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/config"
|
|
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
|
)
|
|
|
|
func TestAdminTasksCrossTenantQueryAndRedaction(t *testing.T) {
|
|
databaseURL := strings.TrimSpace(os.Getenv("AI_GATEWAY_TEST_DATABASE_URL"))
|
|
if databaseURL == "" {
|
|
t.Skip("set AI_GATEWAY_TEST_DATABASE_URL to run the PostgreSQL integration flow")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
applyMigration(t, ctx, databaseURL)
|
|
db, err := store.Connect(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatalf("connect store: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
suffix := strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
createTenant := func(key, name string) store.GatewayTenant {
|
|
t.Helper()
|
|
tenant, err := db.CreateTenant(ctx, store.GatewayTenantInput{
|
|
TenantKey: key + "-" + suffix,
|
|
Name: name,
|
|
Source: "gateway",
|
|
Status: "active",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create tenant %s: %v", name, err)
|
|
}
|
|
return tenant
|
|
}
|
|
createUser := func(tenant store.GatewayTenant, prefix string, roles []string) store.GatewayUser {
|
|
t.Helper()
|
|
username := prefix + "_" + suffix
|
|
user, err := db.CreateGatewayUser(ctx, store.GatewayUserInput{
|
|
UserKey: "gateway:" + username,
|
|
Username: username,
|
|
DisplayName: prefix + " display",
|
|
Email: username + "@example.com",
|
|
Source: "gateway",
|
|
GatewayTenantID: tenant.ID,
|
|
TenantKey: tenant.TenantKey,
|
|
Roles: roles,
|
|
Status: "active",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create gateway user %s: %v", prefix, err)
|
|
}
|
|
return user
|
|
}
|
|
authUser := func(user store.GatewayUser, apiKeyName string) *auth.User {
|
|
return &auth.User{
|
|
ID: user.ID,
|
|
Username: user.Username,
|
|
Roles: user.Roles,
|
|
Source: user.Source,
|
|
GatewayUserID: user.ID,
|
|
GatewayTenantID: user.GatewayTenantID,
|
|
TenantKey: user.TenantKey,
|
|
APIKeyID: "key-" + apiKeyName + "-" + suffix,
|
|
APIKeyName: apiKeyName,
|
|
APIKeyPrefix: "sk-" + apiKeyName,
|
|
}
|
|
}
|
|
createTask := func(user *auth.User, model, secret string) store.GatewayTask {
|
|
t.Helper()
|
|
task, err := db.CreateTask(ctx, store.CreateTaskInput{
|
|
Kind: "chat.completions",
|
|
Model: model,
|
|
RunMode: "simulation",
|
|
Request: map[string]any{
|
|
"model": model,
|
|
"diagnostic": map[string]any{
|
|
"apiKey": secret,
|
|
},
|
|
},
|
|
}, user)
|
|
if err != nil {
|
|
t.Fatalf("create task for %s: %v", user.Username, err)
|
|
}
|
|
return task
|
|
}
|
|
|
|
tenantA := createTenant("admin-task-a", "Admin Task Tenant A")
|
|
tenantB := createTenant("admin-task-b", "Admin Task Tenant B")
|
|
operator := createUser(tenantA, "admin_task_operator", []string{"operator"})
|
|
userA := createUser(tenantA, "admin_task_user_a", []string{"user"})
|
|
userB := createUser(tenantB, "admin_task_user_b", []string{"user"})
|
|
userAAuth := authUser(userA, "alpha-key")
|
|
userBAuth := authUser(userB, "bravo-key")
|
|
|
|
modelA := "admin-task-model-a-" + suffix
|
|
modelB := "admin-task-model-b-" + suffix
|
|
taskA := createTask(userAAuth, modelA, "alpha-secret-"+suffix)
|
|
taskBSecret := "bravo-secret-" + suffix
|
|
taskB := createTask(userBAuth, modelB, taskBSecret)
|
|
|
|
platform, err := db.CreatePlatform(ctx, store.CreatePlatformInput{
|
|
Provider: "openai",
|
|
PlatformKey: "admin-task-platform-" + suffix,
|
|
Name: "Admin Task Platform " + suffix,
|
|
BaseURL: "https://example.invalid/v1",
|
|
AuthType: "bearer",
|
|
Credentials: map[string]any{"apiKey": "platform-secret-" + suffix},
|
|
Status: "enabled",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create platform: %v", err)
|
|
}
|
|
attemptID, err := db.CreateTaskAttempt(ctx, store.CreateTaskAttemptInput{
|
|
TaskID: taskB.ID,
|
|
AttemptNo: 1,
|
|
PlatformID: platform.ID,
|
|
QueueKey: "admin-task-integration-" + suffix,
|
|
Status: "succeeded",
|
|
Simulated: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create task attempt: %v", err)
|
|
}
|
|
if _, err := db.Pool().Exec(ctx, `
|
|
UPDATE gateway_tasks
|
|
SET status = 'succeeded',
|
|
model_type = 'text_generate',
|
|
requested_model = $2,
|
|
resolved_model = $2,
|
|
request_id = $3,
|
|
billing_status = 'settled',
|
|
result = $4::jsonb,
|
|
finished_at = now(),
|
|
updated_at = now()
|
|
WHERE id = $1::uuid`,
|
|
taskB.ID,
|
|
modelB,
|
|
"request-admin-task-"+suffix,
|
|
`{"authorization":"Bearer result-secret","output":"ok"}`,
|
|
); err != nil {
|
|
t.Fatalf("enrich task: %v", err)
|
|
}
|
|
if _, err := db.Pool().Exec(ctx, `
|
|
UPDATE gateway_task_attempts
|
|
SET request_snapshot = $2::jsonb,
|
|
response_snapshot = $3::jsonb,
|
|
finished_at = now()
|
|
WHERE id = $1::uuid`,
|
|
attemptID,
|
|
`{"headers":{"authorization":"Bearer attempt-secret"}}`,
|
|
`{"cookie":"attempt-cookie","status":"ok"}`,
|
|
); err != nil {
|
|
t.Fatalf("enrich task attempt: %v", err)
|
|
}
|
|
if _, err := db.Pool().Exec(ctx, `
|
|
INSERT INTO gateway_task_param_preprocessing_logs (
|
|
task_id, attempt_id, attempt_no, model_type, platform_id, client_id,
|
|
changed, change_count, actual_input, converted_output, changes, model_snapshot
|
|
)
|
|
VALUES (
|
|
$1::uuid, $2::uuid, 1, 'text_generate', $3::uuid, $4,
|
|
true, 1, $5::jsonb, $6::jsonb, $7::jsonb, '{}'::jsonb
|
|
)`,
|
|
taskB.ID,
|
|
attemptID,
|
|
platform.ID,
|
|
"admin-task-client-"+suffix,
|
|
`{"password":"input-secret","prompt":"hello"}`,
|
|
`{"token":"converted-secret","prompt":"hello"}`,
|
|
`[{"path":"credentials.secret","before":"old-secret","after":"new-secret"}]`,
|
|
); err != nil {
|
|
t.Fatalf("create parameter preprocessing log: %v", err)
|
|
}
|
|
|
|
const jwtSecret = "admin-task-integration-jwt-secret"
|
|
handlerCtx, cancelHandler := context.WithCancel(ctx)
|
|
defer cancelHandler()
|
|
server := httptest.NewServer(NewServerWithContext(handlerCtx, config.Config{
|
|
AppEnv: "test",
|
|
HTTPAddr: ":0",
|
|
DatabaseURL: databaseURL,
|
|
IdentityMode: "hybrid",
|
|
JWTSecret: jwtSecret,
|
|
CORSAllowedOrigin: "*",
|
|
}, db, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
|
defer server.Close()
|
|
|
|
authenticator := auth.New(jwtSecret, "", "")
|
|
signToken := func(user *auth.User) string {
|
|
t.Helper()
|
|
token, err := authenticator.SignJWT(user, time.Hour)
|
|
if err != nil {
|
|
t.Fatalf("sign JWT for %s: %v", user.Username, err)
|
|
}
|
|
return token
|
|
}
|
|
operatorToken := signToken(&auth.User{
|
|
ID: operator.ID,
|
|
Username: operator.Username,
|
|
Roles: operator.Roles,
|
|
Source: operator.Source,
|
|
GatewayUserID: operator.ID,
|
|
GatewayTenantID: operator.GatewayTenantID,
|
|
TenantKey: operator.TenantKey,
|
|
})
|
|
userAToken := signToken(userAAuth)
|
|
|
|
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks", userAToken, nil, http.StatusForbidden, nil)
|
|
|
|
query := url.Values{
|
|
"q": {taskB.ID},
|
|
"tenantId": {tenantB.ID},
|
|
"userId": {userB.ID},
|
|
"status": {"succeeded"},
|
|
"platformId": {platform.ID},
|
|
"model": {modelB},
|
|
"modelType": {"text_generate"},
|
|
"runMode": {"simulation"},
|
|
"billingStatus": {"settled"},
|
|
"apiKey": {"bravo-key"},
|
|
"page": {"1"},
|
|
"pageSize": {"10"},
|
|
}
|
|
var listResponse struct {
|
|
Items []store.AdminGatewayTask `json:"items"`
|
|
Total int `json:"total"`
|
|
Page int `json:"page"`
|
|
PageSize int `json:"pageSize"`
|
|
}
|
|
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks?"+query.Encode(), operatorToken, nil, http.StatusOK, &listResponse)
|
|
if len(listResponse.Items) != 1 || listResponse.Total != 1 || listResponse.Page != 1 || listResponse.PageSize != 10 {
|
|
t.Fatalf("unexpected filtered admin task list: %+v", listResponse)
|
|
}
|
|
listed := listResponse.Items[0]
|
|
if listed.ID != taskB.ID || listed.AdminContext.User == nil || listed.AdminContext.User.ID != userB.ID ||
|
|
listed.AdminContext.Tenant == nil || listed.AdminContext.Tenant.ID != tenantB.ID ||
|
|
listed.AdminContext.LatestPlatform == nil || listed.AdminContext.LatestPlatform.ID != platform.ID {
|
|
t.Fatalf("admin task list should expose cross-tenant identity and platform summaries: %+v", listed)
|
|
}
|
|
if nestedString(listed.Request, "diagnostic", "apiKey") != "***" ||
|
|
nestedString(listed.Result, "authorization") != "***" ||
|
|
nestedString(listed.Attempts[0].RequestSnapshot, "headers", "authorization") != "***" {
|
|
t.Fatalf("admin task list should mask task and attempt secrets: %+v", listed)
|
|
}
|
|
|
|
var maskedDetail store.AdminGatewayTask
|
|
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID, operatorToken, nil, http.StatusOK, &maskedDetail)
|
|
if nestedString(maskedDetail.Request, "diagnostic", "apiKey") != "***" {
|
|
t.Fatalf("admin task detail should be masked by default: %+v", maskedDetail.Request)
|
|
}
|
|
var fullDetail store.AdminGatewayTask
|
|
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"?sensitive=full", operatorToken, nil, http.StatusOK, &fullDetail)
|
|
if nestedString(fullDetail.Request, "diagnostic", "apiKey") != taskBSecret {
|
|
t.Fatalf("explicit full task detail should expose stored content: %+v", fullDetail.Request)
|
|
}
|
|
|
|
var maskedLogs struct {
|
|
Items []store.TaskParamPreprocessingLog `json:"items"`
|
|
}
|
|
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing", operatorToken, nil, http.StatusOK, &maskedLogs)
|
|
if len(maskedLogs.Items) != 1 ||
|
|
nestedString(maskedLogs.Items[0].ActualInput, "password") != "***" ||
|
|
nestedString(maskedLogs.Items[0].ConvertedOutput, "token") != "***" {
|
|
t.Fatalf("admin task preprocessing logs should be masked by default: %+v", maskedLogs.Items)
|
|
}
|
|
var fullLogs struct {
|
|
Items []store.TaskParamPreprocessingLog `json:"items"`
|
|
}
|
|
doJSON(t, server.URL, http.MethodGet, "/api/admin/tasks/"+taskB.ID+"/param-preprocessing?sensitive=full", operatorToken, nil, http.StatusOK, &fullLogs)
|
|
if len(fullLogs.Items) != 1 ||
|
|
nestedString(fullLogs.Items[0].ActualInput, "password") != "input-secret" ||
|
|
nestedString(fullLogs.Items[0].ConvertedOutput, "token") != "converted-secret" {
|
|
t.Fatalf("explicit full preprocessing logs should expose stored content: %+v", fullLogs.Items)
|
|
}
|
|
|
|
var ownTasks struct {
|
|
Items []store.GatewayTask `json:"items"`
|
|
}
|
|
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks?pageSize=100", userAToken, nil, http.StatusOK, &ownTasks)
|
|
if !gatewayTaskListHasID(ownTasks.Items, taskA.ID) || gatewayTaskListHasID(ownTasks.Items, taskB.ID) {
|
|
t.Fatalf("workspace task list must remain scoped to the current user: %+v", ownTasks.Items)
|
|
}
|
|
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID, userAToken, nil, http.StatusNotFound, nil)
|
|
doJSON(t, server.URL, http.MethodGet, "/api/workspace/tasks/"+taskB.ID+"/param-preprocessing", userAToken, nil, http.StatusNotFound, nil)
|
|
}
|
|
|
|
func nestedString(value map[string]any, path ...string) string {
|
|
var current any = value
|
|
for _, key := range path {
|
|
object, ok := current.(map[string]any)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
current = object[key]
|
|
}
|
|
result, _ := current.(string)
|
|
return result
|
|
}
|
|
|
|
func gatewayTaskListHasID(items []store.GatewayTask, taskID string) bool {
|
|
for _, item := range items {
|
|
if item.ID == taskID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|