feat(httpserver): serve health with security headers and graceful shutdown
- commit
- b156bd841cf7200bbf98494a31520600e1a810f1
- author
- brice · · 2026-10-10 06:16 UTC
- parent
- aa54e60
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +443 -0
A internal/adapters/httpserver/health.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package httpserver | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // healthHandler answers 200 "ok" when every check passes and 503 otherwise. | |
| 12 | + | // The failure is logged, never sent: the body stays the same for any cause. | |
| 13 | + | func healthHandler(checks []Check) http.HandlerFunc { | |
| 14 | + | return func(w http.ResponseWriter, r *http.Request) { | |
| 15 | + | w.Header().Set("Cache-Control", "no-store") | |
| 16 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 17 | + | for _, check := range checks { | |
| 18 | + | if err := check(r.Context()); err != nil { | |
| 19 | + | log := zerowrap.FromCtx(r.Context()) | |
| 20 | + | log.Warn().Err(err).Msg("health check failed") | |
| 21 | + | w.WriteHeader(http.StatusServiceUnavailable) | |
| 22 | + | _, _ = w.Write([]byte("unavailable\n")) | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | _, _ = w.Write([]byte("ok\n")) | |
| 27 | + | } | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package httpserver | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "net/http" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // healthHandler answers 200 "ok" when every check passes and 503 otherwise. | |||
| 12 | + | // The failure is logged, never sent: the body stays the same for any cause. | |||
| 13 | + | func healthHandler(checks []Check) http.HandlerFunc { | |||
| 14 | + | return func(w http.ResponseWriter, r *http.Request) { | |||
| 15 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 16 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |||
| 17 | + | for _, check := range checks { | |||
| 18 | + | if err := check(r.Context()); err != nil { | |||
| 19 | + | log := zerowrap.FromCtx(r.Context()) | |||
| 20 | + | log.Warn().Err(err).Msg("health check failed") | |||
| 21 | + | w.WriteHeader(http.StatusServiceUnavailable) | |||
| 22 | + | _, _ = w.Write([]byte("unavailable\n")) | |||
| 23 | + | return | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | _, _ = w.Write([]byte("ok\n")) | |||
| 27 | + | } | |||
| 28 | + | } | |||
A internal/adapters/httpserver/middleware.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package httpserver | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/zerowrap" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval' | |
| 13 | + | // for htmx attribute expressions; inline scripts stay forbidden. | |
| 14 | + | const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |
| 15 | + | ||
| 16 | + | // withSecurityHeaders sets the security headers on every response, before | |
| 17 | + | // the handler runs, so that errors and 404s carry them too. | |
| 18 | + | func withSecurityHeaders(next http.Handler) http.Handler { | |
| 19 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 20 | + | h := w.Header() | |
| 21 | + | h.Set("Content-Security-Policy", contentSecurityPolicy) | |
| 22 | + | h.Set("X-Frame-Options", "DENY") | |
| 23 | + | h.Set("X-Content-Type-Options", "nosniff") | |
| 24 | + | h.Set("Referrer-Policy", "same-origin") | |
| 25 | + | next.ServeHTTP(w, r) | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // statusRecorder remembers the status code of a response. | |
| 30 | + | type statusRecorder struct { | |
| 31 | + | http.ResponseWriter | |
| 32 | + | status int | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (s *statusRecorder) WriteHeader(code int) { | |
| 36 | + | if s.status == 0 { | |
| 37 | + | s.status = code | |
| 38 | + | } | |
| 39 | + | s.ResponseWriter.WriteHeader(code) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (s *statusRecorder) Write(b []byte) (int, error) { | |
| 43 | + | if s.status == 0 { | |
| 44 | + | s.status = http.StatusOK | |
| 45 | + | } | |
| 46 | + | return s.ResponseWriter.Write(b) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Unwrap lets http.ResponseController reach Flush and friends. | |
| 50 | + | func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } | |
| 51 | + | ||
| 52 | + | // withRequestLog logs method, path, status and duration at debug level. It | |
| 53 | + | // never logs the query string or any header: they can carry credentials. | |
| 54 | + | func withRequestLog(next http.Handler) http.Handler { | |
| 55 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 56 | + | start := time.Now() | |
| 57 | + | rec := &statusRecorder{ResponseWriter: w} | |
| 58 | + | next.ServeHTTP(rec, r) | |
| 59 | + | status := rec.status | |
| 60 | + | if status == 0 { | |
| 61 | + | status = http.StatusOK | |
| 62 | + | } | |
| 63 | + | log := zerowrap.FromCtx(r.Context()) | |
| 64 | + | log.Debug(). | |
| 65 | + | Str("method", r.Method). | |
| 66 | + | Str("path", r.URL.Path). | |
| 67 | + | Int("status", status). | |
| 68 | + | Dur("duration", time.Since(start)). | |
| 69 | + | Msg("request") | |
| 70 | + | }) | |
| 71 | + | } | |
| @@ -0,0 +1,71 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package httpserver | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "net/http" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/zerowrap" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // contentSecurityPolicy allows scripts from the origin plus 'unsafe-eval' | |||
| 13 | + | // for htmx attribute expressions; inline scripts stay forbidden. | |||
| 14 | + | const contentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |||
| 15 | + | ||||
| 16 | + | // withSecurityHeaders sets the security headers on every response, before | |||
| 17 | + | // the handler runs, so that errors and 404s carry them too. | |||
| 18 | + | func withSecurityHeaders(next http.Handler) http.Handler { | |||
| 19 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 20 | + | h := w.Header() | |||
| 21 | + | h.Set("Content-Security-Policy", contentSecurityPolicy) | |||
| 22 | + | h.Set("X-Frame-Options", "DENY") | |||
| 23 | + | h.Set("X-Content-Type-Options", "nosniff") | |||
| 24 | + | h.Set("Referrer-Policy", "same-origin") | |||
| 25 | + | next.ServeHTTP(w, r) | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // statusRecorder remembers the status code of a response. | |||
| 30 | + | type statusRecorder struct { | |||
| 31 | + | http.ResponseWriter | |||
| 32 | + | status int | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (s *statusRecorder) WriteHeader(code int) { | |||
| 36 | + | if s.status == 0 { | |||
| 37 | + | s.status = code | |||
| 38 | + | } | |||
| 39 | + | s.ResponseWriter.WriteHeader(code) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func (s *statusRecorder) Write(b []byte) (int, error) { | |||
| 43 | + | if s.status == 0 { | |||
| 44 | + | s.status = http.StatusOK | |||
| 45 | + | } | |||
| 46 | + | return s.ResponseWriter.Write(b) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Unwrap lets http.ResponseController reach Flush and friends. | |||
| 50 | + | func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } | |||
| 51 | + | ||||
| 52 | + | // withRequestLog logs method, path, status and duration at debug level. It | |||
| 53 | + | // never logs the query string or any header: they can carry credentials. | |||
| 54 | + | func withRequestLog(next http.Handler) http.Handler { | |||
| 55 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 56 | + | start := time.Now() | |||
| 57 | + | rec := &statusRecorder{ResponseWriter: w} | |||
| 58 | + | next.ServeHTTP(rec, r) | |||
| 59 | + | status := rec.status | |||
| 60 | + | if status == 0 { | |||
| 61 | + | status = http.StatusOK | |||
| 62 | + | } | |||
| 63 | + | log := zerowrap.FromCtx(r.Context()) | |||
| 64 | + | log.Debug(). | |||
| 65 | + | Str("method", r.Method). | |||
| 66 | + | Str("path", r.URL.Path). | |||
| 67 | + | Int("status", status). | |||
| 68 | + | Dur("duration", time.Since(start)). | |||
| 69 | + | Msg("request") | |||
| 70 | + | }) | |||
| 71 | + | } | |||
A internal/adapters/httpserver/server.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | // Package httpserver is the HTTP entry of ermite: the router, the security | |
| 4 | + | // headers, the request log and the graceful shutdown. Routes are added here | |
| 5 | + | // as the other adapters arrive; for now it serves /-/health. | |
| 6 | + | package httpserver | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "net" | |
| 13 | + | "net/http" | |
| 14 | + | "os" | |
| 15 | + | "time" | |
| 16 | + | ||
| 17 | + | "github.com/bnema/zerowrap" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | // ShutdownTimeout is how long Run waits for requests in flight after the | |
| 21 | + | // context ends. | |
| 22 | + | const ShutdownTimeout = 10 * time.Second | |
| 23 | + | ||
| 24 | + | // Check reports whether a dependency works. A nil result means healthy. | |
| 25 | + | type Check func(ctx context.Context) error | |
| 26 | + | ||
| 27 | + | // Config configures a Server. | |
| 28 | + | type Config struct { | |
| 29 | + | // Addr is the listen address, "host:port". | |
| 30 | + | Addr string | |
| 31 | + | // HealthChecks run on every GET /-/health; one failure answers 503. | |
| 32 | + | HealthChecks []Check | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // Server is the HTTP server of ermite. | |
| 36 | + | type Server struct { | |
| 37 | + | addr string | |
| 38 | + | handler http.Handler | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // New builds the server and its routes. | |
| 42 | + | func New(cfg Config) *Server { | |
| 43 | + | mux := http.NewServeMux() | |
| 44 | + | // "GET" patterns also match HEAD; other methods get 405 with Allow, and | |
| 45 | + | // unknown paths get 404. | |
| 46 | + | mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks)) | |
| 47 | + | return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Handler returns the root handler, for tests. | |
| 51 | + | func (s *Server) Handler() http.Handler { return s.handler } | |
| 52 | + | ||
| 53 | + | // Run listens on the configured address and serves until ctx ends, then | |
| 54 | + | // shuts down gracefully within ShutdownTimeout. It returns nil after a clean | |
| 55 | + | // shutdown. The logger of ctx is used for the server and its requests. | |
| 56 | + | func (s *Server) Run(ctx context.Context) error { | |
| 57 | + | ln, err := net.Listen("tcp", s.addr) | |
| 58 | + | if err != nil { | |
| 59 | + | return fmt.Errorf("listen on %s: %w", s.addr, err) | |
| 60 | + | } | |
| 61 | + | return s.Serve(ctx, ln) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Serve is Run on an existing listener, which it closes. | |
| 65 | + | func (s *Server) Serve(ctx context.Context, ln net.Listener) error { | |
| 66 | + | log := zerowrap.FromCtx(ctx) | |
| 67 | + | srv := &http.Server{ | |
| 68 | + | Handler: s.handler, | |
| 69 | + | // Requests keep the logger of ctx but outlive its cancellation, so | |
| 70 | + | // that a graceful shutdown lets them finish. | |
| 71 | + | BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) }, | |
| 72 | + | ReadHeaderTimeout: 10 * time.Second, | |
| 73 | + | IdleTimeout: 2 * time.Minute, | |
| 74 | + | // No read or write timeout: Git clones and pushes can be long. | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | errc := make(chan error, 1) | |
| 78 | + | go func() { errc <- srv.Serve(ln) }() | |
| 79 | + | log.Info().Str("addr", ln.Addr().String()).Msg("listening") | |
| 80 | + | ||
| 81 | + | select { | |
| 82 | + | case err := <-errc: | |
| 83 | + | // Serve returns before Shutdown only on a failure. | |
| 84 | + | return fmt.Errorf("serve: %w", err) | |
| 85 | + | case <-ctx.Done(): | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | log.Info().Dur("timeout", ShutdownTimeout).Msg("shutting down") | |
| 89 | + | sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ShutdownTimeout) | |
| 90 | + | defer cancel() | |
| 91 | + | if err := srv.Shutdown(sctx); err != nil { | |
| 92 | + | _ = srv.Close() | |
| 93 | + | return fmt.Errorf("shutdown: %w", err) | |
| 94 | + | } | |
| 95 | + | if err := <-errc; err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 96 | + | return fmt.Errorf("serve: %w", err) | |
| 97 | + | } | |
| 98 | + | log.Info().Msg("http server stopped") | |
| 99 | + | return nil | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // DirWritable returns a Check that creates and removes a file in dir. | |
| 103 | + | func DirWritable(dir string) Check { | |
| 104 | + | return func(context.Context) error { | |
| 105 | + | f, err := os.CreateTemp(dir, ".health-*") | |
| 106 | + | if err != nil { | |
| 107 | + | return fmt.Errorf("data directory is not writable: %w", err) | |
| 108 | + | } | |
| 109 | + | name := f.Name() | |
| 110 | + | cerr := f.Close() | |
| 111 | + | rerr := os.Remove(name) | |
| 112 | + | if cerr != nil { | |
| 113 | + | return fmt.Errorf("data directory is not writable: %w", cerr) | |
| 114 | + | } | |
| 115 | + | if rerr != nil { | |
| 116 | + | return fmt.Errorf("data directory is not writable: %w", rerr) | |
| 117 | + | } | |
| 118 | + | return nil | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | // Package httpserver is the HTTP entry of ermite: the router, the security | |||
| 4 | + | // headers, the request log and the graceful shutdown. Routes are added here | |||
| 5 | + | // as the other adapters arrive; for now it serves /-/health. | |||
| 6 | + | package httpserver | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "context" | |||
| 10 | + | "errors" | |||
| 11 | + | "fmt" | |||
| 12 | + | "net" | |||
| 13 | + | "net/http" | |||
| 14 | + | "os" | |||
| 15 | + | "time" | |||
| 16 | + | ||||
| 17 | + | "github.com/bnema/zerowrap" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // ShutdownTimeout is how long Run waits for requests in flight after the | |||
| 21 | + | // context ends. | |||
| 22 | + | const ShutdownTimeout = 10 * time.Second | |||
| 23 | + | ||||
| 24 | + | // Check reports whether a dependency works. A nil result means healthy. | |||
| 25 | + | type Check func(ctx context.Context) error | |||
| 26 | + | ||||
| 27 | + | // Config configures a Server. | |||
| 28 | + | type Config struct { | |||
| 29 | + | // Addr is the listen address, "host:port". | |||
| 30 | + | Addr string | |||
| 31 | + | // HealthChecks run on every GET /-/health; one failure answers 503. | |||
| 32 | + | HealthChecks []Check | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // Server is the HTTP server of ermite. | |||
| 36 | + | type Server struct { | |||
| 37 | + | addr string | |||
| 38 | + | handler http.Handler | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // New builds the server and its routes. | |||
| 42 | + | func New(cfg Config) *Server { | |||
| 43 | + | mux := http.NewServeMux() | |||
| 44 | + | // "GET" patterns also match HEAD; other methods get 405 with Allow, and | |||
| 45 | + | // unknown paths get 404. | |||
| 46 | + | mux.HandleFunc("GET /-/health", healthHandler(cfg.HealthChecks)) | |||
| 47 | + | return &Server{addr: cfg.Addr, handler: withRequestLog(withSecurityHeaders(mux))} | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // Handler returns the root handler, for tests. | |||
| 51 | + | func (s *Server) Handler() http.Handler { return s.handler } | |||
| 52 | + | ||||
| 53 | + | // Run listens on the configured address and serves until ctx ends, then | |||
| 54 | + | // shuts down gracefully within ShutdownTimeout. It returns nil after a clean | |||
| 55 | + | // shutdown. The logger of ctx is used for the server and its requests. | |||
| 56 | + | func (s *Server) Run(ctx context.Context) error { | |||
| 57 | + | ln, err := net.Listen("tcp", s.addr) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return fmt.Errorf("listen on %s: %w", s.addr, err) | |||
| 60 | + | } | |||
| 61 | + | return s.Serve(ctx, ln) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // Serve is Run on an existing listener, which it closes. | |||
| 65 | + | func (s *Server) Serve(ctx context.Context, ln net.Listener) error { | |||
| 66 | + | log := zerowrap.FromCtx(ctx) | |||
| 67 | + | srv := &http.Server{ | |||
| 68 | + | Handler: s.handler, | |||
| 69 | + | // Requests keep the logger of ctx but outlive its cancellation, so | |||
| 70 | + | // that a graceful shutdown lets them finish. | |||
| 71 | + | BaseContext: func(net.Listener) context.Context { return context.WithoutCancel(ctx) }, | |||
| 72 | + | ReadHeaderTimeout: 10 * time.Second, | |||
| 73 | + | IdleTimeout: 2 * time.Minute, | |||
| 74 | + | // No read or write timeout: Git clones and pushes can be long. | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | errc := make(chan error, 1) | |||
| 78 | + | go func() { errc <- srv.Serve(ln) }() | |||
| 79 | + | log.Info().Str("addr", ln.Addr().String()).Msg("listening") | |||
| 80 | + | ||||
| 81 | + | select { | |||
| 82 | + | case err := <-errc: | |||
| 83 | + | // Serve returns before Shutdown only on a failure. | |||
| 84 | + | return fmt.Errorf("serve: %w", err) | |||
| 85 | + | case <-ctx.Done(): | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | log.Info().Dur("timeout", ShutdownTimeout).Msg("shutting down") | |||
| 89 | + | sctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), ShutdownTimeout) | |||
| 90 | + | defer cancel() | |||
| 91 | + | if err := srv.Shutdown(sctx); err != nil { | |||
| 92 | + | _ = srv.Close() | |||
| 93 | + | return fmt.Errorf("shutdown: %w", err) | |||
| 94 | + | } | |||
| 95 | + | if err := <-errc; err != nil && !errors.Is(err, http.ErrServerClosed) { | |||
| 96 | + | return fmt.Errorf("serve: %w", err) | |||
| 97 | + | } | |||
| 98 | + | log.Info().Msg("http server stopped") | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // DirWritable returns a Check that creates and removes a file in dir. | |||
| 103 | + | func DirWritable(dir string) Check { | |||
| 104 | + | return func(context.Context) error { | |||
| 105 | + | f, err := os.CreateTemp(dir, ".health-*") | |||
| 106 | + | if err != nil { | |||
| 107 | + | return fmt.Errorf("data directory is not writable: %w", err) | |||
| 108 | + | } | |||
| 109 | + | name := f.Name() | |||
| 110 | + | cerr := f.Close() | |||
| 111 | + | rerr := os.Remove(name) | |||
| 112 | + | if cerr != nil { | |||
| 113 | + | return fmt.Errorf("data directory is not writable: %w", cerr) | |||
| 114 | + | } | |||
| 115 | + | if rerr != nil { | |||
| 116 | + | return fmt.Errorf("data directory is not writable: %w", rerr) | |||
| 117 | + | } | |||
| 118 | + | return nil | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
A internal/adapters/httpserver/server_test.go +223 -0
| @@ -0,0 +1,223 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package httpserver_test | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "io" | |
| 11 | + | "net" | |
| 12 | + | "net/http" | |
| 13 | + | "net/http/httptest" | |
| 14 | + | "os" | |
| 15 | + | "strings" | |
| 16 | + | "sync" | |
| 17 | + | "testing" | |
| 18 | + | "time" | |
| 19 | + | ||
| 20 | + | "github.com/bnema/zerowrap" | |
| 21 | + | "github.com/stretchr/testify/assert" | |
| 22 | + | "github.com/stretchr/testify/require" | |
| 23 | + | ||
| 24 | + | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | var securityHeaders = map[string]string{ | |
| 28 | + | "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'", | |
| 29 | + | "X-Frame-Options": "DENY", | |
| 30 | + | "X-Content-Type-Options": "nosniff", | |
| 31 | + | "Referrer-Policy": "same-origin", | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func do(t *testing.T, h http.Handler, method, target string) *httptest.ResponseRecorder { | |
| 35 | + | t.Helper() | |
| 36 | + | rec := httptest.NewRecorder() | |
| 37 | + | h.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) | |
| 38 | + | return rec | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestHealth(t *testing.T) { | |
| 42 | + | ok := httpserver.Check(func(context.Context) error { return nil }) | |
| 43 | + | fail := httpserver.Check(func(context.Context) error { return errors.New("kv down: secret detail") }) | |
| 44 | + | ||
| 45 | + | tests := []struct { | |
| 46 | + | name string | |
| 47 | + | checks []httpserver.Check | |
| 48 | + | method string | |
| 49 | + | wantStatus int | |
| 50 | + | wantBody string | |
| 51 | + | }{ | |
| 52 | + | {"no checks", nil, "GET", 200, "ok\n"}, | |
| 53 | + | {"all pass", []httpserver.Check{ok, ok}, "GET", 200, "ok\n"}, | |
| 54 | + | {"head", []httpserver.Check{ok}, "HEAD", 200, ""}, // the real server drops the body | |
| 55 | + | {"one fails", []httpserver.Check{ok, fail}, "GET", 503, "unavailable\n"}, | |
| 56 | + | {"head fails", []httpserver.Check{fail}, "HEAD", 503, ""}, | |
| 57 | + | {"post", []httpserver.Check{ok}, "POST", 405, ""}, | |
| 58 | + | {"delete", []httpserver.Check{ok}, "DELETE", 405, ""}, | |
| 59 | + | } | |
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | h := httpserver.New(httpserver.Config{HealthChecks: tt.checks}).Handler() | |
| 63 | + | rec := do(t, h, tt.method, "/-/health") | |
| 64 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 65 | + | if tt.method == "GET" { | |
| 66 | + | assert.Equal(t, tt.wantBody, rec.Body.String()) | |
| 67 | + | } | |
| 68 | + | assert.NotContains(t, rec.Body.String(), "secret detail", "the failure cause stays in the log") | |
| 69 | + | if tt.wantStatus == 405 { | |
| 70 | + | assert.Contains(t, rec.Header().Get("Allow"), "GET") | |
| 71 | + | } | |
| 72 | + | for k, v := range securityHeaders { | |
| 73 | + | assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, tt.name) | |
| 74 | + | } | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestHeadOverTheWire(t *testing.T) { | |
| 80 | + | ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler()) | |
| 81 | + | defer ts.Close() | |
| 82 | + | resp, err := http.Head(ts.URL + "/-/health") | |
| 83 | + | require.NoError(t, err) | |
| 84 | + | defer func() { _ = resp.Body.Close() }() | |
| 85 | + | assert.Equal(t, 200, resp.StatusCode) | |
| 86 | + | body, err := io.ReadAll(resp.Body) | |
| 87 | + | require.NoError(t, err) | |
| 88 | + | assert.Empty(t, body) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestNotFoundCarriesSecurityHeaders(t *testing.T) { | |
| 92 | + | h := httpserver.New(httpserver.Config{}).Handler() | |
| 93 | + | for _, target := range []string{"/", "/nope", "/-/", "/-/health/x", "/-/healthz", "/some-repo.git/info/refs"} { | |
| 94 | + | rec := do(t, h, "GET", target) | |
| 95 | + | assert.Equal(t, 404, rec.Code, target) | |
| 96 | + | for k, v := range securityHeaders { | |
| 97 | + | assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, target) | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | type syncBuffer struct { | |
| 103 | + | mu sync.Mutex | |
| 104 | + | b bytes.Buffer | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func (s *syncBuffer) Write(p []byte) (int, error) { | |
| 108 | + | s.mu.Lock() | |
| 109 | + | defer s.mu.Unlock() | |
| 110 | + | return s.b.Write(p) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (s *syncBuffer) String() string { | |
| 114 | + | s.mu.Lock() | |
| 115 | + | defer s.mu.Unlock() | |
| 116 | + | return s.b.String() | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func TestRequestLog(t *testing.T) { | |
| 120 | + | var buf syncBuffer | |
| 121 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})) | |
| 122 | + | h := httpserver.New(httpserver.Config{}).Handler() | |
| 123 | + | ||
| 124 | + | req := httptest.NewRequestWithContext(ctx, "GET", "/missing?token=hunter2", nil) | |
| 125 | + | req.Header.Set("Authorization", "Bearer hunter2") | |
| 126 | + | h.ServeHTTP(httptest.NewRecorder(), req) | |
| 127 | + | ||
| 128 | + | var entry map[string]any | |
| 129 | + | require.NoError(t, json.Unmarshal(bytes.TrimSpace([]byte(buf.String())), &entry), buf.String()) | |
| 130 | + | assert.Equal(t, "debug", entry["level"]) | |
| 131 | + | assert.Equal(t, "GET", entry["method"]) | |
| 132 | + | assert.Equal(t, "/missing", entry["path"]) | |
| 133 | + | assert.EqualValues(t, 404, entry["status"]) | |
| 134 | + | assert.Contains(t, entry, "duration") | |
| 135 | + | assert.NotContains(t, buf.String(), "hunter2") | |
| 136 | + | ||
| 137 | + | // At info level a request logs nothing. | |
| 138 | + | var quiet syncBuffer | |
| 139 | + | ctx = zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &quiet})) | |
| 140 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "GET", "/missing", nil)) | |
| 141 | + | assert.Empty(t, quiet.String()) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestDirWritable(t *testing.T) { | |
| 145 | + | dir := t.TempDir() | |
| 146 | + | check := httpserver.DirWritable(dir) | |
| 147 | + | require.NoError(t, check(context.Background())) | |
| 148 | + | entries, err := os.ReadDir(dir) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | assert.Empty(t, entries, "the probe file is removed") | |
| 151 | + | ||
| 152 | + | assert.Error(t, httpserver.DirWritable(dir+"/missing")(context.Background())) | |
| 153 | + | ||
| 154 | + | if os.Geteuid() != 0 { | |
| 155 | + | require.NoError(t, os.Chmod(dir, 0o500)) | |
| 156 | + | t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) | |
| 157 | + | assert.Error(t, check(context.Background())) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func TestServeAndGracefulShutdown(t *testing.T) { | |
| 162 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 163 | + | require.NoError(t, err) | |
| 164 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 165 | + | defer cancel() | |
| 166 | + | ||
| 167 | + | started := make(chan struct{}) | |
| 168 | + | release := make(chan struct{}) | |
| 169 | + | slow := httpserver.Check(func(context.Context) error { | |
| 170 | + | close(started) | |
| 171 | + | <-release | |
| 172 | + | return nil | |
| 173 | + | }) | |
| 174 | + | srv := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{slow}}) | |
| 175 | + | done := make(chan error, 1) | |
| 176 | + | go func() { done <- srv.Serve(ctx, ln) }() | |
| 177 | + | ||
| 178 | + | // A request in flight when shutdown starts is allowed to finish. | |
| 179 | + | type result struct { | |
| 180 | + | body string | |
| 181 | + | err error | |
| 182 | + | } | |
| 183 | + | res := make(chan result, 1) | |
| 184 | + | go func() { | |
| 185 | + | resp, err := http.Get("http://" + ln.Addr().String() + "/-/health") | |
| 186 | + | if err != nil { | |
| 187 | + | res <- result{err: err} | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | defer func() { _ = resp.Body.Close() }() | |
| 191 | + | b, err := io.ReadAll(resp.Body) | |
| 192 | + | res <- result{body: string(b), err: err} | |
| 193 | + | }() | |
| 194 | + | <-started | |
| 195 | + | cancel() | |
| 196 | + | select { | |
| 197 | + | case err := <-done: | |
| 198 | + | t.Fatalf("Serve returned while a request was in flight: %v", err) | |
| 199 | + | case <-time.After(200 * time.Millisecond): | |
| 200 | + | } | |
| 201 | + | close(release) | |
| 202 | + | ||
| 203 | + | r := <-res | |
| 204 | + | require.NoError(t, r.err) | |
| 205 | + | assert.Equal(t, "ok\n", r.body) | |
| 206 | + | select { | |
| 207 | + | case err := <-done: | |
| 208 | + | assert.NoError(t, err) | |
| 209 | + | case <-time.After(5 * time.Second): | |
| 210 | + | t.Fatal("Serve did not return after the request finished") | |
| 211 | + | } | |
| 212 | + | _, err = net.DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond) | |
| 213 | + | assert.Error(t, err, "the listener is closed") | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestRunListenError(t *testing.T) { | |
| 217 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |
| 218 | + | require.NoError(t, err) | |
| 219 | + | defer func() { _ = ln.Close() }() | |
| 220 | + | err = httpserver.New(httpserver.Config{Addr: ln.Addr().String()}).Run(context.Background()) | |
| 221 | + | require.Error(t, err) | |
| 222 | + | assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error()) | |
| 223 | + | } | |
| @@ -0,0 +1,223 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package httpserver_test | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "io" | |||
| 11 | + | "net" | |||
| 12 | + | "net/http" | |||
| 13 | + | "net/http/httptest" | |||
| 14 | + | "os" | |||
| 15 | + | "strings" | |||
| 16 | + | "sync" | |||
| 17 | + | "testing" | |||
| 18 | + | "time" | |||
| 19 | + | ||||
| 20 | + | "github.com/bnema/zerowrap" | |||
| 21 | + | "github.com/stretchr/testify/assert" | |||
| 22 | + | "github.com/stretchr/testify/require" | |||
| 23 | + | ||||
| 24 | + | "bnema.dev/ermite/internal/adapters/httpserver" | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | var securityHeaders = map[string]string{ | |||
| 28 | + | "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'", | |||
| 29 | + | "X-Frame-Options": "DENY", | |||
| 30 | + | "X-Content-Type-Options": "nosniff", | |||
| 31 | + | "Referrer-Policy": "same-origin", | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func do(t *testing.T, h http.Handler, method, target string) *httptest.ResponseRecorder { | |||
| 35 | + | t.Helper() | |||
| 36 | + | rec := httptest.NewRecorder() | |||
| 37 | + | h.ServeHTTP(rec, httptest.NewRequest(method, target, nil)) | |||
| 38 | + | return rec | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestHealth(t *testing.T) { | |||
| 42 | + | ok := httpserver.Check(func(context.Context) error { return nil }) | |||
| 43 | + | fail := httpserver.Check(func(context.Context) error { return errors.New("kv down: secret detail") }) | |||
| 44 | + | ||||
| 45 | + | tests := []struct { | |||
| 46 | + | name string | |||
| 47 | + | checks []httpserver.Check | |||
| 48 | + | method string | |||
| 49 | + | wantStatus int | |||
| 50 | + | wantBody string | |||
| 51 | + | }{ | |||
| 52 | + | {"no checks", nil, "GET", 200, "ok\n"}, | |||
| 53 | + | {"all pass", []httpserver.Check{ok, ok}, "GET", 200, "ok\n"}, | |||
| 54 | + | {"head", []httpserver.Check{ok}, "HEAD", 200, ""}, // the real server drops the body | |||
| 55 | + | {"one fails", []httpserver.Check{ok, fail}, "GET", 503, "unavailable\n"}, | |||
| 56 | + | {"head fails", []httpserver.Check{fail}, "HEAD", 503, ""}, | |||
| 57 | + | {"post", []httpserver.Check{ok}, "POST", 405, ""}, | |||
| 58 | + | {"delete", []httpserver.Check{ok}, "DELETE", 405, ""}, | |||
| 59 | + | } | |||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | h := httpserver.New(httpserver.Config{HealthChecks: tt.checks}).Handler() | |||
| 63 | + | rec := do(t, h, tt.method, "/-/health") | |||
| 64 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 65 | + | if tt.method == "GET" { | |||
| 66 | + | assert.Equal(t, tt.wantBody, rec.Body.String()) | |||
| 67 | + | } | |||
| 68 | + | assert.NotContains(t, rec.Body.String(), "secret detail", "the failure cause stays in the log") | |||
| 69 | + | if tt.wantStatus == 405 { | |||
| 70 | + | assert.Contains(t, rec.Header().Get("Allow"), "GET") | |||
| 71 | + | } | |||
| 72 | + | for k, v := range securityHeaders { | |||
| 73 | + | assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, tt.name) | |||
| 74 | + | } | |||
| 75 | + | }) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestHeadOverTheWire(t *testing.T) { | |||
| 80 | + | ts := httptest.NewServer(httpserver.New(httpserver.Config{}).Handler()) | |||
| 81 | + | defer ts.Close() | |||
| 82 | + | resp, err := http.Head(ts.URL + "/-/health") | |||
| 83 | + | require.NoError(t, err) | |||
| 84 | + | defer func() { _ = resp.Body.Close() }() | |||
| 85 | + | assert.Equal(t, 200, resp.StatusCode) | |||
| 86 | + | body, err := io.ReadAll(resp.Body) | |||
| 87 | + | require.NoError(t, err) | |||
| 88 | + | assert.Empty(t, body) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestNotFoundCarriesSecurityHeaders(t *testing.T) { | |||
| 92 | + | h := httpserver.New(httpserver.Config{}).Handler() | |||
| 93 | + | for _, target := range []string{"/", "/nope", "/-/", "/-/health/x", "/-/healthz", "/some-repo.git/info/refs"} { | |||
| 94 | + | rec := do(t, h, "GET", target) | |||
| 95 | + | assert.Equal(t, 404, rec.Code, target) | |||
| 96 | + | for k, v := range securityHeaders { | |||
| 97 | + | assert.Equal(t, v, rec.Header().Get(k), "%s on %s", k, target) | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | type syncBuffer struct { | |||
| 103 | + | mu sync.Mutex | |||
| 104 | + | b bytes.Buffer | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func (s *syncBuffer) Write(p []byte) (int, error) { | |||
| 108 | + | s.mu.Lock() | |||
| 109 | + | defer s.mu.Unlock() | |||
| 110 | + | return s.b.Write(p) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (s *syncBuffer) String() string { | |||
| 114 | + | s.mu.Lock() | |||
| 115 | + | defer s.mu.Unlock() | |||
| 116 | + | return s.b.String() | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestRequestLog(t *testing.T) { | |||
| 120 | + | var buf syncBuffer | |||
| 121 | + | ctx := zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: &buf})) | |||
| 122 | + | h := httpserver.New(httpserver.Config{}).Handler() | |||
| 123 | + | ||||
| 124 | + | req := httptest.NewRequestWithContext(ctx, "GET", "/missing?token=hunter2", nil) | |||
| 125 | + | req.Header.Set("Authorization", "Bearer hunter2") | |||
| 126 | + | h.ServeHTTP(httptest.NewRecorder(), req) | |||
| 127 | + | ||||
| 128 | + | var entry map[string]any | |||
| 129 | + | require.NoError(t, json.Unmarshal(bytes.TrimSpace([]byte(buf.String())), &entry), buf.String()) | |||
| 130 | + | assert.Equal(t, "debug", entry["level"]) | |||
| 131 | + | assert.Equal(t, "GET", entry["method"]) | |||
| 132 | + | assert.Equal(t, "/missing", entry["path"]) | |||
| 133 | + | assert.EqualValues(t, 404, entry["status"]) | |||
| 134 | + | assert.Contains(t, entry, "duration") | |||
| 135 | + | assert.NotContains(t, buf.String(), "hunter2") | |||
| 136 | + | ||||
| 137 | + | // At info level a request logs nothing. | |||
| 138 | + | var quiet syncBuffer | |||
| 139 | + | ctx = zerowrap.WithCtx(context.Background(), zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &quiet})) | |||
| 140 | + | h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequestWithContext(ctx, "GET", "/missing", nil)) | |||
| 141 | + | assert.Empty(t, quiet.String()) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestDirWritable(t *testing.T) { | |||
| 145 | + | dir := t.TempDir() | |||
| 146 | + | check := httpserver.DirWritable(dir) | |||
| 147 | + | require.NoError(t, check(context.Background())) | |||
| 148 | + | entries, err := os.ReadDir(dir) | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | assert.Empty(t, entries, "the probe file is removed") | |||
| 151 | + | ||||
| 152 | + | assert.Error(t, httpserver.DirWritable(dir+"/missing")(context.Background())) | |||
| 153 | + | ||||
| 154 | + | if os.Geteuid() != 0 { | |||
| 155 | + | require.NoError(t, os.Chmod(dir, 0o500)) | |||
| 156 | + | t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) | |||
| 157 | + | assert.Error(t, check(context.Background())) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func TestServeAndGracefulShutdown(t *testing.T) { | |||
| 162 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 163 | + | require.NoError(t, err) | |||
| 164 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 165 | + | defer cancel() | |||
| 166 | + | ||||
| 167 | + | started := make(chan struct{}) | |||
| 168 | + | release := make(chan struct{}) | |||
| 169 | + | slow := httpserver.Check(func(context.Context) error { | |||
| 170 | + | close(started) | |||
| 171 | + | <-release | |||
| 172 | + | return nil | |||
| 173 | + | }) | |||
| 174 | + | srv := httpserver.New(httpserver.Config{HealthChecks: []httpserver.Check{slow}}) | |||
| 175 | + | done := make(chan error, 1) | |||
| 176 | + | go func() { done <- srv.Serve(ctx, ln) }() | |||
| 177 | + | ||||
| 178 | + | // A request in flight when shutdown starts is allowed to finish. | |||
| 179 | + | type result struct { | |||
| 180 | + | body string | |||
| 181 | + | err error | |||
| 182 | + | } | |||
| 183 | + | res := make(chan result, 1) | |||
| 184 | + | go func() { | |||
| 185 | + | resp, err := http.Get("http://" + ln.Addr().String() + "/-/health") | |||
| 186 | + | if err != nil { | |||
| 187 | + | res <- result{err: err} | |||
| 188 | + | return | |||
| 189 | + | } | |||
| 190 | + | defer func() { _ = resp.Body.Close() }() | |||
| 191 | + | b, err := io.ReadAll(resp.Body) | |||
| 192 | + | res <- result{body: string(b), err: err} | |||
| 193 | + | }() | |||
| 194 | + | <-started | |||
| 195 | + | cancel() | |||
| 196 | + | select { | |||
| 197 | + | case err := <-done: | |||
| 198 | + | t.Fatalf("Serve returned while a request was in flight: %v", err) | |||
| 199 | + | case <-time.After(200 * time.Millisecond): | |||
| 200 | + | } | |||
| 201 | + | close(release) | |||
| 202 | + | ||||
| 203 | + | r := <-res | |||
| 204 | + | require.NoError(t, r.err) | |||
| 205 | + | assert.Equal(t, "ok\n", r.body) | |||
| 206 | + | select { | |||
| 207 | + | case err := <-done: | |||
| 208 | + | assert.NoError(t, err) | |||
| 209 | + | case <-time.After(5 * time.Second): | |||
| 210 | + | t.Fatal("Serve did not return after the request finished") | |||
| 211 | + | } | |||
| 212 | + | _, err = net.DialTimeout("tcp", ln.Addr().String(), 200*time.Millisecond) | |||
| 213 | + | assert.Error(t, err, "the listener is closed") | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestRunListenError(t *testing.T) { | |||
| 217 | + | ln, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 218 | + | require.NoError(t, err) | |||
| 219 | + | defer func() { _ = ln.Close() }() | |||
| 220 | + | err = httpserver.New(httpserver.Config{Addr: ln.Addr().String()}).Run(context.Background()) | |||
| 221 | + | require.Error(t, err) | |||
| 222 | + | assert.True(t, strings.Contains(err.Error(), "listen on"), err.Error()) | |||
| 223 | + | } | |||