package web
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
// --- the rendering itself ------------------------------------------------------
//
// These render with no index at all — the nil receiver, which is what a page
// gets when the database listing failed. Everything asserted here is therefore
// about the markdown and about what is allowed to reach the browser, not about
// the links.
// renderMemory is one memory body, rendered by a memoryLinks that knows no
// database.
func renderMemory(src string) string {
var noLinks *memoryLinks
return string(noLinks.Body(src))
}
// The shapes the memories are actually written in: a bold leader, code spans, a
// bullet list, a fenced recipe, and hard-wrapped prose that reflows into one
// paragraph rather than keeping the width its author's terminal had.
func TestMemoryBodyRendersMarkdown(t *testing.T) {
got := renderMemory("**Why:** the networks are declared `external: true` everywhere,\n" +
"and compose cannot create one.\n" +
"\n" +
"- first, `./prepare.sh`\n" +
"- then `labng push backup`\n" +
"\n" +
"```\n" +
"dolt clone https://dolt.srht.bigb.es/~bigbes/x\n" +
"```\n")
assert.Contains(t, got, "<strong>Why:</strong>")
assert.Contains(t, got, "<code>external: true</code>")
assert.Contains(t, got, "<ul>")
assert.Contains(t, got, "<li>first, <code>./prepare.sh</code></li>")
assert.Contains(t, got, "<pre><code>dolt clone")
assert.Contains(t, got, "everywhere,\nand compose cannot create one.",
"a hard-wrapped paragraph reflows: the source's line breaks are not the document's")
assert.NotContains(t, got, "**", "the asterisks are markup and must not survive as text")
}
// A memory is stored text and is escaped on its way out, exactly as it was when
// the page printed paragraphs. Markup written in one renders as the characters
// somebody typed and never as an element.
func TestMemoryBodyEscapesStoredMarkup(t *testing.T) {
got := renderMemory("Supersedes the memory that asserted <b>otherwise</b>.\n" +
"\n" +
"<script>alert('x')</script>\n")
assert.NotContains(t, got, "<b>otherwise</b>")
assert.Contains(t, got, "<b>otherwise</b>")
assert.NotContains(t, got, "<script>")
assert.Contains(t, got, "<script>")
}
// Raw markup is shown rather than dropped, which is what makes a placeholder
// survive: goldmark's safe default omits `<NAME>` as a tag, and the memory then
// says "SRHT__VER" — a different fact, silently.
func TestMemoryBodyKeepsPlaceholdersThatLookLikeTags(t *testing.T) {
got := renderMemory("Bump SRHT_<NAME>_VER in phoebe-lab/srht/versions.env.\n")
assert.Contains(t, got, "SRHT_<NAME>_VER")
assert.NotContains(t, got, "raw HTML omitted")
assert.NotContains(t, got, "SRHT__VER", "dropping the tag would rewrite the sentence")
}
// A link whose scheme is a script is written with no destination at all, and an
// image is a link rather than a fetch: opening a memory may not make a request
// to a host the reader never chose to contact.
func TestMemoryBodyRefusesDangerousLinksAndRemoteImages(t *testing.T) {
got := renderMemory("[click](javascript:alert(1)) and \n")
assert.NotContains(t, got, "javascript:")
assert.NotContains(t, got, "<img", "an image reference is not fetched by opening the page")
assert.Contains(t, got, `<a class="mem-img" href="https://tracker.example/p.png">a pixel</a>`)
}
// A bare URL in the prose is a link the reader can follow.
func TestMemoryBodyLinkifiesBareURLs(t *testing.T) {
got := renderMemory("Clone from https://dolt.srht.bigb.es/~bigbes/x to check.\n")
assert.Contains(t, got, `<a href="https://dolt.srht.bigb.es/~bigbes/x">`)
}
// A [[slug]] no database holds is muted and inert — and so is one held by a
// database this reader may not browse, which is the point: the two must be one
// rendering.
func TestMemoryBodyLeavesAnUnresolvedWikilinkInert(t *testing.T) {
got := renderMemory("Related: [[go-vcs-stamp-dirty]] — the stamp.\n")
assert.Contains(t, got, `<span class="mem-link-out"`)
assert.Contains(t, got, ">go-vcs-stamp-dirty</span>")
assert.NotContains(t, got, "<a", "an unresolved reference is not a link")
assert.NotContains(t, got, "[[", "the brackets are notation and are consumed")
}
// Bracket notation that is not a reference is left as the text it is: a
// wikilink is a slug, and prose in brackets is prose.
func TestMemoryBodyIgnoresBracketsThatAreNotSlugs(t *testing.T) {
for _, src := range []string{
"An aside [[with words in it]] mid-sentence.\n",
"An unclosed [[reference that never ends.\n",
"An empty [[]] pair.\n",
} {
got := renderMemory(src)
assert.NotContains(t, got, "mem-link", "%q must not become a reference", src)
assert.Contains(t, got, "[[", "%q keeps its brackets as text", src)
}
}
// A reference inside a code span is being shown, not made — the notation is
// what the memory is talking about. Inline parsers do not run inside a code
// span, which is why this is a property of the design rather than a special
// case.
func TestMemoryBodyDoesNotResolveInsideCodeSpans(t *testing.T) {
got := renderMemory("Link memories with `[[their-name]]` in the body.\n")
assert.Contains(t, got, "<code>[[their-name]]</code>")
assert.NotContains(t, got, "mem-link")
}
// --- the links, across databases ----------------------------------------------
// memoryLinkTracker is a tracker as the index reads one: a config naming its
// issue prefix and holding memories. The memories' own bodies matter only for
// the database whose page is rendered; the rest are read for their slugs.
func memoryLinkTracker(head, prefix string, memories ...[2]string) *fakeSession {
rows := [][]string{{"compact_tier2_days", "30"}, {"issue_prefix", prefix}}
for _, m := range memories {
rows = append(rows, []string{"kv.memory." + m[0], m[1]})
}
return &fakeSession{
branches: []browse.Branch{{Name: "main", Head: head}},
tables: append(beadsTables(), memoryConfigTable()),
rowsByTable: map[string]*browse.RowPage{
"issues": {Columns: []string{"id", "title", "status"}, Total: 0},
"dependencies": {Columns: []string{"id", "issue_id", "depends_on_issue_id", "type"}, Total: 0},
"config": {Columns: []string{"key", "value"}, Rows: rows, Total: len(rows)},
},
}
}
// memoryLinkHarness is the instance these tests read: alice/alpha, whose memory
// page is rendered, a second public tracker (bob/beta) holding a memory alpha's
// prose references, and a PRIVATE one (dave/secrets) holding another. Both alice
// and bob carry a memory under the same slug, which is how the tie is checked.
func memoryLinkHarness(t *testing.T) *harness {
t.Helper()
h := newHarness(t)
body := "See [[gitignore-home]] for the ignore rules, and [[shared-note]] " +
"for the tie.\n" +
"\n" +
"Unknown: [[nowhere-at-all]]. Private: [[secret-note]].\n" +
"\n" +
"- a step\n" +
" - a nested one, see [[gitignore-home]]\n" +
"\n" +
"Filed as beta-46c.2, unlike `beta-nex` which is only quoted.\n" +
"Superseded by beta-46c.3\nand then reopened.\n"
addTracker(h, "alice", 1, "alpha", core.VisibilityPublic,
memoryLinkTracker("h-alpha", "alpha",
[2]string{"the-page", body},
[2]string{"shared-note", "alice's copy"}))
addTracker(h, "bob", 2, "beta", core.VisibilityPublic,
memoryLinkTracker("h-beta", "beta",
[2]string{"gitignore-home", "bob's memory"},
[2]string{"shared-note", "bob's copy"}))
addTracker(h, "dave", 9, "secrets", core.VisibilityPrivate,
memoryLinkTracker("h-secret", "secret",
[2]string{"secret-note", "not for you"}))
setViews(t, h, &memoryView{})
return h
}
// The whole point of the notation: a [[slug]] resolves to whichever database
// holds that memory, and a memory in another tracker is as ordinary a target as
// one in this tracker.
func TestMemoryWikilinksResolveAcrossDatabases(t *testing.T) {
pinClock(t, testNow)
h := memoryLinkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/memory?key=the-page", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
body := rec.Body.String()
// The sibling database's memory.
assert.Contains(t, body,
`<a class="mem-link" href="/~bob/beta/view/memory?key=gitignore-home">gitignore-home</a>`)
// A slug both trackers hold resolves here: the page's own database is asked
// first, and a memory mirrored into two trackers is one memory.
assert.Contains(t, body,
`<a class="mem-link" href="/~alice/alpha/view/memory?key=shared-note">shared-note</a>`)
assert.NotContains(t, body, "/~bob/beta/view/memory?key=shared-note")
// A slug nobody holds, and a slug held by a database this caller may not
// browse, are the same rendering. The second is the load-bearing one: a link,
// a distinct class or a different tooltip would each publish the existence of
// a private database.
assert.Contains(t, body, `<span class="mem-link-out" title="No memory with this slug in a tracker you can browse.">nowhere-at-all</span>`)
assert.Contains(t, body, `<span class="mem-link-out" title="No memory with this slug in a tracker you can browse.">secret-note</span>`)
assert.NotContains(t, body, "/~dave/secrets/")
// And inside a nested list item, whose lines the parser hands back with a
// synthesised indent: an offset into such a line is not an offset into the
// source, and a reference built from one would carry the wrong slug.
assert.Contains(t, body,
`a nested one, see <a class="mem-link" href="/~bob/beta/view/memory?key=gitignore-home">gitignore-home</a>`)
}
// The prose in a memory names issues too, and it reaches the same index the
// detail pane's ids do — one read of one table per database answers both.
func TestMemoryProseLinksIssueIDs(t *testing.T) {
pinClock(t, testNow)
h := memoryLinkHarness(t)
rec := h.do("GET", "/~alice/alpha/view/memory?key=the-page", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
body := rec.Body.String()
assert.Contains(t, body, `<a href="/~bob/beta/view/beads?issue=beta-46c.2">beta-46c.2</a>`)
// An id inside a code span is being quoted, not cited.
assert.Contains(t, body, "<code>beta-nex</code>")
assert.NotContains(t, body, `issue=beta-nex`)
// An id that ends a line keeps the line break that followed it. Without it
// the next line's first word is glued to the id — the link would read
// "beta-46c.3and then reopened".
assert.Contains(t, body,
`<a href="/~bob/beta/view/beads?issue=beta-46c.3">beta-46c.3</a>`+"\nand then reopened.")
}
// The index is built once per request and cached per database on the view, the
// same bound the board's link index has. A second request with unmoved heads
// reads no config row for the databases it only consults.
func TestMemoryLinkIndexIsCachedPerDatabase(t *testing.T) {
pinClock(t, testNow)
h := memoryLinkHarness(t)
first := h.do("GET", "/~alice/alpha/view/memory", nil, nil)
require.Equal(t, http.StatusOK, first.Code)
beta := h.browse.byPath["/var/lib/dolt/~bob/beta"]
require.NotNil(t, beta)
require.Greater(t, beta.rowReads, 0, "the first render must read the sibling's config")
reads := beta.rowReads
second := h.do("GET", "/~alice/alpha/view/memory", nil, nil)
require.Equal(t, http.StatusOK, second.Code)
assert.Equal(t, reads, beta.rowReads, "an unmoved head reads no row the second time")
assert.Contains(t, second.Body.String(), "gitignore-home",
"and the cached index still resolves the reference")
}
// A memory page renders whether or not the index could be built: the links are
// decoration on top of an answer.
func TestMemoryRendersWithoutAnIndex(t *testing.T) {
pinClock(t, testNow)
h := memoryLinkHarness(t)
h.store.listErr = errors.New("db: list repositories: connection refused")
rec := h.do("GET", "/~alice/alpha/view/memory?key=the-page", nil, nil)
require.Equal(t, http.StatusOK, rec.Code, "memory view: %s", rec.Body.String())
body := rec.Body.String()
assert.Contains(t, body, "mem-link-out", "every reference is left unresolved")
assert.NotContains(t, body, `class="mem-link"`)
assert.True(t, strings.Contains(body, "the-page"), "and the memory itself is still shown")
}