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}