v0.33.0 cf26365

token_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12
13	"bnema.dev/ermite/internal/domain"
14)
15
16func TestScopes(t *testing.T) {
17	t.Parallel()
18	all := domain.AllScopes()
19	assert.Len(t, all, 13)
20	assert.Equal(t, domain.ScopeMRForceMerge, all[5])
21	assert.Equal(t, domain.ScopeReleaseWrite, all[len(all)-1])
22	assert.Contains(t, all, domain.ScopeReleaseWrite)
23	assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:len(all)-1]}).HasOwnerRights(), "missing only release:write")
24	assert.Contains(t, all, domain.ScopeIssueWrite)
25	got, err := domain.ParseScope("issue:write")
26	require.NoError(t, err)
27	assert.Equal(t, domain.ScopeIssueWrite, got)
28	for _, s := range all {
29		got, err := domain.ParseScope(s.String())
30		require.NoError(t, err)
31		assert.Equal(t, s, got)
32	}
33	for _, in := range []string{"", "repo", "repo:READ", "admin", "repo:read "} {
34		_, err := domain.ParseScope(in)
35		assert.ErrorIs(t, err, domain.ErrInvalidScope, in)
36	}
37}
38
39func TestHashTokenSecret(t *testing.T) {
40	t.Parallel()
41	h := domain.HashTokenSecret("ermite_abc")
42	assert.Equal(t, h, domain.HashTokenSecret("ermite_abc"))
43	assert.NotEqual(t, h, domain.HashTokenSecret("ermite_abd"))
44	assert.Len(t, h.String(), 64)
45	// SHA-256("") known value.
46	assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", domain.HashTokenSecret("").String())
47}
48
49func TestTokenExpired(t *testing.T) {
50	t.Parallel()
51	now := time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
52	assert.False(t, domain.Token{}.Expired(now), "no expiry never expires")
53	assert.False(t, domain.Token{ExpiresAt: now.Add(time.Second)}.Expired(now))
54	assert.True(t, domain.Token{ExpiresAt: now}.Expired(now), "expires at the instant")
55	assert.True(t, domain.Token{ExpiresAt: now.Add(-time.Second)}.Expired(now))
56}
57
58func TestPrincipalScopes(t *testing.T) {
59	t.Parallel()
60	a, b := mustName(t, "a"), mustName(t, "b")
61	tok := domain.Token{ID: "t1", Scopes: []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}, Repos: []domain.RepoName{a}}
62	p := domain.TokenPrincipal(tok)
63
64	assert.Equal(t, "token", p.Label())
65	assert.Equal(t, "t1", p.TokenID())
66	assert.False(t, p.IsRoot())
67	assert.False(t, p.IsAnonymous())
68	assert.True(t, p.HasScope(domain.ScopeRepoWrite))
69	assert.False(t, p.HasScope(domain.ScopeTokenAdmin))
70	assert.True(t, p.Allows(domain.ScopeRepoWrite, a))
71	assert.False(t, p.Allows(domain.ScopeRepoWrite, b), "outside the repository list")
72	assert.False(t, p.Allows(domain.ScopeRepoAdmin, a), "scope not held")
73
74	// Without a list, every repository.
75	all := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoRead}})
76	assert.True(t, all.Allows(domain.ScopeRepoRead, a))
77	assert.True(t, all.Allows(domain.ScopeRepoRead, b))
78
79	// The principal owns copies.
80	tok.Scopes[0] = domain.ScopeTokenAdmin
81	assert.True(t, p.HasScope(domain.ScopeRepoRead))
82	p.Scopes()[0] = domain.ScopeTokenAdmin
83	assert.True(t, p.HasScope(domain.ScopeRepoRead))
84
85	assert.True(t, domain.Root().Allows(domain.ScopeTokenAdmin, a))
86	assert.False(t, domain.Anonymous().HasScope(domain.ScopeRepoRead))
87	assert.False(t, domain.Anonymous().Allows(domain.ScopeRepoRead, a))
88	assert.Equal(t, "root", domain.Root().Label())
89	assert.Equal(t, "anonymous", domain.Anonymous().Label())
90}
91
92func mustName(t *testing.T, s string) domain.RepoName {
93	t.Helper()
94	n, err := domain.ParseRepoName(s)
95	require.NoError(t, err)
96	return n
97}
98
99func TestOwnerPrincipal(t *testing.T) {
100	t.Parallel()
101	o := domain.Owner()
102	assert.Equal(t, "owner", o.Label())
103	assert.True(t, o.IsOwner())
104	assert.False(t, domain.Root().IsOwner())
105	assert.True(t, o.IsRoot(), "the owner has the rights of root")
106	assert.True(t, o.HasScope(domain.ScopeMRMerge))
107	assert.True(t, o.Allows(domain.ScopeRepoRead, mustName(t, "any")))
108	assert.False(t, o.IsAnonymous())
109	assert.Empty(t, o.TokenID())
110}
111
112func TestHasOwnerRights(t *testing.T) {
113	t.Parallel()
114	a := mustName(t, "a")
115	all := domain.AllScopes()
116	assert.True(t, domain.Root().HasOwnerRights())
117	assert.True(t, domain.Owner().HasOwnerRights())
118	assert.False(t, domain.Anonymous().HasOwnerRights())
119	assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: all}).HasOwnerRights())
120	assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short")
121	assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[:9]}).HasOwnerRights(), "without issue:write")
122	assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository")
123}
124
125func TestParseBranchPrefix(t *testing.T) {
126	t.Parallel()
127	for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} {
128		got, err := domain.ParseBranchPrefix(ok)
129		require.NoError(t, err, ok)
130		assert.Equal(t, ok, got)
131	}
132	for _, bad := range []string{
133		"", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/",
134		"a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/",
135		"refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/",
136	} {
137		_, err := domain.ParseBranchPrefix(bad)
138		assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad)
139	}
140}
141
142func TestParseBranchPrefixes(t *testing.T) {
143	t.Parallel()
144	got, err := domain.ParseBranchPrefixes(nil)
145	require.NoError(t, err)
146	assert.Empty(t, got)
147
148	got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"})
149	require.NoError(t, err)
150	assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept")
151
152	eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"}
153	got, err = domain.ParseBranchPrefixes(eight)
154	require.NoError(t, err)
155	assert.Len(t, got, 8)
156	_, err = domain.ParseBranchPrefixes(append(eight, "i/"))
157	assert.ErrorIs(t, err, domain.ErrInvalidToken)
158	got, err = domain.ParseBranchPrefixes(append(eight, "a/"))
159	require.NoError(t, err, "duplicates do not count")
160	assert.Len(t, got, 8)
161
162	_, err = domain.ParseBranchPrefixes([]string{"a/", "bad"})
163	assert.ErrorIs(t, err, domain.ErrInvalidToken)
164}
165
166func TestAllowsRef(t *testing.T) {
167	t.Parallel()
168	limited := domain.TokenPrincipal(domain.Token{
169		Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"},
170	})
171	for ref, want := range map[string]bool{
172		"refs/heads/agent/claude/x":       true,
173		"refs/heads/agent/claude/a/b":     true,
174		"refs/heads/bot/y":                true,
175		"refs/heads/agent/claude":         false,
176		"refs/heads/agent/claude/":        false,
177		"refs/heads/agent/claudex/y":      false,
178		"refs/heads/main":                 false,
179		"refs/heads/agent/other/x":        false,
180		"refs/heads/other/agent/claude/x": false,
181		"refs/tags/v1":                    false,
182		"refs/tags/agent/claude/x":        false,
183		"refs/notes/commits":              false,
184		"refs/remotes/agent/claude/x":     false,
185		"refs/changes/agent/claude/x":     false,
186		"agent/claude/x":                  false,
187		"heads/agent/claude/x":            false,
188		"HEAD":                            false,
189		"":                                false,
190		"refs/heads/agent/claude/../main": false,
191		"refs/heads/agent/claude/x.lock":  false,
192		"refs/heads/agent/claude/a b":     false,
193		"refs/heads//agent/claude/x":      false,
194	} {
195		assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref)
196	}
197
198	// A corrupt empty prefix still limits the token and allows nothing.
199	corrupt := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{""}})
200	assert.False(t, corrupt.AllowsRef("refs/heads/main"))
201
202	// Without prefixes every ref is allowed.
203	unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}})
204	assert.True(t, unlimited.AllowsRef("refs/tags/v1"))
205	assert.True(t, unlimited.AllowsRef("refs/heads/main"))
206	assert.True(t, domain.Root().AllowsRef("refs/tags/v1"))
207	assert.True(t, domain.Owner().AllowsRef("refs/heads/main"))
208}
209
210func TestBranchPrefixesOfPrincipal(t *testing.T) {
211	t.Parallel()
212	tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}}
213	p := domain.TokenPrincipal(tok)
214	assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
215	assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner")
216	assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
217
218	// The principal owns copies.
219	tok.BranchPrefixes[0] = "b/"
220	assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
221	p.BranchPrefixes()[0] = "c/"
222	assert.Equal(t, []string{"a/"}, p.BranchPrefixes())
223	assert.Empty(t, domain.Root().BranchPrefixes())
224}
225
226func TestBranchLimitedAndCoversBranchPrefix(t *testing.T) {
227	t.Parallel()
228	free := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()})
229	assert.False(t, free.IsBranchLimited())
230	assert.False(t, free.CoversBranchPrefix("agent/x"), "no prefixes cover nothing: callers test IsBranchLimited first")
231	assert.False(t, domain.Root().IsBranchLimited())
232	assert.False(t, domain.Owner().IsBranchLimited())
233	assert.False(t, domain.Anonymous().IsBranchLimited())
234
235	limited := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"agent/claude/", "bot/"}})
236	assert.True(t, limited.IsBranchLimited())
237	for s, want := range map[string]bool{
238		"agent/claude/x": true, "agent/claude/": true, "bot/y": true,
239		"agent/claude": false, "agent/claudex/y": false, "main": false, "": false, "xbot/y": false,
240	} {
241		assert.Equal(t, want, limited.CoversBranchPrefix(s), "%q", s)
242	}
243
244	// A corrupt empty prefix still limits, and covers nothing.
245	corrupt := domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{""}})
246	assert.True(t, corrupt.IsBranchLimited())
247	assert.False(t, corrupt.CoversBranchPrefix("main"))
248	assert.False(t, corrupt.CoversBranchPrefix(""))
249}

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 2

Tags 44

↑↓ moveenter openesc close