v3.4.0 593c1c3

service_test.go RawHistoryFolder

1package proxy
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/zerowrap"
9	"github.com/stretchr/testify/assert"
10
11	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
12	"github.com/bnema/gordon/internal/domain"
13)
14
15func testContext() context.Context {
16	return zerowrap.WithCtx(context.Background(), zerowrap.Default())
17}
18
19// stubProvider is a fixed TargetProvider for proxy tests.
20type stubProvider struct {
21	backends map[string]domain.AppBackend
22}
23
24func (s *stubProvider) LookupHost(host string) (domain.AppBackend, bool) {
25	backend, ok := s.backends[host]
26	return backend, ok
27}
28
29func testService(t *testing.T, configSvc *inmocks.MockConfigService, provider TargetProvider) *Service {
30	if configSvc == nil {
31		configSvc = inmocks.NewMockConfigService(t)
32	}
33	svc := NewService(configSvc, Config{})
34	if provider != nil {
35		svc.WithAppTargets(provider)
36	}
37	return svc
38}
39
40func appBackend(host string, port int) domain.AppBackend {
41	return domain.AppBackend{
42		Host:          "127.0.0.1",
43		Port:          port,
44		ContainerPort: 8080,
45		ContainerID:   "c-app",
46	}
47}
48
49func TestService_GetTarget_FromCache(t *testing.T) {
50	svc := testService(t, nil, nil)
51	ctx := testContext()
52
53	// Pre-populate cache
54	cached := &domain.ProxyTarget{
55		Host:        "127.0.0.1",
56		Port:        18080,
57		ContainerID: "container-123",
58		Scheme:      "http",
59	}
60	svc.targets["app.example.com"] = cachedTarget{target: cached}
61
62	// No provider needed - should return from cache
63	result, err := svc.GetTarget(ctx, "app.example.com")
64
65	assert.NoError(t, err)
66	assert.Equal(t, cached, result)
67}
68
69func TestService_GetTarget_CanonicalizesHostForLookup(t *testing.T) {
70	configSvc := inmocks.NewMockConfigService(t)
71	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
72	svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{}})
73	ctx := testContext()
74
75	result, err := svc.GetTarget(ctx, "App.Example.com")
76	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
77	assert.Nil(t, result)
78}
79
80func TestService_GetTarget_RejectsInvalidHostAuthority(t *testing.T) {
81	svc := testService(t, nil, nil)
82	result, err := svc.GetTarget(testContext(), "app.example.com:8080")
83	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
84	assert.Nil(t, result)
85}
86
87func TestService_GetTarget_AppBackend(t *testing.T) {
88	configSvc := inmocks.NewMockConfigService(t)
89	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
90	svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{
91		"app.example.com": appBackend("127.0.0.1", 18080),
92	}})
93	ctx := testContext()
94
95	result, err := svc.GetTarget(ctx, "app.example.com")
96
97	assert.NoError(t, err)
98	assert.Equal(t, "127.0.0.1", result.Host)
99	assert.Equal(t, 18080, result.Port)
100	assert.Equal(t, "c-app", result.ContainerID)
101	assert.Equal(t, "http", result.Scheme)
102	assert.Equal(t, "app.example.com", result.RouteHost)
103}
104
105func TestService_GetTarget_AppBackendUnresolved(t *testing.T) {
106	configSvc := inmocks.NewMockConfigService(t)
107	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
108	// Zero backend: recorded but unbound (fail closed).
109	svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{
110		"app.example.com": {ContainerPort: 8080},
111	}})
112	ctx := testContext()
113
114	result, err := svc.GetTarget(ctx, "app.example.com")
115	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
116	assert.Nil(t, result)
117}
118
119func TestService_GetTarget_NoProvider(t *testing.T) {
120	configSvc := inmocks.NewMockConfigService(t)
121	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
122	svc := testService(t, configSvc, nil)
123	ctx := testContext()
124
125	result, err := svc.GetTarget(ctx, "app.example.com")
126	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
127	assert.Nil(t, result)
128}
129
130func TestService_GetTarget_NoStaleAppBackendAfterReplacement(t *testing.T) {
131	configSvc := inmocks.NewMockConfigService(t)
132	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{})
133	provider := &stubProvider{backends: map[string]domain.AppBackend{
134		"app.example.com": appBackend("127.0.0.1", 32768),
135	}}
136	svc := testService(t, configSvc, provider)
137	ctx := testContext()
138
139	// First resolution observes the v1 bind.
140	result, err := svc.GetTarget(ctx, "app.example.com")
141	assert.NoError(t, err)
142	assert.Equal(t, 32768, result.Port)
143
144	// Deploy replacement: the index now records the v2 bind. No
145	// invalidation call happens; the next lookup must observe v2.
146	provider.backends["app.example.com"] = appBackend("127.0.0.1", 32769)
147	result, err = svc.GetTarget(ctx, "app.example.com")
148	assert.NoError(t, err)
149	assert.Equal(t, 32769, result.Port)
150
151	// App resolutions never populate the target cache.
152	svc.mu.RLock()
153	_, exists := svc.targets["app.example.com"]
154	svc.mu.RUnlock()
155	assert.False(t, exists)
156
157	// Withdrawal fails closed: no stale-target fallback.
158	delete(provider.backends, "app.example.com")
159	result, err = svc.GetTarget(ctx, "app.example.com")
160	assert.ErrorIs(t, err, domain.ErrNoTargetAvailable)
161	assert.Nil(t, result)
162}
163
164func TestService_GetTarget_ExternalRoute(t *testing.T) {
165	configSvc := inmocks.NewMockConfigService(t)
166	svc := testService(t, configSvc, nil)
167	ctx := testContext()
168
169	// Mock external routes - use public IP to pass SSRF check
170	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
171		"reg.example.com": "203.0.113.10:5000", // TEST-NET-3 (RFC 5737) - documentation range
172	})
173
174	result, err := svc.GetTarget(ctx, "reg.example.com")
175
176	assert.NoError(t, err)
177	assert.Equal(t, "203.0.113.10", result.Host)
178	assert.Equal(t, 5000, result.Port)
179	assert.Equal(t, "", result.ContainerID)
180	assert.Equal(t, "http", result.Scheme)
181}
182
183func TestService_GetTarget_ExternalRoute_Cached(t *testing.T) {
184	configSvc := inmocks.NewMockConfigService(t)
185	svc := testService(t, configSvc, nil)
186	ctx := testContext()
187
188	// First call - should resolve external route (use public IP)
189	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
190		"reg.example.com": "203.0.113.10:5000",
191	}).Once()
192
193	result1, err := svc.GetTarget(ctx, "reg.example.com")
194	assert.NoError(t, err)
195	assert.Equal(t, "203.0.113.10", result1.Host)
196
197	// Second call - should return from cache (no mock call needed)
198	result2, err := svc.GetTarget(ctx, "reg.example.com")
199	assert.NoError(t, err)
200	assert.Equal(t, result1, result2)
201}
202
203func TestService_GetTarget_ExternalRoute_SSRFBlocked(t *testing.T) {
204	configSvc := inmocks.NewMockConfigService(t)
205	svc := testService(t, configSvc, nil)
206	ctx := testContext()
207
208	tests := []struct {
209		name   string
210		target string
211	}{
212		{"localhost", "localhost:5000"},
213		{"loopback IP", "127.0.0.1:5000"},
214		{"private network 10.x", "10.0.0.1:5000"},
215		{"private network 172.x", "172.16.0.1:5000"},
216		{"private network 192.168.x", "192.168.1.1:5000"},
217		{"AWS metadata", "169.254.169.254:80"},
218	}
219
220	for _, tt := range tests {
221		t.Run(tt.name, func(t *testing.T) {
222			// Clear cache
223			svc.targets = make(map[string]cachedTarget)
224
225			configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
226				"ssrf.example.com": tt.target,
227			}).Once()
228
229			result, err := svc.GetTarget(ctx, "ssrf.example.com")
230
231			assert.Error(t, err)
232			assert.ErrorIs(t, err, domain.ErrSSRFBlocked)
233			assert.Nil(t, result)
234		})
235	}
236}
237
238func TestService_GetTarget_ExternalRoute_InvalidTarget(t *testing.T) {
239	configSvc := inmocks.NewMockConfigService(t)
240	svc := testService(t, configSvc, nil)
241	ctx := testContext()
242
243	// Mock external routes with invalid format (missing port)
244	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
245		"invalid.example.com": "not-valid-format",
246	})
247
248	result, err := svc.GetTarget(ctx, "invalid.example.com")
249
250	assert.Error(t, err)
251	assert.Nil(t, result)
252}
253
254func TestService_GetTarget_ExternalRoute_InvalidPort(t *testing.T) {
255	tests := []struct {
256		name   string
257		target string
258	}{
259		{name: "not a number", target: "localhost:abc"},
260		{name: "zero", target: "localhost:0"},
261		{name: "too high", target: "localhost:65536"},
262	}
263
264	for _, tt := range tests {
265		t.Run(tt.name, func(t *testing.T) {
266			configSvc := inmocks.NewMockConfigService(t)
267			svc := testService(t, configSvc, nil)
268			ctx := testContext()
269
270			configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{
271				"invalid.example.com": tt.target,
272			})
273
274			result, err := svc.GetTarget(ctx, "invalid.example.com")
275
276			assert.Error(t, err)
277			assert.Nil(t, result)
278		})
279	}
280}
281
282func TestService_RegisterTarget(t *testing.T) {
283	svc := testService(t, nil, nil)
284	ctx := testContext()
285
286	target := &domain.ProxyTarget{
287		Host:        "127.0.0.1",
288		Port:        18080,
289		ContainerID: "container-123",
290		Scheme:      "http",
291	}
292
293	err := svc.RegisterTarget(ctx, "App.Example.com", target)
294
295	assert.NoError(t, err)
296
297	// Verify target is cached under the canonical domain.
298	svc.mu.RLock()
299	cached := svc.targets["app.example.com"]
300	svc.mu.RUnlock()
301
302	assert.Equal(t, target, cached.target)
303}
304
305func TestService_UnregisterTarget(t *testing.T) {
306	svc := testService(t, nil, nil)
307	ctx := testContext()
308
309	// Pre-populate
310	svc.targets["app.example.com"] = cachedTarget{target: &domain.ProxyTarget{
311		Host: "127.0.0.1",
312		Port: 18080,
313	}}
314
315	err := svc.UnregisterTarget(ctx, "App.Example.com")
316
317	assert.NoError(t, err)
318
319	// Verify target is removed
320	svc.mu.RLock()
321	_, exists := svc.targets["app.example.com"]
322	svc.mu.RUnlock()
323
324	assert.False(t, exists)
325}
326
327func TestService_RefreshTargets(t *testing.T) {
328	svc := testService(t, nil, nil)
329	ctx := testContext()
330
331	// Pre-populate with some targets
332	svc.targets["app1.example.com"] = cachedTarget{target: &domain.ProxyTarget{Host: "127.0.0.1"}}
333	svc.targets["app2.example.com"] = cachedTarget{target: &domain.ProxyTarget{Host: "127.0.0.1"}}
334
335	err := svc.RefreshTargets(ctx)
336
337	assert.NoError(t, err)
338
339	// Verify all targets are cleared
340	svc.mu.RLock()
341	count := len(svc.targets)
342	svc.mu.RUnlock()
343
344	assert.Equal(t, 0, count)
345}
346
347func TestService_UpdateConfig(t *testing.T) {
348	svc := testService(t, nil, nil)
349	svc.UpdateConfig(Config{
350		RegistryDomain: "old.registry.com",
351		RegistryPort:   5000,
352	})
353
354	newConfig := Config{
355		RegistryDomain: "new.registry.com",
356		RegistryPort:   5001,
357	}
358
359	svc.UpdateConfig(newConfig)
360
361	assert.Equal(t, "new.registry.com", svc.config.RegistryDomain)
362	assert.Equal(t, 5001, svc.config.RegistryPort)
363}
364
365func TestService_InvalidateTarget(t *testing.T) {
366	svc := testService(t, nil, nil)
367	ctx := testContext()
368
369	// Pre-populate cache with target
370	svc.targets["app.example.com"] = cachedTarget{target: &domain.ProxyTarget{
371		Host:        "127.0.0.1",
372		Port:        18080,
373		ContainerID: "old-container",
374	}}
375
376	// Invalidate the target using mixed-case input.
377	svc.InvalidateTarget(ctx, "App.Example.com")
378
379	// Verify target is removed from cache
380	svc.mu.RLock()
381	_, exists := svc.targets["app.example.com"]
382	svc.mu.RUnlock()
383
384	assert.False(t, exists, "target should be removed from cache after invalidation")
385}
386
387func TestService_IsKnownHost_AppBackend(t *testing.T) {
388	configSvc := inmocks.NewMockConfigService(t)
389	configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
390	svc := testService(t, configSvc, &stubProvider{backends: map[string]domain.AppBackend{
391		"app.example.com": appBackend("127.0.0.1", 18080),
392	}})
393	ctx := testContext()
394
395	assert.True(t, svc.IsKnownHost(ctx, "app.example.com"))
396	assert.False(t, svc.IsKnownHost(ctx, "unknown.example.com"))
397}
398
399func TestRegistryInFlightTracking(t *testing.T) {
400	svc := &Service{
401		inFlight: make(map[string]int),
402	}
403
404	if got := svc.registryInFlight.Load(); got != 0 {
405		t.Fatalf("expected 0 in-flight, got %d", got)
406	}
407
408	svc.registryInFlight.Add(1)
409	if got := svc.registryInFlight.Load(); got != 1 {
410		t.Fatalf("expected 1 in-flight after Add, got %d", got)
411	}
412
413	svc.registryInFlight.Add(-1)
414	if got := svc.registryInFlight.Load(); got != 0 {
415		t.Fatalf("expected 0 in-flight after release, got %d", got)
416	}
417}
418
419func TestDrainRegistryInFlight(t *testing.T) {
420	svc := &Service{
421		inFlight: make(map[string]int),
422	}
423
424	svc.registryInFlight.Add(2)
425
426	result := make(chan bool, 1)
427	go func() {
428		result <- svc.DrainRegistryInFlight(50 * time.Millisecond)
429	}()
430
431	time.Sleep(5 * time.Millisecond)
432	svc.registryInFlight.Add(-1)
433	svc.registryInFlight.Add(-1)
434
435	select {
436	case drained := <-result:
437		if !drained {
438			t.Fatalf("DrainRegistryInFlight returned false; expected true after all requests completed")
439		}
440	case <-time.After(500 * time.Millisecond):
441		t.Fatal("DrainRegistryInFlight did not return within timeout")
442	}
443}
444
445func TestDrainRegistryInFlightTimeout(t *testing.T) {
446	svc := &Service{
447		inFlight: make(map[string]int),
448	}
449
450	// Add a request and never release it
451	svc.registryInFlight.Add(1)
452
453	drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
454	if drained {
455		t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
456	}
457}
458
459func TestService_ProxyConfig_ReflectsUpdates(t *testing.T) {
460	svc := testService(t, nil, nil)
461	svc.UpdateConfig(Config{
462		RegistryDomain:     "old.registry.com",
463		RegistryPort:       5000,
464		MaxBodySize:        1024,
465		MaxResponseSize:    2048,
466		MaxConcurrentConns: 10,
467	})
468
469	cfg := svc.ProxyConfig()
470	assert.Equal(t, "old.registry.com", cfg.RegistryDomain)
471	assert.Equal(t, 5000, cfg.RegistryPort)
472	assert.Equal(t, int64(1024), cfg.MaxBodySize)
473	assert.Equal(t, int64(2048), cfg.MaxResponseSize)
474	assert.Equal(t, 10, cfg.MaxConcurrentConns)
475
476	svc.UpdateConfig(Config{
477		RegistryDomain:     "new.registry.com",
478		RegistryPort:       5001,
479		MaxBodySize:        4096,
480		MaxResponseSize:    8192,
481		MaxConcurrentConns: 50,
482	})
483
484	cfg = svc.ProxyConfig()
485	assert.Equal(t, "new.registry.com", cfg.RegistryDomain)
486	assert.Equal(t, 5001, cfg.RegistryPort)
487	assert.Equal(t, int64(4096), cfg.MaxBodySize)
488	assert.Equal(t, int64(8192), cfg.MaxResponseSize)
489	assert.Equal(t, 50, cfg.MaxConcurrentConns)
490}
491
492func TestService_IsRegistryDomain(t *testing.T) {
493	svc := testService(t, nil, nil)
494	svc.UpdateConfig(Config{
495		RegistryDomain: "registry.example.com",
496	})
497
498	assert.True(t, svc.IsRegistryDomain("registry.example.com"))
499	assert.False(t, svc.IsRegistryDomain("other.example.com"))
500	assert.False(t, svc.IsRegistryDomain(""))
501}
502
503func TestService_IsRegistryDomain_EmptyConfig(t *testing.T) {
504	svc := testService(t, nil, nil)
505	svc.UpdateConfig(Config{})
506
507	assert.False(t, svc.IsRegistryDomain("registry.example.com"))
508	assert.False(t, svc.IsRegistryDomain(""))
509}
510
511func TestService_TrackInFlight(t *testing.T) {
512	svc := &Service{
513		inFlight: make(map[string]int),
514	}
515
516	// Empty container ID returns noop
517	release := svc.TrackInFlight("")
518	release() // should not panic
519
520	// Track a container
521	release1 := svc.TrackInFlight("c-1")
522	svc.inFlightMu.Lock()
523	assert.Equal(t, 1, svc.inFlight["c-1"])
524	svc.inFlightMu.Unlock()
525
526	// Track same container again
527	release2 := svc.TrackInFlight("c-1")
528	svc.inFlightMu.Lock()
529	assert.Equal(t, 2, svc.inFlight["c-1"])
530	svc.inFlightMu.Unlock()
531
532	// Release one
533	release2()
534	svc.inFlightMu.Lock()
535	assert.Equal(t, 1, svc.inFlight["c-1"])
536	svc.inFlightMu.Unlock()
537
538	// Release last — should delete key
539	release1()
540	svc.inFlightMu.Lock()
541	_, exists := svc.inFlight["c-1"]
542	assert.False(t, exists, "container should be removed from inFlight map when count reaches 0")
543	svc.inFlightMu.Unlock()
544}
545
546func TestService_TrackRegistryRequest(t *testing.T) {
547	svc := &Service{
548		inFlight: make(map[string]int),
549	}
550
551	assert.Equal(t, int64(0), svc.RegistryInFlight())
552
553	svc.TrackRegistryRequest()
554	assert.Equal(t, int64(1), svc.RegistryInFlight())
555
556	svc.TrackRegistryRequest()
557	assert.Equal(t, int64(2), svc.RegistryInFlight())
558
559	svc.ReleaseRegistryRequest()
560	assert.Equal(t, int64(1), svc.RegistryInFlight())
561
562	svc.ReleaseRegistryRequest()
563	assert.Equal(t, int64(0), svc.RegistryInFlight())
564}

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