package main import ( "fmt" "html/template" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "path/filepath" "strings" "testing" ) // --- Test helpers --- // testServer creates a server backed by a real git repository with sample content. func testServer(t *testing.T) *server { t.Helper() tmpDir := t.TempDir() // Create a bare repo. repoDir := filepath.Join(tmpDir, "testrepo.git") gitRun(t, "", "init", "--bare", repoDir) // Mark it public and set metadata. os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644) os.WriteFile(filepath.Join(repoDir, "description"), []byte("A test repository"), 0644) os.WriteFile(filepath.Join(repoDir, "owner"), []byte("tester"), 0644) // Clone, add content, push. workDir := filepath.Join(tmpDir, "work") gitRun(t, "", "clone", repoDir, workDir) gitRun(t, workDir, "config", "user.email", "test@example.com") gitRun(t, workDir, "config", "user.name", "Test User") // Create files in initial commit. os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test Repo\n\nHello world.\n"), 0644) os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0644) os.MkdirAll(filepath.Join(workDir, "pkg"), 0755) os.WriteFile(filepath.Join(workDir, "pkg", "lib.go"), []byte("package pkg\n"), 0644) gitRun(t, workDir, "add", ".") gitRun(t, workDir, "commit", "-m", "Initial commit") gitRun(t, workDir, "push", "origin", "HEAD") // Second commit. os.WriteFile(filepath.Join(workDir, "extra.txt"), []byte("extra content\n"), 0644) gitRun(t, workDir, "add", ".") gitRun(t, workDir, "commit", "-m", "Add extra file") gitRun(t, workDir, "push", "origin", "HEAD") // Tag. gitRun(t, workDir, "tag", "-a", "v1.0", "-m", "Release v1.0") gitRun(t, workDir, "push", "origin", "v1.0") // Feature branch with slash in name. gitRun(t, workDir, "checkout", "-b", "feature/test") os.WriteFile(filepath.Join(workDir, "feature.txt"), []byte("feature content\n"), 0644) gitRun(t, workDir, "add", ".") gitRun(t, workDir, "commit", "-m", "Add feature") gitRun(t, workDir, "push", "origin", "feature/test") git := newGitCLIBackend(repoDir) defaultBranch := git.getDefaultBranch() commit, _ := git.getCommit(defaultBranch) repo := &RepoInfo{ Name: "testrepo", Path: repoDir, GitDir: repoDir, Description: "A test repository", Owner: "tester", Git: git, } if commit != nil { repo.LastUpdated = commit.AuthorDate } tmpl, err := loadTemplates() if err != nil { t.Fatalf("loadTemplates: %v", err) } db, err := openDB(filepath.Join(tmpDir, "test.db")) if err != nil { t.Fatalf("openDB: %v", err) } t.Cleanup(func() { db.Close() }) return &server{ repos: map[string]*RepoInfo{"testrepo": repo}, sorted: []string{"testrepo"}, tmpl: tmpl, db: db, title: "Test Forge", description: "test site description", baseURL: "", scanPath: tmpDir, } } func gitRun(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) if dir != "" { cmd.Dir = dir } cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", ) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("git %v (dir=%s): %v\n%s", args, dir, err, out) } } // get sends a GET through the route dispatcher. func get(t *testing.T, srv *server, path string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() srv.route(w, req) return w } // getWithCookie sends a GET with a session cookie. func getWithCookie(t *testing.T, srv *server, path, handle string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, path, nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)}) w := httptest.NewRecorder() srv.route(w, req) return w } // postForm sends a POST with form data through the route dispatcher. func postForm(t *testing.T, srv *server, path string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() srv.route(w, req) return w } // postFormWithCookie sends a POST with form data and a session cookie. func postFormWithCookie(t *testing.T, srv *server, path string, form url.Values, handle string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)}) w := httptest.NewRecorder() srv.route(w, req) return w } // getMux sends a GET through the full mux (including static asset routes). func getMux(t *testing.T, srv *server, path string) *httptest.ResponseRecorder { t.Helper() mux := http.NewServeMux() mux.HandleFunc("/style.css", srv.serveCSS) mux.HandleFunc("/radiant.svg", srv.serveLogo) mux.HandleFunc("/js/", srv.serveJS) mux.HandleFunc("/fonts/", srv.serveFont) mux.HandleFunc("/", srv.route) req := httptest.NewRequest(http.MethodGet, path, nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) return w } // defaultRef returns the default branch name for the test repo. func defaultRef(t *testing.T, srv *server) string { t.Helper() return srv.repos["testrepo"].Git.getDefaultBranch() } // latestHash returns the latest commit hash on the default branch. func latestHash(t *testing.T, srv *server) string { t.Helper() git := srv.repos["testrepo"].Git h, err := git.resolveRef(git.getDefaultBranch()) if err != nil { t.Fatalf("resolveRef: %v", err) } return h } // initialHash returns the hash of the "Initial commit". func initialHash(t *testing.T, srv *server) string { t.Helper() git := srv.repos["testrepo"].Git h, _ := git.resolveRef(git.getDefaultBranch()) commits, _, _ := git.getLog(h, 0, 50) for _, c := range commits { if c.Subject == "Initial commit" { return c.Hash } } t.Fatal("initial commit not found") return "" } func assertCode(t *testing.T, w *httptest.ResponseRecorder, want int) { t.Helper() if w.Code != want { n := w.Body.Len() if n > 500 { n = 500 } t.Fatalf("expected status %d, got %d\nbody: %s", want, w.Code, w.Body.String()[:n]) } } func assertContains(t *testing.T, w *httptest.ResponseRecorder, substr string) { t.Helper() if !strings.Contains(w.Body.String(), substr) { n := w.Body.Len() if n > 1000 { n = 1000 } t.Errorf("expected body to contain %q\nbody: %s", substr, w.Body.String()[:n]) } } func assertNotContains(t *testing.T, w *httptest.ResponseRecorder, substr string) { t.Helper() if strings.Contains(w.Body.String(), substr) { t.Errorf("expected body NOT to contain %q", substr) } } func assertHeader(t *testing.T, w *httptest.ResponseRecorder, key, wantSubstr string) { t.Helper() got := w.Header().Get(key) if !strings.Contains(got, wantSubstr) { t.Errorf("expected header %s to contain %q, got %q", key, wantSubstr, got) } } func assertRedirect(t *testing.T, w *httptest.ResponseRecorder, code int, locSubstr string) { t.Helper() assertCode(t, w, code) loc := w.Header().Get("Location") if !strings.Contains(loc, locSubstr) { t.Errorf("expected Location to contain %q, got %q", locSubstr, loc) } } // =================================================================== // Static assets // =================================================================== func TestServeCSS(t *testing.T) { srv := testServer(t) w := getMux(t, srv, "/style.css") assertCode(t, w, 200) assertHeader(t, w, "Content-Type", "text/css") assertHeader(t, w, "Cache-Control", "public") if w.Body.Len() == 0 { t.Error("empty CSS body") } } func TestServeLogo(t *testing.T) { srv := testServer(t) w := getMux(t, srv, "/radiant.svg") assertCode(t, w, 200) assertHeader(t, w, "Content-Type", "image/svg+xml") assertHeader(t, w, "Cache-Control", "public") if w.Body.Len() == 0 { t.Error("empty logo body") } } func TestServeJS_Known(t *testing.T) { srv := testServer(t) for _, name := range []string{"hirad.js", "hiril.js"} { w := getMux(t, srv, "/js/"+name) assertCode(t, w, 200) assertHeader(t, w, "Content-Type", "javascript") assertHeader(t, w, "Cache-Control", "public") if w.Body.Len() == 0 { t.Errorf("empty body for %s", name) } } } func TestServeJS_Unknown(t *testing.T) { srv := testServer(t) w := getMux(t, srv, "/js/unknown.js") assertCode(t, w, 404) } func TestServeFont_Known(t *testing.T) { srv := testServer(t) w := getMux(t, srv, "/fonts/RethinkSans.ttf") assertCode(t, w, 200) assertHeader(t, w, "Content-Type", "font/ttf") assertHeader(t, w, "Cache-Control", "public") if w.Body.Len() == 0 { t.Error("empty font body") } } func TestServeFont_Unknown(t *testing.T) { srv := testServer(t) w := getMux(t, srv, "/fonts/nonexistent.ttf") assertCode(t, w, 404) } // =================================================================== // Index page // =================================================================== func TestIndex(t *testing.T) { srv := testServer(t) w := get(t, srv, "/") assertCode(t, w, 200) assertContains(t, w, "testrepo") assertContains(t, w, "A test repository") assertContains(t, w, "Test Forge") assertContains(t, w, "test site description") // Footer assertContains(t, w, "Radiant Forge") } func TestIndex_Empty(t *testing.T) { srv := testServer(t) srv.repos = map[string]*RepoInfo{} srv.sorted = nil w := get(t, srv, "/") assertCode(t, w, 200) assertContains(t, w, "No repositories found") } func TestIndex_SignInLink(t *testing.T) { srv := testServer(t) w := get(t, srv, "/") assertCode(t, w, 200) assertContains(t, w, "Sign in") } func TestIndex_LoggedInShowsAvatar(t *testing.T) { srv := testServer(t) upsertAvatar(srv.db, "alice", "https://example.com/alice.png") w := getWithCookie(t, srv, "/", "alice") assertCode(t, w, 200) assertContains(t, w, "https://example.com/alice.png") assertContains(t, w, "Sign out") } // =================================================================== // Repo summary (home) // =================================================================== func TestSummary(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/") assertCode(t, w, 200) assertContains(t, w, "testrepo") assertContains(t, w, "A test repository") // Navigation tabs assertContains(t, w, "home") assertContains(t, w, "log") assertContains(t, w, "refs") assertContains(t, w, "discussions") // File tree assertContains(t, w, "README.md") assertContains(t, w, "main.go") assertContains(t, w, "pkg") assertContains(t, w, "extra.txt") // README content assertContains(t, w, "Hello world") // Last commit assertContains(t, w, "Add extra file") assertContains(t, w, "Test User") // Clone URLs assertContains(t, w, "testrepo.git") // No footer on repo pages assertNotContains(t, w, "Powered by") } func TestSummary_NoTrailingSlash(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo") assertCode(t, w, 200) assertContains(t, w, "testrepo") } func TestSummary_BranchSelector(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/") assertCode(t, w, 200) assertContains(t, w, "feature/test") } func TestSummary_CloneHTTPS_XForwardedProto(t *testing.T) { srv := testServer(t) req := httptest.NewRequest(http.MethodGet, "/testrepo/", nil) req.Header.Set("X-Forwarded-Proto", "https") w := httptest.NewRecorder() srv.route(w, req) assertCode(t, w, 200) assertContains(t, w, "https://") } // =================================================================== // Refs page // =================================================================== func TestRefs(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/refs") assertCode(t, w, 200) assertContains(t, w, "feature/test") assertContains(t, w, "v1.0") assertContains(t, w, "Branch") assertContains(t, w, "Tag") } // =================================================================== // Log page // =================================================================== func TestLog_Default(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/log/") assertCode(t, w, 200) assertContains(t, w, "Initial commit") assertContains(t, w, "Add extra file") assertContains(t, w, "Test User") assertContains(t, w, "Hash") assertContains(t, w, "Subject") } func TestLog_SpecificRef(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/log/feature/test") assertCode(t, w, 200) assertContains(t, w, "Add feature") assertContains(t, w, "Initial commit") } func TestLog_Pagination(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/log/?page=0") assertCode(t, w, 200) assertContains(t, w, "Initial commit") } func TestLog_HighPage(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/log/"+ref+"?page=999") assertCode(t, w, 200) assertContains(t, w, "No commits found") } func TestLog_NegativePage(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/log/"+ref+"?page=-1") assertCode(t, w, 200) // Negative clamped to 0, should show commits. assertContains(t, w, "Initial commit") } func TestLog_InvalidRef(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/log/nonexistent-ref") assertCode(t, w, 200) // Renders the empty log page. } func TestLog_BranchSelector(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/log/") assertCode(t, w, 200) assertContains(t, w, "feature/test") } // =================================================================== // Tree page // =================================================================== func TestTree_Root(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/tree/"+ref) assertCode(t, w, 200) assertContains(t, w, "main.go") assertContains(t, w, "pkg") assertContains(t, w, "README.md") } func TestTree_Directory(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/tree/"+ref+"/pkg") assertCode(t, w, 200) assertContains(t, w, "lib.go") } func TestTree_File(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/tree/"+ref+"/main.go") assertCode(t, w, 200) assertContains(t, w, "package main") assertContains(t, w, "func main()") assertContains(t, w, "main.go") assertContains(t, w, "/raw/") } func TestTree_NestedFile(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/tree/"+ref+"/pkg/lib.go") assertCode(t, w, 200) assertContains(t, w, "package pkg") } func TestTree_BranchWithSlash(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/tree/feature/test") assertCode(t, w, 200) assertContains(t, w, "feature.txt") } func TestTree_BranchWithSlash_File(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/tree/feature/test/feature.txt") assertCode(t, w, 200) assertContains(t, w, "feature content") } func TestTree_EmptyRest(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/tree/") assertCode(t, w, 200) assertContains(t, w, "main.go") } func TestTree_InvalidRef(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/tree/nonexistent-ref") assertCode(t, w, 404) assertContains(t, w, "Ref not found") } func TestTree_FileNotFound(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/tree/"+ref+"/no-such-file.txt") assertCode(t, w, 404) assertContains(t, w, "File not found") } func TestTree_LineNumbers(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/tree/"+ref+"/main.go") assertCode(t, w, 200) assertContains(t, w, `id="L1"`) assertContains(t, w, `href="#L1"`) } func TestTree_FileShowsSize(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/tree/"+ref+"/main.go") assertCode(t, w, 200) // formatSize should produce "X B" for small files. assertContains(t, w, " B") } // =================================================================== // Commit page // =================================================================== func TestCommit(t *testing.T) { srv := testServer(t) hash := latestHash(t, srv) w := get(t, srv, "/testrepo/commit/"+hash) assertCode(t, w, 200) assertContains(t, w, "Add extra file") assertContains(t, w, "Test User") assertContains(t, w, hash) assertContains(t, w, "extra.txt") assertContains(t, w, "parent") } func TestCommit_Initial(t *testing.T) { srv := testServer(t) hash := initialHash(t, srv) w := get(t, srv, "/testrepo/commit/"+hash) assertCode(t, w, 200) assertContains(t, w, "Initial commit") assertContains(t, w, "README.md") assertContains(t, w, "main.go") } func TestCommit_NotFound(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef") assertCode(t, w, 404) assertContains(t, w, "Commit not found") } func TestCommit_CommitHashInNav(t *testing.T) { srv := testServer(t) hash := latestHash(t, srv) w := get(t, srv, "/testrepo/commit/"+hash) assertCode(t, w, 200) assertContains(t, w, hash[:8]) } func TestCommit_DiffStats(t *testing.T) { srv := testServer(t) hash := latestHash(t, srv) w := get(t, srv, "/testrepo/commit/"+hash) assertCode(t, w, 200) // Diff stats: +N -N assertContains(t, w, "added") } // =================================================================== // Raw file download // =================================================================== func TestRaw(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/raw/"+ref+"/main.go") assertCode(t, w, 200) assertHeader(t, w, "Content-Type", "text/plain") if !strings.Contains(w.Body.String(), "package main") { t.Error("expected raw file content") } cl := w.Header().Get("Content-Length") if cl == "" || cl == "0" { t.Error("expected non-zero Content-Length") } } func TestRaw_Markdown(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/raw/"+ref+"/README.md") assertCode(t, w, 200) assertHeader(t, w, "Content-Type", "text/plain") assertContains(t, w, "# Test Repo") } func TestRaw_NoPath(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/raw/") assertCode(t, w, 404) assertContains(t, w, "No path specified") } func TestRaw_RefOnly(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/raw/"+ref) assertCode(t, w, 404) assertContains(t, w, "No file path") } func TestRaw_InvalidRef(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/raw/nonexistent/file.txt") assertCode(t, w, 404) assertContains(t, w, "Ref not found") } func TestRaw_FileNotFound(t *testing.T) { srv := testServer(t) ref := defaultRef(t, srv) w := get(t, srv, "/testrepo/raw/"+ref+"/no-such-file.txt") assertCode(t, w, 404) assertContains(t, w, "File not found") } // =================================================================== // Routing: repo not found, unknown action, .git suffix // =================================================================== func TestRoute_RepoNotFound(t *testing.T) { srv := testServer(t) w := get(t, srv, "/nonexistent/") assertCode(t, w, 404) assertContains(t, w, "Repository not found") } func TestRoute_UnknownAction(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/foobar") assertCode(t, w, 404) assertContains(t, w, "Page not found") } func TestRoute_RepoNameDotGit(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo.git/refs") assertCode(t, w, 200) assertContains(t, w, "feature/test") } // =================================================================== // Base URL prefix // =================================================================== func TestBaseURL_Index(t *testing.T) { srv := testServer(t) srv.baseURL = "/git" req := httptest.NewRequest(http.MethodGet, "/git/", nil) w := httptest.NewRecorder() srv.route(w, req) assertCode(t, w, 200) assertContains(t, w, "/git/testrepo/") } func TestBaseURL_Summary(t *testing.T) { srv := testServer(t) srv.baseURL = "/git" req := httptest.NewRequest(http.MethodGet, "/git/testrepo/", nil) w := httptest.NewRecorder() srv.route(w, req) assertCode(t, w, 200) assertContains(t, w, "/git/testrepo/") } func TestBaseURL_LinksInRepoPages(t *testing.T) { srv := testServer(t) srv.baseURL = "/code" req := httptest.NewRequest(http.MethodGet, "/code/testrepo/refs", nil) w := httptest.NewRecorder() srv.route(w, req) assertCode(t, w, 200) assertContains(t, w, "/code/testrepo/log/") assertContains(t, w, "/code/testrepo/commit/") } // =================================================================== // Basic auth // =================================================================== func TestBasicAuth_NoCredentials(t *testing.T) { srv := testServer(t) srv.username = "admin" srv.password = "secret" mux := http.NewServeMux() mux.HandleFunc("/", srv.route) handler := srv.basicAuth(mux) req := httptest.NewRequest(http.MethodGet, "/", nil) w := httptest.NewRecorder() handler.ServeHTTP(w, req) assertCode(t, w, 401) assertHeader(t, w, "WWW-Authenticate", "Basic") } func TestBasicAuth_WrongCredentials(t *testing.T) { srv := testServer(t) srv.username = "admin" srv.password = "secret" mux := http.NewServeMux() mux.HandleFunc("/", srv.route) handler := srv.basicAuth(mux) req := httptest.NewRequest(http.MethodGet, "/", nil) req.SetBasicAuth("admin", "wrong") w := httptest.NewRecorder() handler.ServeHTTP(w, req) assertCode(t, w, 401) } func TestBasicAuth_CorrectCredentials(t *testing.T) { srv := testServer(t) srv.username = "admin" srv.password = "secret" mux := http.NewServeMux() mux.HandleFunc("/", srv.route) handler := srv.basicAuth(mux) req := httptest.NewRequest(http.MethodGet, "/", nil) req.SetBasicAuth("admin", "secret") w := httptest.NewRecorder() handler.ServeHTTP(w, req) assertCode(t, w, 200) } // =================================================================== // Error page // =================================================================== func TestRenderError(t *testing.T) { srv := testServer(t) req := httptest.NewRequest(http.MethodGet, "/bad-path", nil) w := httptest.NewRecorder() srv.renderError(w, req, 404, "Something went wrong") assertCode(t, w, 404) assertContains(t, w, "Something went wrong") assertContains(t, w, "404") assertContains(t, w, "/bad-path") assertContains(t, w, "Back to index") } // =================================================================== // Discussions // =================================================================== func TestDiscussions_ListEmpty(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/discussions") assertCode(t, w, 200) assertContains(t, w, "No discussions yet") assertContains(t, w, "discussions") } func TestDiscussions_ListWithItems(t *testing.T) { srv := testServer(t) createDiscussion(srv.db, "testrepo", "First Thread", "body1", "alice") createDiscussion(srv.db, "testrepo", "Second Thread", "body2", "bob") w := get(t, srv, "/testrepo/discussions") assertCode(t, w, 200) assertContains(t, w, "First Thread") assertContains(t, w, "Second Thread") assertContains(t, w, "alice") assertContains(t, w, "bob") assertNotContains(t, w, "No discussions yet") } func TestDiscussions_ListShowsReplyCount(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "With Replies", "", "alice") createReply(srv.db, int(id), "reply 1", "bob") createReply(srv.db, int(id), "reply 2", "carol") w := get(t, srv, "/testrepo/discussions") assertCode(t, w, 200) assertContains(t, w, ">2<") } func TestDiscussions_ListOnlyShowsCurrentRepo(t *testing.T) { srv := testServer(t) createDiscussion(srv.db, "testrepo", "Mine", "", "alice") createDiscussion(srv.db, "other", "Not Mine", "", "bob") w := get(t, srv, "/testrepo/discussions") assertCode(t, w, 200) assertContains(t, w, "Mine") assertNotContains(t, w, "Not Mine") } func TestDiscussions_ListShowsNewButtonWhenLoggedIn(t *testing.T) { srv := testServer(t) w := getWithCookie(t, srv, "/testrepo/discussions", "alice") assertCode(t, w, 200) assertContains(t, w, "New discussion") } func TestDiscussions_ListHidesNewButtonWhenLoggedOut(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/discussions") assertCode(t, w, 200) assertNotContains(t, w, "New discussion") } func TestDiscussions_NewRedirectsWhenNotLoggedIn(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/discussions/new") assertRedirect(t, w, 303, "login") } func TestDiscussions_NewPageWhenLoggedIn(t *testing.T) { srv := testServer(t) w := getWithCookie(t, srv, "/testrepo/discussions/new", "alice") assertCode(t, w, 200) assertContains(t, w, "New discussion") assertContains(t, w, "Title") assertContains(t, w, "Create discussion") } func TestDiscussions_CreateSuccess(t *testing.T) { srv := testServer(t) form := url.Values{"title": {"My Discussion"}, "body": {"Discussion body."}} w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice") assertRedirect(t, w, 303, "/testrepo/discussions/") // Verify persisted. list := listDiscussions(srv.db, "testrepo") if len(list) != 1 { t.Fatalf("expected 1, got %d", len(list)) } if list[0].Title != "My Discussion" { t.Errorf("title: got %q", list[0].Title) } if list[0].Author != "alice" { t.Errorf("author: got %q", list[0].Author) } } func TestDiscussions_CreateEmptyTitle(t *testing.T) { srv := testServer(t) form := url.Values{"title": {""}, "body": {"some body"}} w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice") assertCode(t, w, 200) assertContains(t, w, "Title is required") } func TestDiscussions_CreateNotLoggedIn(t *testing.T) { srv := testServer(t) form := url.Values{"title": {"Title"}, "body": {"body"}} w := postForm(t, srv, "/testrepo/discussions/new", form) assertRedirect(t, w, 303, "login") } func TestDiscussions_ViewDiscussion(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "View Me", "Look at this body", "alice") createReply(srv.db, int(id), "A thoughtful reply", "bob") w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id)) assertCode(t, w, 200) assertContains(t, w, "View Me") assertContains(t, w, "Look at this body") assertContains(t, w, "alice") assertContains(t, w, "A thoughtful reply") assertContains(t, w, "bob") assertContains(t, w, "Sign in") } func TestDiscussions_ViewDiscussionLoggedIn(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "Logged In View", "", "alice") w := getWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), "bob") assertCode(t, w, 200) assertContains(t, w, "Reply as") assertContains(t, w, "bob") assertContains(t, w, "Post reply") } func TestDiscussions_ViewNotFound(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/discussions/99999") assertCode(t, w, 404) assertContains(t, w, "Discussion not found") } func TestDiscussions_ViewInvalidID(t *testing.T) { srv := testServer(t) w := get(t, srv, "/testrepo/discussions/abc") assertCode(t, w, 404) assertContains(t, w, "Discussion not found") } func TestDiscussions_ViewWrongRepo(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "otherrepo", "Wrong Repo", "", "alice") w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id)) assertCode(t, w, 404) assertContains(t, w, "Discussion not found") } func TestDiscussions_Reply(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice") form := url.Values{"body": {"My reply text"}} w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob") assertRedirect(t, w, 303, fmt.Sprintf("/testrepo/discussions/%d#reply-", id)) replies := getReplies(srv.db, int(id)) if len(replies) != 1 { t.Fatalf("expected 1 reply, got %d", len(replies)) } if replies[0].Body != "My reply text" { t.Errorf("body: got %q", replies[0].Body) } if replies[0].Author != "bob" { t.Errorf("author: got %q", replies[0].Author) } } func TestDiscussions_ReplyEmptyBody(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice") form := url.Values{"body": {""}} w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob") assertCode(t, w, 200) assertContains(t, w, "Reply cannot be empty") } func TestDiscussions_ReplyNotLoggedIn(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice") form := url.Values{"body": {"attempt"}} w := postForm(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form) assertCode(t, w, 403) assertContains(t, w, "must be signed in") } // =================================================================== // Login / Logout // =================================================================== func TestLogin_Page(t *testing.T) { srv := testServer(t) w := get(t, srv, "/login") assertCode(t, w, 200) assertContains(t, w, "Sign in with") assertContains(t, w, "Bluesky") assertContains(t, w, "handle") assertContains(t, w, "app_password") } func TestLogin_PageWithReturn(t *testing.T) { srv := testServer(t) w := get(t, srv, "/login?return=/testrepo/discussions") assertCode(t, w, 200) assertContains(t, w, "/testrepo/discussions") } func TestLogin_PostMissingFields(t *testing.T) { srv := testServer(t) form := url.Values{"handle": {""}, "app_password": {""}} w := postForm(t, srv, "/login", form) assertCode(t, w, 200) assertContains(t, w, "Handle and app password are required") } func TestLogin_PostMissingPassword(t *testing.T) { srv := testServer(t) form := url.Values{"handle": {"alice.bsky.social"}, "app_password": {""}} w := postForm(t, srv, "/login", form) assertCode(t, w, 200) assertContains(t, w, "Handle and app password are required") } func TestLogout(t *testing.T) { srv := testServer(t) req := httptest.NewRequest(http.MethodGet, "/logout", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("alice")}) w := httptest.NewRecorder() srv.route(w, req) assertRedirect(t, w, 303, "/") found := false for _, c := range w.Result().Cookies() { if c.Name == sessionCookieName && c.MaxAge < 0 { found = true } } if !found { t.Error("expected session cookie to be cleared") } } func TestLogout_WithReturn(t *testing.T) { srv := testServer(t) req := httptest.NewRequest(http.MethodGet, "/logout?return=/testrepo/", nil) w := httptest.NewRecorder() srv.route(w, req) assertRedirect(t, w, 303, "/testrepo/") } func TestLogout_DefaultReturn(t *testing.T) { srv := testServer(t) req := httptest.NewRequest(http.MethodGet, "/logout", nil) w := httptest.NewRecorder() srv.route(w, req) assertRedirect(t, w, 303, "/") } // =================================================================== // isGitHTTPRequest // =================================================================== func TestIsGitHTTPRequest(t *testing.T) { tests := []struct { path string query string want bool }{ {"/repo/info/refs", "service=git-upload-pack", true}, {"/repo/info/refs", "service=git-receive-pack", true}, {"/repo/info/refs", "", false}, {"/repo/git-upload-pack", "", true}, {"/repo/git-receive-pack", "", true}, {"/repo/HEAD", "", true}, {"/repo/objects/pack/pack-abc.pack", "", true}, {"/repo/refs", "", false}, {"/repo/", "", false}, {"/repo/tree/main", "", false}, } for _, tt := range tests { u := tt.path if tt.query != "" { u += "?" + tt.query } req := httptest.NewRequest(http.MethodGet, u, nil) got := isGitHTTPRequest(req) if got != tt.want { t.Errorf("isGitHTTPRequest(%q, %q) = %v, want %v", tt.path, tt.query, got, tt.want) } } } // =================================================================== // Session sign / verify // =================================================================== func TestSession_SignAndVerify(t *testing.T) { signed := signSession("alice") handle, ok := verifySession(signed) if !ok { t.Fatal("expected valid session") } if handle != "alice" { t.Errorf("expected alice, got %q", handle) } } func TestSession_Garbage(t *testing.T) { if _, ok := verifySession("garbage"); ok { t.Error("expected invalid") } } func TestSession_Tampered(t *testing.T) { signed := signSession("alice") tampered := signed[:len(signed)-2] + "ff" if _, ok := verifySession(tampered); ok { t.Error("expected tampered session to be invalid") } } func TestSession_TwoParts(t *testing.T) { if _, ok := verifySession("a|b"); ok { t.Error("expected invalid with only 2 parts") } } func TestSession_ExpiredTimestamp(t *testing.T) { if _, ok := verifySession("alice|1000000000|fakesig"); ok { t.Error("expected expired session to be invalid") } } func TestSession_BadExpiry(t *testing.T) { if _, ok := verifySession("alice|notanumber|fakesig"); ok { t.Error("expected invalid with bad expiry") } } func TestGetSessionHandle_NoCookie(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) if h := getSessionHandle(req); h != "" { t.Errorf("expected empty, got %q", h) } } func TestGetSessionHandle_ValidCookie(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("bob")}) if h := getSessionHandle(req); h != "bob" { t.Errorf("expected bob, got %q", h) } } func TestGetSessionHandle_InvalidCookie(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "bad"}) if h := getSessionHandle(req); h != "" { t.Errorf("expected empty, got %q", h) } } func TestSetSessionCookie(t *testing.T) { w := httptest.NewRecorder() setSessionCookie(w, "carol") found := false for _, c := range w.Result().Cookies() { if c.Name == sessionCookieName { found = true if !c.HttpOnly { t.Error("expected HttpOnly") } if c.MaxAge <= 0 { t.Error("expected positive MaxAge") } h, ok := verifySession(c.Value) if !ok || h != "carol" { t.Error("cookie value doesn't verify") } } } if !found { t.Error("session cookie not set") } } func TestClearSessionCookie(t *testing.T) { w := httptest.NewRecorder() clearSessionCookie(w) found := false for _, c := range w.Result().Cookies() { if c.Name == sessionCookieName { found = true if c.MaxAge >= 0 { t.Error("expected negative MaxAge") } } } if !found { t.Error("session cookie not set for clearing") } } // =================================================================== // Database CRUD // =================================================================== func TestDB_DiscussionCRUD(t *testing.T) { srv := testServer(t) id, err := createDiscussion(srv.db, "testrepo", "DB Title", "DB Body", "author1") if err != nil { t.Fatalf("createDiscussion: %v", err) } if id == 0 { t.Fatal("expected non-zero ID") } d, err := getDiscussion(srv.db, int(id)) if err != nil { t.Fatalf("getDiscussion: %v", err) } if d.Title != "DB Title" { t.Errorf("title: %q", d.Title) } if d.Body != "DB Body" { t.Errorf("body: %q", d.Body) } if d.Author != "author1" { t.Errorf("author: %q", d.Author) } if d.Repo != "testrepo" { t.Errorf("repo: %q", d.Repo) } } func TestDB_Replies(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "T", "", "a") createReply(srv.db, int(id), "Reply one", "user1") createReply(srv.db, int(id), "Reply two", "user2") replies := getReplies(srv.db, int(id)) if len(replies) != 2 { t.Fatalf("expected 2, got %d", len(replies)) } if replies[0].Body != "Reply one" || replies[0].Author != "user1" { t.Errorf("reply 0: %+v", replies[0]) } if replies[1].Body != "Reply two" || replies[1].Author != "user2" { t.Errorf("reply 1: %+v", replies[1]) } } func TestDB_RepliesEmpty(t *testing.T) { srv := testServer(t) if replies := getReplies(srv.db, 99999); len(replies) != 0 { t.Errorf("expected 0, got %d", len(replies)) } } func TestDB_GetDiscussionNotFound(t *testing.T) { srv := testServer(t) if _, err := getDiscussion(srv.db, 99999); err == nil { t.Error("expected error") } } func TestDB_ReplyCount(t *testing.T) { srv := testServer(t) id, _ := createDiscussion(srv.db, "testrepo", "Count", "", "a") createReply(srv.db, int(id), "r1", "b") createReply(srv.db, int(id), "r2", "c") createReply(srv.db, int(id), "r3", "d") list := listDiscussions(srv.db, "testrepo") if len(list) != 1 { t.Fatalf("expected 1, got %d", len(list)) } if list[0].ReplyCount != 3 { t.Errorf("expected 3 replies, got %d", list[0].ReplyCount) } } func TestDB_AvatarUpsert(t *testing.T) { srv := testServer(t) upsertAvatar(srv.db, "alice", "https://example.com/v1.png") if u := getAvatar(srv.db, "alice"); u != "https://example.com/v1.png" { t.Errorf("got %q", u) } upsertAvatar(srv.db, "alice", "https://example.com/v2.png") if u := getAvatar(srv.db, "alice"); u != "https://example.com/v2.png" { t.Errorf("got %q", u) } } func TestDB_AvatarNotFound(t *testing.T) { srv := testServer(t) if u := getAvatar(srv.db, "nobody"); u != "" { t.Errorf("expected empty, got %q", u) } } func TestDB_AvatarShownInDiscussion(t *testing.T) { srv := testServer(t) upsertAvatar(srv.db, "alice", "https://example.com/alice-av.png") id, _ := createDiscussion(srv.db, "testrepo", "Avatar Test", "body", "alice") w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id)) assertCode(t, w, 200) assertContains(t, w, "https://example.com/alice-av.png") } // =================================================================== // scanRepositories // =================================================================== func TestScanRepositories(t *testing.T) { srv := testServer(t) repos, err := scanRepositories(srv.scanPath, false) if err != nil { t.Fatalf("scanRepositories: %v", err) } if len(repos) != 1 { t.Fatalf("expected 1, got %d", len(repos)) } repo := repos["testrepo"] if repo == nil { t.Fatal("expected 'testrepo'") } if repo.Description != "A test repository" { t.Errorf("description: %q", repo.Description) } if repo.Owner != "tester" { t.Errorf("owner: %q", repo.Owner) } if repo.LastUpdated.IsZero() { t.Error("expected non-zero LastUpdated") } } func TestScanRepositories_SkipsPrivate(t *testing.T) { tmpDir := t.TempDir() gitRun(t, "", "init", "--bare", filepath.Join(tmpDir, "private.git")) repos, _ := scanRepositories(tmpDir, false) if len(repos) != 0 { t.Errorf("expected 0, got %d", len(repos)) } } func TestScanRepositories_SkipsHiddenDirs(t *testing.T) { tmpDir := t.TempDir() repoDir := filepath.Join(tmpDir, ".hidden.git") gitRun(t, "", "init", "--bare", repoDir) os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644) repos, _ := scanRepositories(tmpDir, false) if len(repos) != 0 { t.Errorf("expected 0, got %d", len(repos)) } } func TestScanRepositories_SkipsFiles(t *testing.T) { tmpDir := t.TempDir() os.WriteFile(filepath.Join(tmpDir, "not-a-dir"), []byte("x"), 0644) repos, _ := scanRepositories(tmpDir, false) if len(repos) != 0 { t.Errorf("expected 0, got %d", len(repos)) } } func TestScanRepositories_NonBare(t *testing.T) { tmpDir := t.TempDir() workDir := filepath.Join(tmpDir, "myrepo") gitRun(t, "", "init", workDir) os.WriteFile(filepath.Join(workDir, ".git", "public"), nil, 0644) repos, _ := scanRepositories(tmpDir, false) if len(repos) != 0 { t.Errorf("expected 0 without flag, got %d", len(repos)) } repos, _ = scanRepositories(tmpDir, true) if len(repos) != 1 { t.Fatalf("expected 1 with flag, got %d", len(repos)) } } func TestScanRepositories_StripsGitSuffix(t *testing.T) { srv := testServer(t) repos, _ := scanRepositories(srv.scanPath, false) if _, ok := repos["testrepo"]; !ok { t.Error("expected .git suffix stripped") } } func TestScanRepositories_DefaultDescription(t *testing.T) { tmpDir := t.TempDir() repoDir := filepath.Join(tmpDir, "repo.git") gitRun(t, "", "init", "--bare", repoDir) os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644) repos, _ := scanRepositories(tmpDir, false) if r, ok := repos["repo"]; ok && r.Description != "" { t.Errorf("default description should be empty, got %q", r.Description) } } // =================================================================== // isBareRepo / isWorkTreeRepo // =================================================================== func TestIsBareRepo(t *testing.T) { tmpDir := t.TempDir() repoDir := filepath.Join(tmpDir, "bare.git") gitRun(t, "", "init", "--bare", repoDir) if !isBareRepo(repoDir) { t.Error("expected bare repo") } if isBareRepo(tmpDir) { t.Error("tmpDir is not bare") } } func TestIsWorkTreeRepo(t *testing.T) { tmpDir := t.TempDir() workDir := filepath.Join(tmpDir, "work") gitRun(t, "", "init", workDir) if !isWorkTreeRepo(workDir) { t.Error("expected work tree repo") } if isWorkTreeRepo(tmpDir) { t.Error("tmpDir is not work tree") } } // =================================================================== // Template FuncMap // =================================================================== func TestFuncMap_ShortHash(t *testing.T) { fn := funcMap["shortHash"].(func(string) string) if got := fn("abcdef1234567890"); got != "abcdef12" { t.Errorf("got %q", got) } if got := fn("abc"); got != "abc" { t.Errorf("got %q", got) } if got := fn(""); got != "" { t.Errorf("got %q", got) } } func TestFuncMap_StatusLabel(t *testing.T) { fn := funcMap["statusLabel"].(func(string) string) cases := map[string]string{"A": "added", "D": "deleted", "M": "modified", "R": "renamed", "X": "X"} for in, want := range cases { if got := fn(in); got != want { t.Errorf("statusLabel(%q) = %q, want %q", in, got, want) } } } func TestFuncMap_FormatSize(t *testing.T) { fn := funcMap["formatSize"].(func(int64) string) if got := fn(500); got != "500 B" { t.Errorf("got %q", got) } if got := fn(2048); !strings.Contains(got, "KiB") { t.Errorf("got %q", got) } if got := fn(2 * 1024 * 1024); !strings.Contains(got, "MiB") { t.Errorf("got %q", got) } } func TestFuncMap_DiffFileName(t *testing.T) { fn := funcMap["diffFileName"].(func(DiffFile) string) if got := fn(DiffFile{OldName: "old.go", NewName: "new.go"}); got != "new.go" { t.Errorf("got %q", got) } if got := fn(DiffFile{OldName: "old.go", NewName: ""}); got != "old.go" { t.Errorf("got %q", got) } } func TestFuncMap_ParentPath(t *testing.T) { fn := funcMap["parentPath"].(func(string) string) if got := fn("a/b/c"); got != "a/b" { t.Errorf("got %q", got) } if got := fn("file.go"); got != "" { t.Errorf("got %q", got) } } func TestFuncMap_Indent(t *testing.T) { fn := funcMap["indent"].(func(int) string) if got := fn(0); got != "" { t.Errorf("got %q", got) } if got := fn(2); !strings.Contains(got, "padding-left") { t.Errorf("got %q", got) } } func TestFuncMap_LangClass(t *testing.T) { fn := funcMap["langClass"].(func(string) string) if got := fn("file.rad"); got != "language-radiance" { t.Errorf("got %q", got) } if got := fn("file.ril"); got != "language-ril" { t.Errorf("got %q", got) } if got := fn("file.go"); got != "" { t.Errorf("got %q", got) } } func TestFuncMap_Add(t *testing.T) { fn := funcMap["add"].(func(int, int) int) if got := fn(3, 4); got != 7 { t.Errorf("got %d", got) } } func TestFuncMap_Autolink(t *testing.T) { fn := funcMap["autolink"].(func(string) template.HTML) tests := []struct { input string contains string }{ {"hello https://example.com world", `https://example.com`}, {"no links here", "no links here"}, {"http://foo.com.", `http://foo.com`}, {"