service_test.go RawHistoryFolder

1package pki_test
2
3import (
4	"context"
5	"crypto/tls"
6	"errors"
7	"testing"
8	"time"
9
10	"github.com/bnema/zerowrap"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
15	"github.com/bnema/gordon/internal/boundaries/out"
16	"github.com/bnema/gordon/internal/boundaries/out/mocks"
17	pkiusecase "github.com/bnema/gordon/internal/usecase/pki"
18)
19
20func testLogger() zerowrap.Logger { return zerowrap.Default() }
21
22type blockingCertificateAuthority struct {
23	out.CertificateAuthority
24	started chan struct{}
25	release chan struct{}
26}
27
28func (b *blockingCertificateAuthority) IssueCertificate(domain string) (*tls.Certificate, error) {
29	close(b.started)
30	<-b.release
31	return b.CertificateAuthority.IssueCertificate(domain)
32}
33
34// stubAppRoutes is an ACTIVE-derived host source for PKI tests.
35type stubAppRoutes struct {
36	hosts []out.AppHost
37}
38
39func (s *stubAppRoutes) AppHosts() []out.AppHost { return s.hosts }
40
41func (s *stubAppRoutes) GetExternalRoutes() map[string]string { return nil }
42
43func newRouteCheckerMock(_ *testing.T, domains ...string) *stubAppRoutes {
44	hosts := make([]out.AppHost, 0, len(domains))
45	for _, d := range domains {
46		hosts = append(hosts, out.AppHost{Host: d})
47	}
48	return &stubAppRoutes{hosts: hosts}
49}
50
51func TestService_GetCertificate_KnownDomain(t *testing.T) {
52	dir := t.TempDir()
53	ca, err := pkiadapter.NewCA(dir, testLogger())
54	require.NoError(t, err)
55
56	cfg := newRouteCheckerMock(t, "app.example.com")
57
58	ctx, cancel := context.WithCancel(t.Context())
59	defer cancel()
60
61	svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
62	defer svc.Stop()
63
64	hello := &tls.ClientHelloInfo{ServerName: "app.example.com"}
65	cert, err := svc.GetCertificate(hello)
66	require.NoError(t, err)
67	require.NotNil(t, cert)
68}
69
70func TestService_GetCertificate_AdditionalDomain(t *testing.T) {
71	dir := t.TempDir()
72	ca, err := pkiadapter.NewCA(dir, testLogger())
73	require.NoError(t, err)
74
75	cfg := newRouteCheckerMock(t, "app.example.com")
76
77	ctx, cancel := context.WithCancel(t.Context())
78	defer cancel()
79
80	svc := pkiusecase.NewService(ctx, ca, cfg, []string{"gordon.example.com"}, testLogger())
81	defer svc.Stop()
82
83	hello := &tls.ClientHelloInfo{ServerName: "GORDON.EXAMPLE.COM."}
84	cert, err := svc.GetCertificate(hello)
85	require.NoError(t, err)
86	require.NotNil(t, cert)
87}
88
89func TestService_SetAdditionalDomains_ReplacesAuthorization(t *testing.T) {
90	dir := t.TempDir()
91	ca, err := pkiadapter.NewCA(dir, testLogger())
92	require.NoError(t, err)
93
94	cfg := newRouteCheckerMock(t)
95	ctx, cancel := context.WithCancel(t.Context())
96	defer cancel()
97
98	svc := pkiusecase.NewService(ctx, ca, cfg, []string{"old.example.com"}, testLogger())
99	defer svc.Stop()
100
101	oldHello := &tls.ClientHelloInfo{ServerName: "old.example.com"}
102	cert, err := svc.GetCertificate(oldHello)
103	require.NoError(t, err)
104	require.NotNil(t, cert)
105
106	svc.SetAdditionalDomains([]string{"NEW.EXAMPLE.COM."})
107
108	cert, err = svc.GetCertificate(oldHello)
109	require.NoError(t, err)
110	assert.Nil(t, cert)
111
112	cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "new.example.com"})
113	require.NoError(t, err)
114	require.NotNil(t, cert)
115}
116
117func TestService_SetAdditionalDomains_RevokesConcurrentIssuance(t *testing.T) {
118	dir := t.TempDir()
119	ca, err := pkiadapter.NewCA(dir, testLogger())
120	require.NoError(t, err)
121	blockingCA := &blockingCertificateAuthority{
122		CertificateAuthority: ca,
123		started:              make(chan struct{}),
124		release:              make(chan struct{}),
125	}
126
127	cfg := newRouteCheckerMock(t)
128	ctx, cancel := context.WithCancel(t.Context())
129	defer cancel()
130
131	svc := pkiusecase.NewService(ctx, blockingCA, cfg, []string{"old.example.com"}, testLogger())
132	defer svc.Stop()
133
134	result := make(chan *tls.Certificate, 1)
135	errs := make(chan error, 1)
136	go func() {
137		cert, getErr := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "old.example.com"})
138		result <- cert
139		errs <- getErr
140	}()
141
142	require.Eventually(t, func() bool {
143		select {
144		case <-blockingCA.started:
145			return true
146		default:
147			return false
148		}
149	}, time.Second, 10*time.Millisecond, "certificate issuance did not start")
150	svc.SetAdditionalDomains([]string{"new.example.com"})
151	close(blockingCA.release)
152
153	var getErr error
154	require.Eventually(t, func() bool {
155		select {
156		case getErr = <-errs:
157			return true
158		default:
159			return false
160		}
161	}, time.Second, 10*time.Millisecond, "certificate issuance did not return an error result")
162	require.NoError(t, getErr)
163
164	var cert *tls.Certificate
165	require.Eventually(t, func() bool {
166		select {
167		case cert = <-result:
168			return true
169		default:
170			return false
171		}
172	}, time.Second, 10*time.Millisecond, "certificate issuance did not return a certificate result")
173	assert.Nil(t, cert)
174	assert.Equal(t, 0, svc.CachedCertCount())
175}
176
177func TestService_GetCertificate_WrapsIssuanceError(t *testing.T) {
178	issuerErr := errors.New("issuer unavailable")
179	ca := mocks.NewMockCertificateAuthority(t)
180	ca.EXPECT().IntermediateExpiresAt().Return(time.Now().Add(24 * time.Hour)).Maybe()
181	ca.EXPECT().IntermediateLifetime().Return(24 * time.Hour).Maybe()
182	ca.EXPECT().IssueCertificate("broken.example.com").Return(nil, issuerErr).Once()
183	cfg := newRouteCheckerMock(t, "broken.example.com")
184
185	ctx, cancel := context.WithCancel(t.Context())
186	defer cancel()
187	svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
188	defer svc.Stop()
189
190	cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "broken.example.com"})
191
192	assert.Nil(t, cert)
193	require.ErrorIs(t, err, issuerErr)
194	assert.Equal(t, `issue leaf certificate for "broken.example.com": issuer unavailable`, err.Error())
195}
196
197func TestService_GetCertificate_NeverTLSHostDenied(t *testing.T) {
198	dir := t.TempDir()
199	ca, err := pkiadapter.NewCA(dir, testLogger())
200	require.NoError(t, err)
201
202	cfg := &stubAppRoutes{hosts: []out.AppHost{
203		{Host: "plain.example.com", TLSMode: "never"},
204		{Host: "auto.example.com", TLSMode: "auto"},
205	}}
206
207	ctx, cancel := context.WithCancel(t.Context())
208	defer cancel()
209
210	svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
211	defer svc.Stop()
212
213	cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "plain.example.com"})
214	assert.NoError(t, err)
215	assert.Nil(t, cert, "tls=never hosts stay plain HTTP: no internal certificate")
216
217	cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "auto.example.com"})
218	require.NoError(t, err)
219	require.NotNil(t, cert)
220}
221
222func TestService_GetCertificate_UnknownDomain(t *testing.T) {
223	dir := t.TempDir()
224	ca, err := pkiadapter.NewCA(dir, testLogger())
225	require.NoError(t, err)
226
227	cfg := newRouteCheckerMock(t, "app.example.com")
228
229	ctx, cancel := context.WithCancel(t.Context())
230	defer cancel()
231
232	svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
233	defer svc.Stop()
234
235	hello := &tls.ClientHelloInfo{ServerName: "unknown.example.com"}
236	cert, err := svc.GetCertificate(hello)
237	assert.NoError(t, err)
238	assert.Nil(t, cert)
239}
240
241func TestService_GetCertificate_Caching(t *testing.T) {
242	dir := t.TempDir()
243	ca, err := pkiadapter.NewCA(dir, testLogger())
244	require.NoError(t, err)
245
246	cfg := newRouteCheckerMock(t, "cached.example.com")
247
248	ctx, cancel := context.WithCancel(t.Context())
249	defer cancel()
250
251	svc := pkiusecase.NewService(ctx, ca, cfg, nil, testLogger())
252	defer svc.Stop()
253
254	hello := &tls.ClientHelloInfo{ServerName: "cached.example.com"}
255	cert1, err := svc.GetCertificate(hello)
256	require.NoError(t, err)
257
258	cert2, err := svc.GetCertificate(hello)
259	require.NoError(t, err)
260
261	// Same pointer = served from cache
262	assert.Same(t, cert1, cert2, "second call should return cached cert")
263}

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