Skip to content
quarry Artifact [93f84e79f4]
Login

Artifact [93f84e79f4]

Artifact 93f84e79f409b7f0f108ada0cc7bb7ae6ad2c4b464dd65cc4650fc68472867ff:


package web

import (
	"net/http/httptest"
	"regexp"
	"slices"
	"strings"
	"testing"
	"time"

	"craftdesign.group/quarry/internal/index"
)

// The layouts are what real corpora actually contain — fossil writes
// millisecond UTC with no zone, and an imported store carries the
// second-precision form too. A layout this misses renders the raw stamp,
// which is ugly but honest; a layout it MISPARSES would silently date
// every page wrong, so the parse is worth pinning.
func TestParseArtifactTimeCoversRealCorpusShapes(t *testing.T) {
	want := time.Date(2025, 8, 25, 21, 25, 59, 0, time.UTC)
	for _, s := range []string{
		"2025-08-25T21:25:59.000", // fossil forum post
		"2025-08-25T21:25:59",     // second precision
		"2025-08-25T21:25:59Z",    // RFC3339, quarry's own stamps
		"2025-08-25 21:25:59",     // space-separated
	} {
		got, ok := parseArtifactTime(s)
		if !ok {
			t.Errorf("%q did not parse", s)
			continue
		}
		if !got.Equal(want) {
			t.Errorf("%q parsed to %v, want %v", s, got, want)
		}
	}
	if _, ok := parseArtifactTime("last tuesday"); ok {
		t.Error("nonsense parsed as a time")
	}
}

// An unparseable stamp must be shown, not swallowed. A blank cell where
// a date belongs reads as "no date recorded", which is a different and
// wrong claim about the artifact.
func TestAgoShowsWhatItCannotParse(t *testing.T) {
	if got := string(ago("not a date")); got != "not a date" {
		t.Errorf("ago(unparseable) = %q, want it passed through", got)
	}
	if got := string(ago("")); got != "" {
		t.Errorf("ago(empty) = %q, want empty", got)
	}
	// A parseable one keeps the exact value reachable, because a
	// relative time is not something you can quote in a bug report.
	got := string(ago("2025-08-25T21:25:59.000"))
	if !strings.Contains(got, `datetime="2025-08-25T21:25:59Z"`) {
		t.Errorf("ago dropped the machine-readable value: %s", got)
	}
	if !strings.Contains(got, `title="2025-08-25 21:25:59 UTC"`) {
		t.Errorf("ago dropped the absolute value: %s", got)
	}
}

func TestRelativeWording(t *testing.T) {
	now := time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC)
	for _, tc := range []struct {
		at   time.Time
		want string
	}{
		{now.Add(-30 * time.Second), "just now"},
		{now.Add(-time.Minute), "1 minute ago"},
		{now.Add(-5 * time.Minute), "5 minutes ago"},
		{now.Add(-time.Hour), "1 hour ago"},
		{now.Add(-3 * time.Hour), "3 hours ago"},
		{now.Add(-24 * time.Hour), "1 day ago"},
		{now.Add(-13 * 24 * time.Hour), "13 days ago"},
		// Past a month a date beats a day count; past a year the year is
		// the part that carries the information.
		{now.Add(-60 * 24 * time.Hour), "18 Jun"},
		{now.Add(-400 * 24 * time.Hour), "13 Jul 2025"},
		// Clock skew: a future stamp must not read as "in 3 minutes".
		{now.Add(3 * time.Minute), "2026-08-17 12:03"},
	} {
		if got := relative(tc.at, now); got != tc.want {
			t.Errorf("relative(%v) = %q, want %q", tc.at, got, tc.want)
		}
	}
}

