@ 261101.7
current
package main

import (
	"bufio"
	"io"
	"net"
	"net/http"
	"net/netip"
	"os"
	"path/filepath"
	"strings"
	"syscall"
	"testing"
	"time"
)

func TestEndpointFor(t *testing.T) {
	for _, c := range []struct {
		addr string
		uid  int
		want string
	}{
		{"", 1000, "127.31.0.1:8080"},
		{"", 1001, "127.31.1.1:8080"},
		{"", 1255, "127.31.255.1:8080"},
		{"127.31.1.2", 1001, "127.31.1.2:8080"},
		{"127.31.1.2:9000", 0, "127.31.1.2:9000"},
	} {
		ep, err := endpointFor(c.addr, c.uid)
		if err != nil || ep.String() != c.want {
			t.Errorf("%q uid %d: %v %v, want %s", c.addr, c.uid, ep, err, c.want)
		}
	}
	for _, c := range []struct {
		addr string
		uid  int
	}{{"", 999}, {"", 1256}, {"", 0}, {"192.168.1.5", 1001}, {"::1", 1001}, {"127.31.1.2:0", 1001}, {"docs", 1001}} {
		if ep, err := endpointFor(c.addr, c.uid); err == nil {
			t.Errorf("%q uid %d: %v, want refusal", c.addr, c.uid, ep)
		}
	}
}

func TestCheckDirRefusesHome(t *testing.T) {
	home := t.TempDir()
	os.Mkdir(filepath.Join(home, "docs"), 0o755)
	os.Symlink(home, filepath.Join(t.TempDir(), "h"))
	if _, err := checkDir(home, home); errnoOf(err) != 1 {
		t.Errorf("home: %v, want EPERM", err)
	}
	if _, err := checkDir(filepath.Join(home, "docs"), home); err != nil {
		t.Errorf("below home: %v", err)
	}
	if _, err := checkDir(filepath.Join(home, "missing"), home); err == nil {
		t.Error("missing directory accepted")
	}
	for _, d := range []string{filepath.Dir(home), "/"} {
		if _, err := checkDir(d, home); errnoOf(err) != 1 {
			t.Errorf("%s above home: %v, want EPERM", d, err)
		}
	}
	link := filepath.Join(t.TempDir(), "home")
	os.Symlink(home, link)
	if _, err := checkDir(filepath.Dir(home), link); errnoOf(err) != 1 {
		t.Errorf("above a linked home: %v, want EPERM", err)
	}
}

// The watcher wakes on a change in a watched directory.
func TestWatcherWakes(t *testing.T) {
	w := newWatcher(lockMask)
	defer w.close()
	dir := t.TempDir()
	if w.add(dir) < 0 {
		t.Skip("inotify unavailable")
	}
	os.WriteFile(filepath.Join(dir, "x"), nil, 0o644)
	select {
	case <-w.wake:
	case <-time.After(2 * time.Second):
		t.Fatal("no wake after a file was created")
	}
}

// testEndpoint returns a free port on an address no real server uses, and
// keeps the registry inside the test.
func testEndpoint(t *testing.T) netip.AddrPort {
	t.Helper()
	t.Setenv("XDG_STATE_HOME", t.TempDir())
	ln, err := net.Listen("tcp", "127.31.250.1:0")
	if err != nil {
		t.Skip("cannot listen on 127.31.250.1:", err)
	}
	ep := netip.MustParseAddrPort(ln.Addr().String())
	ln.Close()
	return ep
}

// start runs www for dir as a detached child would and returns its
// report lines once it finished reporting, and a channel with its exit code.
func start(t *testing.T, c config) ([]string, chan int) {
	t.Helper()
	r, w, err := os.Pipe()
	if err != nil {
		t.Fatal(err)
	}
	code := make(chan int, 1)
	go func() { code <- run(c, &reporter{pipe: w}) }()
	b, _ := io.ReadAll(bufio.NewReader(r))
	r.Close()
	return strings.Split(strings.TrimSpace(string(b)), "\n"), code
}

func waitFor(t *testing.T, what string, ok func() bool) {
	t.Helper()
	for end := time.Now().Add(3 * time.Second); time.Now().Before(end); time.Sleep(10 * time.Millisecond) {
		if ok() {
			return
		}
	}
	t.Fatal("timed out waiting for " + what)
}

func status(t *testing.T, url string) int {
	t.Helper()
	resp, err := http.Get(url)
	if err != nil {
		return 0
	}
	resp.Body.Close()
	return resp.StatusCode
}

