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}