274 lines
12 KiB
Go
274 lines
12 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)
|
||
}
|
||
}
|
||
|
||
// 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, authorization_kind, authorization_status, status)
|
||
VALUES ('account-audit-0000000000000000', 'os_keyring', 'creatorhub/account-audit-0000000000000000', '审计账号', 'douyin', 'key-audit', 'owned', 'authorized', '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())
|
||
}
|
||
}
|