New Sign in

fierj

Public

Tiny personal git forge

← fierj / fierj_test.go
package main

import (
	"bytes"
	"html/template"
	"io"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"
)

// TestMain intercepts hook invocations. When git pushes trigger the
// pre-receive hook, it runs this test binary with args [hook pre-receive].
// We handle that here without running any tests.
func TestMain(m *testing.M) {
	if len(os.Args) >= 2 && os.Args[1] == "hook" {
		os.Exit(hookCmd(os.Args[2:]))
	}
	os.Exit(m.Run())
}

// ---------- test helpers ----------

// testServer bundles a running fierj HTTP server with its temp directory.
type testServer struct {
	*httptest.Server
	cfg    Config
	dir    string          // temp dir backing cfg.Dir
	users  UserStore
	secret []byte
	tmpl   *template.Template
}

// newTestServer creates a fierj server with a fresh temp directory, templates,
// and optional pre-seeded users.
func newTestServer(t *testing.T) *testServer {
	t.Helper()

	dir := t.TempDir()
	usersPath := filepath.Join(dir, "users.json")

	tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))

	cfg := Config{
		Addr:         "127.0.0.1:0",
		Host:         "",
		Dir:          dir,
		UsersPath:    usersPath,
		CookieSecret: "test-secret-do-not-use-in-prod",
	}

	users, err := LoadUsers(usersPath)
	if err != nil {
		t.Fatalf("LoadUsers: %v", err)
	}

	secret := []byte(cfg.CookieSecret)

	// Use the shared handler constructor — same as production main().
	handler := newHandler(cfg, users, secret, tmpl)

	ts := httptest.NewServer(handler)
	return &testServer{
		Server: ts,
		cfg:    cfg,
		dir:    dir,
		users:  users,
		secret: secret,
		tmpl:   tmpl,
	}
}

// client returns an http.Client with a cookie jar for session persistence.
func (ts *testServer) client() *http.Client {
	jar, _ := cookiejar.New(nil)
	return &http.Client{Jar: jar}
}

// get is a helper to GET a path and return the response.
func (ts *testServer) get(t *testing.T, client *http.Client, path string) *http.Response {
	t.Helper()
	resp, err := client.Get(ts.URL + path)
	if err != nil {
		t.Fatalf("GET %s: %v", path, err)
	}
	return resp
}