// The nav's job is orientation, so the two states that carry it — where
// you are, and where there is nothing to find — must both survive into
// the markup, and must not collide into one attribute.
func TestNavlinkStates(t *testing.T) {
	current := string(navlink("alpha", "forum", "forum", "forum", true, 12))
	if !strings.Contains(current, `class="here"`) || !strings.Contains(current, `aria-current="page"`) {
		t.Errorf("current section unmarked: %s", current)
	}
	if !strings.Contains(current, `>12</span>`) {
		t.Errorf("count missing: %s", current)
	}

	empty := string(navlink("alpha", "wiki", "wiki", "forum", true, 0))
	if !strings.Contains(empty, `class="empty"`) {
		t.Errorf("empty section unmarked: %s", empty)
	}
	if !strings.Contains(empty, `href="/r/alpha/wiki"`) {
		t.Errorf("empty section stopped being a link: %s", empty)
	}

	// Current AND empty is one class attribute, not two — a browser
	// honours only the first, so the second state would vanish.
	both := string(navlink("alpha", "wiki", "wiki", "wiki", true, 0))
	// Count within the ANCHOR tag only; the count badge carries its own
	// class and would otherwise be mistaken for a duplicate.
	anchor, _, _ := strings.Cut(both, ">")
	if strings.Count(anchor, "class=") != 1 {
		t.Errorf("expected exactly one class attribute on the anchor: %s", both)
	}
	if !strings.Contains(anchor, "here") || !strings.Contains(anchor, "empty") {
		t.Errorf("both states not preserved: %s", both)
	}

	// A section with nothing countable shows no badge rather than "0".
	files := string(navlink("alpha", "tree", "files", "tree", false, false))
	if strings.Contains(files, "<span") {
		t.Errorf("uncountable section rendered a badge: %s", files)
	}
}

// The marker has to follow a reader INTO a section, not just light up on
// its index page — otherwise it goes dark exactly when orientation
// matters most.
func TestSectionOfFollowsDeepPages(t *testing.T) {
	for path, want := range map[string]string{
		"/r/alpha":                         "tree",
		"/r/alpha/tree":                    "tree",
		"/r/alpha/file/trunk/README.md":    "tree",
		"/r/alpha/history/trunk/README.md": "tree",
		"/r/alpha/timeline":                "timeline",
		"/r/alpha/checkin/abc123":          "timeline",
		"/r/alpha/diff":                    "timeline",
		"/r/alpha/wiki":                    "wiki",
		"/r/alpha/wiki/Home/history":       "wiki",
		"/r/alpha/ticket/abc/edit":         "ticket",
		"/r/alpha/forum/abc":               "forum",
		"/fleet":                           "",
	} {
		if got := sectionOf(path); got != want {
			t.Errorf("sectionOf(%q) = %q, want %q", path, got, want)
		}
	}
}

// Badges exist so the numbers line up down the column, and a compact
// duration is what makes that possible — "34 minutes ago" is a different
// width in every row.
func TestAgoShortIsCompact(t *testing.T) {
	for _, tc := range []struct{ d, want string }{
		{"30s", "now"}, {"34m", "34m"}, {"12h", "12h"},
		{"72h", "3d"}, {"9000h", "1y"},
	} {
		dur, err := time.ParseDuration(tc.d)
		if err != nil {
			t.Fatal(err)
		}
		stamp := time.Now().UTC().Add(-dur).Format("2006-01-02T15:04:05")
		got := string(agoShort(stamp))
		if !strings.Contains(got, ">"+tc.want+"<") {
			t.Errorf("agoShort(-%s) = %q, want %q", tc.d, got, tc.want)
		}
		// The exact instant stays reachable; a badge is a summary, not a
		// replacement for the value.
		if !strings.Contains(got, "datetime=") || !strings.Contains(got, "title=") {
			t.Errorf("agoShort dropped the precise value: %q", got)
		}
	}
	if agoShort("not a date") != "" {
		t.Error("agoShort invented a duration for an unparseable stamp")
	}
}

