v0.19.0 55a688e

ci_settings_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"bytes"
7	"context"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/adapters/kvstore"
16	"bnema.dev/ermite/internal/adapters/secretbox"
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20	"bnema.dev/ermite/internal/usecase"
21)
22
23type settingsEnv struct {
24	store *kvstore.Store
25	s     *usecase.CISettings
26	repo  domain.RepoName
27}
28
29func newSettings(t *testing.T, key byte) *settingsEnv {
30	t.Helper()
31	ctx := context.Background()
32	store, err := kvstore.Open(ctx, t.TempDir())
33	require.NoError(t, err)
34	t.Cleanup(func() { _ = store.Close(ctx) })
35	repo := name(t, "proj")
36	require.NoError(t, store.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate, CreatedAt: now}))
37	var sealer ports.SecretSealer
38	if key != 0 {
39		box, err := secretbox.New(bytes.Repeat([]byte{key}, secretbox.KeyLen))
40		require.NoError(t, err)
41		sealer = box
42	}
43	s, err := usecase.NewCISettings(ctx, store, store.Settings(), sealer, &fakeClock{t: now})
44	require.NoError(t, err)
45	return &settingsEnv{store: store, s: s, repo: repo}
46}
47
48// Precedence: environment over repository over instance, for variables and
49// secrets, and only the secrets that were asked for are opened.
50func TestResolvePrecedenceAndSelection(t *testing.T) {
51	ctx, root := context.Background(), domain.Root()
52	e := newSettings(t, 1)
53	inst, repo, env := domain.InstanceScope(), domain.RepoScope(e.repo), domain.EnvironmentScope(e.repo, "prod")
54	_, err := e.s.CreateEnvironment(ctx, root, e.repo, "Prod")
55	require.NoError(t, err)
56
57	for _, c := range []struct {
58		sc          domain.SettingsScope
59		name, value string
60	}{
61		{inst, "A", "inst"}, {inst, "B", "inst"}, {inst, "C", "inst"},
62		{repo, "B", "repo"}, {repo, "C", "repo"},
63		{env, "C", "env"}, {env, "D", "env"},
64	} {
65		_, err := e.s.SetSecret(ctx, root, c.sc, c.name, c.value)
66		require.NoError(t, err)
67		_, err = e.s.SetVariable(ctx, root, c.sc, c.name, "v-"+c.value)
68		require.NoError(t, err)
69	}
70
71	got, err := e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "PROD", WantSecrets: true, AllSecrets: true})
72	require.NoError(t, err)
73	assert.Equal(t, map[string]string{"A": "v-inst", "B": "v-repo", "C": "v-env", "D": "v-env"}, got.Vars)
74	assert.Equal(t, map[string]string{"A": "inst", "B": "repo", "C": "env", "D": "env"}, got.Secrets)
75
76	// Without an environment: instance and repository only.
77	got, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, WantSecrets: true, AllSecrets: true})
78	require.NoError(t, err)
79	assert.Equal(t, map[string]string{"A": "v-inst", "B": "v-repo", "C": "v-repo"}, got.Vars)
80	assert.Equal(t, map[string]string{"A": "inst", "B": "repo", "C": "repo"}, got.Secrets)
81
82	// Only the names asked for (any case) come out.
83	got, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "prod", WantSecrets: true, SecretNames: []string{"d", "A"}})
84	require.NoError(t, err)
85	assert.Equal(t, map[string]string{"A": "inst", "D": "env"}, got.Secrets)
86
87	// Not wanted (an untrusted run), or nothing named: no secret at all.
88	for _, req := range []ports.JobSettingsRequest{
89		{Repo: e.repo, AllSecrets: true, SecretNames: []string{"A"}},
90		{Repo: e.repo, WantSecrets: true},
91	} {
92		got, err = e.s.Resolve(ctx, req)
93		require.NoError(t, err)
94		assert.Empty(t, got.Secrets)
95		assert.NotEmpty(t, got.Vars, "variables are not secret")
96	}
97
98	// A missing environment adds nothing and is not created unless asked.
99	got, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "new"})
100	require.NoError(t, err)
101	assert.Equal(t, "v-repo", got.Vars["C"])
102	page, err := e.s.ListEnvironments(ctx, root, e.repo, 0, "")
103	require.NoError(t, err)
104	assert.Len(t, page.Environments, 1)
105	_, err = e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, Environment: "New", CreateEnvironment: true})
106	require.NoError(t, err)
107	page, err = e.s.ListEnvironments(ctx, root, e.repo, 0, "")
108	require.NoError(t, err)
109	assert.Len(t, page.Environments, 2)
110}
111
112// A sealed value moved to another secret is not given to a job: the ciphertext
113// is bound to its scope and name.
114func TestResolveLeavesOutASwappedCiphertext(t *testing.T) {
115	ctx, root := context.Background(), domain.Root()
116	e := newSettings(t, 1)
117	sc := domain.RepoScope(e.repo)
118	_, err := e.s.SetSecret(ctx, root, sc, "A", "value-of-a")
119	require.NoError(t, err)
120	_, err = e.s.SetSecret(ctx, root, sc, "B", "value-of-b")
121	require.NoError(t, err)
122
123	st := e.store.Settings()
124	sealed, err := st.ListSealedSecrets(ctx, sc)
125	require.NoError(t, err)
126	require.Len(t, sealed, 2)
127	// Swap the two stored values behind the use case's back.
128	_, err = st.PutSecret(ctx, sc, "A", sealed[1].Sealed, now)
129	require.NoError(t, err)
130	_, err = st.PutSecret(ctx, sc, "B", sealed[0].Sealed, now)
131	require.NoError(t, err)
132
133	got, err := e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, WantSecrets: true, AllSecrets: true})
134	require.NoError(t, err)
135	assert.Empty(t, got.Secrets, "neither opens")
136}
137
138func TestSetSecretWithoutAKeyIsAFailedPrecondition(t *testing.T) {
139	ctx, root := context.Background(), domain.Root()
140	e := newSettings(t, 0)
141	_, err := e.s.SetSecret(ctx, root, domain.RepoScope(e.repo), "A", "v")
142	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
143	// Checked after the arguments: a bad name is still invalid.
144	_, err = e.s.SetSecret(ctx, root, domain.RepoScope(e.repo), "1", "v")
145	assert.True(t, domain.IsInvalid(err))
146	// Variables and environments need no key, and no secret is sent.
147	_, err = e.s.SetVariable(ctx, root, domain.RepoScope(e.repo), "V", "v")
148	require.NoError(t, err)
149	got, err := e.s.Resolve(ctx, ports.JobSettingsRequest{Repo: e.repo, WantSecrets: true, AllSecrets: true})
150	require.NoError(t, err)
151	assert.True(t, got.SecretsOff)
152	assert.Empty(t, got.Secrets)
153	assert.Equal(t, "v", got.Vars["V"])
154}
155
156func TestNewCISettingsRefusesAnotherKey(t *testing.T) {
157	ctx := context.Background()
158	dir := t.TempDir()
159	open := func(key byte) error {
160		store, err := kvstore.Open(ctx, dir)
161		require.NoError(t, err)
162		defer func() { _ = store.Close(ctx) }()
163		box, err := secretbox.New(bytes.Repeat([]byte{key}, secretbox.KeyLen))
164		require.NoError(t, err)
165		_, err = usecase.NewCISettings(ctx, store, store.Settings(), box, &fakeClock{t: time.Now()})
166		return err
167	}
168	require.NoError(t, open(1))
169	require.NoError(t, open(1))
170	assert.ErrorIs(t, open(2), domain.ErrSecretsKeyMismatch)
171	assert.NoError(t, open(1), "the first key still starts")
172}
173
174func TestSettingsAccess(t *testing.T) {
175	ctx := context.Background()
176	e := newSettings(t, 1)
177	sc := domain.RepoScope(e.repo)
178	tok := func(scopes []domain.Scope, repos []domain.RepoName, prefixes []string) domain.Principal {
179		return domain.TokenPrincipal(domain.Token{ID: "t", Scopes: scopes, Repos: repos, BranchPrefixes: prefixes})
180	}
181	all := domain.AllScopes()
182	cases := []struct {
183		name string
184		p    domain.Principal
185		sc   domain.SettingsScope
186		want error
187	}{
188		{"root", domain.Root(), sc, nil},
189		{"owner session", domain.Owner(), sc, nil},
190		{"token with the scope", tok([]domain.Scope{domain.ScopeSecretsWrite}, nil, nil), sc, nil},
191		{"token on its repository", tok([]domain.Scope{domain.ScopeSecretsWrite}, []domain.RepoName{e.repo}, nil), sc, nil},
192		{"every scope but branch-limited", tok(all, nil, []string{"agent/"}), sc, domain.ErrForbidden},
193		{"branch-limited, instance scope", tok(all, nil, []string{"agent/"}), domain.InstanceScope(), domain.ErrForbidden},
194		{"reads the repository but lacks the scope", tok([]domain.Scope{domain.ScopeRepoRead}, nil, nil), sc, domain.ErrForbidden},
195		{"cannot read the private repository", tok([]domain.Scope{domain.ScopeMRWrite}, nil, nil), sc, domain.ErrNotFound},
196		{"anonymous", domain.Anonymous(), sc, domain.ErrUnauthenticated},
197		{"job token", domain.RepoTokenPrincipal(domain.CIRepoToken{ID: "j", Repo: e.repo}), sc, domain.ErrForbidden},
198		{"instance, repository-restricted token", tok([]domain.Scope{domain.ScopeSecretsWrite}, []domain.RepoName{e.repo}, nil), domain.InstanceScope(), domain.ErrForbidden},
199		{"instance, unrestricted", tok([]domain.Scope{domain.ScopeSecretsWrite}, nil, nil), domain.InstanceScope(), nil},
200		{"invalid scope", domain.Root(), domain.SettingsScope{}, domain.ErrInvalid},
201	}
202	for _, tc := range cases {
203		_, err := e.s.ListSecrets(ctx, tc.p, tc.sc, 0, "")
204		if tc.want == nil {
205			assert.NoError(t, err, tc.name)
206			continue
207		}
208		assert.ErrorIs(t, err, tc.want, tc.name)
209	}
210}
211
212// A higher scope whose ciphertext does not open withholds the secret: the job
213// never gets the value of a lower scope that the override was meant to replace.
214// Named and "all" resolution agree, and an unrelated secret is not affected.
215func TestResolveWithholdsASecretWhoseOverrideDoesNotOpen(t *testing.T) {
216	ctx, root := context.Background(), domain.Root()
217	e := newSettings(t, 1)
218	inst, repo := domain.InstanceScope(), domain.RepoScope(e.repo)
219	for _, c := range []struct {
220		sc          domain.SettingsScope
221		name, value string
222	}{{inst, "A", "lower-a"}, {inst, "B", "lower-b"}, {repo, "A", "upper-a"}} {
223		_, err := e.s.SetSecret(ctx, root, c.sc, c.name, c.value)
224		require.NoError(t, err)
225	}
226	_, err := e.store.Settings().PutSecret(ctx, repo, "A", []byte("garbage that is not a sealed value at all"), now)
227	require.NoError(t, err)
228
229	for name, req := range map[string]ports.JobSettingsRequest{
230		"all":   {Repo: e.repo, WantSecrets: true, AllSecrets: true},
231		"named": {Repo: e.repo, WantSecrets: true, SecretNames: []string{"a", "B"}},
232	} {
233		got, err := e.s.Resolve(ctx, req)
234		require.NoError(t, err, name)
235		assert.Equal(t, map[string]string{"B": "lower-b"}, got.Secrets, name)
236	}
237}
238
239// Named resolution reads the names it needs, not whole scopes.
240func TestResolveNamedSecretsUsePointReads(t *testing.T) {
241	ctx := context.Background()
242	store := mocks.NewCISettingsStore(t)
243	sealer := mocks.NewSecretSealer(t)
244	sealer.EXPECT().KeyCheck().Return([]byte("k"))
245	store.EXPECT().BindSecretsKey(mock.Anything, mock.Anything).Return(nil)
246	s, err := usecase.NewCISettings(ctx, mocks.NewRepoMetaStore(t), store, sealer, &fakeClock{t: now})
247	require.NoError(t, err)
248	repo := name(t, "proj")
249	store.EXPECT().ListVariables(mock.Anything, mock.Anything).Return(nil, nil)
250	// Two scopes (instance, repository), one name each: no ListSealedSecrets call.
251	store.EXPECT().GetSealedSecret(mock.Anything, domain.InstanceScope(), "A").Return(ports.SealedSecret{}, domain.ErrNotFound)
252	store.EXPECT().GetSealedSecret(mock.Anything, domain.RepoScope(repo), "A").Return(ports.SealedSecret{Name: "A", Sealed: []byte("s")}, nil)
253	sealer.EXPECT().Open([]byte("s"), domain.SecretAAD(domain.RepoScope(repo), "A")).Return([]byte("v"), nil)
254	got, err := s.Resolve(ctx, ports.JobSettingsRequest{Repo: repo, WantSecrets: true, SecretNames: []string{"a", "A"}})
255	require.NoError(t, err)
256	assert.Equal(t, map[string]string{"A": "v"}, got.Secrets)
257}

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 44

↑↓ moveenter openesc close