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>
188 lines
6.1 KiB
TypeScript
188 lines
6.1 KiB
TypeScript
/**
|
|
* 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();
|
|
});
|
|
});
|