v0.5.0 22211fd

rig_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 11

↑↓ moveenter openesc close