v2.31.0-dev2 4b72742

service_test.go RawHistoryFolder

1package secrets
2
3import (
4	"context"
5	"strings"
6	"testing"
7
8	"github.com/bnema/zerowrap"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11
12	"github.com/bnema/gordon/internal/boundaries/out"
13	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14	"github.com/bnema/gordon/internal/domain"
15)
16
17func testLogger() zerowrap.Logger {
18	return zerowrap.Default()
19}
20
21func TestValidateDomain(t *testing.T) {
22	tests := []struct {
23		name    string
24		domain  string
25		wantErr error
26	}{
27		// Valid domains
28		{
29			name:    "valid simple domain",
30			domain:  "example.com",
31			wantErr: nil,
32		},
33		{
34			name:    "valid subdomain",
35			domain:  "app.example.com",
36			wantErr: nil,
37		},
38		{
39			name:    "valid domain with port",
40			domain:  "app.example.com:8080",
41			wantErr: nil,
42		},
43		{
44			name:    "valid domain with hyphens",
45			domain:  "my-app.example-site.com",
46			wantErr: nil,
47		},
48		{
49			name:    "valid single label",
50			domain:  "localhost",
51			wantErr: nil,
52		},
53		// Empty domain
54		{
55			name:    "empty domain",
56			domain:  "",
57			wantErr: ErrDomainEmpty,
58		},
59		// Path traversal attempts
60		{
61			name:    "path traversal with double dots",
62			domain:  "../etc/passwd",
63			wantErr: ErrDomainPathTraversal,
64		},
65		{
66			name:    "path traversal in middle",
67			domain:  "app/../etc/passwd",
68			wantErr: ErrDomainPathTraversal,
69		},
70		{
71			name:    "path traversal at end",
72			domain:  "app.example.com/..",
73			wantErr: ErrDomainPathTraversal,
74		},
75		{
76			name:    "multiple path traversal",
77			domain:  "..../....//etc/passwd",
78			wantErr: ErrDomainPathTraversal,
79		},
80		{
81			name:    "encoded path traversal still blocked",
82			domain:  "app..example.com",
83			wantErr: ErrDomainPathTraversal,
84		},
85		// Domain too long
86		{
87			name:    "domain at max length (253)",
88			domain:  strings.Repeat("a", 253),
89			wantErr: nil,
90		},
91		{
92			name:    "domain exceeds max length",
93			domain:  strings.Repeat("a", 254),
94			wantErr: ErrDomainTooLong,
95		},
96		{
97			name:    "domain way too long",
98			domain:  strings.Repeat("a", 1000),
99			wantErr: ErrDomainTooLong,
100		},
101		// Invalid characters
102		{
103			name:    "null byte in domain",
104			domain:  "app\x00.example.com",
105			wantErr: ErrDomainInvalidChars,
106		},
107		{
108			name:    "null byte at start",
109			domain:  "\x00example.com",
110			wantErr: ErrDomainInvalidChars,
111		},
112		{
113			name:    "underscore rejected to avoid env filename collision",
114			domain:  "app_example.com",
115			wantErr: ErrDomainInvalidChars,
116		},
117	}
118
119	for _, tt := range tests {
120		t.Run(tt.name, func(t *testing.T) {
121			err := ValidateDomain(tt.domain)
122			if tt.wantErr != nil {
123				assert.ErrorIs(t, err, tt.wantErr)
124			} else {
125				assert.NoError(t, err)
126			}
127		})
128	}
129}
130
131func TestValidateDomain_DistinguishesSeparatorsForEnvStorage(t *testing.T) {
132	for _, domain := range []string{"app.example.com", "app:example:com", "app/example/com"} {
133		assert.NoError(t, ValidateDomain(domain), "expected %q to remain valid", domain)
134	}
135}
136
137func TestService_ListKeys_ValidationErrors(t *testing.T) {
138	store := outmocks.NewMockDomainSecretStore(t)
139	svc := NewService(store, testLogger(), nil)
140
141	tests := []struct {
142		name    string
143		domain  string
144		wantErr error
145	}{
146		{
147			name:    "empty domain",
148			domain:  "",
149			wantErr: ErrDomainEmpty,
150		},
151		{
152			name:    "path traversal",
153			domain:  "../etc/passwd",
154			wantErr: ErrDomainPathTraversal,
155		},
156		{
157			name:    "domain too long",
158			domain:  strings.Repeat("a", 300),
159			wantErr: ErrDomainTooLong,
160		},
161	}
162
163	for _, tt := range tests {
164		t.Run(tt.name, func(t *testing.T) {
165			keys, err := svc.ListKeys(context.Background(), tt.domain)
166			assert.Nil(t, keys)
167			assert.ErrorIs(t, err, tt.wantErr)
168		})
169	}
170}
171
172func TestService_ListKeys_Success(t *testing.T) {
173	store := outmocks.NewMockDomainSecretStore(t)
174	svc := NewService(store, testLogger(), nil)
175
176	expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
177	store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
178
179	keys, err := svc.ListKeys(context.Background(), "app.example.com")
180	assert.NoError(t, err)
181	assert.Equal(t, expectedKeys, keys)
182}
183
184func TestService_ListKeysWithAttachments_ValidationErrors(t *testing.T) {
185	store := outmocks.NewMockDomainSecretStore(t)
186	svc := NewService(store, testLogger(), nil)
187
188	tests := []struct {
189		name    string
190		domain  string
191		wantErr error
192	}{
193		{
194			name:    "empty domain",
195			domain:  "",
196			wantErr: ErrDomainEmpty,
197		},
198		{
199			name:    "path traversal",
200			domain:  "../etc/passwd",
201			wantErr: ErrDomainPathTraversal,
202		},
203		{
204			name:    "domain too long",
205			domain:  strings.Repeat("a", 300),
206			wantErr: ErrDomainTooLong,
207		},
208	}
209
210	for _, tt := range tests {
211		t.Run(tt.name, func(t *testing.T) {
212			keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), tt.domain)
213			assert.Nil(t, keys)
214			assert.Nil(t, attachments)
215			assert.ErrorIs(t, err, tt.wantErr)
216		})
217	}
218}
219
220func TestService_ListKeysWithAttachments_Success(t *testing.T) {
221	t.Run("returns both domain and attachment secrets", func(t *testing.T) {
222		store := outmocks.NewMockDomainSecretStore(t)
223		svc := NewService(store, testLogger(), nil)
224
225		expectedKeys := []string{"API_KEY", "DB_PASSWORD"}
226		expectedAttachments := []out.AttachmentSecrets{
227			{Service: "postgres", Keys: []string{"POSTGRES_PASSWORD"}},
228			{Service: "redis", Keys: []string{"REDIS_PASSWORD", "REDIS_USER"}},
229		}
230
231		store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
232		store.EXPECT().ListAttachmentKeys("app.example.com").Return(expectedAttachments, nil)
233
234		keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
235		assert.NoError(t, err)
236		assert.Equal(t, expectedKeys, keys)
237		assert.Equal(t, expectedAttachments, attachments)
238	})
239
240	t.Run("returns empty attachments when none exist", func(t *testing.T) {
241		store := outmocks.NewMockDomainSecretStore(t)
242		svc := NewService(store, testLogger(), nil)
243
244		expectedKeys := []string{"API_KEY"}
245
246		store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
247		store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, nil)
248
249		keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
250		assert.NoError(t, err)
251		assert.Equal(t, expectedKeys, keys)
252		assert.Nil(t, attachments)
253	})
254
255	t.Run("continues without attachments on attachment error", func(t *testing.T) {
256		store := outmocks.NewMockDomainSecretStore(t)
257		svc := NewService(store, testLogger(), nil)
258
259		expectedKeys := []string{"API_KEY"}
260		attachmentErr := assert.AnError
261
262		store.EXPECT().ListKeys("app.example.com").Return(expectedKeys, nil)
263		store.EXPECT().ListAttachmentKeys("app.example.com").Return(nil, attachmentErr)
264
265		// Should still succeed, just without attachments
266		keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
267		assert.NoError(t, err)
268		assert.Equal(t, expectedKeys, keys)
269		assert.Nil(t, attachments)
270	})
271}
272
273func TestService_ListKeysWithAttachments_StoreError(t *testing.T) {
274	store := outmocks.NewMockDomainSecretStore(t)
275	svc := NewService(store, testLogger(), nil)
276
277	storeErr := assert.AnError
278	store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
279
280	keys, attachments, err := svc.ListKeysWithAttachments(context.Background(), "app.example.com")
281	assert.Nil(t, keys)
282	assert.Nil(t, attachments)
283	assert.ErrorIs(t, err, storeErr)
284}
285
286func TestService_GetAll_ValidationErrors(t *testing.T) {
287	store := outmocks.NewMockDomainSecretStore(t)
288	svc := NewService(store, testLogger(), nil)
289
290	tests := []struct {
291		name    string
292		domain  string
293		wantErr error
294	}{
295		{
296			name:    "empty domain",
297			domain:  "",
298			wantErr: ErrDomainEmpty,
299		},
300		{
301			name:    "path traversal",
302			domain:  "app/../secret",
303			wantErr: ErrDomainPathTraversal,
304		},
305	}
306
307	for _, tt := range tests {
308		t.Run(tt.name, func(t *testing.T) {
309			secrets, err := svc.GetAll(context.Background(), tt.domain)
310			assert.Nil(t, secrets)
311			assert.ErrorIs(t, err, tt.wantErr)
312		})
313	}
314}
315
316func TestService_GetAll_Success(t *testing.T) {
317	store := outmocks.NewMockDomainSecretStore(t)
318	svc := NewService(store, testLogger(), nil)
319
320	expectedSecrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
321	store.EXPECT().GetAll("app.example.com").Return(expectedSecrets, nil)
322
323	secrets, err := svc.GetAll(context.Background(), "app.example.com")
324	assert.NoError(t, err)
325	assert.Equal(t, expectedSecrets, secrets)
326}
327
328func TestService_Set_ValidationErrors(t *testing.T) {
329	store := outmocks.NewMockDomainSecretStore(t)
330	svc := NewService(store, testLogger(), nil)
331
332	secrets := map[string]string{"API_KEY": "secret123"}
333
334	tests := []struct {
335		name    string
336		domain  string
337		wantErr error
338	}{
339		{
340			name:    "empty domain",
341			domain:  "",
342			wantErr: ErrDomainEmpty,
343		},
344		{
345			name:    "path traversal",
346			domain:  "../../../etc/passwd",
347			wantErr: ErrDomainPathTraversal,
348		},
349	}
350
351	for _, tt := range tests {
352		t.Run(tt.name, func(t *testing.T) {
353			err := svc.Set(context.Background(), tt.domain, secrets)
354			assert.ErrorIs(t, err, tt.wantErr)
355		})
356	}
357}
358
359func TestService_Set_Success(t *testing.T) {
360	store := outmocks.NewMockDomainSecretStore(t)
361	eventBus := outmocks.NewMockEventPublisher(t)
362	svc := NewService(store, testLogger(), eventBus)
363
364	secrets := map[string]string{"API_KEY": "secret123"}
365	store.EXPECT().Set("app.example.com", secrets).Return(nil)
366	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
367
368	err := svc.Set(context.Background(), "app.example.com", secrets)
369	assert.NoError(t, err)
370}
371
372func TestService_Set_PublishesSecretsChangedEvent(t *testing.T) {
373	store := outmocks.NewMockDomainSecretStore(t)
374	eventBus := outmocks.NewMockEventPublisher(t)
375	svc := NewService(store, testLogger(), eventBus)
376
377	secrets := map[string]string{"API_KEY": "secret123", "DB_PASSWORD": "pass456"}
378	store.EXPECT().Set("app.example.com", secrets).Return(nil)
379	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool {
380		assert.Equal(t, "app.example.com", payload.Domain)
381		assert.Equal(t, "set", payload.Operation)
382		assert.ElementsMatch(t, []string{"API_KEY", "DB_PASSWORD"}, payload.Keys)
383		return true
384	})).Return(nil)
385
386	err := svc.Set(context.Background(), "app.example.com", secrets)
387	assert.NoError(t, err)
388}
389
390func TestService_Delete_ValidationErrors(t *testing.T) {
391	store := outmocks.NewMockDomainSecretStore(t)
392	svc := NewService(store, testLogger(), nil)
393
394	tests := []struct {
395		name    string
396		domain  string
397		wantErr error
398	}{
399		{
400			name:    "empty domain",
401			domain:  "",
402			wantErr: ErrDomainEmpty,
403		},
404		{
405			name:    "path traversal",
406			domain:  "..%2F..%2Fetc%2Fpasswd",
407			wantErr: ErrDomainPathTraversal,
408		},
409	}
410
411	for _, tt := range tests {
412		t.Run(tt.name, func(t *testing.T) {
413			err := svc.Delete(context.Background(), tt.domain, "API_KEY")
414			assert.ErrorIs(t, err, tt.wantErr)
415		})
416	}
417}
418
419func TestService_Delete_Success(t *testing.T) {
420	store := outmocks.NewMockDomainSecretStore(t)
421	eventBus := outmocks.NewMockEventPublisher(t)
422	svc := NewService(store, testLogger(), eventBus)
423
424	store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
425	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
426
427	err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
428	assert.NoError(t, err)
429}
430
431func TestService_Delete_PublishesSecretsChangedEvent(t *testing.T) {
432	store := outmocks.NewMockDomainSecretStore(t)
433	eventBus := outmocks.NewMockEventPublisher(t)
434	svc := NewService(store, testLogger(), eventBus)
435
436	store.EXPECT().Delete("app.example.com", "API_KEY").Return(nil)
437	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.MatchedBy(func(payload domain.SecretsChangedPayload) bool {
438		assert.Equal(t, "app.example.com", payload.Domain)
439		assert.Equal(t, "delete", payload.Operation)
440		assert.Equal(t, []string{"API_KEY"}, payload.Keys)
441		return true
442	})).Return(nil)
443
444	err := svc.Delete(context.Background(), "app.example.com", "API_KEY")
445	assert.NoError(t, err)
446}
447
448func TestService_Set_SucceedsEvenIfEventPublishFails(t *testing.T) {
449	store := outmocks.NewMockDomainSecretStore(t)
450	eventBus := outmocks.NewMockEventPublisher(t)
451	svc := NewService(store, testLogger(), eventBus)
452
453	secrets := map[string]string{"API_KEY": "secret123"}
454	store.EXPECT().Set("app.example.com", secrets).Return(nil)
455	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(assert.AnError)
456
457	err := svc.Set(context.Background(), "app.example.com", secrets)
458	assert.NoError(t, err)
459}
460
461func TestService_NilEventPublisher_DoesNotPanic(t *testing.T) {
462	store := outmocks.NewMockDomainSecretStore(t)
463	svc := NewService(store, testLogger(), nil)
464
465	secrets := map[string]string{"API_KEY": "secret123"}
466	store.EXPECT().Set("app.example.com", secrets).Return(nil)
467
468	assert.NotPanics(t, func() {
469		err := svc.Set(context.Background(), "app.example.com", secrets)
470		assert.NoError(t, err)
471	})
472}
473
474func TestService_StoreError_PropagatedCorrectly(t *testing.T) {
475	store := outmocks.NewMockDomainSecretStore(t)
476	svc := NewService(store, testLogger(), nil)
477
478	storeErr := assert.AnError
479	store.EXPECT().ListKeys("app.example.com").Return(nil, storeErr)
480
481	keys, err := svc.ListKeys(context.Background(), "app.example.com")
482	assert.Nil(t, keys)
483	assert.ErrorIs(t, err, storeErr)
484}
485
486// TestPathTraversalVariants tests various path traversal attack patterns.
487func TestPathTraversalVariants(t *testing.T) {
488	tests := []struct {
489		name   string
490		domain string
491	}{
492		{"simple parent dir", ".."},
493		{"parent dir prefix", "../secret"},
494		{"parent dir suffix", "secret/.."},
495		{"parent dir in middle", "app/../secret"},
496		{"multiple parent dirs", "../../etc/passwd"},
497		{"windows style", "..\\..\\etc\\passwd"},
498		{"mixed slashes with dots", "app/./../../etc"},
499		{"double dots only", "...."},
500		{"triple dots", "..."},
501		{"dot dot in subdomain style", "app..evil.com"},
502	}
503
504	for _, tt := range tests {
505		t.Run(tt.name, func(t *testing.T) {
506			err := ValidateDomain(tt.domain)
507			// All these should be rejected (either path traversal or other validation)
508			// The main goal is that ".." patterns are caught
509			if strings.Contains(tt.domain, "..") {
510				assert.ErrorIs(t, err, ErrDomainPathTraversal, "domain %q should be rejected for path traversal", tt.domain)
511			}
512		})
513	}
514}
515
516// TestService_ValidationHappensBeforeStore ensures validation runs before any store calls.
517func TestService_ValidationHappensBeforeStore(t *testing.T) {
518	store := outmocks.NewMockDomainSecretStore(t)
519	svc := NewService(store, testLogger(), nil)
520
521	// With path traversal domain, store methods should never be called
522	// We don't set up any expectations, so if store is called, the test will fail
523
524	_, err := svc.ListKeys(context.Background(), "../etc/passwd")
525	assert.ErrorIs(t, err, ErrDomainPathTraversal)
526
527	_, err = svc.GetAll(context.Background(), "../etc/passwd")
528	assert.ErrorIs(t, err, ErrDomainPathTraversal)
529
530	err = svc.Set(context.Background(), "../etc/passwd", map[string]string{"key": "value"})
531	assert.ErrorIs(t, err, ErrDomainPathTraversal)
532
533	err = svc.Delete(context.Background(), "../etc/passwd", "key")
534	assert.ErrorIs(t, err, ErrDomainPathTraversal)
535
536	// Verify no store methods were called (mockery would fail if unexpected calls happened)
537	mock.AssertExpectationsForObjects(t, store)
538}
539
540func TestService_SetAttachment(t *testing.T) {
541	store := outmocks.NewMockDomainSecretStore(t)
542	eventBus := outmocks.NewMockEventPublisher(t)
543	svc := NewService(store, testLogger(), eventBus)
544
545	secrets := map[string]string{"POSTGRES_PASSWORD": "secret123"}
546	store.EXPECT().SetAttachment("gordon-app__example__com-postgres", secrets).Return(nil)
547	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
548
549	err := svc.SetAttachment(context.Background(), "app.example.com", "postgres", secrets)
550	assert.NoError(t, err)
551}
552
553func TestService_DeleteAttachment(t *testing.T) {
554	store := outmocks.NewMockDomainSecretStore(t)
555	eventBus := outmocks.NewMockEventPublisher(t)
556	svc := NewService(store, testLogger(), eventBus)
557
558	store.EXPECT().DeleteAttachment("gordon-app__example__com-postgres", "OLD_KEY").Return(nil)
559	eventBus.EXPECT().Publish(domain.EventSecretsChanged, mock.AnythingOfType("domain.SecretsChangedPayload")).Return(nil)
560
561	err := svc.DeleteAttachment(context.Background(), "app.example.com", "postgres", "OLD_KEY")
562	assert.NoError(t, err)
563}
564
565func TestService_SetAttachment_InvalidDomain(t *testing.T) {
566	store := outmocks.NewMockDomainSecretStore(t)
567	svc := NewService(store, testLogger(), nil)
568
569	err := svc.SetAttachment(context.Background(), "", "postgres", map[string]string{"KEY": "val"})
570	assert.ErrorIs(t, err, ErrDomainEmpty)
571}
572
573func TestService_SetAttachment_EmptyService(t *testing.T) {
574	store := outmocks.NewMockDomainSecretStore(t)
575	svc := NewService(store, testLogger(), nil)
576
577	err := svc.SetAttachment(context.Background(), "app.example.com", "", map[string]string{"KEY": "val"})
578	assert.ErrorIs(t, err, ErrServiceEmpty)
579}
580
581func TestService_DeleteAttachment_InvalidDomain(t *testing.T) {
582	store := outmocks.NewMockDomainSecretStore(t)
583	svc := NewService(store, testLogger(), nil)
584
585	err := svc.DeleteAttachment(context.Background(), "", "postgres", "SOME_KEY")
586	assert.ErrorIs(t, err, ErrDomainEmpty)
587}
588
589func TestService_DeleteAttachment_EmptyService(t *testing.T) {
590	store := outmocks.NewMockDomainSecretStore(t)
591	svc := NewService(store, testLogger(), nil)
592
593	err := svc.DeleteAttachment(context.Background(), "app.example.com", "", "SOME_KEY")
594	assert.ErrorIs(t, err, ErrServiceEmpty)
595}
596
597func TestService_SetAttachment_InvalidServiceName(t *testing.T) {
598	store := outmocks.NewMockDomainSecretStore(t)
599	svc := NewService(store, testLogger(), nil)
600
601	err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"K": "V"})
602	assert.ErrorIs(t, err, ErrInvalidServiceName)
603}
604
605func TestService_DeleteAttachment_InvalidServiceName(t *testing.T) {
606	store := outmocks.NewMockDomainSecretStore(t)
607	svc := NewService(store, testLogger(), nil)
608
609	err := svc.DeleteAttachment(context.Background(), "app.example.com", "foo/bar", "SOME_KEY")
610	assert.ErrorIs(t, err, ErrInvalidServiceName)
611}
612
613func TestService_SetAttachment_ServiceNameTooLong(t *testing.T) {
614	store := outmocks.NewMockDomainSecretStore(t)
615	svc := NewService(store, testLogger(), nil)
616
617	// Service name exceeds DNS label limit of 63 characters
618	longService := strings.Repeat("a", 64)
619	err := svc.SetAttachment(context.Background(), "app.example.com", longService, map[string]string{"K": "V"})
620	assert.ErrorIs(t, err, ErrInvalidServiceName)
621}
622
623func TestService_SetAttachment_ServiceNameTrailingHyphen(t *testing.T) {
624	store := outmocks.NewMockDomainSecretStore(t)
625	svc := NewService(store, testLogger(), nil)
626
627	err := svc.SetAttachment(context.Background(), "app.example.com", "postgres-", map[string]string{"K": "V"})
628	assert.ErrorIs(t, err, ErrInvalidServiceName)
629}
630
631func TestService_DeleteAttachment_ServiceNameTooLong(t *testing.T) {
632	store := outmocks.NewMockDomainSecretStore(t)
633	svc := NewService(store, testLogger(), nil)
634
635	// Service name exceeds DNS label limit of 63 characters
636	longService := strings.Repeat("a", 64)
637	err := svc.DeleteAttachment(context.Background(), "app.example.com", longService, "SOME_KEY")
638	assert.ErrorIs(t, err, ErrInvalidServiceName)
639}
640
641func TestService_DeleteAttachment_ServiceNameTrailingHyphen(t *testing.T) {
642	store := outmocks.NewMockDomainSecretStore(t)
643	svc := NewService(store, testLogger(), nil)
644
645	err := svc.DeleteAttachment(context.Background(), "app.example.com", "redis-", "SOME_KEY")
646	assert.ErrorIs(t, err, ErrInvalidServiceName)
647}
648
649func TestService_AttachmentValidationHappensBeforeStore(t *testing.T) {
650	store := outmocks.NewMockDomainSecretStore(t)
651	svc := NewService(store, testLogger(), nil)
652
653	// With invalid service name, store methods should never be called
654	err := svc.SetAttachment(context.Background(), "app.example.com", "../evil", map[string]string{"key": "value"})
655	assert.ErrorIs(t, err, ErrInvalidServiceName)
656
657	err = svc.DeleteAttachment(context.Background(), "app.example.com", "../evil", "key")
658	assert.ErrorIs(t, err, ErrInvalidServiceName)
659
660	// Verify no store methods were called
661	mock.AssertExpectationsForObjects(t, store)
662}

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