v0.19.0 55a688e

ci_permissions_test.go RawHistoryFolder

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}
139
140func TestPermissionViolations(t *testing.T) {
141	t.Parallel()
142	asked, allowed := domain.PermissionViolations(
143		map[string]string{"contents": "write", "issues": "read", "packages": "read", "pages": "none"},
144		map[string]string{"contents": "read", "packages": "write"})
145	assert.Equal(t, []string{"contents: write", "issues: read"}, asked)
146	assert.Equal(t, []string{"contents: read", "issues: none"}, allowed)
147	asked, _ = domain.PermissionViolations(map[string]string{"contents": "read"}, map[string]string{"contents": "write"})
148	assert.Empty(t, asked, "lowering is allowed")
149}

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 3

Tags 43

↑↓ moveenter openesc close