v2.31.0-dev13 0d280f4

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		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}

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