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 }