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
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* Database chores for TravelHub, driven through psql.
*
* Credentials come from backend/.env (or the real environment, which wins), so
* this works the same on a developer machine and in CI.
*
* node scripts/db.mjs create create the application database
* node scripts/db.mjs migrate apply every migration in order
* node scripts/db.mjs reset drop, recreate and migrate (destructive)
* node scripts/db.mjs test-db create the empty database the Go tests use
*/
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const MIGRATIONS = join(ROOT, 'backend', 'migrations');
// --- configuration ---------------------------------------------------------
/** Reads backend/.env without overriding anything already in the environment. */
function loadDotEnv() {
const path = join(ROOT, 'backend', '.env');
if (!existsSync(path)) {
return;
}
for (const line of readFileSync(path, 'utf8').split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const eq = trimmed.indexOf('=');
if (eq === -1) {
continue;
}
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if (value.length >= 2 && /^["'].*["']$/.test(value)) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
loadDotEnv();
const CONFIG = {
host: process.env.DB_HOST ?? 'localhost',
port: process.env.DB_PORT ?? '5432',
user: process.env.DB_USER ?? 'postgres',
password: process.env.DB_PASSWORD ?? '',
database: process.env.DB_NAME ?? 'travelhub',
testDatabase: process.env.TEST_DB_NAME ?? 'travelhub_test',
};
/**
* Finds psql. It is routinely absent from PATH on Windows, so fall back to the
* standard install location before giving up.
*/
function findPsql() {
if (process.env.PSQL) {
return process.env.PSQL;
}
try {
execFileSync('psql', ['--version'], { stdio: 'ignore' });
return 'psql';
} catch {
// Not on PATH; try the usual Windows install root, newest version first.
const base = 'C:\\Program Files\\PostgreSQL';
if (existsSync(base)) {
const versions = readdirSync(base).sort((a, b) => Number(b) - Number(a));
for (const version of versions) {
const candidate = join(base, version, 'bin', 'psql.exe');
if (existsSync(candidate)) {
return candidate;
}
}
}
}
throw new Error(
'psql was not found. Add it to PATH, or set PSQL to its full path.\n' +
'On Windows it usually lives in C:\\Program Files\\PostgreSQL\\17\\bin',
);
}
const PSQL = findPsql();
// --- psql helpers ----------------------------------------------------------
function run(args, { database = 'postgres', capture = false } = {}) {
return execFileSync(
PSQL,
['-U', CONFIG.user, '-h', CONFIG.host, '-p', CONFIG.port, '-d', database, ...args],
{
env: { ...process.env, PGPASSWORD: CONFIG.password },
encoding: 'utf8',
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
},
);
}
function databaseExists(name) {
const out = run(['-tAc', `SELECT 1 FROM pg_database WHERE datname='${name}'`], { capture: true });
return out.trim() === '1';
}
function createDatabase(name) {
if (databaseExists(name)) {
console.log(`· ${name} already exists`);
return false;
}
run(['-c', `CREATE DATABASE "${name}"`], { capture: true });
console.log(`✓ created ${name}`);
return true;
}
function dropDatabase(name) {
if (!databaseExists(name)) {
return;
}
// Existing sessions would block the drop, so close them first.
run(
[
'-c',
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname='${name}' AND pid <> pg_backend_pid()`,
],
{ capture: true },
);
run(['-c', `DROP DATABASE "${name}"`], { capture: true });
console.log(`✓ dropped ${name}`);
}
function migrate(name) {
const files = readdirSync(MIGRATIONS)
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of files) {
process.stdout.write(` applying ${file} ... `);
run(['-v', 'ON_ERROR_STOP=1', '-q', '-f', join(MIGRATIONS, file)], { database: name });
console.log('ok');
}
console.log(`✓ ${name} is up to date (${files.length} migrations)`);
}
// --- commands --------------------------------------------------------------
const commands = {
create() {
createDatabase(CONFIG.database);
},
migrate() {
createDatabase(CONFIG.database);
migrate(CONFIG.database);
},
reset() {
console.log(`Resetting ${CONFIG.database}. All data in it will be lost.`);
dropDatabase(CONFIG.database);
createDatabase(CONFIG.database);
migrate(CONFIG.database);
},
'test-db'() {
// The Go tests apply the schema themselves; they only need the database.
createDatabase(CONFIG.testDatabase);
console.log(`✓ ${CONFIG.testDatabase} ready for go test ./...`);
},
};
const command = process.argv[2];
if (!command || !(command in commands)) {
console.error(`Usage: node scripts/db.mjs <${Object.keys(commands).join('|')}>`);
process.exit(1);
}
if (!CONFIG.password) {
console.error('No database password. Set DB_PASSWORD, or fill in backend/.env.');
process.exit(1);
}
try {
commands[command]();
} catch (err) {
console.error(`\n✗ ${command} failed: ${err.message}`);
process.exit(1);
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/**
* Opens a generated report in the default browser.
*
* node scripts/open-report.mjs the evidence report
* node scripts/open-report.mjs playwright Playwright's own HTML report
*/
import { spawn } from 'node:child_process';
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const which = process.argv[2] ?? 'evidence';
const target =
which === 'playwright'
? join(ROOT, 'e2e', 'playwright-report', 'index.html')
: join(ROOT, 'e2e', 'evidence-report', 'index.html');
if (!existsSync(target)) {
console.error(`No report at ${target}\nRun the suite first: npm run test:e2e`);
process.exit(1);
}
// Each platform has its own opener; none of them need a dependency.
const [command, args] =
process.platform === 'win32'
? ['cmd', ['/c', 'start', '', target]]
: process.platform === 'darwin'
? ['open', [target]]
: ['xdg-open', [target]];
spawn(command, args, { detached: true, stdio: 'ignore' }).unref();
console.log(`Opened ${target}`);
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
/**
* Serves the evidence report over HTTP.
*
* The report is a plain folder of files and opens perfectly well from disk, but
* a localhost URL is easier to share with a colleague on the same machine, and
* some browsers treat file:// pages more suspiciously than http://.
*
* node scripts/serve-report.mjs http://localhost:9400
* node scripts/serve-report.mjs --port 8123
* node scripts/serve-report.mjs --no-open
*/
import { createServer } from 'node:http';
import { createReadStream, existsSync, statSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { dirname, extname, join, normalize, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const DIR = join(ROOT, 'e2e', 'evidence-report');
const args = process.argv.slice(2);
const portFlag = args.indexOf('--port');
const PORT = portFlag !== -1 ? Number(args[portFlag + 1]) : 9400;
const shouldOpen = !args.includes('--no-open');
if (!existsSync(join(DIR, 'index.html'))) {
console.error(`No report at ${DIR}\nRun the suite first: npm run test:e2e`);
process.exit(1);
}
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.md': 'text/markdown; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.pdf': 'application/pdf',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
};
const server = createServer((req, res) => {
const requested = decodeURIComponent((req.url ?? '/').split('?')[0]);
const relative = requested === '/' ? 'index.html' : requested.replace(/^\/+/, '');
// normalize collapses any ".." before it can escape the report directory.
const target = join(DIR, normalize(relative));
if (!target.startsWith(DIR)) {
res.writeHead(403).end('Forbidden');
return;
}
if (!existsSync(target) || statSync(target).isDirectory()) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`Not found: ${relative}\n\nThis server only holds the evidence report.`);
return;
}
res.writeHead(200, {
'Content-Type': TYPES[extname(target).toLowerCase()] ?? 'application/octet-stream',
'Cache-Control': 'no-store',
});
createReadStream(target).pipe(res);
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
console.error(
`Port ${PORT} is already in use.\n` +
`Try another: npm run report:serve -- --port ${PORT + 1}`,
);
process.exit(1);
}
throw err;
});
server.listen(PORT, () => {
const url = `http://localhost:${PORT}/`;
console.log(
[
'',
` Test report: ${url}`,
` PDF: ${url}TravelHub-test-report.pdf`,
` Markdown: ${url}report.md`,
'',
' Press Ctrl+C to stop.',
'',
].join('\n'),
);
if (shouldOpen) {
const [cmd, cmdArgs] =
process.platform === 'win32'
? ['cmd', ['/c', 'start', '', url]]
: process.platform === 'darwin'
? ['open', [url]]
: ['xdg-open', [url]];
spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' }).unref();
}
});
+317
View File
@@ -0,0 +1,317 @@
#!/usr/bin/env node
/**
* Generates TEST-CATALOG.md by reading the test files themselves.
*
* Static parsing rather than running the suites: the catalogue can then be
* regenerated in a second, with no database and no browser, which is the only
* way a document like this stays current.
*
* node scripts/test-catalog.mjs write TEST-CATALOG.md
* node scripts/test-catalog.mjs --check fail if it is out of date (CI)
* node scripts/test-catalog.mjs --stdout print instead of writing
*/
import { readdirSync, readFileSync, statSync, writeFileSync, existsSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const OUT = join(ROOT, 'TEST-CATALOG.md');
// --- file discovery --------------------------------------------------------
function walk(dir, match, found = []) {
if (!existsSync(dir)) {
return found;
}
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('.')) {
continue;
}
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full, match, found);
} else if (match(entry)) {
found.push(full);
}
}
return found;
}
const posix = (p) => relative(ROOT, p).split(sep).join('/');
// --- Go ---------------------------------------------------------------------
/** Turns TestCreateBookingSellsSeats into "Create booking sells seats". */
function humanizeGo(name) {
const body = name.replace(/^Test/, '');
const words = body
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.toLowerCase()
.trim();
return words.charAt(0).toUpperCase() + words.slice(1);
}
function parseGo(file) {
const src = readFileSync(file, 'utf8');
const lines = src.split('\n');
const tests = [];
let current = null;
let buffer = [];
const flush = () => {
if (!current) {
return;
}
current.subtests = subtestsFrom(buffer.join('\n'));
tests.push(current);
current = null;
buffer = [];
};
for (const line of lines) {
const start = /^func (Test\w+)\(t \*testing\.T\)/.exec(line);
if (start) {
flush();
current = { name: start[1], label: humanizeGo(start[1]), subtests: [] };
continue;
}
if (/^func /.test(line)) {
flush();
continue;
}
if (current) {
buffer.push(line);
}
}
flush();
return tests;
}
/**
* Pulls subtest names out of a function body. Covers the three shapes used
* here: a literal t.Run, a table with a `name:` field, and a positional table
* whose first column is the subtest name.
*/
function subtestsFrom(body) {
const names = new Set();
for (const m of body.matchAll(/t\.Run\(\s*"([^"]+)"/g)) {
names.add(m[1]);
}
for (const m of body.matchAll(/\bname:\s*"([^"]+)"/g)) {
names.add(m[1]);
}
// Positional table rows: {"price", "CHEAPSLOW"},
const usesPositional = /t\.Run\(\s*tc\.(\w+)/.test(body) && !/\bname:/.test(body);
if (usesPositional) {
for (const m of body.matchAll(/^\s*\{"([^"]+)",/gm)) {
names.add(m[1]);
}
}
// A table of awkward values run by their own value, e.g. passwords.
for (const m of body.matchAll(/t\.Run\(\s*(\w+),\s*func/g)) {
void m; // the values are not statically knowable; counted as one case
}
return [...names];
}
// --- TypeScript (Vitest and Playwright) -------------------------------------
/**
* Walks a spec file tracking describe nesting by brace depth, so each test is
* reported under the describes that contain it.
*/
function parseSpec(file) {
const src = readFileSync(file, 'utf8');
const lines = src.split('\n');
const groups = [];
const stack = [];
let depth = 0;
const DESCRIBE = /^\s*(?:test\.)?describe(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/;
const CASE = /^\s*(?:it|test)(?:\.\w+)?\s*\(\s*(['"`])(.+?)\1/;
for (const raw of lines) {
const line = raw.replace(/\/\/.*$/, '');
const describe = DESCRIBE.exec(line);
if (describe) {
stack.push({ title: describe[2], depth });
} else {
const testCase = CASE.exec(line);
if (testCase) {
groups.push({ suite: stack.map((s) => s.title).join(' › '), title: testCase[2] });
}
}
depth += (line.match(/\{/g) ?? []).length;
depth -= (line.match(/\}/g) ?? []).length;
while (stack.length && depth <= stack[stack.length - 1].depth) {
stack.pop();
}
}
return groups;
}
// --- rendering --------------------------------------------------------------
function goSection(files) {
const out = [];
let functions = 0;
let cases = 0;
for (const file of files) {
const tests = parseGo(file);
if (tests.length === 0) {
continue;
}
const fileCases = tests.reduce((n, t) => n + Math.max(1, t.subtests.length), 0);
functions += tests.length;
cases += fileCases;
out.push(`### \`${posix(file)}\``, '');
out.push(`${tests.length} tests, ${fileCases} cases.`, '');
for (const test of tests) {
out.push(`- **${test.label}** — \`${test.name}\``);
for (const sub of test.subtests) {
out.push(` - ${sub}`);
}
}
out.push('');
}
return { lines: out, functions, cases };
}
function specSection(files, label) {
const out = [];
let cases = 0;
for (const file of files) {
const found = parseSpec(file);
if (found.length === 0) {
continue;
}
cases += found.length;
out.push(`### \`${posix(file)}\``, '');
out.push(`${found.length} ${found.length === 1 ? 'test' : 'tests'}.`, '');
let lastSuite = null;
for (const item of found) {
if (item.suite !== lastSuite) {
if (item.suite) {
out.push(`- **${item.suite}**`);
}
lastSuite = item.suite;
}
out.push(item.suite ? ` - ${item.title}` : `- ${item.title}`);
}
out.push('');
}
return { lines: out, cases, label };
}
// --- assemble ---------------------------------------------------------------
const goFiles = walk(join(ROOT, 'backend'), (f) => f.endsWith('_test.go')).sort();
const webFiles = walk(join(ROOT, 'frontend', 'src'), (f) => f.endsWith('.spec.ts')).sort();
const e2eFiles = walk(join(ROOT, 'e2e', 'tests'), (f) => f.endsWith('.spec.ts')).sort();
const go = goSection(goFiles);
const web = specSection(webFiles, 'Angular');
const e2e = specSection(e2eFiles, 'Playwright');
const total = go.cases + web.cases + e2e.cases;
const files = goFiles.length + webFiles.length + e2eFiles.length;
// Specs that a second Playwright project re-runs under a mobile viewport.
const REPEATED_ON_MOBILE = /(auth|responsive)\.spec\.ts$/;
const mobileRepeats = e2eFiles
.filter((f) => REPEATED_ON_MOBILE.test(f))
.reduce((n, f) => n + parseSpec(f).length, 0);
const doc = [
'# TravelHub test catalogue',
'',
'> Generated by `npm run docs:tests` from the test sources.',
'> Do not edit by hand — regenerate instead.',
'',
`**${total} test cases** across ${files} files.`,
'',
'| Suite | Files | Cases | Command |',
'| ----- | ----: | ----: | ------- |',
`| [Go](#go-backend) | ${goFiles.length} | ${go.cases} | \`npm run test:go\` |`,
`| [Angular](#angular-frontend) | ${webFiles.length} | ${web.cases} | \`npm run test:web\` |`,
`| [Playwright](#playwright-end-to-end) | ${e2eFiles.length} | ${e2e.cases} | \`npm run test:e2e\` |`,
`| **Total** | **${files}** | **${total}** | \`npm test\` |`,
'',
'### Why the runners report different numbers',
'',
'A *case* below is one thing being tested. The runners count differently, so',
'both figures are given rather than one being quietly preferred:',
'',
`- **Go** reports ${go.functions} test functions plus each table-driven subtest`,
' separately, so `go test -v` prints more `PASS` lines than there are cases',
' here. A few tables build their subtest names at runtime and cannot be',
' listed statically; those appear as a single case.',
`- **Playwright** re-runs \`auth\` and \`responsive\` (${mobileRepeats} tests) under a second,`,
` mobile viewport, so a full run executes ${e2e.cases + mobileRepeats} tests from ${e2e.cases} declared.`,
'- **Angular** counts exactly as listed.',
'',
'Running a single file:',
'',
'```bash',
'cd backend && go test ./internal/repository/ -run TestCreateBookingSellsSeats -v',
'cd frontend && npx ng test --no-watch # all specs',
'cd e2e && npx playwright test tests/booking.spec.ts',
'```',
'',
'---',
'',
'## Go (backend)',
'',
`${go.functions} test functions covering ${go.cases} cases. These run against a real`,
'PostgreSQL, with a fresh tenant created and dropped per test.',
'',
...go.lines,
'---',
'',
'## Angular (frontend)',
'',
`${web.cases} tests. Vitest with jsdom, via \`ng test\`.`,
'',
...web.lines,
'---',
'',
'## Playwright (end to end)',
'',
`${e2e.cases} tests, each run against a real browser and the real stack.`,
'',
...e2e.lines,
].join('\n');
// --- output -----------------------------------------------------------------
if (process.argv.includes('--stdout')) {
process.stdout.write(doc);
} else if (process.argv.includes('--check')) {
const existing = existsSync(OUT) ? readFileSync(OUT, 'utf8') : '';
if (existing.trim() !== doc.trim()) {
console.error('✗ TEST-CATALOG.md is out of date. Run: npm run docs:tests');
process.exit(1);
}
console.log('✓ TEST-CATALOG.md is up to date');
} else {
writeFileSync(OUT, doc + '\n', 'utf8');
console.log(`✓ wrote ${posix(OUT)} — ${total} cases`);
console.log(` Go ${go.cases} · Angular ${web.cases} · Playwright ${e2e.cases}`);
}