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}