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().StageApply(mock.Anything, mock.Anything).Return(nil).Once()
46 store.EXPECT().CommitApply(mock.Anything, "blog", mock.Anything).Return(nil).Once()
47 store.EXPECT().MaterializeApply(mock.Anything, "blog", mock.Anything).Return(nil).Once()
48 store.EXPECT().CollectGarbage(mock.Anything, "blog", mock.Anything).Return(nil).Once()
49
50 svc := apps.NewAppServiceImpl(store, deploy, secrets, zerowrap.Default())
51 _, _, err := svc.Apply(ctx, secretSpec(), []byte("m"), false)
52 require.NoError(t, err)
53
54 // SetSecrets resolves registered names from desired state.
55 desired := domain.AppDesiredRevision{App: "blog", Revision: "rev-1", Spec: secretSpec()}
56 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once()
57 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
58 store.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-uuid-1"}, nil).Once()
59 secrets.EXPECT().SetSecret(mock.Anything, "gordon/apps/app-uuid-1/web/database-url", "v").Return(nil).Once()
60 require.NoError(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"DATABASE_URL": "v"}))
61
62 // Delete refused while still referenced (names + reference check).
63 desiredWithSecret := desired
64 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desiredWithSecret, true, nil).Once()
65 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
66 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desiredWithSecret, true, nil).Once()
67 require.ErrorIs(t, svc.DeleteSecret(ctx, "blog", "web", "DATABASE_URL"), domain.ErrAppStateConflict)
68
69 // Unknown key refused.
70 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(desired, true, nil).Once()
71 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
72 require.ErrorIs(t, svc.SetSecrets(ctx, "blog", "web", map[string]string{"NOPE": "v"}), domain.ErrAppSecretMissing)
73}
74
75func TestAppServiceImpl_OperationByKey(t *testing.T) {
76 ctx := context.Background()
77 store := newMockAppState(t)
78 deploy := newMockDeployEngine(t)
79 secrets := newMockSecretWriter(t)
80 svc := apps.NewAppServiceImpl(store, deploy, secrets, zerowrap.Default())
81
82 op := domain.AppOperation{Op: "op-1", Kind: "deploy", App: "blog", StartedAt: time.Now().UTC()}
83 store.EXPECT().LoadOperation(mock.Anything, "blog", "op-1").Return(op, nil).Once()
84 loaded, err := svc.OperationByKey(ctx, "blog", "op-1")
85 require.NoError(t, err)
86 assert.Equal(t, "op-1", loaded.Op)
87
88 store.EXPECT().LoadOperation(mock.Anything, "blog", "missing").Return(domain.AppOperation{}, domain.ErrAppOperationNotFound).Once()
89 _, err = svc.OperationByKey(ctx, "blog", "missing")
90 require.ErrorIs(t, err, domain.ErrAppOperationNotFound)
91}
92
93func TestAppServiceImpl_ListSecrets_MissingAppNotFound(t *testing.T) {
94 ctx := context.Background()
95 store := newMockAppState(t)
96 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
97
98 store.EXPECT().AppExists(mock.Anything, "blog").Return(false, nil).Once()
99 _, err := svc.ListSecrets(ctx, "blog", "")
100 require.ErrorIs(t, err, domain.ErrAppNotFound)
101}
102
103func TestAppServiceImpl_ListSecrets_OwnershipOnlyAppStaysLive(t *testing.T) {
104 ctx := context.Background()
105 store := newMockAppState(t)
106 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
107
108 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
109 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, nil).Once()
110 store.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{}, false, nil).Once()
111 entries, err := svc.ListSecrets(ctx, "blog", "")
112 require.NoError(t, err)
113 assert.Empty(t, entries)
114}
115
116func TestAppServiceImpl_ListSecrets_WrapsLoadErrors(t *testing.T) {
117 ctx := context.Background()
118 store := newMockAppState(t)
119 svc := apps.NewAppServiceImpl(store, newMockDeployEngine(t), newMockSecretWriter(t), zerowrap.Default())
120
121 store.EXPECT().AppExists(mock.Anything, "blog").Return(true, nil).Once()
122 store.EXPECT().LoadDesired(mock.Anything, "blog").Return(domain.AppDesiredRevision{}, false, errors.New("boom")).Once()
123 _, err := svc.ListSecrets(ctx, "blog", "")
124 require.ErrorContains(t, err, "load desired")
125 require.ErrorContains(t, err, "boom")
126}