// postForm POSTs urlencoded form data and follows redirects (status 303).
func (ts *testServer) postForm(t *testing.T, client *http.Client, path string, data url.Values) *http.Response {
	t.Helper()
	req, err := http.NewRequest("POST", ts.URL+path, strings.NewReader(data.Encode()))
	if err != nil {
		t.Fatalf("NewRequest %s: %v", path, err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := client.Do(req)
	if err != nil {
		t.Fatalf("POST %s: %v", path, err)
	}
	return resp
}

// body reads the full response body. Call it once per response.
func body(t *testing.T, resp *http.Response) string {
	t.Helper()
	b, err := io.ReadAll(resp.Body)
	resp.Body.Close()
	if err != nil {
		t.Fatalf("read body: %v", err)
	}
	return string(b)
}

// bodyStr reads the body and returns it as a string. If the body was already read
// (resp.Body is nil), it returns the previously cached value from resp.Request's context.
// Prefer reading once and using plain strings.Contains in tests.

// assertContains checks that the body contains a substring.
func assertContains(t *testing.T, resp *http.Response, substr string) {
	t.Helper()
	b := body(t, resp)
	if !strings.Contains(b, substr) {
		t.Errorf("expected body to contain %q, got %d bytes", substr, len(b))
	}
}

// assertStatus checks the response status code.
func assertStatus(t *testing.T, resp *http.Response, want int) {
	t.Helper()
	if resp.StatusCode != want {
		t.Errorf("expected status %d, got %d", want, resp.StatusCode)
	}
}

// setupUser goes through the first-run setup flow.
func (ts *testServer) setupUser(t *testing.T, client *http.Client, username, password string) {
	t.Helper()
	resp := ts.postForm(t, client, "/setup", url.Values{
		"username": {username},
		"password": {password},
		"confirm":  {password},
	})
	assertStatus(t, resp, http.StatusOK)
}

// login authenticates as an existing user.
func (ts *testServer) login(t *testing.T, client *http.Client, username, password string) {
	t.Helper()
	resp := ts.postForm(t, client, "/login", url.Values{
		"username": {username},
		"password": {password},
	})
	assertStatus(t, resp, http.StatusOK)
}

// createRepo does POST /new to create a repo.
func (ts *testServer) createRepo(t *testing.T, client *http.Client, name, desc string) {
	t.Helper()
	resp := ts.postForm(t, client, "/new", url.Values{
		"name":        {name},
		"description": {desc},
	})
	assertStatus(t, resp, http.StatusOK)
}

// gitCmd runs a git command in the given directory.
func gitCmd(t *testing.T, dir string, args ...string) string {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr
	if err := cmd.Run(); err != nil {
		t.Fatalf("git %v failed: %v\nstderr: %s", args, err, stderr.String())
	}
	return stdout.String()
}

// ---------- tests ----------

// TestFreshServer_SetupFlow ensures a pristine server requires setup before anything else.
func TestFreshServer_SetupFlow(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()

	// Any page should redirect to /setup (showing the setup form).
	resp := ts.get(t, c, "/")
	assertStatus(t, resp, http.StatusOK)
	b := body(t, resp)
	if !strings.Contains(b, "Welcome to fierj") {
		t.Errorf("fresh server should present setup page, got: %.200s", b)
	}

	// Create admin user via setup
	ts.setupUser(t, c, "admin", "hunter2")

	// Now we should be logged in and see the home page (no repos yet)
	// Now we should be logged in and see the home page (no repos yet).
	resp = ts.get(t, c, "/")
	assertStatus(t, resp, http.StatusOK)
	b = body(t, resp)
	if !strings.Contains(b, "admin") && !strings.Contains(b, "Repositories") {
		t.Error("logged-in user should see the home page")
	}
}

// TestEmptyRepo_ShowsPushInstructions verifies an empty repo tells how to push code.
func TestEmptyRepo_ShowsPushInstructions(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "myproject", "A test project")

	resp := ts.get(t, c, "/myproject")
	assertStatus(t, resp, http.StatusOK)
	b := body(t, resp)

	// An empty repo should mention how to push code
	if !strings.Contains(b, "push") && !strings.Contains(b, "clone") && !strings.Contains(b, "remote") {
		t.Error("empty repo page should give instructions on how to push code")
	}
}

// TestCreateRepoAndPush exercises full git interop: init local → push → browse.
func TestCreateRepoAndPush(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "hello", "Hello world repo")

	// Clone the empty repo (use file:// since we know the path)
	localDir := t.TempDir()
	repoPath := filepath.Join(ts.dir, "hello.git")
	gitCmd(t, localDir, "clone", "file://"+repoPath, "hello")
	localRepo := filepath.Join(localDir, "hello")

	// Create a file and commit
	if err := os.WriteFile(filepath.Join(localRepo, "README.md"), []byte("# Hello\n"), 0644); err != nil {
		t.Fatal(err)
	}
	gitCmd(t, localRepo, "add", "README.md")
	gitCmd(t, localRepo, "commit", "-m", "initial commit")

	// Push to fierj
	gitCmd(t, localRepo, "push", "origin", "main")

	// Now browse the repo on the web
	resp := ts.get(t, c, "/hello")
	assertStatus(t, resp, http.StatusOK)
	b := body(t, resp)
	// Should contain the README filename
	if !strings.Contains(b, "README.md") {
		t.Error("repo tree should show README.md after push")
	}

	// View the blob
	resp = ts.get(t, c, "/hello/blob/main/README.md")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "Hello")

	// View commits
	resp = ts.get(t, c, "/hello/log/main")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "initial commit")
}

