diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | bb6bc2387d58c737dbf92e73f8544bf39319dbd8 (patch) | |
| tree | 6d5eaf5dac213074cd832da50f64260fce728fed /wiki_test.go | |
| download | git-forge-master.tar.gz | |
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.go | 175 |
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) + } +} |