package console import ( "context" "database/sql" "errors" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) func init() { // Package tests run on hosts without root's enrollment file. sqlInstanceIdentity = func() (string, error) { return "i-console-test", nil } } // Fix round 1, item 2: a public transaction the batch callback leaves open // must abort the batch, not vanish into the forced cleanup rollback. func TestPlan9Fix1BatchRefusesTransactionLeftOpen(t *testing.T) { s := openGatedStore(t) seedGateApp(t, s) r := receiptFixture(t, s) err := preparedBatch(t, s, &r, func(v *Store) error { tx, err := v.db.BeginTx(context.Background(), nil) if err != nil { return err } _, err = tx.Exec(`UPDATE audit SET action='left-open' WHERE account_id=7 AND app='blog'`) return err }) if err == nil { t.Fatal("batch reported success after losing an uncommitted transaction") } var n int if qerr := s.rawBoot.QueryRow(`SELECT count(*) FROM claim_sql_receipts WHERE effect=? AND state='committed'`, r.Effect).Scan(&n); qerr != nil || n != 0 { t.Fatalf("receipt committed for an aborted batch: n=%d err=%v", n, qerr) } if countWhere(t, s, "audit", "action='left-open'") != 0 { t.Fatal("uncommitted write landed") } // A transaction the callback finishes itself still commits normally. r2 := r r2.Effect = "ingest-2" if err = preparedBatch(t, s, &r2, func(v *Store) error { tx, err := v.db.Begin() if err != nil { return err } if _, err = tx.Exec(`UPDATE audit SET action='closed' WHERE account_id=7 AND app='blog'`); err != nil { tx.Rollback() return err } return tx.Commit() }); err != nil { t.Fatal(err) } } // I-3: a cloned console (same disk image, different EC2 instance) must not // reuse the original's SQL receipt database identity. Since fix round 2 // (I2-2) the clone opens under an identity of its own rather than refusing. func TestPlan9Fix1SQLIdentityRefusesClonedInstance(t *testing.T) { path := filepath.Join(t.TempDir(), "console.db") restore := UseSQLInstanceIdentityForTest("i-original") s, err := OpenGated(path) if err != nil { restore() t.Fatal(err) } id, err := s.sqlDatabaseID() s.Close() restore() if err != nil { t.Fatal(err) } if !strings.Contains(id, "i-original") { t.Fatalf("database identity %q does not bind the instance", id) } restore = UseSQLInstanceIdentityForTest("i-original") again, err := OpenGated(path) restore() if err != nil { t.Fatalf("original instance refused its own database: %v", err) } id2, err := again.sqlDatabaseID() again.Close() if err != nil || id2 != id { t.Fatalf("identity changed across reopen: %q %q %v", id, id2, err) } restore = UseSQLInstanceIdentityForTest("i-clone") defer restore() clone, err := OpenGated(path) if err != nil { t.Fatalf("clone: %v", err) } defer clone.Close() if cid, err := clone.sqlDatabaseID(); err != nil || cid == id { t.Fatalf("cloned instance reused the original's receipt database identity: %q %v", cid, err) } } func TestPlan9Fix1SQLIdentityRefusesMissingInstance(t *testing.T) { restore := useSQLInstanceIdentity(func() (string, error) { return "", errors.New("no host-actor.json") }) defer restore() if s, err := OpenGated(filepath.Join(t.TempDir(), "console.db")); err == nil { s.Close() t.Fatal("gated DB opened without a per-instance identity") } } // I-4: SQL effects bound to a replaced receipt database surface as errors and // an operator alert once past sql_recover_after; they are never settled. func TestPlan9Fix1ReplacedDatabaseEffectsAlert(t *testing.T) { raw := release.NewDirStore(t.TempDir()) w := claimTestWriter(t, raw) reg := hostops.Registration{Kind: hostops.RegistrationIngest, Owner: claimTestOwner, AppID: "app", Epoch: 1, Expires: time.Now().Add(time.Hour)} past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339Nano) future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) effect := func(id, after string) hostops.Effect { return hostops.Effect{ID: id, OperationID: id, AppID: "app", Resource: "app-sql:" + id, Action: "sql-ingest", Owner: claimTestOwner, State: "uncertain", Inputs: map[string]string{"sql_database": "restored-away", "sql_recover_after": after}} } seedClaimAdmission(t, raw, "bab", "demo", hostops.AppAdmission{V: 1, AppID: "app", Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{"old": reg, "young": reg}, Effects: map[string]hostops.Effect{"old": effect("old", past), "young": effect("young", future)}}) st := openGatedStore(t) s := &Server{store: st, cfg: Config{HostClaimOps: &hostops.Controller{Store: raw, Control: w, Now: time.Now}}} err := s.sweepOneClaimAdmission(t.Context(), hostops.App{Account: "bab", Name: "demo"}) if !errors.Is(err, ErrSQLDatabaseReplaced) { t.Fatalf("replaced-database effect skipped silently: %v", err) } if strings.Contains(err.Error(), "young") { t.Fatalf("effect before sql_recover_after reported: %v", err) } firing, known, aerr := st.AlertSeen(sqlReceiptAlertBox, sqlReplacedRule("bab", "demo", "old"), noMailboxAddress) if aerr != nil || !known || !firing { t.Fatalf("no operator alert recorded: firing=%v known=%v err=%v", firing, known, aerr) } if _, known, _ = st.AlertSeen(sqlReceiptAlertBox, sqlReplacedRule("bab", "demo", "young"), noMailboxAddress); known { t.Fatal("alert raised before sql_recover_after") } a, err := hostops.ReadAdmission(t.Context(), raw, "bab", "demo", "app") if err != nil { t.Fatal(err) } if a.Value.Effects["old"].State != "uncertain" { t.Fatal("replaced-database effect was settled by inference") } } // M13: rows from a statement prepared on the bound view are refused, so a // retained result set can never hold the console's only connection. func TestPlan9Fix1RetainedPreparedRowsCannotPinConnection(t *testing.T) { for _, batch := range []bool{false, true} { s := openGatedStore(t) seedGateApp(t, s) r := receiptFixture(t, s) var receipts []hostops.SQLReceiptScope if batch { receipts = []hostops.SQLReceiptScope{r} } retained := make(chan *sql.Rows, 1) done := make(chan error, 1) go func() { done <- s.withPreparedClaimSQL(t.Context(), []claimSQLScope{gateScope}, allowAllClaimSQL, allowAllClaimSQL, receipts, batch, func(v *Store) error { st, err := v.db.Prepare(`SELECT action FROM audit`) if err != nil { return err } rows, err := st.QueryContext(context.Background()) if err == nil { retained <- rows } return nil }) }() select { case <-done: case <-time.After(3 * time.Second): select { case rows := <-retained: rows.Close() default: } <-done t.Fatalf("batch=%v: retained prepared rows blocked revoke", batch) } if _, err := s.db.Exec(`SELECT 1`); err != nil { t.Fatalf("batch=%v: pool unusable after scope: %v", batch, err) } } } // M13: a nested WithAppMutation is refused before any remote preparation. func TestPlan9Fix1NestedAppMutationRefusedBeforeRemote(t *testing.T) { s := openGatedStore(t) seedGateApp(t, s) calls := 0 g := sqlGrant{scope: gateScope, kind: grantEffect, check: func(context.Context) error { calls++; return nil }} err := s.withClaimSQL(t.Context(), []claimSQLScope{gateScope}, allowAllClaimSQL, func(v *Store) error { return v.WithAppMutation(t.Context(), []sqlGrant{g}, func(*Store) error { return nil }) }) if !errors.Is(err, ErrClaimSQLScope) { t.Fatalf("nested mutation: %v", err) } if calls != 0 { t.Fatalf("nested mutation ran %d remote checks before refusing", calls) } } func sqlIngestFixture(t *testing.T) (*Store, release.Store, release.Store) { t.Helper() s := openGatedStore(t) seedGateApp(t, s) if _, err := s.db.Exec(`UPDATE accounts SET sqid='bab' WHERE id=7`); err != nil { t.Fatal(err) } raw := release.NewDirStore(t.TempDir()) w := claimTestWriter(t, raw) seedClaimAdmission(t, raw, "bab", "blog", hostops.AppAdmission{V: 1, AppID: gateScope.AppID, Revision: 1, Epoch: 1, State: "open", Registrations: map[string]hostops.Registration{}, Effects: map[string]hostops.Effect{}}) bucket := claimTestBoundary(t, raw, w) if err := s.bindClaimSQLMutations(bucket); err != nil { t.Fatal(err) } return s, raw, bucket } // M13: the callback's error appears once in withAutoAppSQL's result. func TestPlan9Fix1AutoSQLErrorNotDuplicated(t *testing.T) { s, _, _ := sqlIngestFixture(t) marker := errors.New("unique-callback-failure") err := s.withAutoAppSQL(t.Context(), gateAcct, "blog", "test", func(*Store) error { return marker }) if !errors.Is(err, marker) { t.Fatalf("callback error lost: %v", err) } if n := strings.Count(err.Error(), marker.Error()); n != 1 { t.Fatalf("callback error appears %d times: %v", n, err) } } // M13: a refusal before the SQL connection is bound is proven by the local // fence at once, so the ingest effect does not wait out its registration. func TestPlan9Fix1PreBindRefusalSettlesIngest(t *testing.T) { s, raw, bucket := sqlIngestFixture(t) runner := bucket.(hostops.SQLIngestRunner) database, err := s.sqlDatabaseID() if err != nil { t.Fatal(err) } refused := errors.New("second grant refused") called := false err = runner.RunSQLIngest(t.Context(), "bab", "blog", "test", database, func(pctx context.Context) error { bad := sqlGrant{scope: gateScope, kind: grantEffect, check: func(context.Context) error { return refused }} return s.WithAppMutation(pctx, []sqlGrant{permitGrant(pctx, bucket, "bab", gateScope), bad}, func(*Store) error { called = true return nil }) }) if !errors.Is(err, refused) || called { t.Fatalf("pre-bind refusal: called=%v err=%v", called, err) } a, err := hostops.ReadCanonicalAdmission(t.Context(), raw, "bab", "blog") if err != nil { t.Fatal(err) } if len(a.Value.Registrations) != 0 { t.Fatalf("ingest registration left to expire: %+v", a.Value.Registrations) } for id, e := range a.Value.Effects { if e.State != "complete" { t.Fatalf("effect %s left %s after a proven refusal", id, e.State) } } } // M13: recovery that finds the original commit records it as committed. func TestPlan9Fix1FenceReportsCommittedReceipt(t *testing.T) { s := openGatedStore(t) seedGateApp(t, s) r := receiptFixture(t, s) if err := preparedBatch(t, s, &r, func(v *Store) error { _, err := v.db.Exec(`UPDATE audit SET action='done' WHERE account_id=7 AND app='blog'`) return err }); err != nil { t.Fatal(err) } committed, err := s.FenceSQLOutcome(t.Context(), r) if err != nil || !committed { t.Fatalf("committed receipt: committed=%v err=%v", committed, err) } other := r other.Effect = "never-ran" committed, err = s.FenceSQLOutcome(t.Context(), other) if err != nil || committed { t.Fatalf("fresh fence: committed=%v err=%v", committed, err) } }