Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422b7c020b |
@@ -238,7 +238,7 @@ variables set by the file's `env:` section are checked the same way.
|
|||||||
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
| `PIXA_UPSTREAM_FETCH_TIMEOUT` | `upstream_fetch_timeout` | Time allowed for one fetch from an upstream host; default `30s` |
|
||||||
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
| `PIXA_UPSTREAM_MAX_RESPONSE_SIZE` | `upstream_max_response_size` | Largest upstream response accepted, in bytes; default 50 MiB |
|
||||||
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
| `PIXA_DOWNSTREAM_TIMEOUT` | `downstream_timeout` | Time allowed for answering one client request; default `60s` |
|
||||||
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read image responses: `*` or one origin; default `*` |
|
| `PIXA_ACCESS_CONTROL_ALLOW_ORIGIN` | `access_control_allow_origin` | CORS origin allowed to read responses: `*` or one origin; default `*` |
|
||||||
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
| `PIXA_METRICS_USERNAME` | `metrics.username` | Username for `/metrics`, which is served only when both are set |
|
||||||
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
| `PIXA_METRICS_PASSWORD` | `metrics.password` | Password for `/metrics`; set together with the username |
|
||||||
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
| `PIXA_SENTRY_DSN` | `sentry_dsn` | Sentry DSN for error reporting; empty disables it |
|
||||||
@@ -247,9 +247,8 @@ variables set by the file's `env:` section are checked the same way.
|
|||||||
|
|
||||||
Key settings in more detail:
|
Key settings in more detail:
|
||||||
|
|
||||||
- `access_control_allow_origin` — the origin a browser lets read the responses
|
- `access_control_allow_origin` — the origin a browser lets read pixa's
|
||||||
of the image routes, `/v1/image/` and `/v1/e/`, sent as the CORS
|
responses, sent as the CORS `Access-Control-Allow-Origin` header: `*`, the
|
||||||
`Access-Control-Allow-Origin` header; no other route sends it. `*`, the
|
|
||||||
default, is any site; otherwise one `http` or `https` origin such as
|
default, is any site; otherwise one `http` or `https` origin such as
|
||||||
`https://example.com`, whose host is a lowercase host name (letters,
|
`https://example.com`, whose host is a lowercase host name (letters,
|
||||||
digits, hyphens and dots, with a letter in its last part) or an IP address
|
digits, hyphens and dots, with a letter in its last part) or an IP address
|
||||||
|
|||||||
@@ -29,12 +29,6 @@ P2: security: referer blacklist
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-29 only the image routes send CORS headers (closes #98): the CORS
|
|
||||||
middleware, with the `access_control_allow_origin` origin, moved from the
|
|
||||||
router root onto a `/v1` subrouter holding `/v1/image/` and `/v1/e/`, where it
|
|
||||||
still answers a preflight `OPTIONS` request; the login and URL generator
|
|
||||||
pages, `/metrics` and the other routes send no `Access-Control-Allow-Origin`;
|
|
||||||
documented in `README.md` and `config.example.yml`.
|
|
||||||
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
|
- 2026-09-29 the container makes `/var/lib/pixa` usable by itself (closes
|
||||||
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
|
#159): `deploy/docker-entrypoint.sh` creates the directory if it is missing,
|
||||||
gives the directory and everything in it to `pixad` when the directory or one
|
gives the directory and everything in it to `pixad` when the directory or one
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errTestHook is the error returned by the test hooks that fail.
|
||||||
|
var errTestHook = errors.New("test hook failed")
|
||||||
|
|
||||||
|
// TestRunAppExitCode checks the exit code runApp returns: the one a
|
||||||
|
// shutdown request carries, 0 for a request without one (as for SIGINT or
|
||||||
|
// SIGTERM), and 1 when the app fails to start or to stop.
|
||||||
|
func TestRunAppExitCode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
hook func(shutdowner fx.Shutdowner) fx.Hook
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "shutdown requested with exit code 1",
|
||||||
|
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||||
|
return fx.StartHook(func() error {
|
||||||
|
return shutdowner.Shutdown(fx.ExitCode(1))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shutdown requested without an exit code",
|
||||||
|
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||||
|
return fx.StartHook(func() error {
|
||||||
|
return shutdowner.Shutdown()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "start fails",
|
||||||
|
hook: func(fx.Shutdowner) fx.Hook {
|
||||||
|
return fx.StartHook(func() error { return errTestHook })
|
||||||
|
},
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "stop fails",
|
||||||
|
hook: func(shutdowner fx.Shutdowner) fx.Hook {
|
||||||
|
return fx.StartStopHook(
|
||||||
|
func() error { return shutdowner.Shutdown() },
|
||||||
|
func() error { return errTestHook },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.NopLogger,
|
||||||
|
fx.Invoke(func(lc fx.Lifecycle, shutdowner fx.Shutdowner) {
|
||||||
|
lc.Append(tc.hook(shutdowner))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
got := runApp(app)
|
||||||
|
t.Logf("runApp() = %d", got)
|
||||||
|
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("runApp() = %d, want %d", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-3
@@ -103,9 +103,8 @@ upstream_max_response_size: 52428800
|
|||||||
# longer than upstream_fetch_timeout plus 20 seconds.
|
# longer than upstream_fetch_timeout plus 20 seconds.
|
||||||
downstream_timeout: 60s
|
downstream_timeout: 60s
|
||||||
|
|
||||||
# The origin a browser lets read the responses of the image routes,
|
# The origin a browser lets read pixa's responses, sent as the CORS
|
||||||
# /v1/image/ and /v1/e/, sent as the CORS Access-Control-Allow-Origin
|
# Access-Control-Allow-Origin header: "*" (the default) is any site;
|
||||||
# header; no other route sends it. "*" (the default) is any site;
|
|
||||||
# otherwise one http or https origin such as https://example.com, whose
|
# otherwise one http or https origin such as https://example.com, whose
|
||||||
# host is a lowercase host name (letters, digits, hyphens and dots, with a
|
# host is a lowercase host name (letters, digits, hyphens and dots, with a
|
||||||
# letter in its last part) or an IP address (IPv6 in brackets, in its
|
# letter in its last part) or an IP address (IPv6 in brackets, in its
|
||||||
|
|||||||
@@ -300,3 +300,69 @@ func TestProcessReleasesSlotOnError(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWaitForProcessing holds a processing slot with a Process call that
|
||||||
|
// cannot finish reading its input. WaitForProcessing must report that image
|
||||||
|
// when its context ends first, wait for it otherwise, return 0 once it has
|
||||||
|
// finished, and give back the slots it took while waiting.
|
||||||
|
func TestWaitForProcessing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
proc := New(Params{MaxConcurrentProcessing: 2})
|
||||||
|
|
||||||
|
gate := make(chan struct{})
|
||||||
|
entered := make(chan struct{}, 1)
|
||||||
|
results := make(chan error, 1)
|
||||||
|
|
||||||
|
openGate := sync.OnceFunc(func() { close(gate) })
|
||||||
|
t.Cleanup(openGate)
|
||||||
|
|
||||||
|
processInBackground(proc, &gatedReader{
|
||||||
|
data: bytes.NewReader(createTestJPEG(t, 10, 10)), gate: gate,
|
||||||
|
entered: entered, counter: &readingCounter{},
|
||||||
|
}, results)
|
||||||
|
waitForEntries(t, entered, 1)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
stillProcessing := proc.WaitForProcessing(ctx)
|
||||||
|
t.Logf("WaitForProcessing() after its context ended: %d", stillProcessing)
|
||||||
|
|
||||||
|
if stillProcessing != 1 {
|
||||||
|
t.Errorf("WaitForProcessing() after its context ended = %d, want 1",
|
||||||
|
stillProcessing)
|
||||||
|
}
|
||||||
|
|
||||||
|
waited := make(chan int, 1)
|
||||||
|
|
||||||
|
go func() { waited <- proc.WaitForProcessing(t.Context()) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-waited:
|
||||||
|
t.Fatalf("WaitForProcessing() = %d while an image was being processed",
|
||||||
|
got)
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
openGate()
|
||||||
|
|
||||||
|
err := <-results
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Process() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case got := <-waited:
|
||||||
|
if got != 0 {
|
||||||
|
t.Errorf("WaitForProcessing() once processing finished = %d, want 0",
|
||||||
|
got)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("WaitForProcessing() did not return once processing finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
if held := len(proc.processingSemaphore); held != 0 {
|
||||||
|
t.Errorf("%d slots still held after WaitForProcessing() returned", held)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestCORSOnlyOnImageRoutes verifies that the image routes answer with the
|
|
||||||
// configured access_control_allow_origin, a preflight request included, and
|
|
||||||
// that the login and URL generator pages send no Access-Control-Allow-Origin,
|
|
||||||
// so no other site can read them. /metrics is left out: its middleware
|
|
||||||
// registers with the process-wide Prometheus registry, which only one test
|
|
||||||
// in this package can do.
|
|
||||||
func TestCORSOnlyOnImageRoutes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const appOrigin = "https://app.example.com"
|
|
||||||
|
|
||||||
s := newTestServer(t)
|
|
||||||
s.config.AccessControlAllowOrigin = appOrigin
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
requests := []struct {
|
|
||||||
method string
|
|
||||||
path string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{http.MethodGet, unsignedImagePath, appOrigin},
|
|
||||||
{http.MethodHead, unsignedImagePath, appOrigin},
|
|
||||||
{http.MethodOptions, unsignedImagePath, appOrigin},
|
|
||||||
{http.MethodGet, encryptedImagePath, appOrigin},
|
|
||||||
{http.MethodGet, "/", ""},
|
|
||||||
{http.MethodOptions, "/", ""},
|
|
||||||
{http.MethodPost, "/generate", ""},
|
|
||||||
{http.MethodGet, "/logout", ""},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range requests {
|
|
||||||
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
t.Context(), tc.method, tc.path, nil)
|
|
||||||
req.Header.Set("Origin", appOrigin)
|
|
||||||
|
|
||||||
// An OPTIONS request naming the method it asks about is the
|
|
||||||
// preflight a browser sends before some cross-origin requests.
|
|
||||||
if tc.method == http.MethodOptions {
|
|
||||||
req.Header.Set("Access-Control-Request-Method", http.MethodGet)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
s.ServeHTTP(rec, req)
|
|
||||||
t.Logf("status %d", rec.Code)
|
|
||||||
|
|
||||||
got := rec.Header().Get("Access-Control-Allow-Origin")
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("Access-Control-Allow-Origin = %q, want %q",
|
|
||||||
got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,10 +13,6 @@ import (
|
|||||||
// unsignedImagePath is an image URL that carries no signature.
|
// unsignedImagePath is an image URL that carries no signature.
|
||||||
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
|
const unsignedImagePath = "/v1/image/cdn.example.com/cat.jpg/100x100.jpeg"
|
||||||
|
|
||||||
// encryptedImagePath is an encrypted image URL whose token cannot be
|
|
||||||
// decrypted.
|
|
||||||
const encryptedImagePath = "/v1/e/token/cat.jpg"
|
|
||||||
|
|
||||||
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance
|
// TestMaintenanceModeRefusesImageRequests verifies that while maintenance
|
||||||
// mode is on, both image routes answer 503 Service Unavailable with a
|
// mode is on, both image routes answer 503 Service Unavailable with a
|
||||||
// Retry-After header and the JSON error body the image handlers send.
|
// Retry-After header and the JSON error body the image handlers send.
|
||||||
@@ -32,7 +28,7 @@ func TestMaintenanceModeRefusesImageRequests(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.MethodGet, unsignedImagePath},
|
{http.MethodGet, unsignedImagePath},
|
||||||
{http.MethodHead, unsignedImagePath},
|
{http.MethodHead, unsignedImagePath},
|
||||||
{http.MethodGet, encryptedImagePath},
|
{http.MethodGet, "/v1/e/token/cat.jpg"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range requests {
|
for _, tc := range requests {
|
||||||
@@ -99,7 +95,7 @@ func TestImageRequestsServedWithoutMaintenanceMode(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
{http.MethodGet, unsignedImagePath, http.StatusUnauthorized},
|
||||||
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
{http.MethodHead, unsignedImagePath, http.StatusUnauthorized},
|
||||||
{http.MethodGet, encryptedImagePath, http.StatusBadRequest},
|
{http.MethodGet, "/v1/e/token/cat.jpg", http.StatusBadRequest},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range requests {
|
for _, tc := range requests {
|
||||||
|
|||||||
+15
-23
@@ -38,6 +38,7 @@ func (s *Server) SetupRoutes() {
|
|||||||
s.router.Use(s.mw.Metrics())
|
s.router.Use(s.mw.Metrics())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
|
s.router.Use(middleware.Timeout(s.config.DownstreamTimeout))
|
||||||
|
|
||||||
if s.sentryEnabled {
|
if s.sentryEnabled {
|
||||||
@@ -73,31 +74,22 @@ func (s *Server) SetupRoutes() {
|
|||||||
|
|
||||||
s.router.Get("/logout", s.h.HandleLogout())
|
s.router.Get("/logout", s.h.HandleLogout())
|
||||||
|
|
||||||
// Image routes, the only ones that send CORS headers, as pages on other
|
// Image routes, refused while maintenance mode is on. Only these: the
|
||||||
// sites read them. They are a subrouter rather than a group: a group's
|
// image's Docker HEALTHCHECK requests the health check, a 503 there
|
||||||
// middleware runs only for a request that matches one of its routes,
|
// would make the container unhealthy, and upaas marks a deploy failed
|
||||||
// and a browser's preflight OPTIONS request matches none, so the CORS
|
// when its container is unhealthy.
|
||||||
// middleware could not answer it.
|
s.router.Group(func(r chi.Router) {
|
||||||
s.router.Route("/v1", func(r chi.Router) {
|
r.Use(s.refuseDuringMaintenance)
|
||||||
r.Use(s.mw.CORS())
|
|
||||||
|
|
||||||
// Refused while maintenance mode is on. Only these: the image's
|
// Main image proxy route
|
||||||
// Docker HEALTHCHECK requests the health check, a 503 there would
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
// make the container unhealthy, and upaas marks a deploy failed
|
r.Get("/v1/image/*", s.h.HandleImage())
|
||||||
// when its container is unhealthy.
|
r.Head("/v1/image/*", s.h.HandleImage())
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(s.refuseDuringMaintenance)
|
|
||||||
|
|
||||||
// Main image proxy route
|
// Encrypted image URL route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
||||||
r.Get("/image/*", s.h.HandleImage())
|
// browsers with content type
|
||||||
r.Head("/image/*", s.h.HandleImage())
|
r.Get("/v1/e/{token}/*", s.h.HandleImageEnc())
|
||||||
|
|
||||||
// Encrypted image URL route
|
|
||||||
// The trailing filename (e.g., /img.jpg) is ignored but helps
|
|
||||||
// browsers with content type
|
|
||||||
r.Get("/e/{token}/*", s.h.HandleImageEnc())
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Metrics endpoint with auth
|
// Metrics endpoint with auth
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/config"
|
||||||
|
"sneak.berlin/go/pixa/internal/globals"
|
||||||
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shutdownRecorder is an fx.Shutdowner that sends the options of each
|
||||||
|
// shutdown request on requests.
|
||||||
|
type shutdownRecorder struct {
|
||||||
|
requests chan []fx.ShutdownOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r shutdownRecorder) Shutdown(opts ...fx.ShutdownOption) error {
|
||||||
|
r.requests <- opts
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSentryInitFailureFailsStartup checks that a Sentry DSN that cannot be
|
||||||
|
// used makes the server's start hook fail, so fx stops what has already
|
||||||
|
// started, instead of the process exiting from a goroutine.
|
||||||
|
func TestSentryInitFailureFailsStartup(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
|
||||||
|
log, err := logger.New(lc, logger.Params{Globals: &globals.Globals{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logger.New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = New(lc, Params{
|
||||||
|
Logger: log,
|
||||||
|
Globals: &globals.Globals{Appname: "pixad"},
|
||||||
|
Config: &config.Config{SentryDSN: "not-a-dsn"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = lc.Start(t.Context())
|
||||||
|
t.Logf("Start() error = %v", err)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Start() error = nil, want the Sentry initialization error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListenErrorRequestsShutdownWithExitCode1 occupies the server's port
|
||||||
|
// and checks that the listen error asks fx to shut down with exit code 1.
|
||||||
|
func TestListenErrorRequestsShutdownWithExitCode1(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
busy, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", ":0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Listen() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = busy.Close() })
|
||||||
|
|
||||||
|
addr, ok := busy.Addr().(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("listener address %v is not a TCP address", busy.Addr())
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make(chan []fx.ShutdownOption, 1)
|
||||||
|
s := &Server{
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
config: &config.Config{Port: addr.Port},
|
||||||
|
shutdowner: shutdownRecorder{requests: requests},
|
||||||
|
}
|
||||||
|
s.httpServer = s.newHTTPServer()
|
||||||
|
|
||||||
|
go s.serveUntilShutdown()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case opts := <-requests:
|
||||||
|
t.Logf("shutdown options = %v", opts)
|
||||||
|
|
||||||
|
if len(opts) != 1 || opts[0] != fx.ExitCode(1) {
|
||||||
|
t.Errorf("shutdown options = %v, want [fx.ExitCode(1)]", opts)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("no shutdown was requested after the listen error")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user