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 · 52m ago
4b8c9e1d
| 1 | package server_test |
| 2 | |
| 3 | import ( |
| 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. |
| 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. |
| 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 | |
| 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 | |
| 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 | |
| 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}" |
| 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 | |
| 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 | } |
| 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 |