1// SPDX-License-Identifier: MIT
2
3package cachesvc
4
5import (
6 "crypto/hmac"
7 "crypto/sha256"
8 "encoding/json"
9 "io"
10 "net/http"
11 "strings"
12 "testing"
13 "time"
14)
15
16// The runtime token is a JWT as @actions/artifact reads it: three base64url
17// segments, a JSON header and claims that jwt-decode accepts, and an scp claim
18// whose Actions.Results entry splits on ':' into the run and job ids
19// (toolkit packages/artifact/src/internal/shared/util.ts:21-75). Its signature
20// is a real HS256 under the key derived for tokens.
21func TestRuntimeTokenIsAJWT(t *testing.T) {
22 r := newRig(t, 1<<20)
23 tok := r.svc.Token("j1", Scope{Repository: repoA, WriteRef: main_, ReadRefs: []string{main_}}, time.Hour, "Actions.Results:42:abc")
24 seg := strings.Split(tok, ".")
25 if len(seg) != 3 {
26 t.Fatalf("%d segments", len(seg))
27 }
28 var hdr map[string]string
29 if b, err := b64.DecodeString(seg[0]); err != nil || json.Unmarshal(b, &hdr) != nil || hdr["alg"] != "HS256" || hdr["typ"] != "JWT" {
30 t.Fatalf("header %q: %v", seg[0], hdr)
31 }
32 var c struct {
33 Scp string `json:"scp"`
34 Exp int64 `json:"exp"`
35 Iat int64 `json:"iat"`
36 J string `json:"j"`
37 }
38 if b, err := b64.DecodeString(seg[1]); err != nil || json.Unmarshal(b, &c) != nil {
39 t.Fatalf("claims %q", seg[1])
40 }
41 now := r.clk.now().Unix()
42 if c.J != "j1" || c.Iat != now || c.Exp != now+3600 {
43 t.Fatalf("claims %+v", c)
44 }
45 scopes := strings.Fields(c.Scp)
46 if len(scopes) != 2 || scopes[0] != genericReadScope || scopes[1] != "Actions.Results:42:abc" {
47 t.Fatalf("scp %q", c.Scp)
48 }
49 if p := strings.Split(scopes[1], ":"); len(p) != 3 || p[1] != "42" || p[2] != "abc" {
50 t.Fatalf("results scope %q", scopes[1])
51 }
52 h := hmac.New(sha256.New, r.svc.sign.mac("token"))
53 h.Write([]byte(seg[0] + "." + seg[1]))
54 if got, _ := b64.DecodeString(seg[2]); !hmac.Equal(got, h.Sum(nil)) {
55 t.Fatal("not an HS256 signature under the token key")
56 }
57
58 job, scp, exp, err := r.svc.RuntimeToken(tok)
59 if err != nil || job != "j1" || scp != c.Scp || exp.Unix() != c.Exp {
60 t.Fatalf("RuntimeToken: %q %q %v %v", job, scp, exp, err)
61 }
62 // A cache-only token has no results scope; a results-only one is accepted
63 // by RuntimeToken but not by the cache (TestBadTokens).
64 if _, scp, _, _ := r.svc.RuntimeToken(r.job("j2", repoA, main_, main_).token); strings.Contains(scp, "Actions.Results") {
65 t.Fatalf("cache-only token with %q", scp)
66 }
67 if job, _, _, err := r.svc.RuntimeToken(r.svc.Token("j3", Scope{}, time.Hour, "Actions.Results:1:x")); err != nil || job != "j3" {
68 t.Fatalf("results-only token: %q %v", job, err)
69 }
70 if _, _, _, err := r.svc.RuntimeToken(tok[:len(tok)-2]); err == nil {
71 t.Fatal("bad signature accepted")
72 }
73 // Blob signatures have a domain of their own.
74 if hmac.Equal(r.svc.BlobMAC(), r.svc.sign.mac("token")) || hmac.Equal(r.svc.BlobMAC([]byte("x")), r.svc.sign.mac("archive", []byte("x"))) {
75 t.Fatal("blob MAC shares a domain")
76 }
77}
78
79// With WithResults, the channel sends the cache API to the cache and every
80// other path to the results handler.
81func TestChannelRoutesResults(t *testing.T) {
82 r := newRig(t, 1<<20)
83 results := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
84 b, _ := io.ReadAll(req.Body)
85 _, _ = io.WriteString(w, "results "+req.URL.Path+" "+string(b))
86 })
87 c := channel(t, r.svc, "er0000000a", "job-a", WithResults(results))
88 tok := r.job("job-a", repoA, main_, main_).token
89 if got := lookupStatus(t, c, tok); got != http.StatusNoContent {
90 t.Fatalf("cache lookup: %d", got)
91 }
92 resp, err := c.Post("http://cache/twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts", "application/json", strings.NewReader("{}"))
93 if err != nil {
94 t.Fatal(err)
95 }
96 b, _ := io.ReadAll(resp.Body)
97 _ = resp.Body.Close()
98 if string(b) != "results /twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts {}" {
99 t.Fatalf("results: %d %q", resp.StatusCode, b)
100 }
101 // Without it, those paths are not found.
102 plain := channel(t, r.svc, "er0000000b", "job-b")
103 resp, err = plain.Post("http://cache/twirp/x", "application/json", strings.NewReader("{}"))
104 if err != nil {
105 t.Fatal(err)
106 }
107 _ = resp.Body.Close()
108 if resp.StatusCode != http.StatusNotFound {
109 t.Fatalf("no results handler: %d", resp.StatusCode)
110 }
111}