package middleware_test import ( "encoding/base64" "io" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "github.com/travelswitch/travelhub/backend/internal/auth" "github.com/travelswitch/travelhub/backend/internal/middleware" "github.com/travelswitch/travelhub/backend/internal/testsupport" ) func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func basic(email, password string) string { return "Basic " + base64.StdEncoding.EncodeToString([]byte(email+":"+password)) } // guarded wraps a handler that records the identity the middleware resolved. func guarded(f *testsupport.Fixture, seen *auth.Identity) http.Handler { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if id, err := auth.FromContext(r.Context()); err == nil && seen != nil { *seen = id } w.WriteHeader(http.StatusOK) }) return middleware.BasicAuth(f.Repo, quietLogger())(inner) } func TestBasicAuthAcceptsValidCredentials(t *testing.T) { f := testsupport.Setup(t) var got auth.Identity req := httptest.NewRequest(http.MethodGet, "/api/session", nil) req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password)) req.Header.Set(middleware.TenantHeader, f.Tenant.Slug) rec := httptest.NewRecorder() guarded(f, &got).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body) } if got.User.ID != f.Agent.ID { t.Errorf("resolved user %s, want %s", got.User.ID, f.Agent.ID) } if got.Tenant.ID != f.Tenant.ID { t.Errorf("resolved tenant %s, want %s", got.Tenant.ID, f.Tenant.ID) } } func TestBasicAuthIsCaseInsensitiveOnEmail(t *testing.T) { f := testsupport.Setup(t) req := httptest.NewRequest(http.MethodGet, "/api/session", nil) req.Header.Set("Authorization", basic(strings.ToUpper(f.Agent.Email), testsupport.Password)) req.Header.Set(middleware.TenantHeader, f.Tenant.Slug) rec := httptest.NewRecorder() guarded(f, nil).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want 200", rec.Code) } } func TestBasicAuthRejections(t *testing.T) { f := testsupport.Setup(t) other := testsupport.Setup(t) cases := []struct { name string authHeader string tenant string wantStatus int }{ { name: "no credentials at all", tenant: f.Tenant.Slug, wantStatus: http.StatusUnauthorized, }, { name: "wrong password", authHeader: basic(f.Agent.Email, "not-the-password"), tenant: f.Tenant.Slug, wantStatus: http.StatusUnauthorized, }, { name: "unknown user", authHeader: basic("nobody@nowhere.test", testsupport.Password), tenant: f.Tenant.Slug, wantStatus: http.StatusUnauthorized, }, { name: "unknown tenant", authHeader: basic(f.Agent.Email, testsupport.Password), tenant: "no-such-agency", wantStatus: http.StatusUnauthorized, }, { // The central rule: right password, wrong agency. name: "valid credentials against another tenant", authHeader: basic(f.Agent.Email, testsupport.Password), tenant: other.Tenant.Slug, wantStatus: http.StatusUnauthorized, }, { name: "missing tenant header", authHeader: basic(f.Agent.Email, testsupport.Password), tenant: "", wantStatus: http.StatusBadRequest, }, { name: "malformed authorization header", authHeader: "Bearer some.jwt.token", tenant: f.Tenant.Slug, wantStatus: http.StatusUnauthorized, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/session", nil) if tc.authHeader != "" { req.Header.Set("Authorization", tc.authHeader) } if tc.tenant != "" { req.Header.Set(middleware.TenantHeader, tc.tenant) } rec := httptest.NewRecorder() guarded(f, nil).ServeHTTP(rec, req) if rec.Code != tc.wantStatus { t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body) } }) } } func TestBasicAuthRefusesSuspendedTenant(t *testing.T) { f := testsupport.Setup(t) f.Suspend(t) req := httptest.NewRequest(http.MethodGet, "/api/session", nil) req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password)) req.Header.Set(middleware.TenantHeader, f.Tenant.Slug) rec := httptest.NewRecorder() guarded(f, nil).ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401 for a suspended tenant", rec.Code) } } // Sending WWW-Authenticate would make the browser pop its own login dialog over // the Angular sign-in screen, so its absence is a deliberate contract. func TestUnauthorizedDoesNotTriggerTheBrowserDialog(t *testing.T) { f := testsupport.Setup(t) req := httptest.NewRequest(http.MethodGet, "/api/session", nil) req.Header.Set("Authorization", basic(f.Agent.Email, "wrong")) req.Header.Set(middleware.TenantHeader, f.Tenant.Slug) rec := httptest.NewRecorder() guarded(f, nil).ServeHTTP(rec, req) if got := rec.Header().Get("WWW-Authenticate"); got != "" { t.Errorf("WWW-Authenticate = %q, want it absent", got) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } } // Deployments that give each agency a subdomain need no client changes. func TestTenantFallsBackToSubdomain(t *testing.T) { f := testsupport.Setup(t) req := httptest.NewRequest(http.MethodGet, "/api/session", nil) req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password)) req.Host = f.Tenant.Slug + ".travelhub.example.com:8080" rec := httptest.NewRecorder() guarded(f, nil).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("status = %d, want 200 resolving the tenant from the subdomain", rec.Code) } } func TestTenantHeaderBeatsSubdomain(t *testing.T) { f := testsupport.Setup(t) other := testsupport.Setup(t) var got auth.Identity req := httptest.NewRequest(http.MethodGet, "/api/session", nil) req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password)) req.Header.Set(middleware.TenantHeader, f.Tenant.Slug) req.Host = other.Tenant.Slug + ".travelhub.example.com" rec := httptest.NewRecorder() guarded(f, &got).ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got.Tenant.ID != f.Tenant.ID { t.Error("the subdomain overrode the explicit tenant header") } } func TestRequireAdmin(t *testing.T) { f := testsupport.Setup(t) adminOnly := middleware.BasicAuth(f.Repo, quietLogger())( middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })), ) cases := []struct { name string email string want int }{ {"admin passes", f.Admin.Email, http.StatusOK}, {"agent is refused", f.Agent.Email, http.StatusForbidden}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/admin", nil) req.Header.Set("Authorization", basic(tc.email, testsupport.Password)) req.Header.Set(middleware.TenantHeader, f.Tenant.Slug) rec := httptest.NewRecorder() adminOnly.ServeHTTP(rec, req) if rec.Code != tc.want { t.Errorf("status = %d, want %d", rec.Code, tc.want) } }) } } func TestCORS(t *testing.T) { allowed := []string{"http://localhost:4200", "https://app.example.com/"} ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) handler := middleware.CORS(allowed)(ok) t.Run("echoes an allowed origin", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/flights", nil) req.Header.Set("Origin", "http://localhost:4200") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:4200" { t.Errorf("Allow-Origin = %q", got) } if got := rec.Header().Get("Vary"); !strings.Contains(got, "Origin") { t.Errorf("Vary = %q, want it to include Origin", got) } if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, middleware.TenantHeader) { t.Errorf("Allow-Headers = %q, want it to include %s", got, middleware.TenantHeader) } }) t.Run("tolerates a trailing slash in configuration", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/flights", nil) req.Header.Set("Origin", "https://app.example.com") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" { t.Errorf("Allow-Origin = %q, want the origin echoed", got) } }) t.Run("ignores an origin that is not on the list", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/flights", nil) req.Header.Set("Origin", "https://evil.example.com") rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" { t.Errorf("Allow-Origin = %q, want it absent", got) } }) t.Run("answers preflight without reaching the handler", func(t *testing.T) { reached := false h := middleware.CORS(allowed)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) req := httptest.NewRequest(http.MethodOptions, "/api/bookings", nil) req.Header.Set("Origin", "http://localhost:4200") rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != http.StatusNoContent { t.Errorf("status = %d, want 204", rec.Code) } if reached { t.Error("preflight should short-circuit before the handler") } }) } func TestRecovererTurnsPanicIntoFiveHundred(t *testing.T) { boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("something went very wrong") }) handler := middleware.Recoverer(quietLogger())(boom) req := httptest.NewRequest(http.MethodGet, "/api/flights", nil) rec := httptest.NewRecorder() // The point is that this call returns rather than taking the process down. handler.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Errorf("status = %d, want 500", rec.Code) } if !strings.Contains(rec.Body.String(), "internal server error") { t.Errorf("body = %s, want a JSON error", rec.Body) } }