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 · 19m ago 4b8c9e1d
history blame raw 6.1 KiB
1package server_test
2
3// Workflow artifacts, end to end: a push declares outputs, a real runner expands
4// the patterns in its checkout, uploads the bytes over SSH, and reports them with
5// the result. The run page and the download are then read-only, like everything
6// else on the web.
7
8import (
9 "io"
10 "net/http"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "rodent.software/vole/ci/runs"
17 "rodent.software/vole/forge"
18 "rodent.software/vole/forge/config"
19)
20
21func artifactWorkflow() map[string]string {
22 return workflowFile(`version: 1
23jobs:
24 build:
25 image: alpine:3.22
26 steps:
27 - name: build two files
28 run: mkdir -p dist && printf 'binary bytes\n' > dist/tool-linux && printf 'notes\n' > dist/NOTES.txt
29 artifacts:
30 - path: dist/*
31`)
32}
33
34func TestWorkflowArtifactsArePublished(t *testing.T) {
35 t.Parallel()
36 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
37 f.publish(f.repo)
38
39 work := workRepo(t, artifactWorkflow())
40 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
41 t.Fatalf("push: %s", errs)
42 }
43 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
44 f.runOnce(t)
45
46 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning })
47 if done.Status != runs.StatusSuccess {
48 t.Fatalf("run status = %s (exit %d)", done.Status, done.ExitCode)
49 }
50 arts, err := f.srv.Runs.Artifacts(done.ID)
51 if err != nil {
52 t.Fatal(err)
53 }
54 if len(arts) != 2 || arts[0].Name != "NOTES.txt" || arts[1].Name != "tool-linux" {
55 t.Fatalf("published %+v, want the two files from dist/", arts)
56 }
57
58 // The run page names them, so a download has a description of its build.
59 code, page := f.get("/" + f.repo + "/runs/" + done.ID)
60 if code != 200 {
61 t.Fatalf("run page = %d", code)
62 }
63 for _, want := range []string{"tool-linux", "NOTES.txt", "download", done.Job} {
64 if !strings.Contains(page, want) {
65 t.Fatalf("run page does not show %q:\n%s", want, page)
66 }
67 }
68
69 // The bytes come back out of the store, with the object id as the validator.
70 url := f.http.URL + "/" + f.repo + "/runs/" + done.ID + "/artifacts/tool-linux"
71 resp, err := http.Get(url)
72 if err != nil {
73 t.Fatal(err)
74 }
75 body, _ := io.ReadAll(resp.Body)
76 resp.Body.Close()
77 if resp.StatusCode != 200 || string(body) != "binary bytes\n" {
78 t.Fatalf("artifact download = %d %q", resp.StatusCode, body)
79 }
80 if got, want := resp.Header.Get("ETag"), `"`+arts[1].OID+`"`; got != want {
81 t.Fatalf("ETag = %q, want %q", got, want)
82 }
83 if cd := resp.Header.Get("Content-Disposition"); !strings.Contains(cd, "attachment") {
84 t.Fatalf("Content-Disposition = %q", cd)
85 }
86 req, _ := http.NewRequest(http.MethodGet, url, nil)
87 req.Header.Set("If-None-Match", `"`+arts[1].OID+`"`)
88 again, err := http.DefaultClient.Do(req)
89 if err != nil {
90 t.Fatal(err)
91 }
92 again.Body.Close()
93 if again.StatusCode != http.StatusNotModified {
94 t.Fatalf("conditional artifact request = %d, want 304", again.StatusCode)
95 }
96
97 if code, _ := f.get("/" + f.repo + "/runs/" + done.ID + "/artifacts/nope"); code != 404 {
98 t.Fatalf("unknown artifact = %d, want 404", code)
99 }
100 // The repository page links the run, so the artifacts are reachable by clicking.
101 if _, repoPage := f.get("/" + f.repo); !strings.Contains(repoPage, "/runs/"+done.ID) {
102 t.Fatalf("repository page does not link the run:\n%s", repoPage)
103 }
104}
105
106// TestRunArtifactsSurviveTheSweep pins the reachability rule: nothing in git names
107// a build output, so the run queue is the only thing keeping it.
108func TestRunArtifactsSurviveTheSweep(t *testing.T) {
109 t.Parallel()
110 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
111 f.publish(f.repo)
112 work := workRepo(t, artifactWorkflow())
113 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
114 t.Fatalf("push: %s", errs)
115 }
116 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
117 f.runOnce(t)
118 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusSuccess })
119 arts, err := f.srv.Runs.Artifacts(done.ID)
120 if err != nil || len(arts) == 0 {
121 t.Fatalf("no artifacts to protect: %+v %v", arts, err)
122 }
123
124 oids, err := f.srv.Runs.ArtifactOIDs()
125 if err != nil {
126 t.Fatal(err)
127 }
128 res, err := forge.GC(forge.GCOptions{
129 Repos: []forge.GCRepo{{Name: f.repo, Dir: filepath.Join(f.cfg.ReposDir(), f.repo+".git")}},
130 Store: f.srv.LFS,
131 RunOIDs: oids,
132 Now: time.Now().Add(48 * time.Hour), // past every grace period
133 })
134 if err != nil {
135 t.Fatalf("gc: %v", err)
136 }
137 for _, a := range arts {
138 if !f.srv.LFS.Has(f.repo, a.OID) {
139 t.Fatalf("gc swept run artifact %q (%+v)", a.Name, res)
140 }
141 }
142}
143
144func TestRunArtifactsArePrivateForPrivateRepos(t *testing.T) {
145 t.Parallel()
146 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
147 // Deliberately not published: an anonymous reader may not see it exists.
148 work := workRepo(t, artifactWorkflow())
149 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
150 t.Fatalf("push: %s", errs)
151 }
152 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
153 f.runOnce(t)
154 done := f.waitForRun(t, 10*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusSuccess })
155
156 for _, path := range []string{
157 "/" + f.repo + "/runs/" + done.ID,
158 "/" + f.repo + "/runs/" + done.ID + "/artifacts/tool-linux",
159 } {
160 if code, _ := f.get(path); code != 404 {
161 t.Fatalf("%s on a private repo = %d, want 404", path, code)
162 }
163 }
164
165 // An authorized reader may fetch the page and the bytes; a shared cache must
166 // not keep either and serve it to an anonymous reader.
167 _, cookie := f.browserSession(t, f.ownerID)
168 for _, path := range []string{
169 "/" + f.repo + "/runs/" + done.ID,
170 "/" + f.repo + "/runs/" + done.ID + "/artifacts/tool-linux",
171 } {
172 resp, _ := f.browserGet(t, path, cookie)
173 if resp.StatusCode != http.StatusOK {
174 t.Fatalf("%s = %d, want 200 for the owner", path, resp.StatusCode)
175 }
176 if cc := resp.Header.Get("Cache-Control"); strings.Contains(cc, "public") {
177 t.Fatalf("%s: Cache-Control = %q on a private repository", path, cc)
178 }
179 }
180}
181