// The nav's width was a function of its numbers: a repository with
// 25,557 check-ins rendered a visibly different nav from one with none,
// and on a phone it wrapped to three lines. Four characters is the
// budget; the exact figure lives in the badge's title.
func TestCompactCountIsBounded(t *testing.T) {
	for _, tc := range []struct {
		n    int
		want string
	}{
		{0, "0"}, {19, "19"}, {503, "503"}, {999, "999"},
		{1000, "1K"}, {1100, "1.1K"}, {5809, "5.8K"}, {9999, "10K"},
		{25557, "25K"}, {42118, "42K"}, {999999, "999K"},
		{1000000, "1M"}, {1234567, "1.2M"}, {25000000, "25M"},
		{-5, "0"},
	} {
		if got := compactCount(tc.n); got != tc.want {
			t.Errorf("compactCount(%d) = %q, want %q", tc.n, got, tc.want)
		}
		if len(compactCount(tc.n)) > 4 {
			t.Errorf("compactCount(%d) = %q, over the four-character budget", tc.n, compactCount(tc.n))
		}
	}
}

// Fossil technote titles carry HTML — "<b>Design Note:</b> About the
// Irreversible Schema Change" is a real one from fossil's own repo.
// Escaping it is safe but reads as literal markup in a list.
func TestPlainTitleStripsMarkup(t *testing.T) {
	got := plainTitle(`<b>Design Note:</b> About the "Irreversible Schema Change"`)
	want := `Design Note: About the "Irreversible Schema Change"`
	if got != want {
		t.Errorf("plainTitle = %q, want %q", got, want)
	}
	// The caller still escapes, so this is a readability pass and not a
	// security boundary — but it must not leave a usable tag behind.
	if strings.Contains(plainTitle(`<script>alert(1)</script>hi`), "<") {
		t.Error("a tag survived stripping")
	}
	if plainTitle("no markup here") != "no markup here" {
		t.Error("plain text was altered")
	}
}

// Fossil's ticket status vocabulary is per-repository, so this cannot be
// exhaustive — it must degrade to "open" for words it doesn't know
// rather than mislabel them, and it must not care about case or spacing.
func TestStatusClass(t *testing.T) {
	for status, want := range map[string]string{
		"Open": "st-open", "Review": "st-open", "Tested": "st-open",
		"Closed": "st-done", "Fixed": "st-done", "Verified": "st-done",
		" DEFERRED ": "st-done", "duplicate": "st-done",
		"Needs Triage": "st-open", "": "st-open",
	} {
		if got := statusClass(status); got != want {
			t.Errorf("statusClass(%q) = %q, want %q", status, got, want)
		}
	}
}

// A Referer is attacker-controllable, so the theme switcher must never
// bounce a reader off this origin. Only a same-site path survives.
func TestLocalRefRefusesOffsiteReferers(t *testing.T) {
	// Only the PATH survives, so an off-origin Referer is reduced to a
	// local path rather than refused — what must never happen is a
	// return value a browser would read as another origin.
	for ref, want := range map[string]string{
		"":                                  "/",
		"http://evil.example/steal":         "/steal",
		"//evil.example/steal":              "/steal",
		"https://quarry.test/r/x/tree?v=ab": "/r/x/tree?v=ab",
		"/r/x/wiki":                         "/r/x/wiki",
		"javascript:alert(1)":               "/",
		"https://evil.example":              "/",
	} {
		req := httptest.NewRequest("POST", "/theme", nil)
		if ref != "" {
			req.Header.Set("Referer", ref)
		}
		got := localRef(req)
		if got != want {
			t.Errorf("localRef(%q) = %q, want %q", ref, got, want)
		}
		if !strings.HasPrefix(got, "/") || strings.HasPrefix(got, "//") {
			t.Errorf("localRef(%q) = %q, which a browser may read as another origin", ref, got)
		}
	}
}

