Bind preference state to versioned immutable receipt history
2 files changed,
+196
−14
internal/console/overagepreferencestore.go+76 −14internal/console/overagepreferencestore_test.go+120 −0
CI passed — run details
diff --git a/internal/console/overagepreferencestore.go b/internal/console/overagepreferencestore.go| index f42b4bb7c..2da74e953 100644 |
| --- a/internal/console/overagepreferencestore.go |
| +++ b/internal/console/overagepreferencestore.go |
| @@ -3,12 +3,13 @@ package console |
| import ( |
| "crypto/sha256" |
| "database/sql" |
| + "encoding/binary" |
| "encoding/hex" |
| - "encoding/json" |
| "errors" |
| "math" |
| "regexp" |
| "time" |
| + "unicode/utf8" |
| "github.com/carlosframework/platform/internal/maintenanceguard" |
| ) |
| @@ -82,6 +83,9 @@ func (s *Store) migrateOveragePreference() error { |
| )`, |
| `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_update BEFORE UPDATE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_delete BEFORE DELETE ON overage_preference_receipts BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| + `CREATE TRIGGER IF NOT EXISTS overage_preference_receipts_no_replace BEFORE INSERT ON overage_preference_receipts |
| + WHEN EXISTS(SELECT 1 FROM overage_preference_receipts WHERE operation_id=NEW.operation_id OR (account_id=NEW.account_id AND revision=NEW.revision)) |
| + BEGIN SELECT RAISE(ABORT,'overage_preference_receipt_immutable'); END`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil { |
| return err |
| @@ -99,7 +103,7 @@ func validOverageEvidence(e overageEvidence) bool { |
| } |
| func validateOverageChange(c overagePreferenceChange) error { |
| - if c.AccountID <= 0 || c.Actor == "" || len(c.Actor) > 320 || c.ExpectedRevision < 0 || |
| + if c.AccountID <= 0 || c.Actor == "" || len(c.Actor) > 320 || !utf8.ValidString(c.Actor) || c.ExpectedRevision < 0 || |
| !maintenanceguard.ValidID(c.OperationID) { |
| return errOveragePreferenceInvalid |
| } |
| @@ -113,9 +117,25 @@ func validateOverageChange(c overagePreferenceChange) error { |
| } |
| func overageChangeDigest(c overagePreferenceChange) string { |
| - b, _ := json.Marshal(c) |
| - h := sha256.Sum256(b) |
| - return hex.EncodeToString(h[:]) |
| + h := sha256.New() |
| + h.Write([]byte("carlos-overage-preference-change-v1\x00")) |
| + var n [8]byte |
| + binary.BigEndian.PutUint64(n[:], uint64(c.AccountID)) |
| + h.Write(n[:]) |
| + binary.BigEndian.PutUint64(n[:], uint64(c.ExpectedRevision)) |
| + h.Write(n[:]) |
| + if c.RequestedOn { |
| + h.Write([]byte{1}) |
| + } else { |
| + h.Write([]byte{0}) |
| + } |
| + for _, value := range []string{c.Actor, c.OperationID, c.Evidence.TermsVersion, |
| + c.Evidence.PriceBook, c.Evidence.Currency, c.Evidence.DisclosureDigest, c.Evidence.OfferDigest} { |
| + binary.BigEndian.PutUint64(n[:], uint64(len(value))) |
| + h.Write(n[:]) |
| + h.Write([]byte(value)) |
| + } |
| + return hex.EncodeToString(h.Sum(nil)) |
| } |
| func scanOveragePreference(row interface{ Scan(...any) error }, accountID int64) (overagePreference, error) { |
| @@ -134,7 +154,7 @@ func scanOveragePreference(row interface{ Scan(...any) error }, accountID int64) |
| } |
| p.RequestedOn = on != 0 |
| p.UpdatedAt = time.Unix(at, 0).UTC() |
| - if p.Revision <= 0 || (on != 0 && on != 1) || p.Actor == "" || |
| + if p.Revision <= 0 || (on != 0 && on != 1) || p.Actor == "" || !utf8.ValidString(p.Actor) || |
| !maintenanceguard.ValidID(p.OperationID) || at < 0 || |
| (p.RequestedOn && !validOverageEvidence(p.Evidence)) || |
| (!p.RequestedOn && p.Evidence != (overageEvidence{})) { |
| @@ -157,14 +177,58 @@ func scanOverageReceipt(row interface{ Scan(...any) error }) (overagePreferenceR |
| return r, digest, err |
| } |
| if !maintenanceguard.ValidID(r.OperationID) || r.AccountID <= 0 || r.Actor == "" || |
| - !overageDigest.MatchString(digest) || r.Revision <= 0 || (on != 0 && on != 1) || at < 0 || |
| + !utf8.ValidString(r.Actor) || !overageDigest.MatchString(digest) || r.Revision <= 0 || (on != 0 && on != 1) || at < 0 || |
| (r.RequestedOn && !validOverageEvidence(r.Evidence)) || |
| (!r.RequestedOn && r.Evidence != (overageEvidence{})) { |
| return overagePreferenceReceipt{}, "", errOveragePreferenceCorrupt |
| } |
| + reconstructed := overagePreferenceChange{AccountID: r.AccountID, Actor: r.Actor, |
| + ExpectedRevision: r.Revision - 1, RequestedOn: r.RequestedOn, |
| + OperationID: r.OperationID, Evidence: r.Evidence} |
| + if overageChangeDigest(reconstructed) != digest { |
| + return overagePreferenceReceipt{}, "", errOveragePreferenceCorrupt |
| + } |
| return r, digest, nil |
| } |
| +func loadOveragePreferenceTx(tx *sql.Tx, accountID int64) (overagePreference, error) { |
| + p, err := scanOveragePreference(tx.QueryRow(`SELECT revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,actor,operation_id,updated_at |
| + FROM overage_preferences WHERE account_id=?`, accountID), accountID) |
| + if err != nil { |
| + return overagePreference{}, err |
| + } |
| + if p.Revision == 0 { |
| + var receipts int |
| + if err := tx.QueryRow(`SELECT count(*) FROM overage_preference_receipts WHERE account_id=?`, accountID).Scan(&receipts); err != nil { |
| + return overagePreference{}, err |
| + } |
| + if receipts != 0 { |
| + return overagePreference{}, errOveragePreferenceCorrupt |
| + } |
| + return p, nil |
| + } |
| + var latestRevision sql.NullInt64 |
| + if err := tx.QueryRow(`SELECT max(revision) FROM overage_preference_receipts WHERE account_id=?`, accountID).Scan(&latestRevision); err != nil { |
| + return overagePreference{}, err |
| + } |
| + if !latestRevision.Valid || latestRevision.Int64 != p.Revision { |
| + return overagePreference{}, errOveragePreferenceCorrupt |
| + } |
| + r, _, err := scanOverageReceipt(tx.QueryRow(`SELECT operation_id,account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at |
| + FROM overage_preference_receipts WHERE operation_id=?`, p.OperationID)) |
| + if err != nil { |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return overagePreference{}, errOveragePreferenceCorrupt |
| + } |
| + return overagePreference{}, err |
| + } |
| + if r.AccountID != p.AccountID || r.Actor != p.Actor || r.Revision != p.Revision || |
| + r.RequestedOn != p.RequestedOn || r.Evidence != p.Evidence || !r.ChangedAt.Equal(p.UpdatedAt) { |
| + return overagePreference{}, errOveragePreferenceCorrupt |
| + } |
| + return p, nil |
| +} |
| + |
| func authorizeOverageOwnerTx(tx *sql.Tx, accountID int64, actor string) error { |
| if _, err := lockMembershipWritesTx(tx); err != nil { |
| return err |
| @@ -195,8 +259,7 @@ func (s *Store) getOveragePreference(accountID int64, actor string) (overagePref |
| if err := authorizeOverageOwnerTx(tx, accountID, actor); err != nil { |
| return overagePreference{}, err |
| } |
| - return scanOveragePreference(tx.QueryRow(`SELECT revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,actor,operation_id,updated_at |
| - FROM overage_preferences WHERE account_id=?`, accountID), accountID) |
| + return loadOveragePreferenceTx(tx, accountID) |
| } |
| func (s *Store) changeOveragePreference(c overagePreferenceChange, now time.Time) (overagePreferenceReceipt, error) { |
| @@ -213,6 +276,10 @@ func (s *Store) changeOveragePreference(c overagePreferenceChange, now time.Time |
| if err := authorizeOverageOwnerTx(tx, c.AccountID, c.Actor); err != nil { |
| return overagePreferenceReceipt{}, err |
| } |
| + current, err := loadOveragePreferenceTx(tx, c.AccountID) |
| + if err != nil { |
| + return overagePreferenceReceipt{}, err |
| + } |
| digest := overageChangeDigest(c) |
| r, storedDigest, err := scanOverageReceipt(tx.QueryRow(`SELECT operation_id,account_id,actor,request_digest,revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,changed_at |
| FROM overage_preference_receipts WHERE operation_id=?`, c.OperationID)) |
| @@ -225,11 +292,6 @@ func (s *Store) changeOveragePreference(c overagePreferenceChange, now time.Time |
| if !errors.Is(err, sql.ErrNoRows) { |
| return overagePreferenceReceipt{}, err |
| } |
| - current, err := scanOveragePreference(tx.QueryRow(`SELECT revision,requested_on,terms_version,price_book,currency,disclosure_digest,offer_digest,actor,operation_id,updated_at |
| - FROM overage_preferences WHERE account_id=?`, c.AccountID), c.AccountID) |
| - if err != nil { |
| - return overagePreferenceReceipt{}, err |
| - } |
| if current.Revision != c.ExpectedRevision { |
| return overagePreferenceReceipt{}, errOveragePreferenceStale |
| } |
diff --git a/internal/console/overagepreferencestore_test.go b/internal/console/overagepreferencestore_test.go| index 5c1215d34..5eb4dc7a4 100644 |
| --- a/internal/console/overagepreferencestore_test.go |
| +++ b/internal/console/overagepreferencestore_test.go |
| @@ -128,6 +128,10 @@ func TestOveragePreferenceEvidenceAndCorruptRow(t *testing.T) { |
| if _, err := s.changeOveragePreference(c, now); !errors.Is(err, errOveragePreferenceInvalid) { |
| t.Fatalf("invalid evidence error = %v", err) |
| } |
| + badActor := overageChange(42, string([]byte{0xff}), 0, false, 41) |
| + if _, err := s.changeOveragePreference(badActor, now); !errors.Is(err, errOveragePreferenceInvalid) { |
| + t.Fatalf("invalid UTF-8 actor error = %v", err) |
| + } |
| c = overageChange(42, overageOwner, 0, false, 8) |
| c.Evidence = overageEvidenceForTest() |
| if _, err := s.changeOveragePreference(c, now); !errors.Is(err, errOveragePreferenceInvalid) { |
| @@ -142,6 +146,110 @@ func TestOveragePreferenceEvidenceAndCorruptRow(t *testing.T) { |
| } |
| } |
| +func TestOveragePreferenceReceiptAndCurrentMustAgree(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| + newFixture := func(t *testing.T, op byte) (*Store, overagePreferenceChange) { |
| + t.Helper() |
| + s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db")) |
| + seedOverageAccount(t, s, 42, overageOwner, now) |
| + c := overageChange(42, overageOwner, 0, true, op) |
| + if _, err := s.changeOveragePreference(c, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + return s, c |
| + } |
| + t.Run("receipt digest", func(t *testing.T) { |
| + s, c := newFixture(t, 42) |
| + if _, err := s.db.Exec(`DROP TRIGGER overage_preference_receipts_no_update`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.db.Exec(`UPDATE overage_preference_receipts SET request_digest=? WHERE operation_id=?`, |
| + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", c.OperationID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.changeOveragePreference(c, now.Add(time.Second)); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Fatalf("corrupt receipt replay error = %v", err) |
| + } |
| + }) |
| + t.Run("missing current", func(t *testing.T) { |
| + s, _ := newFixture(t, 43) |
| + if _, err := s.db.Exec(`DELETE FROM overage_preferences WHERE account_id=42`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.getOveragePreference(42, overageOwner); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Fatalf("orphan receipt error = %v", err) |
| + } |
| + }) |
| + t.Run("current mismatch", func(t *testing.T) { |
| + s, _ := newFixture(t, 44) |
| + if _, err := s.db.Exec(`UPDATE overage_preferences SET updated_at=updated_at+1 WHERE account_id=42`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.getOveragePreference(42, overageOwner); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Fatalf("current mismatch error = %v", err) |
| + } |
| + }) |
| + t.Run("current names older valid receipt", func(t *testing.T) { |
| + s, enable := newFixture(t, 46) |
| + r1, err := s.changeOveragePreference(enable, now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + disable := overageChange(42, overageOwner, 1, false, 47) |
| + if _, err := s.changeOveragePreference(disable, now.Add(time.Second)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.db.Exec(`UPDATE overage_preferences SET revision=?,requested_on=?,terms_version=?,price_book=?,currency=?,disclosure_digest=?,offer_digest=?,actor=?,operation_id=?,updated_at=? WHERE account_id=42`, |
| + r1.Revision, r1.RequestedOn, r1.Evidence.TermsVersion, r1.Evidence.PriceBook, r1.Evidence.Currency, |
| + r1.Evidence.DisclosureDigest, r1.Evidence.OfferDigest, r1.Actor, r1.OperationID, r1.ChangedAt.Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.getOveragePreference(42, overageOwner); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Fatalf("older current get error = %v", err) |
| + } |
| + if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 1, false, 48), now.Add(2*time.Second)); !errors.Is(err, errOveragePreferenceCorrupt) { |
| + t.Fatalf("older current change error = %v", err) |
| + } |
| + var currentRevision, receipts int64 |
| + if err := s.db.QueryRow(`SELECT revision FROM overage_preferences WHERE account_id=42`).Scan(¤tRevision); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.db.QueryRow(`SELECT count(*) FROM overage_preference_receipts WHERE account_id=42`).Scan(&receipts); err != nil { |
| + t.Fatal(err) |
| + } |
| + if currentRevision != 1 || receipts != 2 { |
| + t.Fatalf("corrupt attempt wrote state: revision=%d receipts=%d", currentRevision, receipts) |
| + } |
| + }) |
| +} |
| + |
| +func TestOveragePreferenceReceiptsRejectEveryReplacementRoute(t *testing.T) { |
| + now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| + s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db")) |
| + seedOverageAccount(t, s, 42, overageOwner, now) |
| + c := overageChange(42, overageOwner, 0, true, 45) |
| + if _, err := s.changeOveragePreference(c, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.db.Exec(`PRAGMA recursive_triggers=OFF`); err != nil { |
| + t.Fatal(err) |
| + } |
| + for name, stmt := range map[string]string{ |
| + "update": `UPDATE overage_preference_receipts SET actor='changed' WHERE operation_id='` + c.OperationID + `'`, |
| + "delete": `DELETE FROM overage_preference_receipts WHERE operation_id='` + c.OperationID + `'`, |
| + "replace": `INSERT OR REPLACE INTO overage_preference_receipts SELECT * FROM overage_preference_receipts WHERE operation_id='` + c.OperationID + `'`, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + if _, err := s.db.Exec(stmt); err == nil { |
| + t.Fatal("immutable receipt mutation succeeded") |
| + } |
| + }) |
| + } |
| + if _, err := s.changeOveragePreference(c, now.Add(time.Second)); err != nil { |
| + t.Fatalf("receipt changed after rejected mutations: %v", err) |
| + } |
| +} |
| + |
| func TestOveragePreferenceReplayNeverReappliesHistoricalState(t *testing.T) { |
| now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC) |
| s := newOverageStore(t, filepath.Join(t.TempDir(), "console.db")) |
| @@ -189,6 +297,18 @@ func TestOveragePreferenceConflictsAndClock(t *testing.T) { |
| if _, err := s.changeOveragePreference(overageChange(42, overageOwner, 1, false, 13), now); !errors.Is(err, errOveragePreferenceClock) { |
| t.Fatalf("clock rollback error = %v", err) |
| } |
| + overflowCurrent := c |
| + overflowCurrent.ExpectedRevision = math.MaxInt64 - 1 |
| + if _, err := s.db.Exec(`DROP TRIGGER overage_preference_receipts_no_update`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.db.Exec(`UPDATE overage_preference_receipts SET revision=?,request_digest=? WHERE operation_id=?`, |
| + int64(math.MaxInt64), overageChangeDigest(overflowCurrent), c.OperationID); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := s.migrateOveragePreference(); err != nil { |
| + t.Fatal(err) |
| + } |
| if _, err := s.db.Exec(`UPDATE overage_preferences SET revision=? WHERE account_id=42`, int64(math.MaxInt64)); err != nil { |
| t.Fatal(err) |
| } |