upgrade_registry_test.gno
19.94 Kb · 559 lines
1package upgrade_registry
2
3import (
4 "chain"
5 "strconv"
6 "strings"
7 "testing"
8
9 "gno.land/p/nt/testutils/v0"
10 "gno.land/p/nt/uassert/v0"
11)
12
13// In production the registrant is usually a realm; user addresses give
14// the same self-registration semantics in tests (Register binds the
15// entry to the CALLER's address either way).
16var (
17 v1 = testutils.TestAddress("contract_v1")
18 v2 = testutils.TestAddress("contract_v2")
19 v3 = testutils.TestAddress("contract_v3")
20 teamEOA = testutils.TestAddress("team")
21 attacker = testutils.TestAddress("attacker")
22)
23
24// resetState resets all package-level state between tests.
25func resetState() {
26 entries = make(map[address]*ContractEntry)
27 entryAddrs = []address{}
28 ownerContracts = make(map[address][]address)
29}
30
31// register self-registers addr under name; when owner is non-empty the
32// nominee accepts, completing the two-step handover.
33func register(cur realm, addr address, name string, owner address) {
34 testing.SetRealm(testing.NewUserRealm(addr))
35 Register(cross(cur), name, owner)
36 if owner != "" && owner != addr {
37 testing.SetRealm(testing.NewUserRealm(owner))
38 AcceptOwnership(cross(cur), addr)
39 }
40}
41
42// ---------- Register (proof of control) ----------
43
44func TestRegister_BindsToCallerAddress(cur realm, t *testing.T) {
45 resetState()
46 register(cur, v1, "vault", teamEOA)
47
48 e := entries[v1]
49 uassert.Equal(t, string(v1), string(e.Address))
50 uassert.Equal(t, string(teamEOA), string(e.Owner))
51 uassert.Equal(t, "vault", e.Name)
52
53 // the audit's P1: there is no way to register an address you do not
54 // control — the entry address is ALWAYS the caller's own. An
55 // attacker registering gets an entry for themselves, not the victim.
56 testing.SetRealm(testing.NewUserRealm(attacker))
57 Register(cross(cur), "fake_vault", "")
58 uassert.Equal(t, string(attacker), string(entries[attacker].Address))
59 _, hijacked := entries[v2]
60 uassert.False(t, hijacked, "no entry may exist for an address that never registered itself")
61}
62
63func TestRegister_OwnerDefaultsToContract(cur realm, t *testing.T) {
64 resetState()
65 register(cur, v1, "vault", "")
66
67 uassert.Equal(t, string(v1), string(entries[v1].Owner))
68}
69
70func TestRegister_DuplicateRejected(cur realm, t *testing.T) {
71 resetState()
72 register(cur, v1, "vault", "")
73
74 testing.SetRealm(testing.NewUserRealm(v1))
75 uassert.AbortsWithMessage(t, cur,
76 "contract already registered: "+string(v1),
77 func() { Register(cross(cur), "vault_again", "") },
78 )
79}
80
81func TestRegister_NameValidation(cur realm, t *testing.T) {
82 resetState()
83 testing.SetRealm(testing.NewUserRealm(v1))
84
85 for _, bad := range []string{"", "Vault", "has space", "tick`name", strings.Repeat("a", MaxNameLen+1)} {
86 uassert.AbortsContains(t, cur,
87 "name must be",
88 func() { Register(cross(cur), bad, "") },
89 )
90 }
91}
92
93func TestRegister_InvalidOwnerRejected(cur realm, t *testing.T) {
94 resetState()
95 testing.SetRealm(testing.NewUserRealm(v1))
96
97 uassert.AbortsWithMessage(t, cur,
98 "invalid owner address: junk",
99 func() { Register(cross(cur), "vault", address("junk")) },
100 )
101}
102
103// ---------- Deprecate ----------
104
105func TestDeprecate_OwnerOnly(cur realm, t *testing.T) {
106 resetState()
107 register(cur, v1, "vault_v1", teamEOA)
108 register(cur, v2, "vault_v2", teamEOA)
109
110 testing.SetRealm(testing.NewUserRealm(attacker))
111 uassert.AbortsWithMessage(t, cur,
112 "only the owner can deprecate this contract",
113 func() { Deprecate(cross(cur), v1, v2) },
114 )
115
116 testing.SetRealm(testing.NewUserRealm(teamEOA))
117 Deprecate(cross(cur), v1, v2)
118 uassert.Equal(t, string(v2), string(GetLatest(v1)))
119}
120
121func TestDeprecate_SuccessorMustBeRegistered(cur realm, t *testing.T) {
122 resetState()
123 register(cur, v1, "vault_v1", teamEOA)
124
125 // the audit's dangling-successor + front-running route: pointing at
126 // an unregistered address is refused outright, and a successor entry
127 // can only ever be created by the successor contract itself
128 testing.SetRealm(testing.NewUserRealm(teamEOA))
129 uassert.AbortsWithMessage(t, cur,
130 "successor is not a registered contract: "+string(v2),
131 func() { Deprecate(cross(cur), v1, v2) },
132 )
133}
134
135func TestDeprecate_SelfAndDoubleRejected(cur realm, t *testing.T) {
136 resetState()
137 register(cur, v1, "vault_v1", teamEOA)
138 register(cur, v2, "vault_v2", teamEOA)
139
140 testing.SetRealm(testing.NewUserRealm(teamEOA))
141 uassert.AbortsWithMessage(t, cur,
142 "successor cannot be the contract itself",
143 func() { Deprecate(cross(cur), v1, v1) },
144 )
145 Deprecate(cross(cur), v1, v2)
146 uassert.AbortsWithMessage(t, cur,
147 "contract is already deprecated",
148 func() { Deprecate(cross(cur), v1, v2) },
149 )
150}
151
152func TestDeprecate_CircularChainRejected(cur realm, t *testing.T) {
153 resetState()
154 register(cur, v1, "vault_v1", teamEOA)
155 register(cur, v2, "vault_v2", teamEOA)
156 register(cur, v3, "vault_v3", teamEOA)
157
158 testing.SetRealm(testing.NewUserRealm(teamEOA))
159 Deprecate(cross(cur), v1, v2)
160 Deprecate(cross(cur), v2, v3)
161
162 // closing the loop from the tail must abort
163 uassert.AbortsWithMessage(t, cur,
164 "deprecating "+string(v3)+" to "+string(v1)+" would create a circular migration chain",
165 func() { Deprecate(cross(cur), v3, v1) },
166 )
167}
168
169// ---------- chain queries ----------
170
171func TestGetLatest_WalksChain(cur realm, t *testing.T) {
172 resetState()
173 register(cur, v1, "vault_v1", teamEOA)
174 register(cur, v2, "vault_v2", teamEOA)
175 register(cur, v3, "vault_v3", teamEOA)
176
177 testing.SetRealm(testing.NewUserRealm(teamEOA))
178 Deprecate(cross(cur), v1, v2)
179 Deprecate(cross(cur), v2, v3)
180
181 uassert.Equal(t, string(v3), string(GetLatest(v1)))
182 uassert.Equal(t, string(v3), string(GetLatest(v2)))
183 uassert.Equal(t, string(v3), string(GetLatest(v3)))
184 // unknown addresses come back unchanged
185 uassert.Equal(t, string(attacker), string(GetLatest(attacker)))
186}
187
188func TestGetMigrationChain(cur realm, t *testing.T) {
189 resetState()
190 register(cur, v1, "vault_v1", teamEOA)
191 register(cur, v2, "vault_v2", teamEOA)
192
193 testing.SetRealm(testing.NewUserRealm(teamEOA))
194 Deprecate(cross(cur), v1, v2)
195
196 out := GetMigrationChain(v1)
197 uassert.True(t, strings.Contains(out, "vault_v1"), "chain must name v1")
198 uassert.True(t, strings.Contains(out, "[deprecated]"), "chain must tag deprecation")
199 uassert.True(t, strings.Contains(out, "vault_v2"), "chain must name v2")
200 uassert.Equal(t, "not found: "+string(attacker), GetMigrationChain(attacker))
201}
202
203// ---------- TransferOwnership ----------
204
205func TestTransferOwnership(cur realm, t *testing.T) {
206 resetState()
207 register(cur, v1, "vault", teamEOA)
208 newOwner := testutils.TestAddress("newowner")
209
210 testing.SetRealm(testing.NewUserRealm(attacker))
211 uassert.AbortsWithMessage(t, cur,
212 "only the owner can transfer ownership",
213 func() { TransferOwnership(cross(cur), v1, attacker) },
214 )
215
216 testing.SetRealm(testing.NewUserRealm(teamEOA))
217 uassert.AbortsWithMessage(t, cur,
218 "invalid new owner address: junk",
219 func() { TransferOwnership(cross(cur), v1, address("junk")) },
220 )
221 TransferOwnership(cross(cur), v1, newOwner)
222 // nomination alone changes nothing (two-step)
223 uassert.Equal(t, string(teamEOA), string(entries[v1].Owner))
224 uassert.Equal(t, string(v1), GetOwnerContracts(teamEOA))
225
226 testing.SetRealm(testing.NewUserRealm(newOwner))
227 AcceptOwnership(cross(cur), v1)
228 uassert.Equal(t, string(newOwner), string(entries[v1].Owner))
229 uassert.Equal(t, "none", GetOwnerContracts(teamEOA))
230 uassert.Equal(t, string(v1), GetOwnerContracts(newOwner))
231}
232
233// ---------- render ----------
234
235func TestRender_EmptyState(cur realm, t *testing.T) {
236 resetState()
237 out := Render("")
238 uassert.True(t, strings.HasPrefix(out, "# Upgrade Registry\n\n"), "heading first")
239 uassert.True(t, strings.HasSuffix(out, "No contracts registered.\n"),
240 "empty state must still say the registry is empty")
241 // round-4 audit: the scope disclosure is not conditional on there being
242 // entries — an empty page must not read as an endorsement-free zone.
243 uassert.True(t, strings.Contains(out, "**Names are not.**"),
244 "the name-scope disclosure must appear even in the empty state")
245}
246
247// TestRender_CarriesNameScopeDisclosure is the regression guard for the
248// round-4 audit finding that all three review passes raised: the rendered
249// page gives a self-asserted NAME the most visual weight while only the
250// ADDRESS is actually proven. Names are deliberately never made unique
251// (first-come uniqueness on an immutable permissionless registry would let
252// an attacker burn every valuable name forever), so disclosing the scope in
253// the output is the entire mitigation. It cannot be added after deploy.
254//
255// Each assertion below names a distinct claim, so deleting or softening any
256// one of them fails this test rather than silently degrading the warning.
257func TestRender_CarriesNameScopeDisclosure(cur realm, t *testing.T) {
258 resetState()
259 register(cur, v1, "vault_v1", teamEOA)
260
261 // The impersonation this disclosure exists for: a second, unrelated
262 // address registering the SAME name is permitted and renders equally.
263 register(cur, attacker, "vault_v1", "")
264
265 out := Render("")
266 for _, claim := range []string{
267 "> [!NOTE]",
268 "Addresses are self-registered and therefore verified",
269 "**Names are not.**",
270 "NOT unique",
271 "may impersonate another project",
272 "address, never by name.",
273 } {
274 uassert.True(t, strings.Contains(out, claim),
275 "rendered disclosure must carry the claim: "+claim)
276 }
277
278 // Both duplicate-named entries really are present — the disclosure is
279 // describing a live condition, not a hypothetical one.
280 uassert.Equal(t, 2, strings.Count(out, "**vault_v1**"),
281 "duplicate names are permitted and both must render")
282}
283
284func TestRender_Sections(cur realm, t *testing.T) {
285 resetState()
286 register(cur, v1, "vault_v1", teamEOA)
287 register(cur, v2, "vault_v2", teamEOA)
288
289 testing.SetRealm(testing.NewUserRealm(teamEOA))
290 Deprecate(cross(cur), v1, v2)
291
292 out := Render("")
293 uassert.True(t, strings.Contains(out, "## Active"), "active section")
294 uassert.True(t, strings.Contains(out, "## Deprecated"), "deprecated section")
295 uassert.True(t, strings.Contains(out, "DEPRECATED"), "deprecation flag")
296 uassert.True(t, strings.Contains(out, string(v2)), "successor shown")
297}
298
299// ---------- re-audit regressions (2026-09-02) ----------
300
301func TestOwnerNomination_RequiresConsent(cur realm, t *testing.T) {
302 resetState()
303
304 // re-audit P2: registering with someone else's address as owner must
305 // NOT attach them — no pollution of GetOwnerContracts, no GetInfo
306 // showing them as owner, until they accept
307 victim := teamEOA
308 testing.SetRealm(testing.NewUserRealm(attacker))
309 Register(cross(cur), "gnoswap_router", victim)
310
311 uassert.Equal(t, string(attacker), string(entries[attacker].Owner))
312 uassert.Equal(t, "none", GetOwnerContracts(victim))
313 uassert.True(t, strings.Contains(GetInfo(attacker), string(attacker)),
314 "info must show the contract itself as owner pre-acceptance")
315
316 // only the nominee can accept; a bystander cannot
317 testing.SetRealm(testing.NewUserRealm(newOwnerAddr()))
318 uassert.AbortsWithMessage(t, cur,
319 "caller is not the pending owner of "+string(attacker),
320 func() { AcceptOwnership(cross(cur), attacker) },
321 )
322}
323
324func newOwnerAddr() address {
325 return testutils.TestAddress("bystander")
326}
327
328func TestDeprecate_SuccessorConsentRequired(cur realm, t *testing.T) {
329 resetState()
330 register(cur, v1, "vault", teamEOA)
331
332 // re-audit P2: an attacker chains their entry INTO a legit contract,
333 // forging predecessor provenance — refused unless the caller owns
334 // the successor entry too
335 testing.SetRealm(testing.NewUserRealm(attacker))
336 Register(cross(cur), "vault_v1_old", "")
337 uassert.AbortsWithMessage(t, cur,
338 "successor entry is not owned by the caller: "+string(v1),
339 func() { Deprecate(cross(cur), attacker, v1) },
340 )
341}
342
343func TestMigrationChain_MarksTruncation(cur realm, t *testing.T) {
344 resetState()
345 // build a chain longer than MaxChainLen entirely under one owner
346 prev := address("")
347 addrs := make([]address, MaxChainLen+5)
348 for i := range addrs {
349 addrs[i] = testutils.TestAddress("link_" + strconv.Itoa(i))
350 testing.SetRealm(testing.NewUserRealm(addrs[i]))
351 Register(cross(cur), "link", teamEOA)
352 testing.SetRealm(testing.NewUserRealm(teamEOA))
353 AcceptOwnership(cross(cur), addrs[i])
354 if prev != "" {
355 Deprecate(cross(cur), prev, addrs[i])
356 }
357 prev = addrs[i]
358 }
359
360 out := GetMigrationChain(addrs[0])
361 uassert.True(t, strings.Contains(out, "truncated"),
362 "over-length chains must carry an explicit truncation marker")
363 // GetLatest still resolves the true endpoint
364 uassert.Equal(t, string(addrs[len(addrs)-1]), string(GetLatest(addrs[0])))
365}
366
367func TestTransferNomination_CanBeCleared(cur realm, t *testing.T) {
368 resetState()
369 register(cur, v1, "vault", teamEOA)
370
371 testing.SetRealm(testing.NewUserRealm(teamEOA))
372 TransferOwnership(cross(cur), v1, attacker)
373 TransferOwnership(cross(cur), v1, "")
374
375 // round-4 audit L2: clearing again is a pure no-op, and the emitted
376 // event is now suppressed for it. The return value and the state are
377 // unchanged either way, which is all a unit test can observe here --
378 // this toolchain's testing stdlib carries no events accessor. That the
379 // second clear emits NOTHING is asserted externally by
380 // tools/test_upgrade_registry_events.sh, which counts the events
381 // `gno test -print-events` prints for this very suite.
382 uassert.Equal(t, "pending ownership nomination cleared",
383 TransferOwnership(cross(cur), v1, ""))
384 uassert.Equal(t, "", string(mustGet(v1).PendingOwner))
385
386 testing.SetRealm(testing.NewUserRealm(attacker))
387 uassert.AbortsWithMessage(t, cur,
388 "caller is not the pending owner of "+string(v1),
389 func() { AcceptOwnership(cross(cur), v1) },
390 )
391}
392
393// ---------- round-3 audit regressions (2026-09-02) ----------
394
395func TestRenderLatest_ExactBudgetChainIsComplete(cur realm, t *testing.T) {
396 resetState()
397 // exactly RenderLatestHops deprecated hops + terminal endpoint:
398 // the walk must be reported COMPLETE (round-3 off-by-one)
399 n := RenderLatestHops + 1
400 addrs := make([]address, n)
401 for i := range addrs {
402 addrs[i] = testutils.TestAddress("hop_" + strconv.Itoa(i))
403 testing.SetRealm(testing.NewUserRealm(addrs[i]))
404 Register(cross(cur), "hop", teamEOA)
405 testing.SetRealm(testing.NewUserRealm(teamEOA))
406 AcceptOwnership(cross(cur), addrs[i])
407 if i > 0 {
408 Deprecate(cross(cur), addrs[i-1], addrs[i])
409 }
410 }
411 latest, complete := renderLatest(addrs[0])
412 uassert.True(t, complete, "an exactly-budget chain must be complete")
413 uassert.Equal(t, string(addrs[n-1]), string(latest))
414}
415
416// ---------- audit gap closures (2026-09-21, apps-pipeline round) ----------
417
418func TestStraySendRejectedEverywhere(cur realm, t *testing.T) {
419 resetState()
420 register(cur, v1, "vault", teamEOA)
421
422 // audit Y2: the realm holds no banker, so attached coins would
423 // strand forever — every crossing function must abort them
424 msg := "this realm does not accept coins"
425 testing.SetRealm(testing.NewUserRealm(teamEOA))
426 testing.SetOriginSend(chain.Coins{{Denom: "ugnot", Amount: 5}})
427 uassert.AbortsWithMessage(t, cur, msg, func() { Register(cross(cur), "paid", "") })
428 uassert.AbortsWithMessage(t, cur, msg, func() { AcceptOwnership(cross(cur), v1) })
429 uassert.AbortsWithMessage(t, cur, msg, func() { TransferOwnership(cross(cur), v1, attacker) })
430 uassert.AbortsWithMessage(t, cur, msg, func() { Deprecate(cross(cur), v1, v2) })
431 testing.SetOriginSend(nil)
432}
433
434func TestDeprecate_BoundedWalkRefusal(cur realm, t *testing.T) {
435 resetState()
436 // audit Y5.1: the round-3 bounded-walk panic had zero coverage.
437 // Build a chain longer than MaxChainLen, then point a fresh entry
438 // at its head — the verification walk must refuse, not walk it.
439 prev := address("")
440 addrs := make([]address, MaxChainLen+5)
441 for i := range addrs {
442 addrs[i] = testutils.TestAddress("bw_" + strconv.Itoa(i))
443 testing.SetRealm(testing.NewUserRealm(addrs[i]))
444 Register(cross(cur), "link", teamEOA)
445 testing.SetRealm(testing.NewUserRealm(teamEOA))
446 AcceptOwnership(cross(cur), addrs[i])
447 if prev != "" {
448 Deprecate(cross(cur), prev, addrs[i])
449 }
450 prev = addrs[i]
451 }
452 fresh := testutils.TestAddress("bw_fresh")
453 testing.SetRealm(testing.NewUserRealm(fresh))
454 Register(cross(cur), "fresh", teamEOA)
455 testing.SetRealm(testing.NewUserRealm(teamEOA))
456 AcceptOwnership(cross(cur), fresh)
457 uassert.AbortsWithMessage(t, cur,
458 "successor chain too long to verify; cannot deprecate",
459 func() { Deprecate(cross(cur), fresh, addrs[0]) },
460 )
461}
462
463func TestRenderLatest_LongChainMarked(cur realm, t *testing.T) {
464 resetState()
465 // audit Y5.2: the incomplete branch and Render's long-chain marker
466 // were untested. Budget-boundary node must be NON-terminal here.
467 n := RenderLatestHops + 3
468 addrs := make([]address, n)
469 for i := range addrs {
470 addrs[i] = testutils.TestAddress("lc_" + strconv.Itoa(i))
471 testing.SetRealm(testing.NewUserRealm(addrs[i]))
472 Register(cross(cur), "hop", teamEOA)
473 testing.SetRealm(testing.NewUserRealm(teamEOA))
474 AcceptOwnership(cross(cur), addrs[i])
475 if i > 0 {
476 Deprecate(cross(cur), addrs[i-1], addrs[i])
477 }
478 }
479 _, complete := renderLatest(addrs[0])
480 uassert.False(t, complete, "an over-budget chain must be reported incomplete")
481 uassert.True(t, strings.Contains(Render(""), "(long chain; use GetLatest)"),
482 "render must mark over-budget chains instead of mislabeling an endpoint")
483}
484
485func TestRender_Pagination(cur realm, t *testing.T) {
486 resetState()
487 // audit Y5.3: the MaxRenderEntries window was untested
488 for i := 0; i < MaxRenderEntries+5; i++ {
489 a := testutils.TestAddress("pg_" + strconv.Itoa(i))
490 testing.SetRealm(testing.NewUserRealm(a))
491 Register(cross(cur), "entry", "")
492 }
493 out := Render("")
494 uassert.True(t, strings.Contains(out,
495 "_Showing the most recent "+strconv.Itoa(MaxRenderEntries)+" entries of "+
496 strconv.Itoa(MaxRenderEntries+5)+"."),
497 "over-window registries must announce the window AND the true total")
498 // round-4 audit: announcing the window is not enough on an immutable
499 // realm — the page must also say where the unlisted entries went, since
500 // a flood of new entries evicts older ones from this view permanently.
501 uassert.True(t, strings.Contains(out, "remain queryable by address via GetInfo"),
502 "the eviction notice must point at the accessors that still serve them")
503}
504
505func TestRenomination_OverwritesPrior(cur realm, t *testing.T) {
506 resetState()
507 register(cur, v1, "vault", teamEOA)
508 a := testutils.TestAddress("nominee_a")
509 b := testutils.TestAddress("nominee_b")
510
511 // audit Y5.4: a newer nomination must revoke the older one
512 testing.SetRealm(testing.NewUserRealm(teamEOA))
513 TransferOwnership(cross(cur), v1, a)
514 TransferOwnership(cross(cur), v1, b)
515 testing.SetRealm(testing.NewUserRealm(a))
516 uassert.AbortsWithMessage(t, cur,
517 "caller is not the pending owner of "+string(v1),
518 func() { AcceptOwnership(cross(cur), v1) },
519 )
520 testing.SetRealm(testing.NewUserRealm(b))
521 AcceptOwnership(cross(cur), v1)
522 uassert.Equal(t, string(b), string(entries[v1].Owner))
523}
524
525func TestSelfNominationAcceptKeepsIndexClean(cur realm, t *testing.T) {
526 resetState()
527 register(cur, v1, "vault", teamEOA)
528
529 // audit Y5.5: owner nominating themselves and accepting must not
530 // duplicate or lose the ownerContracts index entry
531 testing.SetRealm(testing.NewUserRealm(teamEOA))
532 TransferOwnership(cross(cur), v1, teamEOA)
533 AcceptOwnership(cross(cur), v1)
534 uassert.Equal(t, string(teamEOA), string(entries[v1].Owner))
535 uassert.Equal(t, string(v1), GetOwnerContracts(teamEOA))
536}
537
538func TestOpsOnUnregisteredAbort(cur realm, t *testing.T) {
539 resetState()
540 // audit Y5.6: the not-found aborts were never asserted
541 ghost := testutils.TestAddress("ghost")
542 msg := "contract not found: " + string(ghost)
543 testing.SetRealm(testing.NewUserRealm(teamEOA))
544 uassert.AbortsWithMessage(t, cur, msg, func() { AcceptOwnership(cross(cur), ghost) })
545 uassert.AbortsWithMessage(t, cur, msg, func() { TransferOwnership(cross(cur), ghost, teamEOA) })
546 uassert.AbortsWithMessage(t, cur, msg, func() { Deprecate(cross(cur), ghost, v1) })
547 // GetInfo shares mustGet with the three crossing paths above; its
548 // same-realm panic is not catchable by the revive-based Aborts helpers
549}
550
551func TestRegister_OwnerEqualsSelfNoPending(cur realm, t *testing.T) {
552 resetState()
553 // audit Y5.7: registering with your own address as owner must leave
554 // no pending nomination (the explicit owner==contractAddr branch)
555 testing.SetRealm(testing.NewUserRealm(v1))
556 Register(cross(cur), "vault", v1)
557 uassert.Equal(t, string(v1), string(entries[v1].Owner))
558 uassert.Equal(t, "", string(entries[v1].PendingOwner))
559}