// Three states in a fixed cycle: a reader who picks light must be able
// to get back to "follow my phone", which a two-way toggle cannot offer.
func TestThemeCycleReturnsToSystem(t *testing.T) {
	theme := ""
	var seen []string
	for range 3 {
		next, _, name := themeState(theme)
		seen = append(seen, name)
		theme = next
	}
	if want := []string{"system", "light", "dark"}; !slices.Equal(seen, want) {
		t.Errorf("cycle = %v, want %v", seen, want)
	}
	if theme != "" {
		t.Errorf("cycle ended at %q, want back at system", theme)
	}
	// An unrecognized stored value must not strand the control.
	if next, _, _ := themeState("chartreuse"); next != "light" {
		t.Errorf("unknown theme advanced to %q, want light", next)
	}
}

// The search box means "in this section". A section with nothing indexed
// must fall through to everything rather than to an empty scope, which
// would silently find nothing and look like a broken search.
func TestSearchContextPerSection(t *testing.T) {
	for section, wantKind := range map[string]string{
		"timeline": "checkin", "wiki": "wiki", "ticket": "ticket",
		"forum": "forum", "technote": "technote",
		"tree": "", "ci": "", "chat": "", "": "",
	} {
		kind, scope := searchContext(section)
		if kind != wantKind {
			t.Errorf("searchContext(%q) kind = %q, want %q", section, kind, wantKind)
		}
		if scope == "" {
			t.Errorf("searchContext(%q) has no scope word", section)
		}
		if kind == "" && scope != "everything" {
			t.Errorf("searchContext(%q) unindexed section scoped to %q", section, scope)
		}
	}
	// Every scope the results page offers must be a kind the index
	// actually knows, or the pill leads to a 404.
	for _, sc := range searchScopes {
		if sc.Kind != "" && !slices.Contains(index.SearchKinds, sc.Kind) {
			t.Errorf("scope %q is not an index kind", sc.Kind)
		}
	}
}

// The avatar is initials because quarry keeps no avatar images and
// fetching a gravatar would send every reader's identity to a third
// party on every page. It must never render empty or panic on the
// shapes a login name actually takes.
func TestInitials(t *testing.T) {
	for name, want := range map[string]string{
		"dmestas":         "DM",
		"dan":             "DA",
		"d":               "D",
		"Dan Mestas":      "DM",
		"dan.mestas":      "DM",
		"dan-mestas":      "DM",
		"dan_mestas":      "DM",
		"[email protected]": "DE",
		"":                "?",
		"   ":             "?",
		"drh":             "DR",
		"Ægir":            "ÆG",
	} {
		got := initials(name)
		if got != want {
			t.Errorf("initials(%q) = %q, want %q", name, got, want)
		}
		if got == "" {
			t.Errorf("initials(%q) is empty; the button would render blank", name)
		}
	}
}

// Templates wrap their tables in .tw, which scrolls. Tables that arrive
// from RENDERED CONTENT — the API doc, a wiki page, a ticket body, a
// forum post — have no wrapper to be put in, and a wide one pushed the
// whole page sideways: /docs overflowed a 320px phone by 208px. Such a
// table has to be its own scroll container, and the ones already in a
// wrapper must stay real tables or their columns stop lining up.
func TestContentTablesScrollThemselves(t *testing.T) {
	if !strings.Contains(baseHTML, "main :not(.tw) > table { display: block; overflow-x: auto;") {
		t.Error("an unwrapped table would push the page sideways")
	}
	if !strings.Contains(baseHTML, ".tw > table { display: table; }") {
		t.Error("wrapped tables lost table layout, so their columns will not align")
	}
}

