vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
| 1 | package hook |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "io" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | "time" |
| 11 | |
| 12 | "rodent.software/vole/ci/runs" |
| 13 | "rodent.software/vole/core/git" |
| 14 | "rodent.software/vole/forge/access" |
| 15 | "rodent.software/vole/forge/identity" |
| 16 | "rodent.software/vole/forge/index" |
| 17 | "rodent.software/vole/forge/repo" |
| 18 | "rodent.software/vole/metadata" |
| 19 | ) |
| 20 | |
| 21 | type fixture struct { |
| 22 | t *testing.T |
| 23 | m *repo.Meta |
| 24 | head string |
| 25 | usersDB string |
| 26 | } |
| 27 | |
| 28 | func newFixture(t *testing.T, blob string) *fixture { |
| 29 | t.Helper() |
| 30 | root := t.TempDir() |
| 31 | // Access rules live in the instance database, so a fixture that pushes has to give |
| 32 | // the gate the same file the repository's rules were written to. |
| 33 | acls := testACLs(t, root) |
| 34 | st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls) |
| 35 | m, err := st.Create("demo", "owner", "") |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | a, err := m.ACL() |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := a.SetEntry("writer", "maintain"); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | if err := a.SetEntry("reader", "read"); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if err := a.SetEntry("contributor", "contribute"); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if err := m.SetACL(a, "test"); err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | |
| 56 | g := m.Git() |
| 57 | blobSHA, err := g.HashObject([]byte(blob)) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | tree, err := g.MkTree([]git.Entry{{Mode: "100644", Type: "blob", SHA: blobSHA, Name: "readme.md"}}) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | commit, err := g.CommitTree(tree, "", "initial") |
| 66 | if err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | return &fixture{t: t, m: m, head: commit, usersDB: testUsersDB(root)} |
| 70 | } |
| 71 | |
| 72 | // testUsersDB is the instance database a test fixture uses: one file holding the |
| 73 | // roster and the access rules, which is what the server has. |
| 74 | func testUsersDB(root string) string { return filepath.Join(root, "users.db") } |
| 75 | |
| 76 | func testACLs(t *testing.T, root string) *access.Store { |
| 77 | t.Helper() |
| 78 | acls, err := access.Open(testUsersDB(root)) |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | acls.AuditPath = filepath.Join(root, "audit.log") |
| 83 | t.Cleanup(func() { acls.Close() }) |
| 84 | return acls |
| 85 | } |
| 86 | |
| 87 | func (f *fixture) run(actor, ref string, maxBlob int64) (int, string) { |
| 88 | f.t.Helper() |
| 89 | return f.runWith(Config{ |
| 90 | Actor: actor, |
| 91 | MaxBlobBytes: maxBlob, |
| 92 | }, ref) |
| 93 | } |
| 94 | |
| 95 | // runWith runs pre-receive with a full config, so LFS rules can be exercised. |
| 96 | func (f *fixture) runWith(cfg Config, ref string) (int, string) { |
| 97 | f.t.Helper() |
| 98 | cfg.RepoDir = f.m.Dir |
| 99 | if cfg.UsersDB == "" { |
| 100 | cfg.UsersDB = f.usersDB |
| 101 | } |
| 102 | var out bytes.Buffer |
| 103 | cfg.In = strings.NewReader(git.ZeroSHA + " " + f.head + " " + ref + "\n") |
| 104 | cfg.Out = &out |
| 105 | return PreReceive(cfg), out.String() |
| 106 | } |
| 107 | |
| 108 | // runFrom is a push that starts at an existing metadata commit, which is what a job's |
| 109 | // write looks like: the branch is already there and the push adds to it. |
| 110 | func (f *fixture) runFrom(cfg Config, old, ref string) (int, string) { |
| 111 | f.t.Helper() |
| 112 | cfg.RepoDir = f.m.Dir |
| 113 | if cfg.UsersDB == "" { |
| 114 | cfg.UsersDB = f.usersDB |
| 115 | } |
| 116 | var out bytes.Buffer |
| 117 | cfg.In = strings.NewReader(old + " " + f.head + " " + ref + "\n") |
| 118 | cfg.Out = &out |
| 119 | return PreReceive(cfg), out.String() |
| 120 | } |
| 121 | |
| 122 | func (f *fixture) setACL(mut func(*access.ACL)) { |
| 123 | f.t.Helper() |
| 124 | a, err := f.m.ACL() |
| 125 | if err != nil { |
| 126 | f.t.Fatal(err) |
| 127 | } |
| 128 | mut(a) |
| 129 | if err := f.m.SetACL(a, "test"); err != nil { |
| 130 | f.t.Fatal(err) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | func TestOwnerCanPush(t *testing.T) { |
| 135 | t.Parallel() |
| 136 | f := newFixture(t, "hello\n") |
| 137 | if code, out := f.run("owner", "refs/heads/main", 0); code != 0 { |
| 138 | t.Fatalf("owner push rejected: %s", out) |
| 139 | } |
| 140 | if code, out := f.run("writer", "refs/heads/feature/x", 0); code != 0 { |
| 141 | t.Fatalf("writer push rejected: %s", out) |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestContributorCanPushOnlyTheirNamespace(t *testing.T) { |
| 146 | t.Parallel() |
| 147 | f := newFixture(t, "hello\n") |
| 148 | own := "refs/heads/contrib/contributor/topic" |
| 149 | if code, out := f.run("contributor", own, 0); code != 0 { |
| 150 | t.Fatalf("contributor push to their namespace was rejected: %s", out) |
| 151 | } |
| 152 | for _, ref := range []string{ |
| 153 | "refs/heads/main", |
| 154 | "refs/heads/contrib/someone-else/topic", |
| 155 | "refs/heads/contrib/contributor", |
| 156 | "refs/tags/contrib/contributor/v1", |
| 157 | } { |
| 158 | code, out := f.run("contributor", ref, 0) |
| 159 | if code == 0 { |
| 160 | t.Fatalf("contributor was allowed to push %s", ref) |
| 161 | } |
| 162 | if !strings.Contains(out, "the contribute role may only push branches under refs/heads/contrib/contributor/") { |
| 163 | t.Fatalf("%s: unclear rejection: %s", ref, out) |
| 164 | } |
| 165 | } |
| 166 | // The issues branch is not a namespace rule: a contributor may write it. This push |
| 167 | // would create it, which is a maintainer's act, and that is the refusal it gets. |
| 168 | if code, out := f.run("contributor", "refs/heads/vole", 0); code == 0 { |
| 169 | t.Fatal("a contributor created the issues branch") |
| 170 | } else if !strings.Contains(out, "creating the issues branch needs a maintainer") { |
| 171 | t.Fatalf("unclear rejection: %s", out) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | func TestInstanceRoleAppliesOnlyToPublicReposAndAdminEverywhere(t *testing.T) { |
| 176 | t.Parallel() |
| 177 | f := newFixture(t, "hello\n") |
| 178 | ident, err := identity.Open(f.usersDB) |
| 179 | if err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | ident.AuditPath = filepath.Join(filepath.Dir(f.usersDB), "audit.log") |
| 183 | defer ident.Close() |
| 184 | if _, _, err := ident.CreateWithInviteRole("invited", access.Contribute, "test", 0); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | |
| 188 | own := "refs/heads/contrib/invited/topic" |
| 189 | f.setACL(func(a *access.ACL) { a.Default = "read" }) |
| 190 | if code, out := f.run("invited", own, 0); code != 0 { |
| 191 | t.Fatalf("instance contributor could not push their namespace to a public repo: %s", out) |
| 192 | } |
| 193 | if code, out := f.run("invited", "refs/heads/main", 0); code == 0 { |
| 194 | t.Fatal("instance contributor pushed a shared branch") |
| 195 | } else if !strings.Contains(out, "the contribute role may only push branches under") { |
| 196 | t.Fatalf("unclear contributor rejection: %s", out) |
| 197 | } |
| 198 | |
| 199 | f.setACL(func(a *access.ACL) { a.Default = "none" }) |
| 200 | if code, out := f.run("invited", own, 0); code == 0 { |
| 201 | t.Fatal("instance contributor pushed to a private repo") |
| 202 | } else if !strings.Contains(out, "does not have maintain or contribute access") { |
| 203 | t.Fatalf("unclear private-repo rejection: %s", out) |
| 204 | } |
| 205 | |
| 206 | if _, err := ident.SetInstanceRole("invited", access.Admin, "test"); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | if code, out := f.run("invited", "refs/heads/main", 0); code != 0 { |
| 210 | t.Fatalf("instance admin could not push to a private repo: %s", out) |
| 211 | } |
| 212 | } |
| 213 | |
| 214 | func TestReadOnlyCannotPush(t *testing.T) { |
| 215 | t.Parallel() |
| 216 | f := newFixture(t, "hello\n") |
| 217 | code, out := f.run("reader", "refs/heads/main", 0) |
| 218 | if code == 0 { |
| 219 | t.Fatal("read-only user was allowed to push") |
| 220 | } |
| 221 | if !strings.Contains(out, "does not have maintain or contribute access") { |
| 222 | t.Fatalf("message should name the reason, got: %s", out) |
| 223 | } |
| 224 | if !strings.Contains(out, "reader") { |
| 225 | t.Fatalf("message should name the actor, got: %s", out) |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | func TestAnonymousCannotPush(t *testing.T) { |
| 230 | t.Parallel() |
| 231 | f := newFixture(t, "hello\n") |
| 232 | code, out := f.run("", "refs/heads/main", 0) |
| 233 | if code == 0 { |
| 234 | t.Fatal("anonymous push was allowed") |
| 235 | } |
| 236 | if !strings.Contains(out, "must be authenticated") { |
| 237 | t.Fatalf("unclear message: %s", out) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func TestProtectedBranch(t *testing.T) { |
| 242 | t.Parallel() |
| 243 | f := newFixture(t, "hello\n") |
| 244 | f.setACL(func(a *access.ACL) { a.Protected = []string{"refs/heads/main"} }) |
| 245 | |
| 246 | if code, out := f.run("writer", "refs/heads/main", 0); code == 0 { |
| 247 | t.Fatal("writer was allowed to push a protected branch") |
| 248 | } else if !strings.Contains(out, "protected") { |
| 249 | t.Fatalf("message should mention protection: %s", out) |
| 250 | } |
| 251 | if code, out := f.run("writer", "refs/heads/topic", 0); code != 0 { |
| 252 | t.Fatalf("protection leaked to an unprotected ref: %s", out) |
| 253 | } |
| 254 | if code, out := f.run("owner", "refs/heads/main", 0); code != 0 { |
| 255 | t.Fatalf("admin must be able to push a protected branch: %s", out) |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | func TestOversizedBlobRejected(t *testing.T) { |
| 260 | t.Parallel() |
| 261 | f := newFixture(t, strings.Repeat("x", 4096)) |
| 262 | code, out := f.run("owner", "refs/heads/main", 1024) |
| 263 | if code == 0 { |
| 264 | t.Fatal("oversized blob was accepted") |
| 265 | } |
| 266 | if !strings.Contains(out, "over the 1024 byte limit") { |
| 267 | t.Fatalf("message should state the limit: %s", out) |
| 268 | } |
| 269 | if code, out := f.run("owner", "refs/heads/main", 1<<20); code != 0 { |
| 270 | t.Fatalf("blob under the limit was rejected: %s", out) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | func TestArbitraryRefsRejected(t *testing.T) { |
| 275 | t.Parallel() |
| 276 | f := newFixture(t, "hello\n") |
| 277 | // refs/vole is no longer server state (access rules moved into the database), but it |
| 278 | // is still not a branch or a tag, so it is refused by the same rule as any other |
| 279 | // namespace rather than by a rule of its own. |
| 280 | for _, ref := range []string{"refs/notes/commits", "refs/vole/acl", "refs/vole/anything"} { |
| 281 | code, out := f.run("owner", ref, 0) |
| 282 | if code == 0 { |
| 283 | t.Fatalf("push to %s was allowed", ref) |
| 284 | } |
| 285 | if !strings.Contains(out, "only refs/heads/*") { |
| 286 | t.Fatalf("%s: unclear message: %s", ref, out) |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // The push log is the audit trail for an ordinary push: attribution is the boundary's, so |
| 292 | // what the instance owes a reader is a record of what it decided. One line per accepted |
| 293 | // ref update, in the same log as the privileged acts. |
| 294 | func TestPostReceiveRecordsThePush(t *testing.T) { |
| 295 | t.Parallel() |
| 296 | dir := t.TempDir() |
| 297 | repoDir := filepath.Join(dir, "demo.git") |
| 298 | if err := git.InitBare(repoDir, "main"); err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | audit := filepath.Join(dir, "audit.log") |
| 302 | var out bytes.Buffer |
| 303 | code := PostReceive(Config{ |
| 304 | Actor: "keenan", |
| 305 | AuditPath: audit, |
| 306 | SpoolDir: filepath.Join(dir, "spool"), |
| 307 | RepoDir: repoDir, |
| 308 | In: strings.NewReader( |
| 309 | "0000000000000000000000000000000000000000 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa refs/heads/main\n" + |
| 310 | "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb 0000000000000000000000000000000000000000 refs/heads/gone\n"), |
| 311 | Out: &out, |
| 312 | }) |
| 313 | if code != 0 { |
| 314 | t.Fatalf("post-receive exited %d: %s", code, out.String()) |
| 315 | } |
| 316 | line, err := os.ReadFile(audit) |
| 317 | if err != nil { |
| 318 | t.Fatal(err) |
| 319 | } |
| 320 | got := string(line) |
| 321 | for _, want := range []string{ |
| 322 | "\tpush\tkeenan\tdemo\trefs/heads/main none -> ", |
| 323 | "\tpush\tkeenan\tdemo\trefs/heads/gone ", "-> none", |
| 324 | } { |
| 325 | if !strings.Contains(got, want) { |
| 326 | t.Fatalf("the push log is missing %q:\n%s", want, got) |
| 327 | } |
| 328 | } |
| 329 | if n := strings.Count(got, "\tpush\t"); n != 2 { |
| 330 | t.Fatalf("%d push lines for two ref updates:\n%s", n, got) |
| 331 | } |
| 332 | // The CI spool is still written, and a push with no spool is still recorded. |
| 333 | if entries, err := runs.Next(filepath.Join(dir, "spool")); err != nil || len(entries) != 2 { |
| 334 | t.Fatalf("spool = %d entries, err %v", len(entries), err) |
| 335 | } |
| 336 | code = PostReceive(Config{ |
| 337 | Actor: "keenan", AuditPath: audit, RepoDir: repoDir, |
| 338 | In: strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main\n"), |
| 339 | Out: &out, |
| 340 | }) |
| 341 | if code != 0 { |
| 342 | t.Fatalf("a push with no spool dir exited %d", code) |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | // A push log that cannot be written must not fail the push: the update already landed, and |
| 347 | // reporting it as rejected would make the client re-send something that worked. |
| 348 | func TestPostReceiveSurvivesAnUnwritableLog(t *testing.T) { |
| 349 | t.Parallel() |
| 350 | dir := t.TempDir() |
| 351 | repoDir := filepath.Join(dir, "demo.git") |
| 352 | if err := git.InitBare(repoDir, "main"); err != nil { |
| 353 | t.Fatal(err) |
| 354 | } |
| 355 | code := PostReceive(Config{ |
| 356 | Actor: "keenan", AuditPath: filepath.Join(dir, "no", "such", "dir", "audit.log"), |
| 357 | RepoDir: repoDir, |
| 358 | In: strings.NewReader("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb refs/heads/main\n"), |
| 359 | Out: io.Discard, |
| 360 | }) |
| 361 | if code != 0 { |
| 362 | t.Fatalf("an unwritable audit log failed the push (exit %d)", code) |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | // A repository can require a linked issue when a change is opened. The rule is |
| 367 | // checked for requests a push adds; turning it on must not block edits to the |
| 368 | // requests that were already there. |
| 369 | func TestNewChangeRequestRequiresALinkedIssueWhenConfigured(t *testing.T) { |
| 370 | t.Parallel() |
| 371 | f := newFixture(t, "hello\n") |
| 372 | f.setACL(func(a *access.ACL) { a.RequireLinkedIssue = true }) |
| 373 | |
| 374 | g := f.m.Git() |
| 375 | now := time.Now().UTC() |
| 376 | iss, err := metadata.NewIssue("A bug", "details\n", "owner", nil, nil, now) |
| 377 | if err != nil { |
| 378 | t.Fatal(err) |
| 379 | } |
| 380 | if err := metadata.CreateIssue(g, iss, "owner"); err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | issueTip, err := g.RevParse(metadata.Ref) |
| 384 | if err != nil { |
| 385 | t.Fatal(err) |
| 386 | } |
| 387 | |
| 388 | linked, err := metadata.NewRequest("Linked change", "body\n", "owner", "main", f.head, now) |
| 389 | if err != nil { |
| 390 | t.Fatal(err) |
| 391 | } |
| 392 | linked.Closes = []string{iss.ID} |
| 393 | if err := metadata.CreateRequest(g, linked, "owner"); err != nil { |
| 394 | t.Fatal(err) |
| 395 | } |
| 396 | linkedTip, err := g.RevParse(metadata.Ref) |
| 397 | if err != nil { |
| 398 | t.Fatal(err) |
| 399 | } |
| 400 | if code, out := f.runRef(Config{Actor: "owner"}, issueTip, linkedTip, metadata.Ref); code != 0 { |
| 401 | t.Fatalf("a linked request was rejected: %s", out) |
| 402 | } |
| 403 | |
| 404 | unlinked, err := metadata.NewRequest("Unlinked change", "body\n", "owner", "main", f.head, now) |
| 405 | if err != nil { |
| 406 | t.Fatal(err) |
| 407 | } |
| 408 | if err := metadata.CreateRequest(g, unlinked, "owner"); err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | unlinkedTip, err := g.RevParse(metadata.Ref) |
| 412 | if err != nil { |
| 413 | t.Fatal(err) |
| 414 | } |
| 415 | code, out := f.runRef(Config{Actor: "owner"}, linkedTip, unlinkedTip, metadata.Ref) |
| 416 | if code == 0 { |
| 417 | t.Fatal("an unlinked request was accepted") |
| 418 | } |
| 419 | if !strings.Contains(out, "requires at least one linked issue") { |
| 420 | t.Fatalf("the refusal does not explain the rule: %s", out) |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | // pushFixture is a repository with a metadata branch and an index that agrees with |
| 425 | // it, which is what post-receive sees on a healthy instance. |
| 426 | func pushFixture(t *testing.T) (*repo.Meta, string) { |
| 427 | t.Helper() |
| 428 | root := t.TempDir() |
| 429 | acls := testACLs(t, root) |
| 430 | st := repo.New(filepath.Join(root, "repos"), "/bin/true", acls) |
| 431 | m, err := st.Create("demo", "owner", "") |
| 432 | if err != nil { |
| 433 | t.Fatal(err) |
| 434 | } |
| 435 | g := m.Git() |
| 436 | tree, err := g.MkTree(nil) |
| 437 | if err != nil { |
| 438 | t.Fatal(err) |
| 439 | } |
| 440 | base, err := g.CommitTree(tree, "", "initial") |
| 441 | if err != nil { |
| 442 | t.Fatal(err) |
| 443 | } |
| 444 | if err := g.UpdateRef("refs/heads/main", base); err != nil { |
| 445 | t.Fatal(err) |
| 446 | } |
| 447 | // The metadata branch starts one commit further along, so a tree walk in the |
| 448 | // trace names which of the two it read. |
| 449 | first, err := g.CommitTree(tree, base, "metadata") |
| 450 | if err != nil { |
| 451 | t.Fatal(err) |
| 452 | } |
| 453 | if err := g.UpdateRef(metadata.Ref, first); err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | path := filepath.Join(root, "index.db") |
| 457 | store, err := index.Open(path) |
| 458 | if err != nil { |
| 459 | t.Fatal(err) |
| 460 | } |
| 461 | defer store.Close() |
| 462 | if err := store.Upsert(m); err != nil { |
| 463 | t.Fatal(err) |
| 464 | } |
| 465 | // A full build, so the default branch summary is current too: post-receive |
| 466 | // refreshes it, and a fixture that left it empty would make that refresh read |
| 467 | // trees the test is counting. |
| 468 | if err := store.IndexRepo(m); err != nil { |
| 469 | t.Fatal(err) |
| 470 | } |
| 471 | return m, path |
| 472 | } |
| 473 | |
| 474 | // followRequest opens a request that follows branch, which is the only reason a push |
| 475 | // to that branch has metadata work to do. |
| 476 | func followRequest(t *testing.T, m *repo.Meta, branch, head string) *metadata.Request { |
| 477 | t.Helper() |
| 478 | req, err := metadata.NewRequest("Land it", "", "keenan", "main", head, time.Now().UTC()) |
| 479 | if err != nil { |
| 480 | t.Fatal(err) |
| 481 | } |
| 482 | req.HeadRef = branch |
| 483 | if err := metadata.CreateRequest(m.Git(), req, "keenan"); err != nil { |
| 484 | t.Fatal(err) |
| 485 | } |
| 486 | return req |
| 487 | } |
| 488 | |
| 489 | // A push to a branch no open request follows moves no metadata, so post-receive must |
| 490 | // not read the branch or queue a commit behind the people writing in it. The index is |
| 491 | // what makes the answer cheap; without one, the hook falls back to doing the work. |
| 492 | func TestPostReceiveOnlySyncsBranchesARequestFollows(t *testing.T) { |
| 493 | m, indexDB := pushFixture(t) |
| 494 | head := "00112233445566778899aabbccddeeff00112233" |
| 495 | req := followRequest(t, m, "feature", head) |
| 496 | reindex(t, m, indexDB) |
| 497 | |
| 498 | // Every child git logs its argv, so "was the metadata branch read?" is a question |
| 499 | // the test can answer rather than infer. |
| 500 | trace := filepath.Join(t.TempDir(), "git.trace") |
| 501 | t.Setenv("GIT_TRACE", trace) |
| 502 | |
| 503 | before, err := m.Git().RevParseRaw(metadata.Ref) |
| 504 | if err != nil { |
| 505 | t.Fatal(err) |
| 506 | } |
| 507 | push := func(branch, newTip string) { |
| 508 | t.Helper() |
| 509 | var out bytes.Buffer |
| 510 | code := PostReceive(Config{ |
| 511 | Actor: "keenan", |
| 512 | RepoDir: m.Dir, |
| 513 | RepoID: m.ID, |
| 514 | IndexDB: indexDB, |
| 515 | Out: &out, |
| 516 | In: strings.NewReader(git.ZeroSHA + " " + newTip + " refs/heads/" + branch + "\n"), |
| 517 | }) |
| 518 | if code != 0 { |
| 519 | t.Fatalf("post-receive exited %d: %s", code, out.String()) |
| 520 | } |
| 521 | } |
| 522 | reads := func() int { |
| 523 | t.Helper() |
| 524 | data, err := os.ReadFile(trace) |
| 525 | if os.IsNotExist(err) { |
| 526 | return 0 |
| 527 | } |
| 528 | if err != nil { |
| 529 | t.Fatal(err) |
| 530 | } |
| 531 | return bytes.Count(data, []byte("ls-tree")) |
| 532 | } |
| 533 | |
| 534 | // A branch nobody proposes from: nothing follows it, so the branch is not read. |
| 535 | push("other", head) |
| 536 | if n := reads(); n != 0 { |
| 537 | t.Fatalf("a push to an unfollowed branch read the metadata tree %d time(s)", n) |
| 538 | } |
| 539 | if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after != before { |
| 540 | t.Fatalf("a push to an unfollowed branch moved %s: %s -> %s (%v)", metadata.Ref, before, after, err) |
| 541 | } |
| 542 | |
| 543 | // The branch a request follows is read, and the push moves that request's head. |
| 544 | if err := os.Remove(trace); err != nil { |
| 545 | t.Fatal(err) |
| 546 | } |
| 547 | next := "ffeeddccbbaa99887766554433221100ffeeddcc" |
| 548 | push("feature", next) |
| 549 | if n := reads(); n == 0 { |
| 550 | t.Fatal("a push to a followed branch did not read the metadata tree") |
| 551 | } |
| 552 | if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after == before { |
| 553 | t.Fatalf("a push to a followed branch did not move %s", metadata.Ref) |
| 554 | } |
| 555 | synced, err := metadata.GetRequest(m.Git(), req.ID) |
| 556 | if err != nil { |
| 557 | t.Fatal(err) |
| 558 | } |
| 559 | if synced.Head != next { |
| 560 | t.Fatalf("the request follows %s, want %s", synced.Head, next) |
| 561 | } |
| 562 | } |
| 563 | |
| 564 | // reindex brings the fixture's index up to the metadata branch, the way a web write |
| 565 | // would have: without it the hook correctly decides it cannot answer from a stale |
| 566 | // index and reads the branch. |
| 567 | func reindex(t *testing.T, m *repo.Meta, path string) { |
| 568 | t.Helper() |
| 569 | store, err := index.Open(path) |
| 570 | if err != nil { |
| 571 | t.Fatal(err) |
| 572 | } |
| 573 | defer store.Close() |
| 574 | if err := store.Ensure(m); err != nil { |
| 575 | t.Fatal(err) |
| 576 | } |
| 577 | } |
| 578 | |
| 579 | // With no index to ask, the hook cannot rule a follower out, so it has to read the |
| 580 | // branch: the sync is worth more than the read. |
| 581 | func TestPostReceiveSyncsWithoutAnIndex(t *testing.T) { |
| 582 | t.Parallel() |
| 583 | m, _ := pushFixture(t) |
| 584 | head := "00112233445566778899aabbccddeeff00112233" |
| 585 | req := followRequest(t, m, "feature", head) |
| 586 | before, err := m.Git().RevParseRaw(metadata.Ref) |
| 587 | if err != nil { |
| 588 | t.Fatal(err) |
| 589 | } |
| 590 | next := "ffeeddccbbaa99887766554433221100ffeeddcc" |
| 591 | var out bytes.Buffer |
| 592 | code := PostReceive(Config{ |
| 593 | Actor: "keenan", |
| 594 | RepoDir: m.Dir, |
| 595 | RepoID: m.ID, |
| 596 | Out: &out, |
| 597 | In: strings.NewReader(git.ZeroSHA + " " + next + " refs/heads/feature\n"), |
| 598 | }) |
| 599 | if code != 0 { |
| 600 | t.Fatalf("post-receive exited %d: %s", code, out.String()) |
| 601 | } |
| 602 | if after, err := m.Git().RevParseRaw(metadata.Ref); err != nil || after == before { |
| 603 | t.Fatalf("%s did not move: %s -> %s (%v)", metadata.Ref, before, after, err) |
| 604 | } |
| 605 | got, err := metadata.GetRequest(m.Git(), req.ID) |
| 606 | if err != nil { |
| 607 | t.Fatal(err) |
| 608 | } |
| 609 | if got.Head != next { |
| 610 | t.Fatalf("the request follows %s, want %s", got.Head, next) |
| 611 | } |
| 612 | } |
| 613 |