Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 19m ago 4b8c9e1d
history blame raw 17.0 KiB
1package hook
2
3// A job's credential is not the account's key: it may add to the issues branch, and
4// only what the account could already do through the browser. These tests are the
5// boundary that makes handing a bot a key safe, so they are written as refusals
6// first and permissions second.
7
8import (
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "rodent.software/vole/ci/runs"
15 "rodent.software/vole/core/git"
16 "rodent.software/vole/forge/access"
17 "rodent.software/vole/metadata"
18)
19
20// scopedFixture is a repository with a metadata branch containing one issue, a bot
21// account that may contribute, and a live run credential for it.
22type scopedFixture struct {
23 *fixture
24 bot string
25 credFP string
26 runsDB string
27 issueID string
28 base string // the metadata tip before the push under test
29}
30
31func newScopedFixture(t *testing.T) *scopedFixture {
32 t.Helper()
33 f := newFixture(t, "# demo\n")
34 // The bot gets exactly the contributor role, which is the interesting case: it is
35 // the role that may not push the issues branch with a key.
36 f.setACL(func(a *access.ACL) {
37 if err := a.SetEntry("coding-agent", "contribute"); err != nil {
38 t.Fatal(err)
39 }
40 })
41 g := f.m.Git()
42 iss, err := metadata.NewIssue("Fix the thing", "It is broken.", "coding-agent", nil, nil, time.Now())
43 if err != nil {
44 t.Fatal(err)
45 }
46 if err := metadata.CreateIssue(g, iss, "coding-agent"); err != nil {
47 t.Fatal(err)
48 }
49
50 s := &scopedFixture{
51 fixture: f,
52 bot: "coding-agent",
53 credFP: "SHA256:credential-for-this-run",
54 runsDB: filepath.Join(filepath.Dir(f.usersDB), "runs.db"),
55 issueID: iss.ID,
56 }
57 store, err := runs.Open(s.runsDB)
58 if err != nil {
59 t.Fatal(err)
60 }
61 defer store.Close()
62 if err := store.SetRunCredential(runs.RunCredential{
63 Fingerprint: s.credFP, Account: s.bot, Repo: f.m.Name, RunID: "01M6FFY0BARR9YDQ3KK64VQ0MG",
64 Expires: time.Now().Add(time.Hour),
65 }, time.Now()); err != nil {
66 t.Fatal(err)
67 }
68 s.base = s.metadataTip()
69 return s
70}
71
72func (s *scopedFixture) metadataTip() string {
73 s.t.Helper()
74 tip, err := s.m.Git().RevParse(metadata.Ref)
75 if err != nil || tip == "" {
76 s.t.Fatalf("metadata tip: %q %v", tip, err)
77 }
78 return tip
79}
80
81// push runs pre-receive as the job, with the credential fingerprint the server would
82// have supplied. It pushes everything since before the writer ran.
83func (s *scopedFixture) push(after func()) (int, string) {
84 s.t.Helper()
85 return s.pushAs(s.bot, s.credFP, after)
86}
87
88// pushAs is the same push by any account, with or without a run credential. An empty
89// keyID is an ordinary key session, which is how the browser's rules and the CLI's
90// are compared.
91func (s *scopedFixture) pushAs(actor, keyID string, after func()) (int, string) {
92 s.t.Helper()
93 before := s.metadataTip()
94 after()
95 s.head = s.metadataTip()
96 code, out := s.runFrom(Config{
97 Actor: actor,
98 KeyID: keyID,
99 RunsDB: s.runsDB,
100 MaxBlobBytes: 1 << 20,
101 }, before, metadata.Ref)
102 return code, out
103}
104
105func TestAJobMayAddItsOwnComment(t *testing.T) {
106 t.Parallel()
107 s := newScopedFixture(t)
108 cm, err := metadata.NewIssueComment("Looking at it now.", s.bot, time.Now())
109 if err != nil {
110 t.Fatal(err)
111 }
112 code, out := s.push(func() {
113 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, s.bot); err != nil {
114 t.Fatal(err)
115 }
116 })
117 if code != 0 {
118 t.Fatalf("a bot could not add its own comment: %s", out)
119 }
120}
121
122// The comment's author is a name in a file, so a job must not be able to write one
123// as somebody else.
124func TestAJobCannotCommentAsSomebodyElse(t *testing.T) {
125 t.Parallel()
126 s := newScopedFixture(t)
127 cm, err := metadata.NewIssueComment("Signed, the maintainer.", "writer", time.Now())
128 if err != nil {
129 t.Fatal(err)
130 }
131 code, out := s.push(func() {
132 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, "writer"); err != nil {
133 t.Fatal(err)
134 }
135 })
136 if code == 0 {
137 t.Fatal("a job's push wrote a comment as another account")
138 }
139 if !strings.Contains(out, "this comment says writer") {
140 t.Fatalf("refusal does not explain the author: %s", out)
141 }
142}
143
144// A contributor may manage the items it wrote: that is the rule the browser applies,
145// and a credential inherits it rather than inventing a narrower one.
146func TestAJobManagesItsOwnIssue(t *testing.T) {
147 t.Parallel()
148 s := newScopedFixture(t)
149 code, out := s.push(func() {
150 if _, err := metadata.UpdateIssue(s.m.Git(), s.issueID, func(i *metadata.Issue) error {
151 i.State = "closed"
152 i.Labels = []string{"done"}
153 return nil
154 }, s.bot); err != nil {
155 t.Fatal(err)
156 }
157 })
158 if code != 0 {
159 t.Fatalf("a contributor could not close the issue it filed: %s", out)
160 }
161}
162
163// Somebody else's issue is a maintainer's to triage, and a contributor's credential
164// does not make it one.
165func TestAJobCannotManageSomebodyElsesIssue(t *testing.T) {
166 t.Parallel()
167 s := newScopedFixture(t)
168 theirs, err := metadata.NewIssue("Their thing", "body", "writer", nil, nil, time.Now())
169 if err != nil {
170 t.Fatal(err)
171 }
172 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
173 t.Fatal(err)
174 }
175 code, out := s.push(func() {
176 if _, err := metadata.UpdateIssue(s.m.Git(), theirs.ID, func(i *metadata.Issue) error {
177 i.State = "closed"
178 return nil
179 }, s.bot); err != nil {
180 t.Fatal(err)
181 }
182 })
183 if code == 0 {
184 t.Fatal("a contributor closed an issue filed by somebody else")
185 }
186 if !strings.Contains(out, "may not change an issue filed by writer") {
187 t.Fatalf("refusal does not explain the author: %s", out)
188 }
189}
190
191// A maintainer's credential is a maintainer's key: it may do everything a maintainer
192// may do, because the role is the grant.
193func TestAMaintainerJobManagesAnyIssue(t *testing.T) {
194 t.Parallel()
195 s := newScopedFixture(t)
196 s.setACL(func(a *access.ACL) {
197 if err := a.SetEntry(s.bot, "maintain"); err != nil {
198 t.Fatal(err)
199 }
200 })
201 theirs, err := metadata.NewIssue("Their thing", "body", "writer", nil, nil, time.Now())
202 if err != nil {
203 t.Fatal(err)
204 }
205 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
206 t.Fatal(err)
207 }
208 code, out := s.push(func() {
209 if _, err := metadata.UpdateIssue(s.m.Git(), theirs.ID, func(i *metadata.Issue) error {
210 i.State = "closed"
211 return nil
212 }, s.bot); err != nil {
213 t.Fatal(err)
214 }
215 })
216 if code != 0 {
217 t.Fatalf("a maintainer credential could not triage an issue: %s", out)
218 }
219}
220
221// A contributor's change request follows a branch that contributor could have pushed.
222func TestAJobOpensAChangeForItsOwnBranch(t *testing.T) {
223 t.Parallel()
224 s := newScopedFixture(t)
225 req, err := metadata.NewRequest("Fix the thing", "body", s.bot, "main", s.head, time.Now())
226 if err != nil {
227 t.Fatal(err)
228 }
229 req.HeadRef = "contrib/coding-agent/fix"
230 code, out := s.push(func() {
231 if err := metadata.CreateRequest(s.m.Git(), req, s.bot); err != nil {
232 t.Fatal(err)
233 }
234 })
235 if code != 0 {
236 t.Fatalf("a bot could not open a change for its own branch: %s", out)
237 }
238
239 // The same request naming somebody else's branch is refused.
240 other, err := metadata.NewRequest("Fix the thing", "body", s.bot, "main", s.head, time.Now())
241 if err != nil {
242 t.Fatal(err)
243 }
244 other.HeadRef = "contrib/somebody-else/fix"
245 code, out = s.push(func() {
246 if err := metadata.CreateRequest(s.m.Git(), other, s.bot); err != nil {
247 t.Fatal(err)
248 }
249 })
250 if code == 0 {
251 t.Fatal("a job opened a change for a branch it cannot push")
252 }
253 if !strings.Contains(out, "may only propose a branch under") {
254 t.Fatalf("refusal does not explain the namespace: %s", out)
255 }
256}
257
258// An approval file is an account's statement about its own review, and a job may only
259// make one when its role could. A contribute bot cannot, which is the same rule the
260// browser applies.
261func TestAJobCannotApproveWithoutTheRole(t *testing.T) {
262 t.Parallel()
263 s := newScopedFixture(t)
264 req, err := metadata.NewRequest("Fix the thing", "body", "writer", "main", s.head, time.Now())
265 if err != nil {
266 t.Fatal(err)
267 }
268 req.HeadRef = "contrib/coding-agent/fix"
269 if err := metadata.CreateRequest(s.m.Git(), req, "writer"); err != nil {
270 t.Fatal(err)
271 }
272 code, out := s.push(func() {
273 if _, err := metadata.ApproveRequest(s.m.Git(), req.ID, s.bot, req.Base, req.Head, time.Now(), s.bot); err != nil {
274 t.Fatal(err)
275 }
276 })
277 if code == 0 {
278 t.Fatal("a contribute job approved a change")
279 }
280 if !strings.Contains(out, "may not approve changes here") {
281 t.Fatalf("refusal does not explain the role: %s", out)
282 }
283}
284
285// A credential names one repository and one run. A second repository, or one whose
286// row has expired, is not the same job.
287func TestACredentialIsBoundToItsRepository(t *testing.T) {
288 t.Parallel()
289 s := newScopedFixture(t)
290 store, err := runs.Open(s.runsDB)
291 if err != nil {
292 t.Fatal(err)
293 }
294 defer store.Close()
295 if err := store.SetRunCredential(runs.RunCredential{
296 Fingerprint: s.credFP, Account: s.bot, Repo: "somewhere-else", RunID: "01M6FFY0BARR9YDQ3KK64VQ0MG",
297 Expires: time.Now().Add(time.Hour),
298 }, time.Now()); err != nil {
299 t.Fatal(err)
300 }
301 cm, err := metadata.NewIssueComment("hello", s.bot, time.Now())
302 if err != nil {
303 t.Fatal(err)
304 }
305 code, out := s.push(func() {
306 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, s.bot); err != nil {
307 t.Fatal(err)
308 }
309 })
310 if code == 0 {
311 t.Fatal("a credential for another repository was accepted")
312 }
313 if !strings.Contains(out, "credential is for") {
314 t.Fatalf("refusal does not name the repository: %s", out)
315 }
316}
317
318// An expired row is not a credential: the session is the account's own, and it may do
319// exactly what a key session may do — no more, because expiry is not a grant.
320func TestAnExpiredCredentialIsJustTheAccount(t *testing.T) {
321 t.Parallel()
322 s := newScopedFixture(t)
323 store, err := runs.Open(s.runsDB)
324 if err != nil {
325 t.Fatal(err)
326 }
327 defer store.Close()
328 if _, err := store.ExpiredCredentials(time.Now().Add(2 * time.Hour)); err != nil {
329 t.Fatal(err)
330 }
331 if cred, err := store.RunCredential(s.credFP, time.Now()); err != nil || cred != nil {
332 t.Fatalf("an expired credential still resolves: %+v %v", cred, err)
333 }
334 // What the role allows still works...
335 cm, err := metadata.NewIssueComment("hello", s.bot, time.Now())
336 if err != nil {
337 t.Fatal(err)
338 }
339 code, out := s.push(func() {
340 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, s.bot); err != nil {
341 t.Fatal(err)
342 }
343 })
344 if code != 0 {
345 t.Fatalf("an expired credential lost the account's own capability: %s", out)
346 }
347 // ...and what it does not allow is still refused: nobody else's issue.
348 theirs, err := metadata.NewIssue("Their thing", "body", "writer", nil, nil, time.Now())
349 if err != nil {
350 t.Fatal(err)
351 }
352 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
353 t.Fatal(err)
354 }
355 code, out = s.push(func() {
356 if _, err := metadata.UpdateIssue(s.m.Git(), theirs.ID, func(i *metadata.Issue) error {
357 i.State = "closed"
358 return nil
359 }, s.bot); err != nil {
360 t.Fatal(err)
361 }
362 })
363 if code == 0 {
364 t.Fatal("an expired credential triaged somebody else's issue")
365 }
366 if !strings.Contains(out, "may not change an issue filed by writer") {
367 t.Fatalf("refusal does not explain the role: %s", out)
368 }
369}
370
371// The browser and the CLI have to offer the same account the same actions. These are
372// the push-path half: a contributor's key may file an issue, comment, manage the item
373// it wrote, and open a change from its own branch, which is what the browser's forms
374// let the same account do.
375
376func TestAContributorKeyMayComment(t *testing.T) {
377 t.Parallel()
378 s := newScopedFixture(t)
379 cm, err := metadata.NewIssueComment("Working on it.", "contributor", time.Now())
380 if err != nil {
381 t.Fatal(err)
382 }
383 code, out := s.pushAs("contributor", "", func() {
384 if err := metadata.AddIssueComment(s.m.Git(), s.issueID, cm, "contributor"); err != nil {
385 t.Fatal(err)
386 }
387 })
388 if code != 0 {
389 t.Fatalf("a contributor could not comment with its key: %s", out)
390 }
391}
392
393func TestAContributorKeyMayFileAnIssue(t *testing.T) {
394 t.Parallel()
395 s := newScopedFixture(t)
396 iss, err := metadata.NewIssue("A new thing", "body", "contributor", []string{"bug"}, nil, time.Now())
397 if err != nil {
398 t.Fatal(err)
399 }
400 code, out := s.pushAs("contributor", "", func() {
401 if err := metadata.CreateIssue(s.m.Git(), iss, "contributor"); err != nil {
402 t.Fatal(err)
403 }
404 })
405 if code != 0 {
406 t.Fatalf("a contributor could not file an issue: %s", out)
407 }
408
409 // The author is this account, as the browser fills it in from the session.
410 s = newScopedFixture(t)
411 theirs, err := metadata.NewIssue("A new thing", "body", "writer", nil, nil, time.Now())
412 if err != nil {
413 t.Fatal(err)
414 }
415 code, out = s.pushAs("contributor", "", func() {
416 if err := metadata.CreateIssue(s.m.Git(), theirs, "writer"); err != nil {
417 t.Fatal(err)
418 }
419 })
420 if code == 0 {
421 t.Fatal("a contributor filed an issue under another account")
422 }
423 if !strings.Contains(out, "this push files the issue as contributor") {
424 t.Fatalf("refusal does not explain the author: %s", out)
425 }
426}
427
428func TestAContributorKeyManagesTheIssueItFiled(t *testing.T) {
429 t.Parallel()
430 s := newScopedFixture(t)
431 iss, err := metadata.NewIssue("Mine", "body", "contributor", nil, nil, time.Now())
432 if err != nil {
433 t.Fatal(err)
434 }
435 if err := metadata.CreateIssue(s.m.Git(), iss, "contributor"); err != nil {
436 t.Fatal(err)
437 }
438 code, out := s.pushAs("contributor", "", func() {
439 if _, err := metadata.UpdateIssue(s.m.Git(), iss.ID, func(i *metadata.Issue) error {
440 i.State = "closed"
441 i.Labels = []string{"wontfix"}
442 return nil
443 }, "contributor"); err != nil {
444 t.Fatal(err)
445 }
446 })
447 if code != 0 {
448 t.Fatalf("a contributor could not close its own issue: %s", out)
449 }
450}
451
452func TestAContributorKeyOpensAChangeForItsBranch(t *testing.T) {
453 t.Parallel()
454 s := newScopedFixture(t)
455 req, err := metadata.NewRequest("Mine", "body", "contributor", "main", s.head, time.Now())
456 if err != nil {
457 t.Fatal(err)
458 }
459 req.HeadRef = "contrib/contributor/fix"
460 code, out := s.pushAs("contributor", "", func() {
461 if err := metadata.CreateRequest(s.m.Git(), req, "contributor"); err != nil {
462 t.Fatal(err)
463 }
464 })
465 if code != 0 {
466 t.Fatalf("a contributor could not open a change from its own branch: %s", out)
467 }
468
469 // Somebody else's branch is not its to propose, with a key or a credential.
470 other, err := metadata.NewRequest("Mine", "body", "contributor", "main", s.head, time.Now())
471 if err != nil {
472 t.Fatal(err)
473 }
474 other.HeadRef = "contrib/writer/fix"
475 code, out = s.pushAs("contributor", "", func() {
476 if err := metadata.CreateRequest(s.m.Git(), other, "contributor"); err != nil {
477 t.Fatal(err)
478 }
479 })
480 if code == 0 {
481 t.Fatal("a contributor proposed a branch it cannot push")
482 }
483 if !strings.Contains(out, "may only propose a branch under") {
484 t.Fatalf("refusal does not explain the namespace: %s", out)
485 }
486}
487
488func TestAContributorKeyCannotApprove(t *testing.T) {
489 t.Parallel()
490 s := newScopedFixture(t)
491 req, err := metadata.NewRequest("Theirs", "body", "writer", "main", s.head, time.Now())
492 if err != nil {
493 t.Fatal(err)
494 }
495 if err := metadata.CreateRequest(s.m.Git(), req, "writer"); err != nil {
496 t.Fatal(err)
497 }
498 code, out := s.pushAs("contributor", "", func() {
499 if _, err := metadata.ApproveRequest(s.m.Git(), req.ID, "contributor", req.Base, req.Head, time.Now(), "contributor"); err != nil {
500 t.Fatal(err)
501 }
502 })
503 if code == 0 {
504 t.Fatal("a contributor approved a change")
505 }
506 if !strings.Contains(out, "may not approve changes here") {
507 t.Fatalf("refusal does not explain the role: %s", out)
508 }
509}
510
511// A maintainer keeps everything a maintainer has, and gains the one rule the browser
512// applies to everyone: a review is somebody else's judgement.
513func TestAMaintainerKeyApprovesSomebodyElsesChange(t *testing.T) {
514 t.Parallel()
515 s := newScopedFixture(t)
516 req, err := metadata.NewRequest("Mine", "body", "contributor", "main", s.head, time.Now())
517 if err != nil {
518 t.Fatal(err)
519 }
520 req.HeadRef = "contrib/contributor/fix"
521 if err := metadata.CreateRequest(s.m.Git(), req, "contributor"); err != nil {
522 t.Fatal(err)
523 }
524 code, out := s.pushAs("writer", "", func() {
525 if _, err := metadata.ApproveRequest(s.m.Git(), req.ID, "writer", req.Base, req.Head, time.Now(), "writer"); err != nil {
526 t.Fatal(err)
527 }
528 })
529 if code != 0 {
530 t.Fatalf("a maintainer could not approve somebody else's change: %s", out)
531 }
532}
533
534func TestAMaintainerCannotApproveItsOwnChange(t *testing.T) {
535 t.Parallel()
536 s := newScopedFixture(t)
537 req, err := metadata.NewRequest("Mine", "body", "writer", "main", s.head, time.Now())
538 if err != nil {
539 t.Fatal(err)
540 }
541 if err := metadata.CreateRequest(s.m.Git(), req, "writer"); err != nil {
542 t.Fatal(err)
543 }
544 // The metadata writer refuses self-approval too, so this is written by hand: the
545 // hook is the boundary for a push that did not come from the CLI.
546 path := metadata.ChangesDir + "/" + req.ID + "/approvals/writer.md"
547 raw := []byte("---\nbase: main\nhead: " + s.head + "\ncreated: " +
548 time.Now().UTC().Format(time.RFC3339) + "\n---\n")
549 code, out := s.pushAs("writer", "", func() {
550 if _, err := git.CommitFiles(s.m.Git(), metadata.Ref, func(files map[string][]byte) error {
551 files[path] = raw
552 return nil
553 }, "hand-written approval", "writer"); err != nil {
554 t.Fatal(err)
555 }
556 })
557 if code == 0 {
558 t.Fatal("a maintainer approved its own change")
559 }
560 if !strings.Contains(out, "cannot approve their own request") {
561 t.Fatalf("refusal does not explain self-approval: %s", out)
562 }
563}
564