1// SPDX-License-Identifier: MIT
2
3package resultssvc
4
5import (
6 "bytes"
7 "crypto/hmac"
8 "crypto/sha256"
9 "encoding/base64"
10 "encoding/json"
11 "errors"
12 "io"
13 "net/http"
14 "net/http/httptest"
15 "strconv"
16 "strings"
17 "sync"
18 "testing"
19 "time"
20)
21
22// clock is a settable clock.
23type clock struct {
24 mu sync.Mutex
25 t time.Time
26}
27
28func (c *clock) now() time.Time {
29 c.mu.Lock()
30 defer c.mu.Unlock()
31 return c.t
32}
33
34func (c *clock) add(d time.Duration) {
35 c.mu.Lock()
36 defer c.mu.Unlock()
37 c.t = c.t.Add(d)
38}
39
40// jwtAuth is the Auth of the tests: HS256 three-segment JWTs as the runner
41// mints them (claims j, exp, iat, scp), and blob MACs under their own domain.
42type jwtAuth struct {
43 key []byte
44 now func() time.Time
45}
46
47func (a *jwtAuth) mac(domain string, parts ...[]byte) []byte {
48 h := hmac.New(sha256.New, a.key)
49 h.Write([]byte(domain))
50 for _, p := range parts {
51 h.Write([]byte{0})
52 h.Write(p)
53 }
54 return h.Sum(nil)
55}
56
57func (a *jwtAuth) tokenKey() []byte { return a.mac("token") }
58
59// mint makes a runtime token of job with the scopes and a life of ttl.
60func (a *jwtAuth) mint(job, scp string, ttl time.Duration) string {
61 b64 := base64.RawURLEncoding
62 now := a.now()
63 h := b64.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
64 c, _ := json.Marshal(map[string]any{"j": job, "scp": scp, "iat": now.Unix(), "exp": now.Add(ttl).Unix()})
65 p := b64.EncodeToString(c)
66 m := hmac.New(sha256.New, a.tokenKey())
67 m.Write([]byte(h + "." + p))
68 return h + "." + p + "." + b64.EncodeToString(m.Sum(nil))
69}
70
71func (a *jwtAuth) RuntimeToken(tok string) (string, string, time.Time, error) {
72 bad := errors.New("invalid token")
73 parts := strings.Split(tok, ".")
74 if len(parts) != 3 {
75 return "", "", time.Time{}, bad
76 }
77 m := hmac.New(sha256.New, a.tokenKey())
78 m.Write([]byte(parts[0] + "." + parts[1]))
79 sig, err := base64.RawURLEncoding.DecodeString(parts[2])
80 if err != nil || !hmac.Equal(sig, m.Sum(nil)) {
81 return "", "", time.Time{}, bad
82 }
83 pb, err := base64.RawURLEncoding.DecodeString(parts[1])
84 var c struct {
85 J string `json:"j"`
86 Scp string `json:"scp"`
87 Exp int64 `json:"exp"`
88 }
89 if err != nil || json.Unmarshal(pb, &c) != nil || a.now().Unix() >= c.Exp {
90 return "", "", time.Time{}, bad
91 }
92 return c.J, c.Scp, time.Unix(c.Exp, 0), nil
93}
94
95func (a *jwtAuth) BlobMAC(parts ...[]byte) []byte { return a.mac("blob", parts...) }
96
97// genericRead is the scope that GitHub puts before Actions.Results.
98const genericRead = "Actions.GenericRead:00000000-0000-0000-0000-000000000000"
99
100// rig is a Service in front of a fake ermite.
101type rig struct {
102 t *testing.T
103 fake *fakeErmite
104 url string
105 auth *jwtAuth
106 clk *clock
107 svc *Service
108 nJobs int
109}
110
111func newRig(t *testing.T) *rig {
112 t.Helper()
113 clk := &clock{t: time.Now()}
114 f := newFakeErmite()
115 auth := &jwtAuth{key: []byte("test key"), now: clk.now}
116 return &rig{t: t, fake: f, url: f.serve(t), auth: auth, clk: clk, svc: New(Config{Auth: auth, Now: clk.now})}
117}
118
119// tjob is a job of the rig: its Job, its runtime token and its channel.
120type tjob struct {
121 Job
122 token string
123 srv *httptest.Server
124}
125
126// job registers a job in the fake ermite and serves its channel.
127func (r *rig) job(run int64, id string, attempt int32) *tjob {
128 r.t.Helper()
129 r.nJobs++
130 et := "ermitejob_" + strconv.Itoa(r.nJobs)
131 r.fake.addJob(et, run, id, attempt)
132 j := Job{ID: id, RunID: run, Attempt: attempt, Client: client(r.url, et)}
133 srv := httptest.NewServer(r.svc.Handler(j))
134 r.t.Cleanup(srv.Close)
135 return &tjob{Job: j, token: r.auth.mint(id, genericRead+" "+Scope(run, id, attempt), time.Hour), srv: srv}
136}
137
138// twirp posts a Twirp request with the job's token and returns the status and
139// the body.
140func (j *tjob) twirp(t *testing.T, method, body string) (int, []byte) {
141 t.Helper()
142 return j.twirpWith(t, j.token, method, body)
143}
144
145func (j *tjob) twirpWith(t *testing.T, token, method, body string) (int, []byte) {
146 t.Helper()
147 req, _ := http.NewRequest(http.MethodPost, j.srv.URL+TwirpPath+method, strings.NewReader(body))
148 req.Header.Set("Content-Type", "application/json")
149 if token != "" {
150 req.Header.Set("Authorization", "Bearer "+token)
151 }
152 res, err := http.DefaultClient.Do(req)
153 if err != nil {
154 t.Fatal(err)
155 }
156 defer func() { _ = res.Body.Close() }()
157 b, _ := io.ReadAll(res.Body)
158 if ct := res.Header.Get("Content-Type"); ct != "application/json" {
159 t.Errorf("%s: Content-Type %q", method, ct)
160 }
161 return res.StatusCode, b
162}
163
164// do sends a request and returns the response with its body read.
165func do(t *testing.T, method, url string, body io.Reader) (*http.Response, []byte) {
166 t.Helper()
167 req, _ := http.NewRequest(method, url, body)
168 res, err := http.DefaultClient.Do(req)
169 if err != nil {
170 t.Fatal(err)
171 }
172 defer func() { _ = res.Body.Close() }()
173 b, _ := io.ReadAll(res.Body)
174 return res, b
175}
176
177// create calls CreateArtifact and returns the signed upload URL.
178func (j *tjob) create(t *testing.T, name, mime string) string {
179 t.Helper()
180 body := `{"workflow_run_backend_id":"x","workflow_job_run_backend_id":"y","name":"` + name + `","version":7`
181 if mime != "" {
182 body += `,"mime_type":"` + mime + `"`
183 }
184 st, b := j.twirp(t, "CreateArtifact", body+"}")
185 var out struct {
186 OK bool `json:"ok"`
187 URL string `json:"signedUploadUrl"`
188 }
189 if st != http.StatusOK || json.Unmarshal(b, &out) != nil || !out.OK {
190 t.Fatalf("CreateArtifact: %d %s", st, b)
191 }
192 return out.URL
193}
194
195// upload uploads content as blocks of blockSize and finalizes it; it returns
196// the artifact id.
197func (j *tjob) upload(t *testing.T, name, mime string, content []byte, blockSize int) string {
198 t.Helper()
199 u := j.create(t, name, mime)
200 var list strings.Builder
201 list.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><BlockList>`)
202 for i := 0; i == 0 || i*blockSize < len(content); i++ {
203 part := content[i*blockSize : min(len(content), (i+1)*blockSize)]
204 id := base64.StdEncoding.EncodeToString([]byte(name + "-block-" + strconv.Itoa(i)))
205 res, b := do(t, http.MethodPut, u+"&comp=block&blockid="+id, bytes.NewReader(part))
206 if res.StatusCode != http.StatusCreated {
207 t.Fatalf("Put Block: %d %s", res.StatusCode, b)
208 }
209 list.WriteString("<Latest>" + id + "</Latest>")
210 }
211 list.WriteString("</BlockList>")
212 res, b := do(t, http.MethodPut, u+"&comp=blocklist", strings.NewReader(list.String()))
213 if res.StatusCode != http.StatusCreated {
214 t.Fatalf("Put Block List: %d %s", res.StatusCode, b)
215 }
216 return j.finalize(t, name, content)
217}
218
219func (j *tjob) finalize(t *testing.T, name string, content []byte) string {
220 t.Helper()
221 st, b := j.twirp(t, "FinalizeArtifact", `{"workflow_run_backend_id":"x","workflow_job_run_backend_id":"y","name":"`+name+
222 `","size":"`+strconv.Itoa(len(content))+`","hash":"`+digestOf(content)+`"}`)
223 var out struct {
224 OK bool `json:"ok"`
225 ID string `json:"artifactId"`
226 }
227 if st != http.StatusOK || json.Unmarshal(b, &out) != nil || !out.OK || out.ID == "" {
228 t.Fatalf("FinalizeArtifact: %d %s", st, b)
229 }
230 return out.ID
231}
232
233// signedURL calls GetSignedArtifactURL.
234func (j *tjob) signedURL(t *testing.T, name string) string {
235 t.Helper()
236 st, b := j.twirp(t, "GetSignedArtifactURL", `{"workflow_run_backend_id":"x","workflow_job_run_backend_id":"uploader","name":"`+name+`"}`)
237 var out struct {
238 URL string `json:"signedUrl"`
239 }
240 if st != http.StatusOK || json.Unmarshal(b, &out) != nil || out.URL == "" {
241 t.Fatalf("GetSignedArtifactURL: %d %s", st, b)
242 }
243 return out.URL
244}