From dbfd191bc507f15d4214a01eec9b0d97b71017b2 Mon Sep 17 00:00:00 2001 From: Ludovic Vielle Date: Fri, 12 Jun 2026 12:14:40 +0200 Subject: [PATCH] Add cache control to Files API static assets Brand assets served at /api/files/v1/static had no cache headers. Introduce brand.Assets to own the embedded filesystem, content-hash ETags, and HTTP serving. Responses now carry Cache-Control and ETag so clients can cache and revalidate; stable email URLs stay revalidatable (max-age=3600, no immutable). Replace hardcoded Default*Path constants with StaticPathPrefix, logical filename constants, and StaticPath(). NewAssets validates required assets at startup so a rename fails fast instead of 404ing in sent emails. The files handler keeps routing and 404 rendering; ServeAssets sets cache headers and serves the file. Signed-off-by: Ludovic Vielle --- packages/emails/emails.go | 4 +- pkg/brand/brand.go | 26 ++--- pkg/brand/http.go | 103 ++++++++++++++++++ .../api/cookiebanner/v1/cors_middleware.go | 12 +- pkg/server/api/cookiebanner/v1/handler.go | 58 +++++----- pkg/server/api/files/v1/handler.go | 32 +++--- pkg/server/api/files/v1/handler_test.go | 39 +++++++ pkg/server/{jsonutil => jsonx}/errors.go | 2 +- 8 files changed, 208 insertions(+), 68 deletions(-) create mode 100644 pkg/brand/http.go rename pkg/server/{jsonutil => jsonx}/errors.go (98%) diff --git a/packages/emails/emails.go b/packages/emails/emails.go index 378b45c11..a493c492c 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -66,10 +66,10 @@ func DefaultPresenterConfig(baseURL string) PresenterConfig { return PresenterConfig{ APIBaseURL: baseURL, // always API base URL BaseURL: baseURL, // can change to custom domain when needed - PoweredByLogoPath: brand.DefaultPoweredByLogoPath, + PoweredByLogoPath: brand.StaticPath(brand.PoweredByLogo), SenderCompanyName: "Probo", SenderCompanyWebsiteURL: "https://www.probo.com", - SenderCompanyLogoPath: brand.DefaultSenderCompanyLogoPath, + SenderCompanyLogoPath: brand.StaticPath(brand.SenderCompanyLogo), SenderCompanyHeadquarterAddress: "Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US", } } diff --git a/pkg/brand/brand.go b/pkg/brand/brand.go index 14c680209..8116e1a85 100644 --- a/pkg/brand/brand.go +++ b/pkg/brand/brand.go @@ -2,24 +2,18 @@ package brand import ( "embed" - "io/fs" ) -var ( - //go:embed assets - staticAssets embed.FS +//go:embed assets +var staticAssets embed.FS - Assets fs.FS - - DefaultPoweredByLogoPath string = "/api/files/v1/static/probo-gray-small.png" - DefaultSenderCompanyLogoPath string = "/api/files/v1/static/probo.png" +// Logical brand assets. These filenames must exist under assets/; NewAssets +// verifies their presence at startup so a rename or removal fails fast instead +// of silently producing a 404 in the consumers (e.g. emails). +const ( + PoweredByLogo = "probo-gray-small.png" + SenderCompanyLogo = "probo.png" ) -func init() { - var err error - - Assets, err = fs.Sub(staticAssets, "assets") - if err != nil { - panic(err) - } -} +// requiredAssets are validated to exist whenever an Assets is constructed. +var requiredAssets = []string{PoweredByLogo, SenderCompanyLogo} diff --git a/pkg/brand/http.go b/pkg/brand/http.go new file mode 100644 index 000000000..b800252fc --- /dev/null +++ b/pkg/brand/http.go @@ -0,0 +1,103 @@ +package brand + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io/fs" + "net/http" + "path" +) + +// StaticPathPrefix is the single source of truth for the public URL path under +// which brand assets are served. It must stay in sync with the route mounted by +// the files API handler (/api -> /files/v1 -> /static/{file}). +const StaticPathPrefix = "/api/files/v1/static" + +// StaticPath returns the public URL path that serves the named brand asset. +func StaticPath(name string) string { + return path.Join(StaticPathPrefix, name) +} + +// Assets provides access to the embedded brand assets together with their +// precomputed content-hash ETags. +type Assets struct { + fs fs.FS + etags map[string]string +} + +// NewAssets builds an Assets from the embedded brand files. It panics on error +// because the assets are embedded at build time and a failure here is a +// programming error, not a runtime condition. +func NewAssets() *Assets { + assetsFS, err := fs.Sub(staticAssets, "assets") + if err != nil { + panic(fmt.Errorf("cannot open brand assets: %w", err)) + } + + for _, name := range requiredAssets { + if _, err := fs.Stat(assetsFS, name); err != nil { + panic(fmt.Errorf("missing required brand asset %q: %w", name, err)) + } + } + + return &Assets{ + fs: assetsFS, + etags: mustComputeAssetETags(assetsFS), + } +} + +// Stat returns file information for the named asset. +func (a *Assets) Stat(name string) (fs.FileInfo, error) { + return fs.Stat(a.fs, name) +} + +// ServeAssets writes the named asset to the response. It delegates to +// http.ServeFileFS, which sets Content-Type, handles range requests, and honors +// a pre-set ETag response header for If-None-Match (304 Not Modified). +func (a *Assets) ServeAssets(w http.ResponseWriter, r *http.Request, name string) { + w.Header().Set("Cache-Control", "public, max-age=3600") + + if etag, ok := a.etags[name]; ok { + w.Header().Set("ETag", `"`+etag+`"`) + } + + http.ServeFileFS(w, r, a.fs, name) +} + +// mustComputeAssetETags precomputes content-hash ETags for every embedded +// brand asset, mirroring the MD5 walk used by the SPA static handler. It panics +// on error because the assets are embedded at build time and a failure here is +// a programming error, not a runtime condition. +func mustComputeAssetETags(assets fs.FS) map[string]string { + etags := make(map[string]string) + + err := fs.WalkDir( + assets, + ".", + func(name string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return nil + } + + content, err := fs.ReadFile(assets, name) + if err != nil { + return fmt.Errorf("cannot read brand asset %q: %w", name, err) + } + + hash := md5.Sum(content) + etags[name] = hex.EncodeToString(hash[:]) + + return nil + }, + ) + if err != nil { + panic(fmt.Errorf("cannot compute brand asset etags: %w", err)) + } + + return etags +} diff --git a/pkg/server/api/cookiebanner/v1/cors_middleware.go b/pkg/server/api/cookiebanner/v1/cors_middleware.go index eb5d31268..ef21de2e0 100644 --- a/pkg/server/api/cookiebanner/v1/cors_middleware.go +++ b/pkg/server/api/cookiebanner/v1/cors_middleware.go @@ -22,7 +22,7 @@ import ( "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/cookiebanner" "go.probo.inc/probo/pkg/gid" - "go.probo.inc/probo/pkg/server/jsonutil" + "go.probo.inc/probo/pkg/server/jsonx" ) func newCORSMiddleware(logger *log.Logger, cookieBannerSvc *cookiebanner.Service) func(http.Handler) http.Handler { @@ -37,32 +37,32 @@ func newCORSMiddleware(logger *log.Logger, cookieBannerSvc *cookiebanner.Service bannerIDStr := chi.URLParam(r, "bannerID") if bannerIDStr == "" { - jsonutil.RenderForbidden(w) + jsonx.RenderForbidden(w) return } bannerID, err := gid.ParseGID(bannerIDStr) if err != nil { - jsonutil.RenderForbidden(w) + jsonx.RenderForbidden(w) return } banner, err := cookieBannerSvc.GetActiveCookieBanner(r.Context(), bannerID) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - jsonutil.RenderForbidden(w) + jsonx.RenderForbidden(w) return } logger.ErrorCtx(r.Context(), "cannot load cookie banner for CORS check", log.Error(err)) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } canonicalOrigin := cookiebanner.CanonicalizeOrigin(origin) if banner.Origin != canonicalOrigin { - jsonutil.RenderForbidden(w) + jsonx.RenderForbidden(w) return } diff --git a/pkg/server/api/cookiebanner/v1/handler.go b/pkg/server/api/cookiebanner/v1/handler.go index c051f3b8c..408b9b707 100644 --- a/pkg/server/api/cookiebanner/v1/handler.go +++ b/pkg/server/api/cookiebanner/v1/handler.go @@ -31,7 +31,7 @@ import ( "go.probo.inc/probo/pkg/geoloc" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/server/api/clientip" - "go.probo.inc/probo/pkg/server/jsonutil" + "go.probo.inc/probo/pkg/server/jsonx" "go.probo.inc/probo/pkg/uri" ) @@ -69,7 +69,7 @@ func NewMux( func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } @@ -81,17 +81,17 @@ func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { config, err := h.cookieBannerSvc.GetActiveBannerConfig(r.Context(), bannerID, lang, regulation, sdkVersion) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) + jsonx.RenderNotFound(w, fmt.Errorf("banner not found")) return } if errors.Is(err, cookiebanner.ErrNoPublishedVersion) { - jsonutil.RenderNotFound(w, fmt.Errorf("no published version")) + jsonx.RenderNotFound(w, fmt.Errorf("no published version")) return } h.logger.ErrorCtx(r.Context(), "cannot get banner config", log.Error(err), log.String("sdk_version", sdkVersion)) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } @@ -124,25 +124,25 @@ func (h *Handler) resolveCountryCode(r *http.Request) *coredata.CountryCode { func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } visitorID := chi.URLParam(r, "visitorID") if visitorID == "" { - jsonutil.RenderBadRequest(w, fmt.Errorf("missing visitor id")) + jsonx.RenderBadRequest(w, fmt.Errorf("missing visitor id")) return } consent, err := h.cookieBannerSvc.GetVisitorConsent(r.Context(), bannerID, visitorID) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) + jsonx.RenderNotFound(w, fmt.Errorf("banner not found")) return } if errors.Is(err, cookiebanner.ErrConsentNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("consent not found")) + jsonx.RenderNotFound(w, fmt.Errorf("consent not found")) return } @@ -152,7 +152,7 @@ func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) { log.Error(err), log.String("sdk_version", sdkVersionFromContext(r.Context())), ) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } @@ -179,13 +179,13 @@ type ( func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } var body postConsentBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body")) return } @@ -214,17 +214,17 @@ func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) { record, err := h.cookieBannerSvc.RecordConsent(r.Context(), bannerID, req) if err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) + jsonx.RenderNotFound(w, fmt.Errorf("banner not found")) return } if errors.Is(err, cookiebanner.ErrVersionNotFound) || errors.Is(err, cookiebanner.ErrVersionNotPublished) { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid version")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid version")) return } h.logger.ErrorCtx(r.Context(), "cannot record consent", log.Error(err), log.String("sdk_version", sdkVersion)) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } @@ -290,23 +290,23 @@ func sanitizeInitiatorURL(raw *string) *string { func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } var body reportDetectedCookiesBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body")) return } if len(body.Cookies) == 0 { - jsonutil.RenderBadRequest(w, fmt.Errorf("cookies list is empty")) + jsonx.RenderBadRequest(w, fmt.Errorf("cookies list is empty")) return } if len(body.Cookies) > maxDetectedCookiesPerRequest { - jsonutil.RenderBadRequest(w, fmt.Errorf("too many cookies, maximum is %d", maxDetectedCookiesPerRequest)) + jsonx.RenderBadRequest(w, fmt.Errorf("too many cookies, maximum is %d", maxDetectedCookiesPerRequest)) return } @@ -340,7 +340,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req } if len(detected) == 0 { - jsonutil.RenderBadRequest(w, fmt.Errorf("no valid cookie names provided")) + jsonx.RenderBadRequest(w, fmt.Errorf("no valid cookie names provided")) return } @@ -350,7 +350,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req if err := h.cookieBannerSvc.ReportDetectedCookies(r.Context(), bannerID, req); err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) + jsonx.RenderNotFound(w, fmt.Errorf("banner not found")) return } @@ -360,7 +360,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req log.Error(err), log.String("sdk_version", sdkVersionFromContext(r.Context())), ) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } @@ -392,24 +392,24 @@ const maxDetectedTrackersPerRequest = 100 func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Request) { bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID")) if err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id")) return } var body reportDetectedTrackersBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - jsonutil.RenderBadRequest(w, fmt.Errorf("invalid request body")) + jsonx.RenderBadRequest(w, fmt.Errorf("invalid request body")) return } total := len(body.Cookies) + len(body.Storage) + len(body.Resources) if total == 0 { - jsonutil.RenderBadRequest(w, fmt.Errorf("no items provided")) + jsonx.RenderBadRequest(w, fmt.Errorf("no items provided")) return } if total > maxDetectedTrackersPerRequest { - jsonutil.RenderBadRequest(w, fmt.Errorf("too many items, maximum is %d", maxDetectedTrackersPerRequest)) + jsonx.RenderBadRequest(w, fmt.Errorf("too many items, maximum is %d", maxDetectedTrackersPerRequest)) return } @@ -533,18 +533,18 @@ func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Re } if len(req.Cookies)+len(req.Storage)+len(req.Resources) == 0 { - jsonutil.RenderBadRequest(w, fmt.Errorf("no valid items provided")) + jsonx.RenderBadRequest(w, fmt.Errorf("no valid items provided")) return } if err := h.cookieBannerSvc.ReportDetectedTrackers(r.Context(), bannerID, req); err != nil { if errors.Is(err, cookiebanner.ErrBannerNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("banner not found")) + jsonx.RenderNotFound(w, fmt.Errorf("banner not found")) return } h.logger.ErrorCtx(r.Context(), "cannot report detected trackers", log.Error(err), log.String("sdk_version", sdkVersionFromContext(r.Context()))) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } diff --git a/pkg/server/api/files/v1/handler.go b/pkg/server/api/files/v1/handler.go index 6adcb280f..0a70292be 100644 --- a/pkg/server/api/files/v1/handler.go +++ b/pkg/server/api/files/v1/handler.go @@ -17,7 +17,6 @@ package files_v1 import ( "errors" "fmt" - "io/fs" "net/http" "time" @@ -31,7 +30,7 @@ import ( "go.probo.inc/probo/pkg/probo" "go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/server/api/authn" - "go.probo.inc/probo/pkg/server/jsonutil" + "go.probo.inc/probo/pkg/server/jsonx" ) const presignedURLExpiry = 1 * time.Hour @@ -41,6 +40,7 @@ type Handler struct { fileSvc *filemanager.Service probo *probo.Service iamSvc *iam.Service + assets *brand.Assets } func NewMux( @@ -56,6 +56,7 @@ func NewMux( fileSvc: fileSvc, probo: proboSvc, iamSvc: iamSvc, + assets: brand.NewAssets(), } r := chi.NewRouter() @@ -77,12 +78,15 @@ func NewMux( func (h *Handler) handleGetStaticFile(w http.ResponseWriter, r *http.Request) { file := chi.URLParam(r, "file") - if _, statErr := fs.Stat(brand.Assets, file); statErr == nil { - http.ServeFileFS(w, r, brand.Assets, file) + if _, statErr := h.assets.Stat(file); statErr != nil { + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) return } - jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + // ServeAssets honors the ETag header we set above for If-None-Match (and + // If-Range), so it emits 304 Not Modified and handles range requests without + // any extra conditional logic here. + h.assets.ServeAssets(w, r, file) } func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { @@ -90,14 +94,14 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { fileID, err := gid.ParseGID(fileIDStr) if err != nil { - jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) return } file, err := h.fileSvc.GetPublicFile(r.Context(), fileID) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) return } @@ -107,7 +111,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { log.Error(err), log.String("file_id", fileIDStr), ) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } @@ -120,7 +124,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) { log.Error(err), log.String("file_id", fileIDStr), ) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } @@ -133,7 +137,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) { fileID, err := gid.ParseGID(fileIDStr) if err != nil { - jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) return } @@ -153,19 +157,19 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) { scope, err := h.iamSvc.Authorizer.Authorize(ctx, params) if err != nil { - jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) return } f, err := h.probo.Files.Get(ctx, scope, fileID) if err != nil { if errors.Is(err, coredata.ErrResourceNotFound) { - jsonutil.RenderNotFound(w, fmt.Errorf("file not found")) + jsonx.RenderNotFound(w, fmt.Errorf("file not found")) return } h.logger.ErrorCtx(ctx, "cannot get file", log.Error(err), log.String("file_id", fileIDStr)) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } @@ -173,7 +177,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) { presignedURL, err := h.fileSvc.GeneratePresignedURL(ctx, f, presignedURLExpiry) if err != nil { h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr)) - jsonutil.RenderInternalServerError(w) + jsonx.RenderInternalServerError(w) return } diff --git a/pkg/server/api/files/v1/handler_test.go b/pkg/server/api/files/v1/handler_test.go index afe343581..6d7a2c634 100644 --- a/pkg/server/api/files/v1/handler_test.go +++ b/pkg/server/api/files/v1/handler_test.go @@ -19,10 +19,12 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.gearno.de/kit/log" "go.probo.inc/probo/pkg/securecookie" ) @@ -67,6 +69,43 @@ func TestHandleGetFile_InvalidGID(t *testing.T) { assert.Equal(t, http.StatusNotFound, rec.Code) } +func TestHandleGetStaticFile(t *testing.T) { + t.Parallel() + + mux := NewMux( + log.NewLogger(log.WithOutput(io.Discard)), + nil, + nil, + nil, + securecookie.Config{}, + "test-secret", + ) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/static/probo.png", nil) + mux.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Contains(t, rec.Header().Get("Cache-Control"), "max-age=3600") + + etag := rec.Header().Get("ETag") + require.NotEmpty(t, etag) + require.True(t, strings.HasPrefix(etag, `"`) && strings.HasSuffix(etag, `"`)) + + recNotModified := httptest.NewRecorder() + reqNotModified := httptest.NewRequest(http.MethodGet, "/static/probo.png", nil) + reqNotModified.Header.Set("If-None-Match", etag) + mux.ServeHTTP(recNotModified, reqNotModified) + + require.Equal(t, http.StatusNotModified, recNotModified.Code) + + recMissing := httptest.NewRecorder() + reqMissing := httptest.NewRequest(http.MethodGet, "/static/does-not-exist.png", nil) + mux.ServeHTTP(recMissing, reqMissing) + + require.Equal(t, http.StatusNotFound, recMissing.Code) +} + func TestHandleGetFile_UnauthenticatedReturns401(t *testing.T) { t.Parallel() diff --git a/pkg/server/jsonutil/errors.go b/pkg/server/jsonx/errors.go similarity index 98% rename from pkg/server/jsonutil/errors.go rename to pkg/server/jsonx/errors.go index 5eff51835..bc1cd6f6f 100644 --- a/pkg/server/jsonutil/errors.go +++ b/pkg/server/jsonx/errors.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package jsonutil +package jsonx import ( "fmt"