detached 973e682

ci_settings_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6	"context"
7	"testing"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"bnema.dev/ermite/internal/adapters/kvstore"
13	"bnema.dev/ermite/internal/domain"
14)
15
16func TestSettingsSecretsPerScopeAreSortedAndIsolated(t *testing.T) {
17	ctx := context.Background()
18	s, _, repo := ciRepo(t)
19	st := s.Settings()
20	other := name(t, "other")
21	require.NoError(t, s.Create(ctx, domain.Repo{Name: other, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
22	_, err := st.CreateEnvironment(ctx, repo, "Prod", created)
23	require.NoError(t, err)
24
25	inst, repoScope, env := domain.InstanceScope(), domain.RepoScope(repo), domain.EnvironmentScope(repo, "prod")
26	for _, c := range []struct {
27		scope domain.SettingsScope
28		name  string
29	}{{inst, "B"}, {inst, "A"}, {repoScope, "R"}, {env, "E"}, {domain.RepoScope(other), "O"}} {
30		_, err := st.PutSecret(ctx, c.scope, c.name, []byte("sealed-"+c.name), created)
31		require.NoError(t, err)
32	}
33	list := func(sc domain.SettingsScope) []string {
34		got, err := st.ListSealedSecrets(ctx, sc)
35		require.NoError(t, err)
36		var out []string
37		for _, g := range got {
38			out = append(out, g.Name+"="+string(g.Sealed))
39		}
40		return out
41	}
42	assert.Equal(t, []string{"A=sealed-A", "B=sealed-B"}, list(inst))
43	assert.Equal(t, []string{"R=sealed-R"}, list(repoScope))
44	assert.Equal(t, []string{"E=sealed-E"}, list(domain.EnvironmentScope(repo, "PROD")), "an environment is found without regard to case")
45	assert.Equal(t, []string{"O=sealed-O"}, list(domain.RepoScope(other)))
46
47	_, err = st.PutSecret(ctx, inst, "A", []byte("new"), created.Add(1))
48	require.NoError(t, err)
49	assert.Equal(t, []string{"A=new", "B=sealed-B"}, list(inst), "replaced")
50
51	require.NoError(t, st.DeleteSecret(ctx, inst, "A"))
52	assert.ErrorIs(t, st.DeleteSecret(ctx, inst, "A"), domain.ErrNotFound)
53	assert.Equal(t, []string{"B=sealed-B"}, list(inst))
54}
55
56func TestSettingsMissingRepositoryOrEnvironmentIsNotFound(t *testing.T) {
57	ctx := context.Background()
58	s, _, repo := ciRepo(t)
59	st := s.Settings()
60	ghost := name(t, "ghost")
61	for _, sc := range []domain.SettingsScope{domain.RepoScope(ghost), domain.EnvironmentScope(repo, "nope"), domain.EnvironmentScope(ghost, "x")} {
62		_, err := st.PutSecret(ctx, sc, "A", []byte("x"), created)
63		assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
64		_, err = st.PutVariable(ctx, sc, "A", "x", created)
65		assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
66		_, err = st.ListSealedSecrets(ctx, sc)
67		assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
68		_, err = st.ListVariables(ctx, sc)
69		assert.ErrorIs(t, err, domain.ErrNotFound, sc.Key())
70	}
71	_, err := st.CreateEnvironment(ctx, ghost, "x", created)
72	assert.ErrorIs(t, err, domain.ErrNotFound)
73	_, err = st.ListEnvironments(ctx, ghost)
74	assert.ErrorIs(t, err, domain.ErrNotFound)
75	assert.ErrorIs(t, st.DeleteEnvironment(ctx, repo, "nope"), domain.ErrNotFound)
76	assert.Error(t, func() error { _, err := st.PutSecret(ctx, domain.SettingsScope{}, "A", nil, created); return err }(), "an invalid scope")
77}
78
79func TestSettingsVariablesRoundTrip(t *testing.T) {
80	ctx := context.Background()
81	s, _, repo := ciRepo(t)
82	st := s.Settings()
83	sc := domain.RepoScope(repo)
84	_, err := st.PutVariable(ctx, sc, "Z", "", created)
85	require.NoError(t, err)
86	_, err = st.PutVariable(ctx, sc, "A", "multi\nline", created.Add(1))
87	require.NoError(t, err)
88	got, err := st.GetVariable(ctx, sc, "A")
89	require.NoError(t, err)
90	assert.Equal(t, domain.Variable{Name: "A", Value: "multi\nline", UpdatedAt: created.Add(1)}, got)
91	_, err = st.GetVariable(ctx, sc, "NONE")
92	assert.ErrorIs(t, err, domain.ErrNotFound)
93	all, err := st.ListVariables(ctx, sc)
94	require.NoError(t, err)
95	require.Len(t, all, 2)
96	assert.Equal(t, "A", all[0].Name)
97	assert.Equal(t, "", all[1].Value, "an empty value is kept")
98	require.NoError(t, st.DeleteVariable(ctx, sc, "A"))
99	assert.ErrorIs(t, st.DeleteVariable(ctx, sc, "A"), domain.ErrNotFound)
100}
101
102func TestSettingsEnvironmentsAreCaseInsensitiveAndDeleteTheirContent(t *testing.T) {
103	ctx := context.Background()
104	s, _, repo := ciRepo(t)
105	st := s.Settings()
106	e, err := st.CreateEnvironment(ctx, repo, "Prod", created)
107	require.NoError(t, err)
108	assert.Equal(t, "Prod", e.Name)
109	again, err := st.CreateEnvironment(ctx, repo, "PROD", created.Add(1))
110	require.NoError(t, err)
111	assert.Equal(t, domain.Environment{Repo: repo, Name: "Prod", CreatedAt: created}, again, "the existing one, unchanged")
112	_, err = st.CreateEnvironment(ctx, repo, "a/b", created)
113	require.NoError(t, err)
114	_, err = st.CreateEnvironment(ctx, repo, "a", created)
115	require.NoError(t, err)
116
117	sc := domain.EnvironmentScope(repo, "prod")
118	_, err = st.PutSecret(ctx, sc, "S", []byte("x"), created)
119	require.NoError(t, err)
120	_, err = st.PutVariable(ctx, sc, "V", "v", created)
121	require.NoError(t, err)
122	_, err = st.PutSecret(ctx, domain.EnvironmentScope(repo, "a"), "KEEP", []byte("x"), created)
123	require.NoError(t, err)
124	_, err = st.PutSecret(ctx, domain.EnvironmentScope(repo, "a/b"), "KEEP", []byte("x"), created)
125	require.NoError(t, err)
126
127	all, err := st.ListEnvironments(ctx, repo)
128	require.NoError(t, err)
129	require.Len(t, all, 3)
130
131	require.NoError(t, st.DeleteEnvironment(ctx, repo, "PROD"))
132	_, err = st.ListSealedSecrets(ctx, sc)
133	assert.ErrorIs(t, err, domain.ErrNotFound)
134	// A write to the deleted environment cannot leave a stray value.
135	_, err = st.PutSecret(ctx, sc, "S", []byte("x"), created)
136	assert.ErrorIs(t, err, domain.ErrNotFound)
137	// Its neighbours, whose names share a prefix with it, are untouched.
138	for _, env := range []string{"a", "a/b"} {
139		got, err := st.ListSealedSecrets(ctx, domain.EnvironmentScope(repo, env))
140		require.NoError(t, err, env)
141		assert.Len(t, got, 1, env)
142	}
143}
144
145func TestSettingsDieWithTheRepository(t *testing.T) {
146	ctx := context.Background()
147	s, _, repo := ciRepo(t)
148	st := s.Settings()
149	keep := name(t, "proj2") // its name starts like the deleted one's
150	require.NoError(t, s.Create(ctx, domain.Repo{Name: keep, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
151	for _, r := range []domain.RepoName{repo, keep} {
152		_, err := st.CreateEnvironment(ctx, r, "prod", created)
153		require.NoError(t, err)
154		for _, sc := range []domain.SettingsScope{domain.RepoScope(r), domain.EnvironmentScope(r, "prod")} {
155			_, err = st.PutSecret(ctx, sc, "S", []byte("x"), created)
156			require.NoError(t, err)
157			_, err = st.PutVariable(ctx, sc, "V", "v", created)
158			require.NoError(t, err)
159		}
160	}
161	inst := domain.InstanceScope()
162	_, err := st.PutSecret(ctx, inst, "S", []byte("x"), created)
163	require.NoError(t, err)
164
165	require.NoError(t, s.Delete(ctx, repo))
166
167	// A repository created again under the name starts clean.
168	require.NoError(t, s.Create(ctx, domain.Repo{Name: repo, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
169	envs, err := st.ListEnvironments(ctx, repo)
170	require.NoError(t, err)
171	assert.Empty(t, envs)
172	secrets, err := st.ListSealedSecrets(ctx, domain.RepoScope(repo))
173	require.NoError(t, err)
174	assert.Empty(t, secrets)
175	vars, err := st.ListVariables(ctx, domain.RepoScope(repo))
176	require.NoError(t, err)
177	assert.Empty(t, vars)
178	_, err = st.ListSealedSecrets(ctx, domain.EnvironmentScope(repo, "prod"))
179	assert.ErrorIs(t, err, domain.ErrNotFound)
180
181	// Other scopes stay.
182	secrets, err = st.ListSealedSecrets(ctx, domain.EnvironmentScope(keep, "prod"))
183	require.NoError(t, err)
184	assert.Len(t, secrets, 1)
185	secrets, err = st.ListSealedSecrets(ctx, inst)
186	require.NoError(t, err)
187	assert.Len(t, secrets, 1)
188}
189
190func TestBindSecretsKeyRefusesAnotherKeyAcrossARestart(t *testing.T) {
191	ctx := context.Background()
192	dir := t.TempDir()
193	s, err := kvstore.Open(ctx, dir)
194	require.NoError(t, err)
195	require.NoError(t, s.Settings().BindSecretsKey(ctx, []byte("check-one")))
196	require.NoError(t, s.Settings().BindSecretsKey(ctx, []byte("check-one")), "the same key again")
197	require.NoError(t, s.Close(ctx))
198
199	s, err = kvstore.Open(ctx, dir)
200	require.NoError(t, err)
201	t.Cleanup(func() { _ = s.Close(ctx) })
202	assert.ErrorIs(t, s.Settings().BindSecretsKey(ctx, []byte("check-two")), domain.ErrSecretsKeyMismatch)
203	assert.NoError(t, s.Settings().BindSecretsKey(ctx, []byte("check-one")))
204}
205
206// A value written into an environment after DeleteEnvironment took its snapshot
207// is not missed: the delete conflicts and lists again, so nothing survives to be
208// resurrected when the environment is created again.
209func TestDeleteEnvironmentSeesAConcurrentInsertion(t *testing.T) {
210	ctx := context.Background()
211	s, _, repo := ciRepo(t)
212	st := s.Settings()
213	sc := domain.EnvironmentScope(repo, "prod")
214	_, err := st.CreateEnvironment(ctx, repo, "prod", created)
215	require.NoError(t, err)
216	_, err = st.PutSecret(ctx, sc, "OLD", []byte("x"), created)
217	require.NoError(t, err)
218
219	calls := 0
220	restore := kvstore.SetAfterSettingsSnapshot(func() {
221		calls++
222		if calls == 1 { // once, in the window after the first snapshot
223			_, perr := st.PutSecret(ctx, sc, "LATE", []byte("x"), created)
224			require.NoError(t, perr)
225			_, perr = st.PutVariable(ctx, sc, "LATE_VAR", "v", created)
226			require.NoError(t, perr)
227		}
228	})
229	defer restore()
230	require.NoError(t, st.DeleteEnvironment(ctx, repo, "prod"))
231	assert.GreaterOrEqual(t, calls, 2, "the first attempt conflicted and listed again")
232	restore()
233
234	_, err = st.CreateEnvironment(ctx, repo, "prod", created)
235	require.NoError(t, err)
236	secrets, err := st.ListSealedSecrets(ctx, sc)
237	require.NoError(t, err)
238	assert.Empty(t, secrets, "nothing resurrected")
239	vars, err := st.ListVariables(ctx, sc)
240	require.NoError(t, err)
241	assert.Empty(t, vars)
242}
243
244func TestGetSealedSecretIsAPointRead(t *testing.T) {
245	ctx := context.Background()
246	s, _, repo := ciRepo(t)
247	st := s.Settings()
248	sc := domain.RepoScope(repo)
249	_, err := st.PutSecret(ctx, sc, "A", []byte("sealed-a"), created)
250	require.NoError(t, err)
251	got, err := st.GetSealedSecret(ctx, sc, "A")
252	require.NoError(t, err)
253	assert.Equal(t, "A", got.Name)
254	assert.Equal(t, []byte("sealed-a"), got.Sealed)
255	_, err = st.GetSealedSecret(ctx, sc, "B")
256	assert.ErrorIs(t, err, domain.ErrNotFound)
257	_, err = st.GetSealedSecret(ctx, domain.RepoScope(name(t, "ghost")), "A")
258	assert.ErrorIs(t, err, domain.ErrNotFound)
259	_, err = st.GetSealedSecret(ctx, domain.EnvironmentScope(repo, "nope"), "A")
260	assert.ErrorIs(t, err, domain.ErrNotFound)
261}

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 4

Tags 45

↑↓ moveenter openesc close