fix: address CodeRabbit review findings
- Guard leaf cert issuance when intermediate is <1min from expiry - Remove dead HasPrefix check in onboarding handler - Add nil guard on singleflight result in GetCertificate - Omit redundant :443 in HTTPS redirect URLs - Reorder initPKI to check TLSPort before TLS file validation - Add lifetime rationale comments to PKI constants - Wrap ca export file errors with path context - Deduplicate CLI logger config into shared cliLogConfig - Generate RouteChecker mock and use it in pki + integration tests - Convert onboarding handler tests to httptest.NewServer - Document default port changes in upgrading guide
- commit
- 29dcc3b125521af615eb43ade6554e254876f09d
- author
- brice · · 2026-03-31 04:50 UTC
- parent
- 2604a62
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +282 -88
M .mockery.yaml +1 -0
| @@ -24,6 +24,7 @@ packages: | |||
| 24 | 24 | DomainSecretStore: | |
| 25 | 25 | RateLimiter: | |
| 26 | 26 | BackupStorage: | |
| 27 | + | RouteChecker: | |
| 27 | 28 | github.com/bnema/gordon/internal/boundaries/in: | |
| 28 | 29 | interfaces: | |
| 29 | 30 | ContainerService: | |
| @@ -24,6 +24,7 @@ packages: | |||||
| 24 | DomainSecretStore: | 24 | DomainSecretStore: | ||
| 25 | RateLimiter: | 25 | RateLimiter: | ||
| 26 | BackupStorage: | 26 | BackupStorage: | ||
| 27 | + | RouteChecker: | |||
| 27 | github.com/bnema/gordon/internal/boundaries/in: | 28 | github.com/bnema/gordon/internal/boundaries/in: | ||
| 28 | interfaces: | 29 | interfaces: | ||
| 29 | ContainerService: | 30 | ContainerService: | ||
M docs/upgrading.md +31 -0
| @@ -2,6 +2,37 @@ | |||
| 2 | 2 | ||
| 3 | 3 | This guide covers breaking changes and migration steps between major versions. | |
| 4 | 4 | ||
| 5 | + | ## v2.30.0 to v2.31.0 | |
| 6 | + | ||
| 7 | + | ### Breaking: Default Port Changes | |
| 8 | + | ||
| 9 | + | Gordon now defaults to unprivileged ports that work without root or firewall rules: | |
| 10 | + | ||
| 11 | + | | Setting | Old default | New default | | |
| 12 | + | |---------|-------------|-------------| | |
| 13 | + | | `server.port` | `80` | `8088` | | |
| 14 | + | | `server.registry_port` | `5000` | `5000` (unchanged) | | |
| 15 | + | | `server.tls_port` | *(n/a)* | `8443` | | |
| 16 | + | ||
| 17 | + | **If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly: | |
| 18 | + | ||
| 19 | + | ```toml | |
| 20 | + | [server] | |
| 21 | + | port = 80 | |
| 22 | + | ``` | |
| 23 | + | ||
| 24 | + | Or set up firewall port forwarding to the new defaults: | |
| 25 | + | ||
| 26 | + | ```bash | |
| 27 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |
| 28 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |
| 29 | + | sudo firewall-cmd --reload | |
| 30 | + | ``` | |
| 31 | + | ||
| 32 | + | ### New: Internal Certificate Authority | |
| 33 | + | ||
| 34 | + | Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details. | |
| 35 | + | ||
| 5 | 36 | ## v2.16.0 to v2.30.0 | |
| 6 | 37 | ||
| 7 | 38 | ### Breaking: Password Authentication Removed | |
| @@ -2,6 +2,37 @@ | |||||
| 2 | 2 | ||||
| 3 | This guide covers breaking changes and migration steps between major versions. | 3 | This guide covers breaking changes and migration steps between major versions. | ||
| 4 | 4 | ||||
| 5 | + | ## v2.30.0 to v2.31.0 | |||
| 6 | + | ||||
| 7 | + | ### Breaking: Default Port Changes | |||
| 8 | + | ||||
| 9 | + | Gordon now defaults to unprivileged ports that work without root or firewall rules: | |||
| 10 | + | ||||
| 11 | + | | Setting | Old default | New default | | |||
| 12 | + | |---------|-------------|-------------| | |||
| 13 | + | | `server.port` | `80` | `8088` | | |||
| 14 | + | | `server.registry_port` | `5000` | `5000` (unchanged) | | |||
| 15 | + | | `server.tls_port` | *(n/a)* | `8443` | | |||
| 16 | + | ||||
| 17 | + | **If you relied on the old defaults** (no explicit `port` in your config), add the port explicitly: | |||
| 18 | + | ||||
| 19 | + | ```toml | |||
| 20 | + | [server] | |||
| 21 | + | port = 80 | |||
| 22 | + | ``` | |||
| 23 | + | ||||
| 24 | + | Or set up firewall port forwarding to the new defaults: | |||
| 25 | + | ||||
| 26 | + | ```bash | |||
| 27 | + | sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088 | |||
| 28 | + | sudo firewall-cmd --permanent --add-forward-port=port=443:proto=tcp:toport=8443 | |||
| 29 | + | sudo firewall-cmd --reload | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | ### New: Internal Certificate Authority | |||
| 33 | + | ||||
| 34 | + | Gordon now includes an internal CA for automatic on-demand TLS. Set `tls_port = 0` to disable. See [Server Configuration](./config/server.md#internal-ca-and-tls) for details. | |||
| 35 | + | ||||
| 5 | ## v2.16.0 to v2.30.0 | 36 | ## v2.16.0 to v2.30.0 | ||
| 6 | 37 | ||||
| 7 | ### Breaking: Password Authentication Removed | 38 | ### Breaking: Password Authentication Removed | ||
M internal/adapters/in/cli/ca.go +2 -2
| @@ -126,10 +126,10 @@ func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) erro | |||
| 126 | 126 | ||
| 127 | 127 | if outPath != "" { | |
| 128 | 128 | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | |
| 129 | - | return err | |
| 129 | + | return fmt.Errorf("create directory for %s: %w", outPath, err) | |
| 130 | 130 | } | |
| 131 | 131 | if err := os.WriteFile(outPath, rootPEM, 0600); err != nil { | |
| 132 | - | return err | |
| 132 | + | return fmt.Errorf("write certificate to %s: %w", outPath, err) | |
| 133 | 133 | } | |
| 134 | 134 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | |
| 135 | 135 | } | |
| @@ -126,10 +126,10 @@ func runCAExport(_ context.Context, out io.Writer, dataDir, outPath string) erro | |||||
| 126 | 126 | ||||
| 127 | if outPath != "" { | 127 | if outPath != "" { | ||
| 128 | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | 128 | if err := os.MkdirAll(filepath.Dir(outPath), 0750); err != nil { | ||
| 129 | - | return err | 129 | + | return fmt.Errorf("create directory for %s: %w", outPath, err) |
| 130 | } | 130 | } | ||
| 131 | if err := os.WriteFile(outPath, rootPEM, 0600); err != nil { | 131 | if err := os.WriteFile(outPath, rootPEM, 0600); err != nil { | ||
| 132 | - | return err | 132 | + | return fmt.Errorf("write certificate to %s: %w", outPath, err) |
| 133 | } | 133 | } | ||
| 134 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | 134 | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Root CA certificate written to %s", outPath))) | ||
| 135 | } | 135 | } | ||
M internal/adapters/in/cli/local.go +1 -5
| @@ -67,11 +67,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||
| 67 | 67 | // Config file not found is OK for some operations | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | - | // Create a minimal logger for CLI operations | |
| 71 | - | log := zerowrap.New(zerowrap.Config{ | |
| 72 | - | Level: "warn", | |
| 73 | - | Format: "console", | |
| 74 | - | }) | |
| 70 | + | log := zerowrap.New(cliLogConfig) | |
| 75 | 71 | ||
| 76 | 72 | // Create config service (without event bus for CLI operations) | |
| 77 | 73 | configSvc := config.NewService(v, nil) | |
| @@ -67,11 +67,7 @@ func GetLocalServices(configPath string) (*LocalServices, error) { | |||||
| 67 | // Config file not found is OK for some operations | 67 | // Config file not found is OK for some operations | ||
| 68 | } | 68 | } | ||
| 69 | 69 | ||||
| 70 | - | // Create a minimal logger for CLI operations | 70 | + | log := zerowrap.New(cliLogConfig) |
| 71 | - | log := zerowrap.New(zerowrap.Config{ | |||
| 72 | - | Level: "warn", | |||
| 73 | - | Format: "console", | |||
| 74 | - | }) | |||
| 75 | 71 | ||||
| 76 | // Create config service (without event bus for CLI operations) | 72 | // Create config service (without event bus for CLI operations) | ||
| 77 | configSvc := config.NewService(v, nil) | 73 | configSvc := config.NewService(v, nil) | ||
M internal/adapters/in/cli/presentation.go +9 -7
| @@ -19,14 +19,16 @@ var cliWritef = func(w io.Writer, format string, args ...any) error { | |||
| 19 | 19 | return err | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | - | // cliLogger returns a quiet logger for CLI commands that run outside the | |
| 23 | - | // server lifecycle. Level "warn" keeps normal output clean while still | |
| 24 | - | // surfacing actionable warnings from adapters. | |
| 22 | + | // cliLogConfig is the shared logger configuration for CLI commands that run | |
| 23 | + | // outside the server lifecycle. Level "warn" keeps normal output clean while | |
| 24 | + | // still surfacing actionable warnings from adapters. | |
| 25 | + | var cliLogConfig = zerowrap.Config{ | |
| 26 | + | Level: "warn", | |
| 27 | + | Format: "console", | |
| 28 | + | } | |
| 29 | + | ||
| 25 | 30 | func cliLogger() zerowrap.Logger { | |
| 26 | - | return zerowrap.New(zerowrap.Config{ | |
| 27 | - | Level: "warn", | |
| 28 | - | Format: "console", | |
| 29 | - | }) | |
| 31 | + | return zerowrap.New(cliLogConfig) | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | func cliRenderTitle(msg string) string { | |
| @@ -19,14 +19,16 @@ var cliWritef = func(w io.Writer, format string, args ...any) error { | |||||
| 19 | return err | 19 | return err | ||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | - | // cliLogger returns a quiet logger for CLI commands that run outside the | 22 | + | // cliLogConfig is the shared logger configuration for CLI commands that run |
| 23 | - | // server lifecycle. Level "warn" keeps normal output clean while still | 23 | + | // outside the server lifecycle. Level "warn" keeps normal output clean while |
| 24 | - | // surfacing actionable warnings from adapters. | 24 | + | // still surfacing actionable warnings from adapters. |
| 25 | + | var cliLogConfig = zerowrap.Config{ | |||
| 26 | + | Level: "warn", | |||
| 27 | + | Format: "console", | |||
| 28 | + | } | |||
| 29 | + | ||||
| 25 | func cliLogger() zerowrap.Logger { | 30 | func cliLogger() zerowrap.Logger { | ||
| 26 | - | return zerowrap.New(zerowrap.Config{ | 31 | + | return zerowrap.New(cliLogConfig) |
| 27 | - | Level: "warn", | |||
| 28 | - | Format: "console", | |||
| 29 | - | }) | |||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | func cliRenderTitle(msg string) string { | 34 | func cliRenderTitle(msg string) string { | ||
M internal/adapters/in/http/middleware/cidr.go +6 -1
| @@ -103,7 +103,12 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||
| 103 | 103 | if !strings.HasPrefix(path, "/") { | |
| 104 | 104 | path = "/" | |
| 105 | 105 | } | |
| 106 | - | target := fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |
| 106 | + | var target string | |
| 107 | + | if tlsPort == 443 { | |
| 108 | + | target = fmt.Sprintf("https://%s%s", host, path) | |
| 109 | + | } else { | |
| 110 | + | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |
| 111 | + | } | |
| 107 | 112 | ||
| 108 | 113 | log.Debug(). | |
| 109 | 114 | Str("target", target). | |
| @@ -103,7 +103,12 @@ func HTTPSRedirect(proxyNets []*net.IPNet, tlsPort int, forceAll bool, log zerow | |||||
| 103 | if !strings.HasPrefix(path, "/") { | 103 | if !strings.HasPrefix(path, "/") { | ||
| 104 | path = "/" | 104 | path = "/" | ||
| 105 | } | 105 | } | ||
| 106 | - | target := fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | 106 | + | var target string |
| 107 | + | if tlsPort == 443 { | |||
| 108 | + | target = fmt.Sprintf("https://%s%s", host, path) | |||
| 109 | + | } else { | |||
| 110 | + | target = fmt.Sprintf("https://%s:%d%s", host, tlsPort, path) | |||
| 111 | + | } | |||
| 107 | 112 | ||||
| 108 | log.Debug(). | 113 | log.Debug(). | ||
| 109 | Str("target", target). | 114 | Str("target", target). | ||
M internal/adapters/in/http/onboarding/handler.go +1 -6
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "html" | |
| 6 | 6 | "net" | |
| 7 | 7 | "net/http" | |
| 8 | - | "strings" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| 11 | 10 | // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients. | |
| @@ -50,11 +49,7 @@ func (h *Handler) normalizeHTTPSURL(r *http.Request) string { | |||
| 50 | 49 | if h.tlsPort != 0 && h.tlsPort != 443 { | |
| 51 | 50 | hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort) | |
| 52 | 51 | } | |
| 53 | - | u := fmt.Sprintf("https://%s/", hostname) | |
| 54 | - | if !strings.HasPrefix(u, "https://") { | |
| 55 | - | return "https://localhost/" | |
| 56 | - | } | |
| 57 | - | return html.EscapeString(u) | |
| 52 | + | return html.EscapeString(fmt.Sprintf("https://%s/", hostname)) | |
| 58 | 53 | } | |
| 59 | 54 | ||
| 60 | 55 | // ServeOnboardingPage serves the CA trust onboarding HTML page. | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "html" | 5 | "html" | ||
| 6 | "net" | 6 | "net" | ||
| 7 | "net/http" | 7 | "net/http" | ||
| 8 | - | "strings" | |||
| 9 | ) | 8 | ) | ||
| 10 | 9 | ||||
| 11 | // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients. | 10 | // Handler serves CA onboarding endpoints on the TLS port for direct/Tailnet clients. | ||
| @@ -50,11 +49,7 @@ func (h *Handler) normalizeHTTPSURL(r *http.Request) string { | |||||
| 50 | if h.tlsPort != 0 && h.tlsPort != 443 { | 49 | if h.tlsPort != 0 && h.tlsPort != 443 { | ||
| 51 | hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort) | 50 | hostname = fmt.Sprintf("%s:%d", hostname, h.tlsPort) | ||
| 52 | } | 51 | } | ||
| 53 | - | u := fmt.Sprintf("https://%s/", hostname) | 52 | + | return html.EscapeString(fmt.Sprintf("https://%s/", hostname)) |
| 54 | - | if !strings.HasPrefix(u, "https://") { | |||
| 55 | - | return "https://localhost/" | |||
| 56 | - | } | |||
| 57 | - | return html.EscapeString(u) | |||
| 58 | } | 53 | } | ||
| 59 | 54 | ||||
| 60 | // ServeOnboardingPage serves the CA trust onboarding HTML page. | 55 | // ServeOnboardingPage serves the CA trust onboarding HTML page. | ||
M internal/adapters/in/http/onboarding/handler_test.go +51 -31
| @@ -1,56 +1,76 @@ | |||
| 1 | 1 | package onboarding_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "io" | |
| 4 | 5 | "net/http" | |
| 5 | 6 | "net/http/httptest" | |
| 6 | 7 | "testing" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | - | func TestHandler_CACert(t *testing.T) { | |
| 15 | + | func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) { | |
| 16 | + | t.Helper() | |
| 14 | 17 | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | |
| 15 | 18 | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | |
| 16 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, 443) | |
| 17 | 19 | ||
| 18 | - | req := httptest.NewRequest("GET", "/ca.crt", nil) | |
| 19 | - | w := httptest.NewRecorder() | |
| 20 | - | h.ServeCACert(w, req) | |
| 20 | + | h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort) | |
| 21 | + | mux := http.NewServeMux() | |
| 22 | + | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | |
| 23 | + | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | |
| 24 | + | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | |
| 25 | + | ||
| 26 | + | srv := httptest.NewServer(mux) | |
| 27 | + | t.Cleanup(srv.Close) | |
| 28 | + | return srv, rootPEM, mobileconfig | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestHandler_CACert(t *testing.T) { | |
| 32 | + | srv, rootPEM, _ := newTestServer(t, 443) | |
| 33 | + | ||
| 34 | + | resp, err := http.Get(srv.URL + "/ca.crt") | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | defer resp.Body.Close() | |
| 21 | 37 | ||
| 22 | - | assert.Equal(t, http.StatusOK, w.Code) | |
| 23 | - | assert.Equal(t, "application/x-x509-ca-cert", w.Header().Get("Content-Type")) | |
| 24 | - | assert.Equal(t, rootPEM, w.Body.Bytes()) | |
| 38 | + | body, err := io.ReadAll(resp.Body) | |
| 39 | + | require.NoError(t, err) | |
| 40 | + | ||
| 41 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 42 | + | assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type")) | |
| 43 | + | assert.Equal(t, rootPEM, body) | |
| 25 | 44 | } | |
| 26 | 45 | ||
| 27 | 46 | func TestHandler_Mobileconfig(t *testing.T) { | |
| 28 | - | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | |
| 29 | - | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | |
| 30 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, 443) | |
| 47 | + | srv, _, _ := newTestServer(t, 443) | |
| 48 | + | ||
| 49 | + | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | defer resp.Body.Close() | |
| 31 | 52 | ||
| 32 | - | req := httptest.NewRequest("GET", "/ca.mobileconfig", nil) | |
| 33 | - | w := httptest.NewRecorder() | |
| 34 | - | h.ServeMobileconfig(w, req) | |
| 53 | + | body, err := io.ReadAll(resp.Body) | |
| 54 | + | require.NoError(t, err) | |
| 35 | 55 | ||
| 36 | - | assert.Equal(t, http.StatusOK, w.Code) | |
| 37 | - | assert.Equal(t, "application/x-apple-aspen-config", w.Header().Get("Content-Type")) | |
| 38 | - | assert.Contains(t, w.Body.String(), "com.apple.security.root") | |
| 56 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 57 | + | assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type")) | |
| 58 | + | assert.Contains(t, string(body), "com.apple.security.root") | |
| 39 | 59 | } | |
| 40 | 60 | ||
| 41 | 61 | func TestHandler_OnboardingPage(t *testing.T) { | |
| 42 | - | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | |
| 43 | - | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | |
| 44 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, 443) | |
| 45 | - | ||
| 46 | - | req := httptest.NewRequest("GET", "/ca", nil) | |
| 47 | - | w := httptest.NewRecorder() | |
| 48 | - | h.ServeOnboardingPage(w, req) | |
| 49 | - | ||
| 50 | - | assert.Equal(t, http.StatusOK, w.Code) | |
| 51 | - | assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) | |
| 52 | - | body := w.Body.String() | |
| 53 | - | assert.Contains(t, body, "Gordon") | |
| 54 | - | assert.Contains(t, body, "/ca.crt") | |
| 55 | - | assert.Contains(t, body, "/ca.mobileconfig") | |
| 62 | + | srv, _, _ := newTestServer(t, 443) | |
| 63 | + | ||
| 64 | + | resp, err := http.Get(srv.URL + "/ca") | |
| 65 | + | require.NoError(t, err) | |
| 66 | + | defer resp.Body.Close() | |
| 67 | + | ||
| 68 | + | body, err := io.ReadAll(resp.Body) | |
| 69 | + | require.NoError(t, err) | |
| 70 | + | ||
| 71 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 72 | + | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | |
| 73 | + | assert.Contains(t, string(body), "Gordon") | |
| 74 | + | assert.Contains(t, string(body), "/ca.crt") | |
| 75 | + | assert.Contains(t, string(body), "/ca.mobileconfig") | |
| 56 | 76 | } | |
| @@ -1,56 +1,76 @@ | |||||
| 1 | package onboarding_test | 1 | package onboarding_test | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "io" | |||
| 4 | "net/http" | 5 | "net/http" | ||
| 5 | "net/http/httptest" | 6 | "net/http/httptest" | ||
| 6 | "testing" | 7 | "testing" | ||
| 7 | 8 | ||||
| 8 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 9 | 11 | ||||
| 10 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | 12 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | ||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | - | func TestHandler_CACert(t *testing.T) { | 15 | + | func newTestServer(t *testing.T, tlsPort int) (*httptest.Server, []byte, []byte) { |
| 16 | + | t.Helper() | |||
| 14 | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | 17 | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | ||
| 15 | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | 18 | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | ||
| 16 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, 443) | |||
| 17 | 19 | ||||
| 18 | - | req := httptest.NewRequest("GET", "/ca.crt", nil) | 20 | + | h := onboarding.NewHandler(rootPEM, mobileconfig, tlsPort) |
| 19 | - | w := httptest.NewRecorder() | 21 | + | mux := http.NewServeMux() |
| 20 | - | h.ServeCACert(w, req) | 22 | + | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) |
| 23 | + | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | |||
| 24 | + | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | |||
| 25 | + | ||||
| 26 | + | srv := httptest.NewServer(mux) | |||
| 27 | + | t.Cleanup(srv.Close) | |||
| 28 | + | return srv, rootPEM, mobileconfig | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestHandler_CACert(t *testing.T) { | |||
| 32 | + | srv, rootPEM, _ := newTestServer(t, 443) | |||
| 33 | + | ||||
| 34 | + | resp, err := http.Get(srv.URL + "/ca.crt") | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | defer resp.Body.Close() | |||
| 21 | 37 | ||||
| 22 | - | assert.Equal(t, http.StatusOK, w.Code) | 38 | + | body, err := io.ReadAll(resp.Body) |
| 23 | - | assert.Equal(t, "application/x-x509-ca-cert", w.Header().Get("Content-Type")) | 39 | + | require.NoError(t, err) |
| 24 | - | assert.Equal(t, rootPEM, w.Body.Bytes()) | 40 | + | |
| 41 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 42 | + | assert.Equal(t, "application/x-x509-ca-cert", resp.Header.Get("Content-Type")) | |||
| 43 | + | assert.Equal(t, rootPEM, body) | |||
| 25 | } | 44 | } | ||
| 26 | 45 | ||||
| 27 | func TestHandler_Mobileconfig(t *testing.T) { | 46 | func TestHandler_Mobileconfig(t *testing.T) { | ||
| 28 | - | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | 47 | + | srv, _, _ := newTestServer(t, 443) |
| 29 | - | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | 48 | + | |
| 30 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, 443) | 49 | + | resp, err := http.Get(srv.URL + "/ca.mobileconfig") |
| 50 | + | require.NoError(t, err) | |||
| 51 | + | defer resp.Body.Close() | |||
| 31 | 52 | ||||
| 32 | - | req := httptest.NewRequest("GET", "/ca.mobileconfig", nil) | 53 | + | body, err := io.ReadAll(resp.Body) |
| 33 | - | w := httptest.NewRecorder() | 54 | + | require.NoError(t, err) |
| 34 | - | h.ServeMobileconfig(w, req) | |||
| 35 | 55 | ||||
| 36 | - | assert.Equal(t, http.StatusOK, w.Code) | 56 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) |
| 37 | - | assert.Equal(t, "application/x-apple-aspen-config", w.Header().Get("Content-Type")) | 57 | + | assert.Equal(t, "application/x-apple-aspen-config", resp.Header.Get("Content-Type")) |
| 38 | - | assert.Contains(t, w.Body.String(), "com.apple.security.root") | 58 | + | assert.Contains(t, string(body), "com.apple.security.root") |
| 39 | } | 59 | } | ||
| 40 | 60 | ||||
| 41 | func TestHandler_OnboardingPage(t *testing.T) { | 61 | func TestHandler_OnboardingPage(t *testing.T) { | ||
| 42 | - | rootPEM := []byte("-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----\n") | 62 | + | srv, _, _ := newTestServer(t, 443) |
| 43 | - | mobileconfig := []byte("<plist>com.apple.security.root</plist>") | 63 | + | |
| 44 | - | h := onboarding.NewHandler(rootPEM, mobileconfig, 443) | 64 | + | resp, err := http.Get(srv.URL + "/ca") |
| 45 | - | 65 | + | require.NoError(t, err) | |
| 46 | - | req := httptest.NewRequest("GET", "/ca", nil) | 66 | + | defer resp.Body.Close() |
| 47 | - | w := httptest.NewRecorder() | 67 | + | |
| 48 | - | h.ServeOnboardingPage(w, req) | 68 | + | body, err := io.ReadAll(resp.Body) |
| 49 | - | 69 | + | require.NoError(t, err) | |
| 50 | - | assert.Equal(t, http.StatusOK, w.Code) | 70 | + | |
| 51 | - | assert.Equal(t, "text/html; charset=utf-8", w.Header().Get("Content-Type")) | 71 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) |
| 52 | - | body := w.Body.String() | 72 | + | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) |
| 53 | - | assert.Contains(t, body, "Gordon") | 73 | + | assert.Contains(t, string(body), "Gordon") |
| 54 | - | assert.Contains(t, body, "/ca.crt") | 74 | + | assert.Contains(t, string(body), "/ca.crt") |
| 55 | - | assert.Contains(t, body, "/ca.mobileconfig") | 75 | + | assert.Contains(t, string(body), "/ca.mobileconfig") |
| 56 | } | 76 | } | ||
M internal/adapters/out/pki/ca.go +7 -0
| @@ -30,6 +30,10 @@ import ( | |||
| 30 | 30 | // Compile-time check: *CA satisfies the CertificateAuthority boundary. | |
| 31 | 31 | var _ out.CertificateAuthority = (*CA)(nil) | |
| 32 | 32 | ||
| 33 | + | // Certificate lifetimes balance security and operational convenience: | |
| 34 | + | // - Root: long-lived to minimise key ceremony frequency; stored offline after bootstrap. | |
| 35 | + | // - Intermediate: short-lived to limit blast radius if compromised; auto-renewed. | |
| 36 | + | // - Leaf: very short-lived to reduce exposure; issued on-demand and cached in memory. | |
| 33 | 37 | const ( | |
| 34 | 38 | rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years | |
| 35 | 39 | intermediateLifetime = 7 * 24 * time.Hour // 7 days | |
| @@ -377,6 +381,9 @@ func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) { | |||
| 377 | 381 | expiry := now.Add(leafLifetime) | |
| 378 | 382 | if expiry.After(interCert.NotAfter) { | |
| 379 | 383 | expiry = interCert.NotAfter.Add(-1 * time.Minute) | |
| 384 | + | if !expiry.After(now) { | |
| 385 | + | return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required") | |
| 386 | + | } | |
| 380 | 387 | } | |
| 381 | 388 | ||
| 382 | 389 | template := &x509.Certificate{ | |
| @@ -30,6 +30,10 @@ import ( | |||||
| 30 | // Compile-time check: *CA satisfies the CertificateAuthority boundary. | 30 | // Compile-time check: *CA satisfies the CertificateAuthority boundary. | ||
| 31 | var _ out.CertificateAuthority = (*CA)(nil) | 31 | var _ out.CertificateAuthority = (*CA)(nil) | ||
| 32 | 32 | ||||
| 33 | + | // Certificate lifetimes balance security and operational convenience: | |||
| 34 | + | // - Root: long-lived to minimise key ceremony frequency; stored offline after bootstrap. | |||
| 35 | + | // - Intermediate: short-lived to limit blast radius if compromised; auto-renewed. | |||
| 36 | + | // - Leaf: very short-lived to reduce exposure; issued on-demand and cached in memory. | |||
| 33 | const ( | 37 | const ( | ||
| 34 | rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years | 38 | rootLifetime = 10 * 365 * 24 * time.Hour // ~10 years | ||
| 35 | intermediateLifetime = 7 * 24 * time.Hour // 7 days | 39 | intermediateLifetime = 7 * 24 * time.Hour // 7 days | ||
| @@ -377,6 +381,9 @@ func (ca *CA) IssueCertificate(domain string) (*tls.Certificate, error) { | |||||
| 377 | expiry := now.Add(leafLifetime) | 381 | expiry := now.Add(leafLifetime) | ||
| 378 | if expiry.After(interCert.NotAfter) { | 382 | if expiry.After(interCert.NotAfter) { | ||
| 379 | expiry = interCert.NotAfter.Add(-1 * time.Minute) | 383 | expiry = interCert.NotAfter.Add(-1 * time.Minute) | ||
| 384 | + | if !expiry.After(now) { | |||
| 385 | + | return nil, fmt.Errorf("intermediate CA too close to expiry; renewal required") | |||
| 386 | + | } | |||
| 380 | } | 387 | } | ||
| 381 | 388 | ||||
| 382 | template := &x509.Certificate{ | 389 | template := &x509.Certificate{ | ||
M internal/app/pki_integration_test.go +13 -11
| @@ -9,24 +9,26 @@ import ( | |||
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 12 | - | outboundary "github.com/bnema/gordon/internal/boundaries/out" | |
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 13 | 13 | "github.com/bnema/gordon/internal/domain" | |
| 14 | 14 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 15 | 15 | "github.com/bnema/zerowrap" | |
| 16 | 16 | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 17 | 18 | "github.com/stretchr/testify/require" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | - | // Compile-time check: stubRoutes implements the boundary interface. | |
| 21 | - | var _ outboundary.RouteChecker = (*stubRoutes)(nil) | |
| 22 | - | ||
| 23 | - | type stubRoutes struct { | |
| 24 | - | routes []domain.Route | |
| 21 | + | func newRoutesMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { | |
| 22 | + | m := mocks.NewMockRouteChecker(t) | |
| 23 | + | routes := make([]domain.Route, len(domains)) | |
| 24 | + | for i, d := range domains { | |
| 25 | + | routes[i] = domain.Route{Domain: d} | |
| 26 | + | } | |
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |
| 29 | + | return m | |
| 25 | 30 | } | |
| 26 | 31 | ||
| 27 | - | func (s *stubRoutes) GetRoutes(_ context.Context) []domain.Route { return s.routes } | |
| 28 | - | func (s *stubRoutes) GetExternalRoutes() map[string]string { return nil } | |
| 29 | - | ||
| 30 | 32 | func TestTLSHandshake_OnDemandCert(t *testing.T) { | |
| 31 | 33 | dir := t.TempDir() | |
| 32 | 34 | log := zerowrap.Default() | |
| @@ -34,7 +36,7 @@ func TestTLSHandshake_OnDemandCert(t *testing.T) { | |||
| 34 | 36 | ca, err := pkiadapter.NewCA(dir, log) | |
| 35 | 37 | require.NoError(t, err) | |
| 36 | 38 | ||
| 37 | - | routes := &stubRoutes{routes: []domain.Route{{Domain: "test.local"}}} | |
| 39 | + | routes := newRoutesMock(t, "test.local") | |
| 38 | 40 | ctx, cancel := context.WithCancel(context.Background()) | |
| 39 | 41 | defer cancel() | |
| 40 | 42 | ||
| @@ -87,7 +89,7 @@ func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) { | |||
| 87 | 89 | ca, err := pkiadapter.NewCA(dir, log) | |
| 88 | 90 | require.NoError(t, err) | |
| 89 | 91 | ||
| 90 | - | routes := &stubRoutes{routes: []domain.Route{{Domain: "allowed.local"}}} | |
| 92 | + | routes := newRoutesMock(t, "allowed.local") | |
| 91 | 93 | ctx, cancel := context.WithCancel(context.Background()) | |
| 92 | 94 | defer cancel() | |
| 93 | 95 | ||
| @@ -9,24 +9,26 @@ import ( | |||||
| 9 | "time" | 9 | "time" | ||
| 10 | 10 | ||||
| 11 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | 11 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | ||
| 12 | - | outboundary "github.com/bnema/gordon/internal/boundaries/out" | 12 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" |
| 13 | "github.com/bnema/gordon/internal/domain" | 13 | "github.com/bnema/gordon/internal/domain" | ||
| 14 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | 14 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | ||
| 15 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 16 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 17 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | - | // Compile-time check: stubRoutes implements the boundary interface. | 21 | + | func newRoutesMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { |
| 21 | - | var _ outboundary.RouteChecker = (*stubRoutes)(nil) | 22 | + | m := mocks.NewMockRouteChecker(t) |
| 22 | - | 23 | + | routes := make([]domain.Route, len(domains)) | |
| 23 | - | type stubRoutes struct { | 24 | + | for i, d := range domains { |
| 24 | - | routes []domain.Route | 25 | + | routes[i] = domain.Route{Domain: d} |
| 26 | + | } | |||
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |||
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |||
| 29 | + | return m | |||
| 25 | } | 30 | } | ||
| 26 | 31 | ||||
| 27 | - | func (s *stubRoutes) GetRoutes(_ context.Context) []domain.Route { return s.routes } | |||
| 28 | - | func (s *stubRoutes) GetExternalRoutes() map[string]string { return nil } | |||
| 29 | - | ||||
| 30 | func TestTLSHandshake_OnDemandCert(t *testing.T) { | 32 | func TestTLSHandshake_OnDemandCert(t *testing.T) { | ||
| 31 | dir := t.TempDir() | 33 | dir := t.TempDir() | ||
| 32 | log := zerowrap.Default() | 34 | log := zerowrap.Default() | ||
| @@ -34,7 +36,7 @@ func TestTLSHandshake_OnDemandCert(t *testing.T) { | |||||
| 34 | ca, err := pkiadapter.NewCA(dir, log) | 36 | ca, err := pkiadapter.NewCA(dir, log) | ||
| 35 | require.NoError(t, err) | 37 | require.NoError(t, err) | ||
| 36 | 38 | ||||
| 37 | - | routes := &stubRoutes{routes: []domain.Route{{Domain: "test.local"}}} | 39 | + | routes := newRoutesMock(t, "test.local") |
| 38 | ctx, cancel := context.WithCancel(context.Background()) | 40 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 39 | defer cancel() | 41 | defer cancel() | ||
| 40 | 42 | ||||
| @@ -87,7 +89,7 @@ func TestTLSHandshake_UnknownDomain_Rejected(t *testing.T) { | |||||
| 87 | ca, err := pkiadapter.NewCA(dir, log) | 89 | ca, err := pkiadapter.NewCA(dir, log) | ||
| 88 | require.NoError(t, err) | 90 | require.NoError(t, err) | ||
| 89 | 91 | ||||
| 90 | - | routes := &stubRoutes{routes: []domain.Route{{Domain: "allowed.local"}}} | 92 | + | routes := newRoutesMock(t, "allowed.local") |
| 91 | ctx, cancel := context.WithCancel(context.Background()) | 93 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 92 | defer cancel() | 94 | defer cancel() | ||
| 93 | 95 | ||||
M internal/app/run.go +4 -4
| @@ -417,15 +417,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 417 | 417 | ||
| 418 | 418 | // initPKI initialises the internal CA and PKI service when TLS is enabled. | |
| 419 | 419 | func (si *serviceInit) initPKI() error { | |
| 420 | - | if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") { | |
| 421 | - | return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") | |
| 422 | - | } | |
| 423 | - | ||
| 424 | 420 | if si.cfg.Server.TLSPort == 0 { | |
| 425 | 421 | si.log.Info().Msg("internal CA disabled (server.tls_port=0)") | |
| 426 | 422 | return nil | |
| 427 | 423 | } | |
| 428 | 424 | ||
| 425 | + | if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") { | |
| 426 | + | return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") | |
| 427 | + | } | |
| 428 | + | ||
| 429 | 429 | caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log) | |
| 430 | 430 | if err != nil { | |
| 431 | 431 | return si.log.WrapErr(err, "failed to initialize internal CA") | |
| @@ -417,15 +417,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 417 | 417 | ||||
| 418 | // initPKI initialises the internal CA and PKI service when TLS is enabled. | 418 | // initPKI initialises the internal CA and PKI service when TLS is enabled. | ||
| 419 | func (si *serviceInit) initPKI() error { | 419 | func (si *serviceInit) initPKI() error { | ||
| 420 | - | if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") { | |||
| 421 | - | return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") | |||
| 422 | - | } | |||
| 423 | - | ||||
| 424 | if si.cfg.Server.TLSPort == 0 { | 420 | if si.cfg.Server.TLSPort == 0 { | ||
| 425 | si.log.Info().Msg("internal CA disabled (server.tls_port=0)") | 421 | si.log.Info().Msg("internal CA disabled (server.tls_port=0)") | ||
| 426 | return nil | 422 | return nil | ||
| 427 | } | 423 | } | ||
| 428 | 424 | ||||
| 425 | + | if (si.cfg.Server.TLSCertFile == "") != (si.cfg.Server.TLSKeyFile == "") { | |||
| 426 | + | return fmt.Errorf("both tls_cert_file and tls_key_file must be set, or neither") | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log) | 429 | caAdapter, err := pkiadapter.NewCA(resolveDataDir(si.cfg.Server.DataDir), si.log) | ||
| 430 | if err != nil { | 430 | if err != nil { | ||
| 431 | return si.log.WrapErr(err, "failed to initialize internal CA") | 431 | return si.log.WrapErr(err, "failed to initialize internal CA") | ||
A internal/boundaries/out/mocks/mock_route_checker.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 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/domain" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMockRouteChecker creates a new instance of MockRouteChecker. 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 NewMockRouteChecker(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockRouteChecker { | |
| 20 | + | mock := &MockRouteChecker{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockRouteChecker is an autogenerated mock type for the RouteChecker type | |
| 29 | + | type MockRouteChecker struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockRouteChecker_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockRouteChecker) EXPECT() *MockRouteChecker_Expecter { | |
| 38 | + | return &MockRouteChecker_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // GetExternalRoutes provides a mock function for the type MockRouteChecker | |
| 42 | + | func (_mock *MockRouteChecker) GetExternalRoutes() map[string]string { | |
| 43 | + | ret := _mock.Called() | |
| 44 | + | ||
| 45 | + | if len(ret) == 0 { | |
| 46 | + | panic("no return value specified for GetExternalRoutes") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var r0 map[string]string | |
| 50 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |
| 51 | + | r0 = returnFunc() | |
| 52 | + | } else { | |
| 53 | + | if ret.Get(0) != nil { | |
| 54 | + | r0 = ret.Get(0).(map[string]string) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | return r0 | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // MockRouteChecker_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |
| 61 | + | type MockRouteChecker_GetExternalRoutes_Call struct { | |
| 62 | + | *mock.Call | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // GetExternalRoutes is a helper method to define mock.On call | |
| 66 | + | func (_e *MockRouteChecker_Expecter) GetExternalRoutes() *MockRouteChecker_GetExternalRoutes_Call { | |
| 67 | + | return &MockRouteChecker_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Run(run func()) *MockRouteChecker_GetExternalRoutes_Call { | |
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 72 | + | run() | |
| 73 | + | }) | |
| 74 | + | return _c | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |
| 78 | + | _c.Call.Return(stringToString) | |
| 79 | + | return _c | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |
| 83 | + | _c.Call.Return(run) | |
| 84 | + | return _c | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // GetRoutes provides a mock function for the type MockRouteChecker | |
| 88 | + | func (_mock *MockRouteChecker) GetRoutes(ctx context.Context) []domain.Route { | |
| 89 | + | ret := _mock.Called(ctx) | |
| 90 | + | ||
| 91 | + | if len(ret) == 0 { | |
| 92 | + | panic("no return value specified for GetRoutes") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | var r0 []domain.Route | |
| 96 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Route); ok { | |
| 97 | + | r0 = returnFunc(ctx) | |
| 98 | + | } else { | |
| 99 | + | if ret.Get(0) != nil { | |
| 100 | + | r0 = ret.Get(0).([]domain.Route) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | return r0 | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // MockRouteChecker_GetRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoutes' | |
| 107 | + | type MockRouteChecker_GetRoutes_Call struct { | |
| 108 | + | *mock.Call | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // GetRoutes is a helper method to define mock.On call | |
| 112 | + | // - ctx context.Context | |
| 113 | + | func (_e *MockRouteChecker_Expecter) GetRoutes(ctx interface{}) *MockRouteChecker_GetRoutes_Call { | |
| 114 | + | return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)} | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (_c *MockRouteChecker_GetRoutes_Call) Run(run func(ctx context.Context)) *MockRouteChecker_GetRoutes_Call { | |
| 118 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 119 | + | var arg0 context.Context | |
| 120 | + | if args[0] != nil { | |
| 121 | + | arg0 = args[0].(context.Context) | |
| 122 | + | } | |
| 123 | + | run( | |
| 124 | + | arg0, | |
| 125 | + | ) | |
| 126 | + | }) | |
| 127 | + | return _c | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (_c *MockRouteChecker_GetRoutes_Call) Return(routes []domain.Route) *MockRouteChecker_GetRoutes_Call { | |
| 131 | + | _c.Call.Return(routes) | |
| 132 | + | return _c | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (_c *MockRouteChecker_GetRoutes_Call) RunAndReturn(run func(ctx context.Context) []domain.Route) *MockRouteChecker_GetRoutes_Call { | |
| 136 | + | _c.Call.Return(run) | |
| 137 | + | return _c | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 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/domain" | |||
| 11 | + | mock "github.com/stretchr/testify/mock" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // NewMockRouteChecker creates a new instance of MockRouteChecker. 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 NewMockRouteChecker(t interface { | |||
| 17 | + | mock.TestingT | |||
| 18 | + | Cleanup(func()) | |||
| 19 | + | }) *MockRouteChecker { | |||
| 20 | + | mock := &MockRouteChecker{} | |||
| 21 | + | mock.Mock.Test(t) | |||
| 22 | + | ||||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 24 | + | ||||
| 25 | + | return mock | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // MockRouteChecker is an autogenerated mock type for the RouteChecker type | |||
| 29 | + | type MockRouteChecker struct { | |||
| 30 | + | mock.Mock | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | type MockRouteChecker_Expecter struct { | |||
| 34 | + | mock *mock.Mock | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (_m *MockRouteChecker) EXPECT() *MockRouteChecker_Expecter { | |||
| 38 | + | return &MockRouteChecker_Expecter{mock: &_m.Mock} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // GetExternalRoutes provides a mock function for the type MockRouteChecker | |||
| 42 | + | func (_mock *MockRouteChecker) GetExternalRoutes() map[string]string { | |||
| 43 | + | ret := _mock.Called() | |||
| 44 | + | ||||
| 45 | + | if len(ret) == 0 { | |||
| 46 | + | panic("no return value specified for GetExternalRoutes") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | var r0 map[string]string | |||
| 50 | + | if returnFunc, ok := ret.Get(0).(func() map[string]string); ok { | |||
| 51 | + | r0 = returnFunc() | |||
| 52 | + | } else { | |||
| 53 | + | if ret.Get(0) != nil { | |||
| 54 | + | r0 = ret.Get(0).(map[string]string) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | return r0 | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // MockRouteChecker_GetExternalRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetExternalRoutes' | |||
| 61 | + | type MockRouteChecker_GetExternalRoutes_Call struct { | |||
| 62 | + | *mock.Call | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // GetExternalRoutes is a helper method to define mock.On call | |||
| 66 | + | func (_e *MockRouteChecker_Expecter) GetExternalRoutes() *MockRouteChecker_GetExternalRoutes_Call { | |||
| 67 | + | return &MockRouteChecker_GetExternalRoutes_Call{Call: _e.mock.On("GetExternalRoutes")} | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Run(run func()) *MockRouteChecker_GetExternalRoutes_Call { | |||
| 71 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 72 | + | run() | |||
| 73 | + | }) | |||
| 74 | + | return _c | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) Return(stringToString map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |||
| 78 | + | _c.Call.Return(stringToString) | |||
| 79 | + | return _c | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func (_c *MockRouteChecker_GetExternalRoutes_Call) RunAndReturn(run func() map[string]string) *MockRouteChecker_GetExternalRoutes_Call { | |||
| 83 | + | _c.Call.Return(run) | |||
| 84 | + | return _c | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // GetRoutes provides a mock function for the type MockRouteChecker | |||
| 88 | + | func (_mock *MockRouteChecker) GetRoutes(ctx context.Context) []domain.Route { | |||
| 89 | + | ret := _mock.Called(ctx) | |||
| 90 | + | ||||
| 91 | + | if len(ret) == 0 { | |||
| 92 | + | panic("no return value specified for GetRoutes") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | var r0 []domain.Route | |||
| 96 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []domain.Route); ok { | |||
| 97 | + | r0 = returnFunc(ctx) | |||
| 98 | + | } else { | |||
| 99 | + | if ret.Get(0) != nil { | |||
| 100 | + | r0 = ret.Get(0).([]domain.Route) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | return r0 | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // MockRouteChecker_GetRoutes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRoutes' | |||
| 107 | + | type MockRouteChecker_GetRoutes_Call struct { | |||
| 108 | + | *mock.Call | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | // GetRoutes is a helper method to define mock.On call | |||
| 112 | + | // - ctx context.Context | |||
| 113 | + | func (_e *MockRouteChecker_Expecter) GetRoutes(ctx interface{}) *MockRouteChecker_GetRoutes_Call { | |||
| 114 | + | return &MockRouteChecker_GetRoutes_Call{Call: _e.mock.On("GetRoutes", ctx)} | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func (_c *MockRouteChecker_GetRoutes_Call) Run(run func(ctx context.Context)) *MockRouteChecker_GetRoutes_Call { | |||
| 118 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 119 | + | var arg0 context.Context | |||
| 120 | + | if args[0] != nil { | |||
| 121 | + | arg0 = args[0].(context.Context) | |||
| 122 | + | } | |||
| 123 | + | run( | |||
| 124 | + | arg0, | |||
| 125 | + | ) | |||
| 126 | + | }) | |||
| 127 | + | return _c | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (_c *MockRouteChecker_GetRoutes_Call) Return(routes []domain.Route) *MockRouteChecker_GetRoutes_Call { | |||
| 131 | + | _c.Call.Return(routes) | |||
| 132 | + | return _c | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (_c *MockRouteChecker_GetRoutes_Call) RunAndReturn(run func(ctx context.Context) []domain.Route) *MockRouteChecker_GetRoutes_Call { | |||
| 136 | + | _c.Call.Return(run) | |||
| 137 | + | return _c | |||
| 138 | + | } | |||
M internal/usecase/pki/service.go +3 -0
| @@ -129,6 +129,9 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, | |||
| 129 | 129 | if err != nil { | |
| 130 | 130 | return nil, err | |
| 131 | 131 | } | |
| 132 | + | if result == nil { | |
| 133 | + | return nil, nil | |
| 134 | + | } | |
| 132 | 135 | return result.(*tls.Certificate), nil | |
| 133 | 136 | } | |
| 134 | 137 | ||
| @@ -129,6 +129,9 @@ func (s *Service) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, | |||||
| 129 | if err != nil { | 129 | if err != nil { | ||
| 130 | return nil, err | 130 | return nil, err | ||
| 131 | } | 131 | } | ||
| 132 | + | if result == nil { | |||
| 133 | + | return nil, nil | |||
| 134 | + | } | |||
| 132 | return result.(*tls.Certificate), nil | 135 | return result.(*tls.Certificate), nil | ||
| 133 | } | 136 | } | ||
| 134 | 137 | ||||
M internal/usecase/pki/service_test.go +15 -21
| @@ -7,36 +7,34 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/zerowrap" | |
| 9 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/mock" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ||
| 12 | 13 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 13 | - | outboundary "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 14 | 15 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 16 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | - | // Compile-time check: stubConfigService implements the boundary interface. | |
| 19 | - | var _ outboundary.RouteChecker = (*stubConfigService)(nil) | |
| 19 | + | func testLogger() zerowrap.Logger { return zerowrap.Default() } | |
| 20 | 20 | ||
| 21 | - | // stubConfigService satisfies out.RouteChecker | |
| 22 | - | type stubConfigService struct { | |
| 23 | - | routes []domain.Route | |
| 24 | - | externalRoutes map[string]string | |
| 21 | + | func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { | |
| 22 | + | m := mocks.NewMockRouteChecker(t) | |
| 23 | + | routes := make([]domain.Route, len(domains)) | |
| 24 | + | for i, d := range domains { | |
| 25 | + | routes[i] = domain.Route{Domain: d} | |
| 26 | + | } | |
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |
| 29 | + | return m | |
| 25 | 30 | } | |
| 26 | 31 | ||
| 27 | - | func (s *stubConfigService) GetRoutes(_ context.Context) []domain.Route { return s.routes } | |
| 28 | - | func (s *stubConfigService) GetExternalRoutes() map[string]string { return s.externalRoutes } | |
| 29 | - | ||
| 30 | - | func testLogger() zerowrap.Logger { return zerowrap.Default() } | |
| 31 | - | ||
| 32 | 32 | func TestService_GetCertificate_KnownDomain(t *testing.T) { | |
| 33 | 33 | dir := t.TempDir() | |
| 34 | 34 | ca, err := pkiadapter.NewCA(dir, testLogger()) | |
| 35 | 35 | require.NoError(t, err) | |
| 36 | 36 | ||
| 37 | - | cfg := &stubConfigService{ | |
| 38 | - | routes: []domain.Route{{Domain: "app.example.com"}}, | |
| 39 | - | } | |
| 37 | + | cfg := newRouteCheckerMock(t, "app.example.com") | |
| 40 | 38 | ||
| 41 | 39 | ctx, cancel := context.WithCancel(context.Background()) | |
| 42 | 40 | defer cancel() | |
| @@ -55,9 +53,7 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) { | |||
| 55 | 53 | ca, err := pkiadapter.NewCA(dir, testLogger()) | |
| 56 | 54 | require.NoError(t, err) | |
| 57 | 55 | ||
| 58 | - | cfg := &stubConfigService{ | |
| 59 | - | routes: []domain.Route{{Domain: "app.example.com"}}, | |
| 60 | - | } | |
| 56 | + | cfg := newRouteCheckerMock(t, "app.example.com") | |
| 61 | 57 | ||
| 62 | 58 | ctx, cancel := context.WithCancel(context.Background()) | |
| 63 | 59 | defer cancel() | |
| @@ -76,9 +72,7 @@ func TestService_GetCertificate_Caching(t *testing.T) { | |||
| 76 | 72 | ca, err := pkiadapter.NewCA(dir, testLogger()) | |
| 77 | 73 | require.NoError(t, err) | |
| 78 | 74 | ||
| 79 | - | cfg := &stubConfigService{ | |
| 80 | - | routes: []domain.Route{{Domain: "cached.example.com"}}, | |
| 81 | - | } | |
| 75 | + | cfg := newRouteCheckerMock(t, "cached.example.com") | |
| 82 | 76 | ||
| 83 | 77 | ctx, cancel := context.WithCancel(context.Background()) | |
| 84 | 78 | defer cancel() | |
| @@ -7,36 +7,34 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/bnema/zerowrap" | 8 | "github.com/bnema/zerowrap" | ||
| 9 | "github.com/stretchr/testify/assert" | 9 | "github.com/stretchr/testify/assert" | ||
| 10 | + | "github.com/stretchr/testify/mock" | |||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 11 | 12 | ||||
| 12 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | 13 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | ||
| 13 | - | outboundary "github.com/bnema/gordon/internal/boundaries/out" | 14 | + | "github.com/bnema/gordon/internal/boundaries/out/mocks" |
| 14 | "github.com/bnema/gordon/internal/domain" | 15 | "github.com/bnema/gordon/internal/domain" | ||
| 15 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | 16 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | - | // Compile-time check: stubConfigService implements the boundary interface. | 19 | + | func testLogger() zerowrap.Logger { return zerowrap.Default() } |
| 19 | - | var _ outboundary.RouteChecker = (*stubConfigService)(nil) | |||
| 20 | 20 | ||||
| 21 | - | // stubConfigService satisfies out.RouteChecker | 21 | + | func newRouteCheckerMock(t *testing.T, domains ...string) *mocks.MockRouteChecker { |
| 22 | - | type stubConfigService struct { | 22 | + | m := mocks.NewMockRouteChecker(t) |
| 23 | - | routes []domain.Route | 23 | + | routes := make([]domain.Route, len(domains)) |
| 24 | - | externalRoutes map[string]string | 24 | + | for i, d := range domains { |
| 25 | + | routes[i] = domain.Route{Domain: d} | |||
| 26 | + | } | |||
| 27 | + | m.EXPECT().GetRoutes(mock.Anything).Return(routes).Maybe() | |||
| 28 | + | m.EXPECT().GetExternalRoutes().Return(nil).Maybe() | |||
| 29 | + | return m | |||
| 25 | } | 30 | } | ||
| 26 | 31 | ||||
| 27 | - | func (s *stubConfigService) GetRoutes(_ context.Context) []domain.Route { return s.routes } | |||
| 28 | - | func (s *stubConfigService) GetExternalRoutes() map[string]string { return s.externalRoutes } | |||
| 29 | - | ||||
| 30 | - | func testLogger() zerowrap.Logger { return zerowrap.Default() } | |||
| 31 | - | ||||
| 32 | func TestService_GetCertificate_KnownDomain(t *testing.T) { | 32 | func TestService_GetCertificate_KnownDomain(t *testing.T) { | ||
| 33 | dir := t.TempDir() | 33 | dir := t.TempDir() | ||
| 34 | ca, err := pkiadapter.NewCA(dir, testLogger()) | 34 | ca, err := pkiadapter.NewCA(dir, testLogger()) | ||
| 35 | require.NoError(t, err) | 35 | require.NoError(t, err) | ||
| 36 | 36 | ||||
| 37 | - | cfg := &stubConfigService{ | 37 | + | cfg := newRouteCheckerMock(t, "app.example.com") |
| 38 | - | routes: []domain.Route{{Domain: "app.example.com"}}, | |||
| 39 | - | } | |||
| 40 | 38 | ||||
| 41 | ctx, cancel := context.WithCancel(context.Background()) | 39 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 42 | defer cancel() | 40 | defer cancel() | ||
| @@ -55,9 +53,7 @@ func TestService_GetCertificate_UnknownDomain(t *testing.T) { | |||||
| 55 | ca, err := pkiadapter.NewCA(dir, testLogger()) | 53 | ca, err := pkiadapter.NewCA(dir, testLogger()) | ||
| 56 | require.NoError(t, err) | 54 | require.NoError(t, err) | ||
| 57 | 55 | ||||
| 58 | - | cfg := &stubConfigService{ | 56 | + | cfg := newRouteCheckerMock(t, "app.example.com") |
| 59 | - | routes: []domain.Route{{Domain: "app.example.com"}}, | |||
| 60 | - | } | |||
| 61 | 57 | ||||
| 62 | ctx, cancel := context.WithCancel(context.Background()) | 58 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 63 | defer cancel() | 59 | defer cancel() | ||
| @@ -76,9 +72,7 @@ func TestService_GetCertificate_Caching(t *testing.T) { | |||||
| 76 | ca, err := pkiadapter.NewCA(dir, testLogger()) | 72 | ca, err := pkiadapter.NewCA(dir, testLogger()) | ||
| 77 | require.NoError(t, err) | 73 | require.NoError(t, err) | ||
| 78 | 74 | ||||
| 79 | - | cfg := &stubConfigService{ | 75 | + | cfg := newRouteCheckerMock(t, "cached.example.com") |
| 80 | - | routes: []domain.Route{{Domain: "cached.example.com"}}, | |||
| 81 | - | } | |||
| 82 | 76 | ||||
| 83 | ctx, cancel := context.WithCancel(context.Background()) | 77 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 84 | defer cancel() | 78 | defer cancel() | ||