374 lines
16 KiB
Go
374 lines
16 KiB
Go
package phasea
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) {
|
|
store := &Store{}
|
|
valid := Account{
|
|
ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned",
|
|
CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a",
|
|
}
|
|
for name, mutate := range map[string]func(*Account){
|
|
"id": func(account *Account) { account.ID = "INVALID" },
|
|
"platform": func(account *Account) { account.Platform = "bad platform" },
|
|
"platform account key": func(account *Account) { account.PlatformAccountKey = "secret value" },
|
|
"authorization kind": func(account *Account) { account.AuthorizationKind = "borrowed" },
|
|
"credential provider": func(account *Account) { account.CredentialReference.Provider = "plain_text" },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
account := valid
|
|
mutate(&account)
|
|
if err := store.CreateAccount(context.Background(), account); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("expected invalid account, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
if err := store.CreateAccount(context.Background(), Account{
|
|
ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned",
|
|
CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "raw-value",
|
|
}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("expected non-reference credential value to be rejected, got %v", err)
|
|
}
|
|
if _, _, err := store.Enqueue(context.Background(), Task{ID: "task-a"}); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("expected invalid task, got %v", err)
|
|
}
|
|
if _, err := store.ExecuteMock(context.Background(), "worker-a", "retry"); !errors.Is(err, ErrInvalid) {
|
|
t.Fatalf("expected unsupported outcome to be rejected, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPhaseAOfflineWorkflow(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, execution_attempt, operation_task, confirmation, content_draft,
|
|
runtime_instance, environment_binding, network_exit, social_account, credential_reference,
|
|
browser_env, browser_image, gateway RESTART IDENTITY CASCADE`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
accounts := []Account{
|
|
{ID: "account-a", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned", CredentialReference: CredentialReference{ID: "credential-a", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a"},
|
|
{ID: "account-b", Platform: "mock", PlatformAccountKey: "platform-b", AuthorizationKind: "authorized", CredentialReference: CredentialReference{ID: "credential-b", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-b"},
|
|
}
|
|
for _, account := range accounts {
|
|
if err := store.CreateAccount(ctx, account); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
createdAccount, err := store.GetAccount(ctx, "account-a")
|
|
if err != nil || createdAccount.RuntimeStatus != "paused" || createdAccount.Version != 1 {
|
|
t.Fatalf("new account must start paused: %#v %v", createdAccount, err)
|
|
}
|
|
if err := store.ResumeAccount(ctx, "account-a"); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("account without a healthy binding must not resume: %v", err)
|
|
}
|
|
if err := store.CreateAccount(ctx, Account{
|
|
ID: "account-c", Platform: "mock", PlatformAccountKey: "platform-a", AuthorizationKind: "owned",
|
|
CredentialReference: CredentialReference{ID: "credential-c", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-c",
|
|
}); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected platform accounts to be unique, got %v", err)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO gateway (name, endpoint, token) VALUES ('test-gateway', 'http://127.0.0.1:8081', 'phase-a-test-gateway-token')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO browser_image (version, image_ref) VALUES ('1', 'example/browser:1')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `
|
|
INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) VALUES
|
|
('account-a', 'Account A', 'test-gateway', '1', '{"seed":1}'),
|
|
('account-b', 'Account B', 'test-gateway', '1', '{"seed":2}');
|
|
INSERT INTO network_exit (id, protocol, host, port, health_status)
|
|
VALUES ('exit-shared', 'socks5', '127.0.0.1', 1080, 'healthy');
|
|
INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) VALUES
|
|
('binding-a', 'account-a', 'account-a', 'exit-shared'),
|
|
('binding-b', 'account-b', 'account-b', 'exit-shared')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, id := range []string{"account-a", "account-b"} {
|
|
if err := store.ResumeAccount(ctx, id); err != nil {
|
|
t.Fatalf("resume %s: %v", id, err)
|
|
}
|
|
}
|
|
accountA, err := store.GetAccount(ctx, "account-a")
|
|
if err != nil || accountA.RuntimeStatus != "active" || accountA.Version != 2 {
|
|
t.Fatalf("unexpected resumed account: %#v %v", accountA, err)
|
|
}
|
|
exportedAccount, _ := json.Marshal(accountA)
|
|
if strings.Contains(string(exportedAccount), "creatorhub/account-a") || strings.Contains(string(exportedAccount), "reference_key") {
|
|
t.Fatalf("account response leaked credential key: %s", exportedAccount)
|
|
}
|
|
listedAccounts, err := store.ListAccounts(ctx)
|
|
if err != nil || len(listedAccounts) != 2 {
|
|
t.Fatalf("unexpected account list: %#v %v", listedAccounts, err)
|
|
}
|
|
accountB, err := store.GetAccount(ctx, "account-b")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
lease := time.Now().Add(time.Minute)
|
|
if err := store.BindRuntime(ctx, RuntimeBinding{ID: "binding-a", AccountID: "account-a", RuntimeID: "runtime-a", LeaseUntil: lease}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.BindRuntime(ctx, RuntimeBinding{ID: "binding-a-2", AccountID: "account-a", RuntimeID: "runtime-b", LeaseUntil: lease}); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected one active runtime per account, got %v", err)
|
|
}
|
|
if err := store.BindRuntime(ctx, RuntimeBinding{ID: "binding-b", AccountID: "account-b", RuntimeID: "runtime-a", LeaseUntil: lease}); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("expected one account per runtime, got %v", err)
|
|
}
|
|
if err := store.ReleaseRuntime(ctx, "binding-a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
createApprovedDraft(t, store, "account-a", accountA.Version, "draft-a", "confirmation-a")
|
|
for index := range 20 {
|
|
task := approvedTask(index, "account-a", accountA.Version, "draft-a", "confirmation-a")
|
|
if _, inserted, err := store.Enqueue(ctx, task); err != nil || !inserted {
|
|
t.Fatalf("enqueue task %d: inserted=%v err=%v", index, inserted, err)
|
|
}
|
|
task.ID = fmt.Sprintf("duplicate-%02d", index)
|
|
if existing, inserted, err := store.Enqueue(ctx, task); err != nil || inserted || existing.ID == task.ID {
|
|
t.Fatalf("redeliver task %d: existing=%q inserted=%v err=%v", index, existing.ID, inserted, err)
|
|
}
|
|
}
|
|
|
|
var executed atomic.Int64
|
|
var workers sync.WaitGroup
|
|
errorsFromWorkers := make(chan error, 4)
|
|
for worker := range 4 {
|
|
workers.Add(1)
|
|
go func() {
|
|
defer workers.Done()
|
|
for {
|
|
execution, err := store.ExecuteMock(ctx, fmt.Sprintf("worker-%d", worker), "succeeded")
|
|
if err != nil {
|
|
errorsFromWorkers <- err
|
|
return
|
|
}
|
|
if !execution.WasClaimed {
|
|
return
|
|
}
|
|
executed.Add(1)
|
|
}
|
|
}()
|
|
}
|
|
workers.Wait()
|
|
close(errorsFromWorkers)
|
|
for err := range errorsFromWorkers {
|
|
t.Fatal(err)
|
|
}
|
|
if executed.Load() != 20 {
|
|
t.Fatalf("expected exactly 20 executions, got %d", executed.Load())
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM operation_task WHERE state = 'succeeded'`, 20)
|
|
assertCount(t, store, `SELECT count(*) FROM execution_attempt`, 20)
|
|
|
|
createApprovedDraft(t, store, "account-b", accountB.Version, "draft-b", "confirmation-b")
|
|
unconfirmed := approvedTask(30, "account-b", accountB.Version, "draft-b", "")
|
|
unconfirmed.ConfirmationVersion = 0
|
|
if _, _, err := store.Enqueue(ctx, unconfirmed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
mismatch := approvedTask(31, "account-b", accountB.Version, "draft-b", "confirmation-b")
|
|
mismatch.AccountVersion++
|
|
if _, _, err := store.Enqueue(ctx, mismatch); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if execution, err := store.ExecuteMock(ctx, "worker-validation", "succeeded"); err != nil || execution.WasClaimed {
|
|
t.Fatalf("invalid tasks must not execute: execution=%+v err=%v", execution, err)
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id IN ('task-30', 'task-31') AND state = 'needs_confirmation'`, 2)
|
|
assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id IN ('task-30', 'task-31')`, 0)
|
|
|
|
uncertain := approvedTask(32, "account-b", accountB.Version, "draft-b", "confirmation-b")
|
|
if _, _, err := store.Enqueue(ctx, uncertain); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if execution, err := store.ExecuteMock(ctx, "worker-uncertain", "uncertain"); err != nil || execution.State != "needs_confirmation" {
|
|
t.Fatalf("uncertain outcome was not held: execution=%+v err=%v", execution, err)
|
|
}
|
|
if execution, err := store.ExecuteMock(ctx, "worker-uncertain", "succeeded"); err != nil || execution.WasClaimed {
|
|
t.Fatalf("uncertain task was retried: execution=%+v err=%v", execution, err)
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id = 'task-32'`, 1)
|
|
|
|
policy := approvedTask(33, "account-b", accountB.Version, "draft-b", "confirmation-b")
|
|
if _, _, err := store.Enqueue(ctx, policy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if execution, err := store.ExecuteMock(ctx, "worker-policy", "policy_hold"); err != nil || execution.State != "policy_hold" {
|
|
t.Fatalf("policy outcome was not held: execution=%+v err=%v", execution, err)
|
|
}
|
|
if execution, err := store.ExecuteMock(ctx, "worker-policy", "succeeded"); err != nil || execution.WasClaimed {
|
|
t.Fatalf("policy task was retried: execution=%+v err=%v", execution, err)
|
|
}
|
|
|
|
paused := approvedTask(34, "account-b", accountB.Version, "draft-b", "confirmation-b")
|
|
if _, _, err := store.Enqueue(ctx, paused); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pauseStarted := time.Now()
|
|
if err := store.PauseAccount(ctx, "account-b"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.PauseAccount(ctx, "account-b"); err != nil {
|
|
t.Fatalf("pause should be idempotent: %v", err)
|
|
}
|
|
if execution, err := store.ExecuteMock(ctx, "worker-paused", "succeeded"); err != nil || execution.WasClaimed {
|
|
t.Fatalf("paused account task was claimed: execution=%+v err=%v", execution, err)
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-34' AND state = 'policy_hold'`, 1)
|
|
pausedAccount, err := store.GetAccount(ctx, "account-b")
|
|
if err != nil || pausedAccount.Version != accountB.Version+1 {
|
|
t.Fatalf("idempotent pause must increment once: %#v %v", pausedAccount, err)
|
|
}
|
|
var pausedAt time.Time
|
|
if err := store.db.QueryRowContext(ctx, `SELECT paused_at FROM social_account WHERE id = 'account-b'`).Scan(&pausedAt); err != nil || pausedAt.Sub(pauseStarted) >= time.Minute {
|
|
t.Fatalf("account was not paused within one minute: paused_at=%v err=%v", pausedAt, err)
|
|
}
|
|
|
|
if err := store.RevokeAccount(ctx, "account-b"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.RevokeAccount(ctx, "account-b"); err != nil {
|
|
t.Fatalf("revoke should be idempotent: %v", err)
|
|
}
|
|
revokedAccount, err := store.GetAccount(ctx, "account-b")
|
|
if err != nil || revokedAccount.AuthorizationStatus != "revoked" || revokedAccount.Version != accountB.Version+2 {
|
|
t.Fatalf("unexpected revoked account: %#v %v", revokedAccount, err)
|
|
}
|
|
if err := store.ResumeAccount(ctx, "account-b"); !errors.Is(err, ErrConflict) {
|
|
t.Fatalf("revoked account must not resume: %v", err)
|
|
}
|
|
|
|
cancelled := approvedTask(35, "account-a", accountA.Version, "draft-a", "confirmation-a")
|
|
if _, _, err := store.Enqueue(ctx, cancelled); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.CancelTask(ctx, cancelled.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-35' AND state = 'cancelled'`, 1)
|
|
|
|
expired := approvedTask(36, "account-a", accountA.Version, "draft-a", "confirmation-a")
|
|
if _, _, err := store.Enqueue(ctx, expired); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claimed, err := store.claim(ctx, "worker-crashed")
|
|
if err != nil || !claimed.WasClaimed {
|
|
t.Fatalf("claim task for lease test: execution=%+v err=%v", claimed, err)
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `UPDATE operation_task SET lease_until = now() - interval '1 second' WHERE id = $1`, claimed.TaskID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if execution, err := store.ExecuteMock(ctx, "worker-after-crash", "succeeded"); err != nil || execution.WasClaimed {
|
|
t.Fatalf("expired unknown result was retried: execution=%+v err=%v", execution, err)
|
|
}
|
|
assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-36' AND state = 'needs_confirmation'`, 1)
|
|
assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id = 'task-36' AND outcome = 'uncertain'`, 1)
|
|
|
|
events, err := store.Audit(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
traceable := false
|
|
for _, event := range events {
|
|
if event.EventType == "task_finished" && event.ConfirmationID != "" && event.ConfirmationVersion == 1 && event.AttemptID != "" && event.TaskID != "" {
|
|
traceable = true
|
|
break
|
|
}
|
|
}
|
|
if !traceable {
|
|
t.Fatal("audit does not trace confirmation version, task, and attempt")
|
|
}
|
|
exported, _ := json.Marshal(events)
|
|
for _, forbidden := range []string{"password", "cookie", "token", "credential-a", "creatorhub/account-a"} {
|
|
if strings.Contains(strings.ToLower(string(exported)), forbidden) {
|
|
t.Fatalf("audit export contains sensitive field or credential reference %q", forbidden)
|
|
}
|
|
}
|
|
if _, err := store.db.ExecContext(ctx, `UPDATE audit_event SET event_type = 'rewritten' WHERE id = 1`); err == nil {
|
|
t.Fatal("audit events must be append-only")
|
|
}
|
|
}
|
|
|
|
func createApprovedDraft(t *testing.T, store *Store, accountID string, accountVersion int64, draftID, confirmationID string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if err := store.CreateDraft(ctx, Draft{ID: draftID, AccountID: accountID, Version: 1, Content: "offline mock content"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.Confirm(ctx, Confirmation{
|
|
ID: confirmationID, AccountID: accountID, AccountVersion: accountVersion, DraftID: draftID, DraftVersion: 1, Version: 1,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func approvedTask(index int, accountID string, accountVersion int64, draftID, confirmationID string) Task {
|
|
return Task{
|
|
ID: fmt.Sprintf("task-%02d", index), IdempotencyKey: fmt.Sprintf("idempotency-%02d", index),
|
|
AccountID: accountID, AccountVersion: accountVersion, DraftID: draftID, DraftVersion: 1,
|
|
ConfirmationID: confirmationID, ConfirmationVersion: 1,
|
|
}
|
|
}
|
|
|
|
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"}} {
|
|
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("..", "hub", "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 assertCount(t *testing.T, store *Store, query string, expected int) {
|
|
t.Helper()
|
|
var actual int
|
|
if err := store.db.QueryRowContext(context.Background(), query).Scan(&actual); err != nil || actual != expected {
|
|
t.Fatalf("count mismatch: expected=%d actual=%d err=%v query=%s", expected, actual, err, query)
|
|
}
|
|
}
|