test: add unit tests for authentication and CORS middleware

Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
Noooste
2026-04-19 15:41:17 +02:00
parent 68be5ea2be
commit 9022b90f02
4 changed files with 1498 additions and 0 deletions
+403
View File
@@ -0,0 +1,403 @@
package middleware
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
logpkg "Noooste/garage-ui/pkg/logger"
"github.com/gofiber/fiber/v3"
"github.com/rs/zerolog"
)
// newAuthTestApp builds a fiber.App with RequestID + Logging (buffer-backed) +
// AuthMiddleware + a trivial /protected handler that echoes username/auth
// method into the JSON body so tests can assert on locals.
func newAuthTestApp(t *testing.T, buf *bytes.Buffer, authCfg *config.AuthConfig, svc *auth.Service) *fiber.App {
t.Helper()
base := zerolog.New(buf)
app := fiber.New()
app.Use(RequestID())
app.Use(Logging(base))
app.Use(AuthMiddleware(authCfg, svc))
app.Get("/protected", func(c fiber.Ctx) error {
uname, _ := c.Locals("username").(string)
email, _ := c.Locals("email").(string)
logpkg.FromCtx(c.Context()).Info().Msg("in_handler")
return c.JSON(fiber.Map{
"ok": true,
"username": uname,
"email": email,
})
})
return app
}
// newAuthSvc returns an *auth.Service with the given auth config, JWT
// service initialized, OIDC disabled unless the caller wires it.
func newAuthSvc(t *testing.T, authCfg *config.AuthConfig) *auth.Service {
t.Helper()
svc, err := auth.NewAuthService(authCfg, &config.ServerConfig{})
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
return svc
}
// findLine returns the first parsed log line whose "message" field equals msg,
// failing the test if no such line is present.
func findLine(t *testing.T, buf *bytes.Buffer, msg string) map[string]any {
t.Helper()
for _, line := range parseLines(t, buf) {
if line["message"] == msg {
return line
}
}
t.Fatalf("no %q log line: %s", msg, buf.String())
return nil
}
func TestAuthMiddleware_BothDisabled_AllowsRequest(t *testing.T) {
authCfg := &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: false},
OIDC: config.OIDCConfig{Enabled: false},
}
svc := newAuthSvc(t, authCfg)
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, authCfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body["ok"] != true {
t.Errorf("ok = %v, want true", body["ok"])
}
if body["username"] != "" {
t.Errorf("username should be empty when auth disabled, got %q", body["username"])
}
}
func newAdminCfg() *config.AuthConfig {
return &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
OIDC: config.OIDCConfig{Enabled: false},
}
}
func TestAuthMiddleware_Admin_BearerValid_AllowsAndEnrichesLogger(t *testing.T) {
authCfg := newAdminCfg()
svc := newAuthSvc(t, authCfg)
tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin", Email: "a@b"})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, authCfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body map[string]any
_ = json.NewDecoder(resp.Body).Decode(&body)
if body["username"] != "admin" {
t.Errorf("username = %v, want admin", body["username"])
}
if body["email"] != "a@b" {
t.Errorf("email = %v, want a@b", body["email"])
}
// Enriched handler log line should carry user_id and auth_method=admin.
access := findLine(t, &buf, "in_handler")
if access["user_id"] != "admin" {
t.Errorf("user_id = %v, want admin", access["user_id"])
}
if access["auth_method"] != "admin" {
t.Errorf("auth_method = %v, want admin", access["auth_method"])
}
}
func TestAuthMiddleware_Admin_BearerInvalid_Returns401(t *testing.T) {
authCfg := newAdminCfg()
svc := newAuthSvc(t, authCfg)
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, authCfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer not-a-real-token")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
var env struct {
Success bool `json:"success"`
Error struct {
Code string `json:"code"`
} `json:"error"`
}
_ = json.NewDecoder(resp.Body).Decode(&env)
if env.Success {
t.Error("success should be false")
}
if env.Error.Code != "UNAUTHORIZED" {
t.Errorf("error.code = %q, want UNAUTHORIZED", env.Error.Code)
}
// Warn log should carry reason=no_valid_credentials without leaking the token.
warn := findLine(t, &buf, "authentication_failed")
if warn["reason"] != "no_valid_credentials" {
t.Errorf("reason = %v", warn["reason"])
}
if warn["level"] != "warn" {
t.Errorf("level = %v, want warn", warn["level"])
}
if strings.Contains(buf.String(), "not-a-real-token") {
t.Error("token value must not appear in logs")
}
}
func TestAuthMiddleware_Admin_NoAuthHeader_Returns401(t *testing.T) {
authCfg := newAdminCfg()
svc := newAuthSvc(t, authCfg)
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, authCfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestAuthMiddleware_Admin_NonBearerScheme_Returns401(t *testing.T) {
authCfg := newAdminCfg()
svc := newAuthSvc(t, authCfg)
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, authCfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Basic dXNlcjpwdw==")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func newOIDCCfg(cookieName string) *config.AuthConfig {
return &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: false},
OIDC: config.OIDCConfig{
Enabled: true,
CookieName: cookieName,
},
}
}
// newOIDCSvc returns a Service whose OIDC is *not* initialized (no dialing
// needed), which is fine because AuthMiddleware's OIDC branch only calls
// ValidateSessionToken — a pure JWT operation.
func newOIDCSvc(t *testing.T) *auth.Service {
t.Helper()
return newAuthSvc(t, &config.AuthConfig{OIDC: config.OIDCConfig{Enabled: false}})
}
func TestAuthMiddleware_OIDC_ValidCookie_Allows(t *testing.T) {
cfg := newOIDCCfg("session")
svc := newOIDCSvc(t)
tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice", Email: "a@x"})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, cfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: tok})
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
access := findLine(t, &buf, "in_handler")
if access["auth_method"] != "oidc" {
t.Errorf("auth_method = %v, want oidc", access["auth_method"])
}
if access["user_id"] != "alice" {
t.Errorf("user_id = %v, want alice", access["user_id"])
}
}
func TestAuthMiddleware_OIDC_InvalidCookie_Returns401(t *testing.T) {
cfg := newOIDCCfg("session")
svc := newOIDCSvc(t)
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, cfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "garbage"})
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func TestAuthMiddleware_OIDC_NoCookie_Returns401(t *testing.T) {
cfg := newOIDCCfg("session")
svc := newOIDCSvc(t)
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, cfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
func newBothCfg(cookieName string) *config.AuthConfig {
return &config.AuthConfig{
Admin: config.AdminAuthConfig{Enabled: true, Username: "admin", Password: "pw"},
OIDC: config.OIDCConfig{
Enabled: true,
CookieName: cookieName,
},
}
}
func TestAuthMiddleware_Both_BearerValid_AdminPathWins(t *testing.T) {
cfg := newBothCfg("session")
// OIDC disabled on the Service is fine — see Task 3 rationale.
svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin})
tok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "admin"})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, cfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+tok)
// Also set a valid OIDC cookie; admin should still win.
cookieTok, _ := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"})
req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok})
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
access := findLine(t, &buf, "in_handler")
if access["auth_method"] != "admin" {
t.Errorf("auth_method = %v, want admin", access["auth_method"])
}
if access["user_id"] != "admin" {
t.Errorf("user_id = %v, want admin", access["user_id"])
}
}
func TestAuthMiddleware_Both_BearerInvalid_FallsThroughToOIDCCookie(t *testing.T) {
cfg := newBothCfg("session")
svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin})
cookieTok, err := svc.GenerateSessionToken(&auth.UserInfo{Username: "alice"})
if err != nil {
t.Fatalf("GenerateSessionToken: %v", err)
}
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, cfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer bogus")
req.AddCookie(&http.Cookie{Name: "session", Value: cookieTok})
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200 (OIDC fallback)", resp.StatusCode)
}
access := findLine(t, &buf, "in_handler")
if access["auth_method"] != "oidc" {
t.Errorf("auth_method = %v, want oidc", access["auth_method"])
}
}
func TestAuthMiddleware_Both_AllInvalid_Returns401WithCombinedMethodLabel(t *testing.T) {
cfg := newBothCfg("session")
svc := newAuthSvc(t, &config.AuthConfig{Admin: cfg.Admin})
var buf bytes.Buffer
app := newAuthTestApp(t, &buf, cfg, svc)
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer bogus")
req.AddCookie(&http.Cookie{Name: "session", Value: "also-bogus"})
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 401 {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
warn := findLine(t, &buf, "authentication_failed")
if warn["auth_method"] != "admin+oidc" {
t.Errorf("auth_method = %v, want admin+oidc", warn["auth_method"])
}
}
+282
View File
@@ -0,0 +1,282 @@
package middleware
import (
"net/http/httptest"
"testing"
"Noooste/garage-ui/internal/config"
"github.com/gofiber/fiber/v3"
)
func newCORSApp(t *testing.T, cfg *config.CORSConfig) *fiber.App {
t.Helper()
app := fiber.New()
app.Use(CORSMiddleware(cfg))
app.Get("/x", func(c fiber.Ctx) error {
return c.SendString("ok")
})
return app
}
func TestCORS_Disabled_NoHeadersSet(t *testing.T) {
cfg := &config.CORSConfig{Enabled: false}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://foo.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want empty", got)
}
}
func TestCORS_Enabled_AllowedOriginEchoes(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ok.example"},
AllowedMethods: []string{"GET", "POST"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
MaxAge: 300,
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://ok.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" {
t.Errorf("Allow-Origin = %q", got)
}
if got := resp.Header.Get("Vary"); got != "Origin" {
t.Errorf("Vary = %q, want Origin", got)
}
}
func TestCORS_Enabled_OriginNotInList_NoHeaders(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ok.example"},
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://evil.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want empty", got)
}
}
func TestCORS_Enabled_NoOriginHeader_NoCORSHeaders(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"*"},
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want empty (no Origin header)", got)
}
}
func TestCORS_Wildcard_NoCredentials_AllowsAnyOrigin(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"*"},
AllowCredentials: false,
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://anywhere.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://anywhere.example" {
t.Errorf("Allow-Origin = %q, want echo", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("Allow-Credentials set unexpectedly: %q", got)
}
}
func TestCORS_Wildcard_WithCredentials_RejectsUnlistedOrigin(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"*"},
AllowCredentials: true,
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://evil.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want empty (wildcard+creds must not honor *)", got)
}
}
func TestCORS_ExactMatch_WithCredentials_SetsAllowCredentials(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ok.example"},
AllowCredentials: true,
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://ok.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ok.example" {
t.Errorf("Allow-Origin = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("Allow-Credentials = %q, want true", got)
}
}
func TestCORS_Preflight_AllowedOrigin_Returns204WithHeaders(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ok.example"},
AllowedMethods: []string{"GET", "POST", "PUT"},
AllowedHeaders: []string{"Authorization"},
MaxAge: 600,
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("OPTIONS", "/x", nil)
req.Header.Set("Origin", "https://ok.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 204 {
t.Fatalf("status = %d, want 204", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "GET, POST, PUT" {
t.Errorf("Allow-Methods = %q", got)
}
if got := resp.Header.Get("Access-Control-Allow-Headers"); got != "Authorization" {
t.Errorf("Allow-Headers = %q", got)
}
if got := resp.Header.Get("Access-Control-Max-Age"); got != "600" {
t.Errorf("Max-Age = %q", got)
}
}
func TestCORS_Preflight_DisallowedOrigin_Returns204NoHeaders(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ok.example"},
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("OPTIONS", "/x", nil)
req.Header.Set("Origin", "https://evil.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 204 {
t.Fatalf("status = %d, want 204", resp.StatusCode)
}
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin set for disallowed preflight: %q", got)
}
}
func TestCORS_EmptyAllowedMethods_NoAllowMethodsHeader(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ok.example"},
// AllowedMethods intentionally nil
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://ok.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if got := resp.Header.Get("Access-Control-Allow-Methods"); got != "" {
t.Errorf("Allow-Methods set when list empty: %q", got)
}
}
func TestCORS_MaxAgeZero_NoMaxAgeHeader(t *testing.T) {
cfg := &config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ok.example"},
MaxAge: 0,
}
app := newCORSApp(t, cfg)
req := httptest.NewRequest("GET", "/x", nil)
req.Header.Set("Origin", "https://ok.example")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if got := resp.Header.Get("Access-Control-Max-Age"); got != "" {
t.Errorf("Max-Age set when zero: %q", got)
}
}
func TestIsAllowedOrigin(t *testing.T) {
cases := []struct {
name string
origin string
allowed []string
allowCredentials bool
want bool
}{
{"exact match", "https://ok.example", []string{"https://ok.example"}, false, true},
{"exact match with creds", "https://ok.example", []string{"https://ok.example"}, true, true},
{"wildcard without creds matches", "https://any.example", []string{"*"}, false, true},
{"wildcard with creds rejected", "https://any.example", []string{"*"}, true, false},
{"no match", "https://evil.example", []string{"https://ok.example"}, false, false},
{"empty allowed list", "https://ok.example", nil, false, false},
{"multiple entries — exact hit", "https://b.example", []string{"https://a.example", "https://b.example"}, false, true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if got := isAllowedOrigin(tc.origin, tc.allowed, tc.allowCredentials); got != tc.want {
t.Errorf("isAllowedOrigin(%q, %v, %v) = %v, want %v",
tc.origin, tc.allowed, tc.allowCredentials, got, tc.want)
}
})
}
}
@@ -0,0 +1,209 @@
package routes
import (
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
// testIssuer is a test-only fake OIDC provider. Fields with Fn suffix are
// per-request hooks a test can swap; defaults implement a happy path.
type testIssuer struct {
Server *httptest.Server
ClientID string
Key *rsa.PrivateKey
KeyID string
mu sync.Mutex
// TokenEndpointFn, if set, overrides the default /token handler.
TokenEndpointFn func(w http.ResponseWriter, r *http.Request)
// UserInfoFn, if set, overrides the default /userinfo handler.
UserInfoFn func(w http.ResponseWriter, r *http.Request)
// Default token response state — used by the default TokenEndpointFn.
// Tests mutate these between requests rather than overriding the handler.
DefaultAccessClaims map[string]any
DefaultIDClaims map[string]any
// When true the default /token handler omits id_token from the response.
OmitIDToken bool
// When non-empty, /token returns the specified HTTP status with a JSON
// error payload — simulating code-exchange failures.
TokenError string
// When true the default /token handler returns an id_token signed with a
// rogue key, forcing ID-token verification to fail.
SignIDTokenWithWrongKey bool
}
// newTestIssuer spins up the fake issuer and registers cleanup.
func newTestIssuer(t *testing.T) *testIssuer {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("rsa.GenerateKey: %v", err)
}
iss := &testIssuer{
ClientID: "test-client",
Key: key,
KeyID: "test-key-1",
}
mux := http.NewServeMux()
var srv *httptest.Server
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
doc := map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"userinfo_endpoint": srv.URL + "/userinfo",
"jwks_uri": srv.URL + "/jwks",
"id_token_signing_alg_values_supported": []string{"RS256"},
"response_types_supported": []string{"code"},
"subject_types_supported": []string{"public"},
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(doc)
})
mux.HandleFunc("/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(iss.jwks())
})
mux.HandleFunc("/authorize", func(w http.ResponseWriter, r *http.Request) {
// Tests exercise /auth/oidc/login which only reads the redirect URL;
// no need to implement the full authorize flow here.
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if iss.TokenEndpointFn != nil {
iss.TokenEndpointFn(w, r)
return
}
iss.defaultTokenHandler(w, r)
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
if iss.UserInfoFn != nil {
iss.UserInfoFn(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "user-1",
"preferred_username": "alice",
"email": "alice@example.com",
})
})
srv = httptest.NewServer(mux)
iss.Server = srv
t.Cleanup(srv.Close)
// Happy-path defaults; tests override fields before exercising callback.
iss.DefaultIDClaims = map[string]any{
"iss": srv.URL,
"sub": "user-1",
"aud": iss.ClientID,
"exp": time.Now().Add(10 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"preferred_username": "alice",
"email": "alice@example.com",
}
iss.DefaultAccessClaims = map[string]any{
"iss": srv.URL,
"sub": "user-1",
"exp": time.Now().Add(10 * time.Minute).Unix(),
}
return iss
}
func (iss *testIssuer) defaultTokenHandler(w http.ResponseWriter, r *http.Request) {
iss.mu.Lock()
defer iss.mu.Unlock()
if iss.TokenError != "" {
http.Error(w, iss.TokenError, http.StatusBadRequest)
return
}
access := iss.signJWT(iss.DefaultAccessClaims, iss.Key)
resp := map[string]any{
"access_token": access,
"token_type": "Bearer",
"expires_in": 600,
}
if !iss.OmitIDToken {
key := iss.Key
if iss.SignIDTokenWithWrongKey {
rogue, _ := rsa.GenerateKey(rand.Reader, 2048)
key = rogue
}
resp["id_token"] = iss.signJWT(iss.DefaultIDClaims, key)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
}
// signJWT signs the given claims with the given RSA key using RS256.
func (iss *testIssuer) signJWT(claims map[string]any, key *rsa.PrivateKey) string {
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims))
tok.Header["kid"] = iss.KeyID
signed, err := tok.SignedString(key)
if err != nil {
panic(fmt.Sprintf("signJWT: %v", err))
}
return signed
}
// jwks returns a JWKS document exposing the issuer's RSA public key.
func (iss *testIssuer) jwks() map[string]any {
pub := iss.Key.PublicKey
return map[string]any{
"keys": []map[string]any{
{
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"kid": iss.KeyID,
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()),
},
},
}
}
// IDToken signs an ID token with the issuer key. Tests use this to mint
// access tokens carrying nested role claims for fallback-path assertions.
func (iss *testIssuer) IDToken(claims map[string]any) string {
return iss.signJWT(claims, iss.Key)
}
// AccessToken signs an access token carrying Keycloak-shaped resource_access
// roles at resource_access.test-client.roles.
func (iss *testIssuer) AccessToken(roles []string) string {
rolesAny := make([]any, 0, len(roles))
for _, r := range roles {
rolesAny = append(rolesAny, r)
}
return iss.signJWT(map[string]any{
"iss": iss.Server.URL,
"sub": "user-1",
"exp": time.Now().Add(10 * time.Minute).Unix(),
"resource_access": map[string]any{
"test-client": map[string]any{"roles": rolesAny},
},
}, iss.Key)
}
+604
View File
@@ -0,0 +1,604 @@
package routes
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"Noooste/garage-ui/internal/auth"
"Noooste/garage-ui/internal/config"
"Noooste/garage-ui/internal/handlers"
"Noooste/garage-ui/internal/services/mocks"
"github.com/gofiber/fiber/v3"
)
// routeFixture bundles everything a routes test needs.
type routeFixture struct {
App *fiber.App
Admin *mocks.AdminMock
S3 *mocks.S3Mock
Auth *auth.Service
Cfg *config.Config
}
// newTestApp builds a fully-wired fiber.App via SetupRoutes. The cfgMutator
// lets each test flip Admin/OIDC/CORS flags before the auth.Service is
// constructed. If the mutator sets OIDC.Enabled=true it MUST set
// OIDC.IssuerURL + Scopes + AdminRole + ClientID so NewAuthService can dial
// the issuer — typically via the testIssuer fixture.
func newTestApp(t *testing.T, cfgMutator func(*config.Config)) *routeFixture {
t.Helper()
cfg := &config.Config{
Server: config.ServerConfig{
Port: 8080,
Environment: "test",
},
Auth: config.AuthConfig{},
CORS: config.CORSConfig{},
}
if cfgMutator != nil {
cfgMutator(cfg)
}
svc, err := auth.NewAuthService(&cfg.Auth, &cfg.Server)
if err != nil {
t.Fatalf("NewAuthService: %v", err)
}
admin := &mocks.AdminMock{}
s3 := &mocks.S3Mock{}
app := fiber.New()
SetupRoutes(
app,
cfg,
svc,
handlers.NewHealthHandler("test"),
handlers.NewBucketHandler(admin, s3),
handlers.NewObjectHandler(s3),
handlers.NewUserHandler(admin),
handlers.NewClusterHandler(admin),
handlers.NewMonitoringHandler(admin, s3),
)
return &routeFixture{App: app, Admin: admin, S3: s3, Auth: svc, Cfg: cfg}
}
// expectStatus sends req and asserts the status code.
func expectStatus(t *testing.T, app *fiber.App, req *http.Request, want int) *http.Response {
t.Helper()
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test(%s %s): %v", req.Method, req.URL.Path, err)
}
if resp.StatusCode != want {
t.Fatalf("%s %s: status = %d, want %d", req.Method, req.URL.Path, resp.StatusCode, want)
}
return resp
}
func TestRoutes_Registered_NoAuth(t *testing.T) {
// No auth: every route resolves; auth-specific routes return 404.
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = false
c.Auth.OIDC.Enabled = false
})
// Public routes reachable → not 404. Status may be anything except 404.
for _, tc := range []struct {
method, path string
}{
{"GET", "/health"},
{"GET", "/api/v1/health"},
{"GET", "/auth/config"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("%s %s: %v", tc.method, tc.path, err)
}
if resp.StatusCode == 404 {
t.Errorf("%s %s returned 404 — route not registered", tc.method, tc.path)
}
}
// Auth-specific routes must 404 when both auth methods disabled.
for _, tc := range []struct {
method, path string
}{
{"POST", "/auth/login"},
{"GET", "/auth/me"},
{"GET", "/auth/oidc/login"},
{"GET", "/auth/oidc/callback"},
{"POST", "/auth/oidc/logout"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
expectStatus(t, f.App, req, 404)
}
}
func TestRoutes_Registered_AdminOnly(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
// /auth/login and /auth/me present; /auth/oidc/* not.
for _, tc := range []struct {
method, path string
}{
{"POST", "/auth/login"},
{"GET", "/auth/me"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("%v", err)
}
if resp.StatusCode == 404 {
t.Errorf("%s %s returned 404", tc.method, tc.path)
}
}
for _, tc := range []struct {
method, path string
}{
{"GET", "/auth/oidc/login"},
{"GET", "/auth/oidc/callback"},
{"POST", "/auth/oidc/logout"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
expectStatus(t, f.App, req, 404)
}
}
func TestRoutes_UnknownPath_Returns404(t *testing.T) {
f := newTestApp(t, nil)
req := httptest.NewRequest("GET", "/this/does/not/exist", nil)
expectStatus(t, f.App, req, 404)
}
func TestRoutes_AllAPIRoutesRegistered(t *testing.T) {
// With Admin enabled, hit every path/method declared in SetupRoutes.
// A route being "registered" = status != 404. Specific behavior depends
// on auth (401) or mock setup (500 from errNotConfigured) — we only
// care that fiber routed the request.
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
cases := []struct {
method, path string
}{
// Buckets
{"GET", "/api/v1/buckets/"},
{"POST", "/api/v1/buckets/"},
{"GET", "/api/v1/buckets/b1"},
{"DELETE", "/api/v1/buckets/b1"},
{"POST", "/api/v1/buckets/b1/permissions"},
{"PUT", "/api/v1/buckets/b1/website"},
// Objects (listing + uploads)
{"GET", "/api/v1/buckets/b1/objects/"},
{"POST", "/api/v1/buckets/b1/objects/"},
{"POST", "/api/v1/buckets/b1/objects/upload-multiple"},
{"POST", "/api/v1/buckets/b1/objects/delete-multiple"},
// Object wildcard routes
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/metadata"},
{"GET", "/api/v1/buckets/b1/objects/folder/file.txt/presign"},
{"DELETE", "/api/v1/buckets/b1/objects/folder/file.txt"},
{"HEAD", "/api/v1/buckets/b1/objects/folder/file.txt"},
// Users
{"GET", "/api/v1/users/"},
{"POST", "/api/v1/users/"},
{"GET", "/api/v1/users/AKIA"},
{"GET", "/api/v1/users/AKIA/secret"},
{"DELETE", "/api/v1/users/AKIA"},
{"PATCH", "/api/v1/users/AKIA"},
// Cluster
{"GET", "/api/v1/cluster/health"},
{"GET", "/api/v1/cluster/status"},
{"GET", "/api/v1/cluster/statistics"},
{"GET", "/api/v1/cluster/nodes/n1"},
{"GET", "/api/v1/cluster/nodes/n1/statistics"},
// Monitoring
{"GET", "/api/v1/monitoring/metrics"},
{"GET", "/api/v1/monitoring/admin-health"},
{"GET", "/api/v1/monitoring/dashboard"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("%v", err)
}
if resp.StatusCode == 404 {
t.Errorf("route not registered (404): %s %s", tc.method, tc.path)
}
})
}
}
func TestRoutes_AuthRequired_For_APIRoutes(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
// Unauthenticated requests to /api/v1/* must return 401 — auth
// middleware must run before the handler.
for _, tc := range []struct{ method, path string }{
{"GET", "/api/v1/buckets/"},
{"GET", "/api/v1/users/"},
{"GET", "/api/v1/cluster/health"},
{"GET", "/api/v1/monitoring/metrics"},
// Object wildcard routes register AuthMiddleware separately — prove
// that wiring isn't missed for any of GET/DELETE/HEAD.
{"GET", "/api/v1/buckets/b/objects/k"},
{"DELETE", "/api/v1/buckets/b/objects/k"},
{"HEAD", "/api/v1/buckets/b/objects/k"},
} {
tc := tc
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
expectStatus(t, f.App, req, 401)
})
}
}
func TestRoutes_NoAuth_On_PublicRoutes(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
})
for _, tc := range []struct{ method, path string }{
{"GET", "/health"},
{"GET", "/api/v1/health"},
{"GET", "/auth/config"},
} {
tc := tc
t.Run(tc.path, func(t *testing.T) {
req := httptest.NewRequest(tc.method, tc.path, nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("%v", err)
}
if resp.StatusCode == 401 {
t.Errorf("public route unexpectedly returned 401: %s", tc.path)
}
})
}
}
func TestRoutes_CORS_Preflight_PassesBeforeAuth(t *testing.T) {
f := newTestApp(t, func(c *config.Config) {
c.Auth.Admin.Enabled = true
c.Auth.Admin.Username = "admin"
c.Auth.Admin.Password = "pw"
c.CORS = config.CORSConfig{
Enabled: true,
AllowedOrigins: []string{"https://ui.example"},
AllowedMethods: []string{"GET", "POST"},
}
})
req := httptest.NewRequest("OPTIONS", "/api/v1/buckets/", nil)
req.Header.Set("Origin", "https://ui.example")
resp := expectStatus(t, f.App, req, 204)
if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "https://ui.example" {
t.Errorf("Allow-Origin = %q — CORS should have run before auth", got)
}
}
// newOIDCFixture builds a route fixture with OIDC enabled pointing at a
// running testIssuer. The `adminRole` is applied to cfg.Auth.OIDC.AdminRole
// (pass empty to disable the role gate).
func newOIDCFixture(t *testing.T, adminRole string) (*routeFixture, *testIssuer) {
t.Helper()
iss := newTestIssuer(t)
f := newTestApp(t, func(c *config.Config) {
c.Server.RootURL = "https://app.example"
c.Auth.OIDC = config.OIDCConfig{
Enabled: true,
ClientID: iss.ClientID,
ClientSecret: "secret",
IssuerURL: iss.Server.URL,
Scopes: []string{"openid", "profile", "email"},
AdminRole: adminRole,
UsernameAttribute: "preferred_username",
EmailAttribute: "email",
NameAttribute: "name",
RoleAttributePath: "resource_access.test-client.roles",
CookieName: "session",
CookieSecure: false,
CookieHTTPOnly: true,
CookieSameSite: "Lax",
SessionMaxAge: 3600,
}
})
return f, iss
}
func TestRoutes_OIDCLogin_RedirectsToAuthorizeEndpoint(t *testing.T) {
f, iss := newOIDCFixture(t, "admin")
req := httptest.NewRequest("GET", "/auth/oidc/login", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 303 {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
loc := resp.Header.Get("Location")
if !strings.HasPrefix(loc, iss.Server.URL+"/authorize") {
t.Errorf("Location = %q, want prefix %s/authorize", loc, iss.Server.URL)
}
if !strings.Contains(loc, "state=") {
t.Errorf("Location missing state param: %s", loc)
}
if !strings.Contains(loc, "client_id=test-client") {
t.Errorf("Location missing client_id: %s", loc)
}
}
func TestRoutes_Registered_OIDCOnly(t *testing.T) {
f, _ := newOIDCFixture(t, "admin")
// /auth/oidc/* registered
for _, tc := range []struct{ method, path string }{
{"GET", "/auth/oidc/login"},
{"GET", "/auth/oidc/callback"},
{"POST", "/auth/oidc/logout"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("%v", err)
}
if resp.StatusCode == 404 {
t.Errorf("%s %s not registered", tc.method, tc.path)
}
}
// /auth/login must be 404 — admin disabled.
req := httptest.NewRequest("POST", "/auth/login", nil)
expectStatus(t, f.App, req, 404)
}
// oidcState returns a fresh state token minted by the fixture's auth service.
func oidcState(t *testing.T, f *routeFixture) string {
t.Helper()
s, err := f.Auth.GenerateStateToken()
if err != nil {
t.Fatalf("GenerateStateToken: %v", err)
}
return s
}
func TestRoutes_OIDCCallback_MissingState_Returns400(t *testing.T) {
f, _ := newOIDCFixture(t, "admin")
req := httptest.NewRequest("GET", "/auth/oidc/callback", nil)
expectStatus(t, f.App, req, 400)
}
func TestRoutes_OIDCCallback_InvalidState_Returns400(t *testing.T) {
f, _ := newOIDCFixture(t, "admin")
req := httptest.NewRequest("GET", "/auth/oidc/callback?state=not-a-valid-state&code=c", nil)
expectStatus(t, f.App, req, 400)
}
func TestRoutes_OIDCCallback_MissingCode_Returns400(t *testing.T) {
f, _ := newOIDCFixture(t, "admin")
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state, nil)
expectStatus(t, f.App, req, 400)
}
func TestRoutes_OIDCCallback_TokenExchangeFails_Returns401(t *testing.T) {
f, iss := newOIDCFixture(t, "admin")
iss.TokenError = "invalid_grant"
defer func() { iss.TokenError = "" }()
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
expectStatus(t, f.App, req, 401)
}
func TestRoutes_OIDCCallback_MissingIDToken_Returns401(t *testing.T) {
f, iss := newOIDCFixture(t, "admin")
iss.OmitIDToken = true
defer func() { iss.OmitIDToken = false }()
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
expectStatus(t, f.App, req, 401)
}
func TestRoutes_OIDCCallback_BadIDTokenSignature_Returns401(t *testing.T) {
f, iss := newOIDCFixture(t, "admin")
iss.SignIDTokenWithWrongKey = true
defer func() { iss.SignIDTokenWithWrongKey = false }()
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
expectStatus(t, f.App, req, 401)
}
func TestRoutes_OIDCCallback_RoleGateDenies_Returns403(t *testing.T) {
f, _ := newOIDCFixture(t, "admin") // AdminRole set; no roles anywhere
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
expectStatus(t, f.App, req, 403)
}
func TestRoutes_OIDCCallback_NoRoleGate_HappyPathSetsCookieAndRedirects(t *testing.T) {
f, _ := newOIDCFixture(t, "") // AdminRole empty → role gate skipped
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 303 {
t.Fatalf("status = %d, want 303", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/login?login=success" {
t.Errorf("Location = %q", loc)
}
cookies := resp.Cookies()
var sess *http.Cookie
for _, c := range cookies {
if c.Name == "session" {
sess = c
break
}
}
if sess == nil {
t.Fatalf("no session cookie set: %+v", cookies)
}
if sess.Value == "" {
t.Error("session cookie value empty")
}
if !sess.HttpOnly {
t.Error("session cookie should be HttpOnly")
}
if sess.MaxAge != 3600 {
t.Errorf("MaxAge = %d, want 3600", sess.MaxAge)
}
}
func TestRoutes_OIDCCallback_RoleMatchedViaAccessTokenFallback_Succeeds(t *testing.T) {
f, iss := newOIDCFixture(t, "admin")
// Inject role into the access token so ExtractRolesFromAccessToken returns [admin].
iss.DefaultAccessClaims = map[string]any{
"iss": iss.Server.URL,
"sub": "user-1",
"exp": time.Now().Add(10 * time.Minute).Unix(),
"resource_access": map[string]any{
"test-client": map[string]any{"roles": []any{"admin"}},
},
}
state := oidcState(t, f)
req := httptest.NewRequest("GET", "/auth/oidc/callback?state="+state+"&code=c", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 303 {
t.Fatalf("status = %d, want 303 (access-token role fallback should match)", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != "/login?login=success" {
t.Errorf("Location = %q", loc)
}
var sess *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "session" {
sess = c
}
}
if sess == nil || sess.Value == "" {
t.Fatalf("expected session cookie with value, got %+v", sess)
}
}
func TestRoutes_OIDCLogout_ClearsCookieAndReturns200(t *testing.T) {
f, _ := newOIDCFixture(t, "admin")
req := httptest.NewRequest("POST", "/auth/oidc/logout", nil)
resp, err := f.App.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body map[string]any
_ = json.NewDecoder(resp.Body).Decode(&body)
if body["success"] != true {
t.Errorf("success = %v, want true", body["success"])
}
if body["message"] != "Logged out successfully" {
t.Errorf("message = %v", body["message"])
}
var sess *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == "session" {
sess = c
}
}
if sess == nil {
t.Fatalf("expected session cookie in response")
}
if sess.MaxAge != -1 {
t.Errorf("MaxAge = %d, want -1 (cookie cleared)", sess.MaxAge)
}
if sess.Value != "" {
t.Errorf("cookie Value = %q, want empty", sess.Value)
}
}
func TestRoutes_SPAFallback_NoFrontendDir_DoesNotMount(t *testing.T) {
// Chdir into an empty temp dir — frontend/dist does not exist, so the
// SPA fallback is not registered.
t.Chdir(t.TempDir())
f := newTestApp(t, nil)
req := httptest.NewRequest("GET", "/random/spa/path", nil)
expectStatus(t, f.App, req, 404)
}
func TestRoutes_SPAFallback_WithFrontend_ServesIndexForUnknownPath(t *testing.T) {
if runtime.GOOS == "windows" {
// Fiber's c.SendFile holds a handle on the served file; Windows refuses
// t.TempDir RemoveAll cleanup. Behavior itself is platform-agnostic and
// covered on Linux in CI.
t.Skip("SPA fallback test skipped on Windows due to file-handle cleanup race")
}
dir := t.TempDir()
t.Chdir(dir)
// Create ./frontend/dist/index.html
if err := os.MkdirAll(filepath.Join(dir, "frontend", "dist"), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
index := filepath.Join(dir, "frontend", "dist", "index.html")
if err := os.WriteFile(index, []byte("<!doctype html><title>spa</title>"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
f := newTestApp(t, nil)
// Unknown SPA path → serves index.html.
req := httptest.NewRequest("GET", "/deep/spa/route", nil)
resp := expectStatus(t, f.App, req, 200)
body := make([]byte, 64)
n, _ := resp.Body.Read(body)
if !strings.Contains(string(body[:n]), "spa") {
t.Errorf("body = %q, want index.html content", string(body[:n]))
}
// API prefix is skipped by the fallback → still 404 for unknown API path.
req2 := httptest.NewRequest("GET", "/api/v1/definitely-not-a-route", nil)
expectStatus(t, f.App, req2, 404)
}