// chatref_test.go proves RFC 0011 §14's reference half end to end, on
// a REAL repository: a ticket filed through ticket.new, named from chat
// by the prefix a person would actually type, and the row that lands on
// the ticket's own page. Only the page proves it — a DB row filed under
// a spelling no page looks up is a row nobody sees.
package harness
import (
"encoding/json"
"io"
"net/http"
"net/url"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// refForge is one ingested repository with chat wired, a maintainer who
// can file tickets, and an OLTP handle for the capability edits a
// refusal test needs.
type refForge struct {
t *testing.T
url string
client *http.Client
odb *oltp.DB
}
func newRefForge(t *testing.T) *refForge {
t.Helper()
dataDir := t.TempDir()
srv := ingestAndServeAuth(t, map[string]string{"alpha": buildStore(t, "refseed")}, dataDir,
func(_, user, pass string, _ ops.Auth) api.Decision {
if user != "" && pass == "hunter2" {
return api.Allowed
}
return api.Denied
})
odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { odb.Close() })
client := srv.Client()
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &refForge{t: t, url: srv.URL, client: client, odb: odb}
}
func (f *refForge) do(method, path, user string, form url.Values) (int, string) {
f.t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequest(method, f.url+path, body)
if err != nil {
f.t.Fatal(err)
}
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if user != "" {
req.SetBasicAuth(user, "hunter2")
}
resp, err := f.client.Do(req)
if err != nil {
f.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if loc := resp.Header.Get("Location"); loc != "" {
return resp.StatusCode, loc
}
return resp.StatusCode, string(b)
}
// newTicket files one through the ordinary form and answers its id.
func (f *refForge) newTicket(title string) string {
f.t.Helper()
code, loc := f.do("POST", "/r/alpha/ticket/new", "dan",
url.Values{"title": {title}, "comment": {"filed for the reference test"}})
if code != http.StatusSeeOther {
f.t.Fatalf("ticket new: status %d (%s)", code, loc)
}
return strings.TrimPrefix(loc, "/r/alpha/ticket/")
}
// A chat message naming a ticket by a SHORT prefix puts the backlink
// row on that ticket's page. The row is keyed by the artifact's own id,
// never by the prefix the author typed — a row filed under `a91f3c2e`
// sits where no page ever looks.
func TestChatPrefixReferenceReachesTheTicketPage(t *testing.T) {
f := newRefForge(t)
id := f.newTicket("reference target")
if len(id) < 12 {
t.Fatalf("ticket id too short to prefix: %q", id)
}
prefix := id[:8]
code, body := f.do("POST", "/api/v1/r/alpha/chat", "dan",
url.Values{"text": {"fixed in [" + prefix + "] — please confirm"}})
if code != http.StatusOK {
t.Fatalf("chat post: status %d (%s)", code, body)
}
var posted struct {
Seq uint64 `json:"seq"`
}
if err := json.Unmarshal([]byte(body), &posted); err != nil {
t.Fatal(err)
}
page := ""
if _, page = f.do("GET", "/r/alpha/ticket/"+id, "dan", nil); page == "" {
t.Fatalf("ticket page empty")
}
if !strings.Contains(page, `data-chat-refs data-count="1"`) {
t.Fatalf("no chat backlink row on the ticket page:\n%s", page)
}
if !strings.Contains(page, `href="/r/alpha/chat/`+itoa(posted.Seq)+`"`) {
t.Fatalf("the backlink row does not permalink the message:\n%s", page)
}
// The row is armed like every other reference on the page: its own
// token, and the page's one set of delegated handlers.
if !strings.Contains(page, `data-chat-refs data-count="1" data-seq="`+itoa(posted.Seq)+`"`) {
t.Fatalf("the backlink row carries no preview hook:\n%s", page)
}
if !strings.Contains(page, `data-ref="#`+itoa(posted.Seq)+`"`) {
t.Fatalf("the backlink row carries no reference token:\n%s", page)
}
// One script arms every reference on the page, and no anchor or
// container carries inline handlers.
if n := strings.Count(page, "/refpop."); n != 1 || strings.Contains(page, "data-on:mouseover") {
t.Fatalf("the page loads the preview script %d times, want 1, with no inline handlers", n)
}
}
// An artifact the index has never heard of is not a reference: the
// message renders it as the text its author typed, and nothing is
// recorded to point at a page that does not exist.
func TestChatReferenceToNothingIsPlainText(t *testing.T) {
f := newRefForge(t)
missing := strings.Repeat("9d", 20)
code, body := f.do("POST", "/api/v1/r/alpha/chat", "dan",
url.Values{"text": {"see [" + missing + "] about this"}})
if code != http.StatusOK {
t.Fatalf("chat post: status %d (%s)", code, body)
}
if n, _, _, err := f.odb.ChatRefs("alpha", missing); err != nil || n != 0 {
t.Fatalf("a row was written for an artifact nobody has: (%d, %v)", n, err)
}
// And ref.resolve refuses it the way it refuses a typo: not found.
for _, ref := range []string{
"ticket/" + missing, "checkin/" + missing,
"landing/" + missing, "forum/" + missing, "wiki/NoSuchPage",
} {
if code, _ := f.do("GET", "/api/v1/r/alpha/ref?ref="+url.QueryEscape(ref), "dan", nil); code != http.StatusNotFound {
t.Fatalf("ref.resolve %q: status %d, want 404", ref, code)
}
}
}
// The capability is checked on EVERY branch, the addressed repository
// included. `alpha:ticket/x` was the bypass: it named the reader's own
// repository, so the cap test was skipped and the card came back to a
// reader who could not open the page it previewed.
func TestAddressedRepoIsNotACapBypass(t *testing.T) {
f := newRefForge(t)
id := f.newTicket("gated target")
// Stock category caps hand `r` to everyone; blank them so the
// account's own letters are the whole answer.
for _, cat := range []string{"nobody", "anonymous"} {
if err := f.odb.SetCategoryCaps("alpha", cat, ""); err != nil {
t.Fatal(err)
}
}
if err := f.odb.AddAccount("carl", "hunter2", false); err != nil {
t.Fatal(err)
}
// Chat but not tickets: carl may read the channel and must NOT
// learn anything about a ticket from hovering a reference in it.
if err := f.odb.SetCaps("carl", "alpha", "C"); err != nil {
t.Fatal(err)
}
for _, ref := range []string{"ticket/" + id, "alpha:ticket/" + id} {
q := "/api/v1/r/alpha/ref?ref=" + url.QueryEscape(ref)
// Refused, and refused as not-found: a 403 would confirm the
// ticket exists to someone who may not know that.
if code, body := f.do("GET", q, "carl", nil); code != http.StatusNotFound {
t.Fatalf("%q without r: status %d, want 404\n%s", ref, code, body)
}
// With `r` the same token resolves — the gate is the letter,
// not a wall.
if err := f.odb.SetCaps("carl", "alpha", "Cr"); err != nil {
t.Fatal(err)
}
code, body := f.do("GET", q, "carl", nil)
if code != http.StatusOK {
t.Fatalf("%q with r: status %d\n%s", ref, code, body)
}
var got struct {
Kind string `json:"kind"`
Href string `json:"href"`
}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatal(err)
}
if got.Kind != "ticket" || got.Href != "/r/alpha/ticket/"+id {
t.Fatalf("%q resolved to %+v", ref, got)
}
if err := f.odb.SetCaps("carl", "alpha", "C"); err != nil {
t.Fatal(err)
}
}
// A person card names capabilities, which is `h` — never anonymous.
pq := "/api/v1/r/alpha/ref?ref=" + url.QueryEscape("@dan")
if code, body := f.do("GET", pq, "", nil); code != http.StatusNotFound {
t.Fatalf("anonymous person card: status %d\n%s", code, body)
}
if code, body := f.do("GET", pq, "carl", nil); code != http.StatusNotFound {
t.Fatalf("person card without h: status %d\n%s", code, body)
}
if err := f.odb.SetCaps("carl", "alpha", "Ch"); err != nil {
t.Fatal(err)
}
if code, body := f.do("GET", pq, "carl", nil); code != http.StatusOK {
t.Fatalf("person card with h: status %d\n%s", code, body)
}
}
// A bracketed hash resolves to whatever the index says it is, so the
// letter it is gated on must be that kind's. `o` is the check-in
// letter: behind it alone a reader saw a check-in — and, through
// `[<ticket hash>]`, a ticket's title and status too.
func TestBracketedHashIsGatedByTheKindItResolvesTo(t *testing.T) {
f := newRefForge(t)
id := f.newTicket("bracketed target")
for _, cat := range []string{"nobody", "anonymous"} {
if err := f.odb.SetCategoryCaps("alpha", cat, ""); err != nil {
t.Fatal(err)
}
}
if err := f.odb.AddAccount("carl", "hunter2", false); err != nil {
t.Fatal(err)
}
// Check-ins but not tickets. `[hash]` must follow the first and
// refuse the second.
if err := f.odb.SetCaps("carl", "alpha", "Co"); err != nil {
t.Fatal(err)
}
var tl struct {
Entries []struct {
Kind string `json:"kind"`
Hash string `json:"hash"`
} `json:"entries"`
}
_, body := f.do("GET", "/api/v1/r/alpha/timeline?y=ci", "carl", nil)
if err := json.Unmarshal([]byte(body), &tl); err != nil {
t.Fatalf("timeline: %v\n%s", err, body)
}
ci := ""
for _, e := range tl.Entries {
if e.Kind == "ci" && len(e.Hash) >= 12 {
ci = e.Hash
break
}
}
if ci == "" {
t.Fatal("no check-in to reference")
}
q := func(ref string) string { return "/api/v1/r/alpha/ref?ref=" + url.QueryEscape(ref) }
if code, body := f.do("GET", q("["+id[:12]+"]"), "carl", nil); code != http.StatusNotFound {
t.Fatalf("bracketed ticket behind o alone: status %d, want 404\n%s", code, body)
}
code, body := f.do("GET", q("["+ci[:12]+"]"), "carl", nil)
if code != http.StatusOK {
t.Fatalf("bracketed check-in behind o: status %d\n%s", code, body)
}
var got struct {
Kind string `json:"kind"`
Href string `json:"href"`
}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatal(err)
}
if got.Kind != "checkin" || got.Href != "/r/alpha/checkin/"+ci {
t.Fatalf("bracketed check-in resolved to %+v", got)
}
// With `r` the same bracketed ticket resolves, as a ticket.
if err := f.odb.SetCaps("carl", "alpha", "Cor"); err != nil {
t.Fatal(err)
}
code, body = f.do("GET", q("["+id[:12]+"]"), "carl", nil)
if code != http.StatusOK {
t.Fatalf("bracketed ticket with r: status %d\n%s", code, body)
}
got = struct {
Kind string `json:"kind"`
Href string `json:"href"`
}{}
if err := json.Unmarshal([]byte(body), &got); err != nil {
t.Fatal(err)
}
if got.Kind != "ticket" || got.Href != "/r/alpha/ticket/"+id {
t.Fatalf("bracketed ticket resolved to %+v", got)
}
}
func itoa(n uint64) string {
if n == 0 {
return "0"
}
var b []byte
for n > 0 {
b = append([]byte{byte('0' + n%10)}, b...)
n /= 10
}
return string(b)
}