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",
AllowPrivate: true, // the peer is on 127.0.0.1
})
b.Client = bridgeClient(60*time.Second, true)
b.SendClient = bridgeClient(15*time.Second, true)
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)
}
}
}