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.
|
0c40a1d3
Keenan Nemetz
7d ago
|
1 | package server_test |
| 2 | ||
| 3 | import ( | |
| 4 | "bytes" | |
| 5 | "net/url" | |
| 6 | "os" | |
| 7 | "os/exec" | |
| 8 | "path/filepath" | |
| 9 | "strings" | |
| 10 | "testing" | |
|
4ac42680
Keenan Nemetz
7d ago
|
11 | "time" |
| 12 | ||
|
d37d301f
Keenan Nemetz
16h ago
|
13 | "rodent.software/vole/ci/runs" |
| 14 | "rodent.software/vole/forge/config" | |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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. | |
| 19 | func (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. | |
| 36 | func (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 | ||
| 45 | func 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 | ||
| 121 | func 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 | ||
| 143 | func 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. | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
150 | resp, body := f.browserGet(t, "/"+f.repo+"/settings?tab=secrets", cookie) |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
161 | resp, body = f.browserPost(t, "/"+f.repo+"/settings/secrets", cookie, form) |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
169 | resp, body = f.browserGet(t, "/"+f.repo+"/settings?tab=secrets&secret_saved=PRIVATE_KEY+v1", cookie) |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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"}} | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
201 | resp, body = f.browserPost(t, "/"+f.repo+"/settings/secrets/delete", cookie, deleteForm) |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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 | ||
| 210 | func TestAMaintainerSeesTheSettingsTabForSecrets(t *testing.T) { | |
| 211 | t.Parallel() | |
| 212 | f := browserFixture(t) | |
| 213 | f.addWriter(t) | |
| 214 | _, cookie := f.browserSession(t, f.writerID) | |
| 215 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
216 | resp, body := f.browserGet(t, "/"+f.repo+"/settings", cookie) |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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. | |
|
e189a23e
Keenan Nemetz
22h ago
|
224 | for _, adminOnly := range []string{"Default branch", "Rename repository", "Delete repository", `id="access"`} { |
|
0c40a1d3
Keenan Nemetz
7d ago
|
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) | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
237 | if resp, _ := f.browserGet(t, "/"+f.repo+"/settings", robinCookie); resp.StatusCode != 404 { |
|
0c40a1d3
Keenan Nemetz
7d ago
|
238 | t.Fatalf("a non-maintainer's settings page = %d, want 404", resp.StatusCode) |
| 239 | } | |
| 240 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
241 | |
| 242 | func 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 | |
| 259 | jobs: | |
| 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}" | |
|
e82bbe7d
Keenan Nemetz
7d ago
|
265 | - run: printf 'echoed=%s\n' "$DEPLOY_TOKEN" |
|
4ac42680
Keenan Nemetz
7d ago
|
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 { | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
276 | code, body := f.get("/" + f.repo + "/runs/" + done.ID + "/log") |
|
4ac42680
Keenan Nemetz
7d ago
|
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 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
283 | code, log := f.get("/" + f.repo + "/runs/" + done.ID + "/log") |
|
4ac42680
Keenan Nemetz
7d ago
|
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.", | |
|
e82bbe7d
Keenan Nemetz
7d ago
|
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=***", | |
|
4ac42680
Keenan Nemetz
7d ago
|
293 | } { |
| 294 | if !strings.Contains(log, want) { | |
| 295 | t.Fatalf("the log has no %q:\n%s", want, log) | |
| 296 | } | |
| 297 | } | |
|
e82bbe7d
Keenan Nemetz
7d ago
|
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. | |
|
4ac42680
Keenan Nemetz
7d ago
|
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 | ||
| 313 | func 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 | |
| 319 | jobs: | |
| 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 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
337 | _, log := f.get("/" + f.repo + "/runs/" + done.ID + "/log") |
|
4ac42680
Keenan Nemetz
7d ago
|
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 | } |