aboutsummaryrefslogtreecommitdiff
path: root/wiki_test.go
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitbb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch)
tree6d5eaf5dac213074cd832da50f64260fce728fed /wiki_test.go
downloadgit-forge-bb6bc2387d58c737dbf92e73f8544bf39319dbd8.tar.gz
Enter git-forgeHEADmaster
A small self-hosted git forge: HTTP push and pull, cgit browsing, local and OIDC accounts, TOTP, scoped push tokens, per-repo permissions, issues, a wiki, and outbound mail. It does not reimplement git. git-http-backend and cgit run as CGI behind its own listener, which authorises every request before exec'ing either. One binary, one data directory, one listener: no database, no JavaScript, and state is JSON in a git repo with a working tree.
Diffstat (limited to 'wiki_test.go')
-rw-r--r--wiki_test.go175
1 files changed, 175 insertions, 0 deletions
diff --git a/wiki_test.go b/wiki_test.go
new file mode 100644
index 0000000..eae2c4e
--- /dev/null
+++ b/wiki_test.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// A page name has to survive the trip out to a URL and back to a path in the
+// tree, or a page is listed under a link that does not open it.
+func TestWikiPageNames(t *testing.T) {
+ for _, file := range []string{
+ "index.md", "docs/index.md", "notes/deep/index.md",
+ "plain.md", "myindex.md", "a/myindex.md", "index.notes.md",
+ } {
+ page := wikiPage(file)
+ files, ok := wikiFiles(page)
+ if !ok {
+ t.Errorf("%s becomes the page %q, which is not a usable name", file, page)
+ continue
+ }
+ if !slices.Contains(files, file) {
+ t.Errorf("%s becomes the page %q, which reads %v instead", file, page, files)
+ }
+ }
+}
+
+// No pages yet is a state, not a failure.
+func TestEmptyWikiIsNotAnError(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ if code := get(f.routes(), "/notes/wiki", nil).Code; code != http.StatusOK {
+ t.Errorf("an empty wiki answered %d", code)
+ }
+ if code := get(f.routes(), "/notes/wiki/nope", nil).Code; code != http.StatusOK {
+ t.Errorf("a missing page in an empty wiki answered %d", code)
+ }
+}
+
+// A push can put a name in the tree that reading refuses, and a link that
+// answers 404 is worse than no link.
+func TestWikiIndexOnlyListsPagesItCanOpen(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ dir := f.wikiDir("notes")
+ author := f.store.forgeAuthor()
+ err := commitWith(dir, author, author, func(base string) ([]change, string, error) {
+ return []change{
+ {path: "fine.md", data: []byte("# fine\n")},
+ {path: "a b.md", data: []byte("# spaces\n")},
+ {path: ".hidden.md", data: []byte("# a dot\n")},
+ // Both of these read as a page that opens index.md instead, so
+ // listing them offers a link that never reaches the file.
+ {path: ".md", data: []byte("# a file called .md\n")},
+ {path: "index.md.md", data: []byte("# doubled\n")},
+ }, "wiki: written by push", nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ head, err := resolveRef(dir, branch)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pages := f.wikiPages(dir, head)
+ if !slices.Contains(pages, "fine") {
+ t.Errorf("a usable page is not listed: %v", pages)
+ }
+ // One entry per file, and every entry opens the file it came from.
+ if len(pages) != 1 {
+ t.Errorf("three files that cannot be opened were listed anyway: %v", pages)
+ }
+ h := f.routes()
+ for _, page := range []string{"a b", ".hidden"} {
+ if slices.Contains(pages, page) {
+ t.Errorf("%q is listed but cannot be opened: %v", page, pages)
+ }
+ if code := get(h, "/notes/wiki/"+url.PathEscape(page), nil).Code; code != http.StatusNotFound {
+ t.Errorf("%q answered %d, so it could have been listed after all", page, code)
+ }
+ }
+}
+
+// Two people saving one page from the same base must not both be told they
+// saved. Comparing before the commit rather than inside it leaves the whole
+// commit between the check and the write, so this has to be concurrent.
+func TestWikiSaveIsACompareAndSwap(t *testing.T) {
+ f := testForge(t)
+ addUser(t, f, "alice", "hunter2hunter2")
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featUsers})
+ h := f.routes()
+ session := signIn(t, f, h, "alice")
+ token := f.csrf(withCookies("/", session))
+ post(h, "/notes/wiki/page", url.Values{
+ "csrf": {token}, "base": {""}, "action": {"save"}, "body": {"original\n"}}, session)
+
+ dir := f.wikiDir("notes")
+ base, err := f.wikiBlob(dir, "page.md")
+ if err != nil || base == "" {
+ t.Fatalf("the first page did not land: %q %v", base, err)
+ }
+
+ const writers = 4
+ codes := make([]int, writers)
+ var wg sync.WaitGroup
+ for i := range writers {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ codes[i] = post(h, "/notes/wiki/page", url.Values{
+ "csrf": {token}, "base": {base}, "action": {"save"},
+ "body": {fmt.Sprintf("rewritten by %d\n", i)}}, session).Code
+ }()
+ }
+ wg.Wait()
+
+ saved := 0
+ for i, code := range codes {
+ switch code {
+ case http.StatusSeeOther:
+ saved++
+ case http.StatusOK: // handed back the editor and told to merge
+ default:
+ t.Errorf("writer %d: status %d", i, code)
+ }
+ }
+ if saved != 1 {
+ t.Errorf("%d of %d writers from one base were told they saved", saved, writers)
+ }
+ // And the one that did save is what the page holds.
+ head, _ := resolveRef(dir, branch)
+ body, err := catFile(dir, head+":page.md")
+ if err != nil || !strings.HasPrefix(string(body), "rewritten by ") {
+ t.Errorf("the page reads %q, %v", body, err)
+ }
+}
+
+// An empty repository and one that is not there must not look the same, or a
+// bad restore renders as a friendly page. Both at the primitive and at the
+// page that shows it.
+func TestMissingRepoIsAnError(t *testing.T) {
+ f := testForge(t)
+ addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers})
+ h := f.routes()
+ dir := filepath.Join(f.store.reposDir, "notes.wiki.git")
+ if head, err := resolveRef(dir, branch); head != "" || err != nil {
+ t.Fatalf("an empty repository should be empty and fine: %q %v", head, err)
+ }
+ if code := get(h, "/notes/wiki", nil).Code; code != http.StatusOK {
+ t.Fatalf("an empty wiki answered %d", code)
+ }
+ if err := os.RemoveAll(dir); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := resolveRef(dir, branch); err == nil {
+ t.Error("a missing repository still looks like an empty one")
+ }
+ if code := get(h, "/notes/wiki", nil).Code; code != http.StatusInternalServerError {
+ t.Errorf("a wiki with no repository behind it answered %d", code)
+ }
+ // And the tracker, which is the same question at the other door.
+ if err := os.RemoveAll(f.issuesDir("notes")); err != nil {
+ t.Fatal(err)
+ }
+ if code := get(h, "/notes/issues", nil).Code; code != http.StatusInternalServerError {
+ t.Errorf("a tracker with no repository behind it answered %d", code)
+ }
+}