Files
creator-hub/internal/controlplane/api/account_environment_unit_test.go
T
rogee eab193007c
douyin-release-gate / verify (push) Failing after 3m43s
refactor(accounts): 移除「授权状态」概念——账号收敛为启用/暂停单一状态机
产品已收敛为自有账号管理,撤销授权在 UI 无入口、状态恒为 authorized,属废弃语义:
- migration 1045:social_account DROP authorization_status/revoked_at/authorization_kind
- 删除 revoke API 路由与 RevokeAccount/disableAccount 状态机分支(PauseAccount 独立)
- accountRunnable/就绪判定/采集过滤/登录校验删除 authorization_status 检查
- EnvironmentContext/AccountProfile 契约删字段;前端删「已授权/已撤销」展示与 readiness 分支
- 测试同步:revoke 流程/409 用例删除,seed 语句去列;dev 库测试遗留 revoked 账号待 UI 删除
2026-09-30 14:18:34 +08:00

262 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{}})
// 网关缺失:创建账号即绑定失败 → 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)
}
}
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, 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())
}
}