diff --git a/README.md b/README.md index 6bbf789c..1a0146e1 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 1598 Tests Passing 12 AI Agents + 1602 Tests Passing 12 AI Agents

@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire | OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) | | XML Processing | fast-xml-parser | Booking.com OTA XML protocol | | Package Manager | pnpm workspaces | Monorepo management | -| Testing | Vitest (1598 tests across 221 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | +| Testing | Vitest (1602 tests across 223 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# All tests (1598 tests across 221 test files) +# All tests (1602 tests across 223 test files) # API tests only pnpm --filter @telivityhaip/api test @@ -1190,7 +1190,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (1598 tests, 221 files) +pnpm test # Run all tests (1602 tests, 223 files) pnpm lint # ESLint ``` diff --git a/apps/api/src/common/crypto/confirmation-number.spec.ts b/apps/api/src/common/crypto/confirmation-number.spec.ts new file mode 100644 index 00000000..e2e1abef --- /dev/null +++ b/apps/api/src/common/crypto/confirmation-number.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it, vi } from 'vitest'; +import { generateConfirmationNumber } from './confirmation-number'; + +describe('generateConfirmationNumber', () => { + it('uses cryptographically random 128-bit entropy via an injectable seam', () => { + const entropy = vi.fn((bytes: number) => Buffer.alloc(bytes, 0xa5)); + + const confirmation = generateConfirmationNumber(entropy); + + expect(entropy).toHaveBeenCalledWith(16); + expect(confirmation).toMatch(/^HAIP-[0-9A-HJKMNP-TV-Z]{32}$/); + }); +}); diff --git a/apps/api/src/common/crypto/confirmation-number.ts b/apps/api/src/common/crypto/confirmation-number.ts new file mode 100644 index 00000000..67d56458 --- /dev/null +++ b/apps/api/src/common/crypto/confirmation-number.ts @@ -0,0 +1,29 @@ +import { randomBytes } from 'node:crypto'; + +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +export type ConfirmationEntropy = (bytes: number) => Uint8Array; + +/** A cryptographically random guest-facing bearer credential (128 bits of entropy). */ +export function generateConfirmationNumber( + entropy: ConfirmationEntropy = randomBytes, +): string { + const bytes = entropy(16); + if (bytes.length !== 16) { + throw new Error('Confirmation entropy source must return 16 bytes'); + } + let token = ''; + for (const byte of bytes) { + token += CROCKFORD[byte & 0x1f]; + token += CROCKFORD[(byte >> 5) & 0x1f]; + } + return `HAIP-${token}`; +} + +/** Crockford base32 token without the HAIP- prefix (for channel/connect prefixes). */ +export function generateConfirmationToken( + entropy: ConfirmationEntropy = randomBytes, +): string { + const confirmation = generateConfirmationNumber(entropy); + return confirmation.startsWith('HAIP-') ? confirmation.slice(5) : confirmation; +} diff --git a/apps/api/src/common/date/property-business-date.spec.ts b/apps/api/src/common/date/property-business-date.spec.ts new file mode 100644 index 00000000..177f4daf --- /dev/null +++ b/apps/api/src/common/date/property-business-date.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { calendarDateInTimeZone } from './property-business-date'; + +describe('calendarDateInTimeZone', () => { + it('returns the property calendar date at a UTC midnight boundary', () => { + // 2024-06-02 00:30 UTC is still 2024-06-01 evening in US Eastern. + const instant = new Date('2024-06-02T00:30:00.000Z'); + expect(calendarDateInTimeZone(instant, 'America/New_York')).toBe('2024-06-01'); + expect(calendarDateInTimeZone(instant, 'UTC')).toBe('2024-06-02'); + }); + + it('falls back to UTC when the timezone is invalid', () => { + const instant = new Date('2024-06-02T12:00:00.000Z'); + expect(calendarDateInTimeZone(instant, 'Not/A_Timezone')).toBe('2024-06-02'); + }); +}); diff --git a/apps/api/src/common/date/property-business-date.ts b/apps/api/src/common/date/property-business-date.ts new file mode 100644 index 00000000..20f05b8f --- /dev/null +++ b/apps/api/src/common/date/property-business-date.ts @@ -0,0 +1,18 @@ +/** Resolve a YYYY-MM-DD calendar date in an IANA timezone without server-UTC leakage. */ +export function calendarDateInTimeZone(now: Date, timeZone: string): string { + try { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(now); + const value = Object.fromEntries(parts.map((part) => [part.type, part.value])); + if (value['year'] && value['month'] && value['day']) { + return `${value['year']}-${value['month']}-${value['day']}`; + } + } catch { + // Invalid legacy timezone values fall back to UTC, matching other PMS dates. + } + return now.toISOString().slice(0, 10); +} diff --git a/apps/api/src/common/validation/is-money-string.validator.spec.ts b/apps/api/src/common/validation/is-money-string.validator.spec.ts index 90e98b5c..28028693 100644 --- a/apps/api/src/common/validation/is-money-string.validator.spec.ts +++ b/apps/api/src/common/validation/is-money-string.validator.spec.ts @@ -15,6 +15,11 @@ class Signed { amount!: string; } +class Bounded { + @IsMoneyString({ maximum: '100.00' }) + amount!: string; +} + async function fails(obj: any): Promise { const errors = await validate(obj); return errors.length > 0; @@ -42,4 +47,9 @@ describe('IsMoneyString', () => { expect(await fails(Object.assign(new Signed(), { amount: '50.00' }))).toBe(false); expect(await fails(Object.assign(new Signed(), { amount: 'nope' }))).toBe(true); }); + + it('maximum: accepts the inclusive boundary and rejects values above it', async () => { + expect(await fails(Object.assign(new Bounded(), { amount: '100.00' }))).toBe(false); + expect(await fails(Object.assign(new Bounded(), { amount: '100.01' }))).toBe(true); + }); }); diff --git a/apps/api/src/common/validation/is-money-string.validator.ts b/apps/api/src/common/validation/is-money-string.validator.ts index 71f6a9ea..1bfa5f3d 100644 --- a/apps/api/src/common/validation/is-money-string.validator.ts +++ b/apps/api/src/common/validation/is-money-string.validator.ts @@ -11,6 +11,8 @@ export interface MoneyStringOptions { allowZero?: boolean; /** Allow negative amounts (default false). Use for credit/adjustment fields. */ allowNegative?: boolean; + /** Inclusive upper bound, expressed as a decimal string. */ + maximum?: string; } @ValidatorConstraint({ name: 'isMoneyString', async: false }) @@ -27,6 +29,7 @@ class MoneyStringConstraint implements ValidatorConstraintInterface { const opts: MoneyStringOptions = args?.constraints?.[0] ?? {}; if (!opts.allowNegative && d.isNegative()) return false; if (!opts.allowZero && !opts.allowNegative && d.isZero()) return false; + if (opts.maximum != null && d.gt(new Decimal(opts.maximum))) return false; return true; } @@ -37,7 +40,9 @@ class MoneyStringConstraint implements ValidatorConstraintInterface { : opts.allowZero ? 'a non-negative numeric decimal string' : 'a positive numeric decimal string'; - return `${args?.property} must be ${bound}`; + return opts.maximum == null + ? `${args?.property} must be ${bound}` + : `${args?.property} must be ${bound} no greater than ${opts.maximum}`; } } diff --git a/apps/api/src/modules/connect/connect-booking.service.ts b/apps/api/src/modules/connect/connect-booking.service.ts index a613da4a..5e67470a 100644 --- a/apps/api/src/modules/connect/connect-booking.service.ts +++ b/apps/api/src/modules/connect/connect-booking.service.ts @@ -10,7 +10,7 @@ import { RatePlanService } from '../rate-plan/rate-plan.service'; import { PolicyService } from '../policy/policy.service'; import type { AgentBookDto } from './dto/agent-book.dto'; import type { AgentModifyDto } from './dto/agent-modify.dto'; -import { randomBytes } from 'crypto'; +import { generateConfirmationToken } from '../../common/crypto/confirmation-number'; @Injectable() export class ConnectBookingService { @@ -553,21 +553,4 @@ export class ConnectBookingService { } } -// Crockford base32 alphabet (no I/L/O/U — unambiguous when read/typed). -const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; - -/** - * 128 bits of cryptographic randomness (16 random bytes) rendered in Crockford - * base32. Unguessable — the confirmation number is a bearer credential for the - * booking, so it must not be enumerable (the old `timestamp-4hex` form had only - * ~16 bits of randomness). - */ -export function generateConfirmationToken(): string { - const bytes = randomBytes(16); - let out = ''; - for (let i = 0; i < bytes.length; i++) { - out += CROCKFORD[bytes[i]! & 0x1f]; - out += CROCKFORD[(bytes[i]! >> 5) & 0x1f]; - } - return out; -} +export { generateConfirmationToken } from '../../common/crypto/confirmation-number'; diff --git a/docs/test-stats.json b/docs/test-stats.json index 13c71e8a..53dbb9ee 100644 --- a/docs/test-stats.json +++ b/docs/test-stats.json @@ -1,5 +1,5 @@ { - "tests": 1598, - "files": 221, - "updatedAt": "2026-08-27T09:12:56.919Z" + "tests": 1602, + "files": 223, + "updatedAt": "2026-08-27T10:58:08.378Z" }