// TestBranchesAndRefs exercises branch listing and multi-branch pushes.
func TestBranchesAndRefs(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "branches", "Branch test")

	// Clone and push initial commit to main
	localDir := t.TempDir()
	gitCmd(t, localDir, "clone", "file://"+filepath.Join(ts.dir, "branches.git"), "repo")
	localRepo := filepath.Join(localDir, "repo")
	os.WriteFile(filepath.Join(localRepo, "f.txt"), []byte("main"), 0644)
	gitCmd(t, localRepo, "add", "f.txt")
	gitCmd(t, localRepo, "commit", "-m", "main commit")
	gitCmd(t, localRepo, "push", "origin", "main")

	// Create and push a branch
	gitCmd(t, localRepo, "checkout", "-b", "feature-x")
	os.WriteFile(filepath.Join(localRepo, "f.txt"), []byte("feature"), 0644)
	gitCmd(t, localRepo, "add", "f.txt")
	gitCmd(t, localRepo, "commit", "-m", "feature commit")
	gitCmd(t, localRepo, "push", "origin", "feature-x")

	// View refs page
	resp := ts.get(t, c, "/branches/refs/")
	assertStatus(t, resp, http.StatusOK)
	b := body(t, resp)
	if !strings.Contains(b, "main") {
		t.Error("refs page should list main branch")
	}
	if !strings.Contains(b, "feature-x") {
		t.Error("refs page should list feature-x branch")
	}

	// Browse tree on feature branch
	resp = ts.get(t, c, "/branches/tree/feature-x")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "f.txt")

	// Blob on feature branch should show feature content
	resp = ts.get(t, c, "/branches/blob/feature-x/f.txt")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "feature")
}

// TestPatchesFlow exercises creating a patch from a branch and merging it.
func TestPatchesFlow(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "patchtest", "Patch test repo")

	// Push initial commit
	localDir := t.TempDir()
	gitCmd(t, localDir, "clone", "file://"+filepath.Join(ts.dir, "patchtest.git"), "repo")
	localRepo := filepath.Join(localDir, "repo")
	os.WriteFile(filepath.Join(localRepo, "code.txt"), []byte("v1"), 0644)
	gitCmd(t, localRepo, "add", "code.txt")
	gitCmd(t, localRepo, "commit", "-m", "first version")
	gitCmd(t, localRepo, "push", "origin", "main")

	// Create a feature branch and push it
	gitCmd(t, localRepo, "checkout", "-b", "cool-feature")
	os.WriteFile(filepath.Join(localRepo, "code.txt"), []byte("v2"), 0644)
	gitCmd(t, localRepo, "add", "code.txt")
	gitCmd(t, localRepo, "commit", "-m", "second version")
	gitCmd(t, localRepo, "push", "origin", "cool-feature")

	// List patches — should see the branch as available
	resp := ts.get(t, c, "/patchtest/patches")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "cool-feature")

	// Create a patch from the branch
	resp = ts.postForm(t, c, "/patchtest/patches/new", url.Values{
		"title":  {"Add cool feature"},
		"body":   {"This makes code.txt say v2"},
		"branch": {"cool-feature"},
	})
	assertStatus(t, resp, http.StatusOK)
	b2 := body(t, resp)
	// Should show the diff
	if !strings.Contains(b2, "v2") || !strings.Contains(b2, "v1") {
		t.Error("patch view should show the diff between v1 and v2")
	}

	// Extract patch ID from URL (redirected to /patchtest/patches/<id>)
	// We can look at the final URL
	resp = ts.get(t, c, "/patchtest/patches")
	assertStatus(t, resp, http.StatusOK)
	patchBody := body(t, resp)
	// It should list an open patch
	if !strings.Contains(patchBody, "Add cool feature") {
		t.Error("patch list should show 'Add cool feature'")
	}

	// Merge the patch
	// We need to find the patch ID. Let's list patches and parse.
	// For now, we know there's only one patch; we can load it from the filesystem.
	rp := repoPath(ts.cfg, "patchtest")
	patches, _ := listPatches(rp, "open")
	if len(patches) != 1 {
		t.Fatalf("expected 1 open patch, got %d", len(patches))
	}
	patchID := patches[0].ID

	resp = ts.postForm(t, c, "/patchtest/patches/"+patchID+"/merge", url.Values{})
	assertStatus(t, resp, http.StatusOK)

	// After merge, main should contain v2
	resp = ts.get(t, c, "/patchtest/blob/main/code.txt")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "v2")
}