// Fossil's own new-ticket template labels private_contact "Not publicly
// visible" and gates it behind capability "e" — view private e-mail
// addresses. quarry rendered it to everyone. Faithfully reproducing a
// tracker's content must not mean publishing what that tracker
// deliberately withheld.
func TestPrivateTicketFieldsAreNotRendered(t *testing.T) {
	td := ticketDetail{Fields: []ticketField{
		{Name: "title", Value: "a bug"},
		{Name: "status", Value: "Open"},
		{Name: "private_contact", Value: "[email protected]"},
		{Name: "contact", Value: "[email protected]"},
		{Name: "foundin", Value: "2.7"},
		{Name: "subsystem", Value: "build"},
	}}
	for _, f := range append(td.Extra(), td.Header()...) {
		if privateFields[f.Name] {
			t.Errorf("%s is rendered; fossil hides it behind capability e", f.Name)
		}
		if strings.Contains(f.Value, "@example.com") {
			t.Errorf("an e-mail address reached the page through %s", f.Name)
		}
	}
	// The public fields must still be there — the fix must not be "hide
	// everything unknown".
	var seen []string
	for _, f := range append(td.Extra(), td.Header()...) {
		seen = append(seen, f.Name)
	}
	for _, want := range []string{"status", "subsystem", "foundin"} {
		if !slices.Contains(seen, want) {
			t.Errorf("%s went missing; only the private fields should drop", want)
		}
	}
}

// The mark is embedded, not fetched. The privacy page states that these
// pages load nothing from another host; an icon served from a CDN would
// make that statement false, and a favicon is the asset most likely to
// be added that way without thinking.
func TestFaviconIsEmbeddedAndLinked(t *testing.T) {
	if len(faviconICO) == 0 || len(appleTouchIcon) == 0 {
		t.Fatal("the icons are not embedded in the binary")
	}
	// An ICO begins with a 6-byte header: reserved 0, type 1, then the
	// image count. Asserting the count catches a single-size file,
	// which is what a browser asking for 16px gets served badly.
	if len(faviconICO) < 6 || faviconICO[0] != 0 || faviconICO[1] != 0 || faviconICO[2] != 1 {
		t.Error("favicon.ico is not an ICO")
	}
	if n := int(faviconICO[4]) | int(faviconICO[5])<<8; n < 3 {
		t.Errorf("favicon.ico holds %d sizes, want at least 16/32/48", n)
	}
	for _, want := range []string{`href="/favicon.ico"`, `href="/apple-touch-icon.png"`} {
		if !strings.Contains(baseHTML, want) {
			t.Errorf("the layout does not link %s", want)
		}
	}
	// No SUBRESOURCE may come from another host. A plain <a href> is
	// navigation and a placeholder is text; what matters is anything
	// the page FETCHES — src=, <link href=, and url() in CSS. That is
	// the claim the privacy page makes, and it is the one a stray CDN
	// icon would break.
	sub := regexp.MustCompile(`(?:src|<link[^>]*href)\s*=\s*"([^"]+)"|url\(\s*['"]?([^)'"]+)`)
	for _, m := range sub.FindAllStringSubmatch(baseHTML, -1) {
		ref := m[1] + m[2]
		if strings.HasPrefix(ref, "//") || strings.Contains(ref, "://") {
			t.Errorf("the layout fetches %q from another host", ref)
		}
	}
}

// A popover the UA has closed must not still be laid out. The UA
// stylesheet closes one with [popover]:not(:popover-open){display:none},
// and ANY author display declaration on the base beats it — which left
// every menu on every page as an invisible, hit-testable rectangle at
// opacity 0, swallowing clicks over the content beneath it. The rule
// is therefore: display belongs only in the open state.
func TestPopoverDisplayOnlyInTheOpenState(t *testing.T) {
	// Comments are stripped first: the explanation next to this rule
	// quotes the UA declaration verbatim, so a naive substring search
	// finds "display:" in prose and fails on the fix rather than the bug.
	css := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(baseHTML, "")
	for _, base := range []string{".menu {", ".tip {"} {
		i := strings.Index(css, base)
		if i < 0 {
			continue
		}
		body := css[i : i+strings.Index(css[i:], "}")]
		if strings.Contains(body, "display:") {
			t.Errorf("%s declares display on the closed state; it will override the UA's display:none and stay clickable:\n%s",
				base, body)
		}
	}
	if !strings.Contains(css, ".menu:popover-open { display: flex;") {
		t.Error("the open state does not restore display, so the menu would never show")
	}
}

