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.

Blame · forge/access/store_test.go

Line attribution on main · 375 line(s)

ec5c519c
Keenan Nemetz
16d ago
1 package access
2
3 // Access rules are one row per repository in the instance database. These tests pin the
4 // two things the file-based version gave for free: a repository with no rules denies
5 // everything, and a change leaves a trace.
6 import (
62b59a76
Keenan Nemetz
12d ago
7 "errors"
8 "fmt"
ec5c519c
Keenan Nemetz
16d ago
9 "os"
10 "path/filepath"
11 "strings"
62b59a76
Keenan Nemetz
12d ago
12 "sync"
ec5c519c
Keenan Nemetz
16d ago
13 "testing"
14 "time"
15 )
16
17 func open(t *testing.T) (*Store, string) {
18 t.Helper()
19 dir := t.TempDir()
20 path := filepath.Join(dir, "users.db")
21 s, err := Open(path)
22 if err != nil {
23 t.Fatal(err)
24 }
b1499c72
Keenan Nemetz
13d ago
25 s.AuditPath = filepath.Join(dir, "audit.log")
ec5c519c
Keenan Nemetz
16d ago
26 t.Cleanup(func() { s.Close() })
27 return s, dir
28 }
29 func TestRulesRoundTripAndReplace(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
30 t.Parallel()
ec5c519c
Keenan Nemetz
16d ago
31 s, _ := open(t)
32 if a, err := s.Get("repo-1"); err != nil || a != nil {
33 t.Fatalf("a repository with no rules returned %+v, %v", a, err)
34 }
c5902451
Keenan Nemetz
16d ago
35 a := Empty("01M5OWNER0000000000000000A")
ec5c519c
Keenan Nemetz
16d ago
36 if err := a.SetEntry("01M5WRITER000000000000000B", "write"); err != nil {
37 t.Fatal(err)
38 }
39 a.Protected = []string{"refs/heads/main"}
40 a.SetRunners([]string{"01M5RUNNER000000000000000C"})
41 if err := s.Set("repo-1", a, "keenan", time.Now().UTC()); err != nil {
42 t.Fatal(err)
43 }
44 got, err := s.Get("repo-1")
45 if err != nil || got == nil {
46 t.Fatalf("get: %+v, %v", got, err)
47 }
c5902451
Keenan Nemetz
16d ago
48 if got.RoleFor("01M5WRITER000000000000000B") != Write ||
ec5c519c
Keenan Nemetz
16d ago
49 !got.CanReport("01M5RUNNER000000000000000C") ||
50 len(got.Protected) != 1 {
51 t.Fatalf("rules came back different: %+v", got)
52 }
62b59a76
Keenan Nemetz
12d ago
53 // A blind replacement is refused: a rule set that was not read from the row
54 // cannot know what it is overwriting, so it could restore a role somebody just
55 // revoked.
56 if err := s.Set("repo-1", Empty("01M5OWNER0000000000000000A"), "keenan", time.Now().UTC()); !errors.Is(err, ErrConflict) {
57 t.Fatalf("a blind replacement = %v, want ErrConflict", err)
58 }
59 // A replacement through the rule set that was read is allowed, and nothing of
60 // the old list survives it.
c5902451
Keenan Nemetz
16d ago
61 next := Empty("01M5OWNER0000000000000000A")
62b59a76
Keenan Nemetz
12d ago
62 next.revision = got.revision
ec5c519c
Keenan Nemetz
16d ago
63 if err := s.Set("repo-1", next, "keenan", time.Now().UTC()); err != nil {
64 t.Fatal(err)
65 }
66 got, _ = s.Get("repo-1")
c5902451
Keenan Nemetz
16d ago
67 if got.RoleFor("01M5WRITER000000000000000B") != None {
ec5c519c
Keenan Nemetz
16d ago
68 t.Fatalf("the previous rule survived a replacement: %+v", got)
69 }
70 // Deleting the repository forgets its rules, so a new repository with a recycled id
71 // cannot inherit them.
72 if err := s.Delete("repo-1"); err != nil {
73 t.Fatal(err)
74 }
75 if a, err := s.Get("repo-1"); err != nil || a != nil {
76 t.Fatalf("rules survived deletion: %+v, %v", a, err)
77 }
78 }
79
5b814e99
Keenan Nemetz
14d ago
80 // The store is where the roster's instance-wide role meets a repository's rules.
81 // A public repository gets the global grant; a private one does not, unless the
82 // account is an instance admin.
83 func TestStoreAttachesInstanceRoles(t *testing.T) {
84 t.Parallel()
85 s, _ := open(t)
86 s.InstanceRole = func(name string) Role {
87 switch name {
88 case "invited":
89 return Contribute
90 case "boss":
91 return Admin
92 }
93 return None
94 }
95 pub := Empty("owner")
96 pub.Default = "read"
97 if err := s.Set("public-repo", pub, "test", time.Now().UTC()); err != nil {
98 t.Fatal(err)
99 }
100 private := Empty("owner")
101 if err := s.Set("private-repo", private, "test", time.Now().UTC()); err != nil {
102 t.Fatal(err)
103 }
104
105 got, err := s.Get("public-repo")
106 if err != nil {
107 t.Fatal(err)
108 }
109 if got.RoleFor("invited") != Contribute {
110 t.Fatalf("public instance role = %v", got.RoleFor("invited"))
111 }
112 if got.RepositoryRoleFor("invited") != Read {
113 t.Fatalf("repository-only role = %v, want the default read", got.RepositoryRoleFor("invited"))
114 }
115 got, err = s.Get("private-repo")
116 if err != nil {
117 t.Fatal(err)
118 }
119 if got.RoleFor("invited") != None {
120 t.Fatalf("private repo admitted instance contributor: %v", got.RoleFor("invited"))
121 }
122 if got.RoleFor("boss") != Admin {
123 t.Fatalf("instance admin on private repo = %v", got.RoleFor("boss"))
124 }
125 // A repository without a row still has to see the instance rules.
126 empty := s.Attach(Empty(""))
127 if empty.RoleFor("boss") != Admin {
128 t.Fatalf("attached empty ACL = %v", empty.RoleFor("boss"))
129 }
130 }
131
ec5c519c
Keenan Nemetz
16d ago
132 // An access change is the one administrative act that has to leave a trace: the copy
133 // that lived in git kept it in the commit message.
134 func TestChangesAreAudited(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
135 t.Parallel()
ec5c519c
Keenan Nemetz
16d ago
136 s, dir := open(t)
137 audit := filepath.Join(dir, "audit.log")
138 s.AuditPath = audit
c5902451
Keenan Nemetz
16d ago
139 if err := s.Set("repo-1", Empty("01M5OWNER0000000000000000A"), "keenan", time.Now().UTC()); err != nil {
ec5c519c
Keenan Nemetz
16d ago
140 t.Fatal(err)
141 }
142 raw, err := os.ReadFile(audit)
143 if err != nil {
144 t.Fatalf("no audit line: %v", err)
145 }
b1499c72
Keenan Nemetz
13d ago
146 fields := strings.Split(strings.TrimSuffix(string(raw), "\n"), "\t")
147 if len(fields) != 5 {
148 t.Fatalf("audit line has %d columns, want 5: %q", len(fields), raw)
149 }
150 if fields[1] != "acl.update" || fields[2] != "keenan" || fields[3] != "repo-1" {
151 t.Fatalf("audit line = %q, want action acl.update by keenan on repo-1", raw)
152 }
153 }
154
155 // An ACL change that cannot be recorded must not happen: the rule set is the
156 // instance's only copy, and an unrecorded grant is a grant nobody can explain.
157 func TestChangesFailWhenTheLogCannotBeWritten(t *testing.T) {
158 t.Parallel()
159 s, dir := open(t)
160 blocker := filepath.Join(dir, "blocked")
161 if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
162 t.Fatal(err)
163 }
164 s.AuditPath = filepath.Join(blocker, "audit.log")
165 owner := "01M5OWNER0000000000000000A"
166 if err := s.Set("repo-1", Empty(owner), "keenan", time.Now().UTC()); err == nil {
167 t.Fatal("Set succeeded with an unwritable audit log")
168 }
169 if got, err := s.Get("repo-1"); err != nil || got != nil {
170 t.Fatalf("rules were written anyway: %+v, %v", got, err)
171 }
172 // The deletion is recorded separately because the repository's content has to
173 // survive a failed audit, so it fails the same way and for the same reason.
174 if err := s.RecordDelete("repo-1", "keenan"); err == nil {
175 t.Fatal("RecordDelete succeeded with an unwritable audit log")
176 }
177 }
178
179 // A key comment, a repository name and an account name are user content; none may
180 // add a column or a record of its own.
181 func TestAuditFieldsCannotInjectColumns(t *testing.T) {
182 t.Parallel()
183 s, dir := open(t)
184 audit := filepath.Join(dir, "audit.log")
185 s.AuditPath = audit
186 evile := "a\tb\nc"
187 if err := s.Set(evile, Empty("01M5OWNER0000000000000000A"), evile, time.Now().UTC()); err != nil {
188 t.Fatal(err)
189 }
190 raw, err := os.ReadFile(audit)
191 if err != nil {
192 t.Fatal(err)
193 }
194 if n := strings.Count(string(raw), "\n"); n != 1 {
195 t.Fatalf("injected field wrote %d lines: %q", n, raw)
196 }
197 if got := len(strings.Split(strings.TrimSuffix(string(raw), "\n"), "\t")); got != 5 {
198 t.Fatalf("injected field wrote %d columns: %q", got, raw)
ec5c519c
Keenan Nemetz
16d ago
199 }
200 }
62b59a76
Keenan Nemetz
12d ago
201
202 // The race the settings page used to lose: a repository admin's page was loaded
203 // while they were still an admin, an instance admin revokes that role, and the
204 // stale save then wrote the whole rule set back and restored it. A write against a
205 // rule set that was read before somebody else's change is now refused, so the
206 // revocation wins.
207 func TestARepoAdminSaveCannotRestoreARevokedRole(t *testing.T) {
208 t.Parallel()
209 s, _ := open(t)
210 owner := "01M5OWNER0000000000000000A"
211 if err := s.Set("repo-1", Empty(owner), "keenan", time.Now().UTC()); err != nil {
212 t.Fatal(err)
213 }
214 // Two readers load the same revision; the second is about to save a rule set
215 // that says somebody is an admin.
216 first, err := s.Get("repo-1")
217 if err != nil {
218 t.Fatal(err)
219 }
220 stale, err := s.Get("repo-1")
221 if err != nil {
222 t.Fatal(err)
223 }
224 if err := first.SetEntry("eve", "admin"); err != nil {
225 t.Fatal(err)
226 }
227 if err := s.Set("repo-1", first, "keenan", time.Now().UTC()); err != nil {
228 t.Fatal(err)
229 }
230 // The revocation lands.
231 if _, err := s.Update("repo-1", "root", func(a *ACL) error {
232 return a.SetEntry("eve", "none")
233 }); err != nil {
234 t.Fatal(err)
235 }
236 // The stale save cannot restore it.
237 if err := s.Set("repo-1", stale, "keenan", time.Now().UTC()); !errors.Is(err, ErrConflict) {
238 t.Fatalf("a stale write = %v, want ErrConflict", err)
239 }
240 got, err := s.Get("repo-1")
241 if err != nil {
242 t.Fatal(err)
243 }
244 if got.RoleFor("eve") == Admin {
245 t.Fatalf("a stale write restored a revoked role: %+v", got.Entries)
246 }
247 }
248
249 // Update changes the row as it is now, so a change to one field cannot undo a
250 // concurrent change to another.
251 func TestUpdateKeepsAConcurrentChange(t *testing.T) {
252 t.Parallel()
253 s, _ := open(t)
254 if _, err := s.Update("repo-1", "keenan", func(a *ACL) error {
255 return a.SetEntry("owner", "admin")
256 }); err != nil {
257 t.Fatal(err)
258 }
259 // Both writers loaded nothing in particular; each applies the field it owns.
260 if _, err := s.Update("repo-1", "keenan", func(a *ACL) error {
261 return a.SetEntry("alice", "maintain")
262 }); err != nil {
263 t.Fatal(err)
264 }
265 if _, err := s.Update("repo-1", "keenan", func(a *ACL) error {
266 return a.SetEntry("bob", "read")
267 }); err != nil {
268 t.Fatal(err)
269 }
270 got, err := s.Get("repo-1")
271 if err != nil {
272 t.Fatal(err)
273 }
274 for name, want := range map[string]Role{"owner": Admin, "alice": Maintain, "bob": Read} {
275 if got.RoleFor(name) != want {
276 t.Fatalf("%s = %v, want %v (entries %+v)", name, got.RoleFor(name), want, got.Entries)
277 }
278 }
279 }
280
281 // Concurrent field-scoped edits all survive: the store retries the compare-and-swap
282 // instead of dropping the losers.
283 func TestConcurrentUpdatesAllLand(t *testing.T) {
284 t.Parallel()
285 s, _ := open(t)
286 const writers = 8
287 var wg sync.WaitGroup
288 errs := make(chan error, writers)
289 for i := 0; i < writers; i++ {
290 wg.Add(1)
291 go func(i int) {
292 defer wg.Done()
293 name := fmt.Sprintf("account-%d", i)
294 _, err := s.Update("repo-1", "keenan", func(a *ACL) error {
295 return a.SetEntry(name, "read")
296 })
297 if err != nil {
298 errs <- err
299 }
300 }(i)
301 }
302 wg.Wait()
303 close(errs)
304 for err := range errs {
305 t.Fatalf("Update: %v", err)
306 }
307 got, err := s.Get("repo-1")
308 if err != nil {
309 t.Fatal(err)
310 }
311 for i := 0; i < writers; i++ {
312 if name := fmt.Sprintf("account-%d", i); got.RoleFor(name) != Read {
313 t.Fatalf("%s did not land: %+v", name, got.Entries)
314 }
315 }
316 }
317
318 // A retired account stops counting even while its name is still in the rules: the
319 // row outlives the account, so membership alone is not a grant that lasts.
320 func TestRetiredAccountsStopCounting(t *testing.T) {
321 t.Parallel()
322 s, _ := open(t)
323 s.Active = func(name string) bool { return name != "retired" }
324 if _, err := s.Update("repo-1", "keenan", func(a *ACL) error {
325 if err := a.SetEntry("retired", "maintain"); err != nil {
326 return err
327 }
328 a.SetRunners([]string{"retired", "active-runner"})
329 return nil
330 }); err != nil {
331 t.Fatal(err)
332 }
333 got, err := s.Get("repo-1")
334 if err != nil {
335 t.Fatal(err)
336 }
337 if got.EligibleApprover("retired") {
338 t.Fatal("a retired maintainer still counts as an approver")
339 }
340 if !got.EligibleApprover("keenan") && got.RoleFor("keenan") == Admin {
341 t.Fatal("an active admin stopped counting")
342 }
343 if got.CanReport("retired") {
344 t.Fatal("a retired runner still counts as a runner")
345 }
346 if !got.CanReport("active-runner") {
347 t.Fatal("an active runner stopped counting")
348 }
349 }
350
351 // An approval needs current standing, not just a file on the branch: a contributor
352 // who writes approvals/<their-own-name>.md cannot satisfy a review.
353 func TestEligibleApproverNeedsMaintainOrAdmin(t *testing.T) {
354 t.Parallel()
355 s, _ := open(t)
356 if _, err := s.Update("repo-1", "keenan", func(a *ACL) error {
357 if err := a.SetEntry("writer", "contribute"); err != nil {
358 return err
359 }
360 a.Default = "read"
361 return nil
362 }); err != nil {
363 t.Fatal(err)
364 }
365 got, err := s.Get("repo-1")
366 if err != nil {
367 t.Fatal(err)
368 }
369 if got.EligibleApprover("writer") {
370 t.Fatal("a contributor's approval counts")
371 }
372 if got.EligibleApprover("stranger") {
373 t.Fatal("an approval from somebody with only the default role counts")
374 }
375 }