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