2026-02-20 23:10:52 +00:00
|
|
|
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
|
|
|
|
|
|
package api_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
|
|
|
|
api "forge.lthn.ai/core/go-api"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ── WithSecure ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestWithSecure_Good_SetsHSTSHeader(t *testing.T) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(api.WithSecure())
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sts := w.Header().Get("Strict-Transport-Security")
|
|
|
|
|
if sts == "" {
|
|
|
|
|
t.Fatal("expected Strict-Transport-Security header to be set")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(sts, "max-age=31536000") {
|
|
|
|
|
t.Fatalf("expected max-age=31536000 in STS header, got %q", sts)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(strings.ToLower(sts), "includesubdomains") {
|
|
|
|
|
t.Fatalf("expected includeSubdomains in STS header, got %q", sts)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWithSecure_Good_SetsFrameOptionsDeny(t *testing.T) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(api.WithSecure())
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
xfo := w.Header().Get("X-Frame-Options")
|
|
|
|
|
if xfo != "DENY" {
|
|
|
|
|
t.Fatalf("expected X-Frame-Options=%q, got %q", "DENY", xfo)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWithSecure_Good_SetsContentTypeNosniff(t *testing.T) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(api.WithSecure())
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
cto := w.Header().Get("X-Content-Type-Options")
|
|
|
|
|
if cto != "nosniff" {
|
|
|
|
|
t.Fatalf("expected X-Content-Type-Options=%q, got %q", "nosniff", cto)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWithSecure_Good_SetsReferrerPolicy(t *testing.T) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(api.WithSecure())
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
rp := w.Header().Get("Referrer-Policy")
|
|
|
|
|
if rp != "strict-origin-when-cross-origin" {
|
|
|
|
|
t.Fatalf("expected Referrer-Policy=%q, got %q", "strict-origin-when-cross-origin", rp)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWithSecure_Good_AllHeadersPresent(t *testing.T) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(api.WithSecure())
|
|
|
|
|
e.Register(&stubGroup{})
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify all security headers are present on a regular route.
|
|
|
|
|
checks := map[string]string{
|
2026-02-23 05:47:06 +00:00
|
|
|
"X-Frame-Options": "DENY",
|
|
|
|
|
"X-Content-Type-Options": "nosniff",
|
|
|
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
2026-02-20 23:10:52 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for header, want := range checks {
|
|
|
|
|
got := w.Header().Get(header)
|
|
|
|
|
if got != want {
|
|
|
|
|
t.Errorf("header %s: expected %q, got %q", header, want, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sts := w.Header().Get("Strict-Transport-Security")
|
|
|
|
|
if sts == "" {
|
|
|
|
|
t.Error("expected Strict-Transport-Security header to be set")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWithSecure_Good_CombinesWithOtherMiddleware(t *testing.T) {
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(
|
|
|
|
|
api.WithSecure(),
|
|
|
|
|
api.WithRequestID(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Both secure headers and request ID should be present.
|
|
|
|
|
if w.Header().Get("X-Frame-Options") != "DENY" {
|
|
|
|
|
t.Fatal("expected X-Frame-Options header from WithSecure")
|
|
|
|
|
}
|
|
|
|
|
if w.Header().Get("X-Request-ID") == "" {
|
|
|
|
|
t.Fatal("expected X-Request-ID header from WithRequestID")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 23:15:29 +00:00
|
|
|
func TestWithSecure_Bad_NoSSLRedirect(t *testing.T) {
|
|
|
|
|
// SSL redirect is not enabled — the middleware runs behind a TLS-terminating
|
|
|
|
|
// reverse proxy. Verify plain HTTP requests are not redirected.
|
2026-02-20 23:10:52 +00:00
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(api.WithSecure())
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
// Should get 200, not a 301/302 redirect.
|
|
|
|
|
if w.Code != http.StatusOK {
|
2026-02-20 23:15:29 +00:00
|
|
|
t.Fatalf("expected 200 (no SSL redirect), got %d", w.Code)
|
2026-02-20 23:10:52 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWithSecure_Ugly_DoubleSecureDoesNotPanic(t *testing.T) {
|
|
|
|
|
// Applying WithSecure twice should not panic or cause issues.
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
e, _ := api.New(
|
|
|
|
|
api.WithSecure(),
|
|
|
|
|
api.WithSecure(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
h := e.Handler()
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, "/health", nil)
|
|
|
|
|
h.ServeHTTP(w, req)
|
|
|
|
|
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("expected 200, got %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Headers should still be correctly set.
|
|
|
|
|
if w.Header().Get("X-Frame-Options") != "DENY" {
|
|
|
|
|
t.Fatal("expected X-Frame-Options=DENY after double WithSecure")
|
|
|
|
|
}
|
|
|
|
|
}
|