Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

spendbook_test.gno

73.49 Kb · 2029 lines
   1package spendbook
   2
   3import "testing"
   4
   5const (
   6	alice = "g1alice" // owner
   7	bob   = "g1bob"   // spender
   8	carol = "g1carol" // neither
   9)
  10
  11// day is the default PeriodLength: a round number so window boundaries in the
  12// tests are readable as multiples of it from the anchor.
  13const day = int64(100)
  14
  15// openDefault opens one allowance anchored at `anchor` with a window cap of
  16// 50 per `day`, no lifetime cap and no expiry.
  17func openDefault(t *testing.T, anchor int64) (*Book, uint64) {
  18	t.Helper()
  19	b := MustNew(8)
  20	id, err := b.Open(alice, bob, anchor, Terms{PerPeriod: 50, PeriodLength: day})
  21	if err != nil {
  22		t.Fatalf("Open: %v", err)
  23	}
  24	return b, id
  25}
  26
  27// mustSpend fails the test if a permitted draw is refused.
  28func mustSpend(t *testing.T, b *Book, id uint64, now, amount int64) {
  29	t.Helper()
  30	if err := b.Spend(id, bob, now, amount); err != nil {
  31		t.Fatalf("Spend(%d, now=%d, amt=%d): %v", id, now, amount, err)
  32	}
  33}
  34
  35// refuse fails the test unless the draw is refused with want.
  36func refuse(t *testing.T, b *Book, id uint64, now, amount int64, want error) {
  37	t.Helper()
  38	if err := b.Spend(id, bob, now, amount); err != want {
  39		t.Fatalf("Spend(%d, now=%d, amt=%d): got %v, want %v",
  40			id, now, amount, err, want)
  41	}
  42}
  43
  44// --- construction ---------------------------------------------------------
  45
  46func TestNewRejectsBadConfig(t *testing.T) {
  47	for _, maxLive := range []int{0, -1, MaxLiveLimit + 1} {
  48		if _, err := New(maxLive); err != ErrInvalidConfig {
  49			t.Errorf("New(%d): got %v, want ErrInvalidConfig", maxLive, err)
  50		}
  51	}
  52}
  53
  54func TestNewAcceptsTheBoundaries(t *testing.T) {
  55	for _, maxLive := range []int{1, MaxLiveLimit} {
  56		b, err := New(maxLive)
  57		if err != nil {
  58			t.Fatalf("New(%d): %v", maxLive, err)
  59		}
  60		if b.MaxLive() != maxLive {
  61			t.Errorf("MaxLive: got %d, want %d", b.MaxLive(), maxLive)
  62		}
  63		if b.Count() != 0 {
  64			t.Errorf("fresh Count: got %d, want 0", b.Count())
  65		}
  66	}
  67}
  68
  69func TestMustNewPanicsOnBadConfig(t *testing.T) {
  70	defer func() {
  71		if recover() == nil {
  72			t.Error("MustNew(0) did not panic")
  73		}
  74	}()
  75	MustNew(0)
  76}
  77
  78// --- Open -----------------------------------------------------------------
  79
  80func TestOpenRejectsBadKeys(t *testing.T) {
  81	b := MustNew(4)
  82	long := ""
  83	for i := 0; i <= MaxKeyLen; i++ {
  84		long += "x"
  85	}
  86	ok := Terms{PerPeriod: 10, PeriodLength: day}
  87
  88	cases := []struct {
  89		name            string
  90		owner, spender  string
  91		want            error
  92	}{
  93		{"empty owner", "", bob, ErrEmptyOwner},
  94		{"empty spender", alice, "", ErrEmptySpender},
  95		{"owner too long", long, bob, ErrKeyTooLong},
  96		{"spender too long", alice, long, ErrKeyTooLong},
  97	}
  98	for _, c := range cases {
  99		if _, err := b.Open(c.owner, c.spender, 0, ok); err != c.want {
 100			t.Errorf("%s: got %v, want %v", c.name, err, c.want)
 101		}
 102	}
 103	// The boundary itself is allowed: MaxKeyLen characters exactly.
 104	exact := long[:MaxKeyLen]
 105	if _, err := b.Open(exact, exact, 0, ok); err != nil {
 106		t.Errorf("key of exactly MaxKeyLen: %v", err)
 107	}
 108}
 109
 110func TestOpenRejectsNegativeNow(t *testing.T) {
 111	b := MustNew(4)
 112	_, err := b.Open(alice, bob, -1, Terms{PerPeriod: 10, PeriodLength: day})
 113	if err != ErrInvalidNow {
 114		t.Errorf("got %v, want ErrInvalidNow", err)
 115	}
 116}
 117
 118func TestOpenRejectsInvalidTerms(t *testing.T) {
 119	b := MustNew(4)
 120	cases := []struct {
 121		name  string
 122		terms Terms
 123	}{
 124		{"zero PerPeriod", Terms{PerPeriod: 0, PeriodLength: day}},
 125		{"negative PerPeriod", Terms{PerPeriod: -1, PeriodLength: day}},
 126		{"negative PeriodLength", Terms{PerPeriod: 10, PeriodLength: -1}},
 127		{"negative Total", Terms{PerPeriod: 10, PeriodLength: day, Total: -1}},
 128		{"negative ExpiresAt", Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: -1}},
 129		// An expiry at or before the anchor can never authorize anything.
 130		{"expiry at anchor", Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 1000}},
 131		{"expiry before anchor", Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 999}},
 132	}
 133	for _, c := range cases {
 134		if _, err := b.Open(alice, bob, 1000, c.terms); err != ErrInvalidTerms {
 135			t.Errorf("%s: got %v, want ErrInvalidTerms", c.name, err)
 136		}
 137	}
 138	// One past the anchor is the first acceptable expiry.
 139	ok := Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 1001}
 140	if _, err := b.Open(alice, bob, 1000, ok); err != nil {
 141		t.Errorf("expiry one past the anchor: %v", err)
 142	}
 143}
 144
 145func TestOpenAcceptsTotalBelowPerPeriod(t *testing.T) {
 146	// Not a contradiction: it is a narrower lifetime cap, and Spend is what
 147	// enforces the narrower of the two.
 148	b := MustNew(4)
 149	id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 10})
 150	if err != nil {
 151		t.Fatalf("Open: %v", err)
 152	}
 153	if got := b.Remaining(id, 0); got != 10 {
 154		t.Errorf("Remaining: got %d, want 10 (the lifetime cap)", got)
 155	}
 156	refuse(t, b, id, 0, 11, ErrTotalExceeded)
 157	mustSpend(t, b, id, 0, 10)
 158}
 159
 160func TestOpenEnforcesMaxLive(t *testing.T) {
 161	b := MustNew(2)
 162	terms := Terms{PerPeriod: 10, PeriodLength: day}
 163	id1, err := b.Open(alice, bob, 0, terms)
 164	if err != nil {
 165		t.Fatalf("first Open: %v", err)
 166	}
 167	if _, err := b.Open(alice, bob, 0, terms); err != nil {
 168		t.Fatalf("second Open: %v", err)
 169	}
 170	if _, err := b.Open(alice, bob, 0, terms); err != ErrBookFull {
 171		t.Fatalf("third Open: got %v, want ErrBookFull", err)
 172	}
 173	// Revoking frees a slot; the new id is NOT the revoked one.
 174	if err := b.Revoke(id1, alice); err != nil {
 175		t.Fatalf("Revoke: %v", err)
 176	}
 177	id3, err := b.Open(alice, bob, 0, terms)
 178	if err != nil {
 179		t.Fatalf("Open after Revoke: %v", err)
 180	}
 181	if id3 == id1 {
 182		t.Errorf("id %d was reused after revocation", id3)
 183	}
 184}
 185
 186func TestAFreedSlotDoesNotOverwriteALiveSibling(t *testing.T) {
 187	// Allocating the next id from the LIVE COUNT rather than a monotonic
 188	// counter looks right until a revocation in the middle: the count falls
 189	// back onto an id that is still in use, and the next Open silently
 190	// replaces that allowance instead of adding one. Count is what shows it,
 191	// which is why this asserts the population and not just the ids.
 192	b := MustNew(3)
 193	terms := Terms{PerPeriod: 10, PeriodLength: day}
 194	id1, _ := b.Open(alice, "s1", 0, terms)
 195	id2, _ := b.Open(alice, "s2", 0, terms)
 196	id3, _ := b.Open(alice, "s3", 0, terms)
 197	if err := b.Revoke(id2, alice); err != nil {
 198		t.Fatalf("Revoke: %v", err)
 199	}
 200	id4, err := b.Open(alice, "s4", 0, terms)
 201	if err != nil {
 202		t.Fatalf("Open into the freed slot: %v", err)
 203	}
 204	if b.Count() != 3 {
 205		t.Errorf("Count: got %d, want 3 -- an Open replaced a live allowance",
 206			b.Count())
 207	}
 208	for _, c := range []struct {
 209		id      uint64
 210		spender string
 211	}{{id1, "s1"}, {id3, "s3"}, {id4, "s4"}} {
 212		a, err := b.Get(c.id)
 213		if err != nil {
 214			t.Errorf("Get(%d): %v", c.id, err)
 215			continue
 216		}
 217		if a.Spender != c.spender {
 218			t.Errorf("id %d: spender %q, want %q", c.id, a.Spender, c.spender)
 219		}
 220	}
 221	if _, err := b.Get(id2); err != ErrNotFound {
 222		t.Errorf("the revoked id came back: %v", err)
 223	}
 224}
 225
 226func TestOpenAllocatesDistinctIDsForTheSamePair(t *testing.T) {
 227	// Two allowances for one (owner, spender) pair are independent, and the
 228	// id is what tells them apart. Drawing one down must not touch the other.
 229	b := MustNew(4)
 230	terms := Terms{PerPeriod: 50, PeriodLength: day}
 231	id1, _ := b.Open(alice, bob, 0, terms)
 232	id2, _ := b.Open(alice, bob, 0, terms)
 233	if id1 == id2 {
 234		t.Fatalf("duplicate id %d", id1)
 235	}
 236	mustSpend(t, b, id1, 0, 50)
 237	if got := b.Remaining(id1, 0); got != 0 {
 238		t.Errorf("drawn allowance: got %d, want 0", got)
 239	}
 240	if got := b.Remaining(id2, 0); got != 50 {
 241		t.Errorf("sibling allowance: got %d, want 50", got)
 242	}
 243}
 244
 245// --- Get / Remaining lookups ---------------------------------------------
 246
 247func TestGetUnknownID(t *testing.T) {
 248	b := MustNew(4)
 249	if _, err := b.Get(99); err != ErrNotFound {
 250		t.Errorf("Get: got %v, want ErrNotFound", err)
 251	}
 252	// Remaining cannot distinguish the two cases and documents that it
 253	// answers 0 for both; this pins the documented behavior.
 254	if got := b.Remaining(99, 0); got != 0 {
 255		t.Errorf("Remaining of unknown id: got %d, want 0", got)
 256	}
 257}
 258
 259func TestGetReturnsACopy(t *testing.T) {
 260	b, id := openDefault(t, 0)
 261	a, err := b.Get(id)
 262	if err != nil {
 263		t.Fatalf("Get: %v", err)
 264	}
 265	a.Terms.PerPeriod = 1 << 40
 266	a.SpentWindow = -500
 267	again, _ := b.Get(id)
 268	if again.Terms.PerPeriod != 50 || again.SpentWindow != 0 {
 269		t.Errorf("mutating the returned value reached the book: %+v", again)
 270	}
 271}
 272
 273// --- Spend: the per-window cap -------------------------------------------
 274
 275func TestSpendWithinWindowCap(t *testing.T) {
 276	b, id := openDefault(t, 1000)
 277	mustSpend(t, b, id, 1000, 20)
 278	if got := b.Remaining(id, 1000); got != 30 {
 279		t.Errorf("Remaining: got %d, want 30", got)
 280	}
 281	mustSpend(t, b, id, 1050, 30)
 282	if got := b.Remaining(id, 1050); got != 0 {
 283		t.Errorf("Remaining after full draw: got %d, want 0", got)
 284	}
 285	refuse(t, b, id, 1050, 1, ErrPeriodExceeded)
 286}
 287
 288func TestSpendRejectsBadArguments(t *testing.T) {
 289	b, id := openDefault(t, 1000)
 290	refuse(t, b, id, 1000, 0, ErrInvalidAmount)
 291	refuse(t, b, id, 1000, -5, ErrInvalidAmount)
 292	refuse(t, b, id, -1, 10, ErrInvalidNow)
 293	if err := b.Spend(99, bob, 1000, 10); err != ErrNotFound {
 294		t.Errorf("unknown id: got %v, want ErrNotFound", err)
 295	}
 296}
 297
 298func TestSpendRejectsTheWrongSpender(t *testing.T) {
 299	b, id := openDefault(t, 1000)
 300	// Not the owner's either: owning an allowance does not mean drawing it.
 301	for _, who := range []string{carol, alice, ""} {
 302		if err := b.Spend(id, who, 1000, 10); err != ErrNotSpender {
 303			t.Errorf("Spend as %q: got %v, want ErrNotSpender", who, err)
 304		}
 305	}
 306}
 307
 308func TestSpendBeforeTheAnchor(t *testing.T) {
 309	b, id := openDefault(t, 1000)
 310	refuse(t, b, id, 999, 10, ErrNotStarted)
 311	// The anchor instant itself is spendable.
 312	mustSpend(t, b, id, 1000, 10)
 313}
 314
 315func TestRemainingBeforeTheAnchorIsZero(t *testing.T) {
 316	b, id := openDefault(t, 1000)
 317	if got := b.Remaining(id, 999); got != 0 {
 318		t.Errorf("got %d, want 0", got)
 319	}
 320}
 321
 322func TestARefusedSpendConsumesNothing(t *testing.T) {
 323	b, id := openDefault(t, 1000)
 324	mustSpend(t, b, id, 1000, 40)
 325	before, _ := b.Get(id)
 326
 327	// Every refusal path, in one book, each asserted to leave no trace.
 328	refuse(t, b, id, 1000, 11, ErrPeriodExceeded)
 329	refuse(t, b, id, 1000, 0, ErrInvalidAmount)
 330	refuse(t, b, id, -1, 5, ErrInvalidNow)
 331	if err := b.Spend(id, carol, 1000, 5); err != ErrNotSpender {
 332		t.Fatalf("wrong spender: %v", err)
 333	}
 334	refuse(t, b, id, 999, 5, ErrNotStarted)
 335
 336	after, _ := b.Get(id)
 337	if after != before {
 338		t.Errorf("a refused Spend mutated the allowance:\n before %+v\n after  %+v",
 339			before, after)
 340	}
 341}
 342
 343// --- Spend: anchored windows ---------------------------------------------
 344
 345func TestWindowsAreAnchoredNotSliding(t *testing.T) {
 346	// Anchor 1000, period 100, so boundaries are 1100, 1200, ...
 347	b, id := openDefault(t, 1000)
 348
 349	// Draw the whole cap at the very end of window 0.
 350	mustSpend(t, b, id, 1099, 50)
 351	refuse(t, b, id, 1099, 1, ErrPeriodExceeded)
 352
 353	// One instant later is window 1, and the cap is whole again. Under a
 354	// SLIDING trailing window this draw would have to wait until 1199.
 355	mustSpend(t, b, id, 1100, 50)
 356	if got := b.Remaining(id, 1100); got != 0 {
 357		t.Errorf("window 1 after a full draw: got %d, want 0", got)
 358	}
 359
 360	// The boundary is a property of the anchor, not of when anyone called:
 361	// window 1 ends at 1200 no matter that the first draw in it was at 1100.
 362	refuse(t, b, id, 1199, 1, ErrPeriodExceeded)
 363	mustSpend(t, b, id, 1200, 50)
 364}
 365
 366func TestWindowRefillIsAResetNotACarry(t *testing.T) {
 367	b, id := openDefault(t, 0)
 368	// Spend nothing in windows 0 and 1, then everything available in 2.
 369	mustSpend(t, b, id, 200, 50)
 370	// If unused allowance carried, 150 would have been available and 50 more
 371	// would still be left. The cap bounds worst-case loss PER window, which a
 372	// carry would unbound.
 373	refuse(t, b, id, 200, 1, ErrPeriodExceeded)
 374	if got := b.Remaining(id, 200); got != 0 {
 375		t.Errorf("Remaining: got %d, want 0", got)
 376	}
 377}
 378
 379func TestWindowIndexSkipsAheadCorrectly(t *testing.T) {
 380	b, id := openDefault(t, 1000)
 381	mustSpend(t, b, id, 1000, 50)
 382	// Jump many windows in one step; the index must land on the real one, not
 383	// merely increment.
 384	mustSpend(t, b, id, 1000+37*day+1, 50)
 385	a, _ := b.Get(id)
 386	if a.WindowIndex != 37 {
 387		t.Errorf("WindowIndex: got %d, want 37", a.WindowIndex)
 388	}
 389}
 390
 391func TestLifetimeAllowanceNeverRolls(t *testing.T) {
 392	b := MustNew(4)
 393	// PeriodLength 0 == one window that never rolls.
 394	id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: 0})
 395	if err != nil {
 396		t.Fatalf("Open: %v", err)
 397	}
 398	mustSpend(t, b, id, 0, 50)
 399	for _, now := range []int64{1, 100, 1 << 40, maxInt64} {
 400		if got := b.Remaining(id, now); got != 0 {
 401			t.Errorf("Remaining at %d: got %d, want 0 (never refills)", now, got)
 402		}
 403		refuse(t, b, id, now, 1, ErrPeriodExceeded)
 404	}
 405}
 406
 407// --- Spend: the lifetime cap and expiry ----------------------------------
 408
 409func TestTotalCapSurvivesWindowRollover(t *testing.T) {
 410	b := MustNew(4)
 411	id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 60})
 412	if err != nil {
 413		t.Fatalf("Open: %v", err)
 414	}
 415	mustSpend(t, b, id, 0, 50)
 416	// Window 1 refills the per-window cap, but the lifetime cap does not
 417	// refill -- only 10 of the 60 is left.
 418	if got := b.Remaining(id, day); got != 10 {
 419		t.Errorf("Remaining in window 1: got %d, want 10", got)
 420	}
 421	refuse(t, b, id, day, 11, ErrTotalExceeded)
 422	mustSpend(t, b, id, day, 10)
 423	for _, now := range []int64{day, 2 * day, 99 * day} {
 424		if got := b.Remaining(id, now); got != 0 {
 425			t.Errorf("Remaining at %d: got %d, want 0 (lifetime spent)", now, got)
 426		}
 427	}
 428}
 429
 430func TestExpiry(t *testing.T) {
 431	b := MustNew(4)
 432	id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 250})
 433	if err != nil {
 434		t.Fatalf("Open: %v", err)
 435	}
 436	a, _ := b.Get(id)
 437	if a.IsExpired(249) {
 438		t.Error("expired one instant early")
 439	}
 440	if !a.IsExpired(250) {
 441		t.Error("ExpiresAt is exclusive: 250 must be expired")
 442	}
 443	mustSpend(t, b, id, 249, 10)
 444	refuse(t, b, id, 250, 1, ErrExpired)
 445	if got := b.Remaining(id, 250); got != 0 {
 446		t.Errorf("Remaining when expired: got %d, want 0", got)
 447	}
 448}
 449
 450func TestNoExpiryNeverExpires(t *testing.T) {
 451	b, id := openDefault(t, 0)
 452	a, _ := b.Get(id)
 453	if a.IsExpired(maxInt64) {
 454		t.Error("an allowance with ExpiresAt 0 reported expired")
 455	}
 456}
 457
 458func TestOverflowGuardOnTheLifetimeAccumulator(t *testing.T) {
 459	// With Total == 0 (unlimited) SpentTotal is the one accumulation that can
 460	// genuinely reach the int64 ceiling, so it is the one that needs a guard.
 461	b := MustNew(4)
 462	id, err := b.Open(alice, bob, 0, Terms{PerPeriod: maxInt64, PeriodLength: day})
 463	if err != nil {
 464		t.Fatalf("Open: %v", err)
 465	}
 466	mustSpend(t, b, id, 0, maxInt64-5)
 467	a, _ := b.Get(id)
 468	if a.SpentTotal != maxInt64-5 {
 469		t.Fatalf("SpentTotal: got %d", a.SpentTotal)
 470	}
 471	// Next window: the per-window cap permits this draw, and only the
 472	// overflow guard stands between it and a wrapped SpentTotal.
 473	refuse(t, b, id, day, 10, ErrOverflow)
 474	mustSpend(t, b, id, day, 5)
 475	a, _ = b.Get(id)
 476	if a.SpentTotal != maxInt64 {
 477		t.Errorf("SpentTotal at the ceiling: got %d, want %d", a.SpentTotal, maxInt64)
 478	}
 479	refuse(t, b, id, 2*day, 1, ErrOverflow)
 480}
 481
 482// --- Revoke ---------------------------------------------------------------
 483
 484func TestRevoke(t *testing.T) {
 485	b, id := openDefault(t, 0)
 486	if err := b.Revoke(id, carol); err != ErrNotOwner {
 487		t.Errorf("Revoke as a stranger: got %v, want ErrNotOwner", err)
 488	}
 489	if err := b.Revoke(id, bob); err != ErrNotOwner {
 490		t.Errorf("Revoke as the spender: got %v, want ErrNotOwner", err)
 491	}
 492	if err := b.Revoke(99, alice); err != ErrNotFound {
 493		t.Errorf("Revoke unknown: got %v, want ErrNotFound", err)
 494	}
 495	if err := b.Revoke(id, alice); err != nil {
 496		t.Fatalf("Revoke: %v", err)
 497	}
 498	if b.Count() != 0 {
 499		t.Errorf("Count after Revoke: got %d, want 0", b.Count())
 500	}
 501	// Revocation is immediate and permanent -- the id is never resurrected.
 502	refuse(t, b, id, 0, 1, ErrNotFound)
 503	if err := b.Revoke(id, alice); err != ErrNotFound {
 504		t.Errorf("second Revoke: got %v, want ErrNotFound", err)
 505	}
 506}
 507
 508func TestRevokeIsImmediateNotDeferredToABoundary(t *testing.T) {
 509	// Revocation is the owner's remedy, and a remedy that waits for a window
 510	// boundary is not one.
 511	b, id := openDefault(t, 0)
 512	mustSpend(t, b, id, 0, 10)
 513	if err := b.Revoke(id, alice); err != nil {
 514		t.Fatalf("Revoke: %v", err)
 515	}
 516	refuse(t, b, id, 0, 1, ErrNotFound)
 517}
 518
 519// --- Reap -----------------------------------------------------------------
 520
 521func TestReapOnlyTakesExpiredAllowances(t *testing.T) {
 522	b := MustNew(4)
 523	id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 250})
 524	if err != nil {
 525		t.Fatalf("Open: %v", err)
 526	}
 527	if err := b.Reap(id, 249); err != ErrNotExpired {
 528		t.Errorf("Reap a live allowance: got %v, want ErrNotExpired", err)
 529	}
 530	if err := b.Reap(id, -1); err != ErrInvalidNow {
 531		t.Errorf("Reap with a negative clock: got %v, want ErrInvalidNow", err)
 532	}
 533	if err := b.Reap(99, 250); err != ErrNotFound {
 534		t.Errorf("Reap unknown: got %v, want ErrNotFound", err)
 535	}
 536	// Permissionless: carol is neither owner nor spender and there is no
 537	// caller argument at all, which is the point.
 538	if err := b.Reap(id, 250); err != nil {
 539		t.Fatalf("Reap an expired allowance: %v", err)
 540	}
 541	if b.Count() != 0 {
 542		t.Errorf("Count after Reap: got %d, want 0", b.Count())
 543	}
 544	if err := b.Reap(id, 250); err != ErrNotFound {
 545		t.Errorf("second Reap: got %v, want ErrNotFound", err)
 546	}
 547}
 548
 549func TestReapNeverTakesAnAllowanceWithNoExpiry(t *testing.T) {
 550	b, id := openDefault(t, 0)
 551	if err := b.Reap(id, maxInt64); err != ErrNotExpired {
 552		t.Errorf("got %v, want ErrNotExpired", err)
 553	}
 554}
 555
 556func TestReapReclaimsASlot(t *testing.T) {
 557	// The defect Reap exists for: without it an expired allowance refuses
 558	// every Spend forever while still holding a slot against maxLive.
 559	b := MustNew(1)
 560	id, err := b.Open(alice, bob, 0, Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 50})
 561	if err != nil {
 562		t.Fatalf("Open: %v", err)
 563	}
 564	live := Terms{PerPeriod: 10, PeriodLength: day}
 565	if _, err := b.Open(alice, carol, 50, live); err != ErrBookFull {
 566		t.Fatalf("Open over a corpse: got %v, want ErrBookFull", err)
 567	}
 568	if err := b.Reap(id, 50); err != nil {
 569		t.Fatalf("Reap: %v", err)
 570	}
 571	if _, err := b.Open(alice, carol, 50, live); err != nil {
 572		t.Fatalf("Open after Reap: %v", err)
 573	}
 574}
 575
 576// --- Amend: tighter applies immediately ----------------------------------
 577
 578func TestAmendTighterAppliesImmediately(t *testing.T) {
 579	b, id := openDefault(t, 0)
 580	mustSpend(t, b, id, 10, 20)
 581
 582	now, err := b.Amend(id, alice, 10, Terms{PerPeriod: 30, PeriodLength: day})
 583	if err != nil {
 584		t.Fatalf("Amend: %v", err)
 585	}
 586	if !now {
 587		t.Fatal("a tightening was queued instead of applied")
 588	}
 589	// 30 cap, 20 already spent in this window.
 590	if got := b.Remaining(id, 10); got != 10 {
 591		t.Errorf("Remaining: got %d, want 10", got)
 592	}
 593	refuse(t, b, id, 10, 11, ErrPeriodExceeded)
 594	mustSpend(t, b, id, 10, 10)
 595}
 596
 597func TestAmendTighteningBelowWhatIsSpentClampsToZero(t *testing.T) {
 598	// Reachable, and the allowance must read as drawn rather than negative.
 599	b, id := openDefault(t, 0)
 600	mustSpend(t, b, id, 0, 40)
 601	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 10, PeriodLength: day})
 602	if err != nil {
 603		t.Fatalf("Amend: %v", err)
 604	}
 605	if !applied {
 606		t.Fatal("tightening was queued")
 607	}
 608	if got := b.Remaining(id, 0); got != 0 {
 609		t.Errorf("Remaining: got %d, want 0", got)
 610	}
 611	refuse(t, b, id, 0, 1, ErrPeriodExceeded)
 612	// The next window refills to the NEW, tighter cap.
 613	if got := b.Remaining(id, day); got != 10 {
 614		t.Errorf("Remaining in the next window: got %d, want 10", got)
 615	}
 616}
 617
 618func TestAmendEqualTermsApplyImmediately(t *testing.T) {
 619	// "Tighter OR EQUAL": re-stating the same terms is not a loosening and
 620	// must not be deferred, or a no-op amendment would sit queued.
 621	b, id := openDefault(t, 0)
 622	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day})
 623	if err != nil {
 624		t.Fatalf("Amend: %v", err)
 625	}
 626	if !applied {
 627		t.Error("identical terms were queued")
 628	}
 629}
 630
 631// --- Amend: looser is deferred to the next boundary ----------------------
 632
 633func TestAmendLooserIsPendingUntilTheNextBoundary(t *testing.T) {
 634	b, id := openDefault(t, 0)
 635	mustSpend(t, b, id, 0, 50)
 636
 637	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day})
 638	if err != nil {
 639		t.Fatalf("Amend: %v", err)
 640	}
 641	if applied {
 642		t.Fatal("a loosening took effect inside the live window")
 643	}
 644	// The live window is untouched: this is the drain-then-raise-terms
 645	// attack, and the whole point is that the raise cannot reach back.
 646	if got := b.Remaining(id, 0); got != 0 {
 647		t.Errorf("Remaining in the live window: got %d, want 0", got)
 648	}
 649	refuse(t, b, id, 99, 1, ErrPeriodExceeded)
 650
 651	// At the boundary the pending terms are in force.
 652	if got := b.Remaining(id, day); got != 500 {
 653		t.Errorf("Remaining at the boundary: got %d, want 500", got)
 654	}
 655	mustSpend(t, b, id, day, 500)
 656
 657	// And they are now the allowance's real terms, not a one-window grant.
 658	a, _ := b.Get(id)
 659	if a.HasPending {
 660		t.Error("the pending amendment was not consumed")
 661	}
 662	if a.Terms.PerPeriod != 500 {
 663		t.Errorf("Terms.PerPeriod: got %d, want 500", a.Terms.PerPeriod)
 664	}
 665	if got := b.Remaining(id, 2*day); got != 500 {
 666		t.Errorf("Remaining two windows on: got %d, want 500", got)
 667	}
 668}
 669
 670func TestPendingAmendmentAppliesThroughRemainingWithoutASpend(t *testing.T) {
 671	// Remaining and Spend share one implementation of the rollover rule, so a
 672	// read must not report the old terms just because nobody has spent yet.
 673	b, id := openDefault(t, 0)
 674	if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil {
 675		t.Fatalf("Amend: %v", err)
 676	}
 677	if got := b.Remaining(id, day); got != 500 {
 678		t.Errorf("Remaining: got %d, want 500", got)
 679	}
 680	// ... and what the read promised is what the write honors.
 681	mustSpend(t, b, id, day, 500)
 682}
 683
 684func TestATighteningSupersedesAQueuedLoosening(t *testing.T) {
 685	// Otherwise the owner's tightening silently expires at the next boundary
 686	// and the looser terms they just replaced come back.
 687	b, id := openDefault(t, 0)
 688	if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil {
 689		t.Fatalf("loosening Amend: %v", err)
 690	}
 691	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 20, PeriodLength: day})
 692	if err != nil {
 693		t.Fatalf("tightening Amend: %v", err)
 694	}
 695	if !applied {
 696		t.Fatal("the tightening was queued")
 697	}
 698	a, _ := b.Get(id)
 699	if a.HasPending {
 700		t.Error("the queued loosening survived the tightening")
 701	}
 702	if got := b.Remaining(id, 0); got != 20 {
 703		t.Errorf("Remaining now: got %d, want 20", got)
 704	}
 705	if got := b.Remaining(id, day); got != 20 {
 706		t.Errorf("Remaining after the boundary: got %d, want 20", got)
 707	}
 708	if got := b.Remaining(id, 99*day); got != 20 {
 709		t.Errorf("Remaining much later: got %d, want 20", got)
 710	}
 711}
 712
 713func TestASecondLooseningReplacesTheQueuedOne(t *testing.T) {
 714	b, id := openDefault(t, 0)
 715	if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil {
 716		t.Fatalf("first: %v", err)
 717	}
 718	if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 300, PeriodLength: day}); err != nil {
 719		t.Fatalf("second: %v", err)
 720	}
 721	// 300 is looser than the live 50 (so still queued) but tighter than the
 722	// queued 500: the owner's latest word wins.
 723	if got := b.Remaining(id, day); got != 300 {
 724		t.Errorf("Remaining at the boundary: got %d, want 300", got)
 725	}
 726}
 727
 728func TestAmendAfterAQuietStretchJudgesAgainstTheTermsInForce(t *testing.T) {
 729	// The clock has moved several windows past a pending amendment without a
 730	// Spend to roll the record. Amend must roll first, or it would compare
 731	// the new terms against stale ones.
 732	b, id := openDefault(t, 0)
 733	if _, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day}); err != nil {
 734		t.Fatalf("loosening: %v", err)
 735	}
 736	// Window 5. The pending 500 is in force here, so 200 is a TIGHTENING and
 737	// must apply immediately. Judged against the stale 50 it would look like
 738	// a loosening and be queued for a boundary that has already passed.
 739	applied, err := b.Amend(id, alice, 5*day, Terms{PerPeriod: 200, PeriodLength: day})
 740	if err != nil {
 741		t.Fatalf("Amend: %v", err)
 742	}
 743	if !applied {
 744		t.Fatal("judged against stale terms: a tightening was queued")
 745	}
 746	if got := b.Remaining(id, 5*day); got != 200 {
 747		t.Errorf("Remaining: got %d, want 200", got)
 748	}
 749}
 750
 751func TestRollingThroughAmendResetsTheWindowSpend(t *testing.T) {
 752	b, id := openDefault(t, 0)
 753	mustSpend(t, b, id, 0, 50)
 754	// Amend in window 3 with equal terms; the roll it performs must reset the
 755	// in-window spend rather than carry window 0's 50 forward.
 756	if _, err := b.Amend(id, alice, 3*day, Terms{PerPeriod: 50, PeriodLength: day}); err != nil {
 757		t.Fatalf("Amend: %v", err)
 758	}
 759	a, _ := b.Get(id)
 760	if a.WindowIndex != 3 || a.SpentWindow != 0 {
 761		t.Errorf("WindowIndex=%d SpentWindow=%d, want 3 and 0", a.WindowIndex, a.SpentWindow)
 762	}
 763	if a.SpentTotal != 50 {
 764		t.Errorf("SpentTotal: got %d, want 50 (lifetime does not reset)", a.SpentTotal)
 765	}
 766	mustSpend(t, b, id, 3*day, 50)
 767}
 768
 769// --- Amend: the zero-means-unlimited inversions --------------------------
 770
 771func TestAmendTotalZeroIsLooserNotTighter(t *testing.T) {
 772	// Total 0 means NO lifetime cap, so replacing a positive Total with 0 is
 773	// removing a cap. Reading "smaller is tighter" would let an owner lift it
 774	// mid-window and call it a tightening.
 775	b := MustNew(4)
 776	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 100})
 777	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 0})
 778	if err != nil {
 779		t.Fatalf("Amend: %v", err)
 780	}
 781	if applied {
 782		t.Error("removing the lifetime cap applied immediately")
 783	}
 784	if got := b.Remaining(id, 0); got != 50 {
 785		t.Errorf("Remaining: got %d, want 50 (still capped by the live 100)", got)
 786	}
 787}
 788
 789func TestAmendTotalDownwardIsTighter(t *testing.T) {
 790	b := MustNew(4)
 791	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 100})
 792	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 40})
 793	if err != nil {
 794		t.Fatalf("Amend: %v", err)
 795	}
 796	if !applied {
 797		t.Error("lowering the lifetime cap was queued")
 798	}
 799	if got := b.Remaining(id, 0); got != 40 {
 800		t.Errorf("Remaining: got %d, want 40", got)
 801	}
 802}
 803
 804func TestAmendAddingATotalToAnUncappedAllowanceIsTighter(t *testing.T) {
 805	b, id := openDefault(t, 0) // Total 0 == unlimited
 806	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 60})
 807	if err != nil {
 808		t.Fatalf("Amend: %v", err)
 809	}
 810	if !applied {
 811		t.Error("adding a lifetime cap was queued")
 812	}
 813	if got := b.Remaining(id, 0); got != 50 {
 814		t.Errorf("Remaining: got %d, want 50", got)
 815	}
 816	mustSpend(t, b, id, 0, 50)
 817	if got := b.Remaining(id, day); got != 10 {
 818		t.Errorf("Remaining in window 1: got %d, want 10", got)
 819	}
 820}
 821
 822func TestAmendExpiresAtZeroIsLooserNotTighter(t *testing.T) {
 823	b := MustNew(4)
 824	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 10 * day})
 825	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 0})
 826	if err != nil {
 827		t.Fatalf("Amend: %v", err)
 828	}
 829	if applied {
 830		t.Error("removing the expiry applied immediately")
 831	}
 832	// The live expiry still bites until the boundary consumes the pending
 833	// terms, which here it does at day 1 -- well before the old expiry.
 834	a, _ := b.Get(id)
 835	if !a.HasPending {
 836		t.Fatal("no pending amendment recorded")
 837	}
 838	mustSpend(t, b, id, day, 50)
 839	a, _ = b.Get(id)
 840	if a.Terms.ExpiresAt != 0 {
 841		t.Errorf("ExpiresAt after the boundary: got %d, want 0", a.Terms.ExpiresAt)
 842	}
 843	if a.IsExpired(100 * day) {
 844		t.Error("still expiring on the old terms")
 845	}
 846}
 847
 848func TestAmendShorteningTheExpiryIsTighter(t *testing.T) {
 849	b := MustNew(4)
 850	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 10 * day})
 851	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 2 * day})
 852	if err != nil {
 853		t.Fatalf("Amend: %v", err)
 854	}
 855	if !applied {
 856		t.Fatal("shortening the expiry was queued")
 857	}
 858	mustSpend(t, b, id, day, 10)
 859	refuse(t, b, id, 2*day, 1, ErrExpired)
 860}
 861
 862func TestAmendAddingAnExpiryToAnUnexpiringAllowanceIsTighter(t *testing.T) {
 863	b, id := openDefault(t, 0) // ExpiresAt 0 == never
 864	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 2 * day})
 865	if err != nil {
 866		t.Fatalf("Amend: %v", err)
 867	}
 868	if !applied {
 869		t.Fatal("adding an expiry was queued")
 870	}
 871	refuse(t, b, id, 2*day, 1, ErrExpired)
 872}
 873
 874func TestAmendMixedDirectionsIsLooser(t *testing.T) {
 875	// Tighter in one dimension and looser in another is NOT tighter: the
 876	// predicate is weakly tighter in EVERY dimension.
 877	b := MustNew(4)
 878	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, Total: 100})
 879	applied, err := b.Amend(id, alice, 0,
 880		Terms{PerPeriod: 10, PeriodLength: day, Total: 1000})
 881	if err != nil {
 882		t.Fatalf("Amend: %v", err)
 883	}
 884	if applied {
 885		t.Error("a lower PerPeriod with a raised Total applied immediately")
 886	}
 887	if got := b.Remaining(id, 0); got != 50 {
 888		t.Errorf("Remaining: got %d, want 50 (live terms unchanged)", got)
 889	}
 890}
 891
 892// --- Amend: PeriodLength is immutable ------------------------------------
 893
 894func TestAmendCannotChangePeriodLength(t *testing.T) {
 895	// Moving every future boundary is an enlargement vector wearing a
 896	// different hat, so it is refused in BOTH directions rather than queued.
 897	b, id := openDefault(t, 0)
 898	for _, pl := range []int64{day - 1, day + 1, 0, 1} {
 899		_, err := b.Amend(id, alice, 0, Terms{PerPeriod: 50, PeriodLength: pl})
 900		if err != ErrInvalidTerms {
 901			t.Errorf("PeriodLength %d: got %v, want ErrInvalidTerms", pl, err)
 902		}
 903	}
 904	// And the tighter-or-equal predicate independently refuses it, so the
 905	// check in Amend is not the only thing standing in the way.
 906	a := Terms{PerPeriod: 1, PeriodLength: day}
 907	c := Terms{PerPeriod: 1, PeriodLength: day + 1}
 908	if a.tighterOrEqualTo(c) || c.tighterOrEqualTo(a) {
 909		t.Error("tighterOrEqualTo accepted a PeriodLength change")
 910	}
 911}
 912
 913// --- Amend: no reachable boundary ----------------------------------------
 914
 915func TestAmendLooserOnALifetimeAllowanceIsRefused(t *testing.T) {
 916	// PeriodLength 0 has no next boundary, so queueing would park the
 917	// amendment forever and report it as accepted.
 918	b := MustNew(4)
 919	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: 0})
 920	_, err := b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: 0})
 921	if err != ErrNoBoundary {
 922		t.Errorf("got %v, want ErrNoBoundary", err)
 923	}
 924	// A tightening on a lifetime allowance is still fine: it needs no window.
 925	applied, err := b.Amend(id, alice, 0, Terms{PerPeriod: 10, PeriodLength: 0})
 926	if err != nil {
 927		t.Fatalf("tightening a lifetime allowance: %v", err)
 928	}
 929	if !applied {
 930		t.Error("the tightening was queued")
 931	}
 932}
 933
 934func TestAmendLooserIsRefusedWhenTheExpiryPrecedesTheNextBoundary(t *testing.T) {
 935	// The ordinary-use case: tightening ExpiresAt applies IMMEDIATELY, so an
 936	// owner can pull the expiry in below the next boundary and then try to
 937	// loosen PerPeriod. The boundary the loosening waits for never opens.
 938	b := MustNew(4)
 939	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 10 * day})
 940	applied, err := b.Amend(id, alice, 0,
 941		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day / 2})
 942	if err != nil {
 943		t.Fatalf("tightening the expiry: %v", err)
 944	}
 945	if !applied {
 946		t.Fatal("the tightening was queued")
 947	}
 948	// Next boundary is `day`; the allowance dies at day/2.
 949	_, err = b.Amend(id, alice, 0, Terms{PerPeriod: 500, PeriodLength: day, ExpiresAt: day / 2})
 950	if err != ErrNoBoundary {
 951		t.Errorf("got %v, want ErrNoBoundary", err)
 952	}
 953	// Pushing the expiry back out is itself a loosening, so it cannot be the
 954	// owner's way out: it is judged against the same dead window and refused
 955	// identically. The tightening is one-way without a reachable boundary,
 956	// and Revoke plus a fresh Open is the only route.
 957	if _, err := b.Amend(id, alice, 0,
 958		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 5 * day}); err != ErrNoBoundary {
 959		t.Errorf("re-expanding the expiry: got %v, want ErrNoBoundary", err)
 960	}
 961	a, _ := b.Get(id)
 962	if a.Terms.ExpiresAt != day/2 {
 963		t.Errorf("a loosened expiry applied immediately: %d", a.Terms.ExpiresAt)
 964	}
 965}
 966
 967func TestTheUnreachableBoundaryTestIsGreaterOrEqualNotGreater(t *testing.T) {
 968	// An expiry exactly AT the next boundary is unreachable, because the
 969	// window opens at `day` and IsExpired is already true at `day`. Off by
 970	// one in the comparison would queue an amendment into a window that
 971	// exists for zero instants.
 972	b := MustNew(4)
 973	id, _ := b.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day})
 974	looser := Terms{PerPeriod: 500, PeriodLength: day, ExpiresAt: day}
 975	if _, err := b.Amend(id, alice, 0, looser); err != ErrNoBoundary {
 976		t.Errorf("expiry at the boundary: got %v, want ErrNoBoundary", err)
 977	}
 978
 979	// One instant later the boundary IS reachable, which is what keeps the
 980	// check above from being a tool that merely always refuses.
 981	b2 := MustNew(4)
 982	id2, _ := b2.Open(alice, bob, 0, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day + 1})
 983	applied, err := b2.Amend(id2, alice, 0,
 984		Terms{PerPeriod: 500, PeriodLength: day, ExpiresAt: day + 1})
 985	if err != nil {
 986		t.Fatalf("expiry one past the boundary: %v", err)
 987	}
 988	if applied {
 989		t.Fatal("the loosening applied immediately")
 990	}
 991	// And the one instant it is alive for actually honors the new terms.
 992	mustSpend(t, b2, id2, day, 500)
 993}
 994
 995func TestNextBoundaryRefusesUnrepresentableArithmetic(t *testing.T) {
 996	// An unguarded Anchor + (idx+1)*PeriodLength would wrap and report a
 997	// boundary in the PAST, which is the dangerous direction: Amend would
 998	// queue a loosening as though it were about to take effect.
 999	a := Allowance{Anchor: 0, Terms: Terms{PerPeriod: 1, PeriodLength: maxInt64}}
1000	if _, ok := a.nextBoundary(1); ok {
1001		t.Error("steps * PeriodLength overflow was not detected")
1002	}
1003	if nb, ok := a.nextBoundary(0); !ok || nb != maxInt64 {
1004		t.Errorf("the representable boundary: got %d/%v, want %d/true",
1005			nb, ok, maxInt64)
1006	}
1007	// Overflow in the Anchor + offset term rather than the product.
1008	a2 := Allowance{Anchor: maxInt64 - 5, Terms: Terms{PerPeriod: 1, PeriodLength: 10}}
1009	if _, ok := a2.nextBoundary(0); ok {
1010		t.Error("Anchor + offset overflow was not detected")
1011	}
1012	// A lifetime allowance has none by construction.
1013	a3 := Allowance{Terms: Terms{PerPeriod: 1, PeriodLength: 0}}
1014	if _, ok := a3.nextBoundary(0); ok {
1015		t.Error("a lifetime allowance reported a boundary")
1016	}
1017}
1018
1019func TestAmendLooserIsRefusedWhenTheBoundaryIsUnrepresentable(t *testing.T) {
1020	b := MustNew(4)
1021	id, _ := b.Open(alice, bob, maxInt64-5, Terms{PerPeriod: 50, PeriodLength: 10})
1022	_, err := b.Amend(id, alice, maxInt64-5, Terms{PerPeriod: 500, PeriodLength: 10})
1023	if err != ErrNoBoundary {
1024		t.Errorf("got %v, want ErrNoBoundary", err)
1025	}
1026}
1027
1028// --- Amend: authorization and argument checks ----------------------------
1029
1030func TestAmendRejectsNonOwners(t *testing.T) {
1031	b, id := openDefault(t, 0)
1032	tighter := Terms{PerPeriod: 10, PeriodLength: day}
1033	for _, who := range []string{bob, carol, ""} {
1034		if _, err := b.Amend(id, who, 0, tighter); err != ErrNotOwner {
1035			t.Errorf("Amend as %q: got %v, want ErrNotOwner", who, err)
1036		}
1037	}
1038	if _, err := b.Amend(99, alice, 0, tighter); err != ErrNotFound {
1039		t.Errorf("unknown id: got %v, want ErrNotFound", err)
1040	}
1041}
1042
1043func TestAmendRejectsBadArgumentsAndDeadAllowances(t *testing.T) {
1044	b := MustNew(4)
1045	id, _ := b.Open(alice, bob, 1000, Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 2000})
1046	tighter := Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 2000}
1047
1048	if _, err := b.Amend(id, alice, -1, tighter); err != ErrInvalidNow {
1049		t.Errorf("negative now: got %v, want ErrInvalidNow", err)
1050	}
1051	if _, err := b.Amend(id, alice, 999, tighter); err != ErrNotStarted {
1052		t.Errorf("before the anchor: got %v, want ErrNotStarted", err)
1053	}
1054	if _, err := b.Amend(id, alice, 2000, tighter); err != ErrExpired {
1055		t.Errorf("expired: got %v, want ErrExpired", err)
1056	}
1057	if _, err := b.Amend(id, alice, 1000, Terms{PerPeriod: 0, PeriodLength: day}); err != ErrInvalidTerms {
1058		t.Errorf("invalid terms: got %v, want ErrInvalidTerms", err)
1059	}
1060}
1061
1062func TestAmendIsValidatedAgainstTheAnchorNotNow(t *testing.T) {
1063	// Documented and deliberate: an expiry between the anchor and now is a
1064	// tightening to the point of death, which is the owner's prerogative.
1065	b := MustNew(4)
1066	id, _ := b.Open(alice, bob, 1000, Terms{PerPeriod: 50, PeriodLength: day})
1067	applied, err := b.Amend(id, alice, 1050,
1068		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 1001})
1069	if err != nil {
1070		t.Fatalf("Amend: %v", err)
1071	}
1072	if !applied {
1073		t.Fatal("it was queued")
1074	}
1075	refuse(t, b, id, 1050, 1, ErrExpired)
1076	// And the slot is reclaimable, which is what keeps this from being a leak.
1077	if err := b.Reap(id, 1050); err != nil {
1078		t.Fatalf("Reap: %v", err)
1079	}
1080}
1081
1082// --- Iterate --------------------------------------------------------------
1083
1084func TestIterateVisitsEveryAllowanceInIDOrder(t *testing.T) {
1085	b := MustNew(16)
1086	terms := Terms{PerPeriod: 10, PeriodLength: day}
1087	// Enough entries that a lexical key order would diverge from numeric
1088	// order without the zero padding (9 then 10).
1089	var want []uint64
1090	for i := 0; i < 12; i++ {
1091		id, err := b.Open(alice, bob, 0, terms)
1092		if err != nil {
1093			t.Fatalf("Open %d: %v", i, err)
1094		}
1095		want = append(want, id)
1096	}
1097	var got []uint64
1098	b.Iterate(func(a Allowance) bool {
1099		got = append(got, a.ID)
1100		return false
1101	})
1102	if len(got) != len(want) {
1103		t.Fatalf("visited %d, want %d", len(got), len(want))
1104	}
1105	for i := range want {
1106		if got[i] != want[i] {
1107			t.Fatalf("order diverged at %d: got %v, want %v", i, got, want)
1108		}
1109	}
1110}
1111
1112func TestIterateStopsEarly(t *testing.T) {
1113	b := MustNew(8)
1114	terms := Terms{PerPeriod: 10, PeriodLength: day}
1115	for i := 0; i < 5; i++ {
1116		b.Open(alice, bob, 0, terms)
1117	}
1118	n := 0
1119	b.Iterate(func(a Allowance) bool {
1120		n++
1121		return n == 2
1122	})
1123	if n != 2 {
1124		t.Errorf("visited %d, want 2", n)
1125	}
1126}
1127
1128func TestIteratePassesByValue(t *testing.T) {
1129	b, id := openDefault(t, 0)
1130	b.Iterate(func(a Allowance) bool {
1131		a.Terms.PerPeriod = 1 << 40
1132		a.SpentWindow = -1
1133		return false
1134	})
1135	after, _ := b.Get(id)
1136	if after.Terms.PerPeriod != 50 || after.SpentWindow != 0 {
1137		t.Errorf("fn mutated the book through its argument: %+v", after)
1138	}
1139}
1140
1141func TestIterateOnAnEmptyBook(t *testing.T) {
1142	b := MustNew(4)
1143	b.Iterate(func(a Allowance) bool {
1144		t.Error("fn was called on an empty book")
1145		return false
1146	})
1147}
1148
1149// --- the shared-implementation property ----------------------------------
1150
1151func TestRemainingAgreesWithWhatSpendHonors(t *testing.T) {
1152	// Spend and Remaining share one `at`, so the read and the write cannot
1153	// disagree -- an allowance must never report more than it honors, nor
1154	// honor more than it reports. This walks a clock across boundaries,
1155	// amendments and caps, asserting both halves at every step.
1156	clocks := []int64{0, 10, 99, 100, 150, 199, 200, 201, 500, 501, 900, 1000}
1157
1158	for _, c := range clocks {
1159		b := MustNew(4)
1160		id, err := b.Open(alice, bob, 0,
1161			Terms{PerPeriod: 50, PeriodLength: day, Total: 120, ExpiresAt: 1000})
1162		if err != nil {
1163			t.Fatalf("Open: %v", err)
1164		}
1165		// Some history, so the state at c is not pristine.
1166		if err := b.Spend(id, bob, 0, 30); err != nil {
1167			t.Fatalf("seed Spend: %v", err)
1168		}
1169		if _, err := b.Amend(id, alice, 0,
1170			Terms{PerPeriod: 500, PeriodLength: day, Total: 120, ExpiresAt: 1000}); err != nil {
1171			t.Fatalf("seed Amend: %v", err)
1172		}
1173
1174		r := b.Remaining(id, c)
1175		if r < 0 {
1176			t.Fatalf("clock %d: Remaining is negative: %d", c, r)
1177		}
1178		if r > 0 {
1179			// Exactly what it promised must be spendable...
1180			if err := b.Spend(id, bob, c, r); err != nil {
1181				t.Errorf("clock %d: Remaining said %d but Spend refused it: %v",
1182					c, r, err)
1183			}
1184			// ...and nothing beyond it.
1185			if err := b.Spend(id, bob, c, 1); err == nil {
1186				t.Errorf("clock %d: honored 1 past the reported %d", c, r)
1187			}
1188		} else {
1189			if err := b.Spend(id, bob, c, 1); err == nil {
1190				t.Errorf("clock %d: Remaining said 0 but Spend honored a draw", c)
1191			}
1192		}
1193	}
1194}
1195
1196// --- the last-written record vs the live view ------------------------------
1197//
1198// The three tests below were written AFTER the fact, from measurements taken
1199// on onyx-1 against the deployed bytes, and they are CHARACTERIZATION tests:
1200// they assert the behaviour this package actually has, not the behaviour it
1201// ought to have. They exist because that behaviour misled its own first
1202// consumer, and a hazard nothing asserts is a hazard that gets re-discovered.
1203//
1204// The hazard, stated once: Allowance's exported FIELDS are the last-WRITTEN
1205// record, while the only live-resolved view is behind the unexported at(),
1206// reachable from outside this package through Remaining alone. Between a
1207// window boundary and the next write there is no write to update the record,
1208// so Terms, Pending, HasPending, WindowIndex and SpentWindow all describe a
1209// window the clock has left -- and a consumer that reads them to DISPLAY
1210// state publishes figures that disagree with what Spend will honor.
1211//
1212// Nothing here is a correctness bug in enforcement. Spend and Remaining both
1213// route through at() and were correct in all ten live transactions; see
1214// onyx/DEPLOYMENT.md, Group 9. What is wrong is the shape of the API.
1215
1216func TestExportedFieldsAreTheLastWrittenRecordNotTheLiveView(t *testing.T) {
1217	// This is the on-chain sequence of budgets grant #1, in miniature:
1218	// open, spend, tighten, queue a loosening, then let the window roll with
1219	// no write at all. Measured on onyx-1 at height 189411, 31 windows after
1220	// the grant was opened at 186267.
1221	b, id := openDefault(t, 0) // PerPeriod 50, PeriodLength 100
1222	mustSpend(t, b, id, 10, 30)
1223
1224	// Tighter-or-equal applies now.
1225	applied, err := b.Amend(id, alice, 20, Terms{PerPeriod: 20, PeriodLength: day})
1226	if err != nil {
1227		t.Fatalf("tightening Amend: %v", err)
1228	}
1229	if !applied {
1230		t.Fatalf("a tightening amendment should apply immediately")
1231	}
1232
1233	// Looser is queued for the next boundary.
1234	applied, err = b.Amend(id, alice, 30, Terms{PerPeriod: 80, PeriodLength: day})
1235	if err != nil {
1236		t.Fatalf("loosening Amend: %v", err)
1237	}
1238	if applied {
1239		t.Fatalf("a loosening amendment should be queued, not applied")
1240	}
1241
1242	// Now cross several boundaries WITHOUT writing. now = 3150 is window 31,
1243	// matching the live measurement.
1244	const now = int64(3150)
1245
1246	a, err := b.Get(id)
1247	if err != nil {
1248		t.Fatalf("Get: %v", err)
1249	}
1250
1251	// The live view, through the only exported door that resolves it.
1252	if got := b.Remaining(id, now); got != 80 {
1253		t.Fatalf("Remaining: got %d, want 80 (the queued terms took effect "+
1254			"at the boundary and the window spend reset)", got)
1255	}
1256	// And enforcement agrees with the live view, which is the half that
1257	// matters: on chain this was a real 800000ugnot draw at height 189436
1258	// (tx f58bc91016b055bfdfeae5e60f7aea1f6652d0f9d45152c89f7a0bcc0bafbf64)
1259	// against a record still reporting a cap of 200000.
1260	if err := b.Spend(id, bob, now, 80); err != nil {
1261		t.Fatalf("Spend of the full live Remaining was refused: %v", err)
1262	}
1263
1264	// The record, meanwhile, says three things that are all false at `now`.
1265	// Each is asserted individually so a future change breaks the specific
1266	// claim rather than a lump.
1267	if a.Terms.PerPeriod != 20 {
1268		t.Errorf("Allowance.Terms.PerPeriod: got %d, want the SUPERSEDED 20 "+
1269			"-- the field is the last-written record", a.Terms.PerPeriod)
1270	}
1271	if !a.HasPending {
1272		t.Errorf("Allowance.HasPending: got false, want true -- the pending " +
1273			"flag stays set after its boundary until the next write, so a " +
1274			"consumer reports an amendment as 'queued' that is already live")
1275	}
1276	if a.Pending.PerPeriod != 80 {
1277		t.Errorf("Allowance.Pending.PerPeriod: got %d, want 80",
1278			a.Pending.PerPeriod)
1279	}
1280	if a.WindowIndex != 0 {
1281		t.Errorf("Allowance.WindowIndex: got %d, want the LEFT window 0",
1282			a.WindowIndex)
1283	}
1284	if a.SpentWindow != 30 {
1285		t.Errorf("Allowance.SpentWindow: got %d, want the left window's 30",
1286			a.SpentWindow)
1287	}
1288
1289	// The disagreement, stated as the one assertion a consumer cares about:
1290	// a display path reading the field publishes a cap of 20 over the very
1291	// window in which the enforcer just honored 80 -- a factor of four, and
1292	// in the dangerous direction (the owner is shown LESS authority than the
1293	// delegate actually holds).
1294	if a.Terms.PerPeriod >= 80 {
1295		t.Fatalf("Terms.PerPeriod is %d, which no longer understates the "+
1296			"honored cap of 80: this test is describing behaviour the "+
1297			"package has stopped having", a.Terms.PerPeriod)
1298	}
1299}
1300
1301func TestTheStaleRecordSelfHealsOnTheNextWrite(t *testing.T) {
1302	// The hazard is bounded: it lasts from a boundary until the next write,
1303	// and any write ends it. Measured on chain -- after the draw at 189436,
1304	// GrantInfo reported 800000 and PendingTerms reported nothing queued.
1305	b, id := openDefault(t, 0)
1306	mustSpend(t, b, id, 10, 30)
1307	if _, err := b.Amend(id, alice, 30, Terms{PerPeriod: 80, PeriodLength: day}); err != nil {
1308		t.Fatalf("Amend: %v", err)
1309	}
1310
1311	const now = int64(3150)
1312	mustSpend(t, b, id, now, 5)
1313
1314	a, err := b.Get(id)
1315	if err != nil {
1316		t.Fatalf("Get: %v", err)
1317	}
1318	if a.Terms.PerPeriod != 80 {
1319		t.Errorf("after a write, Terms.PerPeriod: got %d, want 80", a.Terms.PerPeriod)
1320	}
1321	if a.HasPending {
1322		t.Errorf("after a write, HasPending should be cleared")
1323	}
1324	if a.WindowIndex != 31 {
1325		t.Errorf("after a write, WindowIndex: got %d, want 31", a.WindowIndex)
1326	}
1327	if a.SpentWindow != 5 {
1328		t.Errorf("after a write, SpentWindow: got %d, want 5", a.SpentWindow)
1329	}
1330}
1331
1332func TestRemainingIsTheOnlyExportedLiveView(t *testing.T) {
1333	// The remedy a future version wants is an exported live-resolved view --
1334	// Allowance.At(now), say -- so a consumer's display path cannot read a
1335	// stale field by accident.
1336	//
1337	// THIS TEST DOES NOT ENFORCE THAT ABSENCE, and the first draft of this
1338	// comment claimed it did ("it fails the moment such a method is added").
1339	// That was false: nothing below would fail if At were added, and Gno
1340	// offers no reflection over a method set to write the check with. The
1341	// claim is removed rather than reworded, because a test whose comment
1342	// promises a guard it does not have is worse than no test -- a reader
1343	// stops looking, which is the §7a/C11 failure in miniature.
1344	//
1345	// What it actually does: pin the two live-resolving doors that DO exist,
1346	// so a change to either breaks here. The deliberateness of At's absence
1347	// lives in prose (onyx/p/spendbook/MAINNET_ASSESSMENT.md) and nowhere
1348	// else; that is a documented gap, not an enforced one.
1349	b, id := openDefault(t, 0)
1350	mustSpend(t, b, id, 10, 30)
1351
1352	// IsExpired resolves live. In v1 this comment read "IsExpired resolves
1353	// live, but expiry does not depend on the window, so it is not affected
1354	// by the hazard" -- and the second half was FALSE about the package it
1355	// was written in. Expiry DID depend on the window, because a pending
1356	// amendment can move ExpiresAt and v1's IsExpired read the stored field
1357	// rather than asking at(). That is Y2, stated in the affirmative, in a
1358	// test comment. It is corrected rather than deleted, because the
1359	// sentence is a record of how the defect stayed invisible: the claim
1360	// read like a reason not to look.
1361	//
1362	// This case is unaffected either way -- the allowance has no expiry and
1363	// no pending expiry -- which is exactly why it never caught anything.
1364	// The claim is enforced in the Y2 regression block at the bottom of
1365	// this file.
1366	if a, _ := b.Get(id); a.IsExpired(3150) {
1367		t.Errorf("an allowance with no expiry is never expired")
1368	}
1369	// Remaining resolves live and IS affected -- it is the whole remedy a
1370	// consumer has today.
1371	if got := b.Remaining(id, 3150); got != 50 {
1372		t.Errorf("Remaining across a boundary: got %d, want the refilled 50", got)
1373	}
1374	if a, _ := b.Get(id); a.Remaining(3150) != 50 {
1375		t.Errorf("Allowance.Remaining must agree with Book.Remaining")
1376	}
1377}
1378
1379// --- Y2 regression: the expiry is resolved LIVE, in both directions -------
1380//
1381// EVERY case below carries its own "v1:" line saying whether it fails or passes
1382// against v1, and where it fails, v1's wrong answer is written out beside the
1383// assertion. The first draft of this header instead claimed "every case below
1384// FAILS against v1 unless it is marked otherwise" -- one blanket adjective over
1385// a whole subject set, which is the defect this repo keeps catching in its own
1386// prose. Measured, it was wrong about five of the fourteen: a preservation case
1387// (authorization unchanged, the zero value unchanged, the pre-anchor guard, the
1388// pending terms not promoted early) is SUPPOSED to pass against v1, and reading
1389// the header would have made a reader expect it to fail and distrust the suite.
1390//
1391// The split is not restated here; it is re-derivable, and the probe that
1392// derives it is the non-vacuity control for this whole block -- a regression
1393// test that passes against the source it was written to indict proves nothing:
1394//
1395//	mkdir /tmp/v1probe && cd /tmp/v1probe
1396//	cp <repo>/onyx/p/spendbook/spendbook.gno .          # the DEPLOYED bytes
1397//	cp <repo>/onyx/p/spendbook/v2/spendbook_test.gno .  # this file
1398//	cp <repo>/onyx/p/spendbook/v2/gnomod.toml .
1399//	gno test -v .      # expect exit 1; read WHICH names fail
1400//
1401// The block is organised by the two directions the stale read pointed:
1402//
1403//	UNDER-authorization  an extension or a removal of the expiry is accepted,
1404//	                     queued, comes into force at the boundary, and is then
1405//	                     ignored at the OLD expiry -- so the owner's own
1406//	                     amendment is discarded and a third party may Reap.
1407//	OVER-authorization   an amendment that RAISES PerPeriod and LOWERS
1408//	                     ExpiresAt queues as a whole, and past the boundary the
1409//	                     raised cap is honoured while the lowered expiry is
1410//	                     not -- a draw past an expiry the owner set.
1411
1412// openExtendable opens the shape every extension case below needs: a window
1413// of 100 with a cap of 50, expiring at 300, so the next boundary (100) lands
1414// strictly before the expiry and a loosening is therefore queueable.
1415func openExtendable(t *testing.T) (*Book, uint64) {
1416	t.Helper()
1417	b := MustNew(4)
1418	id, err := b.Open(alice, bob, 0,
1419		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 3 * day})
1420	if err != nil {
1421		t.Fatalf("Open: %v", err)
1422	}
1423	return b, id
1424}
1425
1426// queueExtension raises PerPeriod to 80 and pushes ExpiresAt out to 1000.
1427// Both moves are looser, so the whole amendment queues; raising PerPeriod
1428// alongside the expiry is what makes "the pending terms are not in force
1429// yet" observable before the boundary, where the expiry alone is not.
1430func queueExtension(t *testing.T, b *Book, id uint64, now int64) {
1431	t.Helper()
1432	applied, err := b.Amend(id, alice, now,
1433		Terms{PerPeriod: 80, PeriodLength: day, ExpiresAt: 10 * day})
1434	if err != nil {
1435		t.Fatalf("Amend(extend): %v", err)
1436	}
1437	if applied {
1438		t.Fatalf("an extension of the expiry applied immediately; it is " +
1439			"LOOSER and tighterOrEqualTo must queue it")
1440	}
1441}
1442
1443func TestAnExtensionCanOnlyEverBeQueuedNotApplied(t *testing.T) {
1444	// The premise of the whole defect, asserted first because without it
1445	// there would be a workaround: there is NO way to extend an expiry
1446	// immediately. tighterOrEqualTo reads ExpiresAt 0 as unlimited, so a
1447	// later expiry is looser in that dimension and a later-or-removed expiry
1448	// is the only kind of expiry change that can be queued at all.
1449	//
1450	// v1: PASSES -- it is about tighterOrEqualTo, which the fix does not
1451	// touch. It is here so a reader cannot conclude the queueing path was
1452	// avoidable.
1453	live := Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 3 * day}
1454	for _, tc := range []struct {
1455		name string
1456		to   Terms
1457	}{
1458		{"pushed out", Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 4 * day}},
1459		{"removed", Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 0}},
1460	} {
1461		if tc.to.tighterOrEqualTo(live) {
1462			t.Errorf("%s: tighterOrEqualTo says an expiry %s is tighter; it is "+
1463				"looser, and treating it as tighter would let an owner undo an "+
1464				"expiry mid-window", tc.name, tc.name)
1465		}
1466	}
1467}
1468
1469func TestTheOldExpiryIsNeverReachableWhileStillInForce(t *testing.T) {
1470	// A structural consequence of Amend's queueability guard, and the reason
1471	// the stale read had no legitimate window to be right in: a loosening is
1472	// only queued when the next boundary arrives STRICTLY before the current
1473	// expiry (nb >= ExpiresAt is refused). So by the time the clock reaches
1474	// the old expiry, the boundary has already passed and the pending terms
1475	// are already the terms in force. v1 consulted the old expiry at an
1476	// instant where it was, without exception, superseded.
1477	//
1478	// v1: PASSES -- it is Amend's guard, which the fix does not touch.
1479	b := MustNew(4)
1480	// Expiry at 50, inside window 0, so the next boundary (100) is past it.
1481	id, err := b.Open(alice, bob, 0,
1482		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: day / 2})
1483	if err != nil {
1484		t.Fatalf("Open: %v", err)
1485	}
1486	if _, err := b.Amend(id, alice, 0,
1487		Terms{PerPeriod: 80, PeriodLength: day, ExpiresAt: 10 * day}); err != ErrNoBoundary {
1488		t.Fatalf("queueing against an expiry inside the current window: got %v, "+
1489			"want ErrNoBoundary", err)
1490	}
1491}
1492
1493func TestPendingTermsAreNotInForceBeforeTheBoundary(t *testing.T) {
1494	// The other half of the fix's correctness: routing IsExpired through at()
1495	// must not promote a pending amendment EARLY. Window 0 still runs on the
1496	// old terms in every dimension.
1497	//
1498	// v1: PASSES -- and that is the point. This is a PRESERVATION case: it
1499	// pins the behaviour the fix must not disturb, so it is supposed to hold
1500	// on both sides. A reader who expects it to fail against v1 has misread
1501	// which half of the fix it covers.
1502	b, id := openExtendable(t)
1503	queueExtension(t, b, id, 10)
1504
1505	if got := b.Remaining(id, 50); got != 50 {
1506		t.Errorf("Remaining inside window 0: got %d, want the OLD cap 50", got)
1507	}
1508	if got := b.Remaining(id, day-1); got != 50 {
1509		t.Errorf("Remaining at the last instant of window 0: got %d, want 50", got)
1510	}
1511	refuse(t, b, id, 50, 51, ErrPeriodExceeded)
1512	a, _ := b.Get(id)
1513	if a.Terms.ExpiresAt != 3*day {
1514		t.Errorf("the live expiry in window 0: got %d, want the OLD %d",
1515			a.Terms.ExpiresAt, 3*day)
1516	}
1517	if a.IsExpired(day - 1) {
1518		t.Error("not expired yet at the last instant of window 0")
1519	}
1520}
1521
1522func TestPendingTermsTakeEffectExactlyAtTheBoundary(t *testing.T) {
1523	// Off by one here would either delay the extension by a whole window or
1524	// grant it a window early.
1525	//
1526	// v1: PASSES -- Remaining already resolved through at() in v1, so the
1527	// boundary arithmetic was never the broken part. IsExpired was.
1528	b, id := openExtendable(t)
1529	queueExtension(t, b, id, 10)
1530
1531	if got := b.Remaining(id, day-1); got != 50 {
1532		t.Errorf("one instant before the boundary: got %d, want 50", got)
1533	}
1534	if got := b.Remaining(id, day); got != 80 {
1535		t.Errorf("AT the boundary: got %d, want the pending 80", got)
1536	}
1537}
1538
1539func TestAQueuedExpiryExtensionSurvivesTheOldExpiry(t *testing.T) {
1540	// THE Y2 CASE. No write of any kind between queueing the amendment and
1541	// the old expiry -- that is the whole point, because any write promotes
1542	// the pending terms into the record and ends the hazard.
1543	//
1544	// v1: FAILS. At now=300: IsExpired reads the stored ExpiresAt of 300, reports
1545	// EXPIRED, Remaining answers 0 and Spend returns ErrExpired -- on an
1546	// allowance whose owner extended it to 1000 and was told "accepted".
1547	b, id := openExtendable(t)
1548	queueExtension(t, b, id, 10)
1549
1550	const oldExpiry = 3 * day // 300
1551	const newExpiry = 10 * day
1552
1553	a, _ := b.Get(id)
1554	if a.IsExpired(oldExpiry) {
1555		t.Fatalf("IsExpired(%d) is true: the extension to %d was accepted and "+
1556			"queued, took effect at the boundary, and has been discarded",
1557			oldExpiry, newExpiry)
1558	}
1559	if got := b.Remaining(id, oldExpiry); got != 80 {
1560		t.Errorf("Remaining(%d): got %d, want the extended terms' 80",
1561			oldExpiry, got)
1562	}
1563	// Enforcement must agree with the view, which is the half that moves value.
1564	if err := b.Spend(id, bob, oldExpiry, 80); err != nil {
1565		t.Fatalf("Spend at the old expiry was refused: %v", err)
1566	}
1567	// And the new expiry does bite, at exactly the right instant.
1568	if got := b.Remaining(id, newExpiry-1); got == 0 {
1569		t.Error("one instant before the new expiry the allowance must be alive")
1570	}
1571	if got := b.Remaining(id, newExpiry); got != 0 {
1572		t.Errorf("Remaining at the new expiry %d: got %d, want 0", newExpiry, got)
1573	}
1574	refuse(t, b, id, newExpiry, 1, ErrExpired)
1575}
1576
1577func TestAThirdPartyCannotReapAnExtendedAllowance(t *testing.T) {
1578	// v1: FAILS.
1579	//
1580	// Reap is PERMISSIONLESS, which is sound only because an expired
1581	// allowance can authorize nothing. Under v1 the extended allowance read
1582	// as expired at 300, so the justification failed and any caller could
1583	// destroy a live grant -- the one place the defect let a third party act.
1584	b, id := openExtendable(t)
1585	queueExtension(t, b, id, 10)
1586
1587	const oldExpiry = 3 * day
1588	if err := b.Reap(id, oldExpiry); err != ErrNotExpired {
1589		t.Fatalf("Reap at the old expiry: got %v, want ErrNotExpired -- a "+
1590			"third party just deleted a live allowance", err)
1591	}
1592	if b.Count() != 1 {
1593		t.Fatalf("live count after the refused Reap: got %d, want 1", b.Count())
1594	}
1595	// Once genuinely expired it is reapable, which is the behaviour Reap is
1596	// for and must not be lost by fixing the predicate.
1597	if err := b.Reap(id, 10*day); err != nil {
1598		t.Fatalf("Reap at the real expiry: %v", err)
1599	}
1600	if b.Count() != 0 {
1601		t.Errorf("live count after the Reap: got %d, want 0", b.Count())
1602	}
1603}
1604
1605func TestExpiryRemovalSurvivesTheOldExpiryWithNoWrite(t *testing.T) {
1606	// ExpiresAt -> 0 is the same class as an extension by way of
1607	// tighterOrEqualTo's other arm: 0 means unlimited, so removing the expiry
1608	// is LOOSER and can only be queued.
1609	//
1610	// v1: FAILS. v1 covered this only on the WRITTEN path -- see
1611	// TestAmendExpiresAtZeroIsLooserNotTighter above, which spends at the
1612	// boundary and so promotes the pending terms before reading them. With no
1613	// write the removal was discarded at the old expiry exactly as an
1614	// extension was, and "no write" is the ordinary case for an allowance
1615	// nobody has drawn on yet.
1616	b := MustNew(4)
1617	id, err := b.Open(alice, bob, 0,
1618		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 3 * day})
1619	if err != nil {
1620		t.Fatalf("Open: %v", err)
1621	}
1622	applied, err := b.Amend(id, alice, 10,
1623		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 0})
1624	if err != nil {
1625		t.Fatalf("Amend(remove expiry): %v", err)
1626	}
1627	if applied {
1628		t.Fatal("removing the expiry applied immediately")
1629	}
1630
1631	const oldExpiry = 3 * day
1632	a, _ := b.Get(id)
1633	if a.IsExpired(oldExpiry) {
1634		t.Fatalf("IsExpired(%d) is true after the expiry was REMOVED", oldExpiry)
1635	}
1636	if got := b.Remaining(id, oldExpiry); got != 50 {
1637		t.Errorf("Remaining(%d): got %d, want 50", oldExpiry, got)
1638	}
1639	if err := b.Reap(id, oldExpiry); err != ErrNotExpired {
1640		t.Errorf("Reap(%d): got %v, want ErrNotExpired", oldExpiry, err)
1641	}
1642	// "No expiry" means no expiry, at any instant, forever.
1643	if a.IsExpired(maxInt64) {
1644		t.Error("an allowance whose expiry was removed must never expire")
1645	}
1646	if err := b.Spend(id, bob, 1000*day, 50); err != nil {
1647		t.Fatalf("Spend long after the removed expiry: %v", err)
1648	}
1649	// The stored record is still stale -- the fix does not write, it stops
1650	// READING the stale field. Asserted so the mechanism is not mistaken for
1651	// a migration of stored state.
1652	a, _ = b.Get(id)
1653	if a.Terms.ExpiresAt != 0 {
1654		t.Errorf("after the spend promoted the terms, ExpiresAt: got %d, want 0",
1655			a.Terms.ExpiresAt)
1656	}
1657}
1658
1659func TestAQueuedTighteningOfTheExpiryStopsAuthorizingDraws(t *testing.T) {
1660	// v1: FAILS -- THE MIRROR CASE, and the dangerous direction: v1
1661	// OVER-authorized here.
1662	//
1663	// tighterOrEqualTo is weakly-tighter in EVERY dimension, so an amendment
1664	// that raises PerPeriod and lowers ExpiresAt is looser as a whole and
1665	// queues as a whole. Past the boundary, v1's at() handed Spend the RAISED
1666	// cap while v1's IsExpired reported the allowance alive on the OLD, later
1667	// expiry -- so the spender could draw, at the new higher cap, past an
1668	// expiry the owner themselves had set. No bound in the package stopped
1669	// it: the bound had moved and the gate had not been told.
1670	b := MustNew(4)
1671	id, err := b.Open(alice, bob, 0,
1672		Terms{PerPeriod: 100, PeriodLength: 1000, ExpiresAt: 5000})
1673	if err != nil {
1674		t.Fatalf("Open: %v", err)
1675	}
1676	applied, err := b.Amend(id, alice, 0,
1677		Terms{PerPeriod: 200, PeriodLength: 1000, ExpiresAt: 3000})
1678	if err != nil {
1679		t.Fatalf("Amend(raise cap, pull expiry in): %v", err)
1680	}
1681	if applied {
1682		t.Fatal("a mixed-direction amendment applied immediately")
1683	}
1684
1685	// Before the new expiry the owner's intent holds in full: raised cap.
1686	if got := b.Remaining(id, 1500); got != 200 {
1687		t.Errorf("Remaining(1500): got %d, want the raised 200", got)
1688	}
1689
1690	// At and after the new expiry it is dead, even though the record still
1691	// says 5000.
1692	a, _ := b.Get(id)
1693	if a.Terms.ExpiresAt != 5000 {
1694		t.Fatalf("precondition: the stored expiry should still be the stale "+
1695			"5000, got %d", a.Terms.ExpiresAt)
1696	}
1697	for _, now := range []int64{3000, 3500, 4999} {
1698		if !a.IsExpired(now) {
1699			t.Errorf("IsExpired(%d) is false: the owner pulled the expiry in to "+
1700				"3000 and v1 would authorize a 200 draw here", now)
1701		}
1702		if got := b.Remaining(id, now); got != 0 {
1703			t.Errorf("Remaining(%d): got %d, want 0", now, got)
1704		}
1705	}
1706	refuse(t, b, id, 3500, 1, ErrExpired)
1707	refuse(t, b, id, 3500, 200, ErrExpired)
1708
1709	// It is now genuinely dead, so Reap's permissionless justification holds
1710	// and the slot is reclaimable.
1711	if err := b.Reap(id, 3500); err != nil {
1712		t.Errorf("Reap on the correctly-expired allowance: %v", err)
1713	}
1714}
1715
1716func TestRevokeStillWorksOnWhatTheFixDeclaresExpired(t *testing.T) {
1717	// The owner's fast remedy must not be taken away by making IsExpired
1718	// stricter. Revoke carries no IsExpired gate, deliberately, and the
1719	// mirror case is the first state in which the distinction is observable:
1720	// the allowance is expired on terms that are not in the record.
1721	//
1722	// v1: FAILS, on its SECOND half only. Revoke passes on both sides -- it has
1723	// no expiry gate to get wrong. The Amend assertion is what v1 misses: at
1724	// 3500 v1 reads the stored 5000, calls the allowance alive, and lets the
1725	// owner amend a grant that is dead on its own queued terms.
1726	b := MustNew(4)
1727	id, _ := b.Open(alice, bob, 0,
1728		Terms{PerPeriod: 100, PeriodLength: 1000, ExpiresAt: 5000})
1729	if _, err := b.Amend(id, alice, 0,
1730		Terms{PerPeriod: 200, PeriodLength: 1000, ExpiresAt: 3000}); err != nil {
1731		t.Fatalf("Amend: %v", err)
1732	}
1733	if err := b.Revoke(id, alice); err != nil {
1734		t.Fatalf("Revoke: %v", err)
1735	}
1736	if b.Count() != 0 {
1737		t.Errorf("live count after Revoke: got %d, want 0", b.Count())
1738	}
1739	// Amend, by contrast, IS gated on expiry and now correctly refuses --
1740	// the owner cannot amend a grant that is dead on its own queued terms.
1741	b2 := MustNew(4)
1742	id2, _ := b2.Open(alice, bob, 0,
1743		Terms{PerPeriod: 100, PeriodLength: 1000, ExpiresAt: 5000})
1744	if _, err := b2.Amend(id2, alice, 0,
1745		Terms{PerPeriod: 200, PeriodLength: 1000, ExpiresAt: 3000}); err != nil {
1746		t.Fatalf("Amend: %v", err)
1747	}
1748	if _, err := b2.Amend(id2, alice, 3500,
1749		Terms{PerPeriod: 10, PeriodLength: 1000, ExpiresAt: 3000}); err != ErrExpired {
1750		t.Errorf("Amend on a correctly-expired allowance: got %v, want ErrExpired",
1751			err)
1752	}
1753}
1754
1755func TestTheFixGrantsNoAuthorityToAnyoneNew(t *testing.T) {
1756	// The extended window is a new state, so the authorization checks are
1757	// re-asserted inside it rather than assumed to carry.
1758	//
1759	// v1: PASSES -- a PRESERVATION case. The defect was never an authorization
1760	// defect for Spend, Amend or Revoke; it was a liveness defect that let a
1761	// permissionless Reap act (covered above) and let an authorized spender
1762	// draw past an expiry. Nobody new ever gained a right, on either side.
1763	b, id := openExtendable(t)
1764	queueExtension(t, b, id, 10)
1765	const oldExpiry = 3 * day
1766
1767	if err := b.Spend(id, carol, oldExpiry, 1); err != ErrNotSpender {
1768		t.Errorf("Spend by a stranger: got %v, want ErrNotSpender", err)
1769	}
1770	if err := b.Spend(id, alice, oldExpiry, 1); err != ErrNotSpender {
1771		t.Errorf("Spend by the OWNER: got %v, want ErrNotSpender", err)
1772	}
1773	if _, err := b.Amend(id, carol, oldExpiry,
1774		Terms{PerPeriod: 999, PeriodLength: day}); err != ErrNotOwner {
1775		t.Errorf("Amend by a stranger: got %v, want ErrNotOwner", err)
1776	}
1777	if _, err := b.Amend(id, bob, oldExpiry,
1778		Terms{PerPeriod: 999, PeriodLength: day}); err != ErrNotOwner {
1779		t.Errorf("Amend by the SPENDER: got %v, want ErrNotOwner", err)
1780	}
1781	if err := b.Revoke(id, carol); err != ErrNotOwner {
1782		t.Errorf("Revoke by a stranger: got %v, want ErrNotOwner", err)
1783	}
1784	if err := b.Revoke(id, bob); err != ErrNotOwner {
1785		t.Errorf("Revoke by the SPENDER: got %v, want ErrNotOwner", err)
1786	}
1787	if b.Count() != 1 {
1788		t.Errorf("live count after six refused calls: got %d, want 1", b.Count())
1789	}
1790}
1791
1792func TestAccountingIsUnchangedAcrossTheExtendedBoundary(t *testing.T) {
1793	// The fix must move the expiry gate and nothing else. The window reset,
1794	// the lifetime accumulator and the window index all behave exactly as the
1795	// package doc describes, measured across the boundary the extension made
1796	// reachable.
1797	//
1798	// v1: FAILS -- not on an accounting assertion, but on mustSpend at the old
1799	// expiry, which v1 refuses with ErrExpired. The accounting it was written
1800	// to pin is therefore UNREACHABLE on v1: there is no v1 state in which
1801	// these figures could be compared, which is itself the measurement.
1802	b := MustNew(4)
1803	id, err := b.Open(alice, bob, 0,
1804		Terms{PerPeriod: 50, PeriodLength: day, Total: 120, ExpiresAt: 3 * day})
1805	if err != nil {
1806		t.Fatalf("Open: %v", err)
1807	}
1808	mustSpend(t, b, id, 10, 40) // window 0
1809	queueExtension(t, b, id, 20)
1810
1811	// queueExtension keeps Total at 0 -- it names no Total, so the pending
1812	// terms REMOVE the lifetime cap. Assert that premise rather than relying
1813	// on it silently, since it is what makes the draw below permitted.
1814	a, _ := b.Get(id)
1815	if a.Pending.Total != 0 || a.Terms.Total != 120 {
1816		t.Fatalf("precondition: pending Total %d, live Total %d",
1817			a.Pending.Total, a.Terms.Total)
1818	}
1819
1820	mustSpend(t, b, id, 3*day, 80) // past the old expiry, on the new terms
1821
1822	a, _ = b.Get(id)
1823	if a.SpentTotal != 120 {
1824		t.Errorf("SpentTotal: got %d, want 40+80=120", a.SpentTotal)
1825	}
1826	if a.SpentWindow != 80 {
1827		t.Errorf("SpentWindow: got %d, want 80 (the window reset, it did not "+
1828			"carry the 40)", a.SpentWindow)
1829	}
1830	if a.WindowIndex != 3 {
1831		t.Errorf("WindowIndex: got %d, want 3", a.WindowIndex)
1832	}
1833	if a.HasPending {
1834		t.Error("HasPending must be cleared once the write promotes the terms")
1835	}
1836	if a.Terms.ExpiresAt != 10*day {
1837		t.Errorf("promoted ExpiresAt: got %d, want %d", a.Terms.ExpiresAt, 10*day)
1838	}
1839	// The window cap still bites at the new figure, not above it.
1840	refuse(t, b, id, 3*day, 1, ErrPeriodExceeded)
1841}
1842
1843func TestRemainingAgreesWithSpendAcrossEveryQueuedExpiryChange(t *testing.T) {
1844	// The cross-check the package doc names as the reason everything routes
1845	// through at(): a view that disagrees with the enforcer is the defect,
1846	// whichever way it points. Each row gets a FRESH book, because Spend
1847	// writes and a write ends the hazard being measured.
1848	//
1849	// v1: FAILS. Note WHERE: v1 keeps Remaining and Spend in AGREEMENT on
1850	// every row -- both route through at() -- so the agreement invariant alone
1851	// would have passed against the defective package. What fails is the
1852	// `alive` column: v1 calls the three post-boundary live rows dead. Two
1853	// views agreeing is not the same as either being right, which is why the
1854	// expected liveness is spelled out per row instead of only cross-checked.
1855	for _, tc := range []struct {
1856		name  string
1857		now   int64
1858		alive bool
1859	}{
1860		{"extension: inside the original window", 50, true},
1861		{"extension: at the boundary", day, true},
1862		{"extension: at the old expiry", 3 * day, true},
1863		{"extension: one before the new expiry", 10*day - 1, true},
1864		{"extension: at the new expiry", 10 * day, false},
1865		{"extension: long after the new expiry", 100 * day, false},
1866	} {
1867		b, id := openExtendable(t)
1868		queueExtension(t, b, id, 10)
1869
1870		remaining := b.Remaining(id, tc.now)
1871		err := b.Spend(id, bob, tc.now, 1)
1872
1873		if tc.alive {
1874			if remaining <= 0 {
1875				t.Errorf("%s: Remaining=%d, want > 0", tc.name, remaining)
1876			}
1877			if err != nil {
1878				t.Errorf("%s: Spend=%v, want nil", tc.name, err)
1879			}
1880		} else {
1881			if remaining != 0 {
1882				t.Errorf("%s: Remaining=%d, want 0", tc.name, remaining)
1883			}
1884			if err != ErrExpired {
1885				t.Errorf("%s: Spend=%v, want ErrExpired", tc.name, err)
1886			}
1887		}
1888		if (remaining > 0) != (err == nil) {
1889			t.Errorf("%s: the view and the enforcer disagree (Remaining=%d, "+
1890				"Spend=%v)", tc.name, remaining, err)
1891		}
1892	}
1893}
1894
1895func TestIsExpiredToleratesAPreAnchorNow(t *testing.T) {
1896	// IsExpired is EXPORTED and now routes through at(), so it is the one
1897	// place a caller-supplied now can reach windowIndex without an upstream
1898	// ErrNotStarted. Reap is the in-package caller with no anchor check of
1899	// its own.
1900	//
1901	// v1: PASSES -- and the agreement is the claim. v1 could not reach
1902	// windowIndex from IsExpired at all, so this case exists to prove the new
1903	// path did not introduce a divergence where v1 had no exposure. A FAIL
1904	// here would mean the fix had moved behaviour it was supposed to leave
1905	// alone, so "passes on both sides" is the only acceptable outcome.
1906	//
1907	// First, the hazard the guard exists for, measured rather than asserted:
1908	// a negative numerator folds roughly two windows into one, because Go
1909	// truncates toward zero.
1910	a := Allowance{Anchor: 500, Terms: Terms{PerPeriod: 1, PeriodLength: day}}
1911	if a.windowIndex(401) != a.windowIndex(599) {
1912		t.Fatalf("precondition: windowIndex(401)=%d windowIndex(599)=%d -- the "+
1913			"fold this guard avoids is not present, so the guard's stated "+
1914			"reason is wrong", a.windowIndex(401), a.windowIndex(599))
1915	}
1916
1917	// Then the guard: every pre-anchor now answers false, which is also what
1918	// v1 answered for any allowance valid() would accept, since valid()
1919	// requires ExpiresAt > Anchor.
1920	e := Allowance{
1921		Anchor: 500,
1922		Terms:  Terms{PerPeriod: 1, PeriodLength: day, ExpiresAt: 1000},
1923	}
1924	for _, now := range []int64{-1, 0, 401, 499} {
1925		if e.IsExpired(now) {
1926			t.Errorf("IsExpired(%d) with Anchor 500: got true, want false", now)
1927		}
1928	}
1929	// Reach it through Reap, the caller that has no anchor check.
1930	b := MustNew(4)
1931	id, err := b.Open(alice, bob, 500,
1932		Terms{PerPeriod: 50, PeriodLength: day, ExpiresAt: 1000})
1933	if err != nil {
1934		t.Fatalf("Open: %v", err)
1935	}
1936	if err := b.Reap(id, 100); err != ErrNotExpired {
1937		t.Errorf("Reap before the anchor: got %v, want ErrNotExpired", err)
1938	}
1939	if err := b.Reap(id, -1); err != ErrInvalidNow {
1940		t.Errorf("Reap with a negative now: got %v, want ErrInvalidNow", err)
1941	}
1942	if err := b.Reap(id, 1000); err != nil {
1943		t.Errorf("Reap at the expiry: %v", err)
1944	}
1945}
1946
1947func TestThePreAnchorGuardIsLoadBearingOnACallerBuiltAllowance(t *testing.T) {
1948	// The companion to the test above, and it exists because the sabotage
1949	// harness found the gap: deleting the `now < a.Anchor` guard outright was
1950	// UNCAUGHT by the whole suite. TestIsExpiredToleratesAPreAnchorNow could
1951	// not catch it, and that is a property of the DOMAIN rather than a
1952	// weakness in the test -- over allowances valid() accepts the guard is
1953	// genuinely inert, which is also what README.md claims, in a sentence
1954	// carefully scoped to "every allowance it would accept".
1955	//
1956	// Measured rather than reasoned: a sweep of 4,176 valid()-accepted
1957	// (anchor, PeriodLength, ExpiresAt, pending, now) combinations found ZERO
1958	// inputs distinguishing the guarded body from the unguarded one, counting
1959	// a panic as a difference. Widening past validity found 3,570.
1960	//
1961	// So the observable domain is exactly `0 != ExpiresAt <= now < Anchor`,
1962	// and valid() rejects every member of it (`ExpiresAt <= anchor` is one of
1963	// its refusals). It is reachable anyway, which is the whole reason this
1964	// test is here rather than a comment saying the term cannot be sabotaged:
1965	// Get returns an Allowance BY VALUE and Iterate passes one, IsExpired is
1966	// exported with a value receiver, so a consumer can hold a copy, mutate
1967	// its Terms, and call IsExpired on a shape the Book would never store.
1968	//
1969	// v1: PASSES. v1 read a.Terms.ExpiresAt directly and never reached
1970	// windowIndex from here, so it answered false too -- another PRESERVATION
1971	// case. The guard is what keeps v2 agreeing with it.
1972	//
1973	// What is NOT claimed: no authorization consequence. Reap resolves the
1974	// STORED allowance, which is always valid()-accepted, so a caller's
1975	// mutated copy cannot make Reap delete anything. This pins consistency of
1976	// an exported method, not a permission.
1977	a := Allowance{
1978		Owner: alice, Spender: bob, Anchor: 1000,
1979		Terms: Terms{PerPeriod: 10, PeriodLength: day, ExpiresAt: 5},
1980	}
1981	if a.Terms.valid(a.Anchor) {
1982		t.Fatalf("precondition: valid(1000) accepted ExpiresAt 5, so this case " +
1983			"is no longer about a shape the Book refuses to store")
1984	}
1985	// now sits at or after the expiry and strictly before the anchor: the
1986	// guard answers false, the unguarded body would answer true.
1987	for _, now := range []int64{5, 6, 100, 999} {
1988		if a.IsExpired(now) {
1989			t.Errorf("IsExpired(%d) with Anchor 1000 / ExpiresAt 5: got true, "+
1990				"want false -- the pre-anchor guard is not in force, so an "+
1991				"exported method disagrees with v1 on a caller-built shape", now)
1992		}
1993	}
1994	// The same with no window at all, so both of windowIndex's arms are
1995	// covered on this path and not just the folding one.
1996	lifetime := a
1997	lifetime.Terms.PeriodLength = 0
1998	if lifetime.IsExpired(100) {
1999		t.Errorf("IsExpired(100) on a lifetime allowance with Anchor 1000 / " +
2000			"ExpiresAt 5: got true, want false")
2001	}
2002	// The control: past the anchor the expiry IS honoured, so the guard is
2003	// proven to be a pre-anchor shortcut and not a blanket `return false`.
2004	// Without this, deleting the guard's condition and keeping `return false`
2005	// would pass everything above.
2006	if !a.IsExpired(1000) {
2007		t.Errorf("IsExpired(1000) at the anchor with ExpiresAt 5: got false, " +
2008			"want true -- the guard has become an unconditional false and " +
2009			"IsExpired no longer reports expiry at all")
2010	}
2011}
2012
2013func TestTheZeroAllowanceIsUnchangedByTheFix(t *testing.T) {
2014	// A hand-built Allowance is reachable: Get returns one by value and
2015	// Iterate passes one. The zero value must answer exactly as it did in
2016	// v1, or the fix has changed a shape no consumer declared.
2017	//
2018	// v1: PASSES -- a PRESERVATION case, and the one with the least margin:
2019	// the zero value has Anchor 0 and PeriodLength 0, so it takes the guard's
2020	// false branch on now < 0 and windowIndex's PeriodLength == 0 branch
2021	// otherwise. Both arms are exercised below.
2022	var z Allowance
2023	for _, now := range []int64{-1, 0, 1, maxInt64} {
2024		if z.IsExpired(now) {
2025			t.Errorf("the zero Allowance has ExpiresAt 0 and must never expire; "+
2026				"IsExpired(%d) said true", now)
2027		}
2028	}
2029}