1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10
11 "bnema.dev/ermite/internal/domain"
12)
13
14func spec(all string, kv ...string) *domain.PermissionSpec {
15 s := &domain.PermissionSpec{All: all, Levels: map[string]string{}}
16 for i := 0; i+1 < len(kv); i += 2 {
17 s.Levels[kv[i]] = kv[i+1]
18 }
19 return s
20}
21
22func TestResolvePermissions(t *testing.T) {
23 t.Parallel()
24 for _, tt := range []struct {
25 name string
26 workflow, job *domain.PermissionSpec
27 want map[string]string // nil: nothing asked, the default applies
28 }{
29 {"nothing anywhere", nil, nil, nil},
30 {"workflow only", spec("", "contents", "write"), nil, map[string]string{"metadata": "read", "contents": "write"}},
31 {"job replaces the workflow, no merge", spec("", "contents", "write", "issues", "write"), spec("", "pull-requests", "read"),
32 map[string]string{"metadata": "read", "pull-requests": "read"}},
33 {"job alone", nil, spec("", "contents", "write"), map[string]string{"metadata": "read", "contents": "write"}},
34 {"empty mapping is all none", spec("", "contents", "write"), spec(""), map[string]string{"metadata": "read"}},
35 {"workflow {} with no job", spec(""), nil, map[string]string{"metadata": "read"}},
36 {"none removes a key", spec("", "contents", "none", "packages", "read"), nil, map[string]string{"metadata": "read", "packages": "read"}},
37 {"read-all", spec("read-all"), nil, map[string]string{
38 "metadata": "read", "actions": "read", "artifact-metadata": "read", "attestations": "read", "checks": "read", "code-quality": "read",
39 "contents": "read", "deployments": "read", "discussions": "read", "drives": "read", "issues": "read", "models": "read",
40 "packages": "read", "pages": "read", "pull-requests": "read", "repository-projects": "read", "security-events": "read",
41 "statuses": "read", "vulnerability-alerts": "read"}},
42 {"write-all", nil, spec("write-all"), map[string]string{
43 "metadata": "read", "actions": "write", "artifact-metadata": "write", "attestations": "write", "checks": "write", "code-quality": "write",
44 "contents": "write", "copilot-requests": "write", "deployments": "write", "discussions": "write", "drives": "write", "id-token": "write",
45 "issues": "write", "models": "read", "packages": "write", "pages": "write", "pull-requests": "write", "repository-projects": "write",
46 "security-events": "write", "statuses": "write", "vulnerability-alerts": "read"}},
47 {"job read-all over workflow write-all", spec("write-all"), spec("read-all"), nil}, // checked below by key
48 {"a level a key cannot have is none", spec("", "models", "write", "id-token", "read", "contents", "read"), nil,
49 map[string]string{"metadata": "read", "contents": "read"}},
50 {"unknown keys and levels are none", spec("", "bogus", "write", "contents", "admin"), nil, map[string]string{"metadata": "read"}},
51 {"id-token write", spec("", "id-token", "write"), nil, map[string]string{"metadata": "read", "id-token": "write"}},
52 } {
53 got := domain.ResolvePermissions(tt.workflow, tt.job)
54 if tt.name == "job read-all over workflow write-all" {
55 assert.Equal(t, "read", got["contents"], tt.name)
56 assert.Equal(t, "read", got["issues"], tt.name)
57 assert.Equal(t, "read", got["models"], tt.name)
58 assert.NotContains(t, got, "id-token", tt.name)
59 continue
60 }
61 assert.Equal(t, tt.want, got, tt.name)
62 }
63}
64
65func TestTokenPermissionsTrust(t *testing.T) {
66 t.Parallel()
67 def := map[string]string{"contents": "read", "metadata": "read", "packages": "read"}
68 assert.Equal(t, def, domain.TokenPermissions(nil, true), "absent: the restricted default")
69 assert.Equal(t, def, domain.TokenPermissions(nil, false))
70 assert.Equal(t, def, domain.TokenPermissions(map[string]string{}, true), "empty is absent")
71
72 asked := map[string]string{"metadata": "read", "contents": "write", "issues": "write", "id-token": "write", "packages": "read", "models": "read"}
73 assert.Equal(t, asked, domain.TokenPermissions(asked, true), "trusted: as asked")
74 assert.Equal(t, map[string]string{"metadata": "read", "contents": "read", "issues": "read", "packages": "read", "models": "read"},
75 domain.TokenPermissions(asked, false), "untrusted: every write is read, id-token (no read) is none")
76
77 in := map[string]string{"contents": "write"}
78 _ = domain.TokenPermissions(in, false)
79 assert.Equal(t, map[string]string{"contents": "write"}, in, "the input is not changed")
80 assert.Equal(t, "read", domain.TokenPermissions(map[string]string{"metadata": "none"}, true)["metadata"], "metadata is always read")
81}
82
83func TestRepoTokenScopes(t *testing.T) {
84 t.Parallel()
85 for _, tt := range []struct {
86 name string
87 perms map[string]string
88 want []domain.Scope
89 }{
90 {"absent is the default", nil, []domain.Scope{domain.ScopeRepoRead}},
91 {"contents read", map[string]string{"contents": "read"}, []domain.Scope{domain.ScopeRepoRead}},
92 {"contents write", map[string]string{"contents": "write"}, []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite}},
93 {"contents none", map[string]string{"metadata": "read", "packages": "read"}, nil},
94 {"pull-requests write only", map[string]string{"pull-requests": "write"}, []domain.Scope{domain.ScopeMRWrite}},
95 {"pull-requests read", map[string]string{"contents": "read", "pull-requests": "read"}, []domain.Scope{domain.ScopeRepoRead}},
96 {"both", map[string]string{"contents": "write", "pull-requests": "write"}, []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite}},
97 {"write-all adds no other scope", domain.ResolvePermissions(spec("write-all"), nil), []domain.Scope{domain.ScopeRepoRead, domain.ScopeRepoWrite, domain.ScopeMRWrite}},
98 } {
99 assert.Equal(t, tt.want, domain.RepoTokenScopes(tt.perms), tt.name)
100 }
101}
102
103func TestRepoTokenPrincipalFollowsItsPermissions(t *testing.T) {
104 t.Parallel()
105 repo, _ := domain.ParseRepoName("proj")
106 other, _ := domain.ParseRepoName("other")
107 exp := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
108 tok := func(perms map[string]string) domain.Principal {
109 return domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "ab12", Repo: repo, ExpiresAt: exp, Permissions: perms})
110 }
111
112 w := tok(map[string]string{"contents": "write", "pull-requests": "write", "actions": "write", "issues": "write", "id-token": "write"})
113 assert.True(t, w.Allows(domain.ScopeRepoRead, repo))
114 assert.True(t, w.Allows(domain.ScopeRepoWrite, repo))
115 assert.True(t, w.Allows(domain.ScopeMRWrite, repo))
116 assert.False(t, w.Allows(domain.ScopeRepoWrite, other), "one repository only")
117 for _, s := range []domain.Scope{domain.ScopeRepoAdmin, domain.ScopeMRMerge, domain.ScopeCIWrite, domain.ScopeSecretsWrite, domain.ScopeRunnerRegister, domain.ScopeTokenAdmin} {
118 assert.False(t, w.HasScope(s), "never "+string(s))
119 }
120 assert.False(t, w.HasOwnerRights())
121 assert.False(t, w.IsBranchLimited())
122 assert.True(t, w.ViaJobToken())
123
124 none := tok(map[string]string{"metadata": "read"})
125 assert.False(t, none.Allows(domain.ScopeRepoRead, repo), "contents: none reads nothing")
126 assert.True(t, none.ViaJobToken())
127
128 assert.True(t, tok(nil).Allows(domain.ScopeRepoRead, repo))
129 assert.False(t, tok(nil).Allows(domain.ScopeRepoWrite, repo))
130
131 arch := domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "cd", Repo: repo, Archive: true, Sha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ExpiresAt: exp})
132 assert.True(t, arch.ViaJobToken())
133
134 assert.False(t, domain.Root().ViaJobToken())
135 assert.False(t, domain.Owner().ViaJobToken())
136 assert.False(t, domain.Anonymous().ViaJobToken())
137 assert.False(t, domain.TokenPrincipal(domain.Token{ID: "0123456789abcdef"}).ViaJobToken())
138}