v0.4.4 edcdd4c

token_test.go RawHistoryFolder

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}

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