From bb6bc2387d58c737dbf92e73f8544bf39319dbd8 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter git-forge 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. --- store_test.go | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 store_test.go (limited to 'store_test.go') diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..9ae8c58 --- /dev/null +++ b/store_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "bytes" + "log" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSplitRepo(t *testing.T) { + f := testForge(t) + author := f.store.forgeAuthor() + for _, path := range []string{"notes", "infra", "infra/dns"} { + repo := Repo{Visibility: visPublic, Issues: featDisabled, Wiki: featDisabled, Labels: []string{}, Grants: map[string]string{}} + if err := f.store.CreateRepo(path, repo, author); err != nil { + t.Fatal(err) + } + } + cases := []struct{ url, repo, kind, rest string }{ + {"/notes", "notes", "", ""}, + {"/notes/", "notes", "", ""}, + {"/notes/settings", "notes", "", "settings"}, + {"/infra/dns", "infra/dns", "", ""}, // the section wins over the repo + {"/infra/dns/log/", "infra/dns", "", "log/"}, // and keeps winning deeper + {"/infra/other", "infra", "", "other"}, + {"/infra/dns.issues/log/", "infra/dns", "issues", "log/"}, + {"/notes.wiki/tree/", "notes", "wiki", "tree/"}, + {"/nothing", "", "", ""}, + } + for _, c := range cases { + repo, kind, rest, ok := f.store.splitRepo(c.url) + if repo != c.repo || kind != c.kind || rest != c.rest || ok != (c.repo != "") { + t.Errorf("%s resolved to %q %q %q %v", c.url, repo, kind, rest, ok) + } + } +} + +// One process per datadir is a correctness rule: two lose updates to +// users.json with nothing in any log to say so. +func TestDatadirLockIsExclusive(t *testing.T) { + dir := t.TempDir() + unlock, err := lockDatadir(dir) + if err != nil { + t.Fatalf("the first lock was refused: %v", err) + } + if _, err := lockDatadir(dir); err == nil { + t.Error("a second process took a datadir that was already being served") + } + if _, err := lockDatadir(t.TempDir()); err != nil { + t.Errorf("a different datadir was refused: %v", err) + } + unlock() + if unlock, err = lockDatadir(dir); err != nil { + t.Errorf("the lock outlived the process that held it: %v", err) + } + unlock() +} + +// A directory whose name could never be a repository path must not be +// servable, and must not stop the forge starting either. +func TestStrayDirectoryIsSkipped(t *testing.T) { + dir := t.TempDir() + s, err := openStore(dir) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"Uppercase.git", "with space.git", "settings.git"} { + if err := os.MkdirAll(filepath.Join(s.reposDir, name), 0700); err != nil { + t.Fatal(err) + } + } + s, err = openStore(dir) + if err != nil { + t.Fatalf("a stray directory stopped the forge starting: %v", err) + } + if repos := s.Repos(); len(repos) != 0 { + t.Errorf("a directory that is not a usable path is being served: %v", repos) + } +} + +// The scan exists to report what arrived by hand, and a report buried in noise +// is not a report: a repository owns two sibling directories, and reading +// either as a section warns about every file git keeps inside it. +func TestScanIsQuietOnAHealthyDatadir(t *testing.T) { + f := testForge(t) + addRepo(t, f, "notes", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + addRepo(t, f, "infra/dns", Repo{Visibility: visPublic, Issues: featUsers, Wiki: featUsers}) + + var buf bytes.Buffer + scan := func() string { + t.Helper() + buf.Reset() + old := log.Writer() + log.SetOutput(&buf) + err := f.store.scanRepos() + log.SetOutput(old) + if err != nil { + t.Fatal(err) + } + return strings.TrimSpace(buf.String()) + } + + if out := scan(); out != "" { + t.Errorf("a healthy datadir warned about itself:\n%s", out) + } + for _, path := range []string{"notes", "infra/dns"} { + if _, ok := f.store.Repo(path); !ok { + t.Errorf("%s did not survive the scan", path) + } + } + // And the one thing it is for is still said. + if err := os.MkdirAll(filepath.Join(f.store.reposDir, "Uppercase.git"), 0700); err != nil { + t.Fatal(err) + } + if out := scan(); !strings.Contains(out, "Uppercase") { + t.Errorf("a stray directory was not reported:\n%s", out) + } +} + +// The scan is the one place that reports a name nothing has validated, so a +// directory can be named to look like the next line of the log. +func TestTheScanCannotBeMadeToForgeALogLine(t *testing.T) { + f := testForge(t) + // No slash: this has to be one name on disk, not a path. + forged := "innocent\n2026-08-22 00:00:00 203.0.113.9 GET x 200 0 1ms" + repos := f.store.reposDir + // One of each thing the scan reports: a stray file, a directory whose name + // is not a usable path, and a stray file inside a section. + if err := os.WriteFile(filepath.Join(repos, forged), nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repos, forged+".git"), 0700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repos, "sect"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repos, "sect", forged), nil, 0600); err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + old := log.Writer() + log.SetOutput(&buf) + err := f.store.scanRepos() + log.SetOutput(old) + if err != nil { + t.Fatal(err) + } + + out := strings.TrimRight(buf.String(), "\n") + lines := strings.Split(out, "\n") + if len(lines) != 3 { + t.Fatalf("three strays wrote %d lines:\n%s", len(lines), out) + } + for _, line := range lines { + if !strings.Contains(line, "repos: ") || !strings.Contains(line, `\n`) { + t.Errorf("the line break was not escaped: %q", line) + } + } +} + +// Every single-account edit found the account before the write. Writing an +// absent key back would put a record in users.json under a name nothing else +// knows about, with an origin the next start refuses to load. +func TestEditingAnAccountThatIsGoneWritesNothing(t *testing.T) { + dir := t.TempDir() + store, err := openStore(dir) + if err != nil { + t.Fatal(err) + } + + ran := false + err = store.UpdateUser("ghost", "users: sign out every session for ghost", store.forgeAuthor(), + func(acct *User) error { + ran = true + acct.Epoch++ + return nil + }) + if err != nil { + t.Fatalf("editing an account that is gone: %v", err) + } + if ran { + t.Error("the edit ran against an account that is not there") + } + if _, ok := store.User("ghost"); ok { + t.Fatal("the account was recreated") + } + // The consequence that matters: the datadir still starts. + if _, err := openStore(dir); err != nil { + t.Errorf("a fresh start refuses the datadir: %v", err) + } +} -- cgit v1.2.3