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
+96
View File
@@ -0,0 +1,96 @@
import { expect, Locator, Page } from '@playwright/test';
/** The bookings list. */
export class BookingsPage {
readonly rows: Locator;
readonly emptyState: Locator;
readonly filterAll: Locator;
readonly filterConfirmed: Locator;
readonly filterCancelled: Locator;
constructor(private readonly page: Page) {
this.rows = page.locator('table.data tbody tr');
this.emptyState = page.locator('.empty');
this.filterAll = page.locator('.filter', { hasText: 'All' });
this.filterConfirmed = page.locator('.filter', { hasText: 'Confirmed' });
this.filterCancelled = page.locator('.filter', { hasText: 'Cancelled' });
}
async goto(): Promise<void> {
await this.page.goto('/bookings');
await this.settled();
}
/** Waits for either a populated table or the empty state. */
async settled(): Promise<void> {
await expect(this.page.locator('.filters')).toBeVisible();
await expect(this.page.locator('.skeleton')).toHaveCount(0);
}
rowFor(reference: string): Locator {
return this.rows.filter({ hasText: reference });
}
async open(reference: string): Promise<void> {
await this.rowFor(reference).getByRole('link', { name: 'View' }).click();
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
}
async filterBy(status: 'all' | 'confirmed' | 'cancelled'): Promise<void> {
const button = {
all: this.filterAll,
confirmed: this.filterConfirmed,
cancelled: this.filterCancelled,
}[status];
await button.click();
await this.settled();
}
}
/** A single booking, with its cancel flow. */
export class BookingDetailPage {
readonly confirmationBanner: Locator;
readonly reference: Locator;
readonly statusBadge: Locator;
readonly travellerRows: Locator;
readonly totalPaid: Locator;
readonly cancelButton: Locator;
readonly confirmCancelButton: Locator;
readonly keepButton: Locator;
readonly cancelledNotice: Locator;
constructor(page: Page) {
this.confirmationBanner = page.locator('.confirmed-banner');
this.reference = page.locator('.sum-list div', { hasText: 'Reference' }).locator('dd');
this.statusBadge = page.locator('.head-actions .badge');
this.travellerRows = page.locator('table.data tbody tr');
this.totalPaid = page.locator('.total-row strong');
this.cancelButton = page.getByRole('button', { name: 'Cancel booking' });
this.confirmCancelButton = page.getByRole('button', { name: /Yes, cancel booking|Cancelling/ });
this.keepButton = page.getByRole('button', { name: 'Keep it' });
this.cancelledNotice = page.locator('.alert-info');
}
async bookingReference(): Promise<string> {
return (await this.reference.innerText()).trim();
}
/**
* Clicks through the two-step cancel confirmation.
*
* The caller must name the booking it means to void. Cancelling is
* destructive and these tests share a database with seeded demo data, so the
* page object refuses to act on whatever booking happens to be on screen
* after a mis-step earlier in the test.
*/
async cancel(expectedReference: string): Promise<void> {
await expect(
this.reference,
`refusing to cancel: expected booking ${expectedReference} on screen`,
).toHaveText(expectedReference);
await this.cancelButton.click();
await this.confirmCancelButton.click();
await expect(this.cancelledNotice).toBeVisible();
}
}
+76
View File
@@ -0,0 +1,76 @@
import { expect, Locator, Page } from '@playwright/test';
export interface Traveller {
firstName: string;
lastName: string;
dateOfBirth?: string;
passportNumber?: string;
}
/** The booking form. */
export class CheckoutPage {
readonly travellerFieldsets: Locator;
readonly addTraveller: Locator;
readonly contactEmail: Locator;
readonly contactPhone: Locator;
readonly confirm: Locator;
readonly total: Locator;
readonly error: Locator;
readonly seatsLeft: Locator;
constructor(private readonly page: Page) {
this.travellerFieldsets = page.locator('.pax');
this.addTraveller = page.getByRole('button', { name: '+ Add traveller' });
this.contactEmail = page.locator('#contactEmail');
this.contactPhone = page.locator('#contactPhone');
this.confirm = page.getByRole('button', { name: /Confirm booking|Confirming/ });
this.total = page.locator('.fare-row.total strong');
this.error = page.locator('.alert-error');
this.seatsLeft = page.locator('.sum-list div', { hasText: 'Seats left' }).locator('dd');
}
async waitUntilReady(): Promise<void> {
await expect(this.travellerFieldsets.first()).toBeVisible();
}
async totalAmount(): Promise<number> {
const text = await this.total.innerText();
return Number(text.replace(/[^0-9.]/g, ''));
}
/** Fills one traveller block, adding a row first if necessary. */
async fillTraveller(index: number, traveller: Traveller): Promise<void> {
// Wait for each added row to render before deciding whether another is
// needed -- re-counting immediately races Angular and adds one too many.
let count = await this.travellerFieldsets.count();
while (count <= index) {
await this.addTraveller.click();
count += 1;
await expect(this.travellerFieldsets).toHaveCount(count);
}
await this.page.locator(`#first-${index}`).fill(traveller.firstName);
await this.page.locator(`#last-${index}`).fill(traveller.lastName);
if (traveller.dateOfBirth) {
await this.page.locator(`#dob-${index}`).fill(traveller.dateOfBirth);
}
if (traveller.passportNumber) {
await this.page.locator(`#passport-${index}`).fill(traveller.passportNumber);
}
}
async fillTravellers(travellers: Traveller[]): Promise<void> {
for (let i = 0; i < travellers.length; i++) {
await this.fillTraveller(i, travellers[i]);
}
}
async removeTraveller(index: number): Promise<void> {
await this.travellerFieldsets.nth(index).getByRole('button', { name: 'Remove' }).click();
}
/** Confirms and waits for the confirmation screen. */
async confirmBooking(): Promise<void> {
await this.confirm.click();
await this.page.waitForURL(/\/bookings\/[0-9a-f-]+/);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { expect, Locator, Page } from '@playwright/test';
/** The agency overview. */
export class DashboardPage {
readonly greeting: Locator;
readonly tiles: Locator;
readonly recentRows: Locator;
readonly topRoutes: Locator;
constructor(private readonly page: Page) {
this.greeting = page.locator('h1');
this.tiles = page.locator('.tile');
this.recentRows = page.locator('table.data tbody tr');
this.topRoutes = page.locator('.route-row');
}
async goto(): Promise<void> {
await this.page.goto('/dashboard');
await this.settled();
}
async settled(): Promise<void> {
await expect(this.page.locator('.skeleton')).toHaveCount(0);
await expect(this.tiles.first()).toBeVisible();
}
/** Reads one headline figure by its label, e.g. "Revenue" or "Seats sold". */
async tileValue(label: string): Promise<number> {
const tile = this.tiles.filter({ has: this.page.locator('.tile-label', { hasText: label }) });
const text = await tile.locator('.tile-value').innerText();
return Number(text.replace(/[^0-9.]/g, ''));
}
}
+56
View File
@@ -0,0 +1,56 @@
import { expect, Locator, Page } from '@playwright/test';
import { Account } from '../fixtures/accounts';
/** The sign-in screen: agency picker, credentials, demo shortcuts. */
export class LoginPage {
readonly tenantSelect: Locator;
readonly email: Locator;
readonly password: Locator;
readonly submit: Locator;
readonly error: Locator;
readonly demoChips: Locator;
constructor(private readonly page: Page) {
this.tenantSelect = page.locator('#tenant');
this.email = page.locator('#email');
this.password = page.locator('#password');
// Matched by role rather than label: the button relabels itself to
// "Signing in…" while a request is in flight, and tests assert on that.
this.submit = page.locator('form button[type="submit"]');
this.error = page.locator('.alert-error');
this.demoChips = page.locator('.demo-chip');
}
async goto(): Promise<void> {
await this.page.goto('/login');
await expect(this.submit).toBeVisible();
}
/** Fills the form from the demo shortcut for the given account. */
async useDemoChip(account: Account): Promise<void> {
await this.demoChips.filter({ hasText: account.email }).first().click();
}
async fill(account: Account, password = account.password): Promise<void> {
await this.tenantSelect.selectOption(account.tenant);
await this.email.fill(account.email);
await this.password.fill(password);
}
/** Signs in and waits for the console to appear. */
async signIn(account: Account): Promise<void> {
await this.goto();
await this.useDemoChip(account);
await this.submit.click();
await this.page.waitForURL('**/dashboard');
}
/** Attempts a sign-in that is expected to fail, and returns the message. */
async signInExpectingFailure(account: Account, password: string): Promise<string> {
await this.fill(account, password);
await this.submit.click();
await expect(this.error).toBeVisible();
return (await this.error.innerText()).trim();
}
}
+119
View File
@@ -0,0 +1,119 @@
import { expect, Locator, Page } from '@playwright/test';
/** One flight result card. */
export class FlightCard {
constructor(readonly root: Locator) {}
get price(): Locator {
return this.root.locator('.price');
}
get seatsBadge(): Locator {
return this.root.locator('.fare-tags .badge').last();
}
get selectButton(): Locator {
return this.root.getByRole('button', { name: 'Select' });
}
async fare(): Promise<number> {
const text = (await this.price.innerText()).replace(/[^0-9.]/g, '');
return Number(text);
}
async seatsLeft(): Promise<number> {
const text = await this.seatsBadge.innerText();
return Number(text.replace(/[^0-9]/g, ''));
}
async flightNumber(): Promise<string> {
return (await this.root.locator('.carrier-meta').innerText()).split('·')[0].trim();
}
async durationMinutes(): Promise<number> {
const text = await this.root.locator('.leg-duration').innerText();
const match = /(?:(\d+)h\s*)?(\d+)m/.exec(text);
if (!match) {
throw new Error(`unparseable duration: ${text}`);
}
return Number(match[1] ?? 0) * 60 + Number(match[2]);
}
}
/** The flight search screen. */
export class SearchPage {
readonly origin: Locator;
readonly destination: Locator;
readonly date: Locator;
readonly cabin: Locator;
readonly travellers: Locator;
readonly searchButton: Locator;
readonly resetButton: Locator;
readonly sort: Locator;
readonly resultSummary: Locator;
readonly results: Locator;
readonly emptyState: Locator;
readonly nextPage: Locator;
readonly prevPage: Locator;
constructor(private readonly page: Page) {
this.origin = page.locator('#origin');
this.destination = page.locator('#destination');
this.date = page.locator('#date');
this.cabin = page.locator('#cabin');
this.travellers = page.locator('#passengers');
this.searchButton = page.getByRole('button', { name: 'Search', exact: true });
this.resetButton = page.getByRole('button', { name: 'Reset' });
this.sort = page.locator('.sort select');
this.resultSummary = page.locator('.results-bar span').first();
this.results = page.locator('.flight');
this.emptyState = page.locator('.empty');
this.nextPage = page.getByRole('button', { name: 'Next →' });
this.prevPage = page.getByRole('button', { name: '← Previous' });
}
async goto(): Promise<void> {
await this.page.goto('/search');
await this.settled();
}
card(index = 0): FlightCard {
return new FlightCard(this.results.nth(index));
}
/** Waits for the result list to stop showing skeletons. */
async settled(): Promise<void> {
await expect(this.page.locator('.skeleton-card')).toHaveCount(0);
await expect(this.resultSummary).not.toHaveText(/Searching/);
}
async searchRoute(from: string, to: string): Promise<void> {
await this.origin.selectOption(from);
await this.destination.selectOption(to);
await this.searchButton.click();
await this.settled();
}
async setTravellers(n: number): Promise<void> {
await this.travellers.selectOption(String(n));
await this.searchButton.click();
await this.settled();
}
async sortBy(value: 'price' | 'duration' | 'departure'): Promise<void> {
await this.sort.selectOption(value);
await this.settled();
}
/** Reads the "Showing 1-10 of 42 flights" line. */
async totalResults(): Promise<number> {
const text = await this.resultSummary.innerText();
const match = /of (\d+) flights/.exec(text);
return match ? Number(match[1]) : 0;
}
/** Finds the card for a given flight number on the current page. */
cardForFlight(flightNumber: string): FlightCard {
return new FlightCard(this.results.filter({ hasText: flightNumber }).first());
}
}
+43
View File
@@ -0,0 +1,43 @@
import { Locator, Page } from '@playwright/test';
/** The signed-in chrome: sidebar navigation and the account bar. */
export class ShellPage {
readonly agencyName: Locator;
readonly agencySlug: Locator;
readonly accountName: Locator;
readonly accountMeta: Locator;
readonly signOut: Locator;
constructor(private readonly page: Page) {
this.agencyName = page.locator('.tenant-name');
this.agencySlug = page.locator('.tenant-slug');
this.accountName = page.locator('.account-name');
this.accountMeta = page.locator('.account-meta');
this.signOut = page.getByRole('button', { name: 'Sign out' });
}
/**
* Navigates by sidebar link. The href selector is deliberate: several screens
* also carry a "Search flights" call-to-action button, and matching by role
* and name alone would be ambiguous.
*/
async goToDashboard(): Promise<void> {
await this.page.locator('.nav-link[href="/dashboard"]').click();
await this.page.waitForURL('**/dashboard');
}
async goToSearch(): Promise<void> {
await this.page.locator('.nav-link[href="/search"]').click();
await this.page.waitForURL('**/search');
}
async goToBookings(): Promise<void> {
await this.page.locator('.nav-link[href="/bookings"]').click();
await this.page.waitForURL('**/bookings');
}
async signOutAndWait(): Promise<void> {
await this.signOut.click();
await this.page.waitForURL('**/login');
}
}