207 lines
8.9 KiB
Go
207 lines
8.9 KiB
Go
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{}})
|
|
|
|
// 网关缺失:创建账号即绑定失败 → 503 environment_binding_failed,但账号已存在(可补建重试)
|
|
response := do(app, http.MethodPost, "/api/phase-a/accounts",
|
|
`{"name":"测试账号","platform":"douyin","platform_account_key":"key-binding-1","cookies":"sessionid=1"}`)
|
|
if response.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("expected 503 when no gateway exists, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
var failure map[string]string
|
|
if err := json.Unmarshal(response.Body.Bytes(), &failure); err != nil || failure["reason_code"] != "environment_binding_failed" || failure["account_id"] == "" {
|
|
t.Fatalf("binding failure payload: %s err=%v", response.Body.String(), err)
|
|
}
|
|
accountID := failure["account_id"]
|
|
if _, err := accountStore.GetAccount(ctx, accountID); err != nil {
|
|
t.Fatalf("account must exist after failed binding: %v", err)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// start 冲突分支:吊销账号后 start → 409 readiness blocked
|
|
if response := do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/revoke", ""); response.Code != http.StatusNoContent {
|
|
t.Fatalf("revoke failed: %d: %s", response.Code, response.Body.String())
|
|
}
|
|
response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/start", "")
|
|
if response.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409 start on revoked account, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
var conflict map[string]string
|
|
if err := json.Unmarshal(response.Body.Bytes(), &conflict); err != nil || conflict["reason_code"] != "account_revoked" || conflict["readiness"] != "blocked" {
|
|
t.Fatalf("start conflict payload: %s err=%v", response.Body.String(), 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())
|
|
}
|
|
|
|
// 创建即绑定 → 审计与浏览器环境随账号生成
|
|
response := do(app, http.MethodPost, "/api/phase-a/accounts",
|
|
`{"name":"待删账号","platform":"douyin","platform_account_key":"key-delete-1","cookies":"sessionid=1"}`)
|
|
if response.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201 create account, got %d: %s", response.Code, response.Body.String())
|
|
}
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil || created.ID == "" {
|
|
t.Fatalf("create payload: %s err=%v", response.Body.String(), err)
|
|
}
|
|
accountID := created.ID
|
|
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)
|
|
}
|
|
}
|