// The tooltip follows Basecoat's shape — a ::after carrying
// content: attr(data-tip), no script — with one deliberate departure:
// Basecoat hides its tooltip for keyboard users
// (:focus-visible:not(:hover) { display: none }), which makes an
// icon-only control unnameable without a mouse. Ours shows on focus.
func TestTooltipIsReachableByKeyboard(t *testing.T) {
	if !strings.Contains(baseHTML, "[data-tip]:hover::after, [data-tip]:focus-visible::after") {
		t.Error("the tooltip does not appear on keyboard focus")
	}
	if !strings.Contains(baseHTML, "content: attr(data-tip)") {
		t.Error("the tooltip text does not come from the attribute")
	}
	// It must never eat clicks — the failure the popover menus already had.
	if !strings.Contains(baseHTML, "pointer-events: none") {
		t.Error("the tooltip is hit-testable and will swallow clicks over the trigger")
	}
	// Every trigger must ALSO carry a real accessible name: hover is
	// unavailable on touch and to a screen reader, so data-tip alone
	// would leave the control unnamed for both.
	for _, frag := range []string{`data-tip="select the clone command"`, `data-tip="account"`} {
		i := strings.Index(baseHTML, frag)
		if i < 0 {
			continue
		}
		start := strings.LastIndex(baseHTML[:i], "<button")
		end := i + strings.Index(baseHTML[i:], ">")
		if !strings.Contains(baseHTML[start:end], "aria-label=") {
			t.Errorf("a data-tip trigger has no aria-label: %s", baseHTML[start:end])
		}
	}
}

// Status vocabularies are per repository and drift within one: quarry's
// own store holds "Open" and "open" both. Ungrouped that draws four
// columns where there are two, and the label shown must be the tracker's
// own spelling rather than one this code picked.
func TestBoardGroupsStatusesCaseInsensitively(t *testing.T) {
	cols := groupByStatus([]ticketSummary{
		{ID: "a", Status: "Open"}, {ID: "b", Status: "open"}, {ID: "c", Status: "Open"},
		{ID: "d", Status: "Closed"}, {ID: "e", Status: "Fixed"}, {ID: "f", Status: "Fixed"},
	})
	if len(cols) != 3 {
		t.Fatalf("got %d columns, want 3 (Open/Closed/Fixed): %+v", len(cols), cols)
	}
	if cols[0].Status != "Open" || len(cols[0].Tickets) != 3 {
		t.Errorf("first column = %q with %d tickets, want Open with 3 (the majority spelling)",
			cols[0].Status, len(cols[0].Tickets))
	}
	// Unresolved leads, whatever the sizes: a board is read left to
	// right and the live work belongs at the left.
	if statusClass(cols[0].Status) != "st-open" {
		t.Errorf("board leads with %q, a resolved status", cols[0].Status)
	}
	for _, c := range cols[1:] {
		if statusClass(c.Status) != "st-done" {
			t.Errorf("unresolved column %q sorted after a resolved one", c.Status)
		}
	}
}

// The board's move control is the mechanism, not a fallback: it must be
// real submitting markup, and it must be gated on write CAPABILITY. A
// reader with Basic auth can write and is not "signed in", and gating
// on session state hid the only way such a reader could move a card.
func TestBoardMoveControlIsPlainMarkup(t *testing.T) {
	src := baseHTML
	if strings.Contains(src, "ticketBoardPage") {
		t.Skip("board template moved")
	}
	for _, want := range []string{`{{if $.CanWrite}}<form class="move" method="post"`} {
		if !strings.Contains(boardSource, want) {
			t.Errorf("board move control is not %q", want)
		}
	}
	if strings.Contains(boardSource, "data-on:") {
		t.Error("the board's only control depends on script bindings")
	}
}

