vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 57m ago
4b8c9e1d
| 1 | package server_test |
| 2 | |
| 3 | // The landing page is the instance's front door: identity, repositories, and a short |
| 4 | // activity rail. The full feed has its own page so the landing page can stay |
| 5 | // about the work rather than the log. |
| 6 | |
| 7 | import ( |
| 8 | "fmt" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "rodent.software/vole/forge/config" |
| 15 | "rodent.software/vole/metadata" |
| 16 | ) |
| 17 | |
| 18 | func TestHomeIsALandingPage(t *testing.T) { |
| 19 | t.Parallel() |
| 20 | f := newFixtureWith(t, func(c *config.Config) { |
| 21 | c.InstanceName = "Acme Forge" |
| 22 | }) |
| 23 | f.publish(f.repo) |
| 24 | f.instanceRepo(t, map[string][]byte{"profile/README.md": []byte("What this forge is for.")}) |
| 25 | |
| 26 | code, body := f.get("/") |
| 27 | if code != 200 { |
| 28 | t.Fatalf("home = %d", code) |
| 29 | } |
| 30 | for _, want := range []string{ |
| 31 | `class="hero"`, "<h1>Acme Forge</h1>", |
| 32 | `readme-card`, `class="md readme-body"`, "What this forge is for.", |
| 33 | "Repositories", `class="repo-card"`, `href="/demo"`, |
| 34 | "Latest", `href="/-/activity"`, |
| 35 | } { |
| 36 | if !strings.Contains(body, want) { |
| 37 | t.Fatalf("home is missing %q:\n%s", want, body) |
| 38 | } |
| 39 | } |
| 40 | for _, unwanted := range []string{ |
| 41 | `class="readme-head"`, `class="readme-name"`, |
| 42 | `href="/?edit=1`, `name="intro"`, |
| 43 | } { |
| 44 | if strings.Contains(body, unwanted) { |
| 45 | t.Fatalf("home still has the old introduction header or editor %q:\n%s", unwanted, body) |
| 46 | } |
| 47 | } |
| 48 | if strings.Contains(body, "Recent activity") { |
| 49 | t.Fatalf("home still leads with the feed:\n%s", body) |
| 50 | } |
| 51 | if !strings.Contains(body, `<h2>Repositories</h2>`) { |
| 52 | t.Fatalf("home has no repository list heading:\n%s", body) |
| 53 | } |
| 54 | // The introduction belongs to the repository column, not the whole page. |
| 55 | main := strings.Index(body, `class="home-main"`) |
| 56 | readme := strings.Index(body, "readme-card") |
| 57 | rail := strings.Index(body, `class="home-rail"`) |
| 58 | if main < 0 || readme < 0 || rail < 0 || main > readme || readme > rail { |
| 59 | t.Fatalf("the introduction is not inside the repository column (main=%d readme=%d rail=%d):\n%s", main, readme, rail, body) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestHomeHeadingFallsBackToVole(t *testing.T) { |
| 64 | t.Parallel() |
| 65 | f := newFixture(t) |
| 66 | _, body := f.get("/") |
| 67 | if !strings.Contains(body, "<h1>Vole</h1>") { |
| 68 | t.Fatalf("home has no fallback heading:\n%s", body) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestHomePutsNewRepositoryInTheHero(t *testing.T) { |
| 73 | t.Parallel() |
| 74 | f := browserFixture(t) |
| 75 | f.publish(f.repo) |
| 76 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { |
| 77 | t.Fatal(err) |
| 78 | } |
| 79 | _, cookie := f.browserSession(t, f.ownerID) |
| 80 | _, body := f.browserGet(t, "/", cookie) |
| 81 | |
| 82 | hero := strings.Index(body, `class="hero"`) |
| 83 | button := strings.Index(body, `href="/-/repos/new"`) |
| 84 | grid := strings.Index(body, `class="home-grid"`) |
| 85 | if hero < 0 || button < 0 || grid < 0 || hero > button || button > grid { |
| 86 | t.Fatalf("the New repository action is not in the page header (hero=%d button=%d grid=%d):\n%s", hero, button, grid, body) |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | func TestHomeHasTheCleanTopBar(t *testing.T) { |
| 91 | t.Parallel() |
| 92 | f := newFixture(t) |
| 93 | f.publish(f.repo) |
| 94 | |
| 95 | _, body := f.get("/") |
| 96 | for _, want := range []string{ |
| 97 | `class="topbar"`, `class="topnav"`, `href="/-/activity"`, |
| 98 | `class="topsearch"`, `class="topsearch-box"`, `href="/-/login"`, |
| 99 | } { |
| 100 | if !strings.Contains(body, want) { |
| 101 | t.Fatalf("home is missing the top-bar marker %q:\n%s", want, body) |
| 102 | } |
| 103 | } |
| 104 | for _, unwanted := range []string{`class="rail"`, `src="/-/static/nav.js"`, `>Projects</a>`, `>Repositories</a>`, `href="/-/runners"`, `href="/-/admin"`} { |
| 105 | if strings.Contains(body, unwanted) { |
| 106 | t.Fatalf("home still has %q:\n%s", unwanted, body) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | _, cookie := f.browserSession(t, f.ownerID) |
| 111 | _, body = f.browserGet(t, "/", cookie) |
| 112 | for _, want := range []string{`class="account-menu"`, `class="account-pop"`, `href="/-/account"`, `action="/-/logout"`} { |
| 113 | if !strings.Contains(body, want) { |
| 114 | t.Fatalf("signed-in top bar is missing %q:\n%s", want, body) |
| 115 | } |
| 116 | } |
| 117 | if strings.Contains(body, `href="/-/admin"`) { |
| 118 | t.Fatalf("non-admin sees the Status link:\n%s", body) |
| 119 | } |
| 120 | |
| 121 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | _, body = f.browserGet(t, "/", cookie) |
| 125 | if !strings.Contains(body, `href="/-/admin"`) { |
| 126 | t.Fatalf("instance admin does not see the Status link:\n%s", body) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestHomeSearchShowsProjectCards(t *testing.T) { |
| 131 | t.Parallel() |
| 132 | f := newFixture(t) |
| 133 | f.publish(f.repo) |
| 134 | |
| 135 | _, body := f.get("/?q=demo") |
| 136 | for _, want := range []string{"Results", `class="repo-card"`, `href="/demo"`, "clear search"} { |
| 137 | if !strings.Contains(body, want) { |
| 138 | t.Fatalf("search results are missing %q:\n%s", want, body) |
| 139 | } |
| 140 | } |
| 141 | if strings.Contains(body, `class="home-rail"`) { |
| 142 | t.Fatalf("search results kept the landing-page activity rail:\n%s", body) |
| 143 | } |
| 144 | |
| 145 | _, body = f.get("/?q=nomatch") |
| 146 | if !strings.Contains(body, "No repositories match") { |
| 147 | t.Fatalf("empty search state missing:\n%s", body) |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | func TestInstanceActivityListsOnlyVisibleRepositories(t *testing.T) { |
| 152 | t.Parallel() |
| 153 | f := newFixture(t) |
| 154 | f.publish(f.repo) |
| 155 | |
| 156 | // The public repository has a commit (not an event), an issue, and a release. |
| 157 | work := workRepo(t, map[string]string{"readme.md": "public\n"}) |
| 158 | if _, errs, err := runGit(t, work, "commit", "--allow-empty", "-qm", "public-commit"); err != nil { |
| 159 | t.Fatalf("public commit: %s", errs) |
| 160 | } |
| 161 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "public release"); err != nil { |
| 162 | t.Fatalf("public tag: %s", errs) |
| 163 | } |
| 164 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil { |
| 165 | t.Fatalf("public push: %s", errs) |
| 166 | } |
| 167 | clone := filepath.Join(t.TempDir(), "clone") |
| 168 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil { |
| 169 | t.Fatalf("public clone: %s", errs) |
| 170 | } |
| 171 | if _, errs, err := f.cli(clone, "issue", "new", "--title", "public-issue-marker", "-m", "body"); err != nil { |
| 172 | t.Fatalf("public issue: %v\n%s", err, errs) |
| 173 | } |
| 174 | |
| 175 | secret, err := f.srv.Repos.Create("secret", f.ownerID, "private") |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | if err := f.srv.Index.Upsert(secret); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | privateWork := workRepo(t, map[string]string{"hidden.md": "private\n"}) |
| 183 | if _, errs, err := runGit(t, privateWork, "commit", "--allow-empty", "-qm", "private-commit"); err != nil { |
| 184 | t.Fatalf("private commit: %s", errs) |
| 185 | } |
| 186 | if _, errs, err := f.git(privateWork, "push", f.sshURL("secret"), "main:refs/heads/main"); err != nil { |
| 187 | t.Fatalf("private push: %s", errs) |
| 188 | } |
| 189 | privateClone := filepath.Join(t.TempDir(), "private-clone") |
| 190 | if _, errs, err := f.git("", "clone", "-q", f.sshURL("secret"), privateClone); err != nil { |
| 191 | t.Fatalf("private clone: %s", errs) |
| 192 | } |
| 193 | if _, errs, err := f.cli(privateClone, "issue", "new", "--title", "private-issue-marker", "-m", "body"); err != nil { |
| 194 | t.Fatalf("private issue: %v\n%s", err, errs) |
| 195 | } |
| 196 | |
| 197 | code, body := f.get("/-/activity") |
| 198 | if code != 200 { |
| 199 | t.Fatalf("activity = %d", code) |
| 200 | } |
| 201 | for _, want := range []string{`href="/demo"`, "public-issue-marker", "v1.0.0", "released"} { |
| 202 | if !strings.Contains(body, want) { |
| 203 | t.Fatalf("activity feed is missing %q:\n%s", want, body) |
| 204 | } |
| 205 | } |
| 206 | for _, unwanted := range []string{"private-issue-marker", `href="/secret"`, "public-commit", "private-commit", "committed"} { |
| 207 | if strings.Contains(body, unwanted) { |
| 208 | t.Fatalf("activity feed contains %q:\n%s", unwanted, body) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | _, body = f.get("/-/activity?kind=issues") |
| 213 | if !strings.Contains(body, "public-issue-marker") || strings.Contains(body, "v1.0.0") { |
| 214 | t.Fatalf("issue filter is wrong:\n%s", body) |
| 215 | } |
| 216 | _, body = f.get("/-/activity?kind=releases") |
| 217 | if !strings.Contains(body, "v1.0.0") || strings.Contains(body, "public-issue-marker") { |
| 218 | t.Fatalf("release filter is wrong:\n%s", body) |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | func TestFirstRunOnboardingIsAnInstanceAdminPage(t *testing.T) { |
| 223 | t.Parallel() |
| 224 | f := newFixture(t) |
| 225 | |
| 226 | // The fixture's one repository is owned by the account we sign in as, so |
| 227 | // remove it to reach the state a fresh instance is in: an admin and no |
| 228 | // repositories. |
| 229 | m, err := f.srv.Repos.Open(f.repo) |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | if err := f.srv.Repos.Delete(f.repo, f.ownerID); err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | if err := f.srv.Index.Remove(m.ID); err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | _, cookie := f.browserSession(t, f.ownerID) |
| 243 | resp, body := f.browserGet(t, "/", cookie) |
| 244 | if resp.StatusCode != 200 { |
| 245 | t.Fatalf("home = %d", resp.StatusCode) |
| 246 | } |
| 247 | for _, want := range []string{"No repositories yet", "New repository", "Or from the instance directory", "--owner keenan", `href="/-/repos/new"`} { |
| 248 | if !strings.Contains(body, want) { |
| 249 | t.Fatalf("first-run page is missing %q:\n%s", want, body) |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | func TestHomeCardShowsTopLanguage(t *testing.T) { |
| 255 | t.Parallel() |
| 256 | f := newFixture(t) |
| 257 | f.publish(f.repo) |
| 258 | work := workRepo(t, map[string]string{"main.go": "package main\n", "README.md": "readme\n"}) |
| 259 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { |
| 260 | t.Fatalf("push: %s", errs) |
| 261 | } |
| 262 | |
| 263 | _, body := f.get("/") |
| 264 | if !strings.Contains(body, `class="lang-dot lang-go"`) || !strings.Contains(body, `class="repo-lang"`) { |
| 265 | t.Fatalf("home card has no top-language marker:\n%s", body) |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | // TestHomeRailShowsTheNewestEventsAcrossRepositories pins the rail to the events |
| 270 | // table: the newest events, bounded to what fits, with no per-repository walk of |
| 271 | // the whole feed. |
| 272 | func TestHomeRailShowsTheNewestEventsAcrossRepositories(t *testing.T) { |
| 273 | t.Parallel() |
| 274 | f := newFixture(t) |
| 275 | f.publish(f.repo) |
| 276 | f.instanceRepo(t, map[string][]byte{"profile/README.md": []byte("What this forge is for.")}) |
| 277 | m := mustRepo(t, f) |
| 278 | base := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) |
| 279 | for i := 0; i < 12; i++ { |
| 280 | iss, err := metadata.NewIssue(fmt.Sprintf("rail %02d", i), "body\n", "keenan", nil, nil, base.Add(time.Duration(i)*time.Minute)) |
| 281 | if err != nil { |
| 282 | t.Fatal(err) |
| 283 | } |
| 284 | if err := metadata.CreateIssue(m.Git(), iss, "keenan"); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | } |
| 288 | _, body := f.get("/") |
| 289 | if n := strings.Count(body, `class="activity-event"`); n != 10 { |
| 290 | t.Fatalf("the rail shows %d events, want %d", n, 10) |
| 291 | } |
| 292 | if !strings.Contains(body, "rail 11") || strings.Contains(body, "rail 00") { |
| 293 | t.Fatalf("the rail is not the newest events:\n%s", body) |
| 294 | } |
| 295 | } |
| 296 |