Files
creator-hub/internal/controlplane/api/account_environment_unit_test.go
T
rogee bcc33fe497 feat(accounts): 账号即环境——创建即绑定、幂等补建、start 组合接口
- POST /api/phase-a/accounts 创建成功即绑定环境:alias=账号文本 ID、
  唯一网关自动取(缺失 404/多网关 409)、出口直连、seed 由账号 ID 派生
  (deriveSeed,数字主键落地后改 bigint id+1000);绑定失败 503 透传 account_id
- POST /api/phase-a/accounts/:id/environment 幂等补建:已绑定原样返回
- POST /api/phase-a/accounts/:id/start = resume + 启动环境(环境缺失自愈补建,
  审计对齐全);吊销账号 start → 409 readiness blocked;停止沿用 pause
- 测试:deriveSeed 纯单测(确定性/值域/固定向量)+ PG 集成覆盖
  绑定失败恢复、补建幂等、seed 断言、start 激活与冲突分支;
  RegisterAccountRoutes 参数升级为 HubStore,路由矩阵登记新端点
2026-09-28 20:10:50 +08:00

141 lines
6.2 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"
hub "git.ipao.vip/rogee/creator-hub/internal/environment"
"github.com/gofiber/fiber/v3"
)
func TestDeriveSeed(t *testing.T) {
// 确定性:同 ID 派生同 seed
if deriveSeed("account-0123456789abcdef01234567") != deriveSeed("account-0123456789abcdef01234567") {
t.Fatal("deriveSeed must be deterministic")
}
// 值域:1001..2147483647(seed 上限约束,偏移 1000 对齐未来 bigint id + 1000)
for _, accountID := range []string{"account-a", "account-000000000000000000000000", "account-zzzzzzzzzzzzzzzzzzzzzzzz", ""} {
seed := deriveSeed(accountID)
if seed < 1001 || seed > 2147483647 {
t.Fatalf("seed out of range for %q: %d", accountID, seed)
}
}
// 不同 ID 派生不同 seed(固定向量,防回归)
if deriveSeed("account-a") == deriveSeed("account-b") {
t.Fatal("distinct accounts must derive distinct seeds")
}
if got := deriveSeed("account-a"); got != 1816671480 {
t.Fatalf("deriveSeed vector drifted: %d", got)
}
}
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())
}
// 注册唯一网关后:补建成功,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)
}
environment, err := hubStore.GetEnvironmentContext(ctx, accountID)
if err != nil || environment.Fingerprint.Seed != deriveSeed(accountID) || environment.Exit.ID != "" {
t.Fatalf("auto-bound environment: %#v err=%v", environment, err)
}
// 幂等:重复补建返回既有环境
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)
}
auditDB, err := sql.Open("pgx", databaseURL)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = auditDB.Close() })
var startAudits int
if err := auditDB.QueryRowContext(ctx,
`SELECT count(*) FROM audit_event WHERE 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)
}
}