render_test.gno
18.81 Kb · 490 lines
1package grants
2
3import (
4 "strings"
5 "testing"
6
7 "gno.land/p/nt/testutils/v0"
8 "gno.land/p/nt/uassert/v0"
9 "gno.land/p/nt/urequire/v0"
10)
11
12var (
13 erin = testutils.TestAddress("erin") // applicant, mid-delivery
14 frank = testutils.TestAddress("frank") // donor
15 grace = testutils.TestAddress("grace") // beneficiary of a request she did not file
16)
17
18// demo drives a program through every state the renderer has to show: a
19// membership change that executed, a donation, a grant carried over one
20// dissent, a milestone released and paid, a proof the board refused, a second
21// attempt still under review, and a request turned down flat.
22//
23// It uses the library's own API only, so if a rule changes under it the
24// pinned pages below move and the diff says exactly what changed.
25func demo() *Program {
26 p := NewProgram("ugnot", alice, bob, carol)
27 p.Fund(frank, 3000, 100)
28
29 m, _ := p.Board.SubmitMemberChange(alice, dave, true,
30 "Reviewed six of the last eight applications from the outside. Give them the seat.", 100)
31 p.Board.Vote(alice, m.ID, true, "Proposed it.", 101)
32 p.Board.Vote(bob, m.ID, true, "No objection.", 102)
33
34 g, _ := p.Apply(erin, "Port the p/nt/avl benchmarks to gno",
35 "Three weeks. A benchmark harness, a report on the tree rebalancing costs, "+
36 "and a PR against the monorepo.",
37 "harness merged:1200,report published:800", 110)
38 p.Board.Vote(alice, g.ID, true, "Cheap for what it tells us.", 111)
39 p.Board.Vote(bob, g.ID, false, "The second milestone is vague.", 112)
40 p.Board.Vote(carol, g.ID, true, "Vague but bounded. Worth it.", 113)
41 p.Board.Vote(dave, g.ID, true, "Agreed with carol.", 114)
42
43 p.Board.SubmitProof(erin, g.ID, 0, Proof{
44 Kind: "url", Ref: "https://github.com/gnolang/gno/pull/9999",
45 Note: "Harness merged, 14 benchmarks, runs in CI.", Height: 120,
46 })
47 p.Review(alice, g.ID, 0, true, "Merged, I reviewed it.", 121, 3000)
48 p.Review(carol, g.ID, 0, true, "Confirmed.", 122, 3000)
49 p.Review(dave, g.ID, 0, true, "Confirmed.", 123, 3000)
50
51 p.Board.SubmitProof(erin, g.ID, 1, Proof{Kind: "text", Ref: "it is basically done", Height: 130})
52 p.Review(alice, g.ID, 1, false, "That is not a report.", 131, 1800)
53 p.Review(bob, g.ID, 1, false, "Agreed, no numbers.", 132, 1800)
54 p.Review(carol, g.ID, 1, false, "Same.", 133, 1800)
55
56 p.Board.SubmitProof(erin, g.ID, 1, Proof{
57 Kind: "url", Ref: "https://example.com/avl-rebalancing-costs",
58 Note: "Rewritten with the measurements.", Height: 140,
59 })
60 p.Review(alice, g.ID, 1, true, "Much better.", 141, 1800)
61
62 o, _ := p.ApplyFor(erin, grace, "Her account is empty, so she cannot pay the gas to file this herself.",
63 "Translate the onboarding guide to Portuguese",
64 "One pass over the eight pages, reviewed by a second speaker.",
65 "translation merged:600", 145)
66 p.Board.Vote(alice, o.ID, true, "Cheap, and we have no pt-BR page at all.", 146)
67 p.Board.Vote(bob, o.ID, true, "Fine.", 147)
68 p.Board.Vote(carol, o.ID, true, "Fine.", 148)
69
70 n, _ := p.Apply(mallory, "Rewrite gnoweb in a different framework",
71 "Six months, one person, no migration plan.", "the rewrite:250000", 150)
72 p.Board.Vote(alice, n.ID, false, "No migration plan and no second maintainer.", 151)
73 p.Board.Vote(bob, n.ID, false, "Out of scope for this board.", 152)
74 p.Board.Vote(carol, n.ID, false, "Same.", 153)
75 return p
76}
77
78// view wraps demo in a Renderer configured the way a realm would. Balance is
79// 3000 donated minus the 1200 tranche already paid.
80func view() Renderer {
81 return Renderer{
82 Program: demo(),
83 Title: "Example grant board",
84 Intro: "Anyone may ask this board for money.",
85 Path: "gno.land/r/example/board/v0",
86 Link: "/r/example/board/v0",
87 Treasury: testutils.TestAddress("treasury"),
88 Balance: 1800,
89 Footer: "Built on [p/moul/grants/v0](/p/moul/grants/v0).",
90 }
91}
92
93func TestDemoReachedEveryStateTheseExamplesPin(t *testing.T) {
94 p := demo()
95 uassert.Equal(t, 4, p.Board.MemberCount(), "the membership change executed")
96 uassert.Equal(t, 4, p.Board.Size())
97 uassert.Equal(t, "completed", p.Board.Get(1).Status.String())
98 uassert.Equal(t, "approved", p.Board.Get(2).Status.String())
99 uassert.Equal(t, "approved", p.Board.Get(3).Status.String())
100 uassert.Equal(t, "rejected", p.Board.Get(4).Status.String())
101
102 o := p.Board.Get(3)
103 uassert.True(t, o.OnBehalf(), "request 3 is filed by erin for grace")
104 uassert.Equal(t, grace.String(), o.Payee().String())
105
106 g := p.Board.Get(2)
107 uassert.True(t, g.Milestones[0].Released, "one tranche paid")
108 uassert.Equal(t, int64(1200), p.Disbursed())
109 uassert.Equal(t, int64(1400), p.Committed(), "800 still owed on 2, 600 on 3")
110 uassert.Equal(t, int64(3000), p.Raised())
111 uassert.Equal(t, 1, len(p.Payments))
112 uassert.Equal(t, 2, len(g.Milestones[1].Attempts), "one refused, one in flight")
113 uassert.Equal(t, "refused", g.Milestones[1].Attempts[0].Outcome.String())
114 uassert.Equal(t, "under review", g.Milestones[1].Attempts[1].Outcome.String())
115}
116
117// Deliberately over an EMPTY program: the routing is what is under test, and
118// rendering the full demo four times costs seconds of VM time for nothing.
119func TestRenderUnknownPaths(t *testing.T) {
120 v := Renderer{Program: NewProgram("ugnot", alice), Title: "Example grant board", Link: "/r/x/v0"}
121 uassert.Equal(t, "# Example grant board\n\nThere is no request 99.\n", v.Render("request/99"))
122 uassert.Equal(t, "# Example grant board\n\n`abc` is not a request number.\n", v.Render("request/abc"))
123 uassert.True(t, len(v.Render("nope")) > 0)
124 uassert.Equal(t, v.Render(""), v.Render("/"), "the root is the root either way")
125}
126
127// ExampleRendererIndex pins the board page.
128func ExampleRendererIndex() {
129 print(view().Render(""))
130 // Output:
131 // # Example grant board
132 //
133 // Anyone may ask this board for money.
134 //
135 // ## Treasury
136 //
137 // | Field | Value |
138 // |---|---|
139 // | Address | `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` |
140 // | Balance | 1800 ugnot |
141 // | Promised | 1400 ugnot |
142 // | Unpromised | 400 ugnot |
143 // | Donated | 3000 ugnot |
144 // | Paid out | 1200 ugnot |
145 //
146 // ## Board
147 //
148 // A decision needs a majority of the members eligible to cast a ballot on it,
149 // recomputed every time: the party a decision is about never votes on it, so an
150 // applicant who sits on the board shrinks the room rather than packing it.
151 //
152 // | Member | Joined at height |
153 // |---|---|
154 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | 102 |
155 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | 0 |
156 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | 0 |
157 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | 0 |
158 //
159 // ## Requests
160 //
161 // | # | Kind | Title | For | Asking | Status | Ballots |
162 // |---|---|---|---|---|---|---|
163 // | [1](/r/example/board/v0:request/1) | membership | Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l | - | nothing | completed | 2 for, 0 against |
164 // | [2](/r/example/board/v0:request/2) | grant | Port the p/nt/avl benchmarks to gno | `g1v4exjm…sj8d` | 2000 ugnot | approved | 3 for, 1 against |
165 // | [3](/r/example/board/v0:request/3) | grant | Translate the onboarding guide to Portuguese | `g1vaexzc…qsw9` (filed by g1v4exjm…sj8d) | 600 ugnot | approved | 3 for, 0 against |
166 // | [4](/r/example/board/v0:request/4) | grant | Rewrite gnoweb in a different framework | `g1d4skcm…cwc9` | 250000 ugnot | rejected | 0 for, 3 against |
167 //
168 // ## Where the money went
169 //
170 // [The ledger](/r/example/board/v0:ledger) lists every donation in and every tranche
171 // out, with the height, the payee and the milestone it paid for.
172 //
173 // ## Calling it
174 //
175 // ```sh
176 // # put money in
177 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Fund -send 5000000ugnot ...
178 // # ask for some
179 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Apply \
180 // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...
181 // # ask on behalf of someone who cannot pay the gas to ask
182 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func ApplyFor \
183 // -args g1... -args 'their account is empty' \
184 // -args 'Port the thing' -args 'why it matters' -args 'design:100,ship:400' ...
185 // # decide (members only)
186 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Vote -args 1 -args true -args 'reason' ...
187 // # show your work, then get paid for it
188 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func SubmitProof \
189 // -args 1 -args 0 -args url -args 'https://...' -args 'what it is' ...
190 // gnokey maketx call -pkgpath gno.land/r/example/board/v0 -func Review -args 1 -args 0 -args true -args 'looks done' ...
191 // ```
192 //
193 // Built on [p/moul/grants/v0](/p/moul/grants/v0).
194}
195
196// ExampleRendererRequest pins the richest page: a grant mid-delivery with one
197// tranche paid, a refused proof kept on the record, and a second attempt
198// still being reviewed.
199func ExampleRendererRequest() {
200 print(view().Render("request/2"))
201 // Output:
202 // # Request 2: Port the p/nt/avl benchmarks to gno
203 //
204 // | Field | Value |
205 // |---|---|
206 // | Kind | grant |
207 // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` |
208 // | Status | approved |
209 // | Filed at height | 110 |
210 // | Decided at height | 114 |
211 // | Asking | 2000 ugnot |
212 // | Paid so far | 1200 ugnot |
213 // | Still owed | 800 ugnot |
214 //
215 // ## The ask
216 //
217 //
218 //
219 // Three weeks. A benchmark harness, a report on the tree rebalancing costs, and a PR against the monorepo.
220 //
221 //
222 //
223 // ## Ballots
224 //
225 // Needs 3 of 4 eligible members. Standing: 3 for, 1 against.
226 //
227 // | Member | Vote | Height | Reason |
228 // |---|---|---|---|
229 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 111 | Cheap for what it tells us\. |
230 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | against | 112 | The second milestone is vague\. |
231 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 113 | Vague but bounded\. Worth it\. |
232 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | for | 114 | Agreed with carol\. |
233 //
234 // ## Milestones
235 //
236 // Earned in order. The applicant submits a proof, the board reviews that proof,
237 // and the verdict that carries also moves the coins.
238 //
239 // ### 1. harness merged: 1200 ugnot
240 //
241 // Released at height 123, paid to `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d`.
242 //
243 // **Proof 1** (url, height 120, accepted)
244 //
245 // ```
246 // https://github.com/gnolang/gno/pull/9999
247 // ```
248 //
249 //
250 //
251 // Harness merged, 14 benchmarks, runs in CI.
252 //
253 //
254 //
255 // | Member | Vote | Height | Reason |
256 // |---|---|---|---|
257 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 121 | Merged, I reviewed it\. |
258 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | accept | 122 | Confirmed\. |
259 // | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` | accept | 123 | Confirmed\. |
260 //
261 // ### 2. report published: 800 ugnot
262 //
263 // A proof is under review.
264 //
265 // **Proof 1** (text, height 130, refused)
266 //
267 // ```
268 // it is basically done
269 // ```
270 //
271 // | Member | Vote | Height | Reason |
272 // |---|---|---|---|
273 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | refuse | 131 | That is not a report\. |
274 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | refuse | 132 | Agreed, no numbers\. |
275 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | refuse | 133 | Same\. |
276 //
277 // **Proof 2** (url, height 140, under review)
278 //
279 // ```
280 // https://example.com/avl-rebalancing-costs
281 // ```
282 //
283 //
284 //
285 // Rewritten with the measurements.
286 //
287 //
288 //
289 // | Member | Vote | Height | Reason |
290 // |---|---|---|---|
291 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | accept | 141 | Much better\. |
292}
293
294// ExampleRendererMembership pins a membership request, which asks for no
295// money and therefore renders no milestones.
296func ExampleRendererMembership() {
297 print(view().Render("request/1"))
298 // Output:
299 // # Request 1: Add g1v3shve2lta047h6lta047h6lta047h6lel7d9l
300 //
301 // | Field | Value |
302 // |---|---|
303 // | Kind | membership |
304 // | Applicant | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` |
305 // | Subject | `g1v3shve2lta047h6lta047h6lta047h6lel7d9l` |
306 // | Effect | add to the board |
307 // | Status | completed |
308 // | Filed at height | 100 |
309 // | Decided at height | 102 |
310 //
311 // ## The ask
312 //
313 //
314 //
315 // Reviewed six of the last eight applications from the outside. Give them the seat.
316 //
317 //
318 //
319 // ## Ballots
320 //
321 // Needs 2 of 3 eligible members. Standing: 2 for, 0 against.
322 //
323 // | Member | Vote | Height | Reason |
324 // |---|---|---|---|
325 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 101 | Proposed it\. |
326 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 102 | No objection\. |
327}
328
329// ExampleRendererLedger pins the money trail.
330func ExampleRendererLedger() {
331 print(view().Render("ledger"))
332 // Output:
333 // # Example grant board: the ledger
334 //
335 // Every coin in and every coin out, in the order it happened. Coins sent
336 // straight to `g1w3ex2ctnw4e8jh6lta047h6lta047h6llhugks` land in the treasury
337 // without appearing here, which is why `Fund` exists: it is the same transfer
338 // with a name attached.
339 //
340 // ## In
341 //
342 // | Height | From | Amount |
343 // |---|---|---|
344 // | 100 | `g1veexzmntta047h6lta047h6lta047h6lthxxu5` | 3000 ugnot |
345 //
346 // Total in: 3000 ugnot
347 //
348 // ## Out
349 //
350 // | Height | Request | Milestone | To | Amount |
351 // |---|---|---|---|---|
352 // | 123 | [2](/r/example/board/v0:request/2) | 1 | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` | 1200 ugnot |
353 //
354 // Total out: 1200 ugnot
355 //
356 // Balance now: 1800 ugnot. [Back to the board](/r/example/board/v0).
357}
358
359// ExampleRendererOnBehalf pins a request one address filed for another: the
360// beneficiary row, the callout carrying the applicant's reason, and the "For"
361// column that says on the board page who the money would reach.
362func ExampleRendererOnBehalf() {
363 print(view().Render("request/3"))
364 // Output:
365 // # Request 3: Translate the onboarding guide to Portuguese
366 //
367 // | Field | Value |
368 // |---|---|
369 // | Kind | grant |
370 // | Applicant | `g1v4exjmjlta047h6lta047h6lta047h6lv8sj8d` |
371 // | Beneficiary | `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9` |
372 // | Status | approved |
373 // | Filed at height | 145 |
374 // | Decided at height | 148 |
375 // | Asking | 600 ugnot |
376 // | Paid so far | 0 ugnot |
377 // | Still owed | 600 ugnot |
378 //
379 // ## Filed for someone else
380 //
381 // The applicant is not the payee: every tranche of this request pays
382 // `g1vaexzcm9ta047h6lta047h6lta047h6lr5qsw9`. Both addresses are barred from voting on it, and
383 // either may submit a proof. The reason given:
384 //
385 // > Her account is empty, so she cannot pay the gas to file this herself\.
386 //
387 // Nothing checks that reason. It is a claim by the applicant, and the point of
388 // printing it here is that a member can check it before voting.
389 //
390 // ## The ask
391 //
392 // One pass over the eight pages, reviewed by a second speaker.
393 //
394 // ## Ballots
395 //
396 // Needs 3 of 4 eligible members. Standing: 3 for, 0 against.
397 //
398 // | Member | Vote | Height | Reason |
399 // |---|---|---|---|
400 // | `g1v9kxjcm9ta047h6lta047h6lta047h6lzd40gh` | for | 146 | Cheap, and we have no pt\-BR page at all\. |
401 // | `g1vfhkyh6lta047h6lta047h6lta047h6l03vdhu` | for | 147 | Fine\. |
402 // | `g1vdshymmvta047h6lta047h6lta047h6l2asz94` | for | 148 | Fine\. |
403 //
404 // ## Milestones
405 //
406 // Earned in order. The applicant submits a proof, the board reviews that proof,
407 // and the verdict that carries also moves the coins.
408 //
409 // ### 1. translation merged: 600 ugnot
410 //
411 // Waiting on a proof from the applicant.
412}
413
414// injected is every dangerous markdown sequence a caller can type, in one
415// string, so a slot that forgets to escape shows up as a live link or a
416// gnoweb tag in the assertions below rather than on a deployed page.
417const injected = "[click](https://evil.example)  " +
418 "<gno-columns> | fake | cell | ```\nsecond line\n"
419
420// injectedShort is the same attack inside the 100-character limit a title and
421// a milestone name are held to.
422const injectedShort = "[click](https://evil.example) <gno-columns>|x|\nnext"
423
424// TestEveryCallerSuppliedStringIsEscaped drives one board through every slot
425// a caller controls and asserts that none of them can reshape the page.
426//
427// The bar is not the same in every slot, on purpose. A one-line slot (a
428// title, a milestone name, the reason on a ballot, the reason for filing on
429// someone else's behalf) is chrome the page lays out, so nothing a caller
430// typed may survive as markup there. A prose slot (the body of an
431// application, the note on a proof) goes through sanitize.Block, which
432// PRESERVES inline links and emphasis by design: a grant application whose
433// link to the merged PR renders as literal text is a worse page, and the
434// sanitizer's own contract is that prose formats. What Block still kills is
435// everything structural, so the paragraph cannot leave its paragraph.
436//
437// Assertions are on the dangerous SEQUENCE, never on the exact escaped bytes:
438// those belong to the sanitizer, they change when it changes, and a test that
439// pins them fails for the wrong reason.
440func TestEveryCallerSuppliedStringIsEscaped(t *testing.T) {
441 p := NewProgram("ugnot", alice, bob)
442 r, err := p.ApplyFor(erin, grace, injected, injectedShort, injected, injectedShort+":100", 10)
443 urequire.NoError(t, err)
444 p.Board.Vote(alice, r.ID, true, injected, 11)
445 p.Board.Vote(bob, r.ID, true, injected, 12)
446 urequire.NoError(t, p.Board.SubmitProof(erin, r.ID, 0, Proof{
447 Kind: "url", Ref: injected, Note: injected, Height: 13,
448 }))
449 p.Board.Review(alice, r.ID, 0, false, injected, 14)
450
451 v := Renderer{Program: p, Title: "Board", Path: "gno.land/r/x/v0", Link: "/r/x/v0", Balance: 0}
452
453 // Every structural line of every page: a table row, a heading, a
454 // blockquote. Nothing a caller typed may render as markup in one.
455 for _, page := range []string{"", "request/1", "ledger"} {
456 for _, line := range strings.Split(v.Render(page), "\n") {
457 if !strings.HasPrefix(line, "|") && !strings.HasPrefix(line, "#") &&
458 !strings.HasPrefix(line, ">") {
459 continue
460 }
461 for _, dead := range []string{"](http", "![", "<gno-"} {
462 uassert.False(t, strings.Contains(line, dead),
463 "page "+page+" line still carries "+dead+": "+line)
464 }
465 }
466 }
467
468 // A table row keeps exactly its own cells: an unescaped pipe would open a
469 // column and an unescaped newline would end the row early, and both
470 // reshape the table silently rather than failing.
471 for _, line := range strings.Split(v.Render("request/1"), "\n") {
472 if !strings.HasPrefix(line, "| `"+alice.String()+"`") {
473 continue
474 }
475 uassert.Equal(t, 5, strings.Count(line, "|")-strings.Count(line, "\\|"),
476 "the ballot row kept its four cells: "+line)
477 }
478
479 // An escaper that ate the content would pass every assertion above.
480 out := v.Render("request/1")
481 uassert.True(t, strings.Contains(out, "click"), "the readable text survived")
482 uassert.True(t, strings.Contains(out, "second line"), "the second line survived")
483
484 // The structural attack is dead in the prose slots too, even though the
485 // link in them is deliberately alive.
486 body := out[strings.Index(out, "## The ask"):strings.Index(out, "## Ballots")]
487 uassert.False(t, strings.Contains(body, "\n<gno-"), "no gnoweb tag at a line start")
488 uassert.False(t, strings.Contains(body, "\n| "), "prose cannot open a table")
489 uassert.False(t, strings.Contains(body, "\n#"), "prose cannot forge a heading")
490}