package bolo import ( "chain" "math" "strconv" "strings" "testing" "gno.land/p/nt/markdown/sanitize/v0" "gno.land/p/nt/seqid/v0" "gno.land/p/nt/testutils/v0" ) func TestGnot(t *testing.T) { for ugnot, want := range map[int64]string{ 0: "0 GNOT", 1: "0.000001 GNOT", 1_500_000: "1.5 GNOT", 5_000_000: "5 GNOT", 1_234_567: "1.234567 GNOT", 10_000_010: "10.00001 GNOT", -2_500_000: "-2.5 GNOT", } { if got := gnot(ugnot); got != want { t.Errorf("gnot(%d) = %q, want %q", ugnot, got, want) } } if got := when(0); got != "1970-01-01 00:00 UTC" { t.Errorf("when(0) = %q", got) } for n, want := range map[int]string{0: "0 donors", 1: "1 donor", 2: "2 donors"} { if got := count(n, "donor", "donors"); got != want { t.Errorf("count(%d) = %q, want %q", n, got, want) } } } func TestParsers(t *testing.T) { for s, want := range map[string]int{"1": 1, "20": 20, "999999999": 999999999} { if n, ok := parsePage(s); !ok || n != want { t.Errorf("parsePage(%q) = %d, %t", s, n, ok) } } for _, s := range []string{"", "0", "01", "-1", "+1", "1x", " 1", "1000000000"} { if _, ok := parsePage(s); ok { t.Errorf("parsePage(%q) accepted", s) } } good := testutils.TestAddress("renderer").String() if _, ok := parseAddress(good); !ok { t.Errorf("parseAddress refused %s", good) } last := "q" if good[39:] == "q" { last = "p" } badSum := good[:39] + last // 40 characters, a lowercase "g", a wrong checksum for _, s := range []string{"", "notanaddress", strings.ToUpper(good), good[:39], good + "x", badSum} { if _, ok := parseAddress(s); ok { t.Errorf("parseAddress(%q) accepted", s) } } if q, ok := parseQuery("page=2&&size=1000000&x"); !ok || q["page"] != "2" || q["size"] != "1000000" { t.Errorf("parseQuery: %v, %t", q, ok) } if _, ok := parseQuery("page=1&page=2"); ok { t.Errorf("parseQuery accepted a key twice") } } // TestRenderNotFound: every path that fails gets the one not-found page, which echoes // nothing from the request (API §5). func TestRenderNotFound(t *testing.T) { c := testCampaign(9961, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() c.receipts.Set(seqid.ID(1).Binary(), &receipt{seq: 1, donor: testutils.TestAddress("donor"), amount: 5}) bad := []string{ "c/0" + id, "c/x", "c/" + id + "/", "c/" + id + "/r/x", "c/" + id + "/r/0", "c/" + id + "/r/2", "c/" + id + "/donations?page=-1", "c/" + id + "/donations?page=0", "c/" + id + "/ledger?page=x", "c/" + id + "/updates?page=1&page=2", "c/" + id + "/nope", "c/99999", "c/", "c/" + id + "/r/1/x", "u/notanaddress", "u/" + strings.ToUpper(testutils.TestAddress("renderer").String()), "u/" + testutils.TestAddress("renderer").String() + "/nope", "u/" + testutils.TestAddress("renderer").String() + "/gave?after=0x", "?after=x", "?after=", "?cat=nope", "?cat=", "c?cat=nope", "c/" + id + "/r?page=0", "u/", "u?address=a&address=b", "about/x", "x", "/", "//", strings.Repeat("c/", 200), } for _, path := range bad { if got := Render(path); got != notFound { t.Errorf("Render(%q) isn't the not-found page:\n%s", path, got) } } // A key the route doesn't take is ignored. if Render("c/"+id+"/donations?size=1000000") == notFound || Render("?page=-1") == notFound { t.Errorf("an ignored key made the page fail") } } // injection is what a stranger, a moderator or an organizer might write to pass for // Bolo, plant a link, an image, raw HTML or a mention, open an alert or one of // gnoweb's blocks, hide characters, or break out of their slot — shapes the writes' // own checks let through, the hidden characters stripped (API §2.3). const injection = "[!WARNING] [Verify](https://evil.example) **BOLO** # x | y ] ) ! raw ![i](https://evil.example/i.png) \u200b\u202e @bolo_help " // mention reports whether s holds what gnoweb makes a link of on its own: an "@" no // backslash escapes, or an address after a space or opening a line's text. func mention(s string) bool { for i := 0; i < len(s); i++ { if s[i] == '@' && (i == 0 || s[i-1] != '\\') { return true } } for _, line := range strings.Split(s, "\n") { if strings.HasPrefix(strings.TrimLeft(line, "#>- "), "g1") { return true } } return strings.Contains(s, " g1") } // ownTags reports whether every tag of gnoweb's blocks in s that isn't escaped stands // on a line of its own, as Bolo writes them — the only place gnoweb reads one — and the // columns open as often as they close. func ownTags(s string) bool { lines := map[string]bool{"": true, "": true, "": true, "": true, " ": true, "": true} for _, line := range strings.Split(s, "\n") { own := lines[line] || line == " " || strings.HasPrefix(line, "") && isDigits(line[len("")]) for i := 0; i+5 <= len(line); i++ { if (line[i:i+5] == "\n") == strings.Count(s, "\n\n") } // isDigits reports whether s is one or more decimal digits. func isDigits(s string) bool { for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return s != "" } // TestRenderEscapes: every text slot is escaped by who wrote it. Strangers' and // moderators' text can carry no link, no image, no mention and none of gnoweb's // blocks; the organizer's story and updates stay inside their quote, below Bolo's own // blocks; the alerts are Bolo's — the notice on the campaign's page, the refund banner // on a refunded gift's receipt — which no user text can open; addresses sit in code // spans (API §5). func TestRenderEscapes(t *testing.T) { phisher := testutils.TestAddress("phisher").String() inj := phisher + " " + injection + " " + phisher story := "[!WARNING] Notice from Bolo's moderator\nHelp us.\n## Check it yourself\n- Escrow: `g1fake`\n| a | b |\n[ok](https://ok.example) @bolo_help" body := "## Act\n[Give 5 GNOT](https://evil.example)" donor := testutils.TestAddress("donor") c := testCampaign(9962, modeEscrow, 0) c.text.title, c.text.description = inj, story c.text.imageCID, c.text.imageAlt = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", inj c.state.notice = inj id, unstore := storeCampaign(c) defer unstore() c.receipts.Set(seqid.ID(1).Binary(), &receipt{seq: 1, donor: donor, amount: 5, message: inj}) c.state.donationCount, c.state.recent[0], c.state.recentNext = 1, 1, 1 c.updates.Set(seqid.ID(1).Binary(), &update{seq: 1, body: body}) c.state.updateCount = 1 c.contributions.Set(donor.String(), &contribution{amount: 5, refunded: 1, public: true}) c.refunds.Set(seqid.ID(1).Binary(), &refund{seq: 1, amount: 1, donor: donor, kind: "open"}) c.withdrawals.Set(seqid.ID(1).Binary(), &withdrawal{seq: 1, amount: 1, to: c.beneficiary}) byOrganizer.Set(c.organizer.String()+"/"+c.id.Binary(), true) defer byOrganizer.Remove(c.organizer.String() + "/" + c.id.Binary()) org := "u/" + c.organizer.String() for _, path := range []string{"c/" + id, "c/" + id + "/r/1", "c/" + id + "/donations", "c/" + id + "/updates", "c/" + id + "/ledger", "c/" + id + "/give", "c/" + id + "/give?amount=5", "?cat=water", org, org + "/organized"} { out := Render(path) // The organizer's own text, quoted, keeps its links and mentions: without it, // nothing on the page may carry one. rest := strings.ReplaceAll(strings.ReplaceAll(out, sanitize.Blockquote(story), ""), sanitize.Blockquote(body), "") if strings.Contains(rest, "](https://") || mention(rest) { t.Errorf("%s: a link, an image or a mention outside the organizer's quote:\n%s", path, rest) } if strings.Contains(strings.ReplaceAll(rest, `\<`, ""), "") || strings.ContainsAny(rest, "\u200b\u202e") { t.Errorf("%s: raw HTML or a hidden character outside the organizer's quote:\n%s", path, rest) } if !ownTags(rest) { t.Errorf("%s: a block's tag Bolo didn't write:\n%s", path, rest) } alerts, entities := 0, 0 switch path { case "c/" + id: alerts, entities = 1, 2 if !strings.HasPrefix(out, "> [!WARNING] Notice from Bolo's moderator\n> ") { t.Errorf("the notice isn't the alert at the top of the page:\n%s", out) } case "c/" + id + "/r/1": alerts = 1 if !strings.HasPrefix(out, "> [!CAUTION] Refunded since\n> 0.000001 GNOT went back to this donor after this gift.\n\n# Gift receipt") { t.Errorf("the refund banner isn't the alert at the top of the receipt:\n%s", out) } } if n := strings.Count("\n"+out, "\n> [!"); n != alerts { t.Errorf("%s: %d alerts, want %d", path, n, alerts) } // The title's heading and the notice's line open with an address; nothing // else may write its "g" as an entity, which a table of contents shows as is. if n := strings.Count(out, "g"); n != entities { t.Errorf("%s: %d entities, want %d", path, n, entities) } } page := Render("c/" + id) verifyAt, storyAt := strings.Index(page, "## Check it yourself\n\n- **Escrow** `g1"), strings.Index(page, "## In the organizer's words") if verifyAt < 0 || storyAt < 0 || verifyAt > storyAt { t.Errorf("the verification block isn't before the story: %d, %d", verifyAt, storyAt) } if !strings.Contains(page, "## In the organizer's words\n"+sanitize.Blockquote(story)) { t.Errorf("the story isn't quoted under its heading:\n%s", page) } if !strings.Contains(page, "](ipfs://QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG)") { t.Errorf("the photo is missing:\n%s", page) } } // TestRenderHiddenPhoto: a hidden photo is gone from the campaign's page, its // description with it (API §3.1, §3.5, §5). func TestRenderHiddenPhoto(t *testing.T) { c := testCampaign(9968, modeEscrow, 0) c.text.imageCID, c.text.imageAlt = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", "The village well" id, unstore := storeCampaign(c) defer unstore() if page := Render("c/" + id); !strings.Contains(page, "![The village well](ipfs://") { t.Errorf("the photo is missing:\n%s", page) } c.state.imageHidden = true if page := Render("c/" + id); strings.Contains(page, "ipfs://") || strings.Contains(page, "The village well") { t.Errorf("a hidden photo is still on the page:\n%s", page) } } // TestRenderDelisted: a delisted campaign shows its notice and no donate links, and is // on no list that promotes (API §5). func TestRenderDelisted(t *testing.T) { org := testutils.TestAddress("renderorg") cs, unindex := indexCampaigns(9963, 2, org, org) defer unindex() off := cs[0] off.state.listed, off.state.notice = false, "Under review" dropActive(off) defer addActive(off) if page := Render("c/9963"); !strings.Contains(page, "Under review") || strings.Contains(page, "## Give") || strings.Contains(page, "func=Donate&id=9963&tip") { t.Errorf("a delisted campaign's page:\n%s", page) } if !strings.Contains(Render("c/9964"), "## Give") { t.Errorf("a listed campaign has no donate links") } for _, path := range []string{"u/" + org.String(), "u/" + org.String() + "/organized"} { if addr := Render(path); strings.Contains(addr, ":c/9963)") || !strings.Contains(addr, ":c/9964)") { t.Errorf("%s lists the delisted campaign, or misses the listed one:\n%s", path, addr) } } if home := Render("?cat=animals"); strings.Contains(home, ":c/9963)") || !strings.Contains(home, ":c/9964)") { t.Errorf("the home page:\n%s", home) } } // TestRenderBounds: a path longer than any valid one gets the not-found page before it // is parsed, and a page's size never comes from the query: ?size= is ignored (API §5). func TestRenderBounds(t *testing.T) { if got := Render("?pad=" + strings.Repeat("a", 300)); got != notFound { t.Errorf("a 305-byte path rendered") } c := testCampaign(9965, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() for i := 1; i <= 25; i++ { c.receipts.Set(seqid.ID(i).Binary(), &receipt{seq: uint64(i), donor: testutils.TestAddress("donor"), amount: 5}) } if n := strings.Count(Render("c/"+id+"/donations?size=50"), "[receipt]("); n != 20 { t.Errorf("?size=50: %d gifts on the page, want 20", n) } if n := strings.Count(Render("c/"+id+"/donations?page=2"), "[receipt]("); n != 5 { t.Errorf("page 2: %d gifts, want 5", n) } } // TestRenderGiveLinks: the one-click donate links appear only while the campaign is // open, listed, and Bolo isn't paused (API §5). func TestRenderGiveLinks(t *testing.T) { c := testCampaign(9966, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() defer func() { paused = false }() give := func() bool { return strings.Contains(Render("c/"+id), "## Give") } if !give() { t.Errorf("an open, listed campaign has no donate links") } paused = true if give() { t.Errorf("donate links while Bolo is paused") } paused = false c.deadline = now() - 1 if give() { t.Errorf("donate links past the deadline") } c.deadline = 0 c.state.status = statusClosed if give() { t.Errorf("donate links on a closed campaign") } } // TestRenderActLinks: Withdraw is offered only while the escrow holds coins, and Close // whenever someone could close: the organizer while the campaign is open, anyone once // CanClose says so (API §5). func TestRenderActLinks(t *testing.T) { c := testCampaign(9967, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() page := func() string { return Render("c/" + id) } closeBy := func(who string) string { return "func=Close&id=" + id + "): " + who } if p := page(); strings.Contains(p, "[Withdraw](") || !strings.Contains(p, closeBy("the organizer")) { t.Errorf("an empty escrow offers Withdraw, or the organizer can't close:\n%s", p) } testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5))) if p := page(); !strings.Contains(p, "[Withdraw](") { t.Errorf("an escrow holding coins offers no Withdraw:\n%s", p) } c.deadline = now() - 1 // passed, not recorded: anyone may close if p := page(); !strings.Contains(p, closeBy("anyone")) { t.Errorf("a passed deadline nobody recorded offers no Close to anyone:\n%s", p) } c.deadline, c.state.status = 0, statusClosed if p := page(); strings.Contains(p, "[Close](") { t.Errorf("a closed campaign offers Close:\n%s", p) } // Withdraw stops when the beneficiary's window ends; Finalize appears when it can. c.state.raised, c.state.withdrawn, c.state.everWithdrawn, c.state.closedAt = 7_000_000, 2_000_000, true, now() testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5_000_000))) // the 5 GNOT the books hold if p := page(); !strings.Contains(p, "[Withdraw](") || strings.Contains(p, "[Finalize](") { t.Errorf("closed, the beneficiary's window running: no Withdraw, or a Finalize:\n%s", p) } c.state.closedAt = now() - c.params.ExitDelay if p := page(); strings.Contains(p, "[Withdraw](") || !strings.Contains(p, "func=Finalize&id="+id+"): anyone") { t.Errorf("closed, the beneficiary's window over: a Withdraw, or no Finalize:\n%s", p) } } // TestRenderEmptyPages: a page with nothing to show says so, and says "None on this // page." on a later page or when a "More" follows — the home page once its read budget // went on passed deadlines, an address list whose newest are all delisted, a campaign's // own lists past their first page (API §5). func TestRenderEmptyPages(t *testing.T) { org := testutils.TestAddress("emptyorg") if home := Render("?cat=animals"); !strings.Contains(home, "No campaigns here yet.") { t.Errorf("an empty category:\n%s", home) } if later := Render("?cat=animals&after=1"); !strings.Contains(later, "None on this page.") || strings.Contains(later, "[More](") { t.Errorf("a later page with nothing on it:\n%s", later) } cs, unindex := indexCampaigns(9600, 4*listPage+1, org, org) defer unindex() for _, c := range cs { c.deadline = now() - 1 } if home := Render("?cat=animals"); !strings.Contains(home, "None on this page.") || !strings.Contains(home, "[More](") { t.Errorf("a page of passed deadlines, with more to read:\n%s", home) } for _, c := range cs { c.state.listed = false } for _, p := range []string{"u/" + org.String(), "u/" + org.String() + "/organized"} { if page := Render(p); !strings.Contains(page, "None on this page.") || !strings.Contains(page, "[More](") { t.Errorf("%s: a page of delisted campaigns, with more to come:\n%s", p, page) } } other := "u/" + testutils.TestAddress("nobody").String() + "/organized" if first, later := Render(other), Render(other+"?after=1"); !strings.Contains(first, "None.") || !strings.Contains(later, "None on this page.") { t.Errorf("an empty address list, first and later pages:\n%s\n%s", first, later) } c := testCampaign(9969, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() for route, first := range map[string]string{"donations": "No gifts yet.", "ledger": "None.\n\n## Refunds\n\nNone.", "updates": "No updates yet."} { later := strings.ReplaceAll(strings.ReplaceAll(first, "yet.", "on this page."), "None.", "None on this page.") if p1, p2 := Render("c/"+id+"/"+route), Render("c/"+id+"/"+route+"?page=2"); !strings.Contains(p1, first) || !strings.Contains(p2, later) { t.Errorf("an empty %s list, pages 1 and 2:\n%s\n%s", route, p1, p2) } } var b strings.Builder writeListed(&b, []CampaignView{{Listed: false}}, true) if b.String() != "None on this page.\n\n" { t.Errorf("a list of delisted campaigns with more to come: %q", b.String()) } b.Reset() writeListed(&b, nil, false) if b.String() != "None.\n\n" { t.Errorf("an empty list: %q", b.String()) } } // giveTo records a public gift of amount ugnot from donor to c, as Donate would for // the pages to show: its receipt, the recent gifts, the counts, the donor's // contribution, and the donor's list. func giveTo(c *campaign, seq uint64, donor address, amount int64) { s := c.state c.receipts.Set(seqid.ID(seq).Binary(), &receipt{seq: seq, donor: donor, amount: amount, at: now(), message: "Courage"}) s.recent[s.recentNext%recentSize] = seq s.recentNext++ s.donationCount++ s.donorCount++ s.raised += amount c.contributions.Set(donor.String(), &contribution{amount: amount, public: true}) byDonor.Set(donor.String()+"/"+c.id.Binary(), true) } // pageLinks returns the paths of a page's links to Bolo's own pages, and the // functions its transaction links call. func pageLinks(page string) (paths, funcs []string) { at := "](" + webPath for i := strings.Index(page, at); i >= 0; i = strings.Index(page, at) { page = page[i+len(at):] end := strings.Index(page, ")") target := page[:end] switch { case target == "": paths = append(paths, "") case target[0] == ':': paths = append(paths, target[1:]) case strings.HasPrefix(target, "$help&func="): name, _, _ := strings.Cut(strings.TrimPrefix(target, "$help&func="), "&") funcs = append(funcs, name) } } return paths, funcs } // TestRenderLinks: every link a page writes leads to a page, every transaction link // calls one of Bolo's writes, and every prefix of every path renders a page too, as // gnoweb's header links each one (UI spec §4.13) — on campaigns of each kind. func TestRenderLinks(t *testing.T) { org, ben, donor := testutils.TestAddress("linkorg"), testutils.TestAddress("linkben"), testutils.TestAddress("linkdonor") cs, unindex := indexCampaigns(9700, 4, org, ben) defer unindex() open, direct, closed, settled := cs[0], cs[1], cs[2], cs[3] direct.mode = modeDirect closed.state.status, closed.state.closedAt, closed.state.closeReason = statusClosed, now(), reasonOrganizer settled.state.status, settled.state.closedAt, settled.state.closeReason = statusSettled, now(), reasonOrganizer dropActive(closed) dropActive(settled) for _, c := range cs { giveTo(c, 1, donor, 5_000_000) defer byDonor.Remove(donor.String() + "/" + c.id.Binary()) } open.updates.Set(seqid.ID(1).Binary(), &update{seq: 1, at: now(), body: "Started."}) open.state.updateCount = 1 open.withdrawals.Set(seqid.ID(1).Binary(), &withdrawal{seq: 1, amount: 1_000_000, at: now(), to: ben}) open.state.withdrawn, open.state.everWithdrawn = 1_000_000, true closed.refunds.Set(seqid.ID(1).Binary(), &refund{seq: 1, amount: 1_000_000, at: now(), donor: donor, kind: windowOpen}) closed.state.refunded = 1_000_000 for _, c := range cs { // the bank holds what the books say if held := trackedBalance(c); held > 0 { testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", held))) } } writes := map[string]bool{"Donate": true, "Refund": true, "Withdraw": true, "Close": true, "Finalize": true, "PostUpdate": true, "CreateCampaign": true} seen := map[string]bool{} queue := []string{"", "about", "params", "u", "u?address=" + donor.String(), "u?address=nope", "c/9700/give?amount=7.5", "c/9700/give?amount=nope"} for _, c := range cs { queue = append(queue, "c/"+idString(c.id)+"/r/1") } for len(queue) > 0 { path := queue[0] queue = queue[1:] if seen[path] { continue } seen[path] = true page := Render(path) if page == notFound { t.Errorf("%q: not found", path) continue } paths, funcs := pageLinks(page) for _, f := range funcs { if !writes[f] { t.Errorf("%q links to a transaction calling %q", path, f) } } route, _, _ := strings.Cut(path, "?") segs := strings.Split(route, "/") for i := 1; i < len(segs); i++ { paths = append(paths, strings.Join(segs[:i], "/")) } queue = append(queue, paths...) } for _, want := range []string{"c", "c/9700", "c/9700/r", "c/9700/give", "c/9700/donations", "c/9700/ledger", "c/9700/updates", "u", "u/" + org.String(), "u/" + ben.String(), "u/" + donor.String()} { if !seen[want] { t.Errorf("no link or prefix reaches %q", want) } } } // TestRenderPrefixes: the prefixes gnoweb's header links render the pages they stand // for: "c" the home page, "c/{id}/r" the campaign's gifts (UI spec §4.13). func TestRenderPrefixes(t *testing.T) { c := testCampaign(9971, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() if Render("c") != Render("") || Render("c?cat=water") != Render("?cat=water") { t.Errorf("c isn't the home page") } if Render("c/"+id+"/r") != Render("c/"+id+"/donations") || Render("c/"+id+"/r?page=2") != Render("c/"+id+"/donations?page=2") { t.Errorf("c/%s/r isn't the gifts page", id) } } // TestRenderLookup: "u" is a form to find an address, which submits back as // ?address=: a valid address gets its page, anything else the form again with one // fixed line, echoing nothing (API §5). func TestRenderLookup(t *testing.T) { form := Render("u") if !strings.Contains(form, "# Find an address") || !strings.Contains(form, "\n\n \n\n") || strings.Contains(form, "isn't") { t.Errorf("the lookup page:\n%s", form) } good := testutils.TestAddress("lookedup").String() if got := Render("u?address=" + good); got != Render("u/"+good) { t.Errorf("a valid address doesn't get its page:\n%s", got) } for _, bad := range []string{"", "notanaddress", strings.ToUpper(good), good + "x", "%3Cb%3Eecho%3C/b%3E", "echo"} { got := Render("u?address=" + bad) if !strings.Contains(got, "That isn't a gno.land address.") || !strings.Contains(got, " [!NOTE] Full for now\n") { t.Errorf("donate links with no room, or no banner:\n%s", page) } escrowCap = 0 if page = Render("c/" + id); !presetLink(page, 50_000_000) || strings.Contains(page, "It can take") { t.Errorf("no cap, yet a preset left out or a room said:\n%s", page) } escrowCap = 30_000_000 c.mode = modeDirect // the cap is escrow's if page = Render("c/" + id); !presetLink(page, 50_000_000) || strings.Contains(page, "Full for now") { t.Errorf("a direct campaign held to the escrow cap:\n%s", page) } // The room is the cap less the tracked balance, never the bank's, and a preset // under the smallest gift is left out too. c.mode = modeEscrow c.state.raised, c.state.withdrawn, c.state.refunded, c.state.everWithdrawn = 40_000_000, 20_000_000, 2_000_000, true // 18 GNOT tracked testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 27_000_000))) // and 9 sent outside Bolo page = Render("c/" + id) if !presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) || presetLink(page, 20_000_000) || !strings.Contains(page, "It can take 12 GNOT more for now") { t.Errorf("the room isn't the cap less the tracked balance:\n%s", page) } savedMin := minDonation minDonation = 7_000_000 if page = Render("c/" + id); presetLink(page, 5_000_000) || !presetLink(page, 10_000_000) { t.Errorf("a preset under the smallest gift:\n%s", page) } minDonation = savedMin escrowCap = 23_000_000 // exactly 5 GNOT of room if page = Render("c/" + id); !presetLink(page, 5_000_000) || presetLink(page, 10_000_000) { t.Errorf("a preset equal to the room left out:\n%s", page) } escrowCap = 19_000_000 // room for exactly the smallest gift if page = Render("c/" + id); !strings.Contains(page, "## Give") || presetLink(page, 5_000_000) || !strings.Contains(page, "It can take 1 GNOT more for now") { t.Errorf("room for exactly the smallest gift:\n%s", page) } } // TestRenderBanner: the campaign's page shows the one state banner the UI spec's state // matrix gives its state (§6), and none while nothing is out of the ordinary. func TestRenderBanner(t *testing.T) { c := testCampaign(9973, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() defer func() { paused = false }() banner := func() string { page := Render("c/" + id) if n := strings.Count("\n"+page, "\n> [!"); n > 1 { t.Errorf("%d banners:\n%s", n, page) } i := strings.Index(page, "> [!") if i < 0 { return "" } return page[i : i+strings.Index(page[i:], "\n\n")] } if b := banner(); b != "" { t.Errorf("an open campaign shows a banner: %q", b) } paused = true if b := banner(); !strings.HasPrefix(b, "> [!CAUTION] Bolo is paused\n") { t.Errorf("paused: %q", b) } paused = false saved, savedMin := escrowCap, minDonation escrowCap = 500_000 // below the smallest gift: no withdrawal makes room if b := banner(); b != "> [!NOTE] Full for now\n> Bolo's escrow cap is set below its smallest gift, so no campaign in escrow can take a gift for now." { t.Errorf("a cap below the smallest gift: %q", b) } escrowCap, c.state.raised = 2_000_000, 1_500_000 // half a GNOT of room, under the smallest gift if b := banner(); b != "> [!NOTE] Full for now\n> This campaign can't take more until the beneficiary withdraws." { t.Errorf("a full escrow: %q", b) } escrowCap = minDonation // a cap of exactly the smallest gift: a withdrawal makes room if b := banner(); b != "> [!NOTE] Full for now\n> This campaign can't take more until the beneficiary withdraws." { t.Errorf("a full escrow, its cap the smallest gift: %q", b) } escrowCap, minDonation, c.state.raised = saved, savedMin, 0 c.params.DormancyPeriod = 30 * secondsPerDay // the campaign's own, not the live setting's 90 c.createdAt = now() - c.params.DormancyPeriod if b := banner(); b != "> [!NOTE] Inactive\n> No gift and no update for 30 days: anyone can close it now." { t.Errorf("dormant: %q", b) } c.createdAt = now() s := c.state s.status, s.closedAt, s.closeReason, s.raised = statusClosed, now(), reasonOrganizer, 7_000_000 if b := banner(); !strings.HasPrefix(b, "> [!NOTE] Closed to new donations\n> Closed by its organizer on ") || !strings.Contains(b, "The beneficiary can withdraw the 7 GNOT held until ") || !strings.Contains(b, ", 60 days left; after that, donors can reclaim their share.") { t.Errorf("closed, before the exit window: %q", b) } s.closedAt, s.everWithdrawn = now()-c.params.ExitDelay, true if b := banner(); !strings.Contains(b, "The beneficiary didn't collect in time: a donor who hasn't reclaimed their share yet can, at any time.") { t.Errorf("closed, the exit window open: %q", b) } s.closeReason = reasonDormant if b := banner(); !strings.Contains(b, "after 30 days with no gift and no update.") { t.Errorf("closed as dormant: %q", b) } s.closeReason, s.raised = reasonDeadline, 0 if b := banner(); !strings.HasSuffix(b, "It ended at its deadline, on "+when(s.closedAt)+".") { t.Errorf("ended at its deadline, nothing held: %q", b) } s.status = statusSettled if b := banner(); b != "> [!SUCCESS] Completed\n> Closed to new donations on "+when(s.closedAt)+", then settled: the organizer's deposit went back to them." { t.Errorf("settled: %q", b) } } // TestRenderMode: the mode in words says what it means now, with the refund link // while a refund window is open to some donor (UI spec §4.3). func TestRenderMode(t *testing.T) { c := testCampaign(9974, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() mode := func() string { page := Render("c/" + id) i := strings.Index(page, "\n**Escrow**: ") if i < 0 { i = strings.Index(page, "\n**Direct**: ") } if i < 0 { t.Fatalf("no mode line:\n%s", page) } return page[i+1 : i+1+strings.Index(page[i+1:], "\n")] } refund := " [Refund](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=Refund&id=" + id + ")" if m := mode(); m != "**Escrow**: the gifts stay in escrow until the beneficiary withdraws them. Until then, a donor can take their gift back." { t.Errorf("escrow, nothing given: %q", m) } c.state.raised = 5_000_000 testing.IssueCoins(escrowAddr(c.id), chain.NewCoins(chain.NewCoin("ugnot", 5_000_000))) // the bank holds what the books say if m := mode(); !strings.HasSuffix(m, "a donor can take their gift back."+refund) { t.Errorf("escrow, gifts held, nothing withdrawn: %q", m) } c.state.status, c.state.closedAt = statusClosed, now() // closed, the beneficiary's window running if m := mode(); !strings.HasSuffix(m, "a donor can take their gift back."+refund) { t.Errorf("escrow, closed, nothing withdrawn, the window running: %q", m) } c.state.closedAt = now() - c.params.ExitDelay // never withdrawn, the window passed if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund { t.Errorf("escrow, the beneficiary's window passed with nothing withdrawn: %q", m) } c.state.status = statusSettled if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund { t.Errorf("escrow, settled with nothing withdrawn and coins held: %q", m) } c.state.refunded = 5_000_000 // every gift back: nothing held if m := mode(); m != "**Escrow**: no gift is held any more." { t.Errorf("escrow, settled, nothing held: %q", m) } c.state.status, c.state.closedAt = statusClosed, now() if m := mode(); m != "**Escrow**: no gift is held any more." { t.Errorf("escrow, closed, nothing held: %q", m) } c.state.status, c.state.closedAt, c.state.refunded = statusOpen, 0, 0 c.state.withdrawn, c.state.everWithdrawn = 2_000_000, true if m := mode(); m != "**Escrow**: the beneficiary has withdrawn funds, so gifts can't be refunded now." { t.Errorf("escrow, after a withdrawal: %q", m) } c.state.status, c.state.closedAt = statusClosed, now() if m := mode(); m != "**Escrow**: the beneficiary has withdrawn funds, so gifts can't be refunded now." { t.Errorf("escrow, closed after a withdrawal, the window running: %q", m) } c.state.closedAt = now() - c.params.ExitDelay if m := mode(); m != "**Escrow**: "+"the beneficiary didn't collect in time, so a donor who hasn't reclaimed their share yet can, at any time."+refund { t.Errorf("escrow, the exit window open: %q", m) } c.mode = modeDirect if m := mode(); m != "**Direct**: each gift goes straight to the beneficiary, in the same transaction. Gifts can't be refunded." { t.Errorf("direct: %q", m) } } // TestProgressBar: the bar fills min(net, goal) of its width, its text the uncapped // percentage; with no goal there is none; a share never overflows (UI spec §5). func TestProgressBar(t *testing.T) { fill := func(bar string) string { i := strings.LastIndex(bar, "%3Crect%20width='") rest := bar[i+len("%3Crect%20width='"):] return rest[:strings.Index(rest, "'")] } for _, tc := range []struct { net, goal int64 width int alt, filled string }{ {0, 400, barWide, "0%", "0"}, {90_000_000, 400_000_000, barWide, "22%", "225"}, {90_000_000, 400_000_000, barCard, "22%", "90"}, {496_000_000, 400_000_000, barWide, "124%", "1000"}, {1, 3, barWide, "33%", "333"}, } { bar := progressBar(tc.net, tc.goal, tc.width) if !strings.HasPrefix(bar, "!["+tc.alt+" of the goal](data:image/svg+xml;utf8,%3Csvg%20") || fill(bar) != tc.filled || !strings.Contains(bar, "width='"+strconv.Itoa(tc.width)+"'%20height='10'") { t.Errorf("progressBar(%d, %d, %d) = %s", tc.net, tc.goal, tc.width, bar) } } if bar := progressBar(5, 0, barWide); bar != "" { t.Errorf("a bar with no goal: %s", bar) } if got := share(math.MaxInt64, 1, 1000); got <= 0 { t.Errorf("share overflowed: %d", got) } if got := share(-5, 10, 100); got != 0 { t.Errorf("share of a negative part: %d", got) } } // TestRenderCards: campaigns are cards two to a row, a lone last one in a row of its // own with an empty column; a card says what a donor weighs (UI spec §4.13). func TestRenderCards(t *testing.T) { var b strings.Builder v := CampaignView{ID: "1", Title: "Well", Category: "water", Mode: modeEscrow, Status: statusOpen, NetRaised: 3_000_000, Goal: 10_000_000, DonorCount: 1, Bond: 5_000_000, Notice: "Under review"} writeCards(&b, []CampaignView{v, v, v}) out := b.String() if strings.Count(out, "\n") != 2 || strings.Count(out, "\n\n") != 2 || strings.Count(out, "\n\n") != 2 || strings.Contains(out, "") || !strings.HasSuffix(out, "\n\n\n") { t.Errorf("not two to a row:\n%s", out) } if !strings.Contains(out, "**3 GNOT** of 10 GNOT · 1 donor \nEscrow · water · no deadline · bond 5 GNOT · **moderator's notice**\n") { t.Errorf("a card's lines:\n%s", out) } b.Reset() v.Goal, v.Deadline, v.Bond, v.Notice, v.Status = 0, now()+3*secondsPerDay+60, 0, "", statusOpen writeCards(&b, []CampaignView{v, v}) if out = b.String(); strings.Contains(out, "data:image") || !strings.Contains(out, "**3 GNOT** raised · 1 donor \nEscrow · water · 3 days left\n") || strings.HasSuffix(out, "\n\n\n") { t.Errorf("two cards with no goal:\n%s", out) } for status, word := range map[string]string{statusClosed: "closed to new donations", statusSettled: "completed"} { v.Status = status if got := timeWord(v); got != word { t.Errorf("timeWord(%s) = %q", status, got) } } if got := left(now() + secondsPerDay - 1); got != "less than a day left" { t.Errorf("left, under a day: %q", got) } } // TestRenderLedgerDirect: a direct campaign's money out says nothing goes out, and // its page doesn't link there (API §5). func TestRenderLedgerDirect(t *testing.T) { c := testCampaign(9975, modeDirect, 0) id, unstore := storeCampaign(c) defer unstore() if page := Render("c/" + id + "/ledger"); strings.Contains(page, "## Withdrawals") || !strings.Contains(page, "**Direct**: each gift went straight to the beneficiary, `"+c.beneficiary.String()+"`") { t.Errorf("a direct campaign's money out:\n%s", page) } if page := Render("c/" + id); strings.Contains(page, "/ledger)") || strings.Contains(page, "| Held in escrow |") { t.Errorf("a direct campaign's page links to money out, or shows an escrow:\n%s", page) } } // TestRenderStart: the home page links to CreateCampaign's form, the deposit attached, // only while this version takes new campaigns; paused or sunset, it says why, and a // sunset version points to the registry, where new campaigns start (UI spec §4.13). func TestRenderStart(t *testing.T) { defer func() { paused, sunset = false, false }() start := "[Start a campaign](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=CreateCampaign&.send=" + strconv.FormatInt(params.CreationDeposit, 10) + "ugnot) — a " + gnot(params.CreationDeposit) + " deposit, returned when it completes · [Find an address](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0:u)\n" if home := Render(""); !strings.Contains(home, start) { t.Errorf("no link to start a campaign:\n%s", home) } paused = true if home := Render(""); strings.Contains(home, "func=CreateCampaign") || !strings.Contains(home, "Bolo is paused: new campaigns and gifts wait until it resumes · [Find an address]") { t.Errorf("paused:\n%s", home) } paused, sunset = false, true if home := Render(""); strings.Contains(home, "func=CreateCampaign") || !strings.Contains(home, "This version of Bolo takes no new campaigns: [see where they start](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo) · [Find an address]") { t.Errorf("sunset:\n%s", home) } paused = true // and sunset, which is for good if home, params := Render(""), Render("params"); !strings.Contains(home, "This version of Bolo takes no new campaigns") || !strings.Contains(params, "- New campaigns: not on this version\n") { t.Errorf("paused and sunset:\n%s\n%s", home, params) } } // TestParseGNOT: an amount in GNOT parses only in its one grammar (API §5). func TestParseGNOT(t *testing.T) { for s, want := range map[string]int64{"1": 1_000_000, "7.5": 7_500_000, "7%2C5": 7_500_000, "0.000001": 1, "0.5": 500_000, "999999999999": 999_999_999_999_000_000, "10.25": 10_250_000, "+7.5+": 7_500_000, "++7.5": 7_500_000, "1%2C00": 1_000_000, "1%2C0000": 1_000_000, "1.000": 1_000_000} { if got, ok := parseGNOT(s); !ok || got != want { t.Errorf("parseGNOT(%q) = %d, %t; want %d", s, got, ok, want) } } for _, s := range []string{"", ".5", "1.", "01", "00.5", "-1", "1e3", "1.1234567", "1,5", "7%2c5", "1 ", " 1", "1..5", "7.5.1", "1234567890123", "%2C5", "1%2C", "1%2C000", "12%2C500", "+", "+-1", "7+5"} { if got, ok := parseGNOT(s); ok { t.Errorf("parseGNOT(%q) accepted: %d", s, got) } } } // TestRenderGive: another amount's page asks the amount in gnoweb's form and answers // one Donate would take with its one-click link; any other gets the form again with // what the campaign can take, echoing nothing; a campaign that takes no gift says so // (API §5). func TestRenderGive(t *testing.T) { c := testCampaign(9976, modeEscrow, 0) id, unstore := storeCampaign(c) defer unstore() saved := escrowCap defer func() { escrowCap, paused = saved, false }() escrowCap = 30_000_000 c.state.raised = 12_000_000 // 18 GNOT of room page := func(q string) string { return Render("c/" + id + "/give" + q) } form := "\n\n \n\n" if p := page(""); !strings.Contains(p, form) || !strings.Contains(p, "Any amount from 1 GNOT to 18 GNOT, in GNOT.") || strings.Contains(p, "isn't") { t.Errorf("the page for another amount:\n%s", p) } link := "**[Give 7.5 GNOT](/r/g12yg9nh4ncma44emgm8msxe8aavzywt0p95tanv/bolo/v0$help&func=Donate&.send=7500000ugnot&anonymous=false&id=" + id + "&message=&tip=0)**" for _, q := range []string{"?amount=7.5", "?amount=7%2C5"} { if p := page(q); !strings.Contains(p, link) || strings.Contains(p, "