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
+48
View File
@@ -0,0 +1,48 @@
/** The seeded demo accounts, from backend/migrations/002_seed.sql. */
export interface Account {
tenant: string;
tenantName: string;
email: string;
password: string;
fullName: string;
role: 'admin' | 'agent';
}
export const DEMO_PASSWORD = 'demo1234';
export const SKYLINE_AGENT: Account = {
tenant: 'skyline',
tenantName: 'Skyline Travel',
email: 'agent@skyline.test',
password: DEMO_PASSWORD,
fullName: 'Marcus Reid',
role: 'agent',
};
export const SKYLINE_ADMIN: Account = {
tenant: 'skyline',
tenantName: 'Skyline Travel',
email: 'admin@skyline.test',
password: DEMO_PASSWORD,
fullName: 'Ava Chen',
role: 'admin',
};
export const GLOBETROTTER_AGENT: Account = {
tenant: 'globetrotter',
tenantName: 'Globetrotter Tours',
email: 'agent@globetrotter.test',
password: DEMO_PASSWORD,
fullName: 'Tom Okafor',
role: 'agent',
};
export const GLOBETROTTER_ADMIN: Account = {
tenant: 'globetrotter',
tenantName: 'Globetrotter Tours',
email: 'admin@globetrotter.test',
password: DEMO_PASSWORD,
fullName: 'Priya Nair',
role: 'admin',
};
+265
View File
@@ -0,0 +1,265 @@
/**
* Simulated backend for UI tests.
*
* The end-to-end specs drive the real stack, which is the right way to prove
* the system works. But some conditions are impractical to stage for real:
* a 500 from the database, a fifteen-second response, a seat sold by someone
* else in the half second between opening checkout and confirming, an agency
* with a hundred thousand flights, a passenger whose name is 200 characters
* long.
*
* These helpers intercept `/api/**` in the browser and answer it directly, so
* a test can put the UI in any of those states deterministically, in
* milliseconds, without touching the database.
*/
import { Page } from '@playwright/test';
// --- canned data -----------------------------------------------------------
export const TENANTS = [
{ slug: 'skyline', name: 'Skyline Travel' },
{ slug: 'globetrotter', name: 'Globetrotter Tours' },
];
export const SESSION = {
user: {
id: 'u-1',
tenantId: 't-1',
email: 'agent@skyline.test',
fullName: 'Marcus Reid',
role: 'agent',
createdAt: '2026-01-01T00:00:00Z',
},
tenant: {
id: 't-1',
slug: 'skyline',
name: 'Skyline Travel',
status: 'active',
createdAt: '2026-01-01T00:00:00Z',
},
};
export const AIRPORTS = [
{ id: 'a-jfk', iataCode: 'JFK', name: 'John F. Kennedy International', city: 'New York', country: 'United States', timezone: 'America/New_York' },
{ id: 'a-lhr', iataCode: 'LHR', name: 'Heathrow', city: 'London', country: 'United Kingdom', timezone: 'Europe/London' },
];
export const AIRLINES = [{ id: 'al-aa', iataCode: 'AA', name: 'American Airlines' }];
export function makeFlight(overrides: Record<string, unknown> = {}) {
return {
id: 'f-1',
flightNumber: 'AA100',
airline: AIRLINES[0],
origin: AIRPORTS[0],
destination: AIRPORTS[1],
departureTime: '2026-12-19T18:00:00Z',
arrivalTime: '2026-12-20T01:00:00Z',
durationMinutes: 420,
aircraft: 'Boeing 787-9',
cabinClass: 'economy',
basePrice: 483.3,
currency: 'USD',
seatsTotal: 180,
seatsAvailable: 165,
status: 'scheduled',
...overrides,
};
}
export function makeBooking(overrides: Record<string, unknown> = {}) {
return {
id: 'b-1',
bookingReference: 'SIM001',
flight: makeFlight(),
passengers: [{ id: 'p-1', firstName: 'Dana', lastName: 'Whitfield' }],
passengerCount: 1,
totalAmount: 483.3,
currency: 'USD',
status: 'confirmed',
contactEmail: 'ops@example.com',
contactPhone: '',
bookedBy: 'Marcus Reid',
createdAt: '2026-09-12T13:10:00Z',
...overrides,
};
}
export function makeDashboard(overrides: Record<string, unknown> = {}) {
return {
totalBookings: 6,
confirmedBookings: 5,
cancelledBookings: 1,
totalRevenue: 7263.16,
currency: 'USD',
flightsInStock: 294,
seatsSold: 12,
recentBookings: [makeBooking()],
topRoutes: [{ route: 'JFK to LHR', bookings: 3, revenue: 4000 }],
...overrides,
};
}
export function page$<T>(items: T[], total = items.length, limit = 10, offset = 0) {
return { items, total, limit, offset };
}
// --- scenario --------------------------------------------------------------
/**
* How one endpoint should answer. A bare value is a 200 with that value as the
* body; use `reply`, `fail` or `slow` for anything else.
*
* The envelope carries a symbol brand rather than being recognised by shape.
* Domain objects here have a `status` field of their own -- a flight is
* "scheduled", a booking is "confirmed" -- so duck-typing on `status` would
* silently read a booking as an HTTP response.
*/
const ENVELOPE = Symbol('travelhub.reply');
export interface ReplyEnvelope {
[ENVELOPE]: true;
status: number;
body: unknown;
delayMs?: number;
}
export type Reply = ReplyEnvelope | unknown;
/** An explicit response: status, body and optional latency. */
export function reply(opts: { status?: number; body?: unknown; delayMs?: number }): ReplyEnvelope {
return {
[ENVELOPE]: true,
status: opts.status ?? 200,
body: opts.body ?? {},
delayMs: opts.delayMs,
};
}
/** An error response, in the shape the Go API uses. */
export function fail(status: number, error = 'simulated failure'): ReplyEnvelope {
return reply({ status, body: { error } });
}
/** A successful but slow response, for asserting on loading states. */
export function slow(body: unknown, delayMs: number): ReplyEnvelope {
return reply({ status: 200, body, delayMs });
}
export interface Scenario {
/** Latency applied to every endpoint that does not set its own. */
delayMs?: number;
/** Abort every API call, as though the network were gone. */
offline?: boolean;
session?: Reply;
tenants?: Reply;
airports?: Reply;
airlines?: Reply;
flights?: Reply;
flightById?: Reply;
bookings?: Reply;
bookingById?: Reply;
createBooking?: Reply;
cancelBooking?: Reply;
dashboard?: Reply;
}
function normalise(value: Reply): { status: number; body: unknown; delayMs?: number } {
if (value !== null && typeof value === 'object' && ENVELOPE in value) {
const envelope = value as ReplyEnvelope;
return { status: envelope.status, body: envelope.body, delayMs: envelope.delayMs };
}
return { status: 200, body: value };
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Installs the simulated API. Anything the scenario does not mention falls
* back to a sensible, healthy default, so a test only spells out the one
* condition it is about.
*/
export async function simulateApi(page: Page, scenario: Scenario = {}): Promise<void> {
const defaults: Scenario = {
session: SESSION,
tenants: TENANTS,
airports: AIRPORTS,
airlines: AIRLINES,
flights: page$([makeFlight()]),
flightById: makeFlight(),
bookings: page$([makeBooking()]),
bookingById: makeBooking(),
createBooking: reply({ status: 201, body: makeBooking() }),
cancelBooking: makeBooking({ status: 'cancelled' }),
dashboard: makeDashboard(),
};
const merged = { ...defaults, ...scenario };
await page.route('**/api/**', async (route) => {
if (scenario.offline) {
await route.abort('internetdisconnected');
return;
}
const request = route.request();
const path = new URL(request.url()).pathname;
const method = request.method();
let answer: Reply;
if (path === '/api/health') {
answer = { status: 'ok' };
} else if (path === '/api/tenants') {
answer = merged.tenants;
} else if (path === '/api/session') {
answer = merged.session;
} else if (path === '/api/airports') {
answer = merged.airports;
} else if (path === '/api/airlines') {
answer = merged.airlines;
} else if (path === '/api/dashboard') {
answer = merged.dashboard;
} else if (path === '/api/flights') {
answer = merged.flights;
} else if (path.startsWith('/api/flights/')) {
answer = merged.flightById;
} else if (path.endsWith('/cancel')) {
answer = merged.cancelBooking;
} else if (path === '/api/bookings') {
answer = method === 'POST' ? merged.createBooking : merged.bookings;
} else if (path.startsWith('/api/bookings/')) {
answer = merged.bookingById;
} else {
answer = fail(404, 'not found');
}
const { status, body, delayMs } = normalise(answer);
const wait = delayMs ?? merged.delayMs ?? 0;
if (wait > 0) {
await sleep(wait);
}
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
/**
* Puts credentials in localStorage before the app boots, so a simulated test
* lands straight on the screen under test instead of signing in first.
*/
export async function primeCredentials(page: Page, tenant = 'skyline'): Promise<void> {
await page.addInitScript(
([slug]) => {
localStorage.setItem(
'travelhub.credentials',
JSON.stringify({ tenant: slug, basic: btoa('agent@skyline.test:demo1234') }),
);
},
[tenant],
);
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Evidence capture for passing tests.
*
* Playwright keeps screenshots and traces only when something fails, which is
* the right default for debugging but useless as a record of what the suite
* actually proved. This fixture lets a test mark the moments that matter and
* record the data it observed, so a successful run produces a readable report
* rather than a row of green ticks.
*
* await evidence.step('Search results for JFK to LHR', { total: 42, fare: 483.3 });
*
* Each call attaches a screenshot plus a JSON blob. `reporters/evidence.ts`
* turns those attachments into the report.
*/
import { Page, TestInfo } from '@playwright/test';
/** Attachment name prefixes the reporter looks for. */
export const SHOT_PREFIX = 'evidence';
export const DATA_PREFIX = 'evidence-data';
export type Facts = Record<string, unknown>;
export class Evidence {
private sequence = 0;
constructor(
private readonly page: Page,
private readonly info: TestInfo,
) {}
/**
* Records a named moment: a screenshot of the current page, and optionally
* the data the test observed there.
*/
async step(name: string, facts?: Facts): Promise<void> {
const seq = this.next();
const shot = await this.page.screenshot({ fullPage: false, animations: 'disabled' });
await this.info.attach(`${SHOT_PREFIX}:${seq}:${name}`, {
body: shot,
contentType: 'image/png',
});
if (facts) {
await this.attachFacts(seq, name, facts);
}
}
/** Records data with no screenshot -- for values read across several pages. */
async note(name: string, facts: Facts): Promise<void> {
await this.attachFacts(this.next(), name, facts);
}
/**
* Records the whole scrollable page rather than the viewport. Slower, so it
* is reserved for the screens worth showing in full.
*/
async fullPage(name: string, facts?: Facts): Promise<void> {
const seq = this.next();
const shot = await this.page.screenshot({ fullPage: true, animations: 'disabled' });
await this.info.attach(`${SHOT_PREFIX}:${seq}:${name}`, {
body: shot,
contentType: 'image/png',
});
if (facts) {
await this.attachFacts(seq, name, facts);
}
}
private async attachFacts(seq: string, name: string, facts: Facts): Promise<void> {
await this.info.attach(`${DATA_PREFIX}:${seq}:${name}`, {
body: Buffer.from(JSON.stringify(facts, null, 2), 'utf8'),
contentType: 'application/json',
});
}
private next(): string {
this.sequence += 1;
return String(this.sequence).padStart(2, '0');
}
}
+88
View File
@@ -0,0 +1,88 @@
import { expect, test as base } from '@playwright/test';
import { Account } from './accounts';
import { Evidence } from './evidence';
import { BookingDetailPage, BookingsPage } from '../pages/bookings.page';
import { CheckoutPage } from '../pages/checkout.page';
import { DashboardPage } from '../pages/dashboard.page';
import { LoginPage } from '../pages/login.page';
import { SearchPage } from '../pages/search.page';
import { ShellPage } from '../pages/shell.page';
interface Fixtures {
login: LoginPage;
shell: ShellPage;
search: SearchPage;
checkout: CheckoutPage;
bookings: BookingsPage;
bookingDetail: BookingDetailPage;
dashboard: DashboardPage;
/** Signs in and lands on the dashboard. */
signInAs: (account: Account) => Promise<void>;
/** Records screenshots and observed data for the run report. */
evidence: Evidence;
/** Console and uncaught errors seen during the test; asserted empty at the end. */
consoleErrors: string[];
}
export const test = base.extend<Fixtures>({
login: async ({ page }, use) => use(new LoginPage(page)),
shell: async ({ page }, use) => use(new ShellPage(page)),
search: async ({ page }, use) => use(new SearchPage(page)),
checkout: async ({ page }, use) => use(new CheckoutPage(page)),
bookings: async ({ page }, use) => use(new BookingsPage(page)),
bookingDetail: async ({ page }, use) => use(new BookingDetailPage(page)),
dashboard: async ({ page }, use) => use(new DashboardPage(page)),
signInAs: async ({ login }, use) => {
await use(async (account: Account) => {
await login.signIn(account);
});
},
evidence: async ({ page }, use, testInfo) => {
await use(new Evidence(page, testInfo));
},
// Every test also asserts the browser console stayed quiet. A stack trace in
// the console is a real defect even when the assertions happen to pass.
consoleErrors: [
async ({ page }, use) => {
const errors: string[] = [];
page.on('console', (message) => {
if (message.type() !== 'error') {
return;
}
// The browser logs this for any non-2xx response, including the 401s
// and 404s these tests deliberately provoke. It says nothing about
// whether the application handled them, so it is not a defect signal.
if (message.text().startsWith('Failed to load resource:')) {
return;
}
errors.push(`console.error: ${message.text()}`);
});
page.on('pageerror', (error) => {
errors.push(`uncaught: ${error.message}`);
});
await use(errors);
expect(errors, 'the browser console should be clean').toEqual([]);
},
{ auto: true },
],
});
export { expect } from '@playwright/test';
/**
* A surname unique to this test run, so a booking can be found again in a list
* that other tests and the seed data also write to.
*/
export function uniqueSurname(prefix = 'E2E'): string {
return `${prefix}${Date.now().toString(36).toUpperCase()}`;
}