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