v2.31.0-dev16 8d0a092

secrets_handler_test.go RawHistoryFolder

1package container
2
3import (
4	"context"
5	"sync/atomic"
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
14	"github.com/bnema/gordon/internal/domain"
15)
16
17func TestSecretsChangedHandler_CanHandle(t *testing.T) {
18	containerSvc := inmocks.NewMockContainerService(t)
19	configSvc := inmocks.NewMockConfigService(t)
20
21	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 200*time.Millisecond)
22	defer h.Stop()
23
24	assert.True(t, h.CanHandle(domain.EventSecretsChanged))
25	assert.False(t, h.CanHandle(domain.EventConfigReload))
26}
27
28func TestSecretsChangedHandler_DebouncesBurstEvents(t *testing.T) {
29	containerSvc := inmocks.NewMockContainerService(t)
30	configSvc := inmocks.NewMockConfigService(t)
31
32	debounceDelay := 200 * time.Millisecond
33	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
34	defer h.Stop()
35
36	route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
37	configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
38
39	var deployCalls atomic.Int32
40	containerSvc.EXPECT().Deploy(mock.Anything, *route).RunAndReturn(func(_ context.Context, deployedRoute domain.Route) (*domain.Container, error) {
41		deployCalls.Add(1)
42		return &domain.Container{ID: "container-1"}, nil
43	}).Once()
44
45	event := domain.Event{
46		Type: domain.EventSecretsChanged,
47		Data: domain.SecretsChangedPayload{
48			Domain:    route.Domain,
49			Operation: "set",
50			Keys:      []string{"A", "B"},
51		},
52	}
53
54	for range 5 {
55		assert.NoError(t, h.Handle(context.Background(), event))
56		time.Sleep(10 * time.Millisecond)
57	}
58
59	require.Eventually(t, func() bool {
60		return deployCalls.Load() == 1
61	}, debounceDelay+time.Second, 10*time.Millisecond, "expected exactly 1 deploy call after debounce")
62}
63
64func TestSecretsChangedHandler_IndependentDomains(t *testing.T) {
65	containerSvc := inmocks.NewMockContainerService(t)
66	configSvc := inmocks.NewMockConfigService(t)
67
68	debounceDelay := 100 * time.Millisecond
69	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
70	defer h.Stop()
71
72	routeOne := &domain.Route{Domain: "app1.example.com", Image: "app1:latest"}
73	routeTwo := &domain.Route{Domain: "app2.example.com", Image: "app2:latest"}
74
75	configSvc.EXPECT().GetRoute(mock.Anything, routeOne.Domain).Return(routeOne, nil).Once()
76	configSvc.EXPECT().GetRoute(mock.Anything, routeTwo.Domain).Return(routeTwo, nil).Once()
77
78	var deployCalls atomic.Int32
79	containerSvc.EXPECT().Deploy(mock.Anything, *routeOne).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
80		deployCalls.Add(1)
81		return &domain.Container{ID: "container-1"}, nil
82	}).Once()
83	containerSvc.EXPECT().Deploy(mock.Anything, *routeTwo).RunAndReturn(func(_ context.Context, _ domain.Route) (*domain.Container, error) {
84		deployCalls.Add(1)
85		return &domain.Container{ID: "container-2"}, nil
86	}).Once()
87
88	assert.NoError(t, h.Handle(context.Background(), domain.Event{
89		Type: domain.EventSecretsChanged,
90		Data: domain.SecretsChangedPayload{Domain: routeOne.Domain, Operation: "set", Keys: []string{"A"}},
91	}))
92	assert.NoError(t, h.Handle(context.Background(), domain.Event{
93		Type: domain.EventSecretsChanged,
94		Data: domain.SecretsChangedPayload{Domain: routeTwo.Domain, Operation: "delete", Keys: []string{"B"}},
95	}))
96
97	require.Eventually(t, func() bool {
98		return deployCalls.Load() == 2
99	}, debounceDelay+time.Second, 10*time.Millisecond, "expected 2 independent deploy calls")
100}
101
102func TestSecretsChangedHandler_NoRouteFound_NoOp(t *testing.T) {
103	containerSvc := inmocks.NewMockContainerService(t)
104	configSvc := inmocks.NewMockConfigService(t)
105
106	debounceDelay := 100 * time.Millisecond
107	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
108	defer h.Stop()
109
110	configSvc.EXPECT().GetRoute(mock.Anything, "missing.example.com").Return(nil, nil).Once()
111
112	assert.NoError(t, h.Handle(context.Background(), domain.Event{
113		Type: domain.EventSecretsChanged,
114		Data: domain.SecretsChangedPayload{Domain: "missing.example.com", Operation: "set", Keys: []string{"A"}},
115	}))
116
117	time.Sleep(debounceDelay + 100*time.Millisecond)
118}
119
120func TestSecretsChangedHandler_InvalidPayload_NoOp(t *testing.T) {
121	containerSvc := inmocks.NewMockContainerService(t)
122	configSvc := inmocks.NewMockConfigService(t)
123
124	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, 100*time.Millisecond)
125	defer h.Stop()
126
127	err := h.Handle(context.Background(), domain.Event{
128		Type: domain.EventSecretsChanged,
129		Data: "wrong-payload",
130	})
131
132	assert.NoError(t, err)
133}
134
135func TestSecretsChangedHandler_FireDeploySkipsStaleGeneration(t *testing.T) {
136	containerSvc := inmocks.NewMockContainerService(t)
137	configSvc := inmocks.NewMockConfigService(t)
138
139	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
140	defer h.Stop()
141
142	const domainName = "app.example.com"
143
144	// White-box: directly set internal state to simulate a stale generation.
145	// This exercises the generation-based debounce logic that prevents
146	// already-fired timers from triggering duplicate deploys.
147	h.mu.Lock()
148	h.generations[domainName] = 2
149	h.timers[domainName] = time.NewTimer(time.Hour)
150	h.mu.Unlock()
151
152	staleDone := make(chan struct{})
153	go func() {
154		defer close(staleDone)
155		h.fireDeploy(context.Background(), domainName, 1)
156	}()
157
158	select {
159	case <-staleDone:
160	case <-time.After(2 * time.Second):
161		t.Fatal("stale fireDeploy did not return")
162	}
163
164	h.mu.Lock()
165	_, timerExists := h.timers[domainName]
166	gen := h.generations[domainName]
167	h.mu.Unlock()
168
169	assert.True(t, timerExists)
170	assert.Equal(t, uint64(2), gen)
171}
172
173func TestSecretsChangedHandler_FireDeployClearsCurrentGeneration(t *testing.T) {
174	containerSvc := inmocks.NewMockContainerService(t)
175	configSvc := inmocks.NewMockConfigService(t)
176
177	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, time.Second)
178	defer h.Stop()
179
180	route := &domain.Route{Domain: "app.example.com", Image: "app:latest"}
181	configSvc.EXPECT().GetRoute(mock.Anything, route.Domain).Return(route, nil).Once()
182	containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(&domain.Container{ID: "container-1"}, nil).Once()
183
184	h.mu.Lock()
185	h.generations[route.Domain] = 1
186	h.timers[route.Domain] = time.NewTimer(time.Hour)
187	h.mu.Unlock()
188
189	h.fireDeploy(context.Background(), route.Domain, 1)
190
191	h.mu.Lock()
192	_, timerExists := h.timers[route.Domain]
193	_, generationExists := h.generations[route.Domain]
194	h.mu.Unlock()
195
196	assert.False(t, timerExists)
197	assert.False(t, generationExists)
198}
199
200func TestSecretsChangedHandler_StopCancelsPendingDeploy(t *testing.T) {
201	containerSvc := inmocks.NewMockContainerService(t)
202	configSvc := inmocks.NewMockConfigService(t)
203
204	debounceDelay := 50 * time.Millisecond
205	h := NewSecretsChangedHandler(testCtx(), containerSvc, configSvc, debounceDelay)
206
207	assert.NoError(t, h.Handle(context.Background(), domain.Event{
208		Type: domain.EventSecretsChanged,
209		Data: domain.SecretsChangedPayload{Domain: "app.example.com", Operation: "set", Keys: []string{"A"}},
210	}))
211
212	h.Stop()
213	time.Sleep(debounceDelay + 50*time.Millisecond)
214}

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