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

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

garden_test.gno

20.38 Kb · 449 lines
  1package garden
  2
  3import (
  4	"strconv"
  5	"strings"
  6	"testing"
  7
  8	"gno.land/p/nt/grc721/v0"
  9	"gno.land/p/nt/testutils/v0"
 10	"gno.land/p/nt/uassert/v0"
 11
 12	"chain"
 13)
 14
 15var (
 16	alice = testutils.TestAddress("alice")
 17	bob   = testutils.TestAddress("bob")
 18	carl  = testutils.TestAddress("carl")
 19	dave  = testutils.TestAddress("dave")
 20)
 21
 22// adoptAs sets the test harness's caller identity to addr (the pattern
 23// p/nt/grc721/v0's own tellers_test.gno uses: testing.SetRealm +
 24// testing.NewUserRealm) and then crosses into Adopt as that address, the
 25// same way a real Adena-signed MsgCall would arrive as Adopt's caller.
 26func adoptAs(cur realm, addr address, name, hat, coat string) {
 27	testing.SetRealm(testing.NewUserRealm(addr))
 28	Adopt(cross(cur), name, hat, coat)
 29}
 30
 31func TestAdoptMintsSoulboundGnome(cur realm, t *testing.T) {
 32	adoptAs(cur, alice, "Bramble", "red", "blue")
 33
 34	tid, name, hat, coat, level, gnomeType, adoptedAt, found := GnomeByAddress(alice)
 35	uassert.True(t, found, "alice should have a gnome after Adopt")
 36	uassert.True(t, tid != "", "token id should not be empty")
 37	uassert.Equal(t, "Bramble", name)
 38	uassert.Equal(t, "red", hat)
 39	uassert.Equal(t, "blue", coat)
 40	uassert.Equal(t, int64(1), level)
 41	uassert.Equal(t, "", gnomeType, "type is unset until a later phase, matching live.js's adopt() stub")
 42	uassert.True(t, adoptedAt > 0, "adoptedAt should be a real unix timestamp")
 43
 44	owner, err := token.OwnerOf(grc721.TokenID(tid))
 45	uassert.NoError(t, err)
 46	uassert.Equal(t, alice, owner, "grc721 ledger and this realm's own index must agree on the owner")
 47
 48	page := Render(string(alice))
 49	uassert.True(t, strings.Contains(page, "Bramble"), "Render should show the gnome's name")
 50	uassert.True(t, strings.Contains(page, "Level"), "Render should show the gnome's level")
 51	uassert.True(t, strings.Contains(page, "red hat"), "Render should show the gnome's look")
 52}
 53
 54func TestRenderUnknownAddressDoesNotPanic(t *testing.T) {
 55	// Its own address, not bob: this used to assert bob had no gnome, and that only held
 56	// because Go runs top-level test functions in declaration order and this realm's
 57	// package-level state (ownerIndex/gnomes) persists across test functions within one
 58	// `gno test` run -- TestAdoptRejectsSecondAdoptSameAddress below adopts bob later in the
 59	// same file. That made this test true by file-ordering accident, not by anything it
 60	// actually asserts; a dedicated, never-adopted address can't regress that way if the
 61	// tests above are ever reordered or a new one is inserted earlier in the file.
 62	addr := testutils.TestAddress("render-unknown")
 63	page := Render(string(addr))
 64	uassert.True(t, strings.Contains(page, "No gnome adopted yet"), "an address with no gnome should get an honest message, not a panic")
 65}
 66
 67func TestAdoptRejectsSecondAdoptSameAddress(cur realm, t *testing.T) {
 68	adoptAs(cur, bob, "First", "red", "blue")
 69
 70	uassert.AbortsWithMessage(t, cur, "garden: this address has already adopted a gnome", func() {
 71		adoptAs(cur, bob, "Second", "green", "teal")
 72	})
 73
 74	// The rejected second attempt must not have replaced the first gnome.
 75	_, name, _, _, _, _, _, found := GnomeByAddress(bob)
 76	uassert.True(t, found)
 77	uassert.Equal(t, "First", name)
 78}
 79
 80func TestAdoptRejectsEveryInvalidHat(cur realm, t *testing.T) {
 81	invalidHats := []string{"pink", "RED", "", "blue" /* a valid coat, not a valid hat */}
 82	for i, hat := range invalidHats {
 83		hat := hat
 84		addr := testutils.TestAddress("hat-" + strconv.Itoa(i))
 85		t.Run(hat, func(t *testing.T) {
 86			uassert.AbortsWithMessage(t, cur, "garden: invalid hat colour", func() {
 87				adoptAs(cur, addr, "Pip", hat, "blue")
 88			})
 89			_, _, _, _, _, _, _, found := GnomeByAddress(addr)
 90			uassert.False(t, found, "a rejected adopt must not mint anything")
 91		})
 92	}
 93}
 94
 95func TestAdoptRejectsEveryInvalidCoat(cur realm, t *testing.T) {
 96	invalidCoats := []string{"black", "BLUE", "", "red" /* a valid hat, not a valid coat */}
 97	for i, coat := range invalidCoats {
 98		coat := coat
 99		addr := testutils.TestAddress("coat-" + strconv.Itoa(i))
100		t.Run(coat, func(t *testing.T) {
101			uassert.AbortsWithMessage(t, cur, "garden: invalid coat colour", func() {
102				adoptAs(cur, addr, "Pip", "red", coat)
103			})
104			_, _, _, _, _, _, _, found := GnomeByAddress(addr)
105			uassert.False(t, found, "a rejected adopt must not mint anything")
106		})
107	}
108}
109
110func TestAdoptRejectsEmptyAndOverLongName(cur realm, t *testing.T) {
111	overLong := strings.Repeat("a", maxNameLen+1)
112	wantMsg := "garden: name must be 1-" + strconv.Itoa(maxNameLen) + " characters"
113
114	tests := []struct {
115		name string
116		id   string
117	}{
118		{"", "empty-name"},
119		{overLong, "overlong-name"},
120	}
121	for _, tc := range tests {
122		tc := tc
123		addr := testutils.TestAddress(tc.id)
124		t.Run(tc.id, func(t *testing.T) {
125			uassert.AbortsWithMessage(t, cur, wantMsg, func() {
126				adoptAs(cur, addr, tc.name, "red", "blue")
127			})
128			_, _, _, _, _, _, _, found := GnomeByAddress(addr)
129			uassert.False(t, found, "a rejected adopt must not mint anything")
130		})
131	}
132}
133
134func TestAdoptAcceptsNameAtMaxLength(cur realm, t *testing.T) {
135	// Boundary check alongside the over-long rejection above: exactly
136	// maxNameLen must still succeed.
137	maxName := strings.Repeat("z", maxNameLen)
138	addr := testutils.TestAddress("maxname")
139	adoptAs(cur, addr, maxName, "navy", "orange")
140
141	_, name, _, _, _, _, _, found := GnomeByAddress(addr)
142	uassert.True(t, found)
143	uassert.Equal(t, maxName, name)
144}
145
146// TestTransferAlwaysFails is rule 6's guarantee ("every transfer fails"),
147// proven rather than assumed: a real transfer is attempted through
148// grc721's own ledger -- the same PrivateLedger.transfer path any
149// TransferFrom wrapper would ultimately call -- and it must panic with the
150// soulbound extension's message. This realm deliberately exposes no public
151// Transfer/TransferFrom wrapper at all (see garden.gno's package doc), so
152// the raw ledger call is the actual mechanism, not a stand-in for one.
153//
154// uassert.PanicsWithMessage, not AbortsWithMessage: ledger.TransferFrom is a
155// plain method call, not a crossing call (it is not reached via a
156// `cross(...)`-invoked function), so this is a same-realm local panic per
157// that function's own doc ("Use PanicsWithMessage for asserting local
158// panics within the same realm").
159//
160// What this test does NOT assert, on purpose: that grc721's own ledger
161// (token.OwnerOf) is untouched after the refused attempt. Checked
162// empirically against this harness, it is not -- PrivateLedger.transfer()
163// (p/nt/grc721/v0) writes owners/balances *before* running extension hooks,
164// and recovering a local panic with PanicsWithMessage does not undo writes
165// made earlier in the same call. On a live chain that half-written state is
166// never observed by anyone, because the whole enclosing transaction aborts
167// atomically at the node's transaction-processing layer -- but `gno test`
168// does not model that layer at all (realm state is documented to persist
169// *between* whole test functions precisely because there is no such
170// rollback). Asserting token.OwnerOf here would therefore be testing a
171// property of `gno test` itself, not of this realm's code. What this realm
172// actually promises its callers -- GnomeByAddress and Render, its own
173// authoritative read path -- is asserted below, and that one holds:
174// ownerIndex/gnomes are written only by Adopt, never by grc721's internal
175// transfer(), so a refused attempt cannot change what they report.
176func TestTransferAlwaysFails(cur realm, t *testing.T) {
177	wantMsg := "garden: gnomes are soulbound; transfer is permanently disabled"
178
179	adoptAs(cur, carl, "Fern", "navy", "brown")
180	tid, _, _, _, _, _, _, found := GnomeByAddress(carl)
181	uassert.True(t, found)
182
183	uassert.PanicsWithMessage(t, cur, wantMsg, func() {
184		if err := ledger.TransferFrom(carl, carl, dave, grc721.TokenID(tid)); err != nil {
185			panic(err)
186		}
187	})
188
189	_, name, _, _, _, _, _, found := GnomeByAddress(carl)
190	uassert.True(t, found)
191	uassert.Equal(t, "Fern", name, "this realm's own read must still show the refused transfer's owner")
192
193	_, _, _, _, _, _, _, found = GnomeByAddress(dave)
194	uassert.False(t, found, "dave adopted nothing and must not appear to have received a gnome through the refused transfer")
195}
196
197// TestAdoptRejectsNamesWithMarkdownZeroWidthAndBidiCharacters is the audit's
198// finding 2 (Render sanitisation), proven at the input boundary:
199// validNameRune is an allowlist, so every one of these -- markdown
200// metacharacters that could break out of Render's "# name" heading or
201// inject structure into a future shared page, a zero-width space that would
202// make two visually-identical names byte-distinct, and a bidi override that
203// could make a rendered name display as something other than what it
204// stores -- must be refused at Adopt time, long before Render ever reads
205// the name back.
206//
207// wantMsg asserts the charset message specifically (garden.gno's Adopt now
208// gives length and charset failures two different messages -- see the
209// second audit round's finding: every name below used to be 9+ runes and
210// this test asserted the *length* message, so it would still have passed
211// even if validNameRune were deleted outright, since none of these are
212// anywhere near maxNameLen). The short two-rune cases at the end of the list
213// close that gap: "a`" and "aㅤ" are both well inside the length budget,
214// so the only way either can fail is the charset rule actually firing.
215func TestAdoptRejectsNamesWithMarkdownZeroWidthAndBidiCharacters(cur realm, t *testing.T) {
216	bad := []string{
217		"back`tick",
218		"star*wars",
219		"under_score",
220		"brack[ets]",
221		"paren(thesis)",
222		"pipe|name",
223		"hash#tag",
224		"angle<brackets>",
225		"escape\\name",
226		"zero​width",     // U+200B ZERO WIDTH SPACE
227		"bidi‮override",  // U+202E RIGHT-TO-LEFT OVERRIDE
228		"isolate⁦bidi",   // U+2066 LEFT-TO-RIGHT ISOLATE
229		"joiner‍charity", // U+200D ZERO WIDTH JOINER
230		"a`",             // 2 runes: short enough that only the charset rule can reject it
231		"aㅤ",        // 2 runes: a letter plus a Hangul filler masquerading as one
232	}
233	wantMsg := "garden: name contains a character that is not an allowed letter, digit, space, hyphen or apostrophe"
234	for i, name := range bad {
235		name := name
236		addr := testutils.TestAddress("badname-" + strconv.Itoa(i))
237		t.Run(strconv.Itoa(i), func(t *testing.T) {
238			uassert.AbortsWithMessage(t, cur, wantMsg, func() {
239				adoptAs(cur, addr, name, "red", "blue")
240			})
241			_, _, _, _, _, _, _, found := GnomeByAddress(addr)
242			uassert.False(t, found, "a name with a markdown/zero-width/bidi character must not mint anything")
243		})
244	}
245}
246
247// TestAdoptRejectsInvisibleLetterOnlyAndSpaceOnlyNames is the second audit
248// round's finding 1, proven directly: unicode.IsLetter admits several
249// HANGUL FILLER code points (category Lo) that render as nothing, so a name
250// made of only one of those was accepted by the old allowlist despite
251// validNameRune's own comment claiming nothing outside letters-and-digits
252// could get in "full stop". Separately, validName never required any
253// visible content at all, so a name of a single space was also accepted --
254// and with no rename, no burn and no admin in this realm, permanently.
255// Both must now be refused, and refused with their own distinct messages
256// (charset vs. visible-content), not the length message.
257//
258// The letter-filler half is table-driven straight from invisibleLetterRunes
259// itself, not a hand-copied list of runes (third audit round, finding Y3):
260// before this change, every test here (Go and JS) only ever exercised
261// U+3164, so deleting U+115F, U+1160 or U+FFA0 from that map -- or, more
262// dangerously, mistyping one of them (e.g. 'ᅟ' as 'ᅡ', which is a
263// *visible* Korean vowel, not a filler) -- would still pass every test in
264// this repo while quietly breaking the guarantee validNameRune's own comment
265// claims. Ranging over the map directly means a change to the map is a
266// change to what this test checks, by construction.
267func TestAdoptRejectsInvisibleLetterOnlyAndSpaceOnlyNames(cur realm, t *testing.T) {
268	wantCharsetMsg := "garden: name contains a character that is not an allowed letter, digit, space, hyphen or apostrophe"
269	i := 0
270	for r := range invisibleLetterRunes {
271		r := r
272		idx := i
273		i++
274		addr := testutils.TestAddress("invisible-letter-" + strconv.Itoa(idx))
275		t.Run("U+"+strconv.FormatInt(int64(r), 16), func(t *testing.T) {
276			uassert.AbortsWithMessage(t, cur, wantCharsetMsg, func() {
277				adoptAs(cur, addr, string(r), "red", "blue")
278			})
279			_, _, _, _, _, _, _, found := GnomeByAddress(addr)
280			uassert.False(t, found, "an invisible-letter-only name must not mint anything")
281		})
282	}
283
284	spaceOnly := []struct {
285		label string
286		name  string
287	}{
288		{"space-only-single", " "},
289		{"space-only-multiple", "   "},
290	}
291	wantSpaceMsg := "garden: name must contain at least one visible character"
292	for i, tc := range spaceOnly {
293		tc := tc
294		addr := testutils.TestAddress("invisible-space-" + strconv.Itoa(i))
295		t.Run(tc.label, func(t *testing.T) {
296			uassert.AbortsWithMessage(t, cur, wantSpaceMsg, func() {
297				adoptAs(cur, addr, tc.name, "red", "blue")
298			})
299			_, _, _, _, _, _, _, found := GnomeByAddress(addr)
300			uassert.False(t, found, "a name with no visible content must not mint anything")
301		})
302	}
303}
304
305// TestAdoptAcceptsCrossScriptHomoglyphs documents and locks in a deliberate,
306// accepted residual (see validNameRune's own comment): this allowlist closes
307// invisible/structural characters, not cross-script look-alikes. A Cyrillic
308// "А" (U+0410) is an ordinary letter by Unicode's own classification, and
309// this realm has no reason to reject it -- gnome names are not identifiers,
310// nothing requires one to be unique or uses one as a lookup key, so two
311// visually-confusable names are a cosmetic curiosity, not a vulnerability.
312// This test exists so a future tightening of validNameRune does not silently
313// start rejecting ordinary single-code-point non-Latin letters while
314// believing it is only closing an invisible-character hole.
315//
316// What this test does NOT claim, corrected in the third audit round: that
317// the current rule accepts "ordinary non-Latin letters" in general. It does
318// not. validNameRune admits individual letter/digit code points and nothing
319// else, and a large class of ordinary non-Latin spellings are built from a
320// base letter plus one or more combining marks (Unicode category Mn) --
321// NFD-normalised Latin, the usual spellings of Devanagari, Thai, Hebrew with
322// niqqud and Arabic with harakat among them. Every combining mark is
323// rejected outright today, so those spellings are not nameable at all, not
324// merely confusable with something else. That is a real, deliberate
325// exclusion, not a bug this test is guarding -- see NOTES.md for the
326// trade-off and why it is a product decision, not an engineering one. This
327// test only ever exercises a single-code-point homoglyph (Cyrillic "А" has
328// no combining marks involved), so it says nothing about, and must not be
329// read as evidence for, the combining-mark case.
330func TestAdoptAcceptsCrossScriptHomoglyphs(cur realm, t *testing.T) {
331	addr := testutils.TestAddress("homoglyph")
332	name := "Аlice" // Cyrillic А + "lice", not the Latin "Alice"
333	adoptAs(cur, addr, name, "red", "blue")
334
335	_, gotName, _, _, _, _, _, found := GnomeByAddress(addr)
336	uassert.True(t, found)
337	uassert.Equal(t, name, gotName)
338}
339
340// TestAdoptAcceptsOrdinaryPunctuationInName checks validNameRune's allowlist
341// is not so tight it breaks ordinary names: a space, a hyphen and an
342// apostrophe are all real characters pack/03-screens' name cards expect to
343// be able to show (e.g. a hyphenated or possessive gnome name).
344func TestAdoptAcceptsOrdinaryPunctuationInName(cur realm, t *testing.T) {
345	addr := testutils.TestAddress("ordinary-punctuation")
346	adoptAs(cur, addr, "Mary-Jane O'Hare", "navy", "teal")
347
348	_, name, _, _, _, _, _, found := GnomeByAddress(addr)
349	uassert.True(t, found)
350	uassert.Equal(t, "Mary-Jane O'Hare", name)
351}
352
353// TestAdoptAcceptsMultiByteNameWithinRuneBudget and
354// TestAdoptRejectsMultiByteNameOverRuneBudget are the "plain bug" fix:
355// validName used to count len(name), which is bytes, while maxNameLen (and
356// the spec) mean characters. "é" is 2 UTF-8 bytes per rune, so 24 of them is
357// 48 bytes -- over budget under the old byte-counting bug despite being
358// exactly 24 characters, which is what the rule was always supposed to
359// allow.
360func TestAdoptAcceptsMultiByteNameWithinRuneBudget(cur realm, t *testing.T) {
361	name := strings.Repeat("é", maxNameLen)
362	uassert.True(t, len(name) > maxNameLen, "sanity: this name must be over budget by byte length")
363	uassert.Equal(t, maxNameLen, len([]rune(name)), "sanity: this name must be exactly on budget by rune count")
364
365	addr := testutils.TestAddress("multibyte-name")
366	adoptAs(cur, addr, name, "red", "blue")
367
368	_, gotName, _, _, _, _, _, found := GnomeByAddress(addr)
369	uassert.True(t, found)
370	uassert.Equal(t, name, gotName)
371}
372
373func TestAdoptRejectsMultiByteNameOverRuneBudget(cur realm, t *testing.T) {
374	name := strings.Repeat("é", maxNameLen+1)
375	addr := testutils.TestAddress("multibyte-overlong")
376	wantMsg := "garden: name must be 1-" + strconv.Itoa(maxNameLen) + " characters"
377
378	uassert.AbortsWithMessage(t, cur, wantMsg, func() {
379		adoptAs(cur, addr, name, "red", "blue")
380	})
381	_, _, _, _, _, _, _, found := GnomeByAddress(addr)
382	uassert.False(t, found, "a name one rune over budget must not mint anything, regardless of byte length")
383}
384
385// TestRenderRejectsInvalidPathWithoutEchoingIt is the audit's finding 3:
386// address(path) is an unchecked conversion, and the old code echoed an
387// unvalidated path straight into the page inside backticks on the
388// not-found branch. A backtick (or any other markdown metacharacter) in an
389// invalid path must never reach the rendered output.
390func TestRenderRejectsInvalidPathWithoutEchoingIt(t *testing.T) {
391	junk := "not-a-valid-address`*[](){}<script>"
392	page := Render(junk)
393
394	uassert.True(t, strings.Contains(page, "invalid address"), "an invalid path should get a clear, generic refusal")
395	uassert.False(t, strings.Contains(page, junk), "the raw junk path must never be echoed back into the page")
396	uassert.False(t, strings.Contains(page, "`"), "no backtick from caller input should ever reach the page")
397}
398
399// TestAlreadyAdoptedChecksOwnerIndexNotJustBalance is the audit's finding 4:
400// ownerIndex (this realm's own record, written once by Adopt and never
401// deleted) must be consulted, not just grc721's balance. ownerIndex is
402// written only by Adopt in real operation; setting it directly here is the
403// only way, short of a second ledger implementation, to exercise "ownerIndex
404// says adopted, the ledger balance alone would not" and prove the fix
405// actually consults both.
406//
407// This permanently poisons addr's ownerIndex entry (third audit round,
408// test-hygiene item): the address below is set to "already adopted" by
409// writing straight into ownerIndex, and this realm has no admin, burn or
410// rename to ever undo that within a single `gno test` run -- realm state
411// persists across test functions, the same property TestRenderUnknownAddressDoesNotPanic's
412// own fix above had to account for. Nothing else in this file reuses
413// testutils.TestAddress("owner-index-only") today; it must stay that way --
414// any future test that adopts as this address would abort on "already
415// adopted" for a reason that has nothing to do with whatever it is testing.
416func TestAlreadyAdoptedChecksOwnerIndexNotJustBalance(cur realm, t *testing.T) {
417	addr := testutils.TestAddress("owner-index-only")
418	ownerIndex.Set(addr.String(), "nonexistent-token-id")
419
420	balance, err := token.BalanceOf(addr)
421	uassert.NoError(t, err)
422	uassert.Equal(t, int64(0), balance, "grc721's own ledger must not have been touched by poking ownerIndex directly")
423
424	uassert.True(t, alreadyAdopted(addr), "ownerIndex alone must be enough to report already-adopted")
425
426	uassert.AbortsWithMessage(t, cur, "garden: this address has already adopted a gnome", func() {
427		adoptAs(cur, addr, "ShouldNotMint", "red", "blue")
428	})
429}
430
431// TestAdoptRejectsAttachedCoins is the audit's finding 5: Adopt is a public
432// crossing function, and this realm has no banker and deliberately no
433// withdrawal path, so any coins a caller attaches would be stranded
434// forever. Adopt must refuse the call outright rather than accept and
435// strand them.
436func TestAdoptRejectsAttachedCoins(cur realm, t *testing.T) {
437	addr := testutils.TestAddress("paid")
438
439	testing.SetRealm(testing.NewUserRealm(addr))
440	testing.SetOriginSend(chain.Coins{{Denom: "ugnot", Amount: 1}})
441	defer testing.SetOriginSend(chain.Coins{})
442
443	uassert.AbortsWithMessage(t, cur, "garden: adopt accepts no coins; this realm has no banker and cannot return them", func() {
444		Adopt(cross(cur), "Payer", "red", "blue")
445	})
446
447	_, _, _, _, _, _, _, found := GnomeByAddress(addr)
448	uassert.False(t, found, "a rejected adopt must not mint anything, coins or not")
449}