| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "path/filepath" |
| 8 | "sync/atomic" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostops" |
| 13 | ) |
| 14 | |
| 15 | // useSQLInstanceSources runs the production identity reader against stubbed |
| 16 | // enrollment and live-instance sources, so no test touches /etc or the |
| 17 | // network. |
| 18 | func useSQLInstanceSources(t *testing.T, enrolled, dmi, imds string) { |
| 19 | t.Helper() |
| 20 | restore := useSQLInstanceIdentity(readSQLInstanceIdentity) |
| 21 | oldE, oldD, oldI := sqlEnrolledInstance, sqlDMIInstance, sqlIMDSInstance |
| 22 | sqlEnrolledInstance = func() (string, error) { return enrolled, nil } |
| 23 | sqlDMIInstance = func() string { return dmi } |
| 24 | sqlIMDSInstance = func() string { return imds } |
| 25 | t.Cleanup(func() { |
| 26 | sqlEnrolledInstance, sqlDMIInstance, sqlIMDSInstance = oldE, oldD, oldI |
| 27 | restore() |
| 28 | }) |
| 29 | } |
| 30 | |
| 31 | // D1: a disk image carries root's host-actor.json with the original's |
| 32 | // instance ID. Only the live machine's ID can tell a clone apart, so the |
| 33 | // enrollment must match it and a copied file alone opens nothing. |
| 34 | func TestPlan9Fix1SQLIdentityRefusesCopiedEnrollment(t *testing.T) { |
| 35 | path := filepath.Join(t.TempDir(), "console.db") |
| 36 | t.Run("original", func(t *testing.T) { |
| 37 | useSQLInstanceSources(t, "i-0a1b2c3d4e5f60001", "i-0a1b2c3d4e5f60001", "") |
| 38 | s, err := OpenGated(path) |
| 39 | if err != nil { |
| 40 | t.Fatal(err) |
| 41 | } |
| 42 | s.Close() |
| 43 | }) |
| 44 | t.Run("clone", func(t *testing.T) { |
| 45 | useSQLInstanceSources(t, "i-0a1b2c3d4e5f60001", "i-0c10e0c10e0c10e01", "") |
| 46 | if s, err := OpenGated(path); err == nil { |
| 47 | s.Close() |
| 48 | t.Fatal("clone opened the original's receipt database from a copied enrollment") |
| 49 | } else if !errors.Is(err, hostops.ErrIdentity) { |
| 50 | t.Fatalf("clone refusal: %v", err) |
| 51 | } |
| 52 | }) |
| 53 | t.Run("clone via IMDS", func(t *testing.T) { |
| 54 | useSQLInstanceSources(t, "i-0a1b2c3d4e5f60001", "", "i-0c10e0c10e0c10e01") |
| 55 | if s, err := OpenGated(path); err == nil { |
| 56 | s.Close() |
| 57 | t.Fatal("clone opened the original's receipt database (IMDS source)") |
| 58 | } |
| 59 | }) |
| 60 | t.Run("no live instance", func(t *testing.T) { |
| 61 | useSQLInstanceSources(t, "i-0a1b2c3d4e5f60001", "", "") |
| 62 | if s, err := OpenGated(filepath.Join(t.TempDir(), "fresh.db")); err == nil { |
| 63 | s.Close() |
| 64 | t.Fatal("gated DB opened on the enrollment file alone") |
| 65 | } |
| 66 | }) |
| 67 | } |
| 68 | |
| 69 | func TestPlan9Fix1SQLLiveInstanceSources(t *testing.T) { |
| 70 | for _, c := range []struct { |
| 71 | name, dmi, imds, want string |
| 72 | }{ |
| 73 | {"nitro asset tag", "i-0123456789abcdef0", "i-0imds", "i-0123456789abcdef0"}, |
| 74 | {"non-instance asset tag falls back", "Default string", "i-0imds", "i-0imds"}, |
| 75 | {"nothing", "", "", ""}, |
| 76 | } { |
| 77 | t.Run(c.name, func(t *testing.T) { |
| 78 | useSQLInstanceSources(t, "unused", c.dmi, c.imds) |
| 79 | if got := sqlLiveInstance(); got != c.want { |
| 80 | t.Fatalf("live instance = %q, want %q", got, c.want) |
| 81 | } |
| 82 | }) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // L4: a public transaction whose own context ends is rolled back by |
| 87 | // database/sql behind the callback's back. If the callback then ignores the |
| 88 | // failed Commit and returns nil, the batch must still abort: its receipt |
| 89 | // would otherwise claim writes that were never made. |
| 90 | func TestPlan9Fix1BatchRefusesContextRolledBackTransaction(t *testing.T) { |
| 91 | s := openGatedStore(t) |
| 92 | seedGateApp(t, s) |
| 93 | r := receiptFixture(t, s) |
| 94 | err := preparedBatch(t, s, &r, func(v *Store) error { |
| 95 | ctx, cancel := context.WithCancel(context.Background()) |
| 96 | defer cancel() |
| 97 | tx, err := v.db.BeginTx(ctx, nil) |
| 98 | if err != nil { |
| 99 | return err |
| 100 | } |
| 101 | if _, err = tx.Exec(`UPDATE audit SET action='ctx-lost' WHERE account_id=7 AND app='blog'`); err != nil { |
| 102 | return err |
| 103 | } |
| 104 | cancel() |
| 105 | // Wait until database/sql's own rollback reached the driver: the |
| 106 | // write is gone from this connection's view. |
| 107 | deadline := time.Now().Add(10 * time.Second) |
| 108 | for { |
| 109 | var n int |
| 110 | if err := v.db.QueryRow(`SELECT count(*) FROM audit WHERE action='ctx-lost'`).Scan(&n); err != nil { |
| 111 | return err |
| 112 | } |
| 113 | if n == 0 { |
| 114 | break |
| 115 | } |
| 116 | if time.Now().After(deadline) { |
| 117 | return errors.New("database/sql never rolled the transaction back") |
| 118 | } |
| 119 | time.Sleep(5 * time.Millisecond) |
| 120 | } |
| 121 | _ = tx.Commit() // the misuse under test: the failure is ignored |
| 122 | return nil |
| 123 | }) |
| 124 | if err == nil { |
| 125 | t.Fatal("batch reported success after its transaction was rolled back by its context") |
| 126 | } |
| 127 | var n int |
| 128 | if qerr := s.rawBoot.QueryRow(`SELECT count(*) FROM claim_sql_receipts WHERE effect=? AND state='committed'`, r.Effect).Scan(&n); qerr != nil || n != 0 { |
| 129 | t.Fatalf("receipt committed for lost writes: n=%d err=%v", n, qerr) |
| 130 | } |
| 131 | |
| 132 | // An explicit rollback under a live context is the callback's own choice |
| 133 | // and still lets the batch commit. |
| 134 | r2 := r |
| 135 | r2.Effect = "ingest-explicit-rollback" |
| 136 | if err = preparedBatch(t, s, &r2, func(v *Store) error { |
| 137 | tx, err := v.db.BeginTx(context.Background(), nil) |
| 138 | if err != nil { |
| 139 | return err |
| 140 | } |
| 141 | if _, err = tx.Exec(`UPDATE audit SET action='rolled' WHERE account_id=7 AND app='blog'`); err != nil { |
| 142 | tx.Rollback() |
| 143 | return err |
| 144 | } |
| 145 | return tx.Rollback() |
| 146 | }); err != nil { |
| 147 | t.Fatalf("explicit rollback aborted the batch: %v", err) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // L4, unbatched: the same lost transaction must not let withClaimSQL report |
| 152 | // success either. |
| 153 | func TestPlan9Fix1ScopeRefusesContextRolledBackTransaction(t *testing.T) { |
| 154 | s := openGatedStore(t) |
| 155 | seedGateApp(t, s) |
| 156 | err := s.withClaimSQL(t.Context(), []claimSQLScope{gateScope}, allowAllClaimSQL, func(v *Store) error { |
| 157 | ctx, cancel := context.WithCancel(context.Background()) |
| 158 | tx, err := v.db.BeginTx(ctx, nil) |
| 159 | if err != nil { |
| 160 | cancel() |
| 161 | return err |
| 162 | } |
| 163 | if _, err = tx.Exec(`UPDATE audit SET action='ctx-lost' WHERE account_id=7 AND app='blog'`); err != nil { |
| 164 | cancel() |
| 165 | return err |
| 166 | } |
| 167 | cancel() |
| 168 | _ = tx.Rollback() // after its context ended: the writes are lost either way |
| 169 | return nil |
| 170 | }) |
| 171 | if err == nil { |
| 172 | t.Fatal("scope reported success after losing a written transaction") |
| 173 | } |
| 174 | if countWhere(t, s, "audit", "action='ctx-lost'") != 0 { |
| 175 | t.Fatal("lost write landed") |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // L4, failed commit: in a batch, a public commit refused because one scope |
| 180 | // died must abort the batch even if the callback swallows the refusal and |
| 181 | // another scope is still live. |
| 182 | func TestPlan9Fix1BatchRefusesSwallowedFailedCommit(t *testing.T) { |
| 183 | s := openGatedStore(t) |
| 184 | seedGateApp(t, s) |
| 185 | r := receiptFixture(t, s) |
| 186 | other := claimSQLScope{AccountID: gateAcct, App: "blog-b", AppID: "app-B", Mode: claimModeEffect} |
| 187 | var killed atomic.Bool |
| 188 | local := func(_ context.Context, sc claimSQLScope) error { |
| 189 | if killed.Load() && sc == gateScope { |
| 190 | return errors.New("permit expired") |
| 191 | } |
| 192 | return nil |
| 193 | } |
| 194 | err := s.withPreparedClaimSQL(t.Context(), []claimSQLScope{gateScope, other}, allowAllClaimSQL, local, []hostops.SQLReceiptScope{r}, true, func(v *Store) error { |
| 195 | tx, err := v.db.Begin() |
| 196 | if err != nil { |
| 197 | return err |
| 198 | } |
| 199 | if _, err = tx.Exec(`UPDATE audit SET action='refused' WHERE account_id=7 AND app='blog'`); err != nil { |
| 200 | return err |
| 201 | } |
| 202 | killed.Store(true) |
| 203 | if err = tx.Commit(); !errors.Is(err, ErrClaimSQLDenied) { |
| 204 | return fmt.Errorf("commit err = %v, want ErrClaimSQLDenied", err) |
| 205 | } |
| 206 | return nil // the misuse under test |
| 207 | }) |
| 208 | if err == nil { |
| 209 | t.Fatal("batch reported success after a swallowed failed commit") |
| 210 | } |
| 211 | var n int |
| 212 | if qerr := s.rawBoot.QueryRow(`SELECT count(*) FROM claim_sql_receipts WHERE effect=? AND state='committed'`, r.Effect).Scan(&n); qerr != nil || n != 0 { |
| 213 | t.Fatalf("receipt committed for lost writes: n=%d err=%v", n, qerr) |
| 214 | } |
| 215 | } |
| 216 | |