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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user