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, 11)
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}