// TestThreadsFlow exercises creating, replying, closing, and reopening a thread.
func TestThreadsFlow(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "alice", "pass123")
	ts.createRepo(t, c, "chat", "Thread test")

	// Create a thread
	resp := ts.postForm(t, c, "/chat/threads/new", url.Values{
		"title": {"Hello world"},
		"body":  {"First post body here."},
	})
	assertStatus(t, resp, http.StatusOK)

	// Should be on the thread view page
	b := body(t, resp)
	if !strings.Contains(b, "Hello world") {
		t.Error("thread view should show the title")
	}
	if !strings.Contains(b, "First post body") {
		t.Error("thread view should show the body")
	}

	// List threads
	resp = ts.get(t, c, "/chat/threads")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "Hello world")

	// Extract the thread ID from the list
	rp := repoPath(ts.cfg, "chat")
	threads, _, _ := listThreadsFiltered(rp, "open")
	if len(threads) != 1 {
		t.Fatalf("expected 1 open thread, got %d", len(threads))
	}
	threadID := threads[0].ID

	// Reply to the thread
	resp = ts.postForm(t, c, "/chat/threads/"+threadID+"/reply", url.Values{
		"body": {"A helpful reply."},
	})
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "A helpful reply")

	// Close the thread
	resp = ts.postForm(t, c, "/chat/threads/"+threadID+"/close", url.Values{
		"body": {"Closing with a note."},
	})
	assertStatus(t, resp, http.StatusOK)
	b = body(t, resp)
	if !strings.Contains(b, "closed") && !strings.Contains(b, "Closed") {
		t.Error("closed thread should indicate closed state")
	}

	// Reopen
	resp = ts.postForm(t, c, "/chat/threads/"+threadID+"/reopen", url.Values{
		"body": {"Reopening."},
	})
	assertStatus(t, resp, http.StatusOK)
	b = body(t, resp)
	if strings.Contains(b, "Reopen") && strings.Contains(b, "Reopening") {
		// Good — the reopen comment should be visible
	}

	// Closed threads list
	resp = ts.get(t, c, "/chat/threads?state=closed")
	assertStatus(t, resp, http.StatusOK)
}

// TestRepoProperties verifies description and visibility in various views.
func TestRepoProperties(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "proj", "A very nice project")

	// Push something so the repo isn't empty
	localDir := t.TempDir()
	gitCmd(t, localDir, "clone", "file://"+filepath.Join(ts.dir, "proj.git"), "repo")
	localRepo := filepath.Join(localDir, "repo")
	os.WriteFile(filepath.Join(localRepo, "README.md"), []byte("# proj"), 0644)
	gitCmd(t, localRepo, "add", "README.md")
	gitCmd(t, localRepo, "commit", "-m", "init")
	gitCmd(t, localRepo, "push", "origin", "main")

	// Description should appear in the tree view
	resp := ts.get(t, c, "/proj")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "A very nice project")

	// Repo list should also show it
	resp = ts.get(t, c, "/")
	assertStatus(t, resp, http.StatusOK)
	b := body(t, resp)
	if !strings.Contains(b, "proj") {
		t.Error("repo list should contain proj")
	}
	if !strings.Contains(b, "A very nice project") {
		t.Error("repo list should contain description")
	}
}

