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 · 1h ago
4b8c9e1d
| 1 | package access |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | "testing" |
| 6 | ) |
| 7 | |
| 8 | func TestRoleOrdering(t *testing.T) { |
| 9 | t.Parallel() |
| 10 | if !Admin.AtLeast(Maintain) || !Maintain.AtLeast(Contribute) || !Contribute.AtLeast(Read) || |
| 11 | Read.AtLeast(Contribute) || Contribute.AtLeast(Maintain) || Maintain != Write { |
| 12 | t.Fatal("role ordering is wrong") |
| 13 | } |
| 14 | for _, c := range []struct { |
| 15 | in string |
| 16 | want Role |
| 17 | ok bool |
| 18 | }{ |
| 19 | {"none", None, true}, |
| 20 | {"", None, true}, |
| 21 | {"read", Read, true}, |
| 22 | {"contributor", Contribute, true}, |
| 23 | {"contribute", Contribute, true}, |
| 24 | {"contrib", Contribute, true}, |
| 25 | {"maintain", Maintain, true}, |
| 26 | {"maintainer", Maintain, true}, |
| 27 | {"write", Maintain, true}, |
| 28 | {"admin", Admin, true}, |
| 29 | {"owner", Admin, true}, |
| 30 | {"ADMIN", Admin, true}, |
| 31 | {"root", None, false}, |
| 32 | } { |
| 33 | got, err := ParseRole(c.in) |
| 34 | if c.ok != (err == nil) { |
| 35 | t.Fatalf("ParseRole(%q) err=%v want ok=%v", c.in, err, c.ok) |
| 36 | } |
| 37 | if c.ok && got != c.want { |
| 38 | t.Fatalf("ParseRole(%q)=%v want %v", c.in, got, c.want) |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | func TestWriteIsTheHistoricalSpellingOfMaintain(t *testing.T) { |
| 43 | t.Parallel() |
| 44 | if got, err := ParseRole("write"); err != nil || got != Maintain || got.String() != "maintain" { |
| 45 | t.Fatalf("ParseRole(write) = %v, %v", got, err) |
| 46 | } |
| 47 | a, err := Parse([]byte(`{"version":1,"default":"write","entries":{"alice":"write"}}`)) |
| 48 | if err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if a.Default != "maintain" || a.Entries["alice"] != "maintain" { |
| 52 | t.Fatalf("old spelling was not normalized: %+v", a) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | func TestContributorIsTheHistoricalSpellingOfContribute(t *testing.T) { |
| 57 | t.Parallel() |
| 58 | if Contributor != Contribute || Contribute.String() != "contribute" { |
| 59 | t.Fatalf("Contribute = %v, alias = %v", Contribute, Contributor) |
| 60 | } |
| 61 | a, err := Parse([]byte(`{"version":1,"default":"contributor","entries":{"alice":"contributor"}}`)) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if a.Default != "contribute" || a.Entries["alice"] != "contribute" { |
| 66 | t.Fatalf("old spelling was not normalized: %+v", a) |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestRoleFor(t *testing.T) { |
| 71 | t.Parallel() |
| 72 | a := &ACL{Default: "none", Entries: map[string]string{ |
| 73 | "owner": "admin", "dev": "write", "guest": "read", "contributor": "contribute", |
| 74 | }} |
| 75 | cases := []struct { |
| 76 | user string |
| 77 | want Role |
| 78 | }{ |
| 79 | {"owner", Admin}, {"dev", Maintain}, {"guest", Read}, |
| 80 | {"contributor", Contribute}, {"stranger", None}, {"", None}, |
| 81 | } |
| 82 | for _, c := range cases { |
| 83 | if got := a.RoleFor(c.user); got != c.want { |
| 84 | t.Errorf("RoleFor(%q)=%v want %v", c.user, got, c.want) |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | func TestInstanceAdminsAreAdminEverywhere(t *testing.T) { |
| 90 | t.Parallel() |
| 91 | a := &ACL{Default: "none", Entries: map[string]string{ |
| 92 | "owner": "admin", "dev": "write", "guest": "read", |
| 93 | }} |
| 94 | for _, c := range []struct { |
| 95 | user string |
| 96 | instanceAdmin bool |
| 97 | want Role |
| 98 | }{ |
| 99 | {"stranger", true, Admin}, |
| 100 | {"guest", true, Admin}, |
| 101 | {"dev", true, Admin}, |
| 102 | {"owner", true, Admin}, |
| 103 | {"stranger", false, None}, |
| 104 | {"", true, None}, |
| 105 | } { |
| 106 | if got := a.EffectiveRole(c.user, c.instanceAdmin); got != c.want { |
| 107 | t.Errorf("EffectiveRole(%q, %t)=%v want %v", c.user, c.instanceAdmin, got, c.want) |
| 108 | } |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestInstanceRolesApplyToPublicReposOnly(t *testing.T) { |
| 113 | t.Parallel() |
| 114 | roles := map[string]Role{ |
| 115 | "contributor": Contribute, |
| 116 | "maintainer": Maintain, |
| 117 | "admin": Admin, |
| 118 | } |
| 119 | attach := func(a *ACL) *ACL { |
| 120 | a.instanceRole = func(name string) Role { return roles[name] } |
| 121 | return a |
| 122 | } |
| 123 | |
| 124 | private := attach(&ACL{Default: "none", Entries: map[string]string{ |
| 125 | "reader": "read", |
| 126 | }}) |
| 127 | if got := private.RoleFor("contributor"); got != None { |
| 128 | t.Fatalf("instance contributor got %v on a private repo, want none", got) |
| 129 | } |
| 130 | if got := private.RoleFor("reader"); got != Read { |
| 131 | t.Fatalf("explicit repo read = %v, want read", got) |
| 132 | } |
| 133 | if got := private.RoleFor("admin"); got != Admin { |
| 134 | t.Fatalf("instance admin got %v on a private repo, want admin", got) |
| 135 | } |
| 136 | |
| 137 | public := attach(&ACL{Default: "read"}) |
| 138 | if got := public.RoleFor("contributor"); got != Contribute { |
| 139 | t.Fatalf("instance contributor on a public repo = %v", got) |
| 140 | } |
| 141 | if got := public.RoleFor("maintainer"); got != Maintain { |
| 142 | t.Fatalf("instance maintainer on a public repo = %v", got) |
| 143 | } |
| 144 | if got := public.RoleFor("stranger"); got != Read { |
| 145 | t.Fatalf("repository default on a public repo = %v", got) |
| 146 | } |
| 147 | |
| 148 | raised := attach(&ACL{Default: "read", Entries: map[string]string{"maintainer": "admin"}}) |
| 149 | if got := raised.RoleFor("maintainer"); got != Admin { |
| 150 | t.Fatalf("repo entry did not raise the instance role: %v", got) |
| 151 | } |
| 152 | if got := raised.RepositoryRoleFor("contributor"); got != Read { |
| 153 | t.Fatalf("repository-only role for a global contributor = %v, want the default read", got) |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | func TestCapabilitiesFollowTheRole(t *testing.T) { |
| 158 | t.Parallel() |
| 159 | a := &ACL{Default: "read", Entries: map[string]string{ |
| 160 | "owner": "admin", "dev": "write", "guest": "read", "contributor": "contribute", |
| 161 | }} |
| 162 | for _, c := range []struct { |
| 163 | user string |
| 164 | discuss, push, triage, approve bool |
| 165 | }{ |
| 166 | {"owner", true, true, true, true}, |
| 167 | {"dev", true, true, true, true}, |
| 168 | {"contributor", true, true, false, false}, |
| 169 | {"guest", false, false, false, false}, |
| 170 | {"stranger", false, false, false, false}, |
| 171 | } { |
| 172 | if got := a.CanDiscuss(c.user); got != c.discuss { |
| 173 | t.Errorf("CanDiscuss(%q)=%v want %v", c.user, got, c.discuss) |
| 174 | } |
| 175 | if got := a.CanOpenRequest(c.user); got != c.discuss { |
| 176 | t.Errorf("CanOpenRequest(%q)=%v want %v", c.user, got, c.discuss) |
| 177 | } |
| 178 | if got := a.CanPush(c.user); got != c.push { |
| 179 | t.Errorf("CanPush(%q)=%v want %v", c.user, got, c.push) |
| 180 | } |
| 181 | if got := a.CanTriage(c.user); got != c.triage { |
| 182 | t.Errorf("CanTriage(%q)=%v want %v", c.user, got, c.triage) |
| 183 | } |
| 184 | if got := a.CanApprove(c.user); got != c.approve { |
| 185 | t.Errorf("CanApprove(%q)=%v want %v", c.user, got, c.approve) |
| 186 | } |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | func TestContributorRefNamespace(t *testing.T) { |
| 191 | t.Parallel() |
| 192 | own := "refs/heads/contrib/alice/topic" |
| 193 | if name, ok := ContributorRefName(own); !ok || name != "alice" { |
| 194 | t.Fatalf("ContributorRefName(%q)=%q,%v", own, name, ok) |
| 195 | } |
| 196 | if _, ok := ContributorRefName("refs/heads/contrib/alice"); ok { |
| 197 | t.Fatal("an empty contributor namespace parsed as a branch") |
| 198 | } |
| 199 | if !IsContributorRef("alice", own) { |
| 200 | t.Fatalf("alice cannot push %s", own) |
| 201 | } |
| 202 | for _, ref := range []string{ |
| 203 | "refs/heads/main", |
| 204 | "refs/heads/contrib/bob/topic", |
| 205 | "refs/heads/contrib/alice", |
| 206 | "refs/tags/contrib/alice/v1", |
| 207 | "refs/heads/vole", |
| 208 | } { |
| 209 | if IsContributorRef("alice", ref) { |
| 210 | t.Errorf("alice was allowed to push %s", ref) |
| 211 | } |
| 212 | } |
| 213 | if IsContributorRef("bob", own) { |
| 214 | t.Fatal("a different account could push alice's namespace") |
| 215 | } |
| 216 | // A name is a whole path component: alice's namespace is not alice-b's. |
| 217 | if IsContributorRef("alice", "refs/heads/contrib/alice-b/topic") { |
| 218 | t.Fatal("the namespace prefix matched a longer account name") |
| 219 | } |
| 220 | if got := ContributorBranchPrefix("alice"); got != "refs/heads/contrib/alice/" { |
| 221 | t.Fatalf("ContributorBranchPrefix = %q", got) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestDefaultAppliesToUnknownUsers(t *testing.T) { |
| 226 | t.Parallel() |
| 227 | a := &ACL{Default: "read", Entries: map[string]string{"owner": "admin"}} |
| 228 | if !a.Can("anyone", Read) { |
| 229 | t.Fatal("default read should grant read to an unknown user") |
| 230 | } |
| 231 | if a.Can("anyone", Maintain) { |
| 232 | t.Fatal("default read must not grant write") |
| 233 | } |
| 234 | if !a.Can("owner", Maintain) { |
| 235 | t.Fatal("explicit entry should win") |
| 236 | } |
| 237 | } |
| 238 | func TestIsProtected(t *testing.T) { |
| 239 | t.Parallel() |
| 240 | a := &ACL{Protected: []string{"refs/heads/main", "refs/heads/release/*"}} |
| 241 | for ref, want := range map[string]bool{ |
| 242 | "refs/heads/main": true, |
| 243 | "refs/heads/mainline": false, |
| 244 | "refs/heads/release/1.0": true, |
| 245 | "refs/heads/release": false, |
| 246 | "refs/heads/feature/x": false, |
| 247 | } { |
| 248 | if got := a.IsProtected(ref); got != want { |
| 249 | t.Errorf("IsProtected(%q)=%v want %v", ref, got, want) |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // A pattern IsProtected cannot match must be refused where it is written: today |
| 255 | // the repository reads as protected while the branch is open. |
| 256 | func TestValidateProtectedPattern(t *testing.T) { |
| 257 | t.Parallel() |
| 258 | valid := []string{ |
| 259 | "refs/heads/main", |
| 260 | "refs/heads/release/*", |
| 261 | "refs/heads/release", |
| 262 | "refs/tags/v1.*", |
| 263 | "refs/*", |
| 264 | "refs/heads/*", |
| 265 | } |
| 266 | for _, pattern := range valid { |
| 267 | if err := ValidateProtectedPattern(pattern); err != nil { |
| 268 | t.Errorf("ValidateProtectedPattern(%q) = %v, want it accepted", pattern, err) |
| 269 | } |
| 270 | } |
| 271 | invalid := map[string]string{ |
| 272 | "": "empty", |
| 273 | "refs/head/main": "refs/heads/main", |
| 274 | "refs/tag/v1.0": "refs/tags/v1.0", |
| 275 | "heads/main": "refs/heads/main", |
| 276 | "main": "refs/", |
| 277 | " refs/heads/main": "whitespace", |
| 278 | "refs/heads/ma in": "space", |
| 279 | "refs/heads/": "refs/heads/*", |
| 280 | "refs/heads": "refs/heads/*", |
| 281 | "refs": "refs/", |
| 282 | "refs/heads/ma*in": `"*"`, |
| 283 | "refs/heads/ma**": `"*"`, |
| 284 | "refs/heads/../main": `".."`, |
| 285 | "refs/heads/main.lock": ".lock", |
| 286 | "refs/heads/main.": "dot", |
| 287 | "refs/heads//main": "empty path component", |
| 288 | } |
| 289 | for pattern, want := range invalid { |
| 290 | err := ValidateProtectedPattern(pattern) |
| 291 | if err == nil { |
| 292 | t.Errorf("ValidateProtectedPattern(%q) was accepted", pattern) |
| 293 | continue |
| 294 | } |
| 295 | if !strings.Contains(err.Error(), want) { |
| 296 | t.Errorf("ValidateProtectedPattern(%q) = %q, want it to mention %q", pattern, err, want) |
| 297 | } |
| 298 | } |
| 299 | } |
| 300 | |
| 301 | // Every accepted pattern must round-trip unchanged and still protect what the |
| 302 | // operator asked for, or validation has only moved the bug. |
| 303 | func TestAcceptedPatternsRoundTrip(t *testing.T) { |
| 304 | t.Parallel() |
| 305 | patterns := []string{"refs/heads/main", "refs/heads/release/*", "refs/tags/v1.*"} |
| 306 | if err := ValidateProtectedPatterns(patterns); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | a := &ACL{Protected: append([]string(nil), patterns...)} |
| 310 | if got := strings.Join(a.Protected, ","); got != strings.Join(patterns, ",") { |
| 311 | t.Fatalf("patterns = %q", got) |
| 312 | } |
| 313 | for _, ref := range []string{"refs/heads/main", "refs/heads/release/1.0", "refs/tags/v1.2"} { |
| 314 | if !a.IsProtected(ref) { |
| 315 | t.Errorf("%q is not protected", ref) |
| 316 | } |
| 317 | } |
| 318 | if a.IsProtected("refs/heads/mainline") { |
| 319 | t.Error("an accepted exact pattern protects a longer ref") |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | func TestJSONRoundTrip(t *testing.T) { |
| 324 | t.Parallel() |
| 325 | a := Empty("keenan") |
| 326 | a.Protected = []string{"refs/heads/main"} |
| 327 | a.RequireLinkedIssue = true |
| 328 | b, err := a.Bytes() |
| 329 | if err != nil { |
| 330 | t.Fatal(err) |
| 331 | } |
| 332 | got, err := Parse(b) |
| 333 | if err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | if got.RoleFor("keenan") != Admin { |
| 337 | t.Fatalf("owner lost admin: %s", b) |
| 338 | } |
| 339 | if !got.IsProtected("refs/heads/main") { |
| 340 | t.Fatal("protection lost") |
| 341 | } |
| 342 | if !got.RequiresLinkedIssue() { |
| 343 | t.Fatal("linked-issue rule lost") |
| 344 | } |
| 345 | } |
| 346 | func TestParseRejectsBadRoles(t *testing.T) { |
| 347 | t.Parallel() |
| 348 | if _, err := Parse([]byte(`{"version":1,"default":"root"}`)); err == nil { |
| 349 | t.Fatal("expected error for unknown default role") |
| 350 | } |
| 351 | if _, err := Parse([]byte(`{"version":1,"entries":{"x":"king"}}`)); err == nil { |
| 352 | t.Fatal("expected error for unknown entry role") |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | // A combined result derived from a contributor head is still untrusted code. The |
| 357 | // runner's cache namespace has to treat it as the contributor's, or that build could |
| 358 | // seed the cache a protected branch uses. |
| 359 | func TestTrustNamespaceFollowsAContributorCandidate(t *testing.T) { |
| 360 | t.Parallel() |
| 361 | cases := map[string]string{ |
| 362 | "refs/heads/main": "direct", |
| 363 | "refs/heads/feature": "direct", |
| 364 | "refs/tags/v1.0.0": "direct", |
| 365 | "refs/heads/contrib/alice/fix": "contrib-alice", |
| 366 | "refs/vole/candidates/REQ/main-abc": "direct", |
| 367 | "refs/vole/candidates/contrib/alice/REQ/main-abc-def": "contrib-alice", |
| 368 | "refs/vole/candidates/contrib/alice": "direct", |
| 369 | "refs/vole/candidates/contrib/alice/": "contrib-alice", |
| 370 | } |
| 371 | for ref, want := range cases { |
| 372 | if got := TrustNamespace(ref); got != want { |
| 373 | t.Errorf("TrustNamespace(%q) = %q, want %q", ref, got, want) |
| 374 | } |
| 375 | } |
| 376 | } |
| 377 |