Files
creator-hub/internal/controlplane/api/account_environment_unit_test.go
T
rogee f1a62e25de
douyin-release-gate / verify (push) Failing after 4m22s
feat(account): create browser environments before verified login
2026-10-06 05:55:53 +08:00

252 lines
11 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 api
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
accountdomain "git.ipao.vip/rogee/creator-hub/internal/account"
"git.ipao.vip/rogee/creator-hub/internal/creator"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
)
func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
accountStore, err := accountdomain.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = accountStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
gateway := &fakeGateway{token: "unit-test-gateway-token"}
gatewayServer := httptest.NewServer(gateway.handler(t))
t.Cleanup(gatewayServer.Close)
app := fiber.New()
RegisterAccountRoutes(app, accountStore, hubStore, &testCredentialBridge{values: map[string]string{}})
// Existing-account maintenance remains available; public pre-login creation is removed.
accountID := maintenanceAccountFixture(t, ctx, accountStore)
if response := do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/environment", ""); response.Code != http.StatusNotFound {
t.Fatalf("expected 404 environment rebind without gateway, got %d: %s", response.Code, response.Body.String())
}
auditDB, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = auditDB.Close() })
// 注册唯一网关后:补建成功,alias=账号 ID、出口直连、seed 派生
if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil {
t.Fatal(err)
}
response := do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/environment", "")
if response.Code != http.StatusOK {
t.Fatalf("expected 200 environment rebind, got %d: %s", response.Code, response.Body.String())
}
var bound struct {
Alias string `json:"alias"`
Gateway string `json:"gateway"`
Created bool `json:"created"`
}
if err := json.Unmarshal(response.Body.Bytes(), &bound); err != nil || bound.Alias != accountID || bound.Gateway != "gw-main" || !bound.Created {
t.Fatalf("rebind payload: %s err=%v", response.Body.String(), err)
}
var accountRowID int64
if err := auditDB.QueryRowContext(ctx, `SELECT id FROM social_account WHERE account_id = $1`, accountID).Scan(&accountRowID); err != nil {
t.Fatal(err)
}
environment, err := hubStore.GetEnvironmentContext(ctx, accountID)
if err != nil || environment.Fingerprint.Seed != accountRowID+1000 || environment.Exit.ID != "" {
t.Fatalf("auto-bound environment: %#v err=%v (account row id %d)", environment, err, accountRowID)
}
// 幂等:重复补建返回既有环境
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/environment", "")
var rebound struct {
Created bool `json:"created"`
}
if err := json.Unmarshal(response.Body.Bytes(), &rebound); err != nil || response.Code != http.StatusOK || rebound.Created {
t.Fatalf("rebind must be idempotent: %d %s err=%v", response.Code, response.Body.String(), err)
}
// start = resume + 启动环境:激活 runtime 并落审计对
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/start", "")
if response.Code != http.StatusNoContent {
t.Fatalf("expected 204 start, got %d: %s", response.Code, response.Body.String())
}
environment, err = hubStore.GetEnvironmentContext(ctx, accountID)
if err != nil || environment.RuntimeID == "" {
t.Fatalf("start must activate the environment runtime: %#v err=%v", environment, err)
}
var startAudits int
if err := auditDB.QueryRowContext(ctx,
`SELECT count(*) FROM audit_event a JOIN social_account sa ON sa.id = a.account_id WHERE sa.account_id = $1 AND action = 'start' AND reason_code IN ('action_requested','environment_started')`, accountID).Scan(&startAudits); err != nil || startAudits != 2 {
t.Fatalf("start audit pair missing: rows=%d err=%v", startAudits, err)
}
// 运行中的账号再次启动只核对已有运行实例,不创建新实例或报 runtime_active。
runtimeID := environment.RuntimeID
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/start", "")
if response.Code != http.StatusNoContent {
t.Fatalf("running account start must be idempotent: %d %s", response.Code, response.Body.String())
}
environment, err = hubStore.GetEnvironmentContext(ctx, accountID)
if err != nil || environment.RuntimeID != runtimeID {
t.Fatalf("repeated start replaced the running runtime: %#v err=%v", environment, err)
}
}
func TestAccountEnvironmentDeletionLifecycle(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
accountStore, err := accountdomain.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = accountStore.Close() })
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
gateway := &fakeGateway{token: "unit-test-gateway-token"}
gatewayServer := httptest.NewServer(gateway.handler(t))
t.Cleanup(gatewayServer.Close)
creatorStore, err := creator.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = creatorStore.Close() })
app := fiber.New()
RegisterAccountRoutes(app, accountStore, hubStore, &testCredentialBridge{values: map[string]string{}})
RegisterAccountDeletion(app, accountStore, hubStore, creatorStore, &testCredentialBridge{values: map[string]string{}})
if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil {
t.Fatal(err)
}
// 删除不存在的账号 → 404
if response := do(app, http.MethodDelete, "/api/phase-a/accounts/account-missing0000000000000", ""); response.Code != http.StatusNotFound {
t.Fatalf("expected 404 deleting missing account, got %d: %s", response.Code, response.Body.String())
}
accountID := maintenanceAccountFixture(t, ctx, accountStore)
if _, _, err := ensureAccountEnvironment(ctx, hubStore, accountID, hub.Fingerprint{}); err != nil {
t.Fatal(err)
}
if _, err := hubStore.GetEnvironmentContext(ctx, accountID); err != nil {
t.Fatalf("auto-bound environment missing: %v", err)
}
// 删除账号 → 204;账号、环境与审计全部清除
if response := do(app, http.MethodDelete, "/api/phase-a/accounts/"+accountID, ""); response.Code != http.StatusNoContent {
t.Fatalf("expected 204 delete account, got %d: %s", response.Code, response.Body.String())
}
if _, err := accountStore.GetAccount(ctx, accountID); err == nil {
t.Fatal("account row must be gone after delete")
}
if _, err := hubStore.GetEnvironmentContext(ctx, accountID); err == nil {
t.Fatal("environment must be gone after delete")
}
auditDB, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = auditDB.Close() })
var auditCount int
if err := auditDB.QueryRowContext(ctx, `
SELECT count(*) FROM audit_event audit
JOIN social_account account ON account.id = audit.account_id
WHERE account.account_id = $1`, accountID).Scan(&auditCount); err != nil {
t.Fatal(err)
}
if auditCount != 0 {
t.Fatalf("account audit events must be purged with the account: rows=%d", auditCount)
}
}
// TestAccountAuditEndpointFilters 驱动 GET /api/phase-a/audit 的过滤参数解析(auditFilter)。
func TestAccountAuditEndpointFilters(t *testing.T) {
databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL")
if databaseURL == "" {
t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage")
}
ctx := context.Background()
databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL)
accountStore, err := accountdomain.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = accountStore.Close() })
// hub.Open 负责把迁移链铺进隔离 schema(account.Open 不建表)。
hubStore, err := hub.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = hubStore.Close() })
app := fiber.New()
RegisterAccountRoutes(app, accountStore, nil, &testCredentialBridge{values: map[string]string{}})
auditDB, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = auditDB.Close() })
if _, err := auditDB.ExecContext(ctx, `
INSERT INTO gateway (name, endpoint, token) VALUES ('gw-1', 'http://gw-1:8081', 'unit-test-gateway-token');
INSERT INTO social_account (account_id, credential_provider, credential_key, name, platform, platform_account_key, status)
VALUES ('account-audit-0000000000000000', 'os_keyring', 'creatorhub/account-audit-0000000000000000', '审计账号', 'douyin', 'key-audit', 'paused');
INSERT INTO audit_event (event_type, account_id, actor, reason_code, details)
SELECT 'account_created', account.id, 'local-user', 'account_created', '{"platform":"douyin"}'
FROM social_account account WHERE account.account_id = 'account-audit-0000000000000000'`); err != nil {
t.Fatal(err)
}
response := do(app, http.MethodGet, "/api/phase-a/audit?account_id=account-audit-0000000000000000&page=1&page_size=10", "")
if response.Code != http.StatusOK {
t.Fatalf("audit query: %d %s", response.Code, response.Body.String())
}
var page struct {
Data []map[string]any `json:"data"`
Total int `json:"total"`
}
if err := json.Unmarshal(response.Body.Bytes(), &page); err != nil || page.Total != 1 || len(page.Data) != 1 {
t.Fatalf("audit page: total=%d data=%d err=%v", page.Total, len(page.Data), err)
}
if page.Data[0]["event_type"] != "account_created" || page.Data[0]["account_id"] != "account-audit-0000000000000000" {
t.Fatalf("audit event fields: %#v", page.Data[0])
}
for _, query := range []string{
"/api/phase-a/audit?page=0",
"/api/phase-a/audit?page_size=1001",
"/api/phase-a/audit?from=not-a-time",
"/api/phase-a/audit?event_type=INVALID%20EVENT",
} {
if response := do(app, http.MethodGet, query, ""); response.Code != http.StatusBadRequest {
t.Fatalf("invalid filter %q must 400: %d %s", query, response.Code, response.Body.String())
}
}
if response := do(app, http.MethodGet, "/api/phase-a/audit?from=2026-01-01T00:00:00Z&to=2027-01-01T00:00:00Z", ""); response.Code != http.StatusOK {
t.Fatalf("time-bounded audit query: %d %s", response.Code, response.Body.String())
}
}