mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
test: add unit tests for authentication and CORS middleware
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user