douyin-release-gate / verify (push) Failing after 3m43s
产品已收敛为自有账号管理,撤销授权在 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 删除
364 lines
15 KiB
Go
364 lines
15 KiB
Go
package account
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type testCredentialBridge struct {
|
|
values map[string]string
|
|
storeErr error
|
|
}
|
|
|
|
func (bridge *testCredentialBridge) Store(ctx context.Context, _ CredentialReference, key, value string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
bridge.values[key] = value
|
|
return bridge.storeErr
|
|
}
|
|
|
|
func TestCreateAccountCompensatesPartialCredentialStore(t *testing.T) {
|
|
credentials := &testCredentialBridge{values: map[string]string{}, storeErr: errors.New("provider failed after write")}
|
|
account := Account{
|
|
ID: "account-partial-store", Name: "Partial Store", Platform: "douyin", PlatformAccountKey: "partial-store",
|
|
Tags: []string{}, Cookies: "sessionid=value",
|
|
CredentialReference: CredentialReference{ID: "account-partial-store-cookies", Provider: "os_keyring"},
|
|
CredentialKey: "creatorhub/account-partial-store/cookies",
|
|
}
|
|
if err := (&Store{}).CreateAccount(context.Background(), account, credentials); err == nil {
|
|
t.Fatal("partial credential store returned success")
|
|
}
|
|
if _, ok := credentials.values[account.CredentialKey]; ok {
|
|
t.Fatal("partial credential store left an orphan")
|
|
}
|
|
}
|
|
|
|
func (bridge *testCredentialBridge) Delete(ctx context.Context, _ CredentialReference, key string) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
delete(bridge.values, key)
|
|
return nil
|
|
}
|
|
|
|
func TestNewAccountIDIsStoreValidAndUnique(t *testing.T) {
|
|
first, second := NewAccountID(), NewAccountID()
|
|
if first == second || !idPattern.MatchString(first) || !idPattern.MatchString(second) {
|
|
t.Fatalf("invalid generated account ids: %q %q", first, second)
|
|
}
|
|
}
|
|
|
|
func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
|
|
store := &Store{}
|
|
valid := Account{
|
|
ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "platform-a",
|
|
Tags: []string{"主账号"}, Cookies: "sessionid=value; token=second",
|
|
CredentialReference: CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"},
|
|
CredentialKey: "creatorhub/account-a/cookies",
|
|
}
|
|
credentials := &testCredentialBridge{values: map[string]string{}}
|
|
for _, platform := range []string{"douyin"} {
|
|
account := valid
|
|
account.Platform = platform
|
|
if !validAccount(account) {
|
|
t.Fatalf("supported platform rejected: %s", platform)
|
|
}
|
|
}
|
|
empty := valid
|
|
empty.Cookies = ""
|
|
if !validAccount(empty) {
|
|
t.Fatal("empty cookies must stay valid (scan-to-login account)")
|
|
}
|
|
for name, mutate := range map[string]func(*Account){
|
|
"id": func(account *Account) { account.ID = "INVALID" },
|
|
"name": func(account *Account) { account.Name = " " },
|
|
"platform": func(account *Account) { account.Platform = "mock" },
|
|
"platform account key": func(account *Account) { account.PlatformAccountKey = "secret value" },
|
|
"tag": func(account *Account) { account.Tags = []string{""} },
|
|
"cookie header": func(account *Account) { account.Cookies = "sessionid" },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
account := valid
|
|
mutate(&account)
|
|
if err := store.CreateAccount(context.Background(), account, credentials); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("expected invalid account, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) {
|
|
t.Helper()
|
|
for _, migrationFile := range []struct {
|
|
version int
|
|
name string
|
|
}{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"},
|
|
{6, "006_runtime_cleanup.sql"}, {7, "007_runtime_binding_version.sql"}, {8, "008_runtime_cleanup_generation.sql"},
|
|
{9, "009_runtime_cleanup_compatibility.sql"}, {10, "010_runtime_network_generation.sql"}, {11, "011_task_recovery.sql"},
|
|
{12, "012_task_recovery_compatibility.sql"}, {13, "013_account_creation.sql"}, {14, "014_account_creation_compatibility.sql"},
|
|
{15, "015_gateway_rename_cascade.sql"}, {16, "016_network_exit_plain_credentials.sql"}, {17, "017_native_browser_versions.sql"},
|
|
{33, "033_unique_fingerprint_seed.sql"}, {34, "034_gateway_browser_default.sql"}} {
|
|
var applied bool
|
|
if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if applied {
|
|
continue
|
|
}
|
|
migration, err := os.ReadFile(filepath.Join("..", "environment", "migrations", migrationFile.name))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.db.Exec(string(migration)); err != nil {
|
|
t.Fatalf("apply test migration %d: %v", migrationFile.version, err)
|
|
}
|
|
if _, err := store.db.Exec(`INSERT INTO schema_migration (version) VALUES ($1)`, migrationFile.version); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateAccountWithoutCookiesSkipsCredentialStore(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()
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
applyHubMigrationsForPhaseATest(t, store)
|
|
if _, err := store.db.ExecContext(ctx, `
|
|
TRUNCATE audit_event, network_exit, social_account, browser_env,
|
|
gateway RESTART IDENTITY CASCADE`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credentials := &testCredentialBridge{values: map[string]string{}}
|
|
account := Account{ID: "account-no-cookies", Name: "扫码账号", Platform: "douyin", PlatformAccountKey: "qr-login",
|
|
Tags: []string{}, Cookies: "",
|
|
CredentialReference: CredentialReference{ID: "account-no-cookies-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-no-cookies/cookies"}
|
|
if err := store.CreateAccount(ctx, account, credentials); err != nil {
|
|
t.Fatalf("creating an account without cookies failed: %v", err)
|
|
}
|
|
if _, stored := credentials.values[account.CredentialKey]; stored {
|
|
t.Fatal("empty cookies must not be written to the credential provider")
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM social_account WHERE account_id = $1`, 1, account.ID)
|
|
assertCount(t, store, `SELECT count(*) FROM social_account WHERE account_id = $1 AND credential_provider = $2 AND credential_key = $3`, 1, account.ID, account.CredentialReference.Provider, account.CredentialKey)
|
|
}
|
|
|
|
func TestAccountCredentialCommitResult(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()
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
applyHubMigrationsForPhaseATest(t, store)
|
|
if _, err := store.db.ExecContext(ctx, `
|
|
TRUNCATE audit_event, network_exit, social_account, browser_env,
|
|
gateway RESTART IDENTITY CASCADE`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credentials := &testCredentialBridge{values: map[string]string{}}
|
|
account := func(id, platformKey string) Account {
|
|
return Account{ID: id, Name: id, Platform: "douyin", PlatformAccountKey: platformKey, Tags: []string{}, Cookies: "sessionid=" + id,
|
|
CredentialReference: CredentialReference{ID: id + "-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/" + id + "/cookies"}
|
|
}
|
|
|
|
store.accountCommit = func(tx *sql.Tx) error {
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
committed := account("account-committed", "platform-committed")
|
|
if err := store.CreateAccount(ctx, committed, credentials); !errors.Is(err, ErrAccountCreationUnknown) {
|
|
t.Fatalf("ambiguous commit did not return unknown: %v", err)
|
|
}
|
|
if credentials.values[committed.CredentialKey] == "" {
|
|
t.Fatal("committed unknown result deleted its credential")
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM social_account WHERE account_id = $1`, 1, committed.ID)
|
|
|
|
store.accountCommit = func(tx *sql.Tx) error {
|
|
_ = tx.Rollback()
|
|
return pgx.ErrTxCommitRollback
|
|
}
|
|
rolledBack := account("account-rolled-back", "platform-rolled-back")
|
|
if err := store.CreateAccount(ctx, rolledBack, credentials); err == nil || errors.Is(err, ErrAccountCreationUnknown) {
|
|
t.Fatalf("known rollback did not return a known failure: %v", err)
|
|
}
|
|
if _, ok := credentials.values[rolledBack.CredentialKey]; ok {
|
|
t.Fatal("known rollback retained its credential")
|
|
}
|
|
|
|
store.accountCommit = func(tx *sql.Tx) error {
|
|
_ = tx.Rollback()
|
|
return io.ErrUnexpectedEOF
|
|
}
|
|
ambiguousRollback := account("account-ambiguous", "platform-ambiguous")
|
|
if err := store.CreateAccount(ctx, ambiguousRollback, credentials); !errors.Is(err, ErrAccountCreationUnknown) {
|
|
t.Fatalf("transport error did not preserve an unknown result: %v", err)
|
|
}
|
|
if credentials.values[ambiguousRollback.CredentialKey] == "" {
|
|
t.Fatal("commit-unknown deleted the credential")
|
|
}
|
|
}
|
|
|
|
func assertCount(t *testing.T, store *Store, query string, expected int, args ...any) {
|
|
t.Helper()
|
|
var actual int
|
|
if err := store.db.QueryRowContext(context.Background(), query, args...).Scan(&actual); err != nil || actual != expected {
|
|
t.Fatalf("count mismatch: expected=%d actual=%d err=%v query=%s", expected, actual, err, query)
|
|
}
|
|
}
|
|
|
|
// TestAccountStoreLifecycleAgainstPostgres 覆盖账号域 PG 全链路:
|
|
// 列表/详情/凭据解析、暂停/吊销/恢复(含冲突分支)与审计分页过滤。
|
|
func TestAccountStoreLifecycleAgainstPostgres(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()
|
|
store, err := Open(ctx, databaseURL)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
if _, err := store.db.ExecContext(ctx, `
|
|
TRUNCATE audit_event, network_exit, social_account, browser_env,
|
|
gateway RESTART IDENTITY CASCADE`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
credentials := &testCredentialBridge{values: map[string]string{}}
|
|
mustCreate := func(id, platformKey string) {
|
|
t.Helper()
|
|
account := Account{ID: id, Name: id, Platform: "douyin", PlatformAccountKey: platformKey, Tags: []string{"主账号"},
|
|
Cookies: "sessionid=" + id, CredentialReference: CredentialReference{ID: id + "-cookies", Provider: "os_keyring"},
|
|
CredentialKey: "creatorhub/" + id + "/cookies"}
|
|
if err := store.CreateAccount(ctx, account, credentials); err != nil {
|
|
t.Fatalf("create %s: %v", id, err)
|
|
}
|
|
}
|
|
mustCreate("account-lifecycle-a", "platform-lifecycle-a")
|
|
mustCreate("account-lifecycle-b", "platform-lifecycle-b")
|
|
|
|
accounts, err := store.ListAccounts(ctx)
|
|
if err != nil || len(accounts) != 2 {
|
|
t.Fatalf("list accounts: %d err=%v", len(accounts), err)
|
|
}
|
|
account, err := store.GetAccount(ctx, "account-lifecycle-a")
|
|
if err != nil || account.RuntimeStatus != "paused" || len(account.Tags) != 1 {
|
|
t.Fatalf("get account: %#v err=%v", account, err)
|
|
}
|
|
if _, err := store.GetAccount(ctx, "account-invalid"); !errors.Is(err, ErrInvalid) && !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("invalid id must be rejected: %v", err)
|
|
}
|
|
if _, err := store.GetAccount(ctx, "account-lifecycle-missing"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("missing account must 404: %v", err)
|
|
}
|
|
resolver := resolverFunc(func(_ context.Context, reference CredentialReference, key string) ([]byte, error) {
|
|
if reference.Provider != "os_keyring" || key != "creatorhub/account-lifecycle-a/cookies" {
|
|
return nil, errors.New("unexpected credential lookup")
|
|
}
|
|
return []byte("sessionid=account-lifecycle-a"), nil
|
|
})
|
|
value, err := store.ResolveAccountCredential(ctx, "account-lifecycle-a", resolver)
|
|
if err != nil || string(value) != "sessionid=account-lifecycle-a" {
|
|
t.Fatalf("resolve credential: %q err=%v", value, err)
|
|
}
|
|
if _, err := store.ResolveAccountCredential(ctx, "account-lifecycle-missing", resolverFunc(func(context.Context, CredentialReference, string) ([]byte, error) {
|
|
return nil, nil
|
|
})); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("missing credential must 404: %v", err)
|
|
}
|
|
|
|
// 环境绑定 + 健康出口:resume 的就绪前置
|
|
if _, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO gateway (name, endpoint, token) VALUES ('gw-1', 'http://gw-1:8081', 'unit-test-gateway-token');
|
|
INSERT INTO network_exit (exit_id, protocol, host, port, health_status) VALUES ('exit-1', 'http', 'proxy.example', 8080, 'healthy');
|
|
INSERT INTO browser_env (alias, name, gateway_id, fingerprint, account_id)
|
|
VALUES ('account-lifecycle-a', '账号 A 环境', (SELECT id FROM gateway WHERE name='gw-1'), '{"seed":1}',
|
|
(SELECT id FROM social_account WHERE account_id='account-lifecycle-a'))`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.ResumeAccount(ctx, "account-lifecycle-a"); err != nil {
|
|
t.Fatalf("resume bound account: %v", err)
|
|
}
|
|
if err := store.ResumeAccount(ctx, "account-lifecycle-a"); err != nil {
|
|
t.Fatalf("resume must be idempotent for active accounts: %v", err)
|
|
}
|
|
if err := store.ResumeAccount(ctx, "account-lifecycle-b"); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("resume without binding must conflict: %v", err)
|
|
}
|
|
if err := store.PauseAccount(ctx, "account-lifecycle-a"); err != nil {
|
|
t.Fatalf("pause: %v", err)
|
|
}
|
|
if account, err := store.GetAccount(ctx, "account-lifecycle-a"); err != nil || account.RuntimeStatus != "paused" {
|
|
t.Fatalf("paused account: %#v err=%v", account, err)
|
|
}
|
|
|
|
// 审计:分页 + 过滤 + 非法过滤参数
|
|
page, err := store.ListAudit(ctx, AuditFilter{AccountID: "account-lifecycle-a", Page: 1, PageSize: 10})
|
|
if err != nil || page.Total < 2 {
|
|
t.Fatalf("audit page: total=%d err=%v", page.Total, err)
|
|
}
|
|
page, err = store.ListAudit(ctx, AuditFilter{BrowserEnvAlias: "account-lifecycle-a", Page: 1, PageSize: 10})
|
|
if err != nil || page.Total != 2 {
|
|
t.Fatalf("audit alias filter must match account-bound environment events: total=%d err=%v", page.Total, err)
|
|
}
|
|
if _, err := store.ListAudit(ctx, AuditFilter{Page: 0, PageSize: 10}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("invalid audit filter must be rejected: %v", err)
|
|
}
|
|
|
|
// 删除链路:活跃 runtime 阻塞 → 清数据 → 删账号
|
|
if err := store.CheckAccountDeletion(ctx, "account-lifecycle-a"); err != nil {
|
|
t.Fatalf("idle environment must not block deletion: %v", err)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `UPDATE browser_env SET runtime_id='runtime-live' WHERE alias='account-lifecycle-a'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.CheckAccountDeletion(ctx, "account-lifecycle-a"); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("active runtime must block deletion: %v", err)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `UPDATE browser_env SET runtime_id=NULL WHERE alias='account-lifecycle-a'`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.DeleteAccountData(ctx, "account-lifecycle-a"); err != nil {
|
|
t.Fatalf("delete account data: %v", err)
|
|
}
|
|
if err := store.DeleteAccount(ctx, "account-lifecycle-a", credentials); err != nil {
|
|
t.Fatalf("delete account: %v", err)
|
|
}
|
|
if _, err := store.GetAccount(ctx, "account-lifecycle-a"); !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("deleted account must 404: %v", err)
|
|
}
|
|
if _, stored := credentials.values["creatorhub/account-lifecycle-a/cookies"]; stored {
|
|
t.Fatal("account deletion must remove its credential")
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `SELECT 1 FROM audit_event LIMIT 1`); err != nil {
|
|
t.Fatalf("other accounts audit must survive: %v", err)
|
|
}
|
|
}
|
|
|
|
type resolverFunc func(context.Context, CredentialReference, string) ([]byte, error)
|
|
|
|
func (fn resolverFunc) Resolve(ctx context.Context, reference CredentialReference, key string) ([]byte, error) {
|
|
return fn(ctx, reference, key)
|
|
}
|