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
+329
View File
@@ -0,0 +1,329 @@
// Package testsupport wires tests to a real PostgreSQL database.
//
// Isolation strategy: every test gets its own freshly created tenant. Because
// the whole application is tenant-scoped by design, a per-test tenant gives
// each test a private world without truncating tables between runs -- and it
// exercises the tenant scoping on every single query rather than trusting it.
//
// Set TEST_DATABASE_URL to point at a throwaway database. The default assumes
// a local "travelhub_test". The schema is applied automatically on first use.
package testsupport
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"github.com/travelswitch/travelhub/backend/internal/config"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// Password is the plaintext behind every user created by this package.
const Password = "testpass123"
var (
poolOnce sync.Once
pool *pgxpool.Pool
poolErr error
)
// Fixture is one test's private slice of the world.
type Fixture struct {
Pool *pgxpool.Pool
Repo *repository.Repository
Tenant models.Tenant
Admin models.User
Agent models.User
}
// Setup returns a Fixture backed by a brand-new tenant. The tenant and
// everything that cascades from it are removed when the test finishes.
func Setup(t *testing.T) *Fixture {
t.Helper()
p := sharedPool(t)
ctx := context.Background()
slug := "test-" + uuid.NewString()[:8]
var tenant models.Tenant
err := p.QueryRow(ctx,
`INSERT INTO tenants (slug, name) VALUES ($1, $2)
RETURNING id, slug, name, status, created_at`,
slug, "Test Agency "+slug,
).Scan(&tenant.ID, &tenant.Slug, &tenant.Name, &tenant.Status, &tenant.CreatedAt)
if err != nil {
t.Fatalf("create tenant: %v", err)
}
t.Cleanup(func() {
// ON DELETE CASCADE clears users, flights, bookings and passengers.
if _, err := p.Exec(context.Background(),
`DELETE FROM tenants WHERE id = $1`, tenant.ID); err != nil {
t.Logf("cleanup tenant %s: %v", tenant.Slug, err)
}
})
f := &Fixture{Pool: p, Repo: repository.New(p), Tenant: tenant}
f.Admin = f.NewUser(t, "admin@"+slug+".test", "admin")
f.Agent = f.NewUser(t, "agent@"+slug+".test", "agent")
return f
}
// NewUser adds a user to the fixture's tenant.
func (f *Fixture) NewUser(t *testing.T, email, role string) models.User {
t.Helper()
// MinCost keeps the suite fast; the middleware verifies any cost.
hash, err := bcrypt.GenerateFromPassword([]byte(Password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
var u models.User
err = f.Pool.QueryRow(context.Background(),
`INSERT INTO users (tenant_id, email, password_hash, full_name, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tenant_id, email, password_hash, full_name, role, created_at`,
f.Tenant.ID, email, string(hash), "Test "+role, role,
).Scan(&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt)
if err != nil {
t.Fatalf("create user %s: %v", email, err)
}
return u
}
// FlightOpts describes a flight to insert. Zero values get sensible defaults.
type FlightOpts struct {
Number string
Origin string // IATA, default JFK
Destination string // IATA, default LHR
DepartsIn time.Duration
Duration time.Duration
Cabin string
Price float64
Seats int
Available *int // defaults to Seats
Status string
}
// NewFlight inserts one flight into the fixture's tenant and returns it.
func (f *Fixture) NewFlight(t *testing.T, opts FlightOpts) models.Flight {
t.Helper()
ctx := context.Background()
if opts.Number == "" {
opts.Number = "TT" + uuid.NewString()[:4]
}
if opts.Origin == "" {
opts.Origin = "JFK"
}
if opts.Destination == "" {
opts.Destination = "LHR"
}
if opts.DepartsIn == 0 {
opts.DepartsIn = 48 * time.Hour
}
if opts.Duration == 0 {
opts.Duration = 7 * time.Hour
}
if opts.Cabin == "" {
opts.Cabin = "economy"
}
if opts.Price == 0 {
opts.Price = 500
}
if opts.Seats == 0 {
opts.Seats = 100
}
if opts.Status == "" {
opts.Status = "scheduled"
}
available := opts.Seats
if opts.Available != nil {
available = *opts.Available
}
departs := time.Now().Add(opts.DepartsIn)
var id uuid.UUID
err := f.Pool.QueryRow(ctx,
`INSERT INTO flights (tenant_id, airline_id, flight_number,
origin_airport_id, destination_airport_id,
departure_time, arrival_time, aircraft, cabin_class,
base_price, currency, seats_total, seats_available, status)
VALUES ($1,
(SELECT id FROM airlines WHERE iata_code = 'AA'),
$2,
(SELECT id FROM airports WHERE iata_code = $3),
(SELECT id FROM airports WHERE iata_code = $4),
$5, $6, 'Boeing 787-9', $7, $8, 'USD', $9, $10, $11)
RETURNING id`,
f.Tenant.ID, opts.Number, opts.Origin, opts.Destination,
departs, departs.Add(opts.Duration), opts.Cabin, opts.Price,
opts.Seats, available, opts.Status,
).Scan(&id)
if err != nil {
t.Fatalf("create flight: %v", err)
}
flight, err := f.Repo.FlightByID(ctx, f.Tenant.ID, id)
if err != nil {
t.Fatalf("read back flight: %v", err)
}
return flight
}
// SeatsAvailable reads the current availability straight from the row, for
// assertions about seat accounting.
func (f *Fixture) SeatsAvailable(t *testing.T, flightID uuid.UUID) int {
t.Helper()
var seats int
err := f.Pool.QueryRow(context.Background(),
`SELECT seats_available FROM flights WHERE id = $1`, flightID).Scan(&seats)
if err != nil {
t.Fatalf("read seats: %v", err)
}
return seats
}
// Suspend flips the tenant to suspended, for tests that check sign-in is
// refused afterwards.
func (f *Fixture) Suspend(t *testing.T) {
t.Helper()
if _, err := f.Pool.Exec(context.Background(),
`UPDATE tenants SET status = 'suspended' WHERE id = $1`, f.Tenant.ID); err != nil {
t.Fatalf("suspend tenant: %v", err)
}
}
// sharedPool opens the pool once per test binary and prepares the schema.
func sharedPool(t *testing.T) *pgxpool.Pool {
t.Helper()
poolOnce.Do(func() {
// Pick up backend/.env so a developer who configured the app can run
// the tests without setting anything extra.
config.LoadDotEnv()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
p, err := pgxpool.New(ctx, databaseURL())
if err != nil {
poolErr = fmt.Errorf("open test pool: %w", err)
return
}
if err := p.Ping(ctx); err != nil {
poolErr = fmt.Errorf("ping test database: %w", err)
return
}
if err := applySchema(ctx, p); err != nil {
poolErr = err
return
}
if err := seedReference(ctx, p); err != nil {
poolErr = err
return
}
pool = p
})
if poolErr != nil {
t.Fatalf("test database unavailable: %v\n"+
"Set TEST_DATABASE_URL, or create the default database with:\n"+
" createdb -U postgres travelhub_test", poolErr)
}
return pool
}
// databaseURL resolves the test DSN, preferring TEST_DATABASE_URL and
// otherwise assembling one from the same DB_* variables the app uses, with the
// database name swapped for the test one.
func databaseURL() string {
if raw := os.Getenv("TEST_DATABASE_URL"); raw != "" {
return raw
}
user := envOr("DB_USER", "postgres")
pass := envOr("DB_PASSWORD", "postgres")
host := envOr("DB_HOST", "localhost")
port := envOr("DB_PORT", "5432")
name := envOr("TEST_DB_NAME", "travelhub_test")
u := &url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: host + ":" + port,
Path: "/" + name,
RawQuery: "sslmode=disable",
}
return u.String()
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// applySchema runs 001_init.sql, which is written to be re-runnable.
func applySchema(ctx context.Context, p *pgxpool.Pool) error {
path := filepath.Join(moduleRoot(), "migrations", "001_init.sql")
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read schema %s: %w", path, err)
}
// Exec with no arguments uses the simple protocol, which accepts a script
// of several statements.
if _, err := p.Exec(ctx, string(sql)); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
return nil
}
// seedReference inserts the global airports and airlines the fixtures need.
func seedReference(ctx context.Context, p *pgxpool.Pool) error {
const sql = `
INSERT INTO airports (iata_code, name, city, country, timezone) VALUES
('JFK', 'John F. Kennedy International', 'New York', 'United States', 'America/New_York'),
('LHR', 'Heathrow', 'London', 'United Kingdom', 'Europe/London'),
('LAX', 'Los Angeles International', 'Los Angeles', 'United States', 'America/Los_Angeles'),
('DXB', 'Dubai International', 'Dubai', 'United Arab Emirates', 'Asia/Dubai')
ON CONFLICT (iata_code) DO NOTHING;
INSERT INTO airlines (iata_code, name) VALUES
('AA', 'American Airlines'),
('BA', 'British Airways')
ON CONFLICT (iata_code) DO NOTHING;`
if _, err := p.Exec(ctx, sql); err != nil {
return fmt.Errorf("seed reference data: %w", err)
}
return nil
}
// moduleRoot locates backend/ from this file's compile-time path, so tests can
// find migrations no matter which package directory they run from.
func moduleRoot() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return "."
}
// .../backend/internal/testsupport/testsupport.go -> .../backend
return filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
}