1// SPDX-License-Identifier: MIT
2
3package cachesvc
4
5import (
6 "context"
7 "io"
8 "net/http"
9 "net/url"
10 "os"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "bnema.dev/ermite-runner/internal/actionscache"
17)
18
19func archiveRig(t *testing.T) (*rig, *actionscache.Cache, string) {
20 t.Helper()
21 ac, err := actionscache.New(actionscache.Config{Dir: filepath.Join(t.TempDir(), "a"), Max: 1 << 20})
22 if err != nil {
23 t.Fatal(err)
24 }
25 r := newRigWith(t, Config{Max: 1 << 20, MinCharge: 1, Archives: ac})
26 key, _ := actionscache.Key("https://github.com", "o/r", strings.Repeat("a", 40))
27 if _, err := ac.Ensure(context.Background(), key, func(_ context.Context, w io.Writer) error {
28 _, err := io.WriteString(w, "tarball")
29 return err
30 }); err != nil {
31 t.Fatal(err)
32 }
33 return r, ac, key
34}
35
36// The guest reads the archive the host fetched on the channel of its job, with
37// the signed URL and nothing else; a URL for another job, another key, a stale
38// expiry or a forged signature gets nothing.
39func TestActionArchiveRoute(t *testing.T) {
40 r, ac, key := archiveRig(t)
41 a := channel(t, r.svc, "er0000000a", "job-a")
42 b := channel(t, r.svc, "er0000000b", "job-b")
43
44 loc := r.svc.ArchiveURL("job-a", key, time.Hour)
45 if !strings.HasPrefix(loc, "http://127.0.0.1:9089"+ArchivePath+key+"?") {
46 t.Fatalf("url %q", loc)
47 }
48 if code, body := archiveStatus(t, a, "GET", loc); code != http.StatusOK || body != "tarball" {
49 t.Fatalf("own channel: %d %q", code, body)
50 }
51 if code, _ := archiveStatus(t, b, "GET", loc); code != http.StatusForbidden {
52 t.Errorf("another job's channel: %d", code)
53 }
54 u, _ := url.Parse(loc)
55 otherKey, _ := actionscache.Key("https://github.com", "o/private", strings.Repeat("a", 40))
56 if code, _ := archiveStatus(t, a, "GET", strings.Replace(loc, key, otherKey, 1)); code != http.StatusForbidden {
57 t.Errorf("a signature moved to another key: %d", code)
58 }
59 q := u.Query()
60 q.Set("sig", "AAAA")
61 u.RawQuery = q.Encode()
62 if code, _ := archiveStatus(t, a, "GET", u.String()); code != http.StatusForbidden {
63 t.Errorf("forged signature: %d", code)
64 }
65 r.clk.t = r.clk.t.Add(2 * time.Hour)
66 if code, _ := archiveStatus(t, a, "GET", loc); code != http.StatusForbidden {
67 t.Errorf("expired: %d", code)
68 }
69 r.clk.t = r.clk.t.Add(-2 * time.Hour)
70
71 // A corrupt archive is never served.
72 files, _ := filepath.Glob(filepath.Join(ac.Dir(), "*.tgz"))
73 if len(files) != 1 {
74 t.Fatalf("files %v", files)
75 }
76 if err := os.WriteFile(files[0], []byte("tampered"), 0o600); err != nil {
77 t.Fatal(err)
78 }
79 if code, body := archiveStatus(t, a, "GET", loc); code != http.StatusNotFound || strings.Contains(body, "tampered") {
80 t.Errorf("corrupt: %d %q", code, body)
81 }
82}
83
84func TestActionArchiveURLNeedsACache(t *testing.T) {
85 r := newRig(t, 1<<20)
86 key, _ := actionscache.Key("https://github.com", "o/r", strings.Repeat("a", 40))
87 if u := r.svc.ArchiveURL("j", key, time.Hour); u != "" {
88 t.Errorf("url without a cache: %q", u)
89 }
90}
91
92// A channel that also serves the results service still sends the action archive
93// route to the cache: the results handler takes only the paths of neither.
94func TestActionArchiveRouteWithResults(t *testing.T) {
95 r, _, key := archiveRig(t)
96 results := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
97 http.Error(w, "results", http.StatusNotFound)
98 })
99 a := channel(t, r.svc, "er0000000a", "job-a", WithResults(results))
100 if code, body := archiveStatus(t, a, "GET", r.svc.ArchiveURL("job-a", key, time.Hour)); code != http.StatusOK || body != "tarball" {
101 t.Fatalf("archive with results: %d %q", code, body)
102 }
103}