v3.3.1 31f1eec

integration_test.go RawHistoryFolder

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}

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