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:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+511
View File
@@ -0,0 +1,511 @@
package handlers_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/httpapi"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// api spins the real router over a per-test tenant, so these exercise the same
// routing, middleware order and auth rules the server runs.
type api struct {
*testsupport.Fixture
handler http.Handler
}
func newAPI(t *testing.T) *api {
t.Helper()
f := testsupport.Setup(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
h := handlers.New(f.Repo, logger)
return &api{
Fixture: f,
handler: httpapi.New(h, f.Repo, logger, []string{"http://localhost:4200"}),
}
}
// do issues an authenticated request as the fixture's agent.
func (a *api) do(t *testing.T, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
return a.as(t, a.Agent.Email, testsupport.Password, a.Tenant.Slug, method, path, body)
}
// anon issues a request with no credentials.
func (a *api) anon(t *testing.T, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func (a *api) as(t *testing.T, email, password, tenant, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(email+":"+password)))
req.Header.Set(middleware.TenantHeader, tenant)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %s: %v", rec.Body, err)
}
return out
}
func requireStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body)
}
}
// --- public endpoints ------------------------------------------------------
func TestHealthNeedsNoCredentials(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/health")
requireStatus(t, rec, http.StatusOK)
got := decode[map[string]string](t, rec)
if got["status"] != "ok" {
t.Errorf("body = %v, want status ok", got)
}
}
func TestTenantListIsPublicButThin(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/tenants")
requireStatus(t, rec, http.StatusOK)
// The picker needs a slug and a name; it must not leak ids or timestamps.
got := decode[[]map[string]any](t, rec)
found := false
for _, tn := range got {
if tn["slug"] == a.Tenant.Slug {
found = true
if _, leaked := tn["id"]; leaked {
t.Error("tenant list leaked internal ids")
}
if len(tn) != 2 {
t.Errorf("tenant entry has %d fields, want just slug and name: %v", len(tn), tn)
}
}
}
if !found {
t.Error("the fixture's tenant is missing from the public list")
}
}
func TestProtectedRoutesRejectAnonymous(t *testing.T) {
a := newAPI(t)
for _, path := range []string{
"/api/session", "/api/flights", "/api/bookings", "/api/dashboard",
"/api/airports", "/api/airlines",
} {
t.Run(path, func(t *testing.T) {
// No credentials and no tenant: the tenant check fires first.
rec := a.anon(t, http.MethodGet, path)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 400 or 401", rec.Code)
}
})
}
}
// --- session ---------------------------------------------------------------
func TestSessionDescribesTheCaller(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}](t, rec)
if got.User.Email != a.Agent.Email {
t.Errorf("user = %s, want %s", got.User.Email, a.Agent.Email)
}
if got.Tenant.Slug != a.Tenant.Slug {
t.Errorf("tenant = %s, want %s", got.Tenant.Slug, a.Tenant.Slug)
}
}
func TestSessionNeverReturnsThePasswordHash(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
if bytes.Contains(rec.Body.Bytes(), []byte("password")) {
t.Errorf("session response mentions a password field: %s", rec.Body)
}
}
// --- flights ---------------------------------------------------------------
func TestSearchFlightsReturnsAPage(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
rec := a.do(t, http.MethodGet, "/api/flights?origin=JFK&destination=LHR", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
Items []models.Flight `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}](t, rec)
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("want one flight, got %d (total %d)", len(got.Items), got.Total)
}
if got.Limit != 25 {
t.Errorf("default limit = %d, want 25", got.Limit)
}
if got.Items[0].Airline.IATACode == "" || got.Items[0].Origin.City == "" {
t.Error("flight should arrive with its airline and airports expanded")
}
}
func TestSearchFlightsValidatesInput(t *testing.T) {
a := newAPI(t)
cases := []struct {
name string
path string
want int
}{
{"bad date", "/api/flights?date=12-09-2026", http.StatusBadRequest},
{"bad cabin", "/api/flights?cabin=luxury", http.StatusBadRequest},
{"good cabin", "/api/flights?cabin=business", http.StatusOK},
{"absurd limit is clamped", "/api/flights?limit=99999", http.StatusOK},
{"negative offset falls back", "/api/flights?offset=-5", http.StatusOK},
{"non-numeric passengers falls back", "/api/flights?passengers=lots", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodGet, tc.path, nil)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.want, rec.Body)
}
})
}
}
func TestGetFlightRejectsAMalformedID(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights/not-a-uuid", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestGetFlightAcrossTenantsIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{})
rec := a.do(t, http.MethodGet, "/api/flights/"+theirFlight.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
}
// --- bookings --------------------------------------------------------------
func newBooking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: "Test", LastName: "Traveller",
})
}
return req
}
func TestCreateBookingReturnsCreated(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 300})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
requireStatus(t, rec, http.StatusCreated)
got := decode[models.Booking](t, rec)
if got.TotalAmount != 600 {
t.Errorf("TotalAmount = %v, want 600", got.TotalAmount)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passengers, want 2", len(got.Passengers))
}
if left := a.SeatsAvailable(t, flight.ID); left != 18 {
t.Errorf("seats = %d, want 18", left)
}
}
func TestCreateBookingValidation(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
noName := newBooking(flight.ID, 1)
noName.Passengers[0].FirstName = " "
tooMany := newBooking(flight.ID, 10)
cases := []struct {
name string
body models.CreateBookingRequest
}{
{"missing flight id", models.CreateBookingRequest{
ContactEmail: "a@b.co",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"missing contact email", models.CreateBookingRequest{
FlightID: flight.ID,
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"malformed contact email", models.CreateBookingRequest{
FlightID: flight.ID,
ContactEmail: "definitely-not-an-email",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"no passengers", models.CreateBookingRequest{
FlightID: flight.ID, ContactEmail: "a@b.co",
}},
{"blank passenger name", noName},
{"more than nine passengers", tooMany},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodPost, "/api/bookings", tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (body: %s)", rec.Code, rec.Body)
}
})
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("rejected bookings consumed seats: %d left, want 20", left)
}
}
func TestCreateBookingRejectsUnknownFields(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
// A client trying to name its own tenant must not be quietly ignored.
body := map[string]any{
"flightId": flight.ID,
"contactEmail": "ops@example.com",
"tenantId": uuid.New(),
"passengers": []map[string]string{{"firstName": "A", "lastName": "B"}},
}
rec := a.do(t, http.MethodPost, "/api/bookings", body)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingRejectsMalformedJSON(t *testing.T) {
a := newAPI(t)
req := httptest.NewRequest(http.MethodPost, "/api/bookings", bytes.NewReader([]byte("{not json")))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString(
[]byte(a.Agent.Email+":"+testsupport.Password)))
req.Header.Set(middleware.TenantHeader, a.Tenant.Slug)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingOverCapacityIsConflict(t *testing.T) {
a := newAPI(t)
two := 2
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 3))
requireStatus(t, rec, http.StatusConflict)
}
func TestCreateBookingOnAnotherTenantsFlightIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{Seats: 50})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(theirFlight.ID, 1))
requireStatus(t, rec, http.StatusNotFound)
}
func TestBookingLifecycleOverHTTP(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 100})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2)))
fetched := decode[models.Booking](t, a.do(t, http.MethodGet, "/api/bookings/"+created.ID.String(), nil))
if fetched.BookingReference != created.BookingReference {
t.Errorf("fetched a different booking: %s vs %s", fetched.BookingReference, created.BookingReference)
}
listed := decode[struct {
Items []models.Booking `json:"items"`
Total int `json:"total"`
}](t, a.do(t, http.MethodGet, "/api/bookings", nil))
if listed.Total != 1 {
t.Errorf("list total = %d, want 1", listed.Total)
}
cancelled := decode[models.Booking](t,
a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil))
if cancelled.Status != "cancelled" {
t.Errorf("status = %q, want cancelled", cancelled.Status)
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("seats after cancel = %d, want 20", left)
}
// A second cancel finds nothing to cancel.
again := a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil)
requireStatus(t, again, http.StatusNotFound)
}
func TestListBookingsRejectsAnUnknownStatus(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/bookings?status=pending", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestBookingsAreInvisibleToOtherTenants(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 1)))
// A second agency, with valid credentials of its own, must see nothing.
other := testsupport.Setup(t)
rec := a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings/"+created.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
list := decode[struct {
Total int `json:"total"`
}](t, a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings", nil))
if list.Total != 0 {
t.Errorf("other tenant sees %d bookings, want 0", list.Total)
}
}
// --- dashboard and reference ----------------------------------------------
func TestDashboardSummarisesTheTenant(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 250})
a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
rec := a.do(t, http.MethodGet, "/api/dashboard", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[models.DashboardStats](t, rec)
if got.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", got.ConfirmedBookings)
}
if got.TotalRevenue != 500 {
t.Errorf("TotalRevenue = %v, want 500", got.TotalRevenue)
}
if len(got.RecentBookings) != 1 {
t.Errorf("RecentBookings = %d, want 1", len(got.RecentBookings))
}
}
func TestReferenceListsAreScopedToTheTenantNetwork(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
airports := decode[[]models.Airport](t, a.do(t, http.MethodGet, "/api/airports", nil))
if len(airports) != 2 {
t.Errorf("got %d airports, want the 2 this tenant flies", len(airports))
}
airlines := decode[[]models.Airline](t, a.do(t, http.MethodGet, "/api/airlines", nil))
if len(airlines) != 1 {
t.Errorf("got %d airlines, want 1", len(airlines))
}
}
// --- routing ---------------------------------------------------------------
func TestUnknownRouteAndWrongMethod(t *testing.T) {
a := newAPI(t)
t.Run("unknown path", func(t *testing.T) {
rec := a.do(t, http.MethodGet, "/api/nonexistent", nil)
requireStatus(t, rec, http.StatusNotFound)
})
t.Run("wrong method", func(t *testing.T) {
rec := a.do(t, http.MethodDelete, "/api/flights", nil)
requireStatus(t, rec, http.StatusMethodNotAllowed)
})
}
func TestEveryJSONResponseCarriesItsContentType(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights", nil)
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
}