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}