1package publictls
2
3import (
4 "context"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/gordon/internal/boundaries/out"
12 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16// ---------------------------------------------------------------------------
17// Integration-style tests using fakes (no real ACME / Cloudflare)
18// ---------------------------------------------------------------------------
19
20func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing.T) {
21 ctx := context.Background()
22
23 // Routes: app.example.com, api.prod.example.com, example.com
24 routes := &fakeRoutes{
25 hosts: []out.AppHost{
26 {Host: "app.example.com"},
27 {Host: "api.prod.example.com"},
28 {Host: "example.com"},
29 },
30 }
31
32 // Mock zone resolver returns "example.com" for routes and the management host.
33 zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
34 zoneResolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(4)
35
36 // Mock issuer: returns valid stored cert for any order.
37 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
38 return defaultStoredCert(order)
39 }, nil)
40 store, _ := newMockCertificateStore(t)
41
42 cfg := Config{
43 Enabled: true,
44 Email: "admin@example.com",
45 Challenge: "cloudflare-dns-01",
46 TLSPort: 8443,
47 ObtainBatchSize: 10,
48 }
49
50 svc := NewService(cfg, ServiceDeps{
51 Config: cfg,
52 Routes: routes,
53 Issuer: issuer,
54 Store: store,
55 ZoneResolver: zoneResolver,
56 Challenges: NewHTTP01Challenges(),
57 AdditionalHosts: []string{"gordon.example.com"},
58 Effective: EffectiveChallenge{
59 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
60 Mode: domain.ACMEChallengeCloudflareDNS01,
61 TokenSource: domain.ACMETokenSourceNone,
62 Reason: "cloudflare dns-01 challenge selected",
63 },
64 })
65
66 // Load (nothing stored).
67 err := svc.Load(ctx)
68 require.NoError(t, err)
69
70 // Reconcile — should obtain DNS wildcard base targets:
71 // example.com (for app.example.com + example.com) and prod.example.com (for api.prod.example.com).
72 err = svc.Reconcile(ctx)
73 require.NoError(t, err)
74
75 // Verify issuer received exactly 2 orders.
76 orders := recorder.Orders()
77
78 require.Len(t, orders, 2, "expected 2 DNS-01 orders")
79
80 // Check order details (orders are sorted alphabetically by target base).
81 order0 := orders[0]
82 assert.Equal(t, "dns01-example.com", order0.ID)
83 assert.ElementsMatch(t, []string{"example.com", "*.example.com"}, order0.Names)
84 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order0.Challenge)
85
86 order1 := orders[1]
87 assert.Equal(t, "dns01-prod.example.com", order1.ID)
88 assert.ElementsMatch(t, []string{"prod.example.com", "*.prod.example.com"}, order1.Names)
89 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order1.Challenge)
90
91 // --- Status reports routes covered ---
92 status := svc.Status(ctx)
93 require.True(t, status.ACMEEnabled)
94 require.Len(t, status.Routes, 3, "expected 3 routes in status")
95
96 // Build a map for easier assertions.
97 routeMap := make(map[string]domain.TLSRouteCoverage)
98 for _, r := range status.Routes {
99 routeMap[r.Domain] = r
100 }
101
102 for _, routeDomain := range []string{"app.example.com", "api.prod.example.com", "example.com"} {
103 rc, ok := routeMap[routeDomain]
104 require.True(t, ok, "route %s should be present in status", routeDomain)
105 assert.True(t, rc.Covered, "route %s should be covered", routeDomain)
106 assert.True(t, rc.RequiredACME)
107 assert.NotEmpty(t, rc.CoveredBy, "route %s should have CoveredBy set", routeDomain)
108 assert.Empty(t, rc.Error, "route %s should have no error", routeDomain)
109 }
110
111 // app.example.com should be covered by the wildcard cert (dns01-example.com).
112 assert.Equal(t, "dns01-example.com", routeMap["app.example.com"].CoveredBy,
113 "app.example.com should be covered by dns01-example.com (wildcard *.example.com)")
114
115 // example.com should be covered by the base cert (dns01-example.com).
116 assert.Equal(t, "dns01-example.com", routeMap["example.com"].CoveredBy,
117 "example.com should be covered by dns01-example.com")
118
119 // api.prod.example.com should be covered by the wildcard cert (dns01-prod.example.com).
120 assert.Equal(t, "dns01-prod.example.com", routeMap["api.prod.example.com"].CoveredBy,
121 "api.prod.example.com should be covered by dns01-prod.example.com (wildcard *.prod.example.com)")
122
123 // --- GetCertificate returns non-nil certs ---
124 t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) {
125 cert, err := svc.GetCertificateForHost("app.example.com")
126 require.NoError(t, err)
127 require.NotNil(t, cert)
128 require.NotEmpty(t, cert.Certificate)
129 })
130
131 t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) {
132 cert, err := svc.GetCertificateForHost("api.prod.example.com")
133 require.NoError(t, err)
134 require.NotNil(t, cert)
135 require.NotEmpty(t, cert.Certificate)
136 })
137
138 t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) {
139 cert, err := svc.GetCertificateForHost("example.com")
140 require.NoError(t, err)
141 require.NotNil(t, cert)
142 require.NotEmpty(t, cert.Certificate)
143 })
144
145 t.Run("GetCertificate_returns_cert_for_management_host", func(t *testing.T) {
146 cert, err := svc.GetCertificateForHost("gordon.example.com")
147 require.NoError(t, err)
148 require.NotNil(t, cert)
149 require.NotEmpty(t, cert.Certificate)
150 })
151
152 t.Run("GetCertificate_returns_nil_for_unknown_wildcard_sibling", func(t *testing.T) {
153 cert, err := svc.GetCertificateForHost("unknown.example.com")
154 require.NoError(t, err)
155 assert.Nil(t, cert, "wildcard coverage must not authorize an unconfigured sibling")
156 })
157
158 // Also verify that a host not in the routes returns nil (no required ACME).
159 // "unrelated.com" is not configured as a route or management host.
160 t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {
161 cert, err := svc.GetCertificateForHost("unrelated.com")
162 require.NoError(t, err)
163 assert.Nil(t, cert, "unrelated host should not require ACME coverage")
164 })
165}
166
167func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
168 ctx := context.Background()
169
170 routes := &fakeRoutes{
171 hosts: []out.AppHost{
172 {Host: "app.example.com"},
173 },
174 }
175
176 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
177 store, _ := newMockCertificateStore(t)
178 challenges := NewHTTP01Challenges()
179
180 cfg := Config{
181 Enabled: true,
182 Email: "admin@example.com",
183 Challenge: "http-01",
184 HTTPPort: 8088,
185 TLSPort: 8443,
186 }
187
188 svc := NewService(cfg, ServiceDeps{
189 Config: cfg,
190 Routes: routes,
191 Issuer: issuer,
192 Store: store,
193 Challenges: challenges,
194 Effective: EffectiveChallenge{
195 ConfiguredMode: domain.ACMEChallengeHTTP01,
196 Mode: domain.ACMEChallengeHTTP01,
197 TokenSource: domain.ACMETokenSourceNone,
198 Reason: "http-01 challenge selected",
199 },
200 })
201
202 // Present an HTTP-01 challenge token/keyAuth.
203 token := "test-token-abc123"
204 keyAuth := "test-key-auth-xyz789"
205 challenges.Present(token, keyAuth)
206
207 // GetHTTP01Challenge should return the keyAuth.
208 gotKeyAuth, ok := svc.GetHTTP01Challenge(ctx, token)
209 require.True(t, ok, "GetHTTP01Challenge should find the token")
210 assert.Equal(t, keyAuth, gotKeyAuth)
211
212 // Load the service (nothing stored).
213 err := svc.Load(ctx)
214 require.NoError(t, err)
215
216 // Reconcile — should create per-route target app.example.com.
217 err = svc.Reconcile(ctx)
218 require.NoError(t, err)
219
220 // Verify the issuer received exactly 1 order.
221 orders := recorder.Orders()
222
223 require.Len(t, orders, 1, "expected 1 HTTP-01 order")
224 assert.Equal(t, "http01-app.example.com", orders[0].ID)
225 assert.Equal(t, []string{"app.example.com"}, orders[0].Names)
226 assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
227
228 // Status reports covered route.
229 status := svc.Status(ctx)
230 require.True(t, status.ACMEEnabled)
231 require.Len(t, status.Routes, 1, "expected 1 route in status")
232 assert.Equal(t, "app.example.com", status.Routes[0].Domain)
233 assert.True(t, status.Routes[0].Covered, "route should be covered")
234 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
235 assert.Empty(t, status.Routes[0].Error)
236
237 // GetCertificateForHost returns non-nil cert for app.example.com.
238 cert, err := svc.GetCertificateForHost("app.example.com")
239 require.NoError(t, err)
240 require.NotNil(t, cert)
241 require.NotEmpty(t, cert.Certificate)
242
243 // GetCertificateForHost returns nil for unrelated host.
244 cert, err = svc.GetCertificateForHost("other.example.com")
245 require.NoError(t, err)
246 assert.Nil(t, cert)
247}
248
249func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing.T) {
250 ctx := context.Background()
251
252 routes := &fakeRoutes{
253 hosts: []out.AppHost{
254 {Host: "missing.example.com"},
255 },
256 }
257
258 // Issuer returns error so no cert is obtained.
259 issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
260 return nil, assert.AnError
261 }, nil)
262 store, _ := newMockCertificateStore(t)
263
264 cfg := Config{
265 Enabled: true,
266 Email: "admin@example.com",
267 Challenge: "http-01",
268 HTTPPort: 8088,
269 TLSPort: 8443,
270 }
271
272 svc := NewService(cfg, ServiceDeps{
273 Config: cfg,
274 Routes: routes,
275 Issuer: issuer,
276 Store: store,
277 Challenges: NewHTTP01Challenges(),
278 Effective: EffectiveChallenge{
279 ConfiguredMode: domain.ACMEChallengeHTTP01,
280 Mode: domain.ACMEChallengeHTTP01,
281 TokenSource: domain.ACMETokenSourceNone,
282 Reason: "http-01 challenge selected",
283 },
284 })
285
286 // Load (nothing stored).
287 err := svc.Load(ctx)
288 require.NoError(t, err)
289
290 // Reconcile — issuer fails, so no cert is cached but requiredHosts is set.
291 err = svc.Reconcile(ctx)
292 require.NoError(t, err) // Reconcile swallows per-target obtain errors
293
294 // GetCertificateForHost for missing.example.com should return ErrTLSRouteNotCovered.
295 _, err = svc.GetCertificateForHost("missing.example.com")
296 require.Error(t, err)
297 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
298 // The error message should contain the hostname.
299 assert.Contains(t, err.Error(), "missing.example.com")
300
301 // Status should show the route is NOT covered and has an error.
302 status := svc.Status(ctx)
303 require.True(t, status.ACMEEnabled)
304 require.Len(t, status.Routes, 1, "expected 1 route in status")
305 rc := status.Routes[0]
306 assert.Equal(t, "missing.example.com", rc.Domain)
307 assert.False(t, rc.Covered, "route should NOT be covered")
308 assert.True(t, rc.RequiredACME)
309
310 assert.Contains(t, rc.Error, "obtain certificate")
311}