v0.4.4 edcdd4c

service_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package cachesvc
4
5import (
6	"bytes"
7	"encoding/json"
8	"fmt"
9	"io"
10	"net/http"
11	"net/http/httptest"
12	"net/url"
13	"os"
14	"path/filepath"
15	"strconv"
16	"strings"
17	"sync"
18	"testing"
19	"time"
20)
21
22type clock struct {
23	mu sync.Mutex
24	t  time.Time
25}
26
27func (c *clock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t }
28func (c *clock) add(d time.Duration) {
29	c.mu.Lock()
30	c.t = c.t.Add(d)
31	c.mu.Unlock()
32}
33
34type rig struct {
35	t   *testing.T
36	svc *Service
37	srv *httptest.Server
38	clk *clock
39	dir string
40}
41
42func newRig(t *testing.T, max int64) *rig {
43	t.Helper()
44	return newRigWith(t, Config{Max: max, MinCharge: 1})
45}
46
47// newRigWith starts a service with cfg on top of the test clock and directory.
48func newRigWith(t *testing.T, cfg Config) *rig {
49	t.Helper()
50	clk := &clock{t: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)}
51	dir := filepath.Join(t.TempDir(), "cache")
52	cfg.Dir, cfg.Now, cfg.ReservationTTL = dir, clk.now, time.Hour
53	svc, err := New(cfg)
54	if err != nil {
55		t.Fatal(err)
56	}
57	srv := httptest.NewServer(svc.Handler())
58	t.Cleanup(func() { srv.Close(); _ = svc.CloseAll() })
59	return &rig{t: t, svc: svc, srv: srv, clk: clk, dir: dir}
60}
61
62// job is a client with the token of one job, in the shapes of the toolkit's v1
63// client (cacheHttpClient.ts).
64type job struct {
65	r     *rig
66	token string
67}
68
69func (r *rig) job(id, repo, write string, reads ...string) *job {
70	return &job{r: r, token: r.svc.Token(id, Scope{Repository: repo, WriteRef: write, ReadRefs: reads}, 6*time.Hour)}
71}
72
73func (j *job) do(method, path string, body []byte, hdr map[string]string) (int, []byte, http.Header) {
74	j.r.t.Helper()
75	req, err := http.NewRequest(method, j.r.srv.URL+APIPath+path, bytes.NewReader(body))
76	if err != nil {
77		j.r.t.Fatal(err)
78	}
79	// getRequestOptions / createHttpClient: cacheHttpClient.ts:56-75.
80	req.Header.Set("Accept", "application/json;api-version=6.0-preview.1")
81	if j.token != "" {
82		req.Header.Set("Authorization", "Bearer "+j.token)
83	}
84	for k, v := range hdr {
85		req.Header.Set(k, v)
86	}
87	resp, err := http.DefaultClient.Do(req)
88	if err != nil {
89		j.r.t.Fatal(err)
90	}
91	defer func() { _ = resp.Body.Close() }()
92	b, _ := io.ReadAll(resp.Body)
93	return resp.StatusCode, b, resp.Header
94}
95
96// reserve is reserveCache (:190): POST caches {key, version, cacheSize}.
97func (j *job) reserve(key, version string, size int) (int, int64) {
98	b, _ := json.Marshal(map[string]any{"key": key, "version": version, "cacheSize": size})
99	code, body, _ := j.do("POST", "caches", b, map[string]string{"Content-Type": "application/json"})
100	var out struct {
101		CacheID int64 `json:"cacheId"`
102	}
103	_ = json.Unmarshal(body, &out)
104	return code, out.CacheID
105}
106
107// chunk is uploadChunk (:206-250).
108func (j *job) chunk(id int64, data []byte, start int) int {
109	end := start + len(data) - 1
110	code, _, _ := j.do("PATCH", fmt.Sprintf("caches/%d", id), data, map[string]string{
111		"Content-Type": "application/octet-stream", "Content-Range": fmt.Sprintf("bytes %d-%d/*", start, end)})
112	return code
113}
114
115func (j *job) commit(id int64, size int) int {
116	b, _ := json.Marshal(map[string]int{"size": size})
117	code, _, _ := j.do("POST", fmt.Sprintf("caches/%d", id), b, map[string]string{"Content-Type": "application/json"})
118	return code
119}
120
121// save does reserve, chunks of 4 bytes uploaded in reverse order, commit.
122func (j *job) save(key, version string, data []byte) int {
123	j.r.t.Helper()
124	code, id := j.reserve(key, version, len(data))
125	if code != http.StatusCreated {
126		return code
127	}
128	for start := (len(data) - 1) / 4 * 4; start >= 0; start -= 4 {
129		if c := j.chunk(id, data[start:min(start+4, len(data))], start); c != http.StatusNoContent {
130			return c
131		}
132	}
133	return j.commit(id, len(data))
134}
135
136type lookupResult struct {
137	Key, Scope, Version, Location string
138	Time                          string
139}
140
141// lookup is getCacheEntry (:77-125): GET cache?keys=&version=.
142func (j *job) lookup(version string, keys ...string) (int, lookupResult) {
143	j.r.t.Helper()
144	code, body, _ := j.do("GET", "cache?keys="+url.QueryEscape(strings.Join(keys, ","))+"&version="+version, nil, nil)
145	var e struct {
146		CacheKey, Scope, CacheVersion, CreationTime, ArchiveLocation string
147	}
148	if code == 200 {
149		if err := json.Unmarshal(body, &e); err != nil {
150			j.r.t.Fatalf("lookup body %q: %v", body, err)
151		}
152	}
153	return code, lookupResult{e.CacheKey, e.Scope, e.CacheVersion, e.ArchiveLocation, e.CreationTime}
154}
155
156// fetch downloads the archive like downloadCacheHttpClient (downloadUtils.ts:168):
157// a bare GET, no bearer token.
158func (r *rig) fetch(loc string) (int, []byte) {
159	r.t.Helper()
160	resp, err := http.Get(loc)
161	if err != nil {
162		r.t.Fatal(err)
163	}
164	defer func() { _ = resp.Body.Close() }()
165	b, _ := io.ReadAll(resp.Body)
166	return resp.StatusCode, b
167}
168
169func (j *job) restore(version string, keys ...string) (string, []byte, bool) {
170	j.r.t.Helper()
171	code, e := j.lookup(version, keys...)
172	if code == 204 {
173		return "", nil, false
174	}
175	if code != 200 {
176		j.r.t.Fatalf("lookup: %d", code)
177	}
178	c, data := j.r.fetch(e.Location)
179	if c != 200 {
180		j.r.t.Fatalf("archive: %d", c)
181	}
182	return e.Key, data, true
183}
184
185const (
186	repoA = "bnema/app"
187	repoB = "bnema/other"
188	main_ = "refs/heads/main"
189	feat  = "refs/heads/feat"
190	v1    = "0123abcd"
191)
192
193func TestSaveAndRestoreByKeyAndPrefix(t *testing.T) {
194	r := newRig(t, 1<<20)
195	w := r.job("j1", repoA, main_, main_)
196	if code := w.save("go-linux-abc", v1, []byte("0123456789")); code != http.StatusNoContent {
197		t.Fatalf("save: %d", code)
198	}
199	rd := r.job("j2", repoA, "", main_)
200
201	// A miss is 204 with no body (the toolkit treats it as "not found").
202	if code, _ := rd.lookup(v1, "go-linux-zzz"); code != http.StatusNoContent {
203		t.Fatalf("miss: %d", code)
204	}
205	// Exact primary key.
206	key, data, ok := rd.restore(v1, "go-linux-abc")
207	if !ok || key != "go-linux-abc" || string(data) != "0123456789" {
208		t.Fatalf("exact: %v %q %q", ok, key, data)
209	}
210	// The primary key is never a prefix: only restore keys are.
211	if code, _ := rd.lookup(v1, "go-linux-a"); code != http.StatusNoContent {
212		t.Fatalf("primary key matched as prefix: %d", code)
213	}
214	key, _, ok = rd.restore(v1, "go-linux-zzz", "go-linux-")
215	if !ok || key != "go-linux-abc" {
216		t.Fatalf("restore key: %v %q", ok, key)
217	}
218	// Another version (paths, compression) is another cache.
219	if code, _ := rd.lookup("ffff", "go-linux-abc"); code != http.StatusNoContent {
220		t.Fatalf("other version: %d", code)
221	}
222	code, e := rd.lookup(v1, "go-linux-abc")
223	if code != 200 || e.Scope != main_ || e.Version != v1 || e.Time == "" {
224		t.Fatalf("entry %+v", e)
225	}
226}
227
228func TestRestoreKeyPrefersNewestAndExact(t *testing.T) {
229	r := newRig(t, 1<<20)
230	w := r.job("j1", repoA, main_, main_)
231	for _, k := range []string{"deps-1", "deps-2", "deps-3"} {
232		if c := w.save(k, v1, []byte(k)); c != http.StatusNoContent {
233			t.Fatal(c)
234		}
235		r.clk.add(time.Minute)
236	}
237	if k, _, _ := w.restore(v1, "nomatch", "deps-"); k != "deps-3" {
238		t.Fatalf("newest prefix match: %q", k)
239	}
240	if k, _, _ := w.restore(v1, "nomatch", "deps-1"); k != "deps-1" {
241		t.Fatalf("exact restore key: %q", k)
242	}
243}
244
245func TestReadScopePriority(t *testing.T) {
246	r := newRig(t, 1<<20)
247	// The default branch built "k" and "pre-1"; the feature ref has its own "k".
248	if c := r.job("j0", repoA, main_, main_).save("k", v1, []byte("from-main")); c != 204 {
249		t.Fatal(c)
250	}
251	if c := r.job("j0b", repoA, main_, main_).save("pre-1", v1, []byte("main-pre")); c != 204 {
252		t.Fatal(c)
253	}
254	if c := r.job("j1", repoA, feat, feat, main_).save("k", v1, []byte("from-feat")); c != 204 {
255		t.Fatal(c)
256	}
257	pr := r.job("j2", repoA, feat, feat, main_)
258	if _, data, _ := pr.restore(v1, "k"); string(data) != "from-feat" {
259		t.Fatalf("own ref first: %q", data)
260	}
261	// Not in the own ref: the next read ref answers.
262	if k, data, ok := pr.restore(v1, "zzz", "pre-"); !ok || k != "pre-1" || string(data) != "main-pre" {
263		t.Fatalf("fallback ref: %v %q %q", ok, k, data)
264	}
265	// A job whose scopes do not include feat never sees its entry.
266	other := r.job("j3", repoA, "refs/heads/other", "refs/heads/other", main_)
267	if _, data, _ := other.restore(v1, "k"); string(data) != "from-main" {
268		t.Fatalf("other ref must read main's: %q", data)
269	}
270	if code, _ := r.job("j4", repoA, "", "refs/heads/other").lookup(v1, "k"); code != 204 {
271		t.Fatalf("read scope leak: %d", code)
272	}
273}
274
275func TestReserveConflict(t *testing.T) {
276	r := newRig(t, 1<<20)
277	a := r.job("j1", repoA, feat, feat)
278	code, id := a.reserve("k", v1, 4)
279	if code != http.StatusCreated || id == 0 {
280		t.Fatalf("%d %d", code, id)
281	}
282	// Reserved but not committed: a second reservation is a 409 (what the toolkit
283	// reports as "another job may be creating this cache").
284	if code, _ := r.job("j2", repoA, feat, feat).reserve("k", v1, 4); code != http.StatusConflict {
285		t.Fatalf("reserved: %d", code)
286	}
287	// Another version, another ref, another repository do not conflict.
288	if code, _ := a.reserve("k", "other", 4); code != http.StatusCreated {
289		t.Fatalf("other version: %d", code)
290	}
291	if code, _ := r.job("j3", repoA, main_, main_).reserve("k", v1, 4); code != http.StatusCreated {
292		t.Fatalf("other ref: %d", code)
293	}
294	if code, _ := r.job("j4", repoB, feat, feat).reserve("k", v1, 4); code != http.StatusCreated {
295		t.Fatalf("other repo: %d", code)
296	}
297	if a.chunk(id, []byte("abcd"), 0) != 204 || a.commit(id, 4) != 204 {
298		t.Fatal("upload")
299	}
300	// Committed: still a conflict.
301	if code, _ := r.job("j5", repoA, feat, feat).reserve("k", v1, 4); code != http.StatusConflict {
302		t.Fatalf("exists: %d", code)
303	}
304}
305
306func TestWriteOutsideRefRefused(t *testing.T) {
307	r := newRig(t, 1<<20)
308	ro := r.job("j1", repoA, "", main_)
309	b, _ := json.Marshal(map[string]any{"key": "k", "version": v1, "cacheSize": 4})
310	code, body, _ := ro.do("POST", "caches", b, nil)
311	if code != http.StatusForbidden || !strings.HasPrefix(string(body), `{"message":"cache write denied: `) {
312		t.Fatalf("read-only job: %d %s", code, body)
313	}
314	// A reservation is bound to its job's write ref: the token of a job with
315	// another ref (or another job) cannot write into it, even with the id.
316	w := r.job("j2", repoA, feat, feat)
317	_, id := w.reserve("k", v1, 4)
318	for name, o := range map[string]*job{
319		"other ref":  r.job("j3", repoA, main_, main_),
320		"other job":  r.job("j4", repoA, feat, feat),
321		"other repo": r.job("j2", repoB, feat, feat),
322		"read only":  r.job("j2", repoA, "", feat),
323	} {
324		if c := o.chunk(id, []byte("abcd"), 0); c != http.StatusForbidden {
325			t.Errorf("%s: chunk %d", name, c)
326		}
327		if c := o.commit(id, 4); c != http.StatusForbidden {
328			t.Errorf("%s: commit %d", name, c)
329		}
330	}
331	if n, _, _ := r.svc.store.stats(); n != 0 {
332		t.Fatalf("entries: %d", n)
333	}
334	if w.chunk(id, []byte("abcd"), 0) != 204 || w.commit(id, 4) != 204 {
335		t.Fatal("the owner can still upload")
336	}
337}
338
339func TestOtherRepositoryNeverRestored(t *testing.T) {
340	r := newRig(t, 1<<20)
341	if c := r.job("j1", repoA, main_, main_).save("k", v1, []byte("secret-a")); c != 204 {
342		t.Fatal(c)
343	}
344	b := r.job("j2", repoB, main_, main_)
345	if code, _ := b.lookup(v1, "k"); code != 204 {
346		t.Fatalf("lookup across repositories: %d", code)
347	}
348	if code, _ := b.lookup(v1, "x", "k"); code != 204 {
349		t.Fatalf("restore key across repositories: %d", code)
350	}
351	// The signed URL of A's entry is not a way in for B either: the signature is
352	// bound to the repository. Control: the genuine URL works; the same id and
353	// expiry signed for repoB, and nothing else changed, is refused.
354	_, e := r.job("j3", repoA, "", main_).lookup(v1, "k")
355	u, _ := url.Parse(e.Location)
356	idStr := strings.TrimPrefix(u.Path, APIPath+"artifacts/")
357	id, _ := strconv.ParseInt(idStr, 10, 64)
358	exp, _ := strconv.ParseInt(u.Query().Get("exp"), 10, 64)
359	if code, data := r.fetch(e.Location); code != 200 || string(data) != "secret-a" {
360		t.Fatalf("control: %d %q", code, data)
361	}
362	forged := url.Values{"exp": {u.Query().Get("exp")}, "job": {"j3"}, "sig": {r.svc.sign.archiveSig(id, repoB, "j3", exp)}}
363	if code, _ := r.fetch(r.srv.URL + APIPath + "artifacts/" + idStr + "?" + forged.Encode()); code != 403 {
364		t.Fatalf("signature for the other repository: %d", code)
365	}
366}
367
368func TestBadTokens(t *testing.T) {
369	r := newRig(t, 1<<20)
370	good := r.job("j1", repoA, main_, main_)
371	other := newRig(t, 1<<20) // another runner: another key
372	expired := r.svc.sign.token(Claims{JobID: "j", Scope: Scope{Repository: repoA, WriteRef: main_, ReadRefs: []string{main_}}, Expiry: r.clk.now().Add(-time.Second).Unix()})
373	seg := strings.Split(good.token, ".")
374	tampered := func() string {
375		payload, _ := b64.DecodeString(seg[1])
376		payload = bytes.Replace(payload, []byte(repoA), []byte(repoB), 1)
377		return seg[0] + "." + b64.EncodeToString(payload) + "." + seg[2]
378	}()
379	algNone := b64.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + "." + seg[1] + "."
380	// A token for the results service only has no cache scope.
381	resultsOnly := r.svc.Token("j1", Scope{}, time.Hour, "Actions.Results:1:x")
382	for name, tok := range map[string]string{
383		"none": "", "garbage": "abc", "dots": "a.b", "other key": other.job("j1", repoA, main_, main_).token,
384		"expired": expired, "tampered claims": tampered, "no mac": seg[0] + "." + seg[1] + ".",
385		"two segments": seg[1] + "." + seg[2], "alg none": algNone, "results only": resultsOnly,
386	} {
387		j := &job{r: r, token: tok}
388		for _, req := range []struct{ m, p string }{{"GET", "cache?keys=k&version=v"}, {"POST", "caches"}, {"PATCH", "caches/1"}, {"POST", "caches/1"}, {"GET", "caches?key=k"}} {
389			if code, _, _ := j.do(req.m, req.p, []byte("{}"), nil); code != http.StatusForbidden {
390				t.Errorf("%s %s %s: %d", name, req.m, req.p, code)
391			}
392		}
393	}
394	// A bare "Authorization: <token>" without the scheme is refused too.
395	req, _ := http.NewRequest("GET", r.srv.URL+APIPath+"cache?keys=k&version=v", nil)
396	req.Header.Set("Authorization", good.token)
397	resp, err := http.DefaultClient.Do(req)
398	if err != nil {
399		t.Fatal(err)
400	}
401	_ = resp.Body.Close()
402	if resp.StatusCode != 403 {
403		t.Fatalf("no scheme: %d", resp.StatusCode)
404	}
405	// A token is not an archive signature, nor the other way round.
406	if r.svc.sign.archiveOK(1, repoA, "j1", 1<<40, good.token, r.clk.now()) {
407		t.Fatal("token accepted as archive signature")
408	}
409	if code, _ := r.fetch(r.srv.URL + APIPath + "artifacts/1?exp=99999999999&sig=x"); code != 403 {
410		t.Fatalf("archive without signature: %d", code)
411	}
412}
413
414func TestChunkRulesAndCommitVerifiesSize(t *testing.T) {
415	r := newRig(t, 1<<20)
416	j := r.job("j1", repoA, main_, main_)
417	_, id := j.reserve("k", v1, 8)
418
419	if c := j.chunk(id, []byte("0123456789"), 0); c != http.StatusRequestEntityTooLarge {
420		t.Fatalf("beyond cacheSize: %d", c)
421	}
422	// Content-Range that disagrees with the body, either way.
423	code, _, _ := j.do("PATCH", fmt.Sprintf("caches/%d", id), []byte("abc"), map[string]string{"Content-Range": "bytes 0-6/*"})
424	if code != 400 {
425		t.Fatalf("short body: %d", code)
426	}
427	code, _, _ = j.do("PATCH", fmt.Sprintf("caches/%d", id), []byte("abcdefg"), map[string]string{"Content-Range": "bytes 0-2/*"})
428	if code != 400 {
429		t.Fatalf("long body: %d", code)
430	}
431	for _, h := range []string{"", "bytes 5-2/*", "bytes a-b/*", "items 0-1/*", "bytes 0-1"} {
432		if c, _, _ := j.do("PATCH", fmt.Sprintf("caches/%d", id), []byte("ab"), map[string]string{"Content-Range": h}); c != 400 {
433			t.Errorf("Content-Range %q: %d", h, c)
434		}
435	}
436	// Second half first, a hole at the start: commit refuses.
437	if j.chunk(id, []byte("4567"), 4) != 204 {
438		t.Fatal("chunk")
439	}
440	if c := j.commit(id, 8); c != 400 {
441		t.Fatalf("commit with a hole: %d", c)
442	}
443	if j.chunk(id, []byte("0123"), 0) != 204 {
444		t.Fatal("chunk")
445	}
446	if j.chunk(id, []byte("0123"), 0) != 204 { // a retry of the same range
447		t.Fatal("chunk, repeated")
448	}
449	// The size of the commit must be the declared one.
450	if c := j.commit(id, 7); c != 400 {
451		t.Fatalf("commit with a wrong size: %d", c)
452	}
453	if c := j.commit(id, 8); c != 204 {
454		t.Fatalf("commit: %d", c)
455	}
456	if c := j.commit(id, 8); c != 404 {
457		t.Fatalf("second commit: %d", c)
458	}
459	if _, data, _ := j.restore(v1, "k"); string(data) != "01234567" {
460		t.Fatalf("content %q", data)
461	}
462	// No temporary file is left.
463	des, _ := os.ReadDir(filepath.Join(r.dir, "data"))
464	for _, de := range des {
465		if strings.HasSuffix(de.Name(), ".tmp") {
466			t.Errorf("left over %s", de.Name())
467		}
468	}
469}
470
471func TestReservationWithoutSizeIsBoundedByTheLimit(t *testing.T) {
472	r := newRig(t, 16)
473	j := r.job("j1", repoA, main_, main_)
474	code, id := j.reserve("k", v1, 0)
475	if code != http.StatusCreated {
476		t.Fatal(code)
477	}
478	if c := j.chunk(id, make([]byte, 17), 0); c != http.StatusRequestEntityTooLarge {
479		t.Fatalf("over the limit: %d", c)
480	}
481	if code, _ := j.reserve("big", v1, 17); code != http.StatusBadRequest {
482		t.Fatalf("cacheSize over the limit: %d", code)
483	}
484}
485
486func TestUncommittedReservationsExpire(t *testing.T) {
487	r := newRig(t, 100)
488	j := r.job("j1", repoA, main_, main_)
489	_, id := j.reserve("k", v1, 60)
490	// The space is held: a second 60-byte cache has no room (nothing to evict).
491	if code, _ := j.reserve("k2", v1, 60); code != http.StatusServiceUnavailable {
492		t.Fatalf("held space: %d", code)
493	}
494	r.clk.add(2 * time.Hour)
495	if c := j.chunk(id, []byte("x"), 0); c != http.StatusNotFound {
496		t.Fatalf("chunk after expiry: %d", c)
497	}
498	if code, _ := j.reserve("k2", v1, 60); code != http.StatusCreated {
499		t.Fatalf("after expiry: %d", code)
500	}
501	// The key is free again and the temporary file is gone.
502	if code, _ := j.reserve("k", v1, 10); code != http.StatusCreated {
503		t.Fatalf("key after expiry: %d", code)
504	}
505	if _, _, held := r.svc.store.stats(); held != 70 {
506		t.Fatalf("held %d", held)
507	}
508	if _, err := os.Stat(r.svc.store.tmp(id)); !os.IsNotExist(err) {
509		t.Fatalf("temp file: %v", err)
510	}
511}
512
513func TestLRUEviction(t *testing.T) {
514	r := newRig(t, 30)
515	j := r.job("j1", repoA, main_, main_)
516	save := func(k string) {
517		t.Helper()
518		if c := j.save(k, v1, bytes.Repeat([]byte{k[0]}, 10)); c != 204 {
519			t.Fatalf("save %s: %d", k, c)
520		}
521		r.clk.add(time.Minute)
522	}
523	save("a")
524	save("b")
525	save("c")
526	// Using "a" makes "b" the least recently used.
527	if _, _, ok := j.restore(v1, "a"); !ok {
528		t.Fatal("a")
529	}
530	r.clk.add(time.Minute)
531	save("d")
532	if code, _ := j.lookup(v1, "b"); code != 204 {
533		t.Fatalf("b must be evicted: %d", code)
534	}
535	for _, k := range []string{"a", "c", "d"} {
536		if code, _ := j.lookup(v1, k); code != 200 {
537			t.Errorf("%s evicted: %d", k, code)
538		}
539	}
540	if n, used, _ := r.svc.store.stats(); n != 3 || used != 30 {
541		t.Fatalf("entries %d used %d", n, used)
542	}
543	des, _ := os.ReadDir(filepath.Join(r.dir, "data"))
544	if len(des) != 3 {
545		t.Fatalf("files on disk: %d", len(des))
546	}
547	// An entry in flight is not evicted, and committing never evicts itself.
548	if c := j.save("e", v1, bytes.Repeat([]byte("e"), 25)); c != 204 {
549		t.Fatalf("large entry: %d", c)
550	}
551	if n, used, _ := r.svc.store.stats(); n != 1 || used != 25 {
552		t.Fatalf("after large entry: entries %d used %d", n, used)
553	}
554}
555
556func TestIndexPersists(t *testing.T) {
557	r := newRig(t, 1<<20)
558	if c := r.job("j1", repoA, main_, main_).save("k", v1, []byte("persist")); c != 204 {
559		t.Fatal(c)
560	}
561	// An abandoned reservation and a stray file are cleaned at restart.
562	r.job("j2", repoA, feat, feat).reserve("half", v1, 5)
563	if err := os.WriteFile(filepath.Join(r.dir, "data", "stray"), []byte("x"), 0o600); err != nil {
564		t.Fatal(err)
565	}
566	if err := r.svc.CloseAll(); err != nil {
567		t.Fatal(err)
568	}
569	svc2, err := New(Config{Dir: r.dir, Now: r.clk.now})
570	if err != nil {
571		t.Fatal(err)
572	}
573	defer func() { _ = svc2.CloseAll() }()
574	e, ok := svc2.store.find(repoA, []string{main_}, []string{"k"}, v1)
575	if !ok || e.Size != 7 {
576		t.Fatalf("after restart: %v %+v", ok, e)
577	}
578	des, _ := os.ReadDir(filepath.Join(r.dir, "data"))
579	if len(des) != 1 {
580		t.Fatalf("files after restart: %d", len(des))
581	}
582	// Tokens do not survive a restart: the key is in memory only.
583	old := r.job("j1", repoA, main_, main_).token
584	if _, err := svc2.sign.verify(old, r.clk.now()); err == nil {
585		t.Fatal("token from the previous run accepted")
586	}
587}
588
589func TestListDiagnostics(t *testing.T) {
590	r := newRig(t, 1<<20)
591	w := r.job("j1", repoA, main_, main_)
592	w.save("go-1", v1, []byte("x"))
593	code, body, _ := w.do("GET", "caches?key=go-", nil, nil)
594	var out struct {
595		TotalCount     int
596		ArtifactCaches []struct{ CacheKey, Scope, CacheVersion string }
597	}
598	if code != 200 || json.Unmarshal(body, &out) != nil || out.TotalCount != 1 || out.ArtifactCaches[0].CacheKey != "go-1" {
599		t.Fatalf("%d %s", code, body)
600	}
601}
602
603func TestParseMax(t *testing.T) {
604	for in, want := range map[string]int64{"": DefaultMax, "10G": 10 << 30, "500m": 500 << 20, "1GiB": 1 << 30, "2048": 2048, "3TB": 3 << 40} {
605		if got, err := ParseMax(in); err != nil || got != want {
606			t.Errorf("%q: %d %v", in, got, err)
607		}
608	}
609	for _, in := range []string{"0", "-1", "G", "ten", "1.5G", "99999999999T"} {
610		if _, err := ParseMax(in); err == nil {
611			t.Errorf("%q accepted", in)
612		}
613	}
614}
615
616func TestAddSpan(t *testing.T) {
617	var l []span
618	for _, s := range []span{{8, 10}, {0, 4}, {4, 6}, {6, 8}} {
619		l = addSpan(l, s)
620	}
621	if !complete(l, 10) || len(l) != 1 {
622		t.Fatalf("%v", l)
623	}
624	if complete(addSpan(nil, span{1, 5}), 5) {
625		t.Fatal("hole at the start")
626	}
627}

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