detached 289a830

Clean up public ACME TLS review issues

commit
289a830df2b739319ed3ad96b631e038f3470f58
author
brice · · 2026-04-29 14:16 UTC
parent
745d4bc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +1731 -631
M .mockery.yaml +6 -0
@@ -25,6 +25,11 @@ packages:
2525 RateLimiter:
2626 BackupStorage:
2727 RouteChecker:
28+ HTTPChallengeSink:
29+ PublicCertificateIssuer:
30+ CertificateStore:
31+ SecretResolver:
32+ CloudflareZoneResolver:
2833 github.com/bnema/gordon/internal/boundaries/in:
2934 interfaces:
3035 ContainerService:
@@ -38,6 +43,7 @@ packages:
3843 SecretService:
3944 LogService:
4045 VolumeService:
46+ PublicTLSService:
4147 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
4248 # Mocked here because it abstracts Docker SDK calls that require a running
4349 # daemon, making unit/integration tests impractical without a test double.
@@ -25,6 +25,11 @@ packages:
25 RateLimiter:25 RateLimiter:
26 BackupStorage:26 BackupStorage:
27 RouteChecker:27 RouteChecker:
28+ HTTPChallengeSink:
29+ PublicCertificateIssuer:
30+ CertificateStore:
31+ SecretResolver:
32+ CloudflareZoneResolver:
28 github.com/bnema/gordon/internal/boundaries/in:33 github.com/bnema/gordon/internal/boundaries/in:
29 interfaces:34 interfaces:
30 ContainerService:35 ContainerService:
@@ -38,6 +43,7 @@ packages:
38 SecretService:43 SecretService:
39 LogService:44 LogService:
40 VolumeService:45 VolumeService:
46+ PublicTLSService:
41 # Exception: pushImageOps is a CLI-local interface, not a boundary port.47 # Exception: pushImageOps is a CLI-local interface, not a boundary port.
42 # Mocked here because it abstracts Docker SDK calls that require a running48 # Mocked here because it abstracts Docker SDK calls that require a running
43 # daemon, making unit/integration tests impractical without a test double.49 # daemon, making unit/integration tests impractical without a test double.
M internal/adapters/in/cli/tls.go +3 -0
@@ -52,6 +52,9 @@ func runTLSStatusCmd(ctx context.Context, cp ControlPlane, out io.Writer, jsonOu
5252 if err != nil {
5353 return fmt.Errorf("failed to get TLS status: %w", err)
5454 }
55+ if status == nil {
56+ return fmt.Errorf("failed to get TLS status: empty response")
57+ }
5558
5659 if jsonOut {
5760 return writeJSON(out, status)
@@ -52,6 +52,9 @@ func runTLSStatusCmd(ctx context.Context, cp ControlPlane, out io.Writer, jsonOu
52 if err != nil {52 if err != nil {
53 return fmt.Errorf("failed to get TLS status: %w", err)53 return fmt.Errorf("failed to get TLS status: %w", err)
54 }54 }
55+ if status == nil {
56+ return fmt.Errorf("failed to get TLS status: empty response")
57+ }
55 58
56 if jsonOut {59 if jsonOut {
57 return writeJSON(out, status)60 return writeJSON(out, status)
M internal/adapters/in/http/acme/handler.go +2 -3
@@ -6,8 +6,6 @@ import (
66 "context"
77 "net/http"
88 "strings"
9-
10- "github.com/bnema/gordon/internal/domain"
119 )
1210
1311 // Prefix is the path prefix for ACME HTTP-01 challenge requests.
@@ -33,6 +31,7 @@ func NewHandler(svc ChallengeService) *Handler {
3331 // The token must be a safe path component and must exist in the challenge store.
3432 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
3533 if r.Method != http.MethodGet && r.Method != http.MethodHead {
34+ w.Header().Set("Allow", "GET, HEAD")
3635 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
3736 return
3837 }
@@ -68,5 +67,5 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
6867 // safeToken validates that the token does not contain path traversal or special
6968 // characters that could be used to access files outside the challenge directory.
7069 func safeToken(token string) bool {
71- return domain.SafePathComponent(token)
70+ return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00")
7271 }
@@ -6,8 +6,6 @@ import (
6 "context"6 "context"
7 "net/http"7 "net/http"
8 "strings"8 "strings"
9-
10- "github.com/bnema/gordon/internal/domain"
11 )9 )
12 10
13 // Prefix is the path prefix for ACME HTTP-01 challenge requests.11 // Prefix is the path prefix for ACME HTTP-01 challenge requests.
@@ -33,6 +31,7 @@ func NewHandler(svc ChallengeService) *Handler {
33 // The token must be a safe path component and must exist in the challenge store.31 // The token must be a safe path component and must exist in the challenge store.
34 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {32 func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
35 if r.Method != http.MethodGet && r.Method != http.MethodHead {33 if r.Method != http.MethodGet && r.Method != http.MethodHead {
34+ w.Header().Set("Allow", "GET, HEAD")
36 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)35 http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
37 return36 return
38 }37 }
@@ -68,5 +67,5 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
68 // safeToken validates that the token does not contain path traversal or special67 // safeToken validates that the token does not contain path traversal or special
69 // characters that could be used to access files outside the challenge directory.68 // characters that could be used to access files outside the challenge directory.
70 func safeToken(token string) bool {69 func safeToken(token string) bool {
71- return domain.SafePathComponent(token)70+ return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00")
72 }71 }
M internal/adapters/in/http/acme/handler_test.go +50 -51
@@ -2,11 +2,13 @@ package acme
22
33 import (
44 "context"
5+ "io"
56 "net/http"
67 "net/http/httptest"
78 "testing"
89
910 "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
1012 )
1113
1214 // challengeSvc is a simple map-backed implementation of ChallengeService for testing.
@@ -17,40 +19,43 @@ func (s challengeSvc) GetHTTP01Challenge(_ context.Context, token string) (strin
1719 return keyAuth, ok
1820 }
1921
20-func TestHandlerServesGETChallenge(t *testing.T) {
21- svc := challengeSvc{"abc": "key-auth"}
22- h := NewHandler(svc)
22+func newTestServer(t *testing.T, svc ChallengeService) *httptest.Server {
23+ t.Helper()
24+ server := httptest.NewServer(NewHandler(svc))
25+ t.Cleanup(server.Close)
26+ return server
27+}
2328
24- req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil)
25- rec := httptest.NewRecorder()
29+func readBody(t *testing.T, resp *http.Response) string {
30+ t.Helper()
31+ defer resp.Body.Close()
32+ body, err := io.ReadAll(resp.Body)
33+ require.NoError(t, err)
34+ return string(body)
35+}
2636
27- h.ServeHTTP(rec, req)
37+func TestHandlerServesGETChallenge(t *testing.T) {
38+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
2839
29- resp := rec.Result()
30- defer resp.Body.Close()
40+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc")
41+ require.NoError(t, err)
3142
3243 assert.Equal(t, http.StatusOK, resp.StatusCode)
3344 assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
3445 assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
35- assert.Equal(t, "key-auth", rec.Body.String())
46+ assert.Equal(t, "key-auth", readBody(t, resp))
3647 }
3748
3849 func TestHandlerServesHEADChallenge(t *testing.T) {
39- svc := challengeSvc{"abc": "key-auth"}
40- h := NewHandler(svc)
41-
42- req := httptest.NewRequest(http.MethodHead, "/.well-known/acme-challenge/abc", nil)
43- rec := httptest.NewRecorder()
50+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
4451
45- h.ServeHTTP(rec, req)
46-
47- resp := rec.Result()
52+ resp, err := http.Head(server.URL + "/.well-known/acme-challenge/abc")
53+ require.NoError(t, err)
4854 defer resp.Body.Close()
4955
5056 assert.Equal(t, http.StatusOK, resp.StatusCode)
5157 assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
5258 assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
53- assert.Empty(t, rec.Body.String())
5459 }
5560
5661 func TestHandlerRejectsUnsafeToken(t *testing.T) {
@@ -61,64 +66,58 @@ func TestHandlerRejectsUnsafeToken(t *testing.T) {
6166 {name: "path traversal", path: "/.well-known/acme-challenge/../secret"},
6267 {name: "slash in token", path: "/.well-known/acme-challenge/foo/bar"},
6368 {name: "empty token", path: "/.well-known/acme-challenge/"},
64- {name: "backslash in token", path: "/.well-known/acme-challenge/foo\\bar"},
69+ {name: "backslash in token", path: "/.well-known/acme-challenge/foo%5Cbar"},
6570 }
6671
6772 for _, tt := range tests {
6873 t.Run(tt.name, func(t *testing.T) {
69- h := NewHandler(challengeSvc{"abc": "key-auth"})
70-
71- req := httptest.NewRequest(http.MethodGet, tt.path, nil)
72- rec := httptest.NewRecorder()
74+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
75+ resp, err := http.Get(server.URL + tt.path)
76+ require.NoError(t, err)
77+ defer resp.Body.Close()
7378
74- h.ServeHTTP(rec, req)
75-
76- assert.Equal(t, http.StatusNotFound, rec.Code, "path: %s", tt.path)
79+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, "path: %s", tt.path)
7780 })
7881 }
7982 }
8083
8184 func TestHandlerRejectsPOST(t *testing.T) {
82- svc := challengeSvc{"abc": "key-auth"}
83- h := NewHandler(svc)
84-
85- req := httptest.NewRequest(http.MethodPost, "/.well-known/acme-challenge/abc", nil)
86- rec := httptest.NewRecorder()
85+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
8786
88- h.ServeHTTP(rec, req)
87+ resp, err := http.Post(server.URL+"/.well-known/acme-challenge/abc", "text/plain", nil)
88+ require.NoError(t, err)
89+ defer resp.Body.Close()
8990
90- assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
91+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
92+ assert.Equal(t, "GET, HEAD", resp.Header.Get("Allow"))
9193 }
9294
9395 func TestHandlerRejectsMissingPrefix(t *testing.T) {
94- h := NewHandler(challengeSvc{"abc": "key-auth"})
96+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
9597
96- req := httptest.NewRequest(http.MethodGet, "/some-other-path", nil)
97- rec := httptest.NewRecorder()
98-
99- h.ServeHTTP(rec, req)
98+ resp, err := http.Get(server.URL + "/some-other-path")
99+ require.NoError(t, err)
100+ defer resp.Body.Close()
100101
101- assert.Equal(t, http.StatusNotFound, rec.Code)
102+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
102103 }
103104
104105 func TestHandlerRejectsUnknownToken(t *testing.T) {
105- h := NewHandler(challengeSvc{"abc": "key-auth"})
106+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
106107
107- req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/unknown", nil)
108- rec := httptest.NewRecorder()
109-
110- h.ServeHTTP(rec, req)
108+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/unknown")
109+ require.NoError(t, err)
110+ defer resp.Body.Close()
111111
112- assert.Equal(t, http.StatusNotFound, rec.Code)
112+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
113113 }
114114
115115 func TestHandlerRejectsNilService(t *testing.T) {
116- h := NewHandler(nil)
116+ server := newTestServer(t, nil)
117117
118- req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil)
119- rec := httptest.NewRecorder()
120-
121- h.ServeHTTP(rec, req)
118+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc")
119+ require.NoError(t, err)
120+ defer resp.Body.Close()
122121
123- assert.Equal(t, http.StatusNotFound, rec.Code)
122+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
124123 }
@@ -2,11 +2,13 @@ package acme
2 2
3 import (3 import (
4 "context"4 "context"
5+ "io"
5 "net/http"6 "net/http"
6 "net/http/httptest"7 "net/http/httptest"
7 "testing"8 "testing"
8 9
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
10 )12 )
11 13
12 // challengeSvc is a simple map-backed implementation of ChallengeService for testing.14 // challengeSvc is a simple map-backed implementation of ChallengeService for testing.
@@ -17,40 +19,43 @@ func (s challengeSvc) GetHTTP01Challenge(_ context.Context, token string) (strin
17 return keyAuth, ok19 return keyAuth, ok
18 }20 }
19 21
20-func TestHandlerServesGETChallenge(t *testing.T) {22+func newTestServer(t *testing.T, svc ChallengeService) *httptest.Server {
21- svc := challengeSvc{"abc": "key-auth"}23+ t.Helper()
22- h := NewHandler(svc)24+ server := httptest.NewServer(NewHandler(svc))
25+ t.Cleanup(server.Close)
26+ return server
27+}
23 28
24- req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil)29+func readBody(t *testing.T, resp *http.Response) string {
25- rec := httptest.NewRecorder()30+ t.Helper()
31+ defer resp.Body.Close()
32+ body, err := io.ReadAll(resp.Body)
33+ require.NoError(t, err)
34+ return string(body)
35+}
26 36
27- h.ServeHTTP(rec, req)37+func TestHandlerServesGETChallenge(t *testing.T) {
38+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
28 39
29- resp := rec.Result()40+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc")
30- defer resp.Body.Close()41+ require.NoError(t, err)
31 42
32 assert.Equal(t, http.StatusOK, resp.StatusCode)43 assert.Equal(t, http.StatusOK, resp.StatusCode)
33 assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))44 assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
34 assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))45 assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
35- assert.Equal(t, "key-auth", rec.Body.String())46+ assert.Equal(t, "key-auth", readBody(t, resp))
36 }47 }
37 48
38 func TestHandlerServesHEADChallenge(t *testing.T) {49 func TestHandlerServesHEADChallenge(t *testing.T) {
39- svc := challengeSvc{"abc": "key-auth"}50+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
40- h := NewHandler(svc)
41-
42- req := httptest.NewRequest(http.MethodHead, "/.well-known/acme-challenge/abc", nil)
43- rec := httptest.NewRecorder()
44 51
45- h.ServeHTTP(rec, req)52+ resp, err := http.Head(server.URL + "/.well-known/acme-challenge/abc")
46-53+ require.NoError(t, err)
47- resp := rec.Result()
48 defer resp.Body.Close()54 defer resp.Body.Close()
49 55
50 assert.Equal(t, http.StatusOK, resp.StatusCode)56 assert.Equal(t, http.StatusOK, resp.StatusCode)
51 assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))57 assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type"))
52 assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))58 assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
53- assert.Empty(t, rec.Body.String())
54 }59 }
55 60
56 func TestHandlerRejectsUnsafeToken(t *testing.T) {61 func TestHandlerRejectsUnsafeToken(t *testing.T) {
@@ -61,64 +66,58 @@ func TestHandlerRejectsUnsafeToken(t *testing.T) {
61 {name: "path traversal", path: "/.well-known/acme-challenge/../secret"},66 {name: "path traversal", path: "/.well-known/acme-challenge/../secret"},
62 {name: "slash in token", path: "/.well-known/acme-challenge/foo/bar"},67 {name: "slash in token", path: "/.well-known/acme-challenge/foo/bar"},
63 {name: "empty token", path: "/.well-known/acme-challenge/"},68 {name: "empty token", path: "/.well-known/acme-challenge/"},
64- {name: "backslash in token", path: "/.well-known/acme-challenge/foo\\bar"},69+ {name: "backslash in token", path: "/.well-known/acme-challenge/foo%5Cbar"},
65 }70 }
66 71
67 for _, tt := range tests {72 for _, tt := range tests {
68 t.Run(tt.name, func(t *testing.T) {73 t.Run(tt.name, func(t *testing.T) {
69- h := NewHandler(challengeSvc{"abc": "key-auth"})74+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
70-75+ resp, err := http.Get(server.URL + tt.path)
71- req := httptest.NewRequest(http.MethodGet, tt.path, nil)76+ require.NoError(t, err)
72- rec := httptest.NewRecorder()77+ defer resp.Body.Close()
73 78
74- h.ServeHTTP(rec, req)79+ assert.Equal(t, http.StatusNotFound, resp.StatusCode, "path: %s", tt.path)
75-
76- assert.Equal(t, http.StatusNotFound, rec.Code, "path: %s", tt.path)
77 })80 })
78 }81 }
79 }82 }
80 83
81 func TestHandlerRejectsPOST(t *testing.T) {84 func TestHandlerRejectsPOST(t *testing.T) {
82- svc := challengeSvc{"abc": "key-auth"}85+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
83- h := NewHandler(svc)
84-
85- req := httptest.NewRequest(http.MethodPost, "/.well-known/acme-challenge/abc", nil)
86- rec := httptest.NewRecorder()
87 86
88- h.ServeHTTP(rec, req)87+ resp, err := http.Post(server.URL+"/.well-known/acme-challenge/abc", "text/plain", nil)
88+ require.NoError(t, err)
89+ defer resp.Body.Close()
89 90
90- assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)91+ assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode)
92+ assert.Equal(t, "GET, HEAD", resp.Header.Get("Allow"))
91 }93 }
92 94
93 func TestHandlerRejectsMissingPrefix(t *testing.T) {95 func TestHandlerRejectsMissingPrefix(t *testing.T) {
94- h := NewHandler(challengeSvc{"abc": "key-auth"})96+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
95 97
96- req := httptest.NewRequest(http.MethodGet, "/some-other-path", nil)98+ resp, err := http.Get(server.URL + "/some-other-path")
97- rec := httptest.NewRecorder()99+ require.NoError(t, err)
98-100+ defer resp.Body.Close()
99- h.ServeHTTP(rec, req)
100 101
101- assert.Equal(t, http.StatusNotFound, rec.Code)102+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
102 }103 }
103 104
104 func TestHandlerRejectsUnknownToken(t *testing.T) {105 func TestHandlerRejectsUnknownToken(t *testing.T) {
105- h := NewHandler(challengeSvc{"abc": "key-auth"})106+ server := newTestServer(t, challengeSvc{"abc": "key-auth"})
106 107
107- req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/unknown", nil)108+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/unknown")
108- rec := httptest.NewRecorder()109+ require.NoError(t, err)
109-110+ defer resp.Body.Close()
110- h.ServeHTTP(rec, req)
111 111
112- assert.Equal(t, http.StatusNotFound, rec.Code)112+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
113 }113 }
114 114
115 func TestHandlerRejectsNilService(t *testing.T) {115 func TestHandlerRejectsNilService(t *testing.T) {
116- h := NewHandler(nil)116+ server := newTestServer(t, nil)
117 117
118- req := httptest.NewRequest(http.MethodGet, "/.well-known/acme-challenge/abc", nil)118+ resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc")
119- rec := httptest.NewRecorder()119+ require.NoError(t, err)
120-120+ defer resp.Body.Close()
121- h.ServeHTTP(rec, req)
122 121
123- assert.Equal(t, http.StatusNotFound, rec.Code)122+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
124 }123 }
M internal/adapters/in/http/admin/handler_test.go +26 -57
@@ -3,7 +3,6 @@ package admin
33 import (
44 "bytes"
55 "context"
6- "crypto/tls"
76 "encoding/json"
87 "errors"
98 "fmt"
@@ -2587,37 +2586,9 @@ func TestHandler_Images_Authorization(t *testing.T) {
25872586 })
25882587 }
25892588
2590-// fakeTLSService is a minimal in.PublicTLSService implementation for testing.
2591-type fakeTLSService struct {
2592- statusFunc func(ctx context.Context) domain.PublicTLSStatus
2593-}
2594-
2595-func (f *fakeTLSService) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
2596- return nil, nil
2597-}
2598-
2599-func (f *fakeTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
2600- return "", false
2601-}
2602-
2603-func (f *fakeTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
2604- if f.statusFunc != nil {
2605- return f.statusFunc(ctx)
2606- }
2607- return domain.PublicTLSStatus{}
2608-}
2609-
2610-func (f *fakeTLSService) Reconcile(ctx context.Context) error {
2611- return nil
2612-}
2613-
2614-func (f *fakeTLSService) Stop(ctx context.Context) error {
2615- return nil
2616-}
2617-
26182589 func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
26192590 handler := newTestHandler(t, func(d *HandlerDeps) {
2620- d.PublicTLSSvc = &fakeTLSService{}
2591+ d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
26212592 })
26222593
26232594 req := httptest.NewRequest("GET", "/admin/tls/status", nil)
@@ -2632,36 +2603,34 @@ func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
26322603
26332604 func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
26342605 now := time.Now()
2635- fakeSvc := &fakeTLSService{
2636- statusFunc: func(ctx context.Context) domain.PublicTLSStatus {
2637- return domain.PublicTLSStatus{
2638- ACMEEnabled: true,
2639- ConfiguredMode: domain.ACMEChallengeAuto,
2640- EffectiveMode: domain.ACMEChallengeHTTP01,
2641- SelectionReason: "auto-selected",
2642- TokenSource: domain.ACMETokenSourcePass,
2643- Certificates: []domain.ManagedCertificate{
2644- {
2645- ID: "cert-1",
2646- Names: []string{"example.com"},
2647- Challenge: domain.ACMEChallengeHTTP01,
2648- Status: domain.TLSCertificateStatusValid,
2649- NotAfter: now.Add(60 * 24 * time.Hour),
2650- },
2651- },
2652- Routes: []domain.TLSRouteCoverage{
2653- {
2654- Domain: "example.com",
2655- Covered: true,
2656- CoveredBy: "cert-1",
2657- RequiredACME: true,
2658- },
2659- },
2660- }
2606+ tlsStatus := domain.PublicTLSStatus{
2607+ ACMEEnabled: true,
2608+ ConfiguredMode: domain.ACMEChallengeAuto,
2609+ EffectiveMode: domain.ACMEChallengeHTTP01,
2610+ SelectionReason: "auto-selected",
2611+ TokenSource: domain.ACMETokenSourcePass,
2612+ Certificates: []domain.ManagedCertificate{
2613+ {
2614+ ID: "cert-1",
2615+ Names: []string{"example.com"},
2616+ Challenge: domain.ACMEChallengeHTTP01,
2617+ Status: domain.TLSCertificateStatusValid,
2618+ NotAfter: now.Add(60 * 24 * time.Hour),
2619+ },
2620+ },
2621+ Routes: []domain.TLSRouteCoverage{
2622+ {
2623+ Domain: "example.com",
2624+ Covered: true,
2625+ CoveredBy: "cert-1",
2626+ RequiredACME: true,
2627+ },
26612628 },
26622629 }
26632630
26642631 handler := newTestHandler(t, func(d *HandlerDeps) {
2632+ fakeSvc := inmocks.NewMockPublicTLSService(t)
2633+ fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus)
26652634 d.PublicTLSSvc = fakeSvc
26662635 })
26672636
@@ -2692,7 +2661,7 @@ func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
26922661
26932662 func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
26942663 handler := newTestHandler(t, func(d *HandlerDeps) {
2695- d.PublicTLSSvc = &fakeTLSService{}
2664+ d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
26962665 })
26972666
26982667 server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
@@ -3,7 +3,6 @@ package admin
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6- "crypto/tls"
7 "encoding/json"6 "encoding/json"
8 "errors"7 "errors"
9 "fmt"8 "fmt"
@@ -2587,37 +2586,9 @@ func TestHandler_Images_Authorization(t *testing.T) {
2587 })2586 })
2588 }2587 }
2589 2588
2590-// fakeTLSService is a minimal in.PublicTLSService implementation for testing.
2591-type fakeTLSService struct {
2592- statusFunc func(ctx context.Context) domain.PublicTLSStatus
2593-}
2594-
2595-func (f *fakeTLSService) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
2596- return nil, nil
2597-}
2598-
2599-func (f *fakeTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
2600- return "", false
2601-}
2602-
2603-func (f *fakeTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
2604- if f.statusFunc != nil {
2605- return f.statusFunc(ctx)
2606- }
2607- return domain.PublicTLSStatus{}
2608-}
2609-
2610-func (f *fakeTLSService) Reconcile(ctx context.Context) error {
2611- return nil
2612-}
2613-
2614-func (f *fakeTLSService) Stop(ctx context.Context) error {
2615- return nil
2616-}
2617-
2618 func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {2589 func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
2619 handler := newTestHandler(t, func(d *HandlerDeps) {2590 handler := newTestHandler(t, func(d *HandlerDeps) {
2620- d.PublicTLSSvc = &fakeTLSService{}2591+ d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2621 })2592 })
2622 2593
2623 req := httptest.NewRequest("GET", "/admin/tls/status", nil)2594 req := httptest.NewRequest("GET", "/admin/tls/status", nil)
@@ -2632,36 +2603,34 @@ func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) {
2632 2603
2633 func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {2604 func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
2634 now := time.Now()2605 now := time.Now()
2635- fakeSvc := &fakeTLSService{2606+ tlsStatus := domain.PublicTLSStatus{
2636- statusFunc: func(ctx context.Context) domain.PublicTLSStatus {2607+ ACMEEnabled: true,
2637- return domain.PublicTLSStatus{2608+ ConfiguredMode: domain.ACMEChallengeAuto,
2638- ACMEEnabled: true,2609+ EffectiveMode: domain.ACMEChallengeHTTP01,
2639- ConfiguredMode: domain.ACMEChallengeAuto,2610+ SelectionReason: "auto-selected",
2640- EffectiveMode: domain.ACMEChallengeHTTP01,2611+ TokenSource: domain.ACMETokenSourcePass,
2641- SelectionReason: "auto-selected",2612+ Certificates: []domain.ManagedCertificate{
2642- TokenSource: domain.ACMETokenSourcePass,2613+ {
2643- Certificates: []domain.ManagedCertificate{2614+ ID: "cert-1",
2644- {2615+ Names: []string{"example.com"},
2645- ID: "cert-1",2616+ Challenge: domain.ACMEChallengeHTTP01,
2646- Names: []string{"example.com"},2617+ Status: domain.TLSCertificateStatusValid,
2647- Challenge: domain.ACMEChallengeHTTP01,2618+ NotAfter: now.Add(60 * 24 * time.Hour),
2648- Status: domain.TLSCertificateStatusValid,2619+ },
2649- NotAfter: now.Add(60 * 24 * time.Hour),2620+ },
2650- },2621+ Routes: []domain.TLSRouteCoverage{
2651- },2622+ {
2652- Routes: []domain.TLSRouteCoverage{2623+ Domain: "example.com",
2653- {2624+ Covered: true,
2654- Domain: "example.com",2625+ CoveredBy: "cert-1",
2655- Covered: true,2626+ RequiredACME: true,
2656- CoveredBy: "cert-1",2627+ },
2657- RequiredACME: true,
2658- },
2659- },
2660- }
2661 },2628 },
2662 }2629 }
2663 2630
2664 handler := newTestHandler(t, func(d *HandlerDeps) {2631 handler := newTestHandler(t, func(d *HandlerDeps) {
2632+ fakeSvc := inmocks.NewMockPublicTLSService(t)
2633+ fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus)
2665 d.PublicTLSSvc = fakeSvc2634 d.PublicTLSSvc = fakeSvc
2666 })2635 })
2667 2636
@@ -2692,7 +2661,7 @@ func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) {
2692 2661
2693 func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {2662 func TestHandler_TLSStatus_POSTReturns405(t *testing.T) {
2694 handler := newTestHandler(t, func(d *HandlerDeps) {2663 handler := newTestHandler(t, func(d *HandlerDeps) {
2695- d.PublicTLSSvc = &fakeTLSService{}2664+ d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t)
2696 })2665 })
2697 2666
2698 server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")2667 server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write")
M internal/adapters/out/acmelego/cloudflare.go +5 -4
@@ -53,9 +53,10 @@ type zoneCacheEntry struct {
5353 }
5454
5555 const (
56- defaultCacheTTL = 10 * time.Minute
57- defaultCacheMaxSize = 100
58- defaultMaxResponseSize = 1 << 20 // 1 MB
56+ defaultCacheTTL = 10 * time.Minute
57+ defaultCloudflareTimeout = 10 * time.Second
58+ defaultCacheMaxSize = 100
59+ defaultMaxResponseSize = 1 << 20 // 1 MB
5960 )
6061
6162 // CloudflareZoneResolverOption configures a CloudflareZoneResolver.
@@ -80,7 +81,7 @@ func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOptio
8081 r := &CloudflareZoneResolver{
8182 token: token,
8283 baseURL: defaultCloudflareBaseURL,
83- client: http.DefaultClient,
84+ client: &http.Client{Timeout: defaultCloudflareTimeout},
8485 cache: make(map[string]zoneCacheEntry),
8586 }
8687 for _, opt := range opts {
@@ -53,9 +53,10 @@ type zoneCacheEntry struct {
53 }53 }
54 54
55 const (55 const (
56- defaultCacheTTL = 10 * time.Minute56+ defaultCacheTTL = 10 * time.Minute
57- defaultCacheMaxSize = 10057+ defaultCloudflareTimeout = 10 * time.Second
58- defaultMaxResponseSize = 1 << 20 // 1 MB58+ defaultCacheMaxSize = 100
59+ defaultMaxResponseSize = 1 << 20 // 1 MB
59 )60 )
60 61
61 // CloudflareZoneResolverOption configures a CloudflareZoneResolver.62 // CloudflareZoneResolverOption configures a CloudflareZoneResolver.
@@ -80,7 +81,7 @@ func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOptio
80 r := &CloudflareZoneResolver{81 r := &CloudflareZoneResolver{
81 token: token,82 token: token,
82 baseURL: defaultCloudflareBaseURL,83 baseURL: defaultCloudflareBaseURL,
83- client: http.DefaultClient,84+ client: &http.Client{Timeout: defaultCloudflareTimeout},
84 cache: make(map[string]zoneCacheEntry),85 cache: make(map[string]zoneCacheEntry),
85 }86 }
86 for _, opt := range opts {87 for _, opt := range opts {
M internal/adapters/out/acmelego/http_provider.go +4 -10
@@ -1,22 +1,16 @@
11 // Package acmelego implements ACME certificate operations using the lego library.
22 package acmelego
33
4-// HTTPChallengeSink defines the storage interface for HTTP-01 challenge
5-// tokens. The usecase layer implements this to serve challenge responses
6-// via the proxy.
7-type HTTPChallengeSink interface {
8- Present(token, keyAuth string)
9- CleanUp(token string)
10-}
4+import "github.com/bnema/gordon/internal/boundaries/out"
115
12-// HTTPProvider adapts an HTTPChallengeSink to the lego challenge.Provider
6+// HTTPProvider adapts an out.HTTPChallengeSink to the lego challenge.Provider
137 // interface for HTTP-01 challenge solving.
148 type HTTPProvider struct {
15- sink HTTPChallengeSink
9+ sink out.HTTPChallengeSink
1610 }
1711
1812 // NewHTTPProvider creates a new HTTPProvider.
19-func NewHTTPProvider(sink HTTPChallengeSink) *HTTPProvider {
13+func NewHTTPProvider(sink out.HTTPChallengeSink) *HTTPProvider {
2014 return &HTTPProvider{sink: sink}
2115 }
2216
@@ -1,22 +1,16 @@
1 // Package acmelego implements ACME certificate operations using the lego library.1 // Package acmelego implements ACME certificate operations using the lego library.
2 package acmelego2 package acmelego
3 3
4-// HTTPChallengeSink defines the storage interface for HTTP-01 challenge4+import "github.com/bnema/gordon/internal/boundaries/out"
5-// tokens. The usecase layer implements this to serve challenge responses
6-// via the proxy.
7-type HTTPChallengeSink interface {
8- Present(token, keyAuth string)
9- CleanUp(token string)
10-}
11 5
12-// HTTPProvider adapts an HTTPChallengeSink to the lego challenge.Provider6+// HTTPProvider adapts an out.HTTPChallengeSink to the lego challenge.Provider
13 // interface for HTTP-01 challenge solving.7 // interface for HTTP-01 challenge solving.
14 type HTTPProvider struct {8 type HTTPProvider struct {
15- sink HTTPChallengeSink9+ sink out.HTTPChallengeSink
16 }10 }
17 11
18 // NewHTTPProvider creates a new HTTPProvider.12 // NewHTTPProvider creates a new HTTPProvider.
19-func NewHTTPProvider(sink HTTPChallengeSink) *HTTPProvider {13+func NewHTTPProvider(sink out.HTTPChallengeSink) *HTTPProvider {
20 return &HTTPProvider{sink: sink}14 return &HTTPProvider{sink: sink}
21 }15 }
22 16
M internal/adapters/out/acmelego/issuer.go +13 -8
@@ -6,7 +6,6 @@ import (
66 "crypto/tls"
77 "crypto/x509"
88 "encoding/json"
9- "errors"
109 "fmt"
1110 "net/http"
1211 "sync"
@@ -38,7 +37,7 @@ type Config struct {
3837 Store out.CertificateStore
3938
4039 // HTTPChallengeSink is used for HTTP-01 challenge token storage.
41- HTTPChallengeSink HTTPChallengeSink
40+ HTTPChallengeSink out.HTTPChallengeSink
4241
4342 // CADirectoryURL is the ACME directory URL. If empty, letsencrypt production is used.
4443 CADirectoryURL string
@@ -62,16 +61,22 @@ func NewIssuer(cfg Config) (*Issuer, error) {
6261 return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired)
6362 }
6463 if cfg.Store == nil {
65- return nil, errors.New("acmelego: certificate store is required")
64+ return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired)
6665 }
6766 if cfg.Challenge == "" {
6867 cfg.Challenge = domain.ACMEChallengeHTTP01
6968 }
70- if cfg.Challenge == domain.ACMEChallengeHTTP01 && cfg.HTTPChallengeSink == nil {
71- return nil, errors.New("acmelego: HTTP-01 challenge requires a non-nil HTTPChallengeSink")
72- }
73- if cfg.Challenge == domain.ACMEChallengeCloudflareDNS01 && cfg.Token == "" {
74- return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
69+ switch cfg.Challenge {
70+ case domain.ACMEChallengeHTTP01:
71+ if cfg.HTTPChallengeSink == nil {
72+ return nil, fmt.Errorf("acmelego: %w", domain.ErrHTTPChallengeSinkRequired)
73+ }
74+ case domain.ACMEChallengeCloudflareDNS01:
75+ if cfg.Token == "" {
76+ return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
77+ }
78+ default:
79+ return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
7580 }
7681
7782 return &Issuer{cfg: cfg}, nil
@@ -6,7 +6,6 @@ import (
6 "crypto/tls"6 "crypto/tls"
7 "crypto/x509"7 "crypto/x509"
8 "encoding/json"8 "encoding/json"
9- "errors"
10 "fmt"9 "fmt"
11 "net/http"10 "net/http"
12 "sync"11 "sync"
@@ -38,7 +37,7 @@ type Config struct {
38 Store out.CertificateStore37 Store out.CertificateStore
39 38
40 // HTTPChallengeSink is used for HTTP-01 challenge token storage.39 // HTTPChallengeSink is used for HTTP-01 challenge token storage.
41- HTTPChallengeSink HTTPChallengeSink40+ HTTPChallengeSink out.HTTPChallengeSink
42 41
43 // CADirectoryURL is the ACME directory URL. If empty, letsencrypt production is used.42 // CADirectoryURL is the ACME directory URL. If empty, letsencrypt production is used.
44 CADirectoryURL string43 CADirectoryURL string
@@ -62,16 +61,22 @@ func NewIssuer(cfg Config) (*Issuer, error) {
62 return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired)61 return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired)
63 }62 }
64 if cfg.Store == nil {63 if cfg.Store == nil {
65- return nil, errors.New("acmelego: certificate store is required")64+ return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired)
66 }65 }
67 if cfg.Challenge == "" {66 if cfg.Challenge == "" {
68 cfg.Challenge = domain.ACMEChallengeHTTP0167 cfg.Challenge = domain.ACMEChallengeHTTP01
69 }68 }
70- if cfg.Challenge == domain.ACMEChallengeHTTP01 && cfg.HTTPChallengeSink == nil {69+ switch cfg.Challenge {
71- return nil, errors.New("acmelego: HTTP-01 challenge requires a non-nil HTTPChallengeSink")70+ case domain.ACMEChallengeHTTP01:
72- }71+ if cfg.HTTPChallengeSink == nil {
73- if cfg.Challenge == domain.ACMEChallengeCloudflareDNS01 && cfg.Token == "" {72+ return nil, fmt.Errorf("acmelego: %w", domain.ErrHTTPChallengeSinkRequired)
74- return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)73+ }
74+ case domain.ACMEChallengeCloudflareDNS01:
75+ if cfg.Token == "" {
76+ return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing)
77+ }
78+ default:
79+ return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge)
75 }80 }
76 81
77 return &Issuer{cfg: cfg}, nil82 return &Issuer{cfg: cfg}, nil
M internal/adapters/out/acmelego/issuer_test.go +26 -38
@@ -1,7 +1,6 @@
11 package acmelego
22
33 import (
4- "context"
54 "crypto/ecdsa"
65 "crypto/elliptic"
76 "crypto/rand"
@@ -23,6 +22,7 @@ import (
2322 "github.com/stretchr/testify/require"
2423
2524 "github.com/bnema/gordon/internal/boundaries/out"
25+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2626 "github.com/bnema/gordon/internal/domain"
2727 )
2828
@@ -35,14 +35,35 @@ func TestNewIssuerValidatesEmail(t *testing.T) {
3535 func TestNewIssuerValidatesStore(t *testing.T) {
3636 _, err := NewIssuer(Config{Email: "test@example.com"})
3737 assert.Error(t, err)
38- assert.Contains(t, err.Error(), "certificate store is required")
38+ assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
39+}
40+
41+func TestNewIssuerValidatesHTTPChallengeSink(t *testing.T) {
42+ _, err := NewIssuer(Config{
43+ Email: "test@example.com",
44+ Challenge: domain.ACMEChallengeHTTP01,
45+ Store: outmocks.NewMockCertificateStore(t),
46+ })
47+ assert.Error(t, err)
48+ assert.ErrorIs(t, err, domain.ErrHTTPChallengeSinkRequired)
49+}
50+
51+func TestNewIssuerValidatesChallengeMode(t *testing.T) {
52+ _, err := NewIssuer(Config{
53+ Email: "test@example.com",
54+ Challenge: domain.ACMEChallengeMode("tls-alpn-01"),
55+ Store: outmocks.NewMockCertificateStore(t),
56+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
57+ })
58+ assert.Error(t, err)
59+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
3960 }
4061
4162 func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
4263 _, err := NewIssuer(Config{
4364 Email: "test@example.com",
4465 Challenge: domain.ACMEChallengeCloudflareDNS01,
45- Store: &mockStore{},
66+ Store: outmocks.NewMockCertificateStore(t),
4667 })
4768 assert.Error(t, err)
4869 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
@@ -51,47 +72,14 @@ func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
5172 func TestNewIssuerValidWithDefaults(t *testing.T) {
5273 issuer, err := NewIssuer(Config{
5374 Email: "test@example.com",
54- Store: &mockStore{},
55- HTTPChallengeSink: &mockHTTPChallengeSink{},
75+ Store: outmocks.NewMockCertificateStore(t),
76+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
5677 })
5778 require.NoError(t, err)
5879 require.NotNil(t, issuer)
5980 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
6081 }
6182
62-// mockHTTPChallengeSink implements HTTPChallengeSink for testing.
63-type mockHTTPChallengeSink struct{}
64-
65-func (m *mockHTTPChallengeSink) Present(token, keyAuth string) {}
66-func (m *mockHTTPChallengeSink) CleanUp(token string) {}
67-
68-// mockStore implements out.CertificateStore for testing.
69-type mockStore struct {
70- account *out.ACMEAccount
71- certs []out.StoredCertificate
72-}
73-
74-func (m *mockStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) {
75- return m.account, nil
76-}
77-
78-func (m *mockStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error {
79- m.account = &account
80- return nil
81-}
82-
83-func (m *mockStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) {
84- return m.certs, nil
85-}
86-
87-func (m *mockStore) Save(ctx context.Context, cert out.StoredCertificate) error {
88- return nil
89-}
90-
91-func (m *mockStore) Lock(ctx context.Context) (func() error, error) {
92- return func() error { return nil }, nil
93-}
94-
9583 func TestPrivateKeyRoundTrip(t *testing.T) {
9684 // Generate an ECDSA P-256 private key
9785 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
@@ -1,7 +1,6 @@
1 package acmelego1 package acmelego
2 2
3 import (3 import (
4- "context"
5 "crypto/ecdsa"4 "crypto/ecdsa"
6 "crypto/elliptic"5 "crypto/elliptic"
7 "crypto/rand"6 "crypto/rand"
@@ -23,6 +22,7 @@ import (
23 "github.com/stretchr/testify/require"22 "github.com/stretchr/testify/require"
24 23
25 "github.com/bnema/gordon/internal/boundaries/out"24 "github.com/bnema/gordon/internal/boundaries/out"
25+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
26 "github.com/bnema/gordon/internal/domain"26 "github.com/bnema/gordon/internal/domain"
27 )27 )
28 28
@@ -35,14 +35,35 @@ func TestNewIssuerValidatesEmail(t *testing.T) {
35 func TestNewIssuerValidatesStore(t *testing.T) {35 func TestNewIssuerValidatesStore(t *testing.T) {
36 _, err := NewIssuer(Config{Email: "test@example.com"})36 _, err := NewIssuer(Config{Email: "test@example.com"})
37 assert.Error(t, err)37 assert.Error(t, err)
38- assert.Contains(t, err.Error(), "certificate store is required")38+ assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
39+}
40+
41+func TestNewIssuerValidatesHTTPChallengeSink(t *testing.T) {
42+ _, err := NewIssuer(Config{
43+ Email: "test@example.com",
44+ Challenge: domain.ACMEChallengeHTTP01,
45+ Store: outmocks.NewMockCertificateStore(t),
46+ })
47+ assert.Error(t, err)
48+ assert.ErrorIs(t, err, domain.ErrHTTPChallengeSinkRequired)
49+}
50+
51+func TestNewIssuerValidatesChallengeMode(t *testing.T) {
52+ _, err := NewIssuer(Config{
53+ Email: "test@example.com",
54+ Challenge: domain.ACMEChallengeMode("tls-alpn-01"),
55+ Store: outmocks.NewMockCertificateStore(t),
56+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
57+ })
58+ assert.Error(t, err)
59+ assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
39 }60 }
40 61
41 func TestNewIssuerValidatesCloudflareToken(t *testing.T) {62 func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
42 _, err := NewIssuer(Config{63 _, err := NewIssuer(Config{
43 Email: "test@example.com",64 Email: "test@example.com",
44 Challenge: domain.ACMEChallengeCloudflareDNS01,65 Challenge: domain.ACMEChallengeCloudflareDNS01,
45- Store: &mockStore{},66+ Store: outmocks.NewMockCertificateStore(t),
46 })67 })
47 assert.Error(t, err)68 assert.Error(t, err)
48 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)69 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
@@ -51,47 +72,14 @@ func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
51 func TestNewIssuerValidWithDefaults(t *testing.T) {72 func TestNewIssuerValidWithDefaults(t *testing.T) {
52 issuer, err := NewIssuer(Config{73 issuer, err := NewIssuer(Config{
53 Email: "test@example.com",74 Email: "test@example.com",
54- Store: &mockStore{},75+ Store: outmocks.NewMockCertificateStore(t),
55- HTTPChallengeSink: &mockHTTPChallengeSink{},76+ HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
56 })77 })
57 require.NoError(t, err)78 require.NoError(t, err)
58 require.NotNil(t, issuer)79 require.NotNil(t, issuer)
59 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)80 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
60 }81 }
61 82
62-// mockHTTPChallengeSink implements HTTPChallengeSink for testing.
63-type mockHTTPChallengeSink struct{}
64-
65-func (m *mockHTTPChallengeSink) Present(token, keyAuth string) {}
66-func (m *mockHTTPChallengeSink) CleanUp(token string) {}
67-
68-// mockStore implements out.CertificateStore for testing.
69-type mockStore struct {
70- account *out.ACMEAccount
71- certs []out.StoredCertificate
72-}
73-
74-func (m *mockStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) {
75- return m.account, nil
76-}
77-
78-func (m *mockStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error {
79- m.account = &account
80- return nil
81-}
82-
83-func (m *mockStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) {
84- return m.certs, nil
85-}
86-
87-func (m *mockStore) Save(ctx context.Context, cert out.StoredCertificate) error {
88- return nil
89-}
90-
91-func (m *mockStore) Lock(ctx context.Context) (func() error, error) {
92- return func() error { return nil }, nil
93-}
94-
95 func TestPrivateKeyRoundTrip(t *testing.T) {83 func TestPrivateKeyRoundTrip(t *testing.T) {
96 // Generate an ECDSA P-256 private key84 // Generate an ECDSA P-256 private key
97 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)85 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
M internal/adapters/out/acmestore/store.go +46 -5
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "os"
1010 "path/filepath"
11+ "strings"
1112 "time"
1213
1314 "github.com/bnema/gordon/internal/boundaries/out"
@@ -184,11 +185,52 @@ func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
184185 return fmt.Errorf("acmestore: unsafe certificate id %q", cert.ID)
185186 }
186187
187- dir := filepath.Join(s.root, certDir, cert.ID)
188- if err := os.MkdirAll(dir, dirMode); err != nil {
189- return fmt.Errorf("acmestore: mkdir %s: %w", cert.ID, err)
188+ parent := filepath.Join(s.root, certDir)
189+ if err := os.MkdirAll(parent, dirMode); err != nil {
190+ return fmt.Errorf("acmestore: mkdir certs: %w", err)
190191 }
191192
193+ dir := filepath.Join(parent, cert.ID)
194+ tmpDir, err := os.MkdirTemp(parent, cert.ID+".tmp-*")
195+ if err != nil {
196+ return fmt.Errorf("acmestore: create temp dir: %w", err)
197+ }
198+ committed := false
199+ defer func() {
200+ if !committed {
201+ os.RemoveAll(tmpDir)
202+ }
203+ }()
204+
205+ if err := writeCertificateFiles(tmpDir, cert); err != nil {
206+ return err
207+ }
208+
209+ backupDir := dir + ".old"
210+ _ = os.RemoveAll(backupDir)
211+ hadExisting := false
212+ if err := os.Rename(dir, backupDir); err != nil {
213+ if !os.IsNotExist(err) {
214+ return fmt.Errorf("acmestore: backup existing cert dir: %w", err)
215+ }
216+ } else {
217+ hadExisting = true
218+ }
219+
220+ if err := os.Rename(tmpDir, dir); err != nil {
221+ if hadExisting {
222+ _ = os.Rename(backupDir, dir)
223+ }
224+ return fmt.Errorf("acmestore: commit cert dir: %w", err)
225+ }
226+ committed = true
227+ if hadExisting {
228+ _ = os.RemoveAll(backupDir)
229+ }
230+ return nil
231+}
232+
233+func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
192234 meta := certMetadata{
193235 ID: cert.ID,
194236 Names: cert.Names,
@@ -217,7 +259,6 @@ func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
217259 if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil {
218260 return fmt.Errorf("acmestore: write metadata: %w", err)
219261 }
220-
221262 return nil
222263 }
223264
@@ -255,7 +296,7 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
255296
256297 // safeID rejects IDs that could cause path traversal.
257298 func safeID(id string) bool {
258- return domain.SafePathComponent(id)
299+ return id != "" && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
259300 }
260301
261302 // writeAtomic writes data to path atomically by writing to a temporary file
@@ -8,6 +8,7 @@ import (
8 "fmt"8 "fmt"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11+ "strings"
11 "time"12 "time"
12 13
13 "github.com/bnema/gordon/internal/boundaries/out"14 "github.com/bnema/gordon/internal/boundaries/out"
@@ -184,11 +185,52 @@ func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
184 return fmt.Errorf("acmestore: unsafe certificate id %q", cert.ID)185 return fmt.Errorf("acmestore: unsafe certificate id %q", cert.ID)
185 }186 }
186 187
187- dir := filepath.Join(s.root, certDir, cert.ID)188+ parent := filepath.Join(s.root, certDir)
188- if err := os.MkdirAll(dir, dirMode); err != nil {189+ if err := os.MkdirAll(parent, dirMode); err != nil {
189- return fmt.Errorf("acmestore: mkdir %s: %w", cert.ID, err)190+ return fmt.Errorf("acmestore: mkdir certs: %w", err)
190 }191 }
191 192
193+ dir := filepath.Join(parent, cert.ID)
194+ tmpDir, err := os.MkdirTemp(parent, cert.ID+".tmp-*")
195+ if err != nil {
196+ return fmt.Errorf("acmestore: create temp dir: %w", err)
197+ }
198+ committed := false
199+ defer func() {
200+ if !committed {
201+ os.RemoveAll(tmpDir)
202+ }
203+ }()
204+
205+ if err := writeCertificateFiles(tmpDir, cert); err != nil {
206+ return err
207+ }
208+
209+ backupDir := dir + ".old"
210+ _ = os.RemoveAll(backupDir)
211+ hadExisting := false
212+ if err := os.Rename(dir, backupDir); err != nil {
213+ if !os.IsNotExist(err) {
214+ return fmt.Errorf("acmestore: backup existing cert dir: %w", err)
215+ }
216+ } else {
217+ hadExisting = true
218+ }
219+
220+ if err := os.Rename(tmpDir, dir); err != nil {
221+ if hadExisting {
222+ _ = os.Rename(backupDir, dir)
223+ }
224+ return fmt.Errorf("acmestore: commit cert dir: %w", err)
225+ }
226+ committed = true
227+ if hadExisting {
228+ _ = os.RemoveAll(backupDir)
229+ }
230+ return nil
231+}
232+
233+func writeCertificateFiles(dir string, cert out.StoredCertificate) error {
192 meta := certMetadata{234 meta := certMetadata{
193 ID: cert.ID,235 ID: cert.ID,
194 Names: cert.Names,236 Names: cert.Names,
@@ -217,7 +259,6 @@ func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error {
217 if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil {259 if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil {
218 return fmt.Errorf("acmestore: write metadata: %w", err)260 return fmt.Errorf("acmestore: write metadata: %w", err)
219 }261 }
220-
221 return nil262 return nil
222 }263 }
223 264
@@ -255,7 +296,7 @@ func (s *Store) Lock(ctx context.Context) (func() error, error) {
255 296
256 // safeID rejects IDs that could cause path traversal.297 // safeID rejects IDs that could cause path traversal.
257 func safeID(id string) bool {298 func safeID(id string) bool {
258- return domain.SafePathComponent(id)299+ return id != "" && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00")
259 }300 }
260 301
261 // writeAtomic writes data to path atomically by writing to a temporary file302 // writeAtomic writes data to path atomically by writing to a temporary file
M internal/app/public_tls_wiring_test.go +11 -52
@@ -1,7 +1,6 @@
11 package app
22
33 import (
4- "context"
54 "crypto/rand"
65 "crypto/rsa"
76 "crypto/tls"
@@ -14,37 +13,10 @@ import (
1413 "github.com/stretchr/testify/assert"
1514 "github.com/stretchr/testify/require"
1615
16+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1717 "github.com/bnema/gordon/internal/domain"
1818 )
1919
20-// stubPublicTLS implements in.PublicTLSService for testing the certificate selector.
21-type stubPublicTLS struct {
22- getCertFunc func(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
23-}
24-
25-func (s *stubPublicTLS) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
26- if s.getCertFunc != nil {
27- return s.getCertFunc(hello)
28- }
29- return nil, nil
30-}
31-
32-func (s *stubPublicTLS) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
33- return "", false
34-}
35-
36-func (s *stubPublicTLS) Status(ctx context.Context) domain.PublicTLSStatus {
37- return domain.PublicTLSStatus{}
38-}
39-
40-func (s *stubPublicTLS) Reconcile(ctx context.Context) error {
41- return nil
42-}
43-
44-func (s *stubPublicTLS) Stop(ctx context.Context) error {
45- return nil
46-}
47-
4820 // generateTestCert creates a self-signed certificate for the given hostnames.
4921 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
5022 t.Helper()
@@ -79,15 +51,9 @@ func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
7951 // the server name is returned before public TLS.
8052 func TestCertificateSelector_StaticCertWins(t *testing.T) {
8153 staticCert := generateTestCert(t, "static.example.com")
82- publicCert := generateTestCert(t, "static.example.com")
83-
8454 selector := &certificateSelector{
8555 staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),
86- publicTLS: &stubPublicTLS{
87- getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
88- return &publicCert, nil
89- },
90- },
56+ publicTLS: inmocks.NewMockPublicTLSService(t),
9157 }
9258
9359 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"})
@@ -100,13 +66,9 @@ func TestCertificateSelector_StaticCertWins(t *testing.T) {
10066 // TestCertificateSelector_PublicTLSErrorPropagates tests that if public TLS
10167 // returns an error (e.g. ErrTLSRouteNotCovered), the error propagates.
10268 func TestCertificateSelector_PublicTLSErrorPropagates(t *testing.T) {
103- selector := &certificateSelector{
104- publicTLS: &stubPublicTLS{
105- getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
106- return nil, domain.ErrTLSRouteNotCovered
107- },
108- },
109- }
69+ publicTLS := inmocks.NewMockPublicTLSService(t)
70+ publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
71+ selector := &certificateSelector{publicTLS: publicTLS}
11072
11173 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
11274 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered,
@@ -117,13 +79,9 @@ func TestCertificateSelector_PublicTLSErrorPropagates(t *testing.T) {
11779 // TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns
11880 // nil,nil and no local PKI, the selector returns nil,nil.
11981 func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {
120- selector := &certificateSelector{
121- publicTLS: &stubPublicTLS{
122- getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
123- return nil, nil
124- },
125- },
126- }
82+ publicTLS := inmocks.NewMockPublicTLSService(t)
83+ publicTLS.EXPECT().GetCertificateForHost("non-acme.example.com").Return(nil, nil)
84+ selector := &certificateSelector{publicTLS: publicTLS}
12785
12886 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})
12987 require.NoError(t, err)
@@ -161,9 +119,10 @@ func TestMatchingStaticCert(t *testing.T) {
161119 got = matchingStaticCert(certs, "other.example.com")
162120 assert.Nil(t, got)
163121
164- // Empty server name
122+ // Empty server name returns the first configured static certificate.
165123 got = matchingStaticCert(certs, "")
166- assert.Nil(t, got)
124+ require.NotNil(t, got)
125+ assert.Equal(t, certA.Certificate[0], got.Certificate[0])
167126
168127 // Empty cert list
169128 got = matchingStaticCert(nil, "app.example.com")
@@ -1,7 +1,6 @@
1 package app1 package app
2 2
3 import (3 import (
4- "context"
5 "crypto/rand"4 "crypto/rand"
6 "crypto/rsa"5 "crypto/rsa"
7 "crypto/tls"6 "crypto/tls"
@@ -14,37 +13,10 @@ import (
14 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
16 15
16+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
17 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
18 )18 )
19 19
20-// stubPublicTLS implements in.PublicTLSService for testing the certificate selector.
21-type stubPublicTLS struct {
22- getCertFunc func(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
23-}
24-
25-func (s *stubPublicTLS) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
26- if s.getCertFunc != nil {
27- return s.getCertFunc(hello)
28- }
29- return nil, nil
30-}
31-
32-func (s *stubPublicTLS) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
33- return "", false
34-}
35-
36-func (s *stubPublicTLS) Status(ctx context.Context) domain.PublicTLSStatus {
37- return domain.PublicTLSStatus{}
38-}
39-
40-func (s *stubPublicTLS) Reconcile(ctx context.Context) error {
41- return nil
42-}
43-
44-func (s *stubPublicTLS) Stop(ctx context.Context) error {
45- return nil
46-}
47-
48 // generateTestCert creates a self-signed certificate for the given hostnames.20 // generateTestCert creates a self-signed certificate for the given hostnames.
49 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {21 func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
50 t.Helper()22 t.Helper()
@@ -79,15 +51,9 @@ func generateTestCert(t *testing.T, hosts ...string) tls.Certificate {
79 // the server name is returned before public TLS.51 // the server name is returned before public TLS.
80 func TestCertificateSelector_StaticCertWins(t *testing.T) {52 func TestCertificateSelector_StaticCertWins(t *testing.T) {
81 staticCert := generateTestCert(t, "static.example.com")53 staticCert := generateTestCert(t, "static.example.com")
82- publicCert := generateTestCert(t, "static.example.com")
83-
84 selector := &certificateSelector{54 selector := &certificateSelector{
85 staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),55 staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}),
86- publicTLS: &stubPublicTLS{56+ publicTLS: inmocks.NewMockPublicTLSService(t),
87- getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
88- return &publicCert, nil
89- },
90- },
91 }57 }
92 58
93 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"})59 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"})
@@ -100,13 +66,9 @@ func TestCertificateSelector_StaticCertWins(t *testing.T) {
100 // TestCertificateSelector_PublicTLSErrorPropagates tests that if public TLS66 // TestCertificateSelector_PublicTLSErrorPropagates tests that if public TLS
101 // returns an error (e.g. ErrTLSRouteNotCovered), the error propagates.67 // returns an error (e.g. ErrTLSRouteNotCovered), the error propagates.
102 func TestCertificateSelector_PublicTLSErrorPropagates(t *testing.T) {68 func TestCertificateSelector_PublicTLSErrorPropagates(t *testing.T) {
103- selector := &certificateSelector{69+ publicTLS := inmocks.NewMockPublicTLSService(t)
104- publicTLS: &stubPublicTLS{70+ publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered)
105- getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {71+ selector := &certificateSelector{publicTLS: publicTLS}
106- return nil, domain.ErrTLSRouteNotCovered
107- },
108- },
109- }
110 72
111 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})73 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"})
112 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered,74 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered,
@@ -117,13 +79,9 @@ func TestCertificateSelector_PublicTLSErrorPropagates(t *testing.T) {
117 // TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns79 // TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns
118 // nil,nil and no local PKI, the selector returns nil,nil.80 // nil,nil and no local PKI, the selector returns nil,nil.
119 func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {81 func TestCertificateSelector_PublicTLSNilNil(t *testing.T) {
120- selector := &certificateSelector{82+ publicTLS := inmocks.NewMockPublicTLSService(t)
121- publicTLS: &stubPublicTLS{83+ publicTLS.EXPECT().GetCertificateForHost("non-acme.example.com").Return(nil, nil)
122- getCertFunc: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {84+ selector := &certificateSelector{publicTLS: publicTLS}
123- return nil, nil
124- },
125- },
126- }
127 85
128 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})86 got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"})
129 require.NoError(t, err)87 require.NoError(t, err)
@@ -161,9 +119,10 @@ func TestMatchingStaticCert(t *testing.T) {
161 got = matchingStaticCert(certs, "other.example.com")119 got = matchingStaticCert(certs, "other.example.com")
162 assert.Nil(t, got)120 assert.Nil(t, got)
163 121
164- // Empty server name122+ // Empty server name returns the first configured static certificate.
165 got = matchingStaticCert(certs, "")123 got = matchingStaticCert(certs, "")
166- assert.Nil(t, got)124+ require.NotNil(t, got)
125+ assert.Equal(t, certA.Certificate[0], got.Certificate[0])
167 126
168 // Empty cert list127 // Empty cert list
169 got = matchingStaticCert(nil, "app.example.com")128 got = matchingStaticCert(nil, "app.example.com")
M internal/app/run.go +6 -3
@@ -548,7 +548,7 @@ func (si *serviceInit) initPublicTLS() error {
548548 }
549549
550550 if si.cfg.Server.TLSPort == 0 {
551- return nil
551+ return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid)
552552 }
553553
554554 ctx := si.ctx
@@ -2777,7 +2777,7 @@ func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.C
27772777
27782778 // 2. Public ACME TLS.
27792779 if s.publicTLS != nil {
2780- cert, err := s.publicTLS.GetCertificate(hello)
2780+ cert, err := s.publicTLS.GetCertificateForHost(hello.ServerName)
27812781 if err != nil {
27822782 return nil, err
27832783 }
@@ -2812,7 +2812,10 @@ func prepareStaticTLSCertificates(certs []tls.Certificate) []staticTLSCertificat
28122812
28132813 func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate {
28142814 if serverName == "" {
2815- return nil
2815+ if len(certs) == 0 {
2816+ return nil
2817+ }
2818+ return &certs[0].cert
28162819 }
28172820 for i := range certs {
28182821 if certs[i].leaf == nil {
@@ -548,7 +548,7 @@ func (si *serviceInit) initPublicTLS() error {
548 }548 }
549 549
550 if si.cfg.Server.TLSPort == 0 {550 if si.cfg.Server.TLSPort == 0 {
551- return nil551+ return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid)
552 }552 }
553 553
554 ctx := si.ctx554 ctx := si.ctx
@@ -2777,7 +2777,7 @@ func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.C
2777 2777
2778 // 2. Public ACME TLS.2778 // 2. Public ACME TLS.
2779 if s.publicTLS != nil {2779 if s.publicTLS != nil {
2780- cert, err := s.publicTLS.GetCertificate(hello)2780+ cert, err := s.publicTLS.GetCertificateForHost(hello.ServerName)
2781 if err != nil {2781 if err != nil {
2782 return nil, err2782 return nil, err
2783 }2783 }
@@ -2812,7 +2812,10 @@ func prepareStaticTLSCertificates(certs []tls.Certificate) []staticTLSCertificat
2812 2812
2813 func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate {2813 func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate {
2814 if serverName == "" {2814 if serverName == "" {
2815- return nil2815+ if len(certs) == 0 {
2816+ return nil
2817+ }
2818+ return &certs[0].cert
2816 }2819 }
2817 for i := range certs {2820 for i := range certs {
2818 if certs[i].leaf == nil {2821 if certs[i].leaf == nil {
A internal/boundaries/in/mocks/mock_public_tls_service.go +321 -0
@@ -0,0 +1,321 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "crypto/tls"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockPublicTLSService creates a new instance of MockPublicTLSService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockPublicTLSService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockPublicTLSService {
21+ mock := &MockPublicTLSService{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockPublicTLSService is an autogenerated mock type for the PublicTLSService type
30+type MockPublicTLSService struct {
31+ mock.Mock
32+}
33+
34+type MockPublicTLSService_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockPublicTLSService) EXPECT() *MockPublicTLSService_Expecter {
39+ return &MockPublicTLSService_Expecter{mock: &_m.Mock}
40+}
41+
42+// GetCertificateForHost provides a mock function for the type MockPublicTLSService
43+func (_mock *MockPublicTLSService) GetCertificateForHost(host string) (*tls.Certificate, error) {
44+ ret := _mock.Called(host)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GetCertificateForHost")
48+ }
49+
50+ var r0 *tls.Certificate
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(string) (*tls.Certificate, error)); ok {
53+ return returnFunc(host)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(string) *tls.Certificate); ok {
56+ r0 = returnFunc(host)
57+ } else {
58+ if ret.Get(0) != nil {
59+ r0 = ret.Get(0).(*tls.Certificate)
60+ }
61+ }
62+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
63+ r1 = returnFunc(host)
64+ } else {
65+ r1 = ret.Error(1)
66+ }
67+ return r0, r1
68+}
69+
70+// MockPublicTLSService_GetCertificateForHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCertificateForHost'
71+type MockPublicTLSService_GetCertificateForHost_Call struct {
72+ *mock.Call
73+}
74+
75+// GetCertificateForHost is a helper method to define mock.On call
76+// - host string
77+func (_e *MockPublicTLSService_Expecter) GetCertificateForHost(host any) *MockPublicTLSService_GetCertificateForHost_Call {
78+ return &MockPublicTLSService_GetCertificateForHost_Call{Call: _e.mock.On("GetCertificateForHost", host)}
79+}
80+
81+func (_c *MockPublicTLSService_GetCertificateForHost_Call) Run(run func(host string)) *MockPublicTLSService_GetCertificateForHost_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 string
84+ if args[0] != nil {
85+ arg0 = args[0].(string)
86+ }
87+ run(
88+ arg0,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *MockPublicTLSService_GetCertificateForHost_Call) Return(certificate *tls.Certificate, err error) *MockPublicTLSService_GetCertificateForHost_Call {
95+ _c.Call.Return(certificate, err)
96+ return _c
97+}
98+
99+func (_c *MockPublicTLSService_GetCertificateForHost_Call) RunAndReturn(run func(host string) (*tls.Certificate, error)) *MockPublicTLSService_GetCertificateForHost_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
104+// GetHTTP01Challenge provides a mock function for the type MockPublicTLSService
105+func (_mock *MockPublicTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
106+ ret := _mock.Called(ctx, token)
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for GetHTTP01Challenge")
110+ }
111+
112+ var r0 string
113+ var r1 bool
114+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool)); ok {
115+ return returnFunc(ctx, token)
116+ }
117+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
118+ r0 = returnFunc(ctx, token)
119+ } else {
120+ r0 = ret.Get(0).(string)
121+ }
122+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
123+ r1 = returnFunc(ctx, token)
124+ } else {
125+ r1 = ret.Get(1).(bool)
126+ }
127+ return r0, r1
128+}
129+
130+// MockPublicTLSService_GetHTTP01Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHTTP01Challenge'
131+type MockPublicTLSService_GetHTTP01Challenge_Call struct {
132+ *mock.Call
133+}
134+
135+// GetHTTP01Challenge is a helper method to define mock.On call
136+// - ctx context.Context
137+// - token string
138+func (_e *MockPublicTLSService_Expecter) GetHTTP01Challenge(ctx any, token any) *MockPublicTLSService_GetHTTP01Challenge_Call {
139+ return &MockPublicTLSService_GetHTTP01Challenge_Call{Call: _e.mock.On("GetHTTP01Challenge", ctx, token)}
140+}
141+
142+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Run(run func(ctx context.Context, token string)) *MockPublicTLSService_GetHTTP01Challenge_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ var arg1 string
149+ if args[1] != nil {
150+ arg1 = args[1].(string)
151+ }
152+ run(
153+ arg0,
154+ arg1,
155+ )
156+ })
157+ return _c
158+}
159+
160+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Return(keyAuth string, ok bool) *MockPublicTLSService_GetHTTP01Challenge_Call {
161+ _c.Call.Return(keyAuth, ok)
162+ return _c
163+}
164+
165+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) RunAndReturn(run func(ctx context.Context, token string) (string, bool)) *MockPublicTLSService_GetHTTP01Challenge_Call {
166+ _c.Call.Return(run)
167+ return _c
168+}
169+
170+// Reconcile provides a mock function for the type MockPublicTLSService
171+func (_mock *MockPublicTLSService) Reconcile(ctx context.Context) error {
172+ ret := _mock.Called(ctx)
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for Reconcile")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
180+ r0 = returnFunc(ctx)
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockPublicTLSService_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile'
188+type MockPublicTLSService_Reconcile_Call struct {
189+ *mock.Call
190+}
191+
192+// Reconcile is a helper method to define mock.On call
193+// - ctx context.Context
194+func (_e *MockPublicTLSService_Expecter) Reconcile(ctx any) *MockPublicTLSService_Reconcile_Call {
195+ return &MockPublicTLSService_Reconcile_Call{Call: _e.mock.On("Reconcile", ctx)}
196+}
197+
198+func (_c *MockPublicTLSService_Reconcile_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Reconcile_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 context.Context
201+ if args[0] != nil {
202+ arg0 = args[0].(context.Context)
203+ }
204+ run(
205+ arg0,
206+ )
207+ })
208+ return _c
209+}
210+
211+func (_c *MockPublicTLSService_Reconcile_Call) Return(err error) *MockPublicTLSService_Reconcile_Call {
212+ _c.Call.Return(err)
213+ return _c
214+}
215+
216+func (_c *MockPublicTLSService_Reconcile_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Reconcile_Call {
217+ _c.Call.Return(run)
218+ return _c
219+}
220+
221+// Status provides a mock function for the type MockPublicTLSService
222+func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
223+ ret := _mock.Called(ctx)
224+
225+ if len(ret) == 0 {
226+ panic("no return value specified for Status")
227+ }
228+
229+ var r0 domain.PublicTLSStatus
230+ if returnFunc, ok := ret.Get(0).(func(context.Context) domain.PublicTLSStatus); ok {
231+ r0 = returnFunc(ctx)
232+ } else {
233+ r0 = ret.Get(0).(domain.PublicTLSStatus)
234+ }
235+ return r0
236+}
237+
238+// MockPublicTLSService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'
239+type MockPublicTLSService_Status_Call struct {
240+ *mock.Call
241+}
242+
243+// Status is a helper method to define mock.On call
244+// - ctx context.Context
245+func (_e *MockPublicTLSService_Expecter) Status(ctx any) *MockPublicTLSService_Status_Call {
246+ return &MockPublicTLSService_Status_Call{Call: _e.mock.On("Status", ctx)}
247+}
248+
249+func (_c *MockPublicTLSService_Status_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Status_Call {
250+ _c.Call.Run(func(args mock.Arguments) {
251+ var arg0 context.Context
252+ if args[0] != nil {
253+ arg0 = args[0].(context.Context)
254+ }
255+ run(
256+ arg0,
257+ )
258+ })
259+ return _c
260+}
261+
262+func (_c *MockPublicTLSService_Status_Call) Return(publicTLSStatus domain.PublicTLSStatus) *MockPublicTLSService_Status_Call {
263+ _c.Call.Return(publicTLSStatus)
264+ return _c
265+}
266+
267+func (_c *MockPublicTLSService_Status_Call) RunAndReturn(run func(ctx context.Context) domain.PublicTLSStatus) *MockPublicTLSService_Status_Call {
268+ _c.Call.Return(run)
269+ return _c
270+}
271+
272+// Stop provides a mock function for the type MockPublicTLSService
273+func (_mock *MockPublicTLSService) Stop(ctx context.Context) error {
274+ ret := _mock.Called(ctx)
275+
276+ if len(ret) == 0 {
277+ panic("no return value specified for Stop")
278+ }
279+
280+ var r0 error
281+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
282+ r0 = returnFunc(ctx)
283+ } else {
284+ r0 = ret.Error(0)
285+ }
286+ return r0
287+}
288+
289+// MockPublicTLSService_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
290+type MockPublicTLSService_Stop_Call struct {
291+ *mock.Call
292+}
293+
294+// Stop is a helper method to define mock.On call
295+// - ctx context.Context
296+func (_e *MockPublicTLSService_Expecter) Stop(ctx any) *MockPublicTLSService_Stop_Call {
297+ return &MockPublicTLSService_Stop_Call{Call: _e.mock.On("Stop", ctx)}
298+}
299+
300+func (_c *MockPublicTLSService_Stop_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Stop_Call {
301+ _c.Call.Run(func(args mock.Arguments) {
302+ var arg0 context.Context
303+ if args[0] != nil {
304+ arg0 = args[0].(context.Context)
305+ }
306+ run(
307+ arg0,
308+ )
309+ })
310+ return _c
311+}
312+
313+func (_c *MockPublicTLSService_Stop_Call) Return(err error) *MockPublicTLSService_Stop_Call {
314+ _c.Call.Return(err)
315+ return _c
316+}
317+
318+func (_c *MockPublicTLSService_Stop_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Stop_Call {
319+ _c.Call.Return(run)
320+ return _c
321+}
@@ -0,0 +1,321 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+ "crypto/tls"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockPublicTLSService creates a new instance of MockPublicTLSService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockPublicTLSService(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockPublicTLSService {
21+ mock := &MockPublicTLSService{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockPublicTLSService is an autogenerated mock type for the PublicTLSService type
30+type MockPublicTLSService struct {
31+ mock.Mock
32+}
33+
34+type MockPublicTLSService_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockPublicTLSService) EXPECT() *MockPublicTLSService_Expecter {
39+ return &MockPublicTLSService_Expecter{mock: &_m.Mock}
40+}
41+
42+// GetCertificateForHost provides a mock function for the type MockPublicTLSService
43+func (_mock *MockPublicTLSService) GetCertificateForHost(host string) (*tls.Certificate, error) {
44+ ret := _mock.Called(host)
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for GetCertificateForHost")
48+ }
49+
50+ var r0 *tls.Certificate
51+ var r1 error
52+ if returnFunc, ok := ret.Get(0).(func(string) (*tls.Certificate, error)); ok {
53+ return returnFunc(host)
54+ }
55+ if returnFunc, ok := ret.Get(0).(func(string) *tls.Certificate); ok {
56+ r0 = returnFunc(host)
57+ } else {
58+ if ret.Get(0) != nil {
59+ r0 = ret.Get(0).(*tls.Certificate)
60+ }
61+ }
62+ if returnFunc, ok := ret.Get(1).(func(string) error); ok {
63+ r1 = returnFunc(host)
64+ } else {
65+ r1 = ret.Error(1)
66+ }
67+ return r0, r1
68+}
69+
70+// MockPublicTLSService_GetCertificateForHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCertificateForHost'
71+type MockPublicTLSService_GetCertificateForHost_Call struct {
72+ *mock.Call
73+}
74+
75+// GetCertificateForHost is a helper method to define mock.On call
76+// - host string
77+func (_e *MockPublicTLSService_Expecter) GetCertificateForHost(host any) *MockPublicTLSService_GetCertificateForHost_Call {
78+ return &MockPublicTLSService_GetCertificateForHost_Call{Call: _e.mock.On("GetCertificateForHost", host)}
79+}
80+
81+func (_c *MockPublicTLSService_GetCertificateForHost_Call) Run(run func(host string)) *MockPublicTLSService_GetCertificateForHost_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 string
84+ if args[0] != nil {
85+ arg0 = args[0].(string)
86+ }
87+ run(
88+ arg0,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *MockPublicTLSService_GetCertificateForHost_Call) Return(certificate *tls.Certificate, err error) *MockPublicTLSService_GetCertificateForHost_Call {
95+ _c.Call.Return(certificate, err)
96+ return _c
97+}
98+
99+func (_c *MockPublicTLSService_GetCertificateForHost_Call) RunAndReturn(run func(host string) (*tls.Certificate, error)) *MockPublicTLSService_GetCertificateForHost_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
103+
104+// GetHTTP01Challenge provides a mock function for the type MockPublicTLSService
105+func (_mock *MockPublicTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) {
106+ ret := _mock.Called(ctx, token)
107+
108+ if len(ret) == 0 {
109+ panic("no return value specified for GetHTTP01Challenge")
110+ }
111+
112+ var r0 string
113+ var r1 bool
114+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool)); ok {
115+ return returnFunc(ctx, token)
116+ }
117+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok {
118+ r0 = returnFunc(ctx, token)
119+ } else {
120+ r0 = ret.Get(0).(string)
121+ }
122+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok {
123+ r1 = returnFunc(ctx, token)
124+ } else {
125+ r1 = ret.Get(1).(bool)
126+ }
127+ return r0, r1
128+}
129+
130+// MockPublicTLSService_GetHTTP01Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHTTP01Challenge'
131+type MockPublicTLSService_GetHTTP01Challenge_Call struct {
132+ *mock.Call
133+}
134+
135+// GetHTTP01Challenge is a helper method to define mock.On call
136+// - ctx context.Context
137+// - token string
138+func (_e *MockPublicTLSService_Expecter) GetHTTP01Challenge(ctx any, token any) *MockPublicTLSService_GetHTTP01Challenge_Call {
139+ return &MockPublicTLSService_GetHTTP01Challenge_Call{Call: _e.mock.On("GetHTTP01Challenge", ctx, token)}
140+}
141+
142+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Run(run func(ctx context.Context, token string)) *MockPublicTLSService_GetHTTP01Challenge_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ var arg1 string
149+ if args[1] != nil {
150+ arg1 = args[1].(string)
151+ }
152+ run(
153+ arg0,
154+ arg1,
155+ )
156+ })
157+ return _c
158+}
159+
160+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Return(keyAuth string, ok bool) *MockPublicTLSService_GetHTTP01Challenge_Call {
161+ _c.Call.Return(keyAuth, ok)
162+ return _c
163+}
164+
165+func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) RunAndReturn(run func(ctx context.Context, token string) (string, bool)) *MockPublicTLSService_GetHTTP01Challenge_Call {
166+ _c.Call.Return(run)
167+ return _c
168+}
169+
170+// Reconcile provides a mock function for the type MockPublicTLSService
171+func (_mock *MockPublicTLSService) Reconcile(ctx context.Context) error {
172+ ret := _mock.Called(ctx)
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for Reconcile")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
180+ r0 = returnFunc(ctx)
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockPublicTLSService_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile'
188+type MockPublicTLSService_Reconcile_Call struct {
189+ *mock.Call
190+}
191+
192+// Reconcile is a helper method to define mock.On call
193+// - ctx context.Context
194+func (_e *MockPublicTLSService_Expecter) Reconcile(ctx any) *MockPublicTLSService_Reconcile_Call {
195+ return &MockPublicTLSService_Reconcile_Call{Call: _e.mock.On("Reconcile", ctx)}
196+}
197+
198+func (_c *MockPublicTLSService_Reconcile_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Reconcile_Call {
199+ _c.Call.Run(func(args mock.Arguments) {
200+ var arg0 context.Context
201+ if args[0] != nil {
202+ arg0 = args[0].(context.Context)
203+ }
204+ run(
205+ arg0,
206+ )
207+ })
208+ return _c
209+}
210+
211+func (_c *MockPublicTLSService_Reconcile_Call) Return(err error) *MockPublicTLSService_Reconcile_Call {
212+ _c.Call.Return(err)
213+ return _c
214+}
215+
216+func (_c *MockPublicTLSService_Reconcile_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Reconcile_Call {
217+ _c.Call.Return(run)
218+ return _c
219+}
220+
221+// Status provides a mock function for the type MockPublicTLSService
222+func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus {
223+ ret := _mock.Called(ctx)
224+
225+ if len(ret) == 0 {
226+ panic("no return value specified for Status")
227+ }
228+
229+ var r0 domain.PublicTLSStatus
230+ if returnFunc, ok := ret.Get(0).(func(context.Context) domain.PublicTLSStatus); ok {
231+ r0 = returnFunc(ctx)
232+ } else {
233+ r0 = ret.Get(0).(domain.PublicTLSStatus)
234+ }
235+ return r0
236+}
237+
238+// MockPublicTLSService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status'
239+type MockPublicTLSService_Status_Call struct {
240+ *mock.Call
241+}
242+
243+// Status is a helper method to define mock.On call
244+// - ctx context.Context
245+func (_e *MockPublicTLSService_Expecter) Status(ctx any) *MockPublicTLSService_Status_Call {
246+ return &MockPublicTLSService_Status_Call{Call: _e.mock.On("Status", ctx)}
247+}
248+
249+func (_c *MockPublicTLSService_Status_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Status_Call {
250+ _c.Call.Run(func(args mock.Arguments) {
251+ var arg0 context.Context
252+ if args[0] != nil {
253+ arg0 = args[0].(context.Context)
254+ }
255+ run(
256+ arg0,
257+ )
258+ })
259+ return _c
260+}
261+
262+func (_c *MockPublicTLSService_Status_Call) Return(publicTLSStatus domain.PublicTLSStatus) *MockPublicTLSService_Status_Call {
263+ _c.Call.Return(publicTLSStatus)
264+ return _c
265+}
266+
267+func (_c *MockPublicTLSService_Status_Call) RunAndReturn(run func(ctx context.Context) domain.PublicTLSStatus) *MockPublicTLSService_Status_Call {
268+ _c.Call.Return(run)
269+ return _c
270+}
271+
272+// Stop provides a mock function for the type MockPublicTLSService
273+func (_mock *MockPublicTLSService) Stop(ctx context.Context) error {
274+ ret := _mock.Called(ctx)
275+
276+ if len(ret) == 0 {
277+ panic("no return value specified for Stop")
278+ }
279+
280+ var r0 error
281+ if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok {
282+ r0 = returnFunc(ctx)
283+ } else {
284+ r0 = ret.Error(0)
285+ }
286+ return r0
287+}
288+
289+// MockPublicTLSService_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
290+type MockPublicTLSService_Stop_Call struct {
291+ *mock.Call
292+}
293+
294+// Stop is a helper method to define mock.On call
295+// - ctx context.Context
296+func (_e *MockPublicTLSService_Expecter) Stop(ctx any) *MockPublicTLSService_Stop_Call {
297+ return &MockPublicTLSService_Stop_Call{Call: _e.mock.On("Stop", ctx)}
298+}
299+
300+func (_c *MockPublicTLSService_Stop_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Stop_Call {
301+ _c.Call.Run(func(args mock.Arguments) {
302+ var arg0 context.Context
303+ if args[0] != nil {
304+ arg0 = args[0].(context.Context)
305+ }
306+ run(
307+ arg0,
308+ )
309+ })
310+ return _c
311+}
312+
313+func (_c *MockPublicTLSService_Stop_Call) Return(err error) *MockPublicTLSService_Stop_Call {
314+ _c.Call.Return(err)
315+ return _c
316+}
317+
318+func (_c *MockPublicTLSService_Stop_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Stop_Call {
319+ _c.Call.Return(run)
320+ return _c
321+}
M internal/boundaries/in/public_tls.go +2 -2
@@ -12,8 +12,8 @@ import (
1212
1313 // PublicTLSService defines the contract for public TLS / ACME certificate management.
1414 type PublicTLSService interface {
15- // GetCertificate returns a TLS certificate for the given ClientHello.
16- GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)
15+ // GetCertificateForHost returns a TLS certificate for the given SNI host.
16+ GetCertificateForHost(host string) (*tls.Certificate, error)
1717
1818 // GetHTTP01Challenge returns the key authorization for an HTTP-01 challenge token.
1919 GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool)
@@ -12,8 +12,8 @@ import (
12 12
13 // PublicTLSService defines the contract for public TLS / ACME certificate management.13 // PublicTLSService defines the contract for public TLS / ACME certificate management.
14 type PublicTLSService interface {14 type PublicTLSService interface {
15- // GetCertificate returns a TLS certificate for the given ClientHello.15+ // GetCertificateForHost returns a TLS certificate for the given SNI host.
16- GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error)16+ GetCertificateForHost(host string) (*tls.Certificate, error)
17 17
18 // GetHTTP01Challenge returns the key authorization for an HTTP-01 challenge token.18 // GetHTTP01Challenge returns the key authorization for an HTTP-01 challenge token.
19 GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool)19 GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool)
A internal/boundaries/out/mocks/mock_certificate_store.go +339 -0
@@ -0,0 +1,339 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockCertificateStore creates a new instance of MockCertificateStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockCertificateStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockCertificateStore {
20+ mock := &MockCertificateStore{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockCertificateStore is an autogenerated mock type for the CertificateStore type
29+type MockCertificateStore struct {
30+ mock.Mock
31+}
32+
33+type MockCertificateStore_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockCertificateStore) EXPECT() *MockCertificateStore_Expecter {
38+ return &MockCertificateStore_Expecter{mock: &_m.Mock}
39+}
40+
41+// LoadAccount provides a mock function for the type MockCertificateStore
42+func (_mock *MockCertificateStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for LoadAccount")
47+ }
48+
49+ var r0 *out.ACMEAccount
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*out.ACMEAccount, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) *out.ACMEAccount); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(*out.ACMEAccount)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
62+ r1 = returnFunc(ctx)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockCertificateStore_LoadAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAccount'
70+type MockCertificateStore_LoadAccount_Call struct {
71+ *mock.Call
72+}
73+
74+// LoadAccount is a helper method to define mock.On call
75+// - ctx context.Context
76+func (_e *MockCertificateStore_Expecter) LoadAccount(ctx any) *MockCertificateStore_LoadAccount_Call {
77+ return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}
78+}
79+
80+func (_c *MockCertificateStore_LoadAccount_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAccount_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ run(
87+ arg0,
88+ )
89+ })
90+ return _c
91+}
92+
93+func (_c *MockCertificateStore_LoadAccount_Call) Return(aCMEAccount *out.ACMEAccount, err error) *MockCertificateStore_LoadAccount_Call {
94+ _c.Call.Return(aCMEAccount, err)
95+ return _c
96+}
97+
98+func (_c *MockCertificateStore_LoadAccount_Call) RunAndReturn(run func(ctx context.Context) (*out.ACMEAccount, error)) *MockCertificateStore_LoadAccount_Call {
99+ _c.Call.Return(run)
100+ return _c
101+}
102+
103+// LoadAll provides a mock function for the type MockCertificateStore
104+func (_mock *MockCertificateStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) {
105+ ret := _mock.Called(ctx)
106+
107+ if len(ret) == 0 {
108+ panic("no return value specified for LoadAll")
109+ }
110+
111+ var r0 []out.StoredCertificate
112+ var r1 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]out.StoredCertificate, error)); ok {
114+ return returnFunc(ctx)
115+ }
116+ if returnFunc, ok := ret.Get(0).(func(context.Context) []out.StoredCertificate); ok {
117+ r0 = returnFunc(ctx)
118+ } else {
119+ if ret.Get(0) != nil {
120+ r0 = ret.Get(0).([]out.StoredCertificate)
121+ }
122+ }
123+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
124+ r1 = returnFunc(ctx)
125+ } else {
126+ r1 = ret.Error(1)
127+ }
128+ return r0, r1
129+}
130+
131+// MockCertificateStore_LoadAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAll'
132+type MockCertificateStore_LoadAll_Call struct {
133+ *mock.Call
134+}
135+
136+// LoadAll is a helper method to define mock.On call
137+// - ctx context.Context
138+func (_e *MockCertificateStore_Expecter) LoadAll(ctx any) *MockCertificateStore_LoadAll_Call {
139+ return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}
140+}
141+
142+func (_c *MockCertificateStore_LoadAll_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAll_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ run(
149+ arg0,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *MockCertificateStore_LoadAll_Call) Return(storedCertificates []out.StoredCertificate, err error) *MockCertificateStore_LoadAll_Call {
156+ _c.Call.Return(storedCertificates, err)
157+ return _c
158+}
159+
160+func (_c *MockCertificateStore_LoadAll_Call) RunAndReturn(run func(ctx context.Context) ([]out.StoredCertificate, error)) *MockCertificateStore_LoadAll_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Lock provides a mock function for the type MockCertificateStore
166+func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) {
167+ ret := _mock.Called(ctx)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Lock")
171+ }
172+
173+ var r0 func() error
174+ var r1 error
175+ if returnFunc, ok := ret.Get(0).(func(context.Context) (func() error, error)); ok {
176+ return returnFunc(ctx)
177+ }
178+ if returnFunc, ok := ret.Get(0).(func(context.Context) func() error); ok {
179+ r0 = returnFunc(ctx)
180+ } else {
181+ if ret.Get(0) != nil {
182+ r0 = ret.Get(0).(func() error)
183+ }
184+ }
185+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
186+ r1 = returnFunc(ctx)
187+ } else {
188+ r1 = ret.Error(1)
189+ }
190+ return r0, r1
191+}
192+
193+// MockCertificateStore_Lock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lock'
194+type MockCertificateStore_Lock_Call struct {
195+ *mock.Call
196+}
197+
198+// Lock is a helper method to define mock.On call
199+// - ctx context.Context
200+func (_e *MockCertificateStore_Expecter) Lock(ctx any) *MockCertificateStore_Lock_Call {
201+ return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}
202+}
203+
204+func (_c *MockCertificateStore_Lock_Call) Run(run func(ctx context.Context)) *MockCertificateStore_Lock_Call {
205+ _c.Call.Run(func(args mock.Arguments) {
206+ var arg0 context.Context
207+ if args[0] != nil {
208+ arg0 = args[0].(context.Context)
209+ }
210+ run(
211+ arg0,
212+ )
213+ })
214+ return _c
215+}
216+
217+func (_c *MockCertificateStore_Lock_Call) Return(unlock func() error, err error) *MockCertificateStore_Lock_Call {
218+ _c.Call.Return(unlock, err)
219+ return _c
220+}
221+
222+func (_c *MockCertificateStore_Lock_Call) RunAndReturn(run func(ctx context.Context) (func() error, error)) *MockCertificateStore_Lock_Call {
223+ _c.Call.Return(run)
224+ return _c
225+}
226+
227+// Save provides a mock function for the type MockCertificateStore
228+func (_mock *MockCertificateStore) Save(ctx context.Context, cert out.StoredCertificate) error {
229+ ret := _mock.Called(ctx, cert)
230+
231+ if len(ret) == 0 {
232+ panic("no return value specified for Save")
233+ }
234+
235+ var r0 error
236+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) error); ok {
237+ r0 = returnFunc(ctx, cert)
238+ } else {
239+ r0 = ret.Error(0)
240+ }
241+ return r0
242+}
243+
244+// MockCertificateStore_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
245+type MockCertificateStore_Save_Call struct {
246+ *mock.Call
247+}
248+
249+// Save is a helper method to define mock.On call
250+// - ctx context.Context
251+// - cert out.StoredCertificate
252+func (_e *MockCertificateStore_Expecter) Save(ctx any, cert any) *MockCertificateStore_Save_Call {
253+ return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}
254+}
255+
256+func (_c *MockCertificateStore_Save_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockCertificateStore_Save_Call {
257+ _c.Call.Run(func(args mock.Arguments) {
258+ var arg0 context.Context
259+ if args[0] != nil {
260+ arg0 = args[0].(context.Context)
261+ }
262+ var arg1 out.StoredCertificate
263+ if args[1] != nil {
264+ arg1 = args[1].(out.StoredCertificate)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ )
270+ })
271+ return _c
272+}
273+
274+func (_c *MockCertificateStore_Save_Call) Return(err error) *MockCertificateStore_Save_Call {
275+ _c.Call.Return(err)
276+ return _c
277+}
278+
279+func (_c *MockCertificateStore_Save_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) error) *MockCertificateStore_Save_Call {
280+ _c.Call.Return(run)
281+ return _c
282+}
283+
284+// SaveAccount provides a mock function for the type MockCertificateStore
285+func (_mock *MockCertificateStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error {
286+ ret := _mock.Called(ctx, account)
287+
288+ if len(ret) == 0 {
289+ panic("no return value specified for SaveAccount")
290+ }
291+
292+ var r0 error
293+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.ACMEAccount) error); ok {
294+ r0 = returnFunc(ctx, account)
295+ } else {
296+ r0 = ret.Error(0)
297+ }
298+ return r0
299+}
300+
301+// MockCertificateStore_SaveAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveAccount'
302+type MockCertificateStore_SaveAccount_Call struct {
303+ *mock.Call
304+}
305+
306+// SaveAccount is a helper method to define mock.On call
307+// - ctx context.Context
308+// - account out.ACMEAccount
309+func (_e *MockCertificateStore_Expecter) SaveAccount(ctx any, account any) *MockCertificateStore_SaveAccount_Call {
310+ return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}
311+}
312+
313+func (_c *MockCertificateStore_SaveAccount_Call) Run(run func(ctx context.Context, account out.ACMEAccount)) *MockCertificateStore_SaveAccount_Call {
314+ _c.Call.Run(func(args mock.Arguments) {
315+ var arg0 context.Context
316+ if args[0] != nil {
317+ arg0 = args[0].(context.Context)
318+ }
319+ var arg1 out.ACMEAccount
320+ if args[1] != nil {
321+ arg1 = args[1].(out.ACMEAccount)
322+ }
323+ run(
324+ arg0,
325+ arg1,
326+ )
327+ })
328+ return _c
329+}
330+
331+func (_c *MockCertificateStore_SaveAccount_Call) Return(err error) *MockCertificateStore_SaveAccount_Call {
332+ _c.Call.Return(err)
333+ return _c
334+}
335+
336+func (_c *MockCertificateStore_SaveAccount_Call) RunAndReturn(run func(ctx context.Context, account out.ACMEAccount) error) *MockCertificateStore_SaveAccount_Call {
337+ _c.Call.Return(run)
338+ return _c
339+}
@@ -0,0 +1,339 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockCertificateStore creates a new instance of MockCertificateStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockCertificateStore(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockCertificateStore {
20+ mock := &MockCertificateStore{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockCertificateStore is an autogenerated mock type for the CertificateStore type
29+type MockCertificateStore struct {
30+ mock.Mock
31+}
32+
33+type MockCertificateStore_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockCertificateStore) EXPECT() *MockCertificateStore_Expecter {
38+ return &MockCertificateStore_Expecter{mock: &_m.Mock}
39+}
40+
41+// LoadAccount provides a mock function for the type MockCertificateStore
42+func (_mock *MockCertificateStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for LoadAccount")
47+ }
48+
49+ var r0 *out.ACMEAccount
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*out.ACMEAccount, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) *out.ACMEAccount); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(*out.ACMEAccount)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
62+ r1 = returnFunc(ctx)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockCertificateStore_LoadAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAccount'
70+type MockCertificateStore_LoadAccount_Call struct {
71+ *mock.Call
72+}
73+
74+// LoadAccount is a helper method to define mock.On call
75+// - ctx context.Context
76+func (_e *MockCertificateStore_Expecter) LoadAccount(ctx any) *MockCertificateStore_LoadAccount_Call {
77+ return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}
78+}
79+
80+func (_c *MockCertificateStore_LoadAccount_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAccount_Call {
81+ _c.Call.Run(func(args mock.Arguments) {
82+ var arg0 context.Context
83+ if args[0] != nil {
84+ arg0 = args[0].(context.Context)
85+ }
86+ run(
87+ arg0,
88+ )
89+ })
90+ return _c
91+}
92+
93+func (_c *MockCertificateStore_LoadAccount_Call) Return(aCMEAccount *out.ACMEAccount, err error) *MockCertificateStore_LoadAccount_Call {
94+ _c.Call.Return(aCMEAccount, err)
95+ return _c
96+}
97+
98+func (_c *MockCertificateStore_LoadAccount_Call) RunAndReturn(run func(ctx context.Context) (*out.ACMEAccount, error)) *MockCertificateStore_LoadAccount_Call {
99+ _c.Call.Return(run)
100+ return _c
101+}
102+
103+// LoadAll provides a mock function for the type MockCertificateStore
104+func (_mock *MockCertificateStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) {
105+ ret := _mock.Called(ctx)
106+
107+ if len(ret) == 0 {
108+ panic("no return value specified for LoadAll")
109+ }
110+
111+ var r0 []out.StoredCertificate
112+ var r1 error
113+ if returnFunc, ok := ret.Get(0).(func(context.Context) ([]out.StoredCertificate, error)); ok {
114+ return returnFunc(ctx)
115+ }
116+ if returnFunc, ok := ret.Get(0).(func(context.Context) []out.StoredCertificate); ok {
117+ r0 = returnFunc(ctx)
118+ } else {
119+ if ret.Get(0) != nil {
120+ r0 = ret.Get(0).([]out.StoredCertificate)
121+ }
122+ }
123+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
124+ r1 = returnFunc(ctx)
125+ } else {
126+ r1 = ret.Error(1)
127+ }
128+ return r0, r1
129+}
130+
131+// MockCertificateStore_LoadAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAll'
132+type MockCertificateStore_LoadAll_Call struct {
133+ *mock.Call
134+}
135+
136+// LoadAll is a helper method to define mock.On call
137+// - ctx context.Context
138+func (_e *MockCertificateStore_Expecter) LoadAll(ctx any) *MockCertificateStore_LoadAll_Call {
139+ return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}
140+}
141+
142+func (_c *MockCertificateStore_LoadAll_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAll_Call {
143+ _c.Call.Run(func(args mock.Arguments) {
144+ var arg0 context.Context
145+ if args[0] != nil {
146+ arg0 = args[0].(context.Context)
147+ }
148+ run(
149+ arg0,
150+ )
151+ })
152+ return _c
153+}
154+
155+func (_c *MockCertificateStore_LoadAll_Call) Return(storedCertificates []out.StoredCertificate, err error) *MockCertificateStore_LoadAll_Call {
156+ _c.Call.Return(storedCertificates, err)
157+ return _c
158+}
159+
160+func (_c *MockCertificateStore_LoadAll_Call) RunAndReturn(run func(ctx context.Context) ([]out.StoredCertificate, error)) *MockCertificateStore_LoadAll_Call {
161+ _c.Call.Return(run)
162+ return _c
163+}
164+
165+// Lock provides a mock function for the type MockCertificateStore
166+func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) {
167+ ret := _mock.Called(ctx)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for Lock")
171+ }
172+
173+ var r0 func() error
174+ var r1 error
175+ if returnFunc, ok := ret.Get(0).(func(context.Context) (func() error, error)); ok {
176+ return returnFunc(ctx)
177+ }
178+ if returnFunc, ok := ret.Get(0).(func(context.Context) func() error); ok {
179+ r0 = returnFunc(ctx)
180+ } else {
181+ if ret.Get(0) != nil {
182+ r0 = ret.Get(0).(func() error)
183+ }
184+ }
185+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
186+ r1 = returnFunc(ctx)
187+ } else {
188+ r1 = ret.Error(1)
189+ }
190+ return r0, r1
191+}
192+
193+// MockCertificateStore_Lock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lock'
194+type MockCertificateStore_Lock_Call struct {
195+ *mock.Call
196+}
197+
198+// Lock is a helper method to define mock.On call
199+// - ctx context.Context
200+func (_e *MockCertificateStore_Expecter) Lock(ctx any) *MockCertificateStore_Lock_Call {
201+ return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}
202+}
203+
204+func (_c *MockCertificateStore_Lock_Call) Run(run func(ctx context.Context)) *MockCertificateStore_Lock_Call {
205+ _c.Call.Run(func(args mock.Arguments) {
206+ var arg0 context.Context
207+ if args[0] != nil {
208+ arg0 = args[0].(context.Context)
209+ }
210+ run(
211+ arg0,
212+ )
213+ })
214+ return _c
215+}
216+
217+func (_c *MockCertificateStore_Lock_Call) Return(unlock func() error, err error) *MockCertificateStore_Lock_Call {
218+ _c.Call.Return(unlock, err)
219+ return _c
220+}
221+
222+func (_c *MockCertificateStore_Lock_Call) RunAndReturn(run func(ctx context.Context) (func() error, error)) *MockCertificateStore_Lock_Call {
223+ _c.Call.Return(run)
224+ return _c
225+}
226+
227+// Save provides a mock function for the type MockCertificateStore
228+func (_mock *MockCertificateStore) Save(ctx context.Context, cert out.StoredCertificate) error {
229+ ret := _mock.Called(ctx, cert)
230+
231+ if len(ret) == 0 {
232+ panic("no return value specified for Save")
233+ }
234+
235+ var r0 error
236+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) error); ok {
237+ r0 = returnFunc(ctx, cert)
238+ } else {
239+ r0 = ret.Error(0)
240+ }
241+ return r0
242+}
243+
244+// MockCertificateStore_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save'
245+type MockCertificateStore_Save_Call struct {
246+ *mock.Call
247+}
248+
249+// Save is a helper method to define mock.On call
250+// - ctx context.Context
251+// - cert out.StoredCertificate
252+func (_e *MockCertificateStore_Expecter) Save(ctx any, cert any) *MockCertificateStore_Save_Call {
253+ return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}
254+}
255+
256+func (_c *MockCertificateStore_Save_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockCertificateStore_Save_Call {
257+ _c.Call.Run(func(args mock.Arguments) {
258+ var arg0 context.Context
259+ if args[0] != nil {
260+ arg0 = args[0].(context.Context)
261+ }
262+ var arg1 out.StoredCertificate
263+ if args[1] != nil {
264+ arg1 = args[1].(out.StoredCertificate)
265+ }
266+ run(
267+ arg0,
268+ arg1,
269+ )
270+ })
271+ return _c
272+}
273+
274+func (_c *MockCertificateStore_Save_Call) Return(err error) *MockCertificateStore_Save_Call {
275+ _c.Call.Return(err)
276+ return _c
277+}
278+
279+func (_c *MockCertificateStore_Save_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) error) *MockCertificateStore_Save_Call {
280+ _c.Call.Return(run)
281+ return _c
282+}
283+
284+// SaveAccount provides a mock function for the type MockCertificateStore
285+func (_mock *MockCertificateStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error {
286+ ret := _mock.Called(ctx, account)
287+
288+ if len(ret) == 0 {
289+ panic("no return value specified for SaveAccount")
290+ }
291+
292+ var r0 error
293+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.ACMEAccount) error); ok {
294+ r0 = returnFunc(ctx, account)
295+ } else {
296+ r0 = ret.Error(0)
297+ }
298+ return r0
299+}
300+
301+// MockCertificateStore_SaveAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveAccount'
302+type MockCertificateStore_SaveAccount_Call struct {
303+ *mock.Call
304+}
305+
306+// SaveAccount is a helper method to define mock.On call
307+// - ctx context.Context
308+// - account out.ACMEAccount
309+func (_e *MockCertificateStore_Expecter) SaveAccount(ctx any, account any) *MockCertificateStore_SaveAccount_Call {
310+ return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}
311+}
312+
313+func (_c *MockCertificateStore_SaveAccount_Call) Run(run func(ctx context.Context, account out.ACMEAccount)) *MockCertificateStore_SaveAccount_Call {
314+ _c.Call.Run(func(args mock.Arguments) {
315+ var arg0 context.Context
316+ if args[0] != nil {
317+ arg0 = args[0].(context.Context)
318+ }
319+ var arg1 out.ACMEAccount
320+ if args[1] != nil {
321+ arg1 = args[1].(out.ACMEAccount)
322+ }
323+ run(
324+ arg0,
325+ arg1,
326+ )
327+ })
328+ return _c
329+}
330+
331+func (_c *MockCertificateStore_SaveAccount_Call) Return(err error) *MockCertificateStore_SaveAccount_Call {
332+ _c.Call.Return(err)
333+ return _c
334+}
335+
336+func (_c *MockCertificateStore_SaveAccount_Call) RunAndReturn(run func(ctx context.Context, account out.ACMEAccount) error) *MockCertificateStore_SaveAccount_Call {
337+ _c.Call.Return(run)
338+ return _c
339+}
A internal/boundaries/out/mocks/mock_cloudflare_zone_resolver.go +105 -0
@@ -0,0 +1,105 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockCloudflareZoneResolver creates a new instance of MockCloudflareZoneResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockCloudflareZoneResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockCloudflareZoneResolver {
20+ mock := &MockCloudflareZoneResolver{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockCloudflareZoneResolver is an autogenerated mock type for the CloudflareZoneResolver type
29+type MockCloudflareZoneResolver struct {
30+ mock.Mock
31+}
32+
33+type MockCloudflareZoneResolver_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockCloudflareZoneResolver) EXPECT() *MockCloudflareZoneResolver_Expecter {
38+ return &MockCloudflareZoneResolver_Expecter{mock: &_m.Mock}
39+}
40+
41+// FindZone provides a mock function for the type MockCloudflareZoneResolver
42+func (_mock *MockCloudflareZoneResolver) FindZone(ctx context.Context, domain string) (out.CloudflareZone, error) {
43+ ret := _mock.Called(ctx, domain)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for FindZone")
47+ }
48+
49+ var r0 out.CloudflareZone
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (out.CloudflareZone, error)); ok {
52+ return returnFunc(ctx, domain)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) out.CloudflareZone); ok {
55+ r0 = returnFunc(ctx, domain)
56+ } else {
57+ r0 = ret.Get(0).(out.CloudflareZone)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
60+ r1 = returnFunc(ctx, domain)
61+ } else {
62+ r1 = ret.Error(1)
63+ }
64+ return r0, r1
65+}
66+
67+// MockCloudflareZoneResolver_FindZone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindZone'
68+type MockCloudflareZoneResolver_FindZone_Call struct {
69+ *mock.Call
70+}
71+
72+// FindZone is a helper method to define mock.On call
73+// - ctx context.Context
74+// - domain string
75+func (_e *MockCloudflareZoneResolver_Expecter) FindZone(ctx any, domain any) *MockCloudflareZoneResolver_FindZone_Call {
76+ return &MockCloudflareZoneResolver_FindZone_Call{Call: _e.mock.On("FindZone", ctx, domain)}
77+}
78+
79+func (_c *MockCloudflareZoneResolver_FindZone_Call) Run(run func(ctx context.Context, domain string)) *MockCloudflareZoneResolver_FindZone_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 string
86+ if args[1] != nil {
87+ arg1 = args[1].(string)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *MockCloudflareZoneResolver_FindZone_Call) Return(cloudflareZone out.CloudflareZone, err error) *MockCloudflareZoneResolver_FindZone_Call {
98+ _c.Call.Return(cloudflareZone, err)
99+ return _c
100+}
101+
102+func (_c *MockCloudflareZoneResolver_FindZone_Call) RunAndReturn(run func(ctx context.Context, domain string) (out.CloudflareZone, error)) *MockCloudflareZoneResolver_FindZone_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
@@ -0,0 +1,105 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockCloudflareZoneResolver creates a new instance of MockCloudflareZoneResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockCloudflareZoneResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockCloudflareZoneResolver {
20+ mock := &MockCloudflareZoneResolver{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockCloudflareZoneResolver is an autogenerated mock type for the CloudflareZoneResolver type
29+type MockCloudflareZoneResolver struct {
30+ mock.Mock
31+}
32+
33+type MockCloudflareZoneResolver_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockCloudflareZoneResolver) EXPECT() *MockCloudflareZoneResolver_Expecter {
38+ return &MockCloudflareZoneResolver_Expecter{mock: &_m.Mock}
39+}
40+
41+// FindZone provides a mock function for the type MockCloudflareZoneResolver
42+func (_mock *MockCloudflareZoneResolver) FindZone(ctx context.Context, domain string) (out.CloudflareZone, error) {
43+ ret := _mock.Called(ctx, domain)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for FindZone")
47+ }
48+
49+ var r0 out.CloudflareZone
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) (out.CloudflareZone, error)); ok {
52+ return returnFunc(ctx, domain)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) out.CloudflareZone); ok {
55+ r0 = returnFunc(ctx, domain)
56+ } else {
57+ r0 = ret.Get(0).(out.CloudflareZone)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
60+ r1 = returnFunc(ctx, domain)
61+ } else {
62+ r1 = ret.Error(1)
63+ }
64+ return r0, r1
65+}
66+
67+// MockCloudflareZoneResolver_FindZone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindZone'
68+type MockCloudflareZoneResolver_FindZone_Call struct {
69+ *mock.Call
70+}
71+
72+// FindZone is a helper method to define mock.On call
73+// - ctx context.Context
74+// - domain string
75+func (_e *MockCloudflareZoneResolver_Expecter) FindZone(ctx any, domain any) *MockCloudflareZoneResolver_FindZone_Call {
76+ return &MockCloudflareZoneResolver_FindZone_Call{Call: _e.mock.On("FindZone", ctx, domain)}
77+}
78+
79+func (_c *MockCloudflareZoneResolver_FindZone_Call) Run(run func(ctx context.Context, domain string)) *MockCloudflareZoneResolver_FindZone_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ var arg0 context.Context
82+ if args[0] != nil {
83+ arg0 = args[0].(context.Context)
84+ }
85+ var arg1 string
86+ if args[1] != nil {
87+ arg1 = args[1].(string)
88+ }
89+ run(
90+ arg0,
91+ arg1,
92+ )
93+ })
94+ return _c
95+}
96+
97+func (_c *MockCloudflareZoneResolver_FindZone_Call) Return(cloudflareZone out.CloudflareZone, err error) *MockCloudflareZoneResolver_FindZone_Call {
98+ _c.Call.Return(cloudflareZone, err)
99+ return _c
100+}
101+
102+func (_c *MockCloudflareZoneResolver_FindZone_Call) RunAndReturn(run func(ctx context.Context, domain string) (out.CloudflareZone, error)) *MockCloudflareZoneResolver_FindZone_Call {
103+ _c.Call.Return(run)
104+ return _c
105+}
A internal/boundaries/out/mocks/mock_http_challenge_sink.go +122 -0
@@ -0,0 +1,122 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockHTTPChallengeSink creates a new instance of MockHTTPChallengeSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockHTTPChallengeSink(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockHTTPChallengeSink {
17+ mock := &MockHTTPChallengeSink{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockHTTPChallengeSink is an autogenerated mock type for the HTTPChallengeSink type
26+type MockHTTPChallengeSink struct {
27+ mock.Mock
28+}
29+
30+type MockHTTPChallengeSink_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockHTTPChallengeSink) EXPECT() *MockHTTPChallengeSink_Expecter {
35+ return &MockHTTPChallengeSink_Expecter{mock: &_m.Mock}
36+}
37+
38+// CleanUp provides a mock function for the type MockHTTPChallengeSink
39+func (_mock *MockHTTPChallengeSink) CleanUp(token string) {
40+ _mock.Called(token)
41+ return
42+}
43+
44+// MockHTTPChallengeSink_CleanUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanUp'
45+type MockHTTPChallengeSink_CleanUp_Call struct {
46+ *mock.Call
47+}
48+
49+// CleanUp is a helper method to define mock.On call
50+// - token string
51+func (_e *MockHTTPChallengeSink_Expecter) CleanUp(token any) *MockHTTPChallengeSink_CleanUp_Call {
52+ return &MockHTTPChallengeSink_CleanUp_Call{Call: _e.mock.On("CleanUp", token)}
53+}
54+
55+func (_c *MockHTTPChallengeSink_CleanUp_Call) Run(run func(token string)) *MockHTTPChallengeSink_CleanUp_Call {
56+ _c.Call.Run(func(args mock.Arguments) {
57+ var arg0 string
58+ if args[0] != nil {
59+ arg0 = args[0].(string)
60+ }
61+ run(
62+ arg0,
63+ )
64+ })
65+ return _c
66+}
67+
68+func (_c *MockHTTPChallengeSink_CleanUp_Call) Return() *MockHTTPChallengeSink_CleanUp_Call {
69+ _c.Call.Return()
70+ return _c
71+}
72+
73+func (_c *MockHTTPChallengeSink_CleanUp_Call) RunAndReturn(run func(token string)) *MockHTTPChallengeSink_CleanUp_Call {
74+ _c.Run(run)
75+ return _c
76+}
77+
78+// Present provides a mock function for the type MockHTTPChallengeSink
79+func (_mock *MockHTTPChallengeSink) Present(token string, keyAuth string) {
80+ _mock.Called(token, keyAuth)
81+ return
82+}
83+
84+// MockHTTPChallengeSink_Present_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Present'
85+type MockHTTPChallengeSink_Present_Call struct {
86+ *mock.Call
87+}
88+
89+// Present is a helper method to define mock.On call
90+// - token string
91+// - keyAuth string
92+func (_e *MockHTTPChallengeSink_Expecter) Present(token any, keyAuth any) *MockHTTPChallengeSink_Present_Call {
93+ return &MockHTTPChallengeSink_Present_Call{Call: _e.mock.On("Present", token, keyAuth)}
94+}
95+
96+func (_c *MockHTTPChallengeSink_Present_Call) Run(run func(token string, keyAuth string)) *MockHTTPChallengeSink_Present_Call {
97+ _c.Call.Run(func(args mock.Arguments) {
98+ var arg0 string
99+ if args[0] != nil {
100+ arg0 = args[0].(string)
101+ }
102+ var arg1 string
103+ if args[1] != nil {
104+ arg1 = args[1].(string)
105+ }
106+ run(
107+ arg0,
108+ arg1,
109+ )
110+ })
111+ return _c
112+}
113+
114+func (_c *MockHTTPChallengeSink_Present_Call) Return() *MockHTTPChallengeSink_Present_Call {
115+ _c.Call.Return()
116+ return _c
117+}
118+
119+func (_c *MockHTTPChallengeSink_Present_Call) RunAndReturn(run func(token string, keyAuth string)) *MockHTTPChallengeSink_Present_Call {
120+ _c.Run(run)
121+ return _c
122+}
@@ -0,0 +1,122 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockHTTPChallengeSink creates a new instance of MockHTTPChallengeSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockHTTPChallengeSink(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockHTTPChallengeSink {
17+ mock := &MockHTTPChallengeSink{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockHTTPChallengeSink is an autogenerated mock type for the HTTPChallengeSink type
26+type MockHTTPChallengeSink struct {
27+ mock.Mock
28+}
29+
30+type MockHTTPChallengeSink_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockHTTPChallengeSink) EXPECT() *MockHTTPChallengeSink_Expecter {
35+ return &MockHTTPChallengeSink_Expecter{mock: &_m.Mock}
36+}
37+
38+// CleanUp provides a mock function for the type MockHTTPChallengeSink
39+func (_mock *MockHTTPChallengeSink) CleanUp(token string) {
40+ _mock.Called(token)
41+ return
42+}
43+
44+// MockHTTPChallengeSink_CleanUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanUp'
45+type MockHTTPChallengeSink_CleanUp_Call struct {
46+ *mock.Call
47+}
48+
49+// CleanUp is a helper method to define mock.On call
50+// - token string
51+func (_e *MockHTTPChallengeSink_Expecter) CleanUp(token any) *MockHTTPChallengeSink_CleanUp_Call {
52+ return &MockHTTPChallengeSink_CleanUp_Call{Call: _e.mock.On("CleanUp", token)}
53+}
54+
55+func (_c *MockHTTPChallengeSink_CleanUp_Call) Run(run func(token string)) *MockHTTPChallengeSink_CleanUp_Call {
56+ _c.Call.Run(func(args mock.Arguments) {
57+ var arg0 string
58+ if args[0] != nil {
59+ arg0 = args[0].(string)
60+ }
61+ run(
62+ arg0,
63+ )
64+ })
65+ return _c
66+}
67+
68+func (_c *MockHTTPChallengeSink_CleanUp_Call) Return() *MockHTTPChallengeSink_CleanUp_Call {
69+ _c.Call.Return()
70+ return _c
71+}
72+
73+func (_c *MockHTTPChallengeSink_CleanUp_Call) RunAndReturn(run func(token string)) *MockHTTPChallengeSink_CleanUp_Call {
74+ _c.Run(run)
75+ return _c
76+}
77+
78+// Present provides a mock function for the type MockHTTPChallengeSink
79+func (_mock *MockHTTPChallengeSink) Present(token string, keyAuth string) {
80+ _mock.Called(token, keyAuth)
81+ return
82+}
83+
84+// MockHTTPChallengeSink_Present_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Present'
85+type MockHTTPChallengeSink_Present_Call struct {
86+ *mock.Call
87+}
88+
89+// Present is a helper method to define mock.On call
90+// - token string
91+// - keyAuth string
92+func (_e *MockHTTPChallengeSink_Expecter) Present(token any, keyAuth any) *MockHTTPChallengeSink_Present_Call {
93+ return &MockHTTPChallengeSink_Present_Call{Call: _e.mock.On("Present", token, keyAuth)}
94+}
95+
96+func (_c *MockHTTPChallengeSink_Present_Call) Run(run func(token string, keyAuth string)) *MockHTTPChallengeSink_Present_Call {
97+ _c.Call.Run(func(args mock.Arguments) {
98+ var arg0 string
99+ if args[0] != nil {
100+ arg0 = args[0].(string)
101+ }
102+ var arg1 string
103+ if args[1] != nil {
104+ arg1 = args[1].(string)
105+ }
106+ run(
107+ arg0,
108+ arg1,
109+ )
110+ })
111+ return _c
112+}
113+
114+func (_c *MockHTTPChallengeSink_Present_Call) Return() *MockHTTPChallengeSink_Present_Call {
115+ _c.Call.Return()
116+ return _c
117+}
118+
119+func (_c *MockHTTPChallengeSink_Present_Call) RunAndReturn(run func(token string, keyAuth string)) *MockHTTPChallengeSink_Present_Call {
120+ _c.Run(run)
121+ return _c
122+}
A internal/boundaries/out/mocks/mock_public_certificate_issuer.go +175 -0
@@ -0,0 +1,175 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockPublicCertificateIssuer creates a new instance of MockPublicCertificateIssuer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockPublicCertificateIssuer(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockPublicCertificateIssuer {
20+ mock := &MockPublicCertificateIssuer{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockPublicCertificateIssuer is an autogenerated mock type for the PublicCertificateIssuer type
29+type MockPublicCertificateIssuer struct {
30+ mock.Mock
31+}
32+
33+type MockPublicCertificateIssuer_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockPublicCertificateIssuer) EXPECT() *MockPublicCertificateIssuer_Expecter {
38+ return &MockPublicCertificateIssuer_Expecter{mock: &_m.Mock}
39+}
40+
41+// Obtain provides a mock function for the type MockPublicCertificateIssuer
42+func (_mock *MockPublicCertificateIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
43+ ret := _mock.Called(ctx, order)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Obtain")
47+ }
48+
49+ var r0 *out.StoredCertificate
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error)); ok {
52+ return returnFunc(ctx, order)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) *out.StoredCertificate); ok {
55+ r0 = returnFunc(ctx, order)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(*out.StoredCertificate)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, out.CertificateOrder) error); ok {
62+ r1 = returnFunc(ctx, order)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockPublicCertificateIssuer_Obtain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Obtain'
70+type MockPublicCertificateIssuer_Obtain_Call struct {
71+ *mock.Call
72+}
73+
74+// Obtain is a helper method to define mock.On call
75+// - ctx context.Context
76+// - order out.CertificateOrder
77+func (_e *MockPublicCertificateIssuer_Expecter) Obtain(ctx any, order any) *MockPublicCertificateIssuer_Obtain_Call {
78+ return &MockPublicCertificateIssuer_Obtain_Call{Call: _e.mock.On("Obtain", ctx, order)}
79+}
80+
81+func (_c *MockPublicCertificateIssuer_Obtain_Call) Run(run func(ctx context.Context, order out.CertificateOrder)) *MockPublicCertificateIssuer_Obtain_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 out.CertificateOrder
88+ if args[1] != nil {
89+ arg1 = args[1].(out.CertificateOrder)
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockPublicCertificateIssuer_Obtain_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Obtain_Call {
100+ _c.Call.Return(storedCertificate, err)
101+ return _c
102+}
103+
104+func (_c *MockPublicCertificateIssuer_Obtain_Call) RunAndReturn(run func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Obtain_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// Renew provides a mock function for the type MockPublicCertificateIssuer
110+func (_mock *MockPublicCertificateIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
111+ ret := _mock.Called(ctx, cert)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for Renew")
115+ }
116+
117+ var r0 *out.StoredCertificate
118+ var r1 error
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error)); ok {
120+ return returnFunc(ctx, cert)
121+ }
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) *out.StoredCertificate); ok {
123+ r0 = returnFunc(ctx, cert)
124+ } else {
125+ if ret.Get(0) != nil {
126+ r0 = ret.Get(0).(*out.StoredCertificate)
127+ }
128+ }
129+ if returnFunc, ok := ret.Get(1).(func(context.Context, out.StoredCertificate) error); ok {
130+ r1 = returnFunc(ctx, cert)
131+ } else {
132+ r1 = ret.Error(1)
133+ }
134+ return r0, r1
135+}
136+
137+// MockPublicCertificateIssuer_Renew_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Renew'
138+type MockPublicCertificateIssuer_Renew_Call struct {
139+ *mock.Call
140+}
141+
142+// Renew is a helper method to define mock.On call
143+// - ctx context.Context
144+// - cert out.StoredCertificate
145+func (_e *MockPublicCertificateIssuer_Expecter) Renew(ctx any, cert any) *MockPublicCertificateIssuer_Renew_Call {
146+ return &MockPublicCertificateIssuer_Renew_Call{Call: _e.mock.On("Renew", ctx, cert)}
147+}
148+
149+func (_c *MockPublicCertificateIssuer_Renew_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockPublicCertificateIssuer_Renew_Call {
150+ _c.Call.Run(func(args mock.Arguments) {
151+ var arg0 context.Context
152+ if args[0] != nil {
153+ arg0 = args[0].(context.Context)
154+ }
155+ var arg1 out.StoredCertificate
156+ if args[1] != nil {
157+ arg1 = args[1].(out.StoredCertificate)
158+ }
159+ run(
160+ arg0,
161+ arg1,
162+ )
163+ })
164+ return _c
165+}
166+
167+func (_c *MockPublicCertificateIssuer_Renew_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Renew_Call {
168+ _c.Call.Return(storedCertificate, err)
169+ return _c
170+}
171+
172+func (_c *MockPublicCertificateIssuer_Renew_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Renew_Call {
173+ _c.Call.Return(run)
174+ return _c
175+}
@@ -0,0 +1,175 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockPublicCertificateIssuer creates a new instance of MockPublicCertificateIssuer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockPublicCertificateIssuer(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockPublicCertificateIssuer {
20+ mock := &MockPublicCertificateIssuer{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockPublicCertificateIssuer is an autogenerated mock type for the PublicCertificateIssuer type
29+type MockPublicCertificateIssuer struct {
30+ mock.Mock
31+}
32+
33+type MockPublicCertificateIssuer_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockPublicCertificateIssuer) EXPECT() *MockPublicCertificateIssuer_Expecter {
38+ return &MockPublicCertificateIssuer_Expecter{mock: &_m.Mock}
39+}
40+
41+// Obtain provides a mock function for the type MockPublicCertificateIssuer
42+func (_mock *MockPublicCertificateIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
43+ ret := _mock.Called(ctx, order)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Obtain")
47+ }
48+
49+ var r0 *out.StoredCertificate
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error)); ok {
52+ return returnFunc(ctx, order)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) *out.StoredCertificate); ok {
55+ r0 = returnFunc(ctx, order)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(*out.StoredCertificate)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, out.CertificateOrder) error); ok {
62+ r1 = returnFunc(ctx, order)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockPublicCertificateIssuer_Obtain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Obtain'
70+type MockPublicCertificateIssuer_Obtain_Call struct {
71+ *mock.Call
72+}
73+
74+// Obtain is a helper method to define mock.On call
75+// - ctx context.Context
76+// - order out.CertificateOrder
77+func (_e *MockPublicCertificateIssuer_Expecter) Obtain(ctx any, order any) *MockPublicCertificateIssuer_Obtain_Call {
78+ return &MockPublicCertificateIssuer_Obtain_Call{Call: _e.mock.On("Obtain", ctx, order)}
79+}
80+
81+func (_c *MockPublicCertificateIssuer_Obtain_Call) Run(run func(ctx context.Context, order out.CertificateOrder)) *MockPublicCertificateIssuer_Obtain_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 out.CertificateOrder
88+ if args[1] != nil {
89+ arg1 = args[1].(out.CertificateOrder)
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockPublicCertificateIssuer_Obtain_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Obtain_Call {
100+ _c.Call.Return(storedCertificate, err)
101+ return _c
102+}
103+
104+func (_c *MockPublicCertificateIssuer_Obtain_Call) RunAndReturn(run func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Obtain_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
108+
109+// Renew provides a mock function for the type MockPublicCertificateIssuer
110+func (_mock *MockPublicCertificateIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
111+ ret := _mock.Called(ctx, cert)
112+
113+ if len(ret) == 0 {
114+ panic("no return value specified for Renew")
115+ }
116+
117+ var r0 *out.StoredCertificate
118+ var r1 error
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error)); ok {
120+ return returnFunc(ctx, cert)
121+ }
122+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) *out.StoredCertificate); ok {
123+ r0 = returnFunc(ctx, cert)
124+ } else {
125+ if ret.Get(0) != nil {
126+ r0 = ret.Get(0).(*out.StoredCertificate)
127+ }
128+ }
129+ if returnFunc, ok := ret.Get(1).(func(context.Context, out.StoredCertificate) error); ok {
130+ r1 = returnFunc(ctx, cert)
131+ } else {
132+ r1 = ret.Error(1)
133+ }
134+ return r0, r1
135+}
136+
137+// MockPublicCertificateIssuer_Renew_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Renew'
138+type MockPublicCertificateIssuer_Renew_Call struct {
139+ *mock.Call
140+}
141+
142+// Renew is a helper method to define mock.On call
143+// - ctx context.Context
144+// - cert out.StoredCertificate
145+func (_e *MockPublicCertificateIssuer_Expecter) Renew(ctx any, cert any) *MockPublicCertificateIssuer_Renew_Call {
146+ return &MockPublicCertificateIssuer_Renew_Call{Call: _e.mock.On("Renew", ctx, cert)}
147+}
148+
149+func (_c *MockPublicCertificateIssuer_Renew_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockPublicCertificateIssuer_Renew_Call {
150+ _c.Call.Run(func(args mock.Arguments) {
151+ var arg0 context.Context
152+ if args[0] != nil {
153+ arg0 = args[0].(context.Context)
154+ }
155+ var arg1 out.StoredCertificate
156+ if args[1] != nil {
157+ arg1 = args[1].(out.StoredCertificate)
158+ }
159+ run(
160+ arg0,
161+ arg1,
162+ )
163+ })
164+ return _c
165+}
166+
167+func (_c *MockPublicCertificateIssuer_Renew_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Renew_Call {
168+ _c.Call.Return(storedCertificate, err)
169+ return _c
170+}
171+
172+func (_c *MockPublicCertificateIssuer_Renew_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Renew_Call {
173+ _c.Call.Return(run)
174+ return _c
175+}
A internal/boundaries/out/mocks/mock_secret_resolver.go +99 -0
@@ -0,0 +1,99 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockSecretResolver creates a new instance of MockSecretResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockSecretResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockSecretResolver {
20+ mock := &MockSecretResolver{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockSecretResolver is an autogenerated mock type for the SecretResolver type
29+type MockSecretResolver struct {
30+ mock.Mock
31+}
32+
33+type MockSecretResolver_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockSecretResolver) EXPECT() *MockSecretResolver_Expecter {
38+ return &MockSecretResolver_Expecter{mock: &_m.Mock}
39+}
40+
41+// ResolveCloudflareToken provides a mock function for the type MockSecretResolver
42+func (_mock *MockSecretResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for ResolveCloudflareToken")
47+ }
48+
49+ var r0 out.SecretValue
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) (out.SecretValue, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) out.SecretValue); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ r0 = ret.Get(0).(out.SecretValue)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
60+ r1 = returnFunc(ctx)
61+ } else {
62+ r1 = ret.Error(1)
63+ }
64+ return r0, r1
65+}
66+
67+// MockSecretResolver_ResolveCloudflareToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveCloudflareToken'
68+type MockSecretResolver_ResolveCloudflareToken_Call struct {
69+ *mock.Call
70+}
71+
72+// ResolveCloudflareToken is a helper method to define mock.On call
73+// - ctx context.Context
74+func (_e *MockSecretResolver_Expecter) ResolveCloudflareToken(ctx any) *MockSecretResolver_ResolveCloudflareToken_Call {
75+ return &MockSecretResolver_ResolveCloudflareToken_Call{Call: _e.mock.On("ResolveCloudflareToken", ctx)}
76+}
77+
78+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Run(run func(ctx context.Context)) *MockSecretResolver_ResolveCloudflareToken_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 context.Context
81+ if args[0] != nil {
82+ arg0 = args[0].(context.Context)
83+ }
84+ run(
85+ arg0,
86+ )
87+ })
88+ return _c
89+}
90+
91+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Return(secretValue out.SecretValue, err error) *MockSecretResolver_ResolveCloudflareToken_Call {
92+ _c.Call.Return(secretValue, err)
93+ return _c
94+}
95+
96+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) RunAndReturn(run func(ctx context.Context) (out.SecretValue, error)) *MockSecretResolver_ResolveCloudflareToken_Call {
97+ _c.Call.Return(run)
98+ return _c
99+}
@@ -0,0 +1,99 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/gordon/internal/boundaries/out"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockSecretResolver creates a new instance of MockSecretResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockSecretResolver(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockSecretResolver {
20+ mock := &MockSecretResolver{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockSecretResolver is an autogenerated mock type for the SecretResolver type
29+type MockSecretResolver struct {
30+ mock.Mock
31+}
32+
33+type MockSecretResolver_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockSecretResolver) EXPECT() *MockSecretResolver_Expecter {
38+ return &MockSecretResolver_Expecter{mock: &_m.Mock}
39+}
40+
41+// ResolveCloudflareToken provides a mock function for the type MockSecretResolver
42+func (_mock *MockSecretResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) {
43+ ret := _mock.Called(ctx)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for ResolveCloudflareToken")
47+ }
48+
49+ var r0 out.SecretValue
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) (out.SecretValue, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) out.SecretValue); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ r0 = ret.Get(0).(out.SecretValue)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
60+ r1 = returnFunc(ctx)
61+ } else {
62+ r1 = ret.Error(1)
63+ }
64+ return r0, r1
65+}
66+
67+// MockSecretResolver_ResolveCloudflareToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveCloudflareToken'
68+type MockSecretResolver_ResolveCloudflareToken_Call struct {
69+ *mock.Call
70+}
71+
72+// ResolveCloudflareToken is a helper method to define mock.On call
73+// - ctx context.Context
74+func (_e *MockSecretResolver_Expecter) ResolveCloudflareToken(ctx any) *MockSecretResolver_ResolveCloudflareToken_Call {
75+ return &MockSecretResolver_ResolveCloudflareToken_Call{Call: _e.mock.On("ResolveCloudflareToken", ctx)}
76+}
77+
78+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Run(run func(ctx context.Context)) *MockSecretResolver_ResolveCloudflareToken_Call {
79+ _c.Call.Run(func(args mock.Arguments) {
80+ var arg0 context.Context
81+ if args[0] != nil {
82+ arg0 = args[0].(context.Context)
83+ }
84+ run(
85+ arg0,
86+ )
87+ })
88+ return _c
89+}
90+
91+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Return(secretValue out.SecretValue, err error) *MockSecretResolver_ResolveCloudflareToken_Call {
92+ _c.Call.Return(secretValue, err)
93+ return _c
94+}
95+
96+func (_c *MockSecretResolver_ResolveCloudflareToken_Call) RunAndReturn(run func(ctx context.Context) (out.SecretValue, error)) *MockSecretResolver_ResolveCloudflareToken_Call {
97+ _c.Call.Return(run)
98+ return _c
99+}
M internal/boundaries/out/public_tls.go +10 -0
@@ -51,6 +51,16 @@ type CloudflareZone struct {
5151 Name string
5252 }
5353
54+// HTTPChallengeSink defines the contract for storing HTTP-01 challenge tokens
55+// while an ACME issuer completes domain validation.
56+type HTTPChallengeSink interface {
57+ // Present stores the key authorization for a challenge token.
58+ Present(token, keyAuth string)
59+
60+ // CleanUp removes the key authorization for a challenge token.
61+ CleanUp(token string)
62+}
63+
5464 // PublicCertificateIssuer defines the contract for obtaining and renewing
5565 // TLS certificates via ACME.
5666 type PublicCertificateIssuer interface {
@@ -51,6 +51,16 @@ type CloudflareZone struct {
51 Name string51 Name string
52 }52 }
53 53
54+// HTTPChallengeSink defines the contract for storing HTTP-01 challenge tokens
55+// while an ACME issuer completes domain validation.
56+type HTTPChallengeSink interface {
57+ // Present stores the key authorization for a challenge token.
58+ Present(token, keyAuth string)
59+
60+ // CleanUp removes the key authorization for a challenge token.
61+ CleanUp(token string)
62+}
63+
54 // PublicCertificateIssuer defines the contract for obtaining and renewing64 // PublicCertificateIssuer defines the contract for obtaining and renewing
55 // TLS certificates via ACME.65 // TLS certificates via ACME.
56 type PublicCertificateIssuer interface {66 type PublicCertificateIssuer interface {
M internal/domain/public_tls.go +28 -31
@@ -16,11 +16,13 @@ const (
1616 )
1717
1818 var (
19- ErrACMEDisabled = errors.New("acme disabled")
20- ErrACMEEmailRequired = errors.New("acme email required")
21- ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
22- ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
23- ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
19+ ErrACMEDisabled = errors.New("acme disabled")
20+ ErrACMEEmailRequired = errors.New("acme email required")
21+ ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
22+ ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
23+ ErrCertificateStoreRequired = errors.New("certificate store required")
24+ ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
25+ ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
2426 )
2527
2628 func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {
@@ -56,24 +58,19 @@ const (
5658 )
5759
5860 type ManagedCertificate struct {
59- ID string `json:"id"`
60- Names []string `json:"names"`
61- Challenge ACMEChallengeMode `json:"challenge"`
62- Status TLSCertificateStatus `json:"status"`
63- NotAfter time.Time `json:"not_after"`
64- LastError string `json:"last_error,omitempty"`
65- RenewalPending bool `json:"renewal_pending"`
61+ ID string
62+ Names []string
63+ Challenge ACMEChallengeMode
64+ Status TLSCertificateStatus
65+ NotAfter time.Time
66+ LastError string
67+ RenewalPending bool
6668 }
6769
6870 func (c ManagedCertificate) Covers(host string) bool {
6971 return CertificateNamesCoverHost(c.Names, host)
7072 }
7173
72-// SafePathComponent reports whether value is a single safe path component.
73-func SafePathComponent(value string) bool {
74- return value != "" && !strings.Contains(value, "/") && !strings.Contains(value, "\\") && !strings.Contains(value, "..") && !strings.Contains(value, "\x00")
75-}
76-
7774 // CertificateNamesCoverHost reports whether any certificate name covers host.
7875 func CertificateNamesCoverHost(names []string, host string) bool {
7976 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
@@ -105,27 +102,27 @@ func (c ManagedCertificate) Health(now time.Time) TLSCertificateStatus {
105102 if !now.Before(c.NotAfter) {
106103 return TLSCertificateStatusExpired
107104 }
108- if now.Add(30 * 24 * time.Hour).After(c.NotAfter) {
105+ if !now.Add(30 * 24 * time.Hour).Before(c.NotAfter) {
109106 return TLSCertificateStatusWarning
110107 }
111108 return TLSCertificateStatusValid
112109 }
113110
114111 type TLSRouteCoverage struct {
115- Domain string `json:"domain"`
116- Covered bool `json:"covered"`
117- CoveredBy string `json:"covered_by,omitempty"`
118- RequiredACME bool `json:"required_acme"`
119- Error string `json:"error,omitempty"`
112+ Domain string
113+ Covered bool
114+ CoveredBy string
115+ RequiredACME bool
116+ Error string
120117 }
121118
122119 type PublicTLSStatus struct {
123- ACMEEnabled bool `json:"acme_enabled"`
124- ConfiguredMode ACMEChallengeMode `json:"configured_mode"`
125- EffectiveMode ACMEChallengeMode `json:"effective_mode"`
126- SelectionReason string `json:"selection_reason"`
127- TokenSource ACMETokenSource `json:"token_source"`
128- Certificates []ManagedCertificate `json:"certificates"`
129- Routes []TLSRouteCoverage `json:"routes"`
130- Errors []string `json:"errors,omitempty"`
120+ ACMEEnabled bool
121+ ConfiguredMode ACMEChallengeMode
122+ EffectiveMode ACMEChallengeMode
123+ SelectionReason string
124+ TokenSource ACMETokenSource
125+ Certificates []ManagedCertificate
126+ Routes []TLSRouteCoverage
127+ Errors []string
131128 }
@@ -16,11 +16,13 @@ const (
16 )16 )
17 17
18 var (18 var (
19- ErrACMEDisabled = errors.New("acme disabled")19+ ErrACMEDisabled = errors.New("acme disabled")
20- ErrACMEEmailRequired = errors.New("acme email required")20+ ErrACMEEmailRequired = errors.New("acme email required")
21- ErrACMEChallengeInvalid = errors.New("acme challenge invalid")21+ ErrACMEChallengeInvalid = errors.New("acme challenge invalid")
22- ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")22+ ErrCloudflareTokenMissing = errors.New("cloudflare api token missing")
23- ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")23+ ErrCertificateStoreRequired = errors.New("certificate store required")
24+ ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required")
25+ ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate")
24 )26 )
25 27
26 func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {28 func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) {
@@ -56,24 +58,19 @@ const (
56 )58 )
57 59
58 type ManagedCertificate struct {60 type ManagedCertificate struct {
59- ID string `json:"id"`61+ ID string
60- Names []string `json:"names"`62+ Names []string
61- Challenge ACMEChallengeMode `json:"challenge"`63+ Challenge ACMEChallengeMode
62- Status TLSCertificateStatus `json:"status"`64+ Status TLSCertificateStatus
63- NotAfter time.Time `json:"not_after"`65+ NotAfter time.Time
64- LastError string `json:"last_error,omitempty"`66+ LastError string
65- RenewalPending bool `json:"renewal_pending"`67+ RenewalPending bool
66 }68 }
67 69
68 func (c ManagedCertificate) Covers(host string) bool {70 func (c ManagedCertificate) Covers(host string) bool {
69 return CertificateNamesCoverHost(c.Names, host)71 return CertificateNamesCoverHost(c.Names, host)
70 }72 }
71 73
72-// SafePathComponent reports whether value is a single safe path component.
73-func SafePathComponent(value string) bool {
74- return value != "" && !strings.Contains(value, "/") && !strings.Contains(value, "\\") && !strings.Contains(value, "..") && !strings.Contains(value, "\x00")
75-}
76-
77 // CertificateNamesCoverHost reports whether any certificate name covers host.74 // CertificateNamesCoverHost reports whether any certificate name covers host.
78 func CertificateNamesCoverHost(names []string, host string) bool {75 func CertificateNamesCoverHost(names []string, host string) bool {
79 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")76 host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
@@ -105,27 +102,27 @@ func (c ManagedCertificate) Health(now time.Time) TLSCertificateStatus {
105 if !now.Before(c.NotAfter) {102 if !now.Before(c.NotAfter) {
106 return TLSCertificateStatusExpired103 return TLSCertificateStatusExpired
107 }104 }
108- if now.Add(30 * 24 * time.Hour).After(c.NotAfter) {105+ if !now.Add(30 * 24 * time.Hour).Before(c.NotAfter) {
109 return TLSCertificateStatusWarning106 return TLSCertificateStatusWarning
110 }107 }
111 return TLSCertificateStatusValid108 return TLSCertificateStatusValid
112 }109 }
113 110
114 type TLSRouteCoverage struct {111 type TLSRouteCoverage struct {
115- Domain string `json:"domain"`112+ Domain string
116- Covered bool `json:"covered"`113+ Covered bool
117- CoveredBy string `json:"covered_by,omitempty"`114+ CoveredBy string
118- RequiredACME bool `json:"required_acme"`115+ RequiredACME bool
119- Error string `json:"error,omitempty"`116+ Error string
120 }117 }
121 118
122 type PublicTLSStatus struct {119 type PublicTLSStatus struct {
123- ACMEEnabled bool `json:"acme_enabled"`120+ ACMEEnabled bool
124- ConfiguredMode ACMEChallengeMode `json:"configured_mode"`121+ ConfiguredMode ACMEChallengeMode
125- EffectiveMode ACMEChallengeMode `json:"effective_mode"`122+ EffectiveMode ACMEChallengeMode
126- SelectionReason string `json:"selection_reason"`123+ SelectionReason string
127- TokenSource ACMETokenSource `json:"token_source"`124+ TokenSource ACMETokenSource
128- Certificates []ManagedCertificate `json:"certificates"`125+ Certificates []ManagedCertificate
129- Routes []TLSRouteCoverage `json:"routes"`126+ Routes []TLSRouteCoverage
130- Errors []string `json:"errors,omitempty"`127+ Errors []string
131 }128 }
M internal/domain/public_tls_test.go +3 -2
@@ -28,6 +28,7 @@ func TestACMEChallengeModeParse(t *testing.T) {
2828 assert.Equal(t, tt.want, got)
2929 } else {
3030 require.Error(t, err)
31+ assert.ErrorIs(t, err, ErrACMEChallengeInvalid)
3132 }
3233 }
3334 }
@@ -68,8 +69,8 @@ func TestManagedCertificateHealth(t *testing.T) {
6869 want: TLSCertificateStatusWarning,
6970 },
7071 {
71- name: "warning exactly at 30 day boundary in the past",
72- cert: ManagedCertificate{NotAfter: now.Add(29 * 24 * time.Hour)},
72+ name: "warning exactly at 30 day boundary",
73+ cert: ManagedCertificate{NotAfter: now.Add(30 * 24 * time.Hour)},
7374 want: TLSCertificateStatusWarning,
7475 },
7576 {
@@ -28,6 +28,7 @@ func TestACMEChallengeModeParse(t *testing.T) {
28 assert.Equal(t, tt.want, got)28 assert.Equal(t, tt.want, got)
29 } else {29 } else {
30 require.Error(t, err)30 require.Error(t, err)
31+ assert.ErrorIs(t, err, ErrACMEChallengeInvalid)
31 }32 }
32 }33 }
33 }34 }
@@ -68,8 +69,8 @@ func TestManagedCertificateHealth(t *testing.T) {
68 want: TLSCertificateStatusWarning,69 want: TLSCertificateStatusWarning,
69 },70 },
70 {71 {
71- name: "warning exactly at 30 day boundary in the past",72+ name: "warning exactly at 30 day boundary",
72- cert: ManagedCertificate{NotAfter: now.Add(29 * 24 * time.Hour)},73+ cert: ManagedCertificate{NotAfter: now.Add(30 * 24 * time.Hour)},
73 want: TLSCertificateStatusWarning,74 want: TLSCertificateStatusWarning,
74 },75 },
75 {76 {
M internal/usecase/publictls/config.go +3 -8
@@ -19,11 +19,6 @@ type Config struct {
1919 DataDir string
2020 }
2121
22-// tokenResolver resolves Cloudflare API tokens.
23-type tokenResolver interface {
24- ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error)
25-}
26-
2722 // EffectiveChallenge represents the resolved ACME challenge configuration.
2823 type EffectiveChallenge struct {
2924 ConfiguredMode domain.ACMEChallengeMode
@@ -35,7 +30,7 @@ type EffectiveChallenge struct {
3530
3631 // ResolveEffectiveChallenge resolves the effective ACME challenge configuration
3732 // from the given Config and optional token resolver.
38-func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver tokenResolver) (EffectiveChallenge, error) {
33+func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver out.SecretResolver) (EffectiveChallenge, error) {
3934 if !cfg.Enabled {
4035 return EffectiveChallenge{
4136 ConfiguredMode: domain.ACMEChallengeAuto,
@@ -114,7 +109,7 @@ func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver tokenRe
114109
115110 // resolveOptionalToken resolves the Cloudflare token, ignoring resolver errors.
116111 // If the resolver is nil or returns an empty token, the result is empty with source none.
117-func resolveOptionalToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource) {
112+func resolveOptionalToken(ctx context.Context, resolver out.SecretResolver) (string, domain.ACMETokenSource) {
118113 if resolver == nil {
119114 return "", domain.ACMETokenSourceNone
120115 }
@@ -131,7 +126,7 @@ func resolveOptionalToken(ctx context.Context, resolver tokenResolver) (string,
131126
132127 // resolveToken resolves the Cloudflare token via the resolver, propagating errors.
133128 // Returns empty token and none source if resolver is nil.
134-func resolveToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource, error) {
129+func resolveToken(ctx context.Context, resolver out.SecretResolver) (string, domain.ACMETokenSource, error) {
135130 if resolver == nil {
136131 return "", domain.ACMETokenSourceNone, nil
137132 }
@@ -19,11 +19,6 @@ type Config struct {
19 DataDir string19 DataDir string
20 }20 }
21 21
22-// tokenResolver resolves Cloudflare API tokens.
23-type tokenResolver interface {
24- ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error)
25-}
26-
27 // EffectiveChallenge represents the resolved ACME challenge configuration.22 // EffectiveChallenge represents the resolved ACME challenge configuration.
28 type EffectiveChallenge struct {23 type EffectiveChallenge struct {
29 ConfiguredMode domain.ACMEChallengeMode24 ConfiguredMode domain.ACMEChallengeMode
@@ -35,7 +30,7 @@ type EffectiveChallenge struct {
35 30
36 // ResolveEffectiveChallenge resolves the effective ACME challenge configuration31 // ResolveEffectiveChallenge resolves the effective ACME challenge configuration
37 // from the given Config and optional token resolver.32 // from the given Config and optional token resolver.
38-func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver tokenResolver) (EffectiveChallenge, error) {33+func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver out.SecretResolver) (EffectiveChallenge, error) {
39 if !cfg.Enabled {34 if !cfg.Enabled {
40 return EffectiveChallenge{35 return EffectiveChallenge{
41 ConfiguredMode: domain.ACMEChallengeAuto,36 ConfiguredMode: domain.ACMEChallengeAuto,
@@ -114,7 +109,7 @@ func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver tokenRe
114 109
115 // resolveOptionalToken resolves the Cloudflare token, ignoring resolver errors.110 // resolveOptionalToken resolves the Cloudflare token, ignoring resolver errors.
116 // If the resolver is nil or returns an empty token, the result is empty with source none.111 // If the resolver is nil or returns an empty token, the result is empty with source none.
117-func resolveOptionalToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource) {112+func resolveOptionalToken(ctx context.Context, resolver out.SecretResolver) (string, domain.ACMETokenSource) {
118 if resolver == nil {113 if resolver == nil {
119 return "", domain.ACMETokenSourceNone114 return "", domain.ACMETokenSourceNone
120 }115 }
@@ -131,7 +126,7 @@ func resolveOptionalToken(ctx context.Context, resolver tokenResolver) (string,
131 126
132 // resolveToken resolves the Cloudflare token via the resolver, propagating errors.127 // resolveToken resolves the Cloudflare token via the resolver, propagating errors.
133 // Returns empty token and none source if resolver is nil.128 // Returns empty token and none source if resolver is nil.
134-func resolveToken(ctx context.Context, resolver tokenResolver) (string, domain.ACMETokenSource, error) {129+func resolveToken(ctx context.Context, resolver out.SecretResolver) (string, domain.ACMETokenSource, error) {
135 if resolver == nil {130 if resolver == nil {
136 return "", domain.ACMETokenSourceNone, nil131 return "", domain.ACMETokenSourceNone, nil
137 }132 }
M internal/usecase/publictls/config_test.go +18 -67
@@ -6,20 +6,19 @@ import (
66 "testing"
77
88 "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
910 "github.com/stretchr/testify/require"
1011
1112 "github.com/bnema/gordon/internal/boundaries/out"
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
1214 "github.com/bnema/gordon/internal/domain"
1315 )
1416
15-// stubResolver is a simple implementation of tokenResolver for testing.
16-type stubResolver struct {
17- value out.SecretValue
18- err error
19-}
20-
21-func (s stubResolver) ResolveCloudflareToken(_ context.Context) (out.SecretValue, error) {
22- return s.value, s.err
17+func newSecretResolverMock(t *testing.T, value out.SecretValue, err error) *outmocks.MockSecretResolver {
18+ t.Helper()
19+ resolver := outmocks.NewMockSecretResolver(t)
20+ resolver.EXPECT().ResolveCloudflareToken(mock.Anything).Return(value, err)
21+ return resolver
2322 }
2423
2524 func TestResolveEffectiveChallenge(t *testing.T) {
@@ -49,16 +48,8 @@ func TestResolveEffectiveChallenge(t *testing.T) {
4948 })
5049
5150 t.Run("auto with cloudflare token returns dns-01", func(t *testing.T) {
52- cfg := Config{
53- Enabled: true,
54- Email: "admin@example.com",
55- Challenge: "auto",
56- TLSPort: 8443,
57- HTTPPort: 8088,
58- }
59- resolver := stubResolver{
60- value: out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass},
61- }
51+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
52+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass}, nil)
6253 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
6354 require.NoError(t, err)
6455 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.Mode)
@@ -68,16 +59,8 @@ func TestResolveEffectiveChallenge(t *testing.T) {
6859 })
6960
7061 t.Run("auto without token returns http-01", func(t *testing.T) {
71- cfg := Config{
72- Enabled: true,
73- Email: "admin@example.com",
74- Challenge: "auto",
75- TLSPort: 8443,
76- HTTPPort: 8088,
77- }
78- resolver := stubResolver{
79- value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone},
80- }
62+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
63+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil)
8164 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
8265 require.NoError(t, err)
8366 assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
@@ -86,44 +69,23 @@ func TestResolveEffectiveChallenge(t *testing.T) {
8669 })
8770
8871 t.Run("forced cloudflare-dns-01 requires token", func(t *testing.T) {
89- cfg := Config{
90- Enabled: true,
91- Email: "admin@example.com",
92- Challenge: "cloudflare-dns-01",
93- TLSPort: 8443,
94- HTTPPort: 8088,
95- }
96- resolver := stubResolver{
97- value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone},
98- }
72+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443, HTTPPort: 8088}
73+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil)
9974 _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
10075 require.Error(t, err)
10176 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
10277 })
10378
10479 t.Run("http-01 requires http port", func(t *testing.T) {
105- cfg := Config{
106- Enabled: true,
107- Email: "admin@example.com",
108- Challenge: "http-01",
109- TLSPort: 8443,
110- HTTPPort: 0,
111- }
80+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", TLSPort: 8443, HTTPPort: 0}
11281 _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
11382 require.Error(t, err)
11483 assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
11584 })
11685
11786 t.Run("cloudflare-dns-01 preserves resolver errors", func(t *testing.T) {
118- cfg := Config{
119- Enabled: true,
120- Email: "admin@example.com",
121- Challenge: "cloudflare-dns-01",
122- TLSPort: 8443,
123- }
124- resolver := stubResolver{
125- err: errors.New("pass failed"),
126- }
87+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443}
88+ resolver := newSecretResolverMock(t, out.SecretValue{}, errors.New("pass failed"))
12789 _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
12890 require.Error(t, err)
12991 assert.Contains(t, err.Error(), "resolve Cloudflare token")
@@ -131,13 +93,7 @@ func TestResolveEffectiveChallenge(t *testing.T) {
13193 })
13294
13395 t.Run("nil resolver in auto mode returns http-01", func(t *testing.T) {
134- cfg := Config{
135- Enabled: true,
136- Email: "admin@example.com",
137- Challenge: "auto",
138- TLSPort: 8443,
139- HTTPPort: 8088,
140- }
96+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
14197 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
14298 require.NoError(t, err)
14399 assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
@@ -145,12 +101,7 @@ func TestResolveEffectiveChallenge(t *testing.T) {
145101 })
146102
147103 t.Run("forced cloudflare-dns-01 with nil resolver returns missing token", func(t *testing.T) {
148- cfg := Config{
149- Enabled: true,
150- Email: "admin@example.com",
151- Challenge: "cloudflare-dns-01",
152- TLSPort: 8443,
153- }
104+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443}
154105 _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
155106 require.Error(t, err)
156107 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
@@ -6,20 +6,19 @@ import (
6 "testing"6 "testing"
7 7
8 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 11
11 "github.com/bnema/gordon/internal/boundaries/out"12 "github.com/bnema/gordon/internal/boundaries/out"
13+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
12 "github.com/bnema/gordon/internal/domain"14 "github.com/bnema/gordon/internal/domain"
13 )15 )
14 16
15-// stubResolver is a simple implementation of tokenResolver for testing.17+func newSecretResolverMock(t *testing.T, value out.SecretValue, err error) *outmocks.MockSecretResolver {
16-type stubResolver struct {18+ t.Helper()
17- value out.SecretValue19+ resolver := outmocks.NewMockSecretResolver(t)
18- err error20+ resolver.EXPECT().ResolveCloudflareToken(mock.Anything).Return(value, err)
19-}21+ return resolver
20-
21-func (s stubResolver) ResolveCloudflareToken(_ context.Context) (out.SecretValue, error) {
22- return s.value, s.err
23 }22 }
24 23
25 func TestResolveEffectiveChallenge(t *testing.T) {24 func TestResolveEffectiveChallenge(t *testing.T) {
@@ -49,16 +48,8 @@ func TestResolveEffectiveChallenge(t *testing.T) {
49 })48 })
50 49
51 t.Run("auto with cloudflare token returns dns-01", func(t *testing.T) {50 t.Run("auto with cloudflare token returns dns-01", func(t *testing.T) {
52- cfg := Config{51+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
53- Enabled: true,52+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass}, nil)
54- Email: "admin@example.com",
55- Challenge: "auto",
56- TLSPort: 8443,
57- HTTPPort: 8088,
58- }
59- resolver := stubResolver{
60- value: out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass},
61- }
62 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)53 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
63 require.NoError(t, err)54 require.NoError(t, err)
64 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.Mode)55 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.Mode)
@@ -68,16 +59,8 @@ func TestResolveEffectiveChallenge(t *testing.T) {
68 })59 })
69 60
70 t.Run("auto without token returns http-01", func(t *testing.T) {61 t.Run("auto without token returns http-01", func(t *testing.T) {
71- cfg := Config{62+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
72- Enabled: true,63+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil)
73- Email: "admin@example.com",
74- Challenge: "auto",
75- TLSPort: 8443,
76- HTTPPort: 8088,
77- }
78- resolver := stubResolver{
79- value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone},
80- }
81 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)64 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
82 require.NoError(t, err)65 require.NoError(t, err)
83 assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)66 assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
@@ -86,44 +69,23 @@ func TestResolveEffectiveChallenge(t *testing.T) {
86 })69 })
87 70
88 t.Run("forced cloudflare-dns-01 requires token", func(t *testing.T) {71 t.Run("forced cloudflare-dns-01 requires token", func(t *testing.T) {
89- cfg := Config{72+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443, HTTPPort: 8088}
90- Enabled: true,73+ resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil)
91- Email: "admin@example.com",
92- Challenge: "cloudflare-dns-01",
93- TLSPort: 8443,
94- HTTPPort: 8088,
95- }
96- resolver := stubResolver{
97- value: out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone},
98- }
99 _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)74 _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
100 require.Error(t, err)75 require.Error(t, err)
101 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)76 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
102 })77 })
103 78
104 t.Run("http-01 requires http port", func(t *testing.T) {79 t.Run("http-01 requires http port", func(t *testing.T) {
105- cfg := Config{80+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", TLSPort: 8443, HTTPPort: 0}
106- Enabled: true,
107- Email: "admin@example.com",
108- Challenge: "http-01",
109- TLSPort: 8443,
110- HTTPPort: 0,
111- }
112 _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)81 _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
113 require.Error(t, err)82 require.Error(t, err)
114 assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)83 assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
115 })84 })
116 85
117 t.Run("cloudflare-dns-01 preserves resolver errors", func(t *testing.T) {86 t.Run("cloudflare-dns-01 preserves resolver errors", func(t *testing.T) {
118- cfg := Config{87+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443}
119- Enabled: true,88+ resolver := newSecretResolverMock(t, out.SecretValue{}, errors.New("pass failed"))
120- Email: "admin@example.com",
121- Challenge: "cloudflare-dns-01",
122- TLSPort: 8443,
123- }
124- resolver := stubResolver{
125- err: errors.New("pass failed"),
126- }
127 _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)89 _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver)
128 require.Error(t, err)90 require.Error(t, err)
129 assert.Contains(t, err.Error(), "resolve Cloudflare token")91 assert.Contains(t, err.Error(), "resolve Cloudflare token")
@@ -131,13 +93,7 @@ func TestResolveEffectiveChallenge(t *testing.T) {
131 })93 })
132 94
133 t.Run("nil resolver in auto mode returns http-01", func(t *testing.T) {95 t.Run("nil resolver in auto mode returns http-01", func(t *testing.T) {
134- cfg := Config{96+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088}
135- Enabled: true,
136- Email: "admin@example.com",
137- Challenge: "auto",
138- TLSPort: 8443,
139- HTTPPort: 8088,
140- }
141 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)97 ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
142 require.NoError(t, err)98 require.NoError(t, err)
143 assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)99 assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode)
@@ -145,12 +101,7 @@ func TestResolveEffectiveChallenge(t *testing.T) {
145 })101 })
146 102
147 t.Run("forced cloudflare-dns-01 with nil resolver returns missing token", func(t *testing.T) {103 t.Run("forced cloudflare-dns-01 with nil resolver returns missing token", func(t *testing.T) {
148- cfg := Config{104+ cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443}
149- Enabled: true,
150- Email: "admin@example.com",
151- Challenge: "cloudflare-dns-01",
152- TLSPort: 8443,
153- }
154 _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)105 _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil)
155 require.Error(t, err)106 require.Error(t, err)
156 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)107 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
M internal/usecase/publictls/http01.go +4 -7
@@ -2,10 +2,8 @@ package publictls
22
33 import (
44 "context"
5- "log/slog"
5+ "strings"
66 "sync"
7-
8- "github.com/bnema/gordon/internal/domain"
97 )
108
119 // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key
@@ -26,7 +24,6 @@ func NewHTTP01Challenges() *HTTP01Challenges {
2624 // ignores unsafe tokens (see safeHTTP01Token) and empty keyAuth values.
2725 func (c *HTTP01Challenges) Present(token, keyAuth string) {
2826 if !safeHTTP01Token(token) || keyAuth == "" {
29- slog.Debug("http01: rejected Present for unsafe token or empty keyAuth")
3027 return
3128 }
3229 c.mu.Lock()
@@ -55,8 +52,8 @@ func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
5552 }
5653
5754 // safeHTTP01Token validates an ACME HTTP-01 challenge token.
58-// It rejects empty tokens, tokens containing "/" or "\", and tokens
59-// equal to "..", which could allow path traversal.
55+// It rejects empty tokens, tokens containing "/", "\", "..", or NUL,
56+// which could allow path traversal or invalid challenge paths.
6057 func safeHTTP01Token(token string) bool {
61- return domain.SafePathComponent(token)
58+ return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00")
6259 }
@@ -2,10 +2,8 @@ package publictls
2 2
3 import (3 import (
4 "context"4 "context"
5- "log/slog"5+ "strings"
6 "sync"6 "sync"
7-
8- "github.com/bnema/gordon/internal/domain"
9 )7 )
10 8
11 // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key9 // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key
@@ -26,7 +24,6 @@ func NewHTTP01Challenges() *HTTP01Challenges {
26 // ignores unsafe tokens (see safeHTTP01Token) and empty keyAuth values.24 // ignores unsafe tokens (see safeHTTP01Token) and empty keyAuth values.
27 func (c *HTTP01Challenges) Present(token, keyAuth string) {25 func (c *HTTP01Challenges) Present(token, keyAuth string) {
28 if !safeHTTP01Token(token) || keyAuth == "" {26 if !safeHTTP01Token(token) || keyAuth == "" {
29- slog.Debug("http01: rejected Present for unsafe token or empty keyAuth")
30 return27 return
31 }28 }
32 c.mu.Lock()29 c.mu.Lock()
@@ -55,8 +52,8 @@ func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) {
55 }52 }
56 53
57 // safeHTTP01Token validates an ACME HTTP-01 challenge token.54 // safeHTTP01Token validates an ACME HTTP-01 challenge token.
58-// It rejects empty tokens, tokens containing "/" or "\", and tokens55+// It rejects empty tokens, tokens containing "/", "\", "..", or NUL,
59-// equal to "..", which could allow path traversal.56+// which could allow path traversal or invalid challenge paths.
60 func safeHTTP01Token(token string) bool {57 func safeHTTP01Token(token string) bool {
61- return domain.SafePathComponent(token)58+ return token != "" && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00")
62 }59 }
M internal/usecase/publictls/integration_test.go +25 -36
@@ -2,7 +2,6 @@ package publictls
22
33 import (
44 "context"
5- "crypto/tls"
65 "testing"
76
87 "github.com/stretchr/testify/assert"
@@ -28,16 +27,14 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
2827 },
2928 }
3029
31- // Fake zone resolver returns "example.com" zone.
32- zoneResolver := fixedZoneResolver{zone: "example.com"}
30+ // Mock zone resolver returns "example.com" zone.
31+ zoneResolver := newZoneResolverMock(t, "example.com")
3332
34- // Fake issuer: returns valid stored cert for any order.
35- issuer := &fakeIssuer{
36- obtain: func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
37- return defaultStoredCert(order)
38- },
39- }
40- store := &fakeStore{}
33+ // Mock issuer: returns valid stored cert for any order.
34+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
35+ return defaultStoredCert(order)
36+ }, nil)
37+ store, _ := newMockCertificateStore(t)
4138
4239 cfg := Config{
4340 Enabled: true,
@@ -71,10 +68,7 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
7168 require.NoError(t, err)
7269
7370 // Verify issuer received exactly 2 orders.
74- issuer.mu.Lock()
75- orders := make([]out.CertificateOrder, len(issuer.orders))
76- copy(orders, issuer.orders)
77- issuer.mu.Unlock()
71+ orders := recorder.Orders()
7872
7973 require.Len(t, orders, 2, "expected 2 DNS-01 orders")
8074
@@ -123,21 +117,21 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
123117
124118 // --- GetCertificate returns non-nil certs ---
125119 t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) {
126- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
120+ cert, err := svc.GetCertificateForHost("app.example.com")
127121 require.NoError(t, err)
128122 require.NotNil(t, cert)
129123 require.NotEmpty(t, cert.Certificate)
130124 })
131125
132126 t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) {
133- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "api.prod.example.com"})
127+ cert, err := svc.GetCertificateForHost("api.prod.example.com")
134128 require.NoError(t, err)
135129 require.NotNil(t, cert)
136130 require.NotEmpty(t, cert.Certificate)
137131 })
138132
139133 t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) {
140- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "example.com"})
134+ cert, err := svc.GetCertificateForHost("example.com")
141135 require.NoError(t, err)
142136 require.NotNil(t, cert)
143137 require.NotEmpty(t, cert.Certificate)
@@ -146,7 +140,7 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
146140 // Also verify that a host not in the routes returns nil (no required ACME).
147141 // "unrelated.com" is not covered by any wildcard base in requiredHosts.
148142 t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {
149- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "unrelated.com"})
143+ cert, err := svc.GetCertificateForHost("unrelated.com")
150144 require.NoError(t, err)
151145 assert.Nil(t, cert, "unrelated host should not require ACME coverage")
152146 })
@@ -161,8 +155,8 @@ func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
161155 },
162156 }
163157
164- issuer := &fakeIssuer{}
165- store := &fakeStore{}
158+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
159+ store, _ := newMockCertificateStore(t)
166160 challenges := NewHTTP01Challenges()
167161
168162 cfg := Config{
@@ -206,10 +200,7 @@ func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
206200 require.NoError(t, err)
207201
208202 // Verify the issuer received exactly 1 order.
209- issuer.mu.Lock()
210- orders := make([]out.CertificateOrder, len(issuer.orders))
211- copy(orders, issuer.orders)
212- issuer.mu.Unlock()
203+ orders := recorder.Orders()
213204
214205 require.Len(t, orders, 1, "expected 1 HTTP-01 order")
215206 assert.Equal(t, "http01-app.example.com", orders[0].ID)
@@ -225,14 +216,14 @@ func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
225216 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
226217 assert.Empty(t, status.Routes[0].Error)
227218
228- // GetCertificate returns non-nil cert for app.example.com.
229- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
219+ // GetCertificateForHost returns non-nil cert for app.example.com.
220+ cert, err := svc.GetCertificateForHost("app.example.com")
230221 require.NoError(t, err)
231222 require.NotNil(t, cert)
232223 require.NotEmpty(t, cert.Certificate)
233224
234- // GetCertificate returns nil for unrelated host.
235- cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "other.example.com"})
225+ // GetCertificateForHost returns nil for unrelated host.
226+ cert, err = svc.GetCertificateForHost("other.example.com")
236227 require.NoError(t, err)
237228 assert.Nil(t, cert)
238229 }
@@ -247,12 +238,10 @@ func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing
247238 }
248239
249240 // Issuer returns error so no cert is obtained.
250- issuer := &fakeIssuer{
251- obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
252- return nil, assert.AnError
253- },
254- }
255- store := &fakeStore{}
241+ issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
242+ return nil, assert.AnError
243+ }, nil)
244+ store, _ := newMockCertificateStore(t)
256245
257246 cfg := Config{
258247 Enabled: true,
@@ -284,8 +273,8 @@ func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing
284273 err = svc.Reconcile(ctx)
285274 require.NoError(t, err) // Reconcile swallows per-target obtain errors
286275
287- // GetCertificate for missing.example.com should return ErrTLSRouteNotCovered.
288- _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "missing.example.com"})
276+ // GetCertificateForHost for missing.example.com should return ErrTLSRouteNotCovered.
277+ _, err = svc.GetCertificateForHost("missing.example.com")
289278 require.Error(t, err)
290279 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
291280 // The error message should contain the hostname.
@@ -2,7 +2,6 @@ package publictls
2 2
3 import (3 import (
4 "context"4 "context"
5- "crypto/tls"
6 "testing"5 "testing"
7 6
8 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
@@ -28,16 +27,14 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
28 },27 },
29 }28 }
30 29
31- // Fake zone resolver returns "example.com" zone.30+ // Mock zone resolver returns "example.com" zone.
32- zoneResolver := fixedZoneResolver{zone: "example.com"}31+ zoneResolver := newZoneResolverMock(t, "example.com")
33 32
34- // Fake issuer: returns valid stored cert for any order.33+ // Mock issuer: returns valid stored cert for any order.
35- issuer := &fakeIssuer{34+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
36- obtain: func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {35+ return defaultStoredCert(order)
37- return defaultStoredCert(order)36+ }, nil)
38- },37+ store, _ := newMockCertificateStore(t)
39- }
40- store := &fakeStore{}
41 38
42 cfg := Config{39 cfg := Config{
43 Enabled: true,40 Enabled: true,
@@ -71,10 +68,7 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
71 require.NoError(t, err)68 require.NoError(t, err)
72 69
73 // Verify issuer received exactly 2 orders.70 // Verify issuer received exactly 2 orders.
74- issuer.mu.Lock()71+ orders := recorder.Orders()
75- orders := make([]out.CertificateOrder, len(issuer.orders))
76- copy(orders, issuer.orders)
77- issuer.mu.Unlock()
78 72
79 require.Len(t, orders, 2, "expected 2 DNS-01 orders")73 require.Len(t, orders, 2, "expected 2 DNS-01 orders")
80 74
@@ -123,21 +117,21 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
123 117
124 // --- GetCertificate returns non-nil certs ---118 // --- GetCertificate returns non-nil certs ---
125 t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) {119 t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) {
126- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})120+ cert, err := svc.GetCertificateForHost("app.example.com")
127 require.NoError(t, err)121 require.NoError(t, err)
128 require.NotNil(t, cert)122 require.NotNil(t, cert)
129 require.NotEmpty(t, cert.Certificate)123 require.NotEmpty(t, cert.Certificate)
130 })124 })
131 125
132 t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) {126 t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) {
133- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "api.prod.example.com"})127+ cert, err := svc.GetCertificateForHost("api.prod.example.com")
134 require.NoError(t, err)128 require.NoError(t, err)
135 require.NotNil(t, cert)129 require.NotNil(t, cert)
136 require.NotEmpty(t, cert.Certificate)130 require.NotEmpty(t, cert.Certificate)
137 })131 })
138 132
139 t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) {133 t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) {
140- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "example.com"})134+ cert, err := svc.GetCertificateForHost("example.com")
141 require.NoError(t, err)135 require.NoError(t, err)
142 require.NotNil(t, cert)136 require.NotNil(t, cert)
143 require.NotEmpty(t, cert.Certificate)137 require.NotEmpty(t, cert.Certificate)
@@ -146,7 +140,7 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing
146 // Also verify that a host not in the routes returns nil (no required ACME).140 // Also verify that a host not in the routes returns nil (no required ACME).
147 // "unrelated.com" is not covered by any wildcard base in requiredHosts.141 // "unrelated.com" is not covered by any wildcard base in requiredHosts.
148 t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {142 t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) {
149- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "unrelated.com"})143+ cert, err := svc.GetCertificateForHost("unrelated.com")
150 require.NoError(t, err)144 require.NoError(t, err)
151 assert.Nil(t, cert, "unrelated host should not require ACME coverage")145 assert.Nil(t, cert, "unrelated host should not require ACME coverage")
152 })146 })
@@ -161,8 +155,8 @@ func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
161 },155 },
162 }156 }
163 157
164- issuer := &fakeIssuer{}158+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
165- store := &fakeStore{}159+ store, _ := newMockCertificateStore(t)
166 challenges := NewHTTP01Challenges()160 challenges := NewHTTP01Challenges()
167 161
168 cfg := Config{162 cfg := Config{
@@ -206,10 +200,7 @@ func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
206 require.NoError(t, err)200 require.NoError(t, err)
207 201
208 // Verify the issuer received exactly 1 order.202 // Verify the issuer received exactly 1 order.
209- issuer.mu.Lock()203+ orders := recorder.Orders()
210- orders := make([]out.CertificateOrder, len(issuer.orders))
211- copy(orders, issuer.orders)
212- issuer.mu.Unlock()
213 204
214 require.Len(t, orders, 1, "expected 1 HTTP-01 order")205 require.Len(t, orders, 1, "expected 1 HTTP-01 order")
215 assert.Equal(t, "http01-app.example.com", orders[0].ID)206 assert.Equal(t, "http01-app.example.com", orders[0].ID)
@@ -225,14 +216,14 @@ func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) {
225 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)216 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
226 assert.Empty(t, status.Routes[0].Error)217 assert.Empty(t, status.Routes[0].Error)
227 218
228- // GetCertificate returns non-nil cert for app.example.com.219+ // GetCertificateForHost returns non-nil cert for app.example.com.
229- cert, err := svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})220+ cert, err := svc.GetCertificateForHost("app.example.com")
230 require.NoError(t, err)221 require.NoError(t, err)
231 require.NotNil(t, cert)222 require.NotNil(t, cert)
232 require.NotEmpty(t, cert.Certificate)223 require.NotEmpty(t, cert.Certificate)
233 224
234- // GetCertificate returns nil for unrelated host.225+ // GetCertificateForHost returns nil for unrelated host.
235- cert, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "other.example.com"})226+ cert, err = svc.GetCertificateForHost("other.example.com")
236 require.NoError(t, err)227 require.NoError(t, err)
237 assert.Nil(t, cert)228 assert.Nil(t, cert)
238 }229 }
@@ -247,12 +238,10 @@ func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing
247 }238 }
248 239
249 // Issuer returns error so no cert is obtained.240 // Issuer returns error so no cert is obtained.
250- issuer := &fakeIssuer{241+ issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
251- obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {242+ return nil, assert.AnError
252- return nil, assert.AnError243+ }, nil)
253- },244+ store, _ := newMockCertificateStore(t)
254- }
255- store := &fakeStore{}
256 245
257 cfg := Config{246 cfg := Config{
258 Enabled: true,247 Enabled: true,
@@ -284,8 +273,8 @@ func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing
284 err = svc.Reconcile(ctx)273 err = svc.Reconcile(ctx)
285 require.NoError(t, err) // Reconcile swallows per-target obtain errors274 require.NoError(t, err) // Reconcile swallows per-target obtain errors
286 275
287- // GetCertificate for missing.example.com should return ErrTLSRouteNotCovered.276+ // GetCertificateForHost for missing.example.com should return ErrTLSRouteNotCovered.
288- _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "missing.example.com"})277+ _, err = svc.GetCertificateForHost("missing.example.com")
289 require.Error(t, err)278 require.Error(t, err)
290 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)279 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
291 // The error message should contain the hostname.280 // The error message should contain the hostname.
M internal/usecase/publictls/renewal.go +18 -2
@@ -60,14 +60,18 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
6060
6161 // Startup and reload paths reconcile routes; the periodic loop only renews
6262 // due certificates to avoid repeated zone lookups and ACME ordering work.
63- _ = s.renewDueCertificates(loopCtx, time.Now())
63+ if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
64+ s.log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
65+ }
6466
6567 for {
6668 select {
6769 case <-loopCtx.Done():
6870 return
6971 case <-ticker.C:
70- _ = s.renewDueCertificates(loopCtx, time.Now())
72+ if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
73+ s.log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
74+ }
7175 }
7276 }
7377 }()
@@ -90,6 +94,11 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
9094 if s.deps.Issuer == nil {
9195 return fmt.Errorf("certificate issuer is nil")
9296 }
97+ unlock, err := s.deps.Store.Lock(ctx)
98+ if err != nil {
99+ return fmt.Errorf("acquire store lock: %w", err)
100+ }
101+ defer func() { _ = unlock() }()
93102
94103 // Collect due certs under lock, then release before making external calls.
95104 s.mu.Lock()
@@ -110,6 +119,13 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
110119 continue
111120 }
112121
122+ if renewed == nil {
123+ s.mu.Lock()
124+ s.lastErr[cert.ID] = "renew returned nil certificate"
125+ s.mu.Unlock()
126+ continue
127+ }
128+
113129 if err := s.deps.Store.Save(ctx, *renewed); err != nil {
114130 s.mu.Lock()
115131 s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", err)
@@ -60,14 +60,18 @@ func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration)
60 60
61 // Startup and reload paths reconcile routes; the periodic loop only renews61 // Startup and reload paths reconcile routes; the periodic loop only renews
62 // due certificates to avoid repeated zone lookups and ACME ordering work.62 // due certificates to avoid repeated zone lookups and ACME ordering work.
63- _ = s.renewDueCertificates(loopCtx, time.Now())63+ if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
64+ s.log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
65+ }
64 66
65 for {67 for {
66 select {68 select {
67 case <-loopCtx.Done():69 case <-loopCtx.Done():
68 return70 return
69 case <-ticker.C:71 case <-ticker.C:
70- _ = s.renewDueCertificates(loopCtx, time.Now())72+ if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil {
73+ s.log.Warn().Err(err).Msg("failed to renew due public TLS certificates")
74+ }
71 }75 }
72 }76 }
73 }()77 }()
@@ -90,6 +94,11 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
90 if s.deps.Issuer == nil {94 if s.deps.Issuer == nil {
91 return fmt.Errorf("certificate issuer is nil")95 return fmt.Errorf("certificate issuer is nil")
92 }96 }
97+ unlock, err := s.deps.Store.Lock(ctx)
98+ if err != nil {
99+ return fmt.Errorf("acquire store lock: %w", err)
100+ }
101+ defer func() { _ = unlock() }()
93 102
94 // Collect due certs under lock, then release before making external calls.103 // Collect due certs under lock, then release before making external calls.
95 s.mu.Lock()104 s.mu.Lock()
@@ -110,6 +119,13 @@ func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error
110 continue119 continue
111 }120 }
112 121
122+ if renewed == nil {
123+ s.mu.Lock()
124+ s.lastErr[cert.ID] = "renew returned nil certificate"
125+ s.mu.Unlock()
126+ continue
127+ }
128+
113 if err := s.deps.Store.Save(ctx, *renewed); err != nil {129 if err := s.deps.Store.Save(ctx, *renewed); err != nil {
114 s.mu.Lock()130 s.mu.Lock()
115 s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", err)131 s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", err)
M internal/usecase/publictls/renewal_test.go +20 -24
@@ -71,11 +71,14 @@ func TestRenewalLoopStops(t *testing.T) {
7171 TLSPort: 8443,
7272 }
7373
74+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
75+ store, _ := newMockCertificateStore(t)
76+
7477 svc := NewService(cfg, ServiceDeps{
7578 Config: cfg,
7679 Routes: &fakeRoutes{},
77- Issuer: &fakeIssuer{},
78- Store: &fakeStore{},
80+ Issuer: issuer,
81+ Store: store,
7982 Challenges: NewHTTP01Challenges(),
8083 Effective: EffectiveChallenge{
8184 ConfiguredMode: domain.ACMEChallengeHTTP01,
@@ -116,27 +119,21 @@ func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
116119 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
117120 require.NoError(t, err)
118121
119- store := &fakeStore{
120- certs: []out.StoredCertificate{
121- {
122- ID: certID,
123- Names: []string{"app.example.com"},
124- Challenge: domain.ACMEChallengeHTTP01,
125- Certificate: tlsCert,
126- FullchainPEM: certPEM,
127- PrivateKeyPEM: keyPEM,
128- NotAfter: notAfterSoon,
129- },
130- },
131- }
122+ store, storeState := newMockCertificateStore(t, out.StoredCertificate{
123+ ID: certID,
124+ Names: []string{"app.example.com"},
125+ Challenge: domain.ACMEChallengeHTTP01,
126+ Certificate: tlsCert,
127+ FullchainPEM: certPEM,
128+ PrivateKeyPEM: keyPEM,
129+ NotAfter: notAfterSoon,
130+ })
132131
133- issuer := &fakeIssuer{
134- renewFn: func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
135- renewed := cert
136- renewed.NotAfter = notAfterRenewed
137- return &renewed, nil
138- },
139- }
132+ issuer, _ := newMockPublicCertificateIssuer(t, nil, func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
133+ renewed := cert
134+ renewed.NotAfter = notAfterRenewed
135+ return &renewed, nil
136+ })
140137
141138 cfg := Config{
142139 Enabled: true,
@@ -176,8 +173,7 @@ func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
176173 require.NoError(t, err)
177174
178175 // Verify the store has the updated certificate.
179- all, err := store.LoadAll(ctx)
180- require.NoError(t, err)
176+ all := storeState.All()
181177 require.Len(t, all, 1)
182178 assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")
183179
@@ -71,11 +71,14 @@ func TestRenewalLoopStops(t *testing.T) {
71 TLSPort: 8443,71 TLSPort: 8443,
72 }72 }
73 73
74+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
75+ store, _ := newMockCertificateStore(t)
76+
74 svc := NewService(cfg, ServiceDeps{77 svc := NewService(cfg, ServiceDeps{
75 Config: cfg,78 Config: cfg,
76 Routes: &fakeRoutes{},79 Routes: &fakeRoutes{},
77- Issuer: &fakeIssuer{},80+ Issuer: issuer,
78- Store: &fakeStore{},81+ Store: store,
79 Challenges: NewHTTP01Challenges(),82 Challenges: NewHTTP01Challenges(),
80 Effective: EffectiveChallenge{83 Effective: EffectiveChallenge{
81 ConfiguredMode: domain.ACMEChallengeHTTP01,84 ConfiguredMode: domain.ACMEChallengeHTTP01,
@@ -116,27 +119,21 @@ func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
116 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)119 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
117 require.NoError(t, err)120 require.NoError(t, err)
118 121
119- store := &fakeStore{122+ store, storeState := newMockCertificateStore(t, out.StoredCertificate{
120- certs: []out.StoredCertificate{123+ ID: certID,
121- {124+ Names: []string{"app.example.com"},
122- ID: certID,125+ Challenge: domain.ACMEChallengeHTTP01,
123- Names: []string{"app.example.com"},126+ Certificate: tlsCert,
124- Challenge: domain.ACMEChallengeHTTP01,127+ FullchainPEM: certPEM,
125- Certificate: tlsCert,128+ PrivateKeyPEM: keyPEM,
126- FullchainPEM: certPEM,129+ NotAfter: notAfterSoon,
127- PrivateKeyPEM: keyPEM,130+ })
128- NotAfter: notAfterSoon,
129- },
130- },
131- }
132 131
133- issuer := &fakeIssuer{132+ issuer, _ := newMockPublicCertificateIssuer(t, nil, func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
134- renewFn: func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {133+ renewed := cert
135- renewed := cert134+ renewed.NotAfter = notAfterRenewed
136- renewed.NotAfter = notAfterRenewed135+ return &renewed, nil
137- return &renewed, nil136+ })
138- },
139- }
140 137
141 cfg := Config{138 cfg := Config{
142 Enabled: true,139 Enabled: true,
@@ -176,8 +173,7 @@ func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
176 require.NoError(t, err)173 require.NoError(t, err)
177 174
178 // Verify the store has the updated certificate.175 // Verify the store has the updated certificate.
179- all, err := store.LoadAll(ctx)176+ all := storeState.All()
180- require.NoError(t, err)
181 require.Len(t, all, 1)177 require.Len(t, all, 1)
182 assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")178 assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")
183 179
M internal/usecase/publictls/service.go +51 -23
@@ -26,7 +26,7 @@ type ServiceDeps struct {
2626 Routes RouteSource
2727 Issuer out.PublicCertificateIssuer
2828 Store out.CertificateStore
29- ZoneResolver zoneResolver
29+ ZoneResolver out.CloudflareZoneResolver
3030 Challenges *HTTP01Challenges
3131 Effective EffectiveChallenge
3232 Log zerowrap.Logger
@@ -85,14 +85,23 @@ func (s *Service) Load(ctx context.Context) error {
8585 s.mu.Lock()
8686 defer s.mu.Unlock()
8787
88+ s.certs = make(map[string]*out.StoredCertificate, len(stored))
89+ s.lastErr = make(map[string]string)
90+
8891 for i := range stored {
8992 cert := &stored[i]
9093 // If the tls.Certificate is empty but PEM data is available, parse it.
9194 if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
9295 parsed, parseErr := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
93- if parseErr == nil {
94- cert.Certificate = parsed
96+ if parseErr != nil {
97+ s.lastErr[cert.ID] = fmt.Sprintf("parse stored certificate: %v", parseErr)
98+ continue
9599 }
100+ cert.Certificate = parsed
101+ }
102+ if len(cert.Certificate.Certificate) == 0 {
103+ s.lastErr[cert.ID] = "stored certificate is empty"
104+ continue
96105 }
97106 s.certs[cert.ID] = cert
98107 if cert.LastError != "" {
@@ -122,6 +131,10 @@ func (s *Service) Reconcile(ctx context.Context) error {
122131 log.Warn().Msg("certificate issuer is nil, cannot reconcile")
123132 return fmt.Errorf("certificate issuer is nil")
124133 }
134+ if s.deps.Routes == nil {
135+ log.Warn().Msg("route source is nil, cannot reconcile")
136+ return fmt.Errorf("route source is nil")
137+ }
125138
126139 // Determine effective challenge mode.
127140 effective := s.deps.Effective
@@ -142,13 +155,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
142155 hosts := routeHosts(routes, external)
143156
144157 // Build required hosts set from route hosts (before target derivation).
145- required := make(map[string]struct{})
146- for _, host := range hosts {
147- host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
148- if host != "" {
149- required[host] = struct{}{}
150- }
151- }
158+ required := canonicalHostSet(hosts)
152159
153160 // Set requiredHosts before DeriveCertificateTargets so GetCertificate
154161 // returns ErrTLSRouteNotCovered if target derivation/issuer fails.
@@ -168,14 +175,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
168175 // Merge target names into required hosts set.
169176 // For DNS-01 wildcard targets, this adds wildcard entries as well;
170177 // the exact route hosts already present suffice for isRequiredHostLocked.
171- for _, t := range targets {
172- for _, name := range t.Names {
173- name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
174- if name != "" {
175- required[name] = struct{}{}
176- }
177- }
178- }
178+ addTargetNames(required, targets)
179179
180180 // Acquire store lock to serialise certificate operations.
181181 unlock, err := s.deps.Store.Lock(ctx)
@@ -205,6 +205,28 @@ func (s *Service) Reconcile(ctx context.Context) error {
205205 return nil
206206 }
207207
208+func canonicalHostSet(hosts []string) map[string]struct{} {
209+ required := make(map[string]struct{}, len(hosts))
210+ for _, host := range hosts {
211+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
212+ if host != "" {
213+ required[host] = struct{}{}
214+ }
215+ }
216+ return required
217+}
218+
219+func addTargetNames(required map[string]struct{}, targets []CertificateTarget) {
220+ for _, t := range targets {
221+ for _, name := range t.Names {
222+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
223+ if name != "" {
224+ required[name] = struct{}{}
225+ }
226+ }
227+ }
228+}
229+
208230 // reconcileMissingTargets obtains and saves certificates for each missing
209231 // target. Called without s.mu held so network I/O does not block readers.
210232 func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget) {
@@ -218,14 +240,20 @@ func (s *Service) reconcileMissingTargets(ctx context.Context, missing []Certifi
218240 stored, err := s.deps.Issuer.Obtain(ctx, order)
219241 if err != nil {
220242 s.mu.Lock()
221- s.lastErr[target.ID] = "obtain certificate failed"
243+ s.lastErr[target.ID] = fmt.Sprintf("obtain certificate: %v", err)
244+ s.mu.Unlock()
245+ continue
246+ }
247+ if stored == nil {
248+ s.mu.Lock()
249+ s.lastErr[target.ID] = "obtain certificate returned nil"
222250 s.mu.Unlock()
223251 continue
224252 }
225253
226254 if err := s.deps.Store.Save(ctx, *stored); err != nil {
227255 s.mu.Lock()
228- s.lastErr[target.ID] = "save certificate failed"
256+ s.lastErr[target.ID] = fmt.Sprintf("save certificate: %v", err)
229257 s.mu.Unlock()
230258 continue
231259 }
@@ -273,14 +301,14 @@ func hostMatchesCert(names []string, host string) bool {
273301 return domain.CertificateNamesCoverHost(names, host)
274302 }
275303
276-// GetCertificate returns a TLS certificate for the given ClientHello.
304+// GetCertificateForHost returns a TLS certificate for the given SNI host.
277305 //
278306 // If the SNI host does not require ACME coverage, returns nil, nil.
279307 // If ACME is required but no cert covers the host, returns
280308 // ErrTLSRouteNotCovered.
281309 // If a valid cert is found, returns a pointer to it.
282-func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
283- host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hello.ServerName)), ".")
310+func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) {
311+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
284312 if host == "" {
285313 return nil, nil
286314 }
@@ -26,7 +26,7 @@ type ServiceDeps struct {
26 Routes RouteSource26 Routes RouteSource
27 Issuer out.PublicCertificateIssuer27 Issuer out.PublicCertificateIssuer
28 Store out.CertificateStore28 Store out.CertificateStore
29- ZoneResolver zoneResolver29+ ZoneResolver out.CloudflareZoneResolver
30 Challenges *HTTP01Challenges30 Challenges *HTTP01Challenges
31 Effective EffectiveChallenge31 Effective EffectiveChallenge
32 Log zerowrap.Logger32 Log zerowrap.Logger
@@ -85,14 +85,23 @@ func (s *Service) Load(ctx context.Context) error {
85 s.mu.Lock()85 s.mu.Lock()
86 defer s.mu.Unlock()86 defer s.mu.Unlock()
87 87
88+ s.certs = make(map[string]*out.StoredCertificate, len(stored))
89+ s.lastErr = make(map[string]string)
90+
88 for i := range stored {91 for i := range stored {
89 cert := &stored[i]92 cert := &stored[i]
90 // If the tls.Certificate is empty but PEM data is available, parse it.93 // If the tls.Certificate is empty but PEM data is available, parse it.
91 if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {94 if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 {
92 parsed, parseErr := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)95 parsed, parseErr := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM)
93- if parseErr == nil {96+ if parseErr != nil {
94- cert.Certificate = parsed97+ s.lastErr[cert.ID] = fmt.Sprintf("parse stored certificate: %v", parseErr)
98+ continue
95 }99 }
100+ cert.Certificate = parsed
101+ }
102+ if len(cert.Certificate.Certificate) == 0 {
103+ s.lastErr[cert.ID] = "stored certificate is empty"
104+ continue
96 }105 }
97 s.certs[cert.ID] = cert106 s.certs[cert.ID] = cert
98 if cert.LastError != "" {107 if cert.LastError != "" {
@@ -122,6 +131,10 @@ func (s *Service) Reconcile(ctx context.Context) error {
122 log.Warn().Msg("certificate issuer is nil, cannot reconcile")131 log.Warn().Msg("certificate issuer is nil, cannot reconcile")
123 return fmt.Errorf("certificate issuer is nil")132 return fmt.Errorf("certificate issuer is nil")
124 }133 }
134+ if s.deps.Routes == nil {
135+ log.Warn().Msg("route source is nil, cannot reconcile")
136+ return fmt.Errorf("route source is nil")
137+ }
125 138
126 // Determine effective challenge mode.139 // Determine effective challenge mode.
127 effective := s.deps.Effective140 effective := s.deps.Effective
@@ -142,13 +155,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
142 hosts := routeHosts(routes, external)155 hosts := routeHosts(routes, external)
143 156
144 // Build required hosts set from route hosts (before target derivation).157 // Build required hosts set from route hosts (before target derivation).
145- required := make(map[string]struct{})158+ required := canonicalHostSet(hosts)
146- for _, host := range hosts {
147- host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
148- if host != "" {
149- required[host] = struct{}{}
150- }
151- }
152 159
153 // Set requiredHosts before DeriveCertificateTargets so GetCertificate160 // Set requiredHosts before DeriveCertificateTargets so GetCertificate
154 // returns ErrTLSRouteNotCovered if target derivation/issuer fails.161 // returns ErrTLSRouteNotCovered if target derivation/issuer fails.
@@ -168,14 +175,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
168 // Merge target names into required hosts set.175 // Merge target names into required hosts set.
169 // For DNS-01 wildcard targets, this adds wildcard entries as well;176 // For DNS-01 wildcard targets, this adds wildcard entries as well;
170 // the exact route hosts already present suffice for isRequiredHostLocked.177 // the exact route hosts already present suffice for isRequiredHostLocked.
171- for _, t := range targets {178+ addTargetNames(required, targets)
172- for _, name := range t.Names {
173- name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
174- if name != "" {
175- required[name] = struct{}{}
176- }
177- }
178- }
179 179
180 // Acquire store lock to serialise certificate operations.180 // Acquire store lock to serialise certificate operations.
181 unlock, err := s.deps.Store.Lock(ctx)181 unlock, err := s.deps.Store.Lock(ctx)
@@ -205,6 +205,28 @@ func (s *Service) Reconcile(ctx context.Context) error {
205 return nil205 return nil
206 }206 }
207 207
208+func canonicalHostSet(hosts []string) map[string]struct{} {
209+ required := make(map[string]struct{}, len(hosts))
210+ for _, host := range hosts {
211+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
212+ if host != "" {
213+ required[host] = struct{}{}
214+ }
215+ }
216+ return required
217+}
218+
219+func addTargetNames(required map[string]struct{}, targets []CertificateTarget) {
220+ for _, t := range targets {
221+ for _, name := range t.Names {
222+ name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
223+ if name != "" {
224+ required[name] = struct{}{}
225+ }
226+ }
227+ }
228+}
229+
208 // reconcileMissingTargets obtains and saves certificates for each missing230 // reconcileMissingTargets obtains and saves certificates for each missing
209 // target. Called without s.mu held so network I/O does not block readers.231 // target. Called without s.mu held so network I/O does not block readers.
210 func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget) {232 func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget) {
@@ -218,14 +240,20 @@ func (s *Service) reconcileMissingTargets(ctx context.Context, missing []Certifi
218 stored, err := s.deps.Issuer.Obtain(ctx, order)240 stored, err := s.deps.Issuer.Obtain(ctx, order)
219 if err != nil {241 if err != nil {
220 s.mu.Lock()242 s.mu.Lock()
221- s.lastErr[target.ID] = "obtain certificate failed"243+ s.lastErr[target.ID] = fmt.Sprintf("obtain certificate: %v", err)
244+ s.mu.Unlock()
245+ continue
246+ }
247+ if stored == nil {
248+ s.mu.Lock()
249+ s.lastErr[target.ID] = "obtain certificate returned nil"
222 s.mu.Unlock()250 s.mu.Unlock()
223 continue251 continue
224 }252 }
225 253
226 if err := s.deps.Store.Save(ctx, *stored); err != nil {254 if err := s.deps.Store.Save(ctx, *stored); err != nil {
227 s.mu.Lock()255 s.mu.Lock()
228- s.lastErr[target.ID] = "save certificate failed"256+ s.lastErr[target.ID] = fmt.Sprintf("save certificate: %v", err)
229 s.mu.Unlock()257 s.mu.Unlock()
230 continue258 continue
231 }259 }
@@ -273,14 +301,14 @@ func hostMatchesCert(names []string, host string) bool {
273 return domain.CertificateNamesCoverHost(names, host)301 return domain.CertificateNamesCoverHost(names, host)
274 }302 }
275 303
276-// GetCertificate returns a TLS certificate for the given ClientHello.304+// GetCertificateForHost returns a TLS certificate for the given SNI host.
277 //305 //
278 // If the SNI host does not require ACME coverage, returns nil, nil.306 // If the SNI host does not require ACME coverage, returns nil, nil.
279 // If ACME is required but no cert covers the host, returns307 // If ACME is required but no cert covers the host, returns
280 // ErrTLSRouteNotCovered.308 // ErrTLSRouteNotCovered.
281 // If a valid cert is found, returns a pointer to it.309 // If a valid cert is found, returns a pointer to it.
282-func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {310+func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) {
283- host := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(hello.ServerName)), ".")311+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
284 if host == "" {312 if host == "" {
285 return nil, nil313 return nil, nil
286 }314 }
M internal/usecase/publictls/service_test.go +145 -136
@@ -16,9 +16,11 @@ import (
1616 "time"
1717
1818 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
1920 "github.com/stretchr/testify/require"
2021
2122 "github.com/bnema/gordon/internal/boundaries/out"
23+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2224 "github.com/bnema/gordon/internal/domain"
2325 )
2426
@@ -53,34 +55,56 @@ func (f *fakeRoutes) GetExternalRoutes() map[string]string {
5355 return out
5456 }
5557
56-// fakeIssuer is a hand-rolled implementation of out.PublicCertificateIssuer
57-// that records issued orders and delegates to pluggable obtain/renew
58-// functions. Generated mocks are less convenient here because the test needs
59-// to inspect captured orders after the call completes — a real slice on the
60-// fake avoids mock expectation boilerplate.
61-type fakeIssuer struct {
62- mu sync.Mutex
63- orders []out.CertificateOrder // recorded orders
64- obtain func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error)
65- renewFn func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error)
58+type mockIssuerRecorder struct {
59+ mu sync.Mutex
60+ orders []out.CertificateOrder
6661 }
6762
68-func (f *fakeIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
69- f.mu.Lock()
70- f.orders = append(f.orders, order)
71- f.mu.Unlock()
72- if f.obtain != nil {
73- return f.obtain(ctx, order)
74- }
75- // Default: return a valid certificate.
76- return defaultStoredCert(order)
63+func (r *mockIssuerRecorder) record(order out.CertificateOrder) {
64+ r.mu.Lock()
65+ defer r.mu.Unlock()
66+ r.orders = append(r.orders, order)
7767 }
7868
79-func (f *fakeIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
80- if f.renewFn != nil {
81- return f.renewFn(ctx, cert)
82- }
83- return &cert, nil
69+func (r *mockIssuerRecorder) Orders() []out.CertificateOrder {
70+ r.mu.Lock()
71+ defer r.mu.Unlock()
72+ orders := make([]out.CertificateOrder, len(r.orders))
73+ copy(orders, r.orders)
74+ return orders
75+}
76+
77+func newMockPublicCertificateIssuer(
78+ t *testing.T,
79+ obtain func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error),
80+ renew func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error),
81+) (*outmocks.MockPublicCertificateIssuer, *mockIssuerRecorder) {
82+ t.Helper()
83+ recorder := &mockIssuerRecorder{}
84+ issuer := outmocks.NewMockPublicCertificateIssuer(t)
85+
86+ obtainCall := issuer.EXPECT().Obtain(mock.Anything, mock.Anything).RunAndReturn(
87+ func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
88+ recorder.record(order)
89+ if obtain != nil {
90+ return obtain(ctx, order)
91+ }
92+ return defaultStoredCert(order)
93+ },
94+ )
95+ obtainCall.Maybe()
96+
97+ renewCall := issuer.EXPECT().Renew(mock.Anything, mock.Anything).RunAndReturn(
98+ func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
99+ if renew != nil {
100+ return renew(ctx, cert)
101+ }
102+ return &cert, nil
103+ },
104+ )
105+ renewCall.Maybe()
106+
107+ return issuer, recorder
84108 }
85109
86110 // defaultStoredCert creates a StoredCertificate with a self-signed TLS cert
@@ -105,65 +129,68 @@ func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, erro
105129 }, nil
106130 }
107131
108-// fakeStore is a hand-rolled implementation of out.CertificateStore that
109-// manages certificates in memory with copy-on-read semantics and a separate
110-// lock for Lock() to avoid deadlock with the data mutex. Generated mocks
111-// cannot easily replicate the Lock/unlock lifecycle used in Reconcile.
112-type fakeStore struct {
113- mu sync.Mutex
114- lockMu sync.Mutex // separate lock for Lock()/unlock to avoid deadlock with mu
115- account *out.ACMEAccount
116- certs []out.StoredCertificate
117- lockHold bool
118-}
119-
120-func (f *fakeStore) LoadAccount(_ context.Context) (*out.ACMEAccount, error) {
121- f.mu.Lock()
122- defer f.mu.Unlock()
123- if f.account == nil {
124- return nil, nil
125- }
126- acct := *f.account
127- return &acct, nil
132+type mockCertificateStoreState struct {
133+ mu sync.Mutex
134+ account *out.ACMEAccount
135+ certs []out.StoredCertificate
128136 }
129137
130-func (f *fakeStore) SaveAccount(_ context.Context, account out.ACMEAccount) error {
131- f.mu.Lock()
132- defer f.mu.Unlock()
133- acct := account
134- f.account = &acct
135- return nil
138+func (s *mockCertificateStoreState) All() []out.StoredCertificate {
139+ s.mu.Lock()
140+ defer s.mu.Unlock()
141+ certs := make([]out.StoredCertificate, len(s.certs))
142+ copy(certs, s.certs)
143+ return certs
136144 }
137145
138-func (f *fakeStore) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
139- f.mu.Lock()
140- defer f.mu.Unlock()
141- out := make([]out.StoredCertificate, len(f.certs))
142- copy(out, f.certs)
143- return out, nil
144-}
146+func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
147+ t.Helper()
148+ state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
149+ store := outmocks.NewMockCertificateStore(t)
145150
146-func (f *fakeStore) Save(_ context.Context, cert out.StoredCertificate) error {
147- f.mu.Lock()
148- defer f.mu.Unlock()
149- for i, c := range f.certs {
150- if c.ID == cert.ID {
151- f.certs[i] = cert
152- return nil
151+ loadAccountCall := store.EXPECT().LoadAccount(mock.Anything).RunAndReturn(func(context.Context) (*out.ACMEAccount, error) {
152+ state.mu.Lock()
153+ defer state.mu.Unlock()
154+ if state.account == nil {
155+ return nil, nil
153156 }
154- }
155- f.certs = append(f.certs, cert)
156- return nil
157-}
157+ account := *state.account
158+ return &account, nil
159+ })
160+ loadAccountCall.Maybe()
158161
159-func (f *fakeStore) Lock(_ context.Context) (func() error, error) {
160- f.lockMu.Lock()
161- f.lockHold = true
162- return func() error {
163- f.lockHold = false
164- f.lockMu.Unlock()
162+ saveAccountCall := store.EXPECT().SaveAccount(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, account out.ACMEAccount) error {
163+ state.mu.Lock()
164+ defer state.mu.Unlock()
165+ accountCopy := account
166+ state.account = &accountCopy
165167 return nil
166- }, nil
168+ })
169+ saveAccountCall.Maybe()
170+
171+ loadAllCall := store.EXPECT().LoadAll(mock.Anything).RunAndReturn(func(context.Context) ([]out.StoredCertificate, error) {
172+ return state.All(), nil
173+ })
174+ loadAllCall.Maybe()
175+
176+ saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
177+ state.mu.Lock()
178+ defer state.mu.Unlock()
179+ for i, existing := range state.certs {
180+ if existing.ID == cert.ID {
181+ state.certs[i] = cert
182+ return nil
183+ }
184+ }
185+ state.certs = append(state.certs, cert)
186+ return nil
187+ })
188+ saveCall.Maybe()
189+
190+ lockCall := store.EXPECT().Lock(mock.Anything).Return(func() error { return nil }, nil)
191+ lockCall.Maybe()
192+
193+ return store, state
167194 }
168195
169196 // ---------------------------------------------------------------------------
@@ -232,8 +259,8 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
232259 {Domain: "app.example.com"},
233260 },
234261 }
235- issuer := &fakeIssuer{}
236- store := &fakeStore{}
262+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
263+ store, storeState := newMockCertificateStore(t)
237264
238265 cfg := Config{
239266 Enabled: true,
@@ -266,10 +293,7 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
266293 require.NoError(t, err)
267294
268295 // Verify the issuer received exactly one order.
269- issuer.mu.Lock()
270- orders := make([]out.CertificateOrder, len(issuer.orders))
271- copy(orders, issuer.orders)
272- issuer.mu.Unlock()
296+ orders := recorder.Orders()
273297
274298 require.Len(t, orders, 1, "expected one order to be placed")
275299 assert.Equal(t, "http01-app.example.com", orders[0].ID)
@@ -277,8 +301,7 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
277301 assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
278302
279303 // Verify the store has the saved certificate.
280- stored, err := store.LoadAll(ctx)
281- require.NoError(t, err)
304+ stored := storeState.All()
282305 require.Len(t, stored, 1)
283306 assert.Equal(t, "http01-app.example.com", stored[0].ID)
284307 assert.Equal(t, []string{"app.example.com"}, stored[0].Names)
@@ -295,19 +318,15 @@ func TestServiceStatusReportsCoverage(t *testing.T) {
295318 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
296319 require.NoError(t, err)
297320
298- store := &fakeStore{
299- certs: []out.StoredCertificate{
300- {
301- ID: "http01-app.example.com",
302- Names: []string{"app.example.com"},
303- Challenge: domain.ACMEChallengeHTTP01,
304- Certificate: tlsCert,
305- FullchainPEM: certPEM,
306- PrivateKeyPEM: keyPEM,
307- NotAfter: time.Now().Add(90 * 24 * time.Hour),
308- },
309- },
310- }
321+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
322+ ID: "http01-app.example.com",
323+ Names: []string{"app.example.com"},
324+ Challenge: domain.ACMEChallengeHTTP01,
325+ Certificate: tlsCert,
326+ FullchainPEM: certPEM,
327+ PrivateKeyPEM: keyPEM,
328+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
329+ })
311330
312331 routes := &fakeRoutes{
313332 routes: []domain.Route{
@@ -364,13 +383,11 @@ func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
364383 {Domain: "app.example.com"},
365384 },
366385 }
367- issuer := &fakeIssuer{
368- // Make Obtain fail so no cert is cached.
369- obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
370- return nil, fmt.Errorf("acme provider unavailable")
371- },
372- }
373- store := &fakeStore{}
386+ // Make Obtain fail so no cert is cached.
387+ issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
388+ return nil, fmt.Errorf("acme provider unavailable")
389+ }, nil)
390+ store, _ := newMockCertificateStore(t)
374391
375392 cfg := Config{
376393 Enabled: true,
@@ -402,8 +419,8 @@ func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
402419 err = svc.Reconcile(ctx)
403420 require.NoError(t, err) // Reconcile swallows per-target obtain errors
404421
405- // GetCertificate for app.example.com should return ErrTLSRouteNotCovered.
406- _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
422+ // GetCertificateForHost for app.example.com should return ErrTLSRouteNotCovered.
423+ _, err = svc.GetCertificateForHost("app.example.com")
407424 require.Error(t, err)
408425 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
409426 assert.Contains(t, err.Error(), "app.example.com")
@@ -424,8 +441,8 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
424441 {Domain: "app.example.com"},
425442 },
426443 }
427- issuer := &fakeIssuer{}
428- store := &fakeStore{}
444+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
445+ store, _ := newMockCertificateStore(t)
429446
430447 cfg := Config{
431448 Enabled: true,
@@ -459,10 +476,10 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
459476 require.Error(t, err)
460477 assert.Contains(t, err.Error(), "derive certificate targets")
461478
462- // GetCertificate for the configured route should return ErrTLSRouteNotCovered,
479+ // GetCertificateForHost for the configured route should return ErrTLSRouteNotCovered,
463480 // not nil,nil, because requiredHosts was set from route hosts before
464481 // the failed DeriveCertificateTargets call.
465- _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})
482+ _, err = svc.GetCertificateForHost("app.example.com")
466483 require.Error(t, err)
467484 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
468485 assert.Contains(t, err.Error(), "app.example.com")
@@ -476,19 +493,15 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
476493 require.NoError(t, err)
477494
478495 // Store a cert with PEM data but empty tls.Certificate.
479- store := &fakeStore{
480- certs: []out.StoredCertificate{
481- {
482- ID: "http01-test.example.com",
483- Names: []string{"test.example.com"},
484- Challenge: domain.ACMEChallengeHTTP01,
485- FullchainPEM: certPEM,
486- PrivateKeyPEM: keyPEM,
487- NotAfter: time.Now().Add(90 * 24 * time.Hour),
488- // Certificate field is zero — will be populated by Load.
489- },
490- },
491- }
496+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
497+ ID: "http01-test.example.com",
498+ Names: []string{"test.example.com"},
499+ Challenge: domain.ACMEChallengeHTTP01,
500+ FullchainPEM: certPEM,
501+ PrivateKeyPEM: keyPEM,
502+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
503+ // Certificate field is zero — will be populated by Load.
504+ })
492505
493506 routes := &fakeRoutes{}
494507 cfg := Config{Enabled: true}
@@ -525,20 +538,16 @@ func TestServiceStatusRedactsSensitiveStrings(t *testing.T) {
525538 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
526539 require.NoError(t, err)
527540
528- store := &fakeStore{
529- certs: []out.StoredCertificate{
530- {
531- ID: "http01-app.example.com",
532- Names: []string{"app.example.com"},
533- Challenge: domain.ACMEChallengeHTTP01,
534- Certificate: tlsCert,
535- FullchainPEM: certPEM,
536- PrivateKeyPEM: keyPEM,
537- NotAfter: time.Now().Add(90 * 24 * time.Hour),
538- LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
539- },
540- },
541- }
541+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
542+ ID: "http01-app.example.com",
543+ Names: []string{"app.example.com"},
544+ Challenge: domain.ACMEChallengeHTTP01,
545+ Certificate: tlsCert,
546+ FullchainPEM: certPEM,
547+ PrivateKeyPEM: keyPEM,
548+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
549+ LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
550+ })
542551
543552 routes := &fakeRoutes{
544553 routes: []domain.Route{
@@ -16,9 +16,11 @@ import (
16 "time"16 "time"
17 17
18 "github.com/stretchr/testify/assert"18 "github.com/stretchr/testify/assert"
19+ "github.com/stretchr/testify/mock"
19 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
20 21
21 "github.com/bnema/gordon/internal/boundaries/out"22 "github.com/bnema/gordon/internal/boundaries/out"
23+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
22 "github.com/bnema/gordon/internal/domain"24 "github.com/bnema/gordon/internal/domain"
23 )25 )
24 26
@@ -53,34 +55,56 @@ func (f *fakeRoutes) GetExternalRoutes() map[string]string {
53 return out55 return out
54 }56 }
55 57
56-// fakeIssuer is a hand-rolled implementation of out.PublicCertificateIssuer58+type mockIssuerRecorder struct {
57-// that records issued orders and delegates to pluggable obtain/renew59+ mu sync.Mutex
58-// functions. Generated mocks are less convenient here because the test needs60+ orders []out.CertificateOrder
59-// to inspect captured orders after the call completes — a real slice on the
60-// fake avoids mock expectation boilerplate.
61-type fakeIssuer struct {
62- mu sync.Mutex
63- orders []out.CertificateOrder // recorded orders
64- obtain func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error)
65- renewFn func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error)
66 }61 }
67 62
68-func (f *fakeIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {63+func (r *mockIssuerRecorder) record(order out.CertificateOrder) {
69- f.mu.Lock()64+ r.mu.Lock()
70- f.orders = append(f.orders, order)65+ defer r.mu.Unlock()
71- f.mu.Unlock()66+ r.orders = append(r.orders, order)
72- if f.obtain != nil {
73- return f.obtain(ctx, order)
74- }
75- // Default: return a valid certificate.
76- return defaultStoredCert(order)
77 }67 }
78 68
79-func (f *fakeIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {69+func (r *mockIssuerRecorder) Orders() []out.CertificateOrder {
80- if f.renewFn != nil {70+ r.mu.Lock()
81- return f.renewFn(ctx, cert)71+ defer r.mu.Unlock()
82- }72+ orders := make([]out.CertificateOrder, len(r.orders))
83- return &cert, nil73+ copy(orders, r.orders)
74+ return orders
75+}
76+
77+func newMockPublicCertificateIssuer(
78+ t *testing.T,
79+ obtain func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error),
80+ renew func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error),
81+) (*outmocks.MockPublicCertificateIssuer, *mockIssuerRecorder) {
82+ t.Helper()
83+ recorder := &mockIssuerRecorder{}
84+ issuer := outmocks.NewMockPublicCertificateIssuer(t)
85+
86+ obtainCall := issuer.EXPECT().Obtain(mock.Anything, mock.Anything).RunAndReturn(
87+ func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
88+ recorder.record(order)
89+ if obtain != nil {
90+ return obtain(ctx, order)
91+ }
92+ return defaultStoredCert(order)
93+ },
94+ )
95+ obtainCall.Maybe()
96+
97+ renewCall := issuer.EXPECT().Renew(mock.Anything, mock.Anything).RunAndReturn(
98+ func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
99+ if renew != nil {
100+ return renew(ctx, cert)
101+ }
102+ return &cert, nil
103+ },
104+ )
105+ renewCall.Maybe()
106+
107+ return issuer, recorder
84 }108 }
85 109
86 // defaultStoredCert creates a StoredCertificate with a self-signed TLS cert110 // defaultStoredCert creates a StoredCertificate with a self-signed TLS cert
@@ -105,65 +129,68 @@ func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, erro
105 }, nil129 }, nil
106 }130 }
107 131
108-// fakeStore is a hand-rolled implementation of out.CertificateStore that132+type mockCertificateStoreState struct {
109-// manages certificates in memory with copy-on-read semantics and a separate133+ mu sync.Mutex
110-// lock for Lock() to avoid deadlock with the data mutex. Generated mocks134+ account *out.ACMEAccount
111-// cannot easily replicate the Lock/unlock lifecycle used in Reconcile.135+ certs []out.StoredCertificate
112-type fakeStore struct {
113- mu sync.Mutex
114- lockMu sync.Mutex // separate lock for Lock()/unlock to avoid deadlock with mu
115- account *out.ACMEAccount
116- certs []out.StoredCertificate
117- lockHold bool
118-}
119-
120-func (f *fakeStore) LoadAccount(_ context.Context) (*out.ACMEAccount, error) {
121- f.mu.Lock()
122- defer f.mu.Unlock()
123- if f.account == nil {
124- return nil, nil
125- }
126- acct := *f.account
127- return &acct, nil
128 }136 }
129 137
130-func (f *fakeStore) SaveAccount(_ context.Context, account out.ACMEAccount) error {138+func (s *mockCertificateStoreState) All() []out.StoredCertificate {
131- f.mu.Lock()139+ s.mu.Lock()
132- defer f.mu.Unlock()140+ defer s.mu.Unlock()
133- acct := account141+ certs := make([]out.StoredCertificate, len(s.certs))
134- f.account = &acct142+ copy(certs, s.certs)
135- return nil143+ return certs
136 }144 }
137 145
138-func (f *fakeStore) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {146+func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
139- f.mu.Lock()147+ t.Helper()
140- defer f.mu.Unlock()148+ state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
141- out := make([]out.StoredCertificate, len(f.certs))149+ store := outmocks.NewMockCertificateStore(t)
142- copy(out, f.certs)
143- return out, nil
144-}
145 150
146-func (f *fakeStore) Save(_ context.Context, cert out.StoredCertificate) error {151+ loadAccountCall := store.EXPECT().LoadAccount(mock.Anything).RunAndReturn(func(context.Context) (*out.ACMEAccount, error) {
147- f.mu.Lock()152+ state.mu.Lock()
148- defer f.mu.Unlock()153+ defer state.mu.Unlock()
149- for i, c := range f.certs {154+ if state.account == nil {
150- if c.ID == cert.ID {155+ return nil, nil
151- f.certs[i] = cert
152- return nil
153 }156 }
154- }157+ account := *state.account
155- f.certs = append(f.certs, cert)158+ return &account, nil
156- return nil159+ })
157-}160+ loadAccountCall.Maybe()
158 161
159-func (f *fakeStore) Lock(_ context.Context) (func() error, error) {162+ saveAccountCall := store.EXPECT().SaveAccount(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, account out.ACMEAccount) error {
160- f.lockMu.Lock()163+ state.mu.Lock()
161- f.lockHold = true164+ defer state.mu.Unlock()
162- return func() error {165+ accountCopy := account
163- f.lockHold = false166+ state.account = &accountCopy
164- f.lockMu.Unlock()
165 return nil167 return nil
166- }, nil168+ })
169+ saveAccountCall.Maybe()
170+
171+ loadAllCall := store.EXPECT().LoadAll(mock.Anything).RunAndReturn(func(context.Context) ([]out.StoredCertificate, error) {
172+ return state.All(), nil
173+ })
174+ loadAllCall.Maybe()
175+
176+ saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
177+ state.mu.Lock()
178+ defer state.mu.Unlock()
179+ for i, existing := range state.certs {
180+ if existing.ID == cert.ID {
181+ state.certs[i] = cert
182+ return nil
183+ }
184+ }
185+ state.certs = append(state.certs, cert)
186+ return nil
187+ })
188+ saveCall.Maybe()
189+
190+ lockCall := store.EXPECT().Lock(mock.Anything).Return(func() error { return nil }, nil)
191+ lockCall.Maybe()
192+
193+ return store, state
167 }194 }
168 195
169 // ---------------------------------------------------------------------------196 // ---------------------------------------------------------------------------
@@ -232,8 +259,8 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
232 {Domain: "app.example.com"},259 {Domain: "app.example.com"},
233 },260 },
234 }261 }
235- issuer := &fakeIssuer{}262+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
236- store := &fakeStore{}263+ store, storeState := newMockCertificateStore(t)
237 264
238 cfg := Config{265 cfg := Config{
239 Enabled: true,266 Enabled: true,
@@ -266,10 +293,7 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
266 require.NoError(t, err)293 require.NoError(t, err)
267 294
268 // Verify the issuer received exactly one order.295 // Verify the issuer received exactly one order.
269- issuer.mu.Lock()296+ orders := recorder.Orders()
270- orders := make([]out.CertificateOrder, len(issuer.orders))
271- copy(orders, issuer.orders)
272- issuer.mu.Unlock()
273 297
274 require.Len(t, orders, 1, "expected one order to be placed")298 require.Len(t, orders, 1, "expected one order to be placed")
275 assert.Equal(t, "http01-app.example.com", orders[0].ID)299 assert.Equal(t, "http01-app.example.com", orders[0].ID)
@@ -277,8 +301,7 @@ func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
277 assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)301 assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
278 302
279 // Verify the store has the saved certificate.303 // Verify the store has the saved certificate.
280- stored, err := store.LoadAll(ctx)304+ stored := storeState.All()
281- require.NoError(t, err)
282 require.Len(t, stored, 1)305 require.Len(t, stored, 1)
283 assert.Equal(t, "http01-app.example.com", stored[0].ID)306 assert.Equal(t, "http01-app.example.com", stored[0].ID)
284 assert.Equal(t, []string{"app.example.com"}, stored[0].Names)307 assert.Equal(t, []string{"app.example.com"}, stored[0].Names)
@@ -295,19 +318,15 @@ func TestServiceStatusReportsCoverage(t *testing.T) {
295 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)318 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
296 require.NoError(t, err)319 require.NoError(t, err)
297 320
298- store := &fakeStore{321+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
299- certs: []out.StoredCertificate{322+ ID: "http01-app.example.com",
300- {323+ Names: []string{"app.example.com"},
301- ID: "http01-app.example.com",324+ Challenge: domain.ACMEChallengeHTTP01,
302- Names: []string{"app.example.com"},325+ Certificate: tlsCert,
303- Challenge: domain.ACMEChallengeHTTP01,326+ FullchainPEM: certPEM,
304- Certificate: tlsCert,327+ PrivateKeyPEM: keyPEM,
305- FullchainPEM: certPEM,328+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
306- PrivateKeyPEM: keyPEM,329+ })
307- NotAfter: time.Now().Add(90 * 24 * time.Hour),
308- },
309- },
310- }
311 330
312 routes := &fakeRoutes{331 routes := &fakeRoutes{
313 routes: []domain.Route{332 routes: []domain.Route{
@@ -364,13 +383,11 @@ func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
364 {Domain: "app.example.com"},383 {Domain: "app.example.com"},
365 },384 },
366 }385 }
367- issuer := &fakeIssuer{386+ // Make Obtain fail so no cert is cached.
368- // Make Obtain fail so no cert is cached.387+ issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
369- obtain: func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {388+ return nil, fmt.Errorf("acme provider unavailable")
370- return nil, fmt.Errorf("acme provider unavailable")389+ }, nil)
371- },390+ store, _ := newMockCertificateStore(t)
372- }
373- store := &fakeStore{}
374 391
375 cfg := Config{392 cfg := Config{
376 Enabled: true,393 Enabled: true,
@@ -402,8 +419,8 @@ func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
402 err = svc.Reconcile(ctx)419 err = svc.Reconcile(ctx)
403 require.NoError(t, err) // Reconcile swallows per-target obtain errors420 require.NoError(t, err) // Reconcile swallows per-target obtain errors
404 421
405- // GetCertificate for app.example.com should return ErrTLSRouteNotCovered.422+ // GetCertificateForHost for app.example.com should return ErrTLSRouteNotCovered.
406- _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})423+ _, err = svc.GetCertificateForHost("app.example.com")
407 require.Error(t, err)424 require.Error(t, err)
408 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)425 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
409 assert.Contains(t, err.Error(), "app.example.com")426 assert.Contains(t, err.Error(), "app.example.com")
@@ -424,8 +441,8 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
424 {Domain: "app.example.com"},441 {Domain: "app.example.com"},
425 },442 },
426 }443 }
427- issuer := &fakeIssuer{}444+ issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
428- store := &fakeStore{}445+ store, _ := newMockCertificateStore(t)
429 446
430 cfg := Config{447 cfg := Config{
431 Enabled: true,448 Enabled: true,
@@ -459,10 +476,10 @@ func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *test
459 require.Error(t, err)476 require.Error(t, err)
460 assert.Contains(t, err.Error(), "derive certificate targets")477 assert.Contains(t, err.Error(), "derive certificate targets")
461 478
462- // GetCertificate for the configured route should return ErrTLSRouteNotCovered,479+ // GetCertificateForHost for the configured route should return ErrTLSRouteNotCovered,
463 // not nil,nil, because requiredHosts was set from route hosts before480 // not nil,nil, because requiredHosts was set from route hosts before
464 // the failed DeriveCertificateTargets call.481 // the failed DeriveCertificateTargets call.
465- _, err = svc.GetCertificate(&tls.ClientHelloInfo{ServerName: "app.example.com"})482+ _, err = svc.GetCertificateForHost("app.example.com")
466 require.Error(t, err)483 require.Error(t, err)
467 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)484 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
468 assert.Contains(t, err.Error(), "app.example.com")485 assert.Contains(t, err.Error(), "app.example.com")
@@ -476,19 +493,15 @@ func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
476 require.NoError(t, err)493 require.NoError(t, err)
477 494
478 // Store a cert with PEM data but empty tls.Certificate.495 // Store a cert with PEM data but empty tls.Certificate.
479- store := &fakeStore{496+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
480- certs: []out.StoredCertificate{497+ ID: "http01-test.example.com",
481- {498+ Names: []string{"test.example.com"},
482- ID: "http01-test.example.com",499+ Challenge: domain.ACMEChallengeHTTP01,
483- Names: []string{"test.example.com"},500+ FullchainPEM: certPEM,
484- Challenge: domain.ACMEChallengeHTTP01,501+ PrivateKeyPEM: keyPEM,
485- FullchainPEM: certPEM,502+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
486- PrivateKeyPEM: keyPEM,503+ // Certificate field is zero — will be populated by Load.
487- NotAfter: time.Now().Add(90 * 24 * time.Hour),504+ })
488- // Certificate field is zero — will be populated by Load.
489- },
490- },
491- }
492 505
493 routes := &fakeRoutes{}506 routes := &fakeRoutes{}
494 cfg := Config{Enabled: true}507 cfg := Config{Enabled: true}
@@ -525,20 +538,16 @@ func TestServiceStatusRedactsSensitiveStrings(t *testing.T) {
525 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)538 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
526 require.NoError(t, err)539 require.NoError(t, err)
527 540
528- store := &fakeStore{541+ store, _ := newMockCertificateStore(t, out.StoredCertificate{
529- certs: []out.StoredCertificate{542+ ID: "http01-app.example.com",
530- {543+ Names: []string{"app.example.com"},
531- ID: "http01-app.example.com",544+ Challenge: domain.ACMEChallengeHTTP01,
532- Names: []string{"app.example.com"},545+ Certificate: tlsCert,
533- Challenge: domain.ACMEChallengeHTTP01,546+ FullchainPEM: certPEM,
534- Certificate: tlsCert,547+ PrivateKeyPEM: keyPEM,
535- FullchainPEM: certPEM,548+ NotAfter: time.Now().Add(90 * 24 * time.Hour),
536- PrivateKeyPEM: keyPEM,549+ LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
537- NotAfter: time.Now().Add(90 * 24 * time.Hour),550+ })
538- LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
539- },
540- },
541- }
542 551
543 routes := &fakeRoutes{552 routes := &fakeRoutes{
544 routes: []domain.Route{553 routes: []domain.Route{
M internal/usecase/publictls/targets.go +18 -12
@@ -17,11 +17,6 @@ type CertificateTarget struct {
1717 Challenge domain.ACMEChallengeMode
1818 }
1919
20-// zoneResolver resolves a hostname to its Cloudflare DNS zone.
21-type zoneResolver interface {
22- FindZone(ctx context.Context, host string) (out.CloudflareZone, error)
23-}
24-
2520 // DeriveCertificateTargets derives certificate targets from routes and external
2621 // routes based on the given ACME challenge mode.
2722 func DeriveCertificateTargets(
@@ -29,7 +24,7 @@ func DeriveCertificateTargets(
2924 mode domain.ACMEChallengeMode,
3025 routes []domain.Route,
3126 external map[string]string,
32- resolver zoneResolver,
27+ resolver out.CloudflareZoneResolver,
3328 ) ([]CertificateTarget, error) {
3429 hosts := routeHosts(routes, external)
3530
@@ -86,7 +81,7 @@ func deriveHTTP01Targets(hosts []string) []CertificateTarget {
8681 }
8782
8883 // deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge.
89-func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolver) ([]CertificateTarget, error) {
84+func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.CloudflareZoneResolver) ([]CertificateTarget, error) {
9085 if resolver == nil {
9186 return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones")
9287 }
@@ -98,7 +93,10 @@ func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolv
9893 if err != nil {
9994 return nil, fmt.Errorf("find zone for %s: %w", host, err)
10095 }
101- base := wildcardBase(host, zone.Name)
96+ base, err := wildcardBase(host, zone.Name)
97+ if err != nil {
98+ return nil, fmt.Errorf("find wildcard base for %s: %w", host, err)
99+ }
102100 if _, exists := baseSeen[base]; !exists {
103101 baseSeen[base] = struct{}{}
104102 bases = append(bases, base)
@@ -124,9 +122,17 @@ func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolv
124122 // If host is a direct child of zone, returns zone.
125123 // If host is deeper (e.g. api.prod.example.com within example.com),
126124 // returns the parent label under the zone (prod.example.com).
127-func wildcardBase(host, zone string) string {
125+func wildcardBase(host, zone string) (string, error) {
126+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
127+ zone = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zone)), ".")
128+ if host == "" || zone == "" {
129+ return "", fmt.Errorf("host and zone must be non-empty")
130+ }
128131 if host == zone {
129- return zone
132+ return zone, nil
133+ }
134+ if !strings.HasSuffix(host, "."+zone) {
135+ return "", fmt.Errorf("zone %q does not match host %q", zone, host)
130136 }
131137
132138 // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts.
@@ -134,11 +140,11 @@ func wildcardBase(host, zone string) string {
134140
135141 if !strings.Contains(trimmed, ".") {
136142 // Direct child of the zone (e.g. app.example.com).
137- return zone
143+ return zone, nil
138144 }
139145
140146 // Deeper than direct child: extract the parent under the zone.
141147 // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod"
142148 _, parent, _ := strings.Cut(trimmed, ".")
143- return parent + "." + zone
149+ return parent + "." + zone, nil
144150 }
@@ -17,11 +17,6 @@ type CertificateTarget struct {
17 Challenge domain.ACMEChallengeMode17 Challenge domain.ACMEChallengeMode
18 }18 }
19 19
20-// zoneResolver resolves a hostname to its Cloudflare DNS zone.
21-type zoneResolver interface {
22- FindZone(ctx context.Context, host string) (out.CloudflareZone, error)
23-}
24-
25 // DeriveCertificateTargets derives certificate targets from routes and external20 // DeriveCertificateTargets derives certificate targets from routes and external
26 // routes based on the given ACME challenge mode.21 // routes based on the given ACME challenge mode.
27 func DeriveCertificateTargets(22 func DeriveCertificateTargets(
@@ -29,7 +24,7 @@ func DeriveCertificateTargets(
29 mode domain.ACMEChallengeMode,24 mode domain.ACMEChallengeMode,
30 routes []domain.Route,25 routes []domain.Route,
31 external map[string]string,26 external map[string]string,
32- resolver zoneResolver,27+ resolver out.CloudflareZoneResolver,
33 ) ([]CertificateTarget, error) {28 ) ([]CertificateTarget, error) {
34 hosts := routeHosts(routes, external)29 hosts := routeHosts(routes, external)
35 30
@@ -86,7 +81,7 @@ func deriveHTTP01Targets(hosts []string) []CertificateTarget {
86 }81 }
87 82
88 // deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge.83 // deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge.
89-func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolver) ([]CertificateTarget, error) {84+func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.CloudflareZoneResolver) ([]CertificateTarget, error) {
90 if resolver == nil {85 if resolver == nil {
91 return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones")86 return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones")
92 }87 }
@@ -98,7 +93,10 @@ func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolv
98 if err != nil {93 if err != nil {
99 return nil, fmt.Errorf("find zone for %s: %w", host, err)94 return nil, fmt.Errorf("find zone for %s: %w", host, err)
100 }95 }
101- base := wildcardBase(host, zone.Name)96+ base, err := wildcardBase(host, zone.Name)
97+ if err != nil {
98+ return nil, fmt.Errorf("find wildcard base for %s: %w", host, err)
99+ }
102 if _, exists := baseSeen[base]; !exists {100 if _, exists := baseSeen[base]; !exists {
103 baseSeen[base] = struct{}{}101 baseSeen[base] = struct{}{}
104 bases = append(bases, base)102 bases = append(bases, base)
@@ -124,9 +122,17 @@ func deriveDNS01Targets(ctx context.Context, hosts []string, resolver zoneResolv
124 // If host is a direct child of zone, returns zone.122 // If host is a direct child of zone, returns zone.
125 // If host is deeper (e.g. api.prod.example.com within example.com),123 // If host is deeper (e.g. api.prod.example.com within example.com),
126 // returns the parent label under the zone (prod.example.com).124 // returns the parent label under the zone (prod.example.com).
127-func wildcardBase(host, zone string) string {125+func wildcardBase(host, zone string) (string, error) {
126+ host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
127+ zone = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zone)), ".")
128+ if host == "" || zone == "" {
129+ return "", fmt.Errorf("host and zone must be non-empty")
130+ }
128 if host == zone {131 if host == zone {
129- return zone132+ return zone, nil
133+ }
134+ if !strings.HasSuffix(host, "."+zone) {
135+ return "", fmt.Errorf("zone %q does not match host %q", zone, host)
130 }136 }
131 137
132 // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts.138 // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts.
@@ -134,11 +140,11 @@ func wildcardBase(host, zone string) string {
134 140
135 if !strings.Contains(trimmed, ".") {141 if !strings.Contains(trimmed, ".") {
136 // Direct child of the zone (e.g. app.example.com).142 // Direct child of the zone (e.g. app.example.com).
137- return zone143+ return zone, nil
138 }144 }
139 145
140 // Deeper than direct child: extract the parent under the zone.146 // Deeper than direct child: extract the parent under the zone.
141 // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod"147 // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod"
142 _, parent, _ := strings.Cut(trimmed, ".")148 _, parent, _ := strings.Cut(trimmed, ".")
143- return parent + "." + zone149+ return parent + "." + zone, nil
144 }150 }
M internal/usecase/publictls/targets_test.go +27 -50
@@ -5,75 +5,54 @@ import (
55 "testing"
66
77 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
89 "github.com/stretchr/testify/require"
910
1011 "github.com/bnema/gordon/internal/boundaries/out"
12+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
1113 "github.com/bnema/gordon/internal/domain"
1214 )
1315
14-// fixedZoneResolver returns a fixed zone name for all lookups.
15-type fixedZoneResolver struct {
16- zone string
17-}
18-
19-func (f fixedZoneResolver) FindZone(_ context.Context, _ string) (out.CloudflareZone, error) {
20- return out.CloudflareZone{Name: f.zone}, nil
16+func newZoneResolverMock(t *testing.T, zone string) *outmocks.MockCloudflareZoneResolver {
17+ t.Helper()
18+ resolver := outmocks.NewMockCloudflareZoneResolver(t)
19+ resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: zone}, nil).Maybe()
20+ return resolver
2121 }
2222
2323 func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {
24- routes := []domain.Route{
25- {Domain: "app.example.com"},
26- {Domain: "registry.example.com"},
27- }
28- mode := domain.ACMEChallengeHTTP01
29-
30- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
24+ routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "registry.example.com"}}
25+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
3126 require.NoError(t, err)
3227 require.Len(t, targets, 2)
3328
3429 assert.Equal(t, "http01-app.example.com", targets[0].ID)
3530 assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
3631 assert.Equal(t, domain.ACMEChallengeHTTP01, targets[0].Challenge)
37-
3832 assert.Equal(t, "http01-registry.example.com", targets[1].ID)
3933 assert.Equal(t, []string{"registry.example.com"}, targets[1].Names)
4034 assert.Equal(t, domain.ACMEChallengeHTTP01, targets[1].Challenge)
4135 }
4236
4337 func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
44- routes := []domain.Route{
45- {Domain: "app.example.com"},
46- {Domain: "api.prod.example.com"},
47- {Domain: "example.com"},
48- }
49- mode := domain.ACMEChallengeCloudflareDNS01
50- resolver := fixedZoneResolver{zone: "example.com"}
51-
52- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, resolver)
38+ routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}}
39+ resolver := newZoneResolverMock(t, "example.com")
40+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
5341 require.NoError(t, err)
5442 require.Len(t, targets, 2)
5543
56- // First target: example.com (includes base and wildcard)
5744 assert.Equal(t, "dns01-example.com", targets[0].ID)
5845 assert.Equal(t, []string{"example.com", "*.example.com"}, targets[0].Names)
5946 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[0].Challenge)
60-
61- // Second target: prod.example.com (includes base and wildcard)
6247 assert.Equal(t, "dns01-prod.example.com", targets[1].ID)
6348 assert.Equal(t, []string{"prod.example.com", "*.prod.example.com"}, targets[1].Names)
6449 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)
6550 }
6651
6752 func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
68- routes := []domain.Route{
69- {Domain: "app.example.com"},
70- }
71- external := map[string]string{
72- "external.example.com": "127.0.0.1:8080",
73- }
74- mode := domain.ACMEChallengeHTTP01
75-
76- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, external, nil)
53+ routes := []domain.Route{{Domain: "app.example.com"}}
54+ external := map[string]string{"external.example.com": "127.0.0.1:8080"}
55+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil)
7756 require.NoError(t, err)
7857 require.Len(t, targets, 2)
7958
@@ -83,27 +62,25 @@ func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
8362 }
8463
8564 func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {
86- routes := []domain.Route{
87- {Domain: "app.example.com"},
88- }
89- mode := domain.ACMEChallengeCloudflareDNS01
90-
91- _, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
65+ routes := []domain.Route{{Domain: "app.example.com"}}
66+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil)
9267 require.Error(t, err)
9368 assert.Contains(t, err.Error(), "resolver is nil")
9469 }
9570
96-func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {
97- routes := []domain.Route{
98- {Domain: "App.Example.Com"},
99- {Domain: "app.example.com"},
100- }
101- mode := domain.ACMEChallengeHTTP01
71+func TestDeriveTargets_DNS01MismatchedZone_ReturnsError(t *testing.T) {
72+ routes := []domain.Route{{Domain: "app.example.com"}}
73+ resolver := newZoneResolverMock(t, "other.test")
74+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
75+ require.Error(t, err)
76+ assert.Contains(t, err.Error(), "does not match host")
77+}
10278
103- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
79+func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {
80+ routes := []domain.Route{{Domain: "App.Example.Com"}, {Domain: "app.example.com"}}
81+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
10482 require.NoError(t, err)
10583 require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")
106-
10784 assert.Equal(t, "http01-app.example.com", targets[0].ID)
10885 assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
10986 }
@@ -5,75 +5,54 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
8 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
9 10
10 "github.com/bnema/gordon/internal/boundaries/out"11 "github.com/bnema/gordon/internal/boundaries/out"
12+ outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
11 "github.com/bnema/gordon/internal/domain"13 "github.com/bnema/gordon/internal/domain"
12 )14 )
13 15
14-// fixedZoneResolver returns a fixed zone name for all lookups.16+func newZoneResolverMock(t *testing.T, zone string) *outmocks.MockCloudflareZoneResolver {
15-type fixedZoneResolver struct {17+ t.Helper()
16- zone string18+ resolver := outmocks.NewMockCloudflareZoneResolver(t)
17-}19+ resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: zone}, nil).Maybe()
18-20+ return resolver
19-func (f fixedZoneResolver) FindZone(_ context.Context, _ string) (out.CloudflareZone, error) {
20- return out.CloudflareZone{Name: f.zone}, nil
21 }21 }
22 22
23 func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {23 func TestDeriveTargets_HTTP01PerRoute(t *testing.T) {
24- routes := []domain.Route{24+ routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "registry.example.com"}}
25- {Domain: "app.example.com"},25+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
26- {Domain: "registry.example.com"},
27- }
28- mode := domain.ACMEChallengeHTTP01
29-
30- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
31 require.NoError(t, err)26 require.NoError(t, err)
32 require.Len(t, targets, 2)27 require.Len(t, targets, 2)
33 28
34 assert.Equal(t, "http01-app.example.com", targets[0].ID)29 assert.Equal(t, "http01-app.example.com", targets[0].ID)
35 assert.Equal(t, []string{"app.example.com"}, targets[0].Names)30 assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
36 assert.Equal(t, domain.ACMEChallengeHTTP01, targets[0].Challenge)31 assert.Equal(t, domain.ACMEChallengeHTTP01, targets[0].Challenge)
37-
38 assert.Equal(t, "http01-registry.example.com", targets[1].ID)32 assert.Equal(t, "http01-registry.example.com", targets[1].ID)
39 assert.Equal(t, []string{"registry.example.com"}, targets[1].Names)33 assert.Equal(t, []string{"registry.example.com"}, targets[1].Names)
40 assert.Equal(t, domain.ACMEChallengeHTTP01, targets[1].Challenge)34 assert.Equal(t, domain.ACMEChallengeHTTP01, targets[1].Challenge)
41 }35 }
42 36
43 func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {37 func TestDeriveTargets_DNS01WildcardBases(t *testing.T) {
44- routes := []domain.Route{38+ routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}}
45- {Domain: "app.example.com"},39+ resolver := newZoneResolverMock(t, "example.com")
46- {Domain: "api.prod.example.com"},40+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
47- {Domain: "example.com"},
48- }
49- mode := domain.ACMEChallengeCloudflareDNS01
50- resolver := fixedZoneResolver{zone: "example.com"}
51-
52- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, resolver)
53 require.NoError(t, err)41 require.NoError(t, err)
54 require.Len(t, targets, 2)42 require.Len(t, targets, 2)
55 43
56- // First target: example.com (includes base and wildcard)
57 assert.Equal(t, "dns01-example.com", targets[0].ID)44 assert.Equal(t, "dns01-example.com", targets[0].ID)
58 assert.Equal(t, []string{"example.com", "*.example.com"}, targets[0].Names)45 assert.Equal(t, []string{"example.com", "*.example.com"}, targets[0].Names)
59 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[0].Challenge)46 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[0].Challenge)
60-
61- // Second target: prod.example.com (includes base and wildcard)
62 assert.Equal(t, "dns01-prod.example.com", targets[1].ID)47 assert.Equal(t, "dns01-prod.example.com", targets[1].ID)
63 assert.Equal(t, []string{"prod.example.com", "*.prod.example.com"}, targets[1].Names)48 assert.Equal(t, []string{"prod.example.com", "*.prod.example.com"}, targets[1].Names)
64 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)49 assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge)
65 }50 }
66 51
67 func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {52 func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
68- routes := []domain.Route{53+ routes := []domain.Route{{Domain: "app.example.com"}}
69- {Domain: "app.example.com"},54+ external := map[string]string{"external.example.com": "127.0.0.1:8080"}
70- }55+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil)
71- external := map[string]string{
72- "external.example.com": "127.0.0.1:8080",
73- }
74- mode := domain.ACMEChallengeHTTP01
75-
76- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, external, nil)
77 require.NoError(t, err)56 require.NoError(t, err)
78 require.Len(t, targets, 2)57 require.Len(t, targets, 2)
79 58
@@ -83,27 +62,25 @@ func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) {
83 }62 }
84 63
85 func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {64 func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) {
86- routes := []domain.Route{65+ routes := []domain.Route{{Domain: "app.example.com"}}
87- {Domain: "app.example.com"},66+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil)
88- }
89- mode := domain.ACMEChallengeCloudflareDNS01
90-
91- _, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)
92 require.Error(t, err)67 require.Error(t, err)
93 assert.Contains(t, err.Error(), "resolver is nil")68 assert.Contains(t, err.Error(), "resolver is nil")
94 }69 }
95 70
96-func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {71+func TestDeriveTargets_DNS01MismatchedZone_ReturnsError(t *testing.T) {
97- routes := []domain.Route{72+ routes := []domain.Route{{Domain: "app.example.com"}}
98- {Domain: "App.Example.Com"},73+ resolver := newZoneResolverMock(t, "other.test")
99- {Domain: "app.example.com"},74+ _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver)
100- }75+ require.Error(t, err)
101- mode := domain.ACMEChallengeHTTP0176+ assert.Contains(t, err.Error(), "does not match host")
77+}
102 78
103- targets, err := DeriveCertificateTargets(context.Background(), mode, routes, nil, nil)79+func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) {
80+ routes := []domain.Route{{Domain: "App.Example.Com"}, {Domain: "app.example.com"}}
81+ targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil)
104 require.NoError(t, err)82 require.NoError(t, err)
105 require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")83 require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target")
106-
107 assert.Equal(t, "http01-app.example.com", targets[0].ID)84 assert.Equal(t, "http01-app.example.com", targets[0].ID)
108 assert.Equal(t, []string{"app.example.com"}, targets[0].Names)85 assert.Equal(t, []string{"app.example.com"}, targets[0].Names)
109 }86 }

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