package account import ( "context" "database/sql" "errors" "io" "os" "path/filepath" "strings" "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 resetAccountTestFixtures(t *testing.T, store *Store) { t.Helper() var schema string if err := store.db.QueryRow(`SELECT current_schema()`).Scan(&schema); err != nil { t.Fatal(err) } if !strings.HasPrefix(schema, "account_test_") { t.Fatalf("refusing to clear non-test schema %q", schema) } if _, err := store.db.Exec(`TRUNCATE audit_event, network_exit, social_account, browser_env, gateway RESTART IDENTITY CASCADE`); 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) resetAccountTestFixtures(t, store) 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) resetAccountTestFixtures(t, store) 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() }) resetAccountTestFixtures(t, store) 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) }