// TestSettingsPage verifies that settings can be changed.
func TestSettingsPage(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "configurable", "Initial desc")

	// Check settings page loads
	resp := ts.get(t, c, "/configurable/settings")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "Initial desc")

	// Update description and set private
	resp = ts.postForm(t, c, "/configurable/settings", url.Values{
		"description": {"Updated description"},
		"is_private":  {"true"},
	})
	assertStatus(t, resp, http.StatusOK)

	// Verify description changed
	resp = ts.get(t, c, "/configurable")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "Updated description")
}

// TestCloneURLsInEmptyRepo checks that clone URLs are present on empty repo pages.
func TestCloneURLsInEmptyRepo(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "clone-me", "Clone test")

	// With no host set, clone URLs are empty strings; the template still renders.
	resp := ts.get(t, c, "/clone-me")
	assertStatus(t, resp, http.StatusOK)
	// The page should at least render without errors.
	b := body(t, resp)
	if !strings.Contains(b, "clone-me") {
		t.Error("repo page should display repo name")
	}

	// The repo should exist on disk and be a valid bare git repo
	repoPath := filepath.Join(ts.dir, "clone-me.git")
	if _, err := os.Stat(repoPath); os.IsNotExist(err) {
		t.Fatal("bare repo was not created")
	}
	// git should recognize it as a bare repo
	cmd := exec.Command("git", "rev-parse", "--is-bare-repository")
	cmd.Dir = repoPath
	out, err := cmd.Output()
	if err != nil {
		t.Fatalf("not a valid git repo: %v", err)
	}
	if strings.TrimSpace(string(out)) != "true" {
		t.Error("repo should be bare")
	}
}

// TestAnonymousAccess verifies that unauthenticated users see repos but need login for private ones.
func TestAnonymousAccess(t *testing.T) {
	ts := newTestServer(t)
	adminClient := ts.client()
	ts.setupUser(t, adminClient, "admin", "admin123")
	ts.createRepo(t, adminClient, "public-repo", "Everyone can see")
	ts.createRepo(t, adminClient, "secret-repo", "Private stuff")

	// Make secret-repo private
	ts.postForm(t, adminClient, "/secret-repo/settings", url.Values{
		"description": {"Private stuff"},
		"is_private":  {"true"},
	})

	// Anonymous client
	anonClient := ts.client()

	// Anon can see public repo
	resp := ts.get(t, anonClient, "/public-repo")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "Everyone can see")

	// Anon is redirected away from private repo
	resp = ts.get(t, anonClient, "/secret-repo")
	// Should redirect to login
	assertStatus(t, resp, http.StatusOK) // 200 because it's a redirect followed by client
	b := body(t, resp)
	// Should be on login page, not the repo
	if strings.Contains(b, "Private stuff") {
		t.Error("anonymous user should not see private repo content")
	}

	// Repo list for anon should only show public repos
	resp = ts.get(t, anonClient, "/")
	assertStatus(t, resp, http.StatusOK)
	b = body(t, resp)
	if !strings.Contains(b, "public-repo") {
		t.Error("repo list should include public-repo")
	}
	if strings.Contains(b, "secret-repo") {
		t.Error("repo list should NOT include secret-repo for anonymous")
	}
}

// TestLoginLogout verifies the login/logout flow.
func TestLoginLogout(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()

	// Setup first user
	ts.setupUser(t, c, "tester", "passw0rd")

	// Logout
	resp := ts.postForm(t, c, "/logout", url.Values{})
	assertStatus(t, resp, http.StatusOK)

	// Now we should need to login again
	resp = ts.get(t, c, "/new")
	assertStatus(t, resp, http.StatusOK)
	b := body(t, resp)
	// Should be on login page
	if strings.Contains(b, "Create") && !strings.Contains(b, "login") && !strings.Contains(b, "Login") {
		t.Error("after logout, /new should require login")
	}

	// Login
	ts.login(t, c, "tester", "passw0rd")

	// Now /new should work
	resp = ts.get(t, c, "/new")
	assertStatus(t, resp, http.StatusOK)
	assertContains(t, resp, "create") // the new repo form
}