- 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,路由矩阵登记新端点
141 lines
6.2 KiB
Go
141 lines
6.2 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"
|
||
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)
|
||
}
|
||
}
|