// The mark replaced the "quarry" wordmark, so the link must keep the
// name the word carried — otherwise the forge's home link is unnamed
// for a screen reader and for anyone with images off.
//
// It is inline SVG in currentColor rather than an image: one shape for
// both themes and every size, which retired two themed PNGs and a
// palette entry that had to be declared three times to stay honest.
func TestHeaderMarkKeepsItsAccessibleName(t *testing.T) {
	if !strings.Contains(baseHTML, `<a class="site" href="/" aria-label="quarry">`) {
		t.Error("the home link has no accessible name")
	}
	if !strings.Contains(baseHTML, `role="img" aria-label="quarry"`) {
		t.Error("the mark is not named for assistive technology")
	}
	// currentColor, or it stops following the theme and needs assets back.
	i := strings.Index(baseHTML, `<svg class="mark"`)
	if i < 0 {
		t.Fatal("the mark is not inline SVG")
	}
	mark := baseHTML[i : i+strings.Index(baseHTML[i:], "</svg>")]
	if !strings.Contains(mark, "currentColor") {
		t.Error("the mark does not use currentColor, so it cannot follow the theme")
	}
	if strings.Contains(mark, "#") || strings.Contains(mark, "var(--") {
		t.Error("the mark hard-codes a colour; it must inherit")
	}
	// The tail must START INSIDE the bowl or the mark reads as a
	// magnifying glass — in a header that has a search box beside it.
	if !strings.Contains(mark, `d="M15.9 15.9 L18.1 18.1"`) {
		t.Error("the tail geometry changed; check it still reads as a Q and not a magnifier")
	}
}

// og:image is fetched by a scraper on its own machine, so it must be
// absolute. A site-relative path here is the classic failure: it renders
// fine, validates fine by eye, and yields a blank card everywhere.
//
// The origin has to come from the request rather than a configured base,
// because quarry is reached by tailnet IP, by localhost and by a public
// hostname in the same deployment, and a card should point back at the
// address the reader actually followed.
func TestSocialCardImageIsAbsolute(t *testing.T) {
	for _, tag := range []string{
		`<meta property="og:image" content="{{.Origin}}/assets/quarry.png"`,
		`<meta property="og:url" content="{{.Origin}}{{.Path}}"`,
		`<meta name="twitter:card" content="summary">`,
	} {
		if !strings.Contains(baseHTML, tag) {
			t.Errorf("missing social tag: %s", tag)
		}
	}
	// "timeline" alone names no repository, and a link to a repo page is
	// the link people actually share.
	if !strings.Contains(baseHTML, `content="{{if and .Repo (ne .Repo .Title)}}{{.Repo}} — {{end}}{{.Title}}"`) {
		t.Error("og:title does not carry the repository")
	}
	// A wide card letterboxes a square logo into a thin band.
	if strings.Contains(baseHTML, `content="summary_large_image"`) {
		t.Error("the mark is square; the wide card crops it to a band")
	}
}

// A proxy terminates TLS, so the scheme quarry sees on the wire is not
// the scheme the reader used. Getting this wrong emits http:// image
// URLs on an https:// page, which scrapers and browsers both drop.
func TestOriginFollowsTheProxy(t *testing.T) {
	for _, tc := range []struct {
		name string
		fwd  string
		want string
	}{
		{"plain", "", "http://example.test"},
		{"behind a TLS proxy", "https", "https://example.test"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest("GET", "http://example.test/r/quarry/timeline", nil)
			if tc.fwd != "" {
				req.Header.Set("X-Forwarded-Proto", tc.fwd)
			}
			if got := origin(req); got != tc.want {
				t.Errorf("origin = %q, want %q", got, tc.want)
			}
			// The clone address must not have drifted from the origin
			// it is now built out of.
			if got, want := cloneURL(req, "quarry"), tc.want+"/r/quarry"; got != want {
				t.Errorf("cloneURL = %q, want %q", got, want)
			}
		})
	}
}