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 · 52m ago 4b8c9e1d
history blame raw 12.1 KiB
1package server_test
2
3import (
4 "bytes"
5 "net/url"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "rodent.software/vole/ci/runs"
14 "rodent.software/vole/forge/config"
15)
16
17// cliAs runs the built binary in dir with a chosen key and stdin, so a test can
18// ask the instance a question as somebody other than the fixture's owner.
19func (f *fixture) cliAs(key, dir, stdin string, args ...string) (string, string, error) {
20 f.t.Helper()
21 cmd := exec.Command(binPath, args...)
22 cmd.Dir = dir
23 cmd.Env = append(os.Environ(),
24 "GIT_SSH_COMMAND=ssh -i "+key+" -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
25 "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
26 "USER=keenan")
27 cmd.Stdin = strings.NewReader(stdin)
28 var out, errb bytes.Buffer
29 cmd.Stdout, cmd.Stderr = &out, &errb
30 err := cmd.Run()
31 return out.String(), errb.String(), err
32}
33
34// secretWorkTree is a checkout whose origin points at the instance, which is all
35// the secret verbs need: they ask the server, not the clone.
36func (f *fixture) secretWorkTree(t *testing.T) string {
37 t.Helper()
38 work := workRepo(t, map[string]string{"readme.md": "hello\n"})
39 if _, errs, err := f.git(work, "remote", "add", "origin", f.sshURL(f.repo)); err != nil {
40 t.Fatalf("add remote: %v\n%s", err, errs)
41 }
42 return work
43}
44
45func TestSecretRoundTripOverTheCLI(t *testing.T) {
46 t.Parallel()
47 f := newFixture(t)
48 f.publish(f.repo)
49 work := f.secretWorkTree(t)
50
51 out, errs, err := f.cliAs(f.keyPath, work, "s3cret\n", "secret", "set", f.repo, "DEPLOY_KEY")
52 if err != nil {
53 t.Fatalf("secret set: %v\n%s", err, errs)
54 }
55 if want := "saved DEPLOY_KEY v1 for " + f.repo + "\n"; out != want {
56 t.Fatalf("set said %q, want %q", out, want)
57 }
58
59 // get writes the value and nothing else, with no line ending added: a redirect
60 // must produce the bytes that were stored.
61 out, errs, err = f.cli(work, "secret", "get", f.repo, "DEPLOY_KEY")
62 if err != nil {
63 t.Fatalf("secret get: %v\n%s", err, errs)
64 }
65 if out != "s3cret" {
66 t.Fatalf("get = %q, want %q", out, "s3cret")
67 }
68
69 out, errs, err = f.cli(work, "secret", "list", f.repo)
70 if err != nil {
71 t.Fatalf("secret list: %v\n%s", err, errs)
72 }
73 for _, want := range []string{"DEPLOY_KEY", "v1", "last read"} {
74 if !strings.Contains(out, want) {
75 t.Fatalf("list has no %q:\n%s", want, out)
76 }
77 }
78 if strings.Contains(out, "s3cret") {
79 t.Fatalf("list shows a value:\n%s", out)
80 }
81
82 // Saving the same name again rotates it, and the version says so.
83 out, errs, err = f.cliAs(f.keyPath, work, "rotated", "secret", "set", f.repo, "DEPLOY_KEY")
84 if err != nil {
85 t.Fatalf("second secret set: %v\n%s", err, errs)
86 }
87 if want := "saved DEPLOY_KEY v2 for " + f.repo + "\n"; out != want {
88 t.Fatalf("set said %q, want %q", out, want)
89 }
90 if out, _, err := f.cli(work, "secret", "get", f.repo, "DEPLOY_KEY"); err != nil || out != "rotated" {
91 t.Fatalf("get after rotation = %q, %v", out, err)
92 }
93
94 audit, err := os.ReadFile(f.cfg.AuditLog())
95 if err != nil {
96 t.Fatal(err)
97 }
98 for _, want := range []string{"secret.write", "secret.read"} {
99 if !strings.Contains(string(audit), want) {
100 t.Fatalf("audit log has no %q:\n%s", want, audit)
101 }
102 }
103 if strings.Contains(string(audit), "s3cret") || strings.Contains(string(audit), "rotated") {
104 t.Fatalf("audit log holds a value:\n%s", audit)
105 }
106
107 out, errs, err = f.cli(work, "secret", "rm", f.repo, "DEPLOY_KEY")
108 if err != nil {
109 t.Fatalf("secret rm: %v\n%s", err, errs)
110 }
111 if want := "deleted DEPLOY_KEY from " + f.repo + "\n"; out != want {
112 t.Fatalf("rm said %q, want %q", out, want)
113 }
114 if _, errs, err := f.cli(work, "secret", "get", f.repo, "DEPLOY_KEY"); err == nil {
115 t.Fatalf("a deleted secret still reads:\n%s", errs)
116 } else if !strings.Contains(errs, "has no secret DEPLOY_KEY") {
117 t.Fatalf("get after delete = %s", errs)
118 }
119}
120
121func TestSecretVerbsNeedTheMaintainerRole(t *testing.T) {
122 t.Parallel()
123 f := newFixture(t)
124 f.publish(f.repo)
125 work := f.secretWorkTree(t)
126
127 pub, key := keypair(t, filepath.Join(f.cfg.DataDir, "id_reader"))
128 if _, err := f.srv.Identity.Create("robin", pub, "test"); err != nil {
129 t.Fatal(err)
130 }
131
132 // No ACL entry, so the repository's default role is all this account has.
133 if _, errs, err := f.cliAs(key, work, "", "secret", "list", f.repo); err == nil {
134 t.Fatalf("an account without maintain read the list:\n%s", errs)
135 } else if !strings.Contains(errs, "may not manage secrets") {
136 t.Fatalf("refusal = %s", errs)
137 }
138 if _, errs, err := f.cliAs(key, work, "value", "secret", "set", f.repo, "TOKEN"); err == nil {
139 t.Fatalf("an account without maintain wrote a secret:\n%s", errs)
140 }
141}
142
143func TestSecretsOnTheSettingsPage(t *testing.T) {
144 t.Parallel()
145 f := browserFixture(t)
146 owner, cookie := f.browserSession(t, f.ownerID)
147
148 // The owner is a repository admin, and an admin is a maintainer, so the
149 // section is theirs too.
150 resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=secrets", cookie)
151 if resp.StatusCode != 200 || !strings.Contains(body, `id="secrets"`) {
152 t.Fatalf("settings page has no secrets section: %d\n%s", resp.StatusCode, body)
153 }
154
155 value := "-----BEGIN KEY-----\nline two\n-----END KEY-----"
156 form := url.Values{
157 "csrf": {owner.CSRF},
158 "secret_name": {"PRIVATE_KEY"},
159 "secret_value": {strings.ReplaceAll(value, "\n", "\r\n")},
160 }
161 resp, body = f.browserPost(t, "/"+f.repo+"/settings/secrets", cookie, form)
162 if resp.StatusCode != 303 {
163 t.Fatalf("save = %d: %s", resp.StatusCode, body)
164 }
165 if loc := resp.Header.Get("Location"); !strings.Contains(loc, "secret_saved=PRIVATE_KEY+v1") {
166 t.Fatalf("save redirected to %q", loc)
167 }
168
169 resp, body = f.browserGet(t, "/"+f.repo+"/settings?tab=secrets&secret_saved=PRIVATE_KEY+v1", cookie)
170 if resp.StatusCode != 200 {
171 t.Fatalf("settings after save = %d", resp.StatusCode)
172 }
173 for _, want := range []string{"Saved PRIVATE_KEY v1.", "PRIVATE_KEY", "v1", "never"} {
174 if !strings.Contains(body, want) {
175 t.Fatalf("the page has no %q:\n%s", want, body)
176 }
177 }
178 // A value never reaches a page: not the whole of it, and not its distinctive
179 // line.
180 for _, secret := range []string{"BEGIN KEY", "line two"} {
181 if strings.Contains(body, secret) {
182 t.Fatalf("the page rendered %q:\n%s", secret, body)
183 }
184 }
185
186 // The stored value is the textarea's value with its line ends normalised, so a
187 // pasted key keeps the bytes a file would have.
188 m, err := f.srv.Repos.Open(f.repo)
189 if err != nil {
190 t.Fatal(err)
191 }
192 got, version, err := f.srv.Secrets.Read(m.ID, "PRIVATE_KEY", f.ownerID, "test")
193 if err != nil {
194 t.Fatal(err)
195 }
196 if got != value || version != 1 {
197 t.Fatalf("stored %q v%d", got, version)
198 }
199
200 deleteForm := url.Values{"csrf": {owner.CSRF}, "secret_name": {"PRIVATE_KEY"}}
201 resp, body = f.browserPost(t, "/"+f.repo+"/settings/secrets/delete", cookie, deleteForm)
202 if resp.StatusCode != 303 || !strings.Contains(resp.Header.Get("Location"), "secret_deleted=PRIVATE_KEY") {
203 t.Fatalf("delete = %d %q: %s", resp.StatusCode, resp.Header.Get("Location"), body)
204 }
205 if _, _, err := f.srv.Secrets.Read(m.ID, "PRIVATE_KEY", f.ownerID, "test"); err == nil {
206 t.Fatal("the value survived the delete")
207 }
208}
209
210func TestAMaintainerSeesTheSettingsTabForSecrets(t *testing.T) {
211 t.Parallel()
212 f := browserFixture(t)
213 f.addWriter(t)
214 _, cookie := f.browserSession(t, f.writerID)
215
216 resp, body := f.browserGet(t, "/"+f.repo+"/settings", cookie)
217 if resp.StatusCode != 200 {
218 t.Fatalf("a maintainer's settings page = %d\n%s", resp.StatusCode, body)
219 }
220 if !strings.Contains(body, `id="secrets"`) {
221 t.Fatalf("a maintainer cannot see the secrets section:\n%s", body)
222 }
223 // The parts of the page only an admin owns must not be offered.
224 for _, adminOnly := range []string{"Default branch", "Rename repository", "Delete repository", `id="access"`} {
225 if strings.Contains(body, adminOnly) {
226 t.Fatalf("a maintainer was offered %q:\n%s", adminOnly, body)
227 }
228 }
229
230 // An account with no role in the repository gets no page at all.
231 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_robin"))
232 robin, err := f.srv.Identity.Create("robin", pub, "test")
233 if err != nil {
234 t.Fatal(err)
235 }
236 _, robinCookie := f.browserSession(t, robin.Name)
237 if resp, _ := f.browserGet(t, "/"+f.repo+"/settings", robinCookie); resp.StatusCode != 404 {
238 t.Fatalf("a non-maintainer's settings page = %d, want 404", resp.StatusCode)
239 }
240}
241
242func TestAJobReadsTheSecretsItDeclares(t *testing.T) {
243 t.Parallel()
244 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
245 f.publish(f.repo)
246
247 m, err := f.srv.Repos.Open(f.repo)
248 if err != nil {
249 t.Fatal(err)
250 }
251 // A value with a newline in it, because a key is not one line and the file the
252 // step sources has to keep the line breaks exact.
253 value := "s3cret\nsecond line"
254 if _, err := f.srv.Secrets.Set(m.ID, "DEPLOY_TOKEN", value, "test"); err != nil {
255 t.Fatal(err)
256 }
257
258 work := workRepo(t, workflowFile(`version: 1
259jobs:
260 deploy:
261 image: alpine:3.22
262 secrets: [DEPLOY_TOKEN, NOT_SET]
263 steps:
264 - run: printf 'length=%s newlines=%s missing=%s\n' "${#DEPLOY_TOKEN}" "$(printf %s "$DEPLOY_TOKEN" | wc -l)" "${NOT_SET-unset}"
265 - run: printf 'echoed=%s\n' "$DEPLOY_TOKEN"
266`))
267 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
268 t.Fatalf("push: %s", errs)
269 }
270 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
271 f.runOnce(t)
272 done := f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
273 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
274 })
275 if done.Status != runs.StatusSuccess {
276 code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
277 t.Fatalf("run = %s (exit %d), log %d:\n%s", done.Status, done.ExitCode, code, body)
278 }
279 if len(done.Secrets) != 2 || done.Secrets[0] != "DEPLOY_TOKEN" || done.Secrets[1] != "NOT_SET" {
280 t.Fatalf("pinned secrets = %v", done.Secrets)
281 }
282
283 code, log := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
284 if code != 200 {
285 t.Fatalf("log = %d", code)
286 }
287 for _, want := range []string{
288 "length=18 newlines=1 missing=unset",
289 "NOT_SET is not set for this repository, so this job runs without it.",
290 // The step prints the value on purpose: the log has to show the placeholder,
291 // including for the line break inside it.
292 "echoed=***",
293 } {
294 if !strings.Contains(log, want) {
295 t.Fatalf("the log has no %q:\n%s", want, log)
296 }
297 }
298 // The value reaches the step and not the log, even when the step prints it, and a
299 // run record names the version rather than the value.
300 if strings.Contains(log, "s3cret") || strings.Contains(log, "second line") {
301 t.Fatalf("the log holds a value:\n%s", log)
302 }
303
304 rec, err := f.srv.Runs.Attempt(done.ID, 1)
305 if err != nil || rec == nil {
306 t.Fatalf("attempt: %v", err)
307 }
308 if len(rec.Secrets) != 1 || rec.Secrets[0].Name != "DEPLOY_TOKEN" || rec.Secrets[0].Version != 1 {
309 t.Fatalf("the record names %+v", rec.Secrets)
310 }
311}
312
313func TestAJobWithoutItsSecretStillRuns(t *testing.T) {
314 t.Parallel()
315 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
316 f.publish(f.repo)
317
318 work := workRepo(t, workflowFile(`version: 1
319jobs:
320 deploy:
321 image: alpine:3.22
322 secrets: [DEPLOY_TOKEN]
323 steps:
324 - run: printf 'value=%s\n' "${DEPLOY_TOKEN-unset}"
325`))
326 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
327 t.Fatalf("push: %s", errs)
328 }
329 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
330 f.runOnce(t)
331 done := f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
332 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
333 })
334 if done.Status != runs.StatusSuccess {
335 t.Fatalf("a job with an unset secret did not run: %s", done.Status)
336 }
337 _, log := f.get("/" + f.repo + "/runs/" + done.ID + "/log")
338 if !strings.Contains(log, "value=unset") {
339 t.Fatalf("the variable was not left unset:\n%s", log)
340 }
341 if !strings.Contains(log, "DEPLOY_TOKEN is not set for this repository, so this job runs without it.") {
342 t.Fatalf("the log does not say the secret was missing:\n%s", log)
343 }
344 rec, err := f.srv.Runs.Attempt(done.ID, 1)
345 if err != nil || rec == nil {
346 t.Fatalf("attempt: %v", err)
347 }
348 if len(rec.Secrets) != 0 {
349 t.Fatalf("the record names secrets it did not run with: %+v", rec.Secrets)
350 }
351}
352