detached 59d5a0b

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

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 45

↑↓ moveenter openesc close