package api
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/uv"
"craftdesign.group/quarry/internal/xfer"
)
// Stock fossil answers an anonymous `sync -u` against a repository it
// will not take pushes on with "pull only - not authorized to push",
// pulls what is new, and exits 0. The front used to refuse the whole
// request on the push card fossil sends with every sync (quarry #324).
const pullOnlyNote = "pull only - not authorized to push"
// serverCheckIn adds a check-in to the repository the front serves.
func (g *uvFossilRig) serverCheckIn(name, content, msg string) {
g.t.Helper()
if err := os.WriteFile(filepath.Join(g.origin, name), []byte(content), 0o644); err != nil {
g.t.Fatal(err)
}
g.zit("add", name)
g.zit("commit", "-m", msg)
}
func TestAnonymousSyncPullsAndPushesNothing(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
a := g.clone("a")
g.uvAdd(a, "old.txt", "old\n", "2026-01-01 00:00:00")
g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
b := g.clone("b", "-u")
// The server moves on: a new check-in and a new unversioned file.
g.serverCheckIn("NEWS.md", "news\n", "server moved on")
g.uvAdd(a, "new.txt", "new\n", "2026-01-02 00:00:00")
g.must(filepath.Dir(a), "sync", "-u", g.as("pub"), "-R", a)
// The reader holds an unversioned file of its own, which it must
// not be able to publish.
g.uvAdd(b, "mine.txt", "mine\n", "2026-01-03 00:00:00")
out, err := g.fossil(filepath.Dir(b), "sync", "-u", g.base, "-R", b)
check(t, err == nil, "an anonymous sync -u exits 0", "anonymous sync -u: %v\n%s", err, out)
check(t, strings.Contains(out, pullOnlyNote), "the reader is told it may only pull", "no pull-only note:\n%s", out)
tl := g.must(filepath.Dir(b), "timeline", "-R", b)
check(t, strings.Contains(tl, "server moved on"), "the new check-in was pulled", "timeline of the reader:\n%s", tl)
got, _ := g.fossil(filepath.Dir(b), "uv", "cat", "new.txt", "-R", b)
check(t, got == "new\n", "the new unversioned file was pulled", "uv cat new.txt = %q", got)
_, pushed := g.objects.has(uv.Key("test", "mine.txt"))
_, row := g.row("mine.txt")
check(t, !pushed && !row, "the reader's own unversioned file was not published", "bucket %v, catalog %v", pushed, row)
}
// A write-bearing card in an anonymous request refuses the whole
// request, the pull in it too.
func TestAnonymousRequestWithAFileCardIsRefusedWhole(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
for _, card := range []string{"config /project 4\nabcd\n", "cfile " + strings.Repeat("a", 40) + " 4\nabcd\n",
"uvfile x.txt 1767225600 " + uv.Hash([]byte("x")) + " 1 0\nx"} {
reply := g.exchange("", "", "push x "+g.code+"\npull x "+g.code+"\n"+card)
check(t, strings.Contains(reply, "error anonymous\\spush\\sis\\snot\\saccepted") && !strings.Contains(reply, "message "),
"an anonymous request with "+strings.Fields(card)[0]+" is refused whole",
"%s request:\n%s", strings.Fields(card)[0], reply)
}
// And with nothing to write it is a pull, told so.
reply := g.exchange("", "", "push x "+g.code+"\npull x "+g.code+"\n")
check(t, !strings.Contains(reply, "error ") && strings.Contains(reply, "message pull\\sonly"),
"an anonymous push card alone is answered as a pull", "reply:\n%s", reply)
}
// A signed-in reader whose account holds no check-in letter is
// answered as stock fossil answers one without push capability.
func TestSignedInReaderWithoutCheckInPulls(t *testing.T) {
g := newUVFossilRigWith(t, 1<<20, forgeFront)
if err := g.db.AddAccount("rdr", "rdrpw", false); err != nil {
t.Fatal(err)
}
if err := g.db.SetCaps("rdr", "test", "o"); err != nil {
t.Fatal(err)
}
b := g.clone("b")
g.serverCheckIn("NEWS.md", "news\n", "server moved on")
url := strings.Replace(g.base, "http://", "http://rdr:rdrpw@", 1)
out, err := g.fossil(filepath.Dir(b), "sync", url, "-R", b)
check(t, err == nil && strings.Contains(out, pullOnlyNote), "a signed-in reader without i syncs pull only", "reader sync: %v\n%s", err, out)
check(t, strings.Contains(g.must(filepath.Dir(b), "timeline", "-R", b), "server moved on"), "and pulls the new check-in", "not pulled")
}
// post is exchange with the status kept, for comparing refusals.
func (g *uvFossilRig) post(user, pw, cards string) (int, string) {
g.t.Helper()
body := cards
if user != "" {
nonce := sha1.Sum([]byte(cards))
n := hex.EncodeToString(nonce[:])
sig := sha1.Sum([]byte(n + xfer.SharedSecret(g.code, user, pw)))
body = "login " + user + " " + n + " " + hex.EncodeToString(sig[:]) + "\n" + cards
}
resp, err := http.Post(g.base, "application/x-fossil", bytes.NewReader(compress([]byte(body))))
if err != nil {
g.t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(decompress(raw))
}
// A store that mirrors an upstream answers an anonymous sync as a
// pull, and still refuses a write whoever signed it.
func TestMirrorAnswersAnonymousSyncAsAPull(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
b := g.clone("b")
g.serverCheckIn("NEWS.md", "news\n", "server moved on")
g.tracking = true
out, err := g.fossil(filepath.Dir(b), "sync", g.base, "-R", b)
check(t, err == nil && strings.Contains(out, pullOnlyNote), "an anonymous sync of a mirror is told pull only", "sync: %v\n%s", err, out)
check(t, strings.Contains(g.must(filepath.Dir(b), "timeline", "-R", b), "server moved on"), "and pulls the new check-in", "not pulled")
reply := g.exchange("pub", "pubpw", "push x "+g.code+"\nconfig /project 4\nabcd\n")
check(t, strings.Contains(reply, "mirrors"), "a signed write to a mirror is still refused", "reply:\n%s", reply)
}
// A login card that does not verify reads as nobody: on a private
// repository it is refused exactly as an unknown name is, whatever the
// account it names holds.
func TestUnverifiedLoginReadsAsAnonymous(t *testing.T) {
g := newUVFossilRigWith(t, 1<<20, func(g *uvFossilRig, f *xfer.Fronter) {
readers := map[string]bool{"pub": true, "dev": true, "rdr": true}
// What registrar.SyncReadGate does for a private repository: only
// the login xfer verified is somebody.
f.SetReadGate(func(_ *http.Request, _, login string) bool { return readers[login] })
f.SetAuth(uvSyncAuth{code: g.code,
pw: map[string]string{"pub": "pubpw", "dev": "devpw", "rdr": "rdrpw"},
letters: map[string]string{"pub": "oiy", "dev": "oi", "rdr": "o"}})
})
pull := "pull x " + g.code + "\n"
// A verified reader is served, so the refusals below are the gate's.
if st, body := g.post("rdr", "rdrpw", pull); st != 200 || strings.Contains(body, "error ") {
t.Fatalf("FAIL: the control, a verified reader, was refused: %d %s", st, body)
}
var want string
for i, who := range []string{"", "ghost", "rdr", "dev", "pub"} {
st, body := g.post(who, "wrong", pull)
got := strings.Join([]string{http.StatusText(st), body}, "|")
if st == 200 {
t.Errorf("FAIL: %q with a wrong password read the private repository: %s", who, body)
}
if i == 0 {
want = got
} else if got != want {
t.Errorf("FAIL: wrong password for %q answered %q, unlike an unknown name's %q", who, got, want)
}
}
t.Log("ok: an unverified login is refused the same whoever it names")
}
// A wrong password reads the same on a writer, a reader and an unknown
// name when the request writes nothing, so a caller cannot probe which
// accounts hold the check-in letter.
func TestWrongPasswordOnAPullDoesNotDisclose(t *testing.T) {
g := newUVFossilRig(t, 1<<20)
round := "push x " + g.code + "\npull x " + g.code + "\n"
var want string
for i, who := range []string{"ghost", "dev", "pub"} {
st, body := g.post(who, "wrong", round)
got := http.StatusText(st) + "|" + body
if !strings.Contains(body, "message pull\\sonly") || strings.Contains(body, "error ") {
t.Errorf("FAIL: wrong password for %q: %d %s", who, st, body)
}
if i == 0 {
want = got
} else if got != want {
t.Errorf("FAIL: wrong password for %q answered %q, unlike an unknown name's %q", who, got, want)
}
}
}