Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user