fierj
PublicTiny personal git forge
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
}