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}