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}