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,187 @@
|
||||
/**
|
||||
* The point of the product: two agencies share one deployment and one database,
|
||||
* and neither can see the other's inventory, customers or revenue.
|
||||
*
|
||||
* These assertions are made through the UI, the way a customer would notice a
|
||||
* leak, rather than against the API.
|
||||
*/
|
||||
import { expect, test, uniqueSurname } from '../fixtures/test';
|
||||
import { GLOBETROTTER_AGENT, SKYLINE_AGENT } from '../fixtures/accounts';
|
||||
|
||||
test.describe('Tenant isolation', () => {
|
||||
test('the same route is priced per agency', async ({
|
||||
signInAs,
|
||||
shell,
|
||||
search,
|
||||
login,
|
||||
evidence,
|
||||
}) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
const skylineFare = await search.card(0).fare();
|
||||
|
||||
await evidence.step('Skyline Travel: cheapest JFK to LHR', {
|
||||
agency: 'Skyline Travel',
|
||||
route: 'JFK to LHR',
|
||||
cheapestFare: `$${skylineFare.toFixed(2)}`,
|
||||
resultCount: await search.totalResults(),
|
||||
});
|
||||
|
||||
await shell.signOutAndWait();
|
||||
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.searchRoute('JFK', 'LHR');
|
||||
await search.sortBy('price');
|
||||
const globetrotterFare = await search.card(0).fare();
|
||||
|
||||
await evidence.step('Globetrotter Tours: same route, its own fare', {
|
||||
agency: 'Globetrotter Tours',
|
||||
route: 'JFK to LHR',
|
||||
cheapestFare: `$${globetrotterFare.toFixed(2)}`,
|
||||
resultCount: await search.totalResults(),
|
||||
});
|
||||
|
||||
await evidence.note('Per-tenant pricing confirmed', {
|
||||
skyline: `$${skylineFare.toFixed(2)}`,
|
||||
globetrotter: `$${globetrotterFare.toFixed(2)}`,
|
||||
difference: `$${(globetrotterFare - skylineFare).toFixed(2)}`,
|
||||
markup: `${(((globetrotterFare - skylineFare) / skylineFare) * 100).toFixed(1)}%`,
|
||||
});
|
||||
|
||||
expect(skylineFare).toBeGreaterThan(0);
|
||||
expect(globetrotterFare).toBeGreaterThan(0);
|
||||
// Globetrotter sells the same seats at a negotiated markup.
|
||||
expect(globetrotterFare).not.toBeCloseTo(skylineFare, 2);
|
||||
expect(globetrotterFare).toBeGreaterThan(skylineFare);
|
||||
});
|
||||
|
||||
test('the sidebar always names the agency you are working in', async ({
|
||||
signInAs,
|
||||
shell,
|
||||
login,
|
||||
}) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await expect(shell.agencyName).toHaveText('Skyline Travel');
|
||||
await expect(shell.agencySlug).toHaveText('skyline');
|
||||
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
|
||||
await expect(shell.agencyName).toHaveText('Globetrotter Tours');
|
||||
await expect(shell.agencySlug).toHaveText('globetrotter');
|
||||
});
|
||||
|
||||
test('one agency never sees another agency booking', async ({
|
||||
signInAs,
|
||||
login,
|
||||
shell,
|
||||
search,
|
||||
checkout,
|
||||
bookings,
|
||||
bookingDetail,
|
||||
page,
|
||||
evidence,
|
||||
}) => {
|
||||
const surname = uniqueSurname('ISO');
|
||||
|
||||
// --- Skyline sells a ticket --------------------------------------------
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
await checkout.waitUntilReady();
|
||||
await checkout.fillTravellers([{ firstName: 'Iris', lastName: surname }]);
|
||||
await checkout.confirmBooking();
|
||||
|
||||
const reference = await bookingDetail.bookingReference();
|
||||
const bookingUrl = page.url();
|
||||
|
||||
await evidence.step('Skyline sells a ticket', {
|
||||
agency: 'Skyline Travel',
|
||||
bookingReference: reference,
|
||||
passenger: `Iris ${surname}`,
|
||||
bookingUrl,
|
||||
});
|
||||
|
||||
await shell.signOutAndWait();
|
||||
|
||||
// --- Globetrotter must not find it -------------------------------------
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await shell.goToBookings();
|
||||
|
||||
await expect(bookings.rowFor(reference)).toHaveCount(0);
|
||||
expect(await page.content()).not.toContain(surname);
|
||||
|
||||
await evidence.step('Globetrotter cannot see it in their bookings', {
|
||||
signedInAs: 'Globetrotter Tours',
|
||||
searchedFor: reference,
|
||||
rowsFound: 0,
|
||||
passengerNameOnPage: false,
|
||||
});
|
||||
|
||||
// Not even by deep-linking straight at the booking's own URL.
|
||||
await page.goto(bookingUrl);
|
||||
await expect(page.getByText('Booking not found')).toBeVisible();
|
||||
expect(await page.content()).not.toContain(surname);
|
||||
|
||||
await evidence.step('Deep link to the booking URL is refused', {
|
||||
signedInAs: 'Globetrotter Tours',
|
||||
requestedUrl: bookingUrl,
|
||||
result: 'Booking not found',
|
||||
dataLeaked: false,
|
||||
});
|
||||
|
||||
// --- clean up as the owning agency -------------------------------------
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await shell.goToBookings();
|
||||
await bookings.open(reference);
|
||||
await bookingDetail.cancel(reference);
|
||||
});
|
||||
|
||||
test('revenue figures are per agency', async ({ signInAs, login, shell, dashboard }) => {
|
||||
await signInAs(SKYLINE_AGENT);
|
||||
await dashboard.settled();
|
||||
const skylineRevenue = await dashboard.tileValue('Revenue');
|
||||
const skylineBookings = await dashboard.tileValue('Bookings');
|
||||
|
||||
await shell.signOutAndWait();
|
||||
await login.signIn(GLOBETROTTER_AGENT);
|
||||
await dashboard.settled();
|
||||
const globetrotterRevenue = await dashboard.tileValue('Revenue');
|
||||
const globetrotterBookings = await dashboard.tileValue('Bookings');
|
||||
|
||||
expect(skylineRevenue).toBeGreaterThan(0);
|
||||
expect(globetrotterRevenue).toBeGreaterThan(0);
|
||||
// Separate books: the seeded agencies sold different volumes.
|
||||
expect(skylineRevenue).not.toBe(globetrotterRevenue);
|
||||
expect(skylineBookings).not.toBe(globetrotterBookings);
|
||||
});
|
||||
|
||||
test('a deep link to another agency flight is refused', async ({
|
||||
signInAs,
|
||||
login,
|
||||
shell,
|
||||
search,
|
||||
page,
|
||||
}) => {
|
||||
// Capture a flight id that belongs to Globetrotter.
|
||||
await signInAs(GLOBETROTTER_AGENT);
|
||||
await shell.goToSearch();
|
||||
await search.card(0).selectButton.click();
|
||||
await page.waitForURL(/\/book\//);
|
||||
const globetrotterBookUrl = page.url();
|
||||
|
||||
await page.goto('/dashboard');
|
||||
await shell.signOutAndWait();
|
||||
|
||||
// Skyline holds a valid id, and still cannot reach it.
|
||||
await login.signIn(SKYLINE_AGENT);
|
||||
await page.goto(globetrotterBookUrl);
|
||||
|
||||
await expect(page.getByText('Flight not available')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user