Skip to content
quarry Artifact [d3752c3fd7]
Login

Artifact [d3752c3fd7]

Artifact d3752c3fd752804c55d5fff6db1c0881ed638906e2a44ced5ac55c95068faf83:


package chat

import (
	"context"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"syscall"
	"testing"
	"time"

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

// fossilChatPeer stands up a REAL fossil server with chat reachable by
// the logins named. The fake in bridge_test.go pins the shapes; this
// pins that the shapes are fossil's, which is the one thing a fake
// cannot say.
//
// Two things here are not defaults, and the bridge does not work
// without either — they are made exactly as an operator has to make
// them (see "Fossil chat bridge" in docs/running.md):
//
//   - `http_authentication_ok`: fossil refuses HTTP Basic without it.
//     It is a config entry rather than a `fossil setting`, so it goes
//     in through `fossil sql`.
//   - a login holding `C`, which is what gates chat at all.
func fossilChatPeer(t *testing.T, logins map[string]string) string {
	t.Helper()
	if _, err := exec.LookPath("fossil"); err != nil {
		t.Skip("fossil not on PATH")
	}
	// Not t.TempDir(): fossil forks per-connection children, and a
	// killed parent's stragglers can still hold the repository when the
	// framework sweeps. Own directory, own patient cleanup — the shape
	// internal/xfer's peer already uses.
	dir, err := os.MkdirTemp("", "fossilchat-")
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		for i := 0; i < 40; i++ {
			if os.RemoveAll(dir) == nil {
				return
			}
			time.Sleep(50 * time.Millisecond)
		}
		t.Logf("fossilChatPeer: %s not fully removed", dir)
	})
	repo := filepath.Join(dir, "peer.fossil")
	run := func(args ...string) {
		t.Helper()
		if out, err := exec.Command("fossil", args...).CombinedOutput(); err != nil {
			t.Fatalf("fossil %v: %v\n%s", args, err, out)
		}
	}
	run("init", repo, "--admin-user", "root")
	for name, pass := range logins {
		run("user", "new", name, name, pass, "-R", repo)
		// C is chat; j reads the timeline the chat page links into.
		run("user", "capabilities", name, "Cj", "-R", repo)
	}
	sql := exec.Command("fossil", "sql", "-R", repo)
	sql.Stdin = strings.NewReader(
		"INSERT OR REPLACE INTO config(name,value,mtime) VALUES('http_authentication_ok','1',now());")
	if out, err := sql.CombinedOutput(); err != nil {
		t.Fatalf("enabling basic auth: %v\n%s", err, out)
	}

	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	port := ln.Addr().(*net.TCPAddr).Port
	ln.Close()
	cmd := exec.Command("fossil", "server", repo, "--port", fmt.Sprint(port), "--localhost")
	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
	if err := cmd.Start(); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
		cmd.Wait()
	})
	base := fmt.Sprintf("http://127.0.0.1:%d", port)
	deadline := time.Now().Add(15 * time.Second)
	for time.Now().Before(deadline) {
		if c, derr := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), time.Second); derr == nil {
			c.Close()
			return base
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Fatalf("fossil server on %d never came up", port)
	return ""
}

// fossilSay posts one chat message to the peer as the named login,
// through the same two gates the bridge goes through.
func fossilSay(t *testing.T, base, user, pass, msg string) {
	t.Helper()
	form := url.Values{"msg": {msg}}
	req, err := http.NewRequest("POST", base+"/chat-send", strings.NewReader(form.Encode()))
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Referer", base+"/chat")
	req.SetBasicAuth(user, pass)
	resp, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
	if resp.StatusCode != http.StatusOK || errorIn(body) != "" {
		t.Fatalf("fossil chat-send as %s: %s %s", user, resp.Status, body)
	}
}

// fossilChatDump reads the peer's chat back through its own endpoint —
// fossil answering, not the bridge asserting about itself.
func fossilChatDump(t *testing.T, base, user, pass string) string {
	t.Helper()
	req, err := http.NewRequest("GET", base+"/chat-poll?name=0&raw=1", nil)
	if err != nil {
		t.Fatal(err)
	}
	req.Header.Set("Referer", base+"/chat")
	req.SetBasicAuth(user, pass)
	resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
	if err != nil {
		t.Fatalf("reading the upstream back: %v", err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
	return string(body)
}

// A real fossil, bridged both ways: a person's message there arrives
// in the quarry channel as `fossil:<user>` with Origin "fossil", a
// quarry message lands there carrying its author's name, and the
// bridge's own posts do not come back.
func TestBridgeAgainstARealFossil(t *testing.T) {
	base := fossilChatPeer(t, map[string]string{"bridgebot": "s3cretpw", "drh": "personpw"})
	ev, err := events.Start(t.TempDir())
	if err != nil {
		t.Fatal(err)
	}
	defer ev.Close()
	r := room(t, ev)

	b := NewBridge(r, newMemStore(), BridgeConfig{
		Repo: "alpha", Code: "codeReal", URL: base, User: "bridgebot", Secret: "s3cretpw",
	})
	b.Client = &http.Client{Timeout: 60 * time.Second}
	b.SendClient = &http.Client{Timeout: 15 * time.Second}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	go b.Run(ctx)
	// The outbound tail starts at NOW; let it attach before posting, or
	// the message is correctly not forwarded.
	time.Sleep(500 * time.Millisecond)

	// Outbound: quarry → fossil, carrying the author's name in the text
	// because fossil attributes everything to the login that sent it.
	if _, err := r.Post("codeReal", Message{Repo: "alpha", User: "dan",
		Text: "hello from quarry"}); err != nil {
		t.Fatal(err)
	}
	waitFor(t, "the message to reach fossil", func() bool {
		return strings.Contains(fossilChatDump(t, base, "bridgebot", "s3cretpw"),
			`**dan:** hello from quarry`)
	})

	// Inbound: a PERSON on the fossil side, not the bridge login.
	fossilSay(t, base, "drh", "personpw", "hello from fossil")
	waitFor(t, "the fossil message to reach quarry", func() bool {
		for _, m := range folded(t, r, "codeReal") {
			if strings.Contains(m.Text, "hello from fossil") {
				if m.Origin != "fossil" || m.User != "fossil:drh" {
					t.Fatalf("inbound line is not attributed to fossil: %+v", m)
				}
				return true
			}
		}
		return false
	})

	// The loop guard, against the real thing: quarry's own outbound
	// message is sitting in the upstream under the bridge's login and
	// must never have come back as a fossil line.
	for _, m := range folded(t, r, "codeReal") {
		if m.Origin == "fossil" && strings.Contains(m.Text, "hello from quarry") {
			t.Fatalf("the bridge re-imported its own message: %+v", m)
		}
	}
}