// A second tree joins the running server; each lock withdraws its tree, and
// the server exits with the last one.
func TestTreesShareOneServer(t *testing.T) {
	ep := testEndpoint(t)
	one, two := t.TempDir(), t.TempDir()
	os.WriteFile(filepath.Join(one, "a.txt"), []byte("one\n"), 0o644)
	os.WriteFile(filepath.Join(two, "b.txt"), []byte("two\n"), 0o644)
	base := "http://" + ep.String()

	lines, served := start(t, config{dir: one, ep: ep})
	if !strings.HasPrefix(lines[0], "url "+base+one+"/") {
		t.Fatalf("server report: %q", lines)
	}
	lines, joined := start(t, config{dir: two, ep: ep})
	if code := <-joined; code != 0 {
		t.Fatalf("join failed with %d: %q", code, lines)
	}
	if !strings.HasPrefix(lines[0], "url "+base+two+"/") {
		t.Fatalf("join report: %q", lines)
	}
	lock1, lock2 := filepath.Join(one, lockName(ep)), filepath.Join(two, lockName(ep))
	if b, _ := os.ReadFile(lock2); parseLock(b).pid != os.Getpid() {
		t.Errorf("joined lock does not name the server: %q", b)
	}
	for _, p := range []string{one + "/a.txt?raw", two + "/b.txt?raw"} {
		if code := status(t, base+p); code != http.StatusOK {
			t.Errorf("%s: status %d", p, code)
		}
	}
	lines, again := start(t, config{dir: two, ep: ep})
	if <-again != 0 || lines[0] != "url "+base+two+"/" {
		t.Errorf("second run in a served tree: %q", lines)
	}

	os.Remove(lock1)
	waitFor(t, "the first tree to go", func() bool { return status(t, base+one+"/a.txt?raw") == http.StatusNotFound })
	if code := status(t, base+two+"/b.txt?raw"); code != http.StatusOK {
		t.Fatalf("second tree went with the first: status %d", code)
	}
	reg := filepath.Join(os.Getenv("XDG_STATE_HOME"), progName, endpointName(ep))
	if hasLink(reg, one) {
		t.Error("withdrawn tree keeps its registry link")
	}

	os.Remove(lock2)
	select {
	case code := <-served:
		if code != 0 {
			t.Errorf("server exited with %d", code)
		}
	case <-time.After(3 * time.Second):
		t.Fatal("server outlived its last lock")
	}
	if entries, _ := os.ReadDir(reg); len(entries) != 0 {
		t.Errorf("registry not empty after exit: %v", entries)
	}
}

// A connection that never sends a request does not hold the server past
// its last lock. Browsers open such connections ahead of need, and so may
// a client's pool, as in TestTreesShareOneServer.
func TestSilentConnectionDoesNotHoldServer(t *testing.T) {
	ep := testEndpoint(t)
	dir := t.TempDir()
	_, served := start(t, config{dir: dir, ep: ep})
	c, err := net.Dial("tcp", ep.String())
	if err != nil {
		t.Fatal(err)
	}
	defer c.Close()
	time.Sleep(50 * time.Millisecond)
	os.Remove(filepath.Join(dir, lockName(ep)))
	select {
	case <-served:
	case <-time.After(2 * time.Second):
		t.Fatal("a connection without a request held the server")
	}
}

// Without inotify the server polls, and still sees a deleted lock.
func TestPollingFallback(t *testing.T) {
	ep := testEndpoint(t)
	old := inotifyInit
	inotifyInit = func(int) (int, error) { return -1, syscall.EMFILE }
	t.Cleanup(func() { inotifyInit = old })
	dir := t.TempDir()
	_, served := start(t, config{dir: dir, ep: ep})
	os.Remove(filepath.Join(dir, lockName(ep)))
	select {
	case <-served:
	case <-time.After(3 * time.Second):
		t.Fatal("polling server outlived its lock")
	}
}

// A server starting on an endpoint clears links and locks a crash left.
func TestStartDropsLeftovers(t *testing.T) {
	ep := testEndpoint(t)
	dir, stale := t.TempDir(), t.TempDir()
	reg, err := openRegistry(ep)
	if err != nil {
		t.Fatal(err)
	}
	staleLock := filepath.Join(stale, lockName(ep))
	os.WriteFile(staleLock, []byte("url: x\npid: 1\n"), 0o644)
	reg.add(staleLock, "")
	reg.close()

	_, served := start(t, config{dir: dir, ep: ep})
	if _, err := os.Stat(staleLock); err == nil {
		t.Error("stale lock survived the start")
	}
	links, _ := (&registry{dir: reg.dir}).links()
	if len(links) != 1 {
		t.Errorf("registry holds %v, want only the new tree", links)
	}
	os.Remove(filepath.Join(dir, lockName(ep)))
	select {
	case <-served:
	case <-time.After(3 * time.Second):
		t.Fatal("server outlived its lock")
	}
}

// The tree's stop button withdraws it, which stops a server with one tree.
func TestStopButtonEndsLastTree(t *testing.T) {
	ep := testEndpoint(t)
	dir := t.TempDir()
	_, served := start(t, config{dir: dir, ep: ep})
	resp, err := http.PostForm("http://"+ep.String()+ownPath+"stop", map[string][]string{"tree": {dir + "/"}})
	if err != nil {
		t.Fatal(err)
	}
	resp.Body.Close()
	select {
	case <-served:
	case <-time.After(3 * time.Second):
		t.Fatal("server kept running after its last tree was stopped")
	}
	if _, err := os.Stat(filepath.Join(dir, lockName(ep))); err == nil {
		t.Error("lock left behind")
	}
}

func TestOrigin(t *testing.T) {
	for ep, want := range map[string]string{
		"127.31.1.1:80":   "http://127.31.1.1",
		"127.31.1.1:8080": "http://127.31.1.1:8080",
		"10.0.0.1:80":     "http://10.0.0.1",
	} {
		if got := origin(netip.MustParseAddrPort(ep)); got != want {
			t.Errorf("%s: %s, want %s", ep, got, want)
		}
	}
}

// A server on 0.0.0.0 hands out the machine's name.
func TestOriginUnspecified(t *testing.T) {
	name, err := os.Hostname()
	if err != nil {
		t.Skip("no hostname")
	}
	if got, want := origin(netip.MustParseAddrPort("0.0.0.0:8080")), "http://"+name+":8080"; got != want {
		t.Errorf("0.0.0.0:8080: %s, want %s", got, want)
	}
}