package main
import (
"bytes"
"compress/zlib"
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"craftdesign.group/quarry/internal/children"
"craftdesign.group/quarry/internal/lease"
"craftdesign.group/quarry/internal/oltp"
)
// zitShim is a zit on PATH that records its argument list and whether
// ZIT_PASSWORD was set (never its value) before running the real one,
// so a test can read what the forge handed zit.
const zitShim = `#!/bin/sh
{ printf 'argv:'; for a in "$@"; do printf ' [%s]' "$a"; done; printf ' pwenv=%s\n' "${ZIT_PASSWORD:+set}"; } >> "$SHIM_LOG"
exec "$REAL_ZIT" "$@"
`
// fossilRig is a real store, a Fossil server holding a clone of it (so
// the project codes match) that wants a login to push, and a registrar
// whose Fossil mirror points at it.
type fossilRig struct {
*closeRig
target, fossilHome, shimLog, remote string
logged *bytes.Buffer
}
const fossilUser, fossilPw = "pusher", "e2e-secret-pw-7d3a"
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
func waitListening(t *testing.T, port int) {
t.Helper()
for i := 0; i < 100; i++ {
if c, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port)); err == nil {
c.Close()
return
}
time.Sleep(50 * time.Millisecond)
}
t.Fatalf("nothing listens on %d", port)
}
func background(t *testing.T, dir string, env []string, name string, args ...string) {
t.Helper()
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), env...)
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
}
func newFossilRig(t *testing.T) fossilRig {
t.Helper()
for _, bin := range []string{"fossil", "zit"} {
if _, err := exec.LookPath(bin); err != nil {
t.Skip(bin + " not on PATH")
}
}
realZit, _ := exec.LookPath("zit")
shimDir, shimLog := t.TempDir(), filepath.Join(t.TempDir(), "shim.log")
if err := os.WriteFile(filepath.Join(shimDir, "zit"), []byte(zitShim), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("REAL_ZIT", realZit)
t.Setenv("SHIM_LOG", shimLog)
t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH"))
fossilHome := t.TempDir()
t.Setenv("FOSSIL_HOME", fossilHome)
r := newCloseRig(t)
if err := r.db.AddRepo("test", r.dir, "", oltp.Public); err != nil {
t.Fatal(err)
}
// The target is a clone of the store, so its project code matches.
src := freePort(t)
srv := exec.Command("zit", "serve", "--port", fmt.Sprint(src))
srv.Dir = r.dir
if err := srv.Start(); err != nil {
t.Fatal(err)
}
waitListening(t, src)
target := filepath.Join(t.TempDir(), "target.fossil")
if out, err := exec.Command("fossil", "clone", fmt.Sprintf("http://127.0.0.1:%d/", src), target).CombinedOutput(); err != nil {
t.Fatalf("fossil clone: %v\n%s", err, out)
}
srv.Process.Kill()
srv.Wait()
for _, args := range [][]string{
{"user", "new", fossilUser, "", fossilPw, "-R", target},
{"user", "capabilities", fossilUser, "oi", "-R", target},
} {
if out, err := exec.Command("fossil", args...).CombinedOutput(); err != nil {
t.Fatalf("fossil %v: %v\n%s", args, err, out)
}
}
port := freePort(t)
background(t, filepath.Dir(target), nil, "fossil", "server", target, "--port", fmt.Sprint(port), "--localhost")
waitListening(t, port)
logged := &bytes.Buffer{}
log.SetOutput(logged)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
return fossilRig{closeRig: r, target: target, fossilHome: fossilHome, shimLog: shimLog,
remote: fmt.Sprintf("http://127.0.0.1:%d/", port), logged: logged}
}
func (r fossilRig) enable(password string) oltp.FossilMirror {
r.t.Helper()
m, err := r.db.SetFossilMirror("test", r.remote, fossilUser, password, "dan", "")
if err != nil {
r.t.Fatal(err)
}
return m
}
func (r fossilRig) mirror() oltp.FossilMirror {
m, _, err := r.db.FossilMirrorOf("test")
if err != nil {
r.t.Fatal(err)
}
return m
}
// pushes is the argument lines of every `zit push` the forge ran.
func (r fossilRig) pushes() []string {
b, _ := os.ReadFile(r.shimLog)
var out []string
for _, l := range strings.Split(string(b), "\n") {
if strings.HasPrefix(l, "argv: [push]") {
out = append(out, l)
}
}
return out
}
func (r fossilRig) fossil(args ...string) string {
r.t.Helper()
out, err := exec.Command("fossil", args...).CombinedOutput()
if err != nil {
r.t.Fatalf("fossil %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func (r fossilRig) blobs() string {
return r.fossil("sql", "-R", r.target, "SELECT count(*) FROM blob")
}
func (r fossilRig) has(hash string) bool {
return exec.Command("fossil", "artifact", hash, "-R", r.target).Run() == nil
}
// loginCards counts the login cards a sink was sent: a Fossil sync
// body is a zlib stream behind a four-byte length, and a login card is
// a line starting "login ".
type loginCards struct {
mu sync.Mutex
cards int
posts int
}
func (l *loginCards) ServeHTTP(w http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
text := body
if len(body) > 4 {
if zr, err := zlib.NewReader(bytes.NewReader(body[4:])); err == nil {
if b, err := io.ReadAll(zr); err == nil {
text = b
}
}
}
l.mu.Lock()
l.posts++
for _, line := range bytes.Split(text, []byte("\n")) {
if bytes.HasPrefix(line, []byte("login ")) {
l.cards++
}
}
l.mu.Unlock()
http.Error(w, "no", http.StatusInternalServerError)
}
func (l *loginCards) count() (cards, posts int) {
l.mu.Lock()
defer l.mu.Unlock()
return l.cards, l.posts
}
// The whole path against a real Fossil server: a run pushes what the
// store holds (check-ins and tickets, not only trunk), a second run with
// nothing new still runs, the password reaches zit only in its
// environment and appears in no argument and no log line, and zit saves
// no login: a push or pull to another address afterwards sends none.
func TestAFossilMirrorPushesEverythingWithoutLeavingALoginBehind(t *testing.T) {
r := newFossilRig(t)
r.enable(fossilPw)
before := r.blobs()
r.commit("b.txt", "second", "")
if _, _, err := r.cli.TicketNew(lease.Unguarded("test: scratch store, no coordinator"), "dan", map[string]string{"title": "after the clone", "status": "Open"}); err != nil {
t.Fatal(err)
}
r.sync()
tip, _, _ := r.ix.TipOf("trunk")
if r.has(tip.Tip) {
t.Fatal("FAIL: the target already holds the new tip")
}
r.rg.fossilMirrorRun("test", time.Now())
m := r.mirror()
if m.Err != "" || m.PushedAt == "" || m.Pushed != tip.Tip || m.Fails != 0 {
t.Fatalf("FAIL: after the push: %+v", m)
}
if !r.has(tip.Tip) {
t.Error("FAIL: the target lacks trunk's tip after the push")
}
if after := r.blobs(); after == before {
t.Errorf("FAIL: the target holds %s artifacts before and after", after)
}
// Each run pushes, even when nothing is new: a push carries more
// than trunk, so an unchanged tip proves nothing.
r.rg.fossilMirrorRun("test", time.Now())
pushes := r.pushes()
if len(pushes) != 2 {
t.Fatalf("FAIL: %d pushes after two runs: %v", len(pushes), pushes)
}
for _, l := range pushes {
for _, want := range []string{"[push] [" + r.remote + "]", "[--user] [" + fossilUser + "]", "[--once]", "pwenv=set"} {
if !strings.Contains(l, want) {
t.Errorf("FAIL: argv line %q lacks %q", l, want)
}
}
if strings.Contains(l, fossilPw) || strings.Contains(l, "--password") {
t.Errorf("FAIL: the password is in argv: %q", l)
}
}
if strings.Contains(r.logged.String(), fossilPw) {
t.Errorf("FAIL: the password is in a log line: %s", r.logged.String())
}
// Nothing was saved: a sink sees no login card from a push or a
// pull with no --user, though a push with one is seen (so the sink
// can tell).
sink := &loginCards{}
srv := httptest.NewServer(sink)
t.Cleanup(srv.Close)
probe := func(env []string, args ...string) {
cmd := exec.Command("zit", args...)
cmd.Dir = r.dir
cmd.Env = append(os.Environ(), env...)
cmd.CombinedOutput() // the sink refuses everything; only what it was sent matters
}
probe([]string{"ZIT_PASSWORD=" + fossilPw}, "push", srv.URL+"/", "--user", fossilUser, "--once")
if cards, _ := sink.count(); cards == 0 {
t.Fatal("FAIL: the sink cannot see a login card, so it proves nothing")
}
base, _ := sink.count()
probe(nil, "push", srv.URL+"/")
probe(nil, "pull", srv.URL+"/")
if cards, posts := sink.count(); cards != base || posts < 3 {
t.Errorf("FAIL: zit sent a saved login (%d cards, was %d; %d posts)", cards, base, posts)
}
// Nor to the same server: no remote is saved, and a push there with
// no --user is anonymous, which the server refuses.
if out := zitIn(t, r.dir, "remote"); !strings.Contains(out, "no remote set") {
t.Errorf("FAIL: zit saved a remote: %q", out)
}
anon := exec.Command("zit", "push", r.remote)
anon.Dir = r.dir
if out, err := anon.CombinedOutput(); err == nil {
t.Errorf("FAIL: an anonymous push to the server was accepted, so a login was saved: %s", out)
}
if out := r.fossil("sql", "-R", r.target, "SELECT count(*) FROM user WHERE login='"+fossilUser+"'"); out != "1" {
t.Errorf("FAIL: the target's user: %s", out)
}
t.Log("ok")
}
// A wrong password is said in words and counted; giving the right one
// clears it and the next run pushes.
func TestAFossilMirrorWithAWrongPasswordSaysSoAndRecovers(t *testing.T) {
r := newFossilRig(t)
r.enable("not-the-password")
r.commit("b.txt", "second", "")
r.sync()
r.rg.fossilMirrorRun("test", time.Now())
m := r.mirror()
if m.Fails != 1 || m.Err != fossilMirrorWords("the peer refused: login failed") || m.Pushed != "" {
t.Fatalf("FAIL: a wrong password: %+v", m)
}
if strings.Contains(m.Err, "not-the-password") || strings.Contains(r.logged.String(), "not-the-password") {
t.Error("FAIL: the wrong password was echoed")
}
// The retry tick waits out the backoff.
if fossilMirrorDue(m.PushedAt, m.ErrAt, m.Fails, time.Now()) {
t.Error("FAIL: the tick would retry inside the backoff")
}
r.enable(fossilPw)
if m := r.mirror(); m.Err != "" || m.Fails != 0 {
t.Errorf("FAIL: a new password did not clear the failure: %+v", m)
}
r.rg.fossilMirrorRun("test", time.Now())
tip, _, _ := r.ix.TipOf("trunk")
if m := r.mirror(); m.Err != "" || m.Pushed != tip.Tip || !r.has(tip.Tip) {
t.Errorf("FAIL: after the right password: %+v", m)
}
t.Log("ok")
}
// Private and taken down: paused, nothing attempted, and the lifted
// pause resumes by itself.
func TestAFossilMirrorOfAPrivateOrTakenDownRepositoryIsNotPushed(t *testing.T) {
r := newFossilRig(t)
r.enable(fossilPw)
vis := oltp.Private
if _, err := r.db.PatchRepo("test", oltp.RepoPatch{Visibility: &vis}, "dan", ""); err != nil {
t.Fatal(err)
}
r.rg.fossilMirrorRun("test", time.Now())
if n := len(r.pushes()); n != 0 {
t.Errorf("FAIL: a private repository was pushed (%d)", n)
}
pub := oltp.Public
if _, err := r.db.PatchRepo("test", oltp.RepoPatch{Visibility: &pub}, "dan", ""); err != nil {
t.Fatal(err)
}
r.rg.refreshPrivate()
if err := r.db.SetTakedown(oltp.Takedown{Repo: "test", Strength: oltp.Disabled, Category: "copyright", Actor: "dmestas"}); err != nil {
t.Fatal(err)
}
r.rg.fossilMirrorRun("test", time.Now())
if n := len(r.pushes()); n != 0 {
t.Errorf("FAIL: a taken-down repository was pushed (%d)", n)
}
t.Log("ok")
}
// A push that ends because the forge is stopping records nothing: the
// stop ended it, and the next start should push without a backoff.
func TestAFossilPushEndedByAStopRecordsNothing(t *testing.T) {
r := newFossilRig(t)
r.enable("not-the-password")
children.Default.Stopping()
t.Cleanup(children.Default.Reset)
r.rg.fossilMirrorRun("test", time.Now())
if m := r.mirror(); m.Err != "" || m.Fails != 0 {
t.Errorf("FAIL: a stop was recorded as a failure: %q, %d", m.Err, m.Fails)
}
t.Log("ok")
}
// A host the forge may not reach is refused at each push, not only at
// the setting: a name can come to resolve elsewhere.
func TestAFossilPushChecksItsHostAtEachPush(t *testing.T) {
r := newFossilRig(t)
r.enable(fossilPw)
r.rg.fossilMirror.host = func(_ context.Context, host string) error { return fmt.Errorf("private address %s", host) }
r.rg.fossilMirrorRun("test", time.Now())
if n := len(r.pushes()); n != 0 {
t.Errorf("FAIL: pushed to a refused host (%d)", n)
}
if m := r.mirror(); m.Fails != 1 || m.Err == "" {
t.Errorf("FAIL: a refused host is not recorded: %+v", m)
}
t.Log("ok")
}