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 TestParseSize(t *testing.T) {
604 const def = 7
605 for in, want := range map[string]int64{"": def, " ": def, "10G": 10 << 30, "500m": 500 << 20, "1GiB": 1 << 30, "2048": 2048, "3TB": 3 << 40, " 2G ": 2 << 30,
606 "0": 0, "00": 0, "0G": 0, "0GiB": 0, "2GIB": 2 << 30} {
607 if got, err := ParseSize(in, def); err != nil || got != want {
608 t.Errorf("%q: %d %v", in, got, err)
609 }
610 }
611 for _, in := range []string{"-1", "G", "ten", "1.5G", "99999999999T"} {
612 _, err := ParseSize(in, def)
613 if err == nil {
614 t.Errorf("%q accepted", in)
615 } else if strings.Contains(err.Error(), "ERMITE_") {
616 t.Errorf("%q: the error names a variable: %v", in, err)
617 }
618 }
619}
620
621func TestAddSpan(t *testing.T) {
622 var l []span
623 for _, s := range []span{{8, 10}, {0, 4}, {4, 6}, {6, 8}} {
624 l = addSpan(l, s)
625 }
626 if !complete(l, 10) || len(l) != 1 {
627 t.Fatalf("%v", l)
628 }
629 if complete(addSpan(nil, span{1, 5}), 5) {
630 t.Fatal("hole at the start")
631 }
632}