main bac21c6

app_service_test.go RawHistoryFolder

1package apps_test
2
3import (
4	"context"
5	"errors"
6	"testing"
7	"time"
8
9	"github.com/bnema/zerowrap"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/gordon/internal/usecase/apps"
16)
17
18func secretSpec() domain.AppSpec {
19	return domain.AppSpec{
20		Name: "blog",
21		Services: []domain.AppService{
22			{
23				Name:      "web",
24				Image:     "img:1",
25				StopGrace: 10 * time.Second,
26				Readiness: domain.AppReadiness{Type: "none", Timeout: 30 * time.Second},
27				HTTP:      []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
28				Secrets:   map[string]string{"DATABASE_URL": "database-url"},
29			},
30		},
31	}
32}
33
34func TestAppServiceImpl_SecretsRoundTrip(t *testing.T) {
35	ctx := context.Background()
36	store := newMockAppState(t)
37	deploy := newMockDeployEngine(t)
38	secrets := newMockSecretWriter(t)
39
40	// Apply persists desired state through the store mock.
41	store.EXPECT().Recover(mock.Anything).Return(nil).Once()
42	store.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
43	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
44	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
45	store.EXPECT().AcceptApply(mock.Anything, mock.Anything).Return(nil).Once()
46
47	svc := apps.NewAppServiceImpl(store, deploy, secrets, zerowrap.Default())
48	_, _, err := svc.Apply(ctx, secretSpec(), []byte("m"), false)
49	require.NoError(t, err)
50
51	// SetSecrets resolves registered names from desired state.
52	desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()}
53	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once()
54	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
55	store.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-uuid-1"}, nil).Once()
56	secrets.EXPECT().SetSecret(mock.Anything, "gordon/apps/app-uuid-1/web/database-url", "v").Return(nil).Once()
57	require.NoError(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"DATABASE_URL": "v"}))
58
59	// Delete refused while still referenced (names + reference check).
60	desiredWithSecret := desired
61	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desiredWithSecret, true, nil).Once()
62	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
63	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desiredWithSecret, true, nil).Once()
64	require.ErrorIs(t, svc.DeleteSecret(ctx, "blog", "web", "DATABASE_URL"), domain.ErrAppStateConflict)
65
66	// Unknown key refused.
67	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once()
68	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
69	require.ErrorIs(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}), domain.ErrAppSecretMissing)
70}
71
72func TestAppServiceImpl_SetSecrets_UnappliedServiceHint(t *testing.T) {
73	ctx := context.Background()
74	store := newMockAppState(t)
75	svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
76
77	// App never applied: neither desired nor active exists.
78	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
79	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
80	err := svc.SetSecrets(ctx, "blog", "runner", map[string]string{"K": "v"})
81	require.ErrorIs(t, err, domain.ErrAppSecretMissing)
82	assert.Contains(t, err.Error(), `app "blog" has no applied manifest; run `+"`gordon apps apply`"+` first`)
83
84	// App applied, but this service is not in the applied spec.
85	desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()}
86	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once()
87	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
88	err = svc.SetSecrets(ctx, "blog", "runner", map[string]string{"K": "v"})
89	require.ErrorIs(t, err, domain.ErrAppSecretMissing)
90	assert.Contains(t, err.Error(), `service "runner" is not in the applied manifest of app "blog"; run `+"`gordon apps apply`"+` first`)
91}
92
93func TestAppServiceImpl_DeleteSecret_NoApplyHint(t *testing.T) {
94	ctx := context.Background()
95	store := newMockAppState(t)
96	svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
97
98	// Deleting never suggests applying or re-declaring the secret.
99	desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()}
100	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Twice()
101	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Twice()
102	for _, tc := range []struct{ service, key string }{{"runner", "K"}, {"web", "NOPE"}} {
103		err := svc.DeleteSecret(ctx, "blog", tc.service, tc.key)
104		require.ErrorIs(t, err, domain.ErrAppSecretMissing)
105		assert.NotContains(t, err.Error(), "apply")
106	}
107}
108
109func TestAppServiceImpl_SetSecrets_UndeclaredSecretHint(t *testing.T) {
110	ctx := context.Background()
111	store := newMockAppState(t)
112	svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
113
114	// Service applied but declares no secrets.
115	spec := secretSpec()
116	spec.Services[0].Secrets = nil
117	noSecrets := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: spec}
118	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(noSecrets, true, nil).Once()
119	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
120	err := svc.SetSecrets(ctx, "blog", "web", map[string]string{"K": "v"})
121	require.ErrorIs(t, err, domain.ErrAppSecretMissing)
122	assert.Contains(t, err.Error(), "[services.web.secrets]")
123	assert.NotContains(t, err.Error(), "not in the applied manifest")
124
125	// Service declares other secrets but not this key.
126	desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()}
127	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once()
128	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
129	err = svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"})
130	require.ErrorIs(t, err, domain.ErrAppSecretMissing)
131	assert.Contains(t, err.Error(), `secret "NOPE" is not declared`)
132	assert.Contains(t, err.Error(), "[services.web.secrets]")
133}
134
135func TestAppServiceImpl_OperationByKey(t *testing.T) {
136	ctx := context.Background()
137	store := newMockAppState(t)
138	deploy := newMockDeployEngine(t)
139	secrets := newMockSecretWriter(t)
140	svc := apps.NewAppServiceImpl(store, deploy, secrets, zerowrap.Default())
141
142	op := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC()}
143	store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil).Once()
144	loaded, err := svc.OperationByKey(ctx, "blog", "op-1")
145	require.NoError(t, err)
146	assert.Equal(t, "op-1", loaded.Op)
147
148	store.EXPECT().LoadOperation(mock.Anything, "blog", "missing").Return(domain.AppOperation{}, domain.ErrAppOperationNotFound).Once()
149	_, err = svc.OperationByKey(ctx, "blog", "missing")
150	require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
151}
152
153func TestAppServiceImpl_ListSecrets_MissingAppNotFound(t *testing.T) {
154	ctx := context.Background()
155	store := newMockAppState(t)
156	svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
157
158	store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
159	_, err := svc.ListSecrets(ctx, "blog", "")
160	require.ErrorIs(t, err, domain.ErrAppNotFound)
161}
162
163func TestAppServiceImpl_ListSecrets_OwnershipOnlyAppStaysLive(t *testing.T) {
164	ctx := context.Background()
165	store := newMockAppState(t)
166	svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
167
168	store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
169	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
170	store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
171	entries, err := svc.ListSecrets(ctx, "blog", "")
172	require.NoError(t, err)
173	assert.Empty(t, entries)
174}
175
176func TestAppServiceImpl_ListSecrets_WrapsLoadErrors(t *testing.T) {
177	ctx := context.Background()
178	store := newMockAppState(t)
179	svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
180
181	store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
182	store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, errors.New("boom")).Once()
183	_, err := svc.ListSecrets(ctx, "blog", "")
184	require.ErrorContains(t, err, "load desired")
185	require.ErrorContains(t, err, "boom")
186}

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 7

Tags 106

↑↓ moveenter openesc close