package web
import (
"bytes"
"context"
"net/http"
"strings"
"testing"
"time"
)
// The tier leads the row: a local ticket's first chip says so, because
// "not yet in the permanent history" changes what every other chip on
// the row means.
func TestLocalChipLeadsTheRow(t *testing.T) {
rows := ticketRows("tk", []ticketSummary{
{ID: "aaaa11112222", Title: "from a visitor", Status: "Open", Local: true},
}, nil)
if len(rows) != 1 || len(rows[0].Chips) == 0 || rows[0].Chips[0].Text != "local" {
t.Fatalf("rows = %+v", rows)
}
}
// is:local is where a reader will try to spell the tier, so the query
// box honours it — both directions.
func TestIsLocalToken(t *testing.T) {
v := ticketParseQuery("is:local crash")
if v.Get("origin") != "local" || v.Get("title") != "crash" || v.Get("state") != "" {
t.Fatalf("parse = %v", v)
}
rep := ticketReportOf(v, append([]string{"origin"}, ticketAxes...))
if got := ticketQueryString("", rep, "crash"); got != "is:local crash" {
t.Fatalf("round trip = %q", got)
}
}
// A LOCAL TICKET IS MARKED ON ITS TITLE, AND PROMOTED ONLY ON CONFIRMATION.
//
// The banner that led a local ticket's page pushed the ticket itself
// below the fold on a phone (dmestas, 2026-09-23). The title carries a
// small local icon instead; Promote and Discard sit in the side panel,
// each behind a disclosure that says what it will do, and only for an
// admin.
func TestALocalTicketIsMarkedAndPromotedOnConfirm(t *testing.T) {
base := pageData{Repo: "tk", Ticket: ticketDetail{ID: strings.Repeat("ab", 20)}, TicketLocal: true}
var buf bytes.Buffer
admin := base
admin.CanAdmin = true
if err := ticketPage.ExecuteTemplate(&buf, "content", admin); err != nil {
t.Fatal(err)
}
html := buf.String()
if strings.Contains(html, `class="tkt-local"`) {
t.Error("FAIL: the banner is back")
}
for _, want := range []string{`data-hook="ticket-local"`, `data-hook="ticket-kept"`} {
if !strings.Contains(html, want) {
t.Errorf("FAIL: admin view missing %s", want)
}
}
for _, act := range []string{"promote", "discard"} {
i := strings.Index(html, `data-hook="ticket-`+act+`"`)
if i < 0 {
t.Errorf("FAIL: no %s disclosure", act)
continue
}
box := html[i:]
box = box[:strings.Index(box, "</details>")]
if !strings.Contains(box, "<summary>") || !strings.Contains(box, "/"+act+`"`) {
t.Errorf("FAIL: the %s form is not behind its confirming disclosure", act)
}
}
buf.Reset()
if err := ticketPage.ExecuteTemplate(&buf, "content", base); err != nil {
t.Fatal(err)
}
html = buf.String()
if !strings.Contains(html, `data-hook="ticket-local"`) || strings.Contains(html, "/promote") || strings.Contains(html, "/discard") {
t.Error("FAIL: a non-admin saw promote or discard, or no local mark")
}
}
// The new-ticket form carries the honeypot and defaults to the local
// door; only a repository ADMIN is offered the fossil checkbox — a
// writer files locally and an admin promotes what belongs.
func TestNewTicketFormDoors(t *testing.T) {
var buf bytes.Buffer
if err := ticketNewPage.ExecuteTemplate(&buf, "content", pageData{Repo: "tk", CanWrite: true, CanAdmin: true}); err != nil {
t.Fatal(err)
}
html := buf.String()
for _, want := range []string{`name="website"`, `name="fossil"`, "start local"} {
if !strings.Contains(html, want) {
t.Fatalf("admin form missing %q", want)
}
}
buf.Reset()
if err := ticketNewPage.ExecuteTemplate(&buf, "content", pageData{Repo: "tk", CanWrite: true}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), `name="fossil"`) {
t.Fatalf("a writer is offered the fossil checkbox")
}
buf.Reset()
if err := ticketNewPage.ExecuteTemplate(&buf, "content", pageData{Repo: "tk"}); err != nil {
t.Fatal(err)
}
if strings.Contains(buf.String(), `name="fossil"`) {
t.Fatal("a visitor was offered the fossil door")
}
}
// freshIntake empties the anonymous-filing limiter, which is package
// state: without it a repeated run (-count) spends the last run's budget.
func freshIntake(t *testing.T) {
t.Helper()
reset := func() {
intakeMu.Lock()
intakeSeen = map[string][]time.Time{}
intakeMu.Unlock()
}
reset()
t.Cleanup(reset)
}
func TestIntakeRateLimit(t *testing.T) {
freshIntake(t)
now := time.Now()
addr := "test-10.9.9.9"
for i := 0; i < 5; i++ {
if !intakeAllowed(addr, now) {
t.Fatalf("filing %d refused", i+1)
}
}
if intakeAllowed(addr, now) {
t.Fatal("the sixth filing inside an hour passed")
}
if !intakeAllowed(addr, now.Add(2*time.Hour)) {
t.Fatal("the window never reopened")
}
if !intakeAllowed("test-other", now) {
t.Fatal("one loud address silenced another")
}
}
func TestRemoteAddrBelievesOnlyTheTrustedProxysLastHop(t *testing.T) {
req, _ := http.NewRequest("GET", "/", nil)
req.RemoteAddr = "127.0.0.1:9999"
req.Header.Set("X-Forwarded-For", "10.9.9.9, 203.0.113.9")
if remoteAddr(req) != "127.0.0.1" {
t.Fatalf("FAIL: an untrusted X-Forwarded-For moved the address to %q", remoteAddr(req))
}
req = req.WithContext(context.WithValue(req.Context(), ctxProxy{}, TrustedProxy("X-Forwarded-For")))
if remoteAddr(req) != "203.0.113.9" {
t.Fatalf("FAIL: trusted chain gave %q, want the hop the proxy appended", remoteAddr(req))
}
t.Log("ok: the address is the socket, or the trusted proxy's own hop")
}