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/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.

Code

395 commit(s) on main

update readme Keenan Nemetz · 1h ago 32290114
history blame raw 5.5 KiB
1package ssh
2
3// --repos lets one runner narrow what it will take, and the narrowing is done on the
4// server against what the account's allowlist grants. These tests are about the
5// direction of that: an operator's filter may shrink a grant, and a client's request
6// may never grow it. The pure half (narrow) is pinned directly; the wire half is pinned
7// through a real session, because the thing that could regress is a request field that
8// nobody remembers to apply.
9
10import (
11 "strings"
12 "testing"
13 "time"
14
15 "rodent.software/vole/ci/runs"
16 "rodent.software/vole/core/id"
17)
18
19func TestNarrowCanOnlySubtract(t *testing.T) {
20 t.Parallel()
21 granted := []string{"a", "b", "c"}
22 cases := []struct {
23 name string
24 asked []string
25 want []string
26 }{
27 {"no filter keeps the whole grant", nil, []string{"a", "b", "c"}},
28 {"a filter keeps only what it names", []string{"b"}, []string{"b"}},
29 {"order follows the grant, not the filter", []string{"c", "a"}, []string{"a", "c"}},
30 {"duplicates are harmless", []string{"a", "a"}, []string{"a"}},
31 {"a name that was never granted is dropped", []string{"a", "z"}, []string{"a"}},
32 {"names outside the grant yield nothing", []string{"z"}, nil},
33 {"an empty grant yields nothing however it is asked for", []string{"a"}, nil},
34 }
35 for _, c := range cases {
36 t.Run(c.name, func(t *testing.T) {
37 g := granted
38 if strings.Contains(c.name, "empty grant") {
39 g = nil
40 }
41 got := narrow(g, c.asked)
42 if strings.Join(got, ",") != strings.Join(c.want, ",") {
43 t.Fatalf("narrow(%v, %v) = %v, want %v", g, c.asked, got, c.want)
44 }
45 })
46 }
47}
48
49// A lease request that names a repository the account may not run for gets nothing,
50// rather than that repository. The allowlist is what decides; the flag only filters
51// what it produced.
52func TestARequestCannotLeaseBeyondTheAllowlist(t *testing.T) {
53 t.Parallel()
54 h := newHarness(t)
55 c := h.runner(t, h.keyPath)
56 if rep := c.call(runnerRequest{Op: "ping"}); rep.Error != "" {
57 t.Fatal(rep.Error)
58 }
59
60 // A second repository the writer's account is not a runner for. Its ACL gives the
61 // writer write access, which is exactly the distinction that matters: writing to a
62 // repository is not permission to execute its code.
63 other, err := h.repos.Create("other", h.writerID, "")
64 if err != nil {
65 t.Fatal(err)
66 }
67 a, err := other.ACL()
68 if err != nil {
69 t.Fatal(err)
70 }
71 if err := a.SetEntry(h.writerID, "maintain"); err != nil {
72 t.Fatal(err)
73 }
74 if err := other.SetACL(a, "test"); err != nil {
75 t.Fatal(err)
76 }
77 runID, err := id.New(time.Now().UTC())
78 if err != nil {
79 t.Fatal(err)
80 }
81 if _, err := h.runs.Enqueue(runs.Run{
82 ID: runID, Repo: "other", Ref: "refs/heads/main", Branch: "main",
83 SHA: strings.Repeat("ab", 20), Workflow: ".vole/workflows/ci.yaml",
84 WorkflowSHA: strings.Repeat("cd", 20), Job: "forbidden",
85 Steps: []runs.Step{{Name: "test", Run: "true"}}, Created: time.Now().UTC(),
86 }, nil); err != nil {
87 t.Fatal(err)
88 }
89
90 // Asking for it explicitly must not help: the grant for demo does not include
91 // other, so the intersection is empty and nothing is leased.
92 rep := c.call(runnerRequest{Op: "lease", Repos: []string{"other"}})
93 if rep.Error != "" {
94 t.Fatal(rep.Error)
95 }
96 if rep.Run != nil {
97 t.Fatalf("leased %q from %q, which the account may not run for", rep.Run.Job, rep.Run.Repo)
98 }
99
100 // The work is still there for somebody who is allowed: an admin is implicit.
101 admin, err := h.runs.Lease(runs.LeaseRequest{
102 Owner: h.writerID, Repos: []string{"other"}, TTL: time.Minute,
103 }, time.Now().UTC())
104 if err != nil {
105 t.Fatal(err)
106 }
107 if admin == nil {
108 t.Fatal("nobody could lease the job; it should still be queued")
109 }
110}
111
112// A filter naming one repository takes that repository's jobs and leaves the rest.
113func TestARepoFilterTakesOnlyWhatItNames(t *testing.T) {
114 t.Parallel()
115 h := newHarness(t)
116 c := h.runner(t, h.keyPath)
117 if rep := c.call(runnerRequest{Op: "ping"}); rep.Error != "" {
118 t.Fatal(rep.Error)
119 }
120
121 // Two repositories the account may run for, one job in each.
122 for _, name := range []string{"demo", "second"} {
123 m, err := h.repos.Open(name)
124 if err != nil {
125 m, err = h.repos.Create(name, h.writerID, "")
126 if err != nil {
127 t.Fatal(err)
128 }
129 }
130 a, err := m.ACL()
131 if err != nil {
132 t.Fatal(err)
133 }
134 a.SetRunners([]string{h.writerID})
135 if err := m.SetACL(a, "test"); err != nil {
136 t.Fatal(err)
137 }
138 runID, err := id.New(time.Now().UTC())
139 if err != nil {
140 t.Fatal(err)
141 }
142 if _, err := h.runs.Enqueue(runs.Run{
143 ID: runID, Repo: name, Ref: "refs/heads/main", Branch: "main",
144 SHA: strings.Repeat("ab", 20), Workflow: ".vole/workflows/ci.yaml",
145 WorkflowSHA: strings.Repeat("cd", 20), Job: "job-" + name,
146 Steps: []runs.Step{{Name: "test", Run: "true"}}, Created: time.Now().UTC(),
147 }, nil); err != nil {
148 t.Fatal(err)
149 }
150 }
151
152 // The ping reports the grant so the client can check its own filter against it.
153 if rep := c.call(runnerRequest{Op: "ping"}); len(rep.Repos) != 2 {
154 t.Fatalf("ping reported %v, want both repositories the account may run for", rep.Repos)
155 }
156
157 rep := c.call(runnerRequest{Op: "lease", Repos: []string{"second"}})
158 if rep.Error != "" {
159 t.Fatal(rep.Error)
160 }
161 if rep.Run == nil {
162 t.Fatal("the filtered repository's job was not leased")
163 }
164 if rep.Run.Repo != "second" {
165 t.Fatalf("leased %q from %q; the filter named only second", rep.Run.Job, rep.Run.Repo)
166 }
167 // demo's job is untouched by that filter: a second, unfiltered runner takes it.
168 if again := c.call(runnerRequest{Op: "lease"}); again.Run == nil || again.Run.Repo != "demo" {
169 t.Fatalf("unfiltered lease = %+v, want demo's job", again.Run)
170 }
171}
172