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_OperationByKey(t *testing.T) {
73 ctx := context.Background()
74 store := newMockAppState(t)
75 deploy := newMockDeployEngine(t)
76 secrets := newMockSecretWriter(t)
77 svc := apps.NewAppServiceImpl(store, deploy, secrets, zerowrap.Default())
78
79 op := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC()}
80 store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil).Once()
81 loaded, err := svc.OperationByKey(ctx, "blog", "op-1")
82 require.NoError(t, err)
83 assert.Equal(t, "op-1", loaded.Op)
84
85 store.EXPECT().LoadOperation(mock.Anything, "blog", "missing").Return(domain.AppOperation{}, domain.ErrAppOperationNotFound).Once()
86 _, err = svc.OperationByKey(ctx, "blog", "missing")
87 require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
88}
89
90func TestAppServiceImpl_ListSecrets_MissingAppNotFound(t *testing.T) {
91 ctx := context.Background()
92 store := newMockAppState(t)
93 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
94
95 store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
96 _, err := svc.ListSecrets(ctx, "blog", "")
97 require.ErrorIs(t, err, domain.ErrAppNotFound)
98}
99
100func TestAppServiceImpl_ListSecrets_OwnershipOnlyAppStaysLive(t *testing.T) {
101 ctx := context.Background()
102 store := newMockAppState(t)
103 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
104
105 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
106 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
107 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
108 entries, err := svc.ListSecrets(ctx, "blog", "")
109 require.NoError(t, err)
110 assert.Empty(t, entries)
111}
112
113func TestAppServiceImpl_ListSecrets_WrapsLoadErrors(t *testing.T) {
114 ctx := context.Background()
115 store := newMockAppState(t)
116 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
117
118 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
119 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, errors.New("boom")).Once()
120 _, err := svc.ListSecrets(ctx, "blog", "")
121 require.ErrorContains(t, err, "load desired")
122 require.ErrorContains(t, err, "boom")
123}