From 4edadceb7d41445cba4b46064d9a2db907f486bb Mon Sep 17 00:00:00 2001 From: updateboi Date: Sun, 26 Jul 2026 13:30:44 +0100 Subject: [PATCH 01/25] feat: cursor pagination, XSS sanitization, location privacy, hCaptcha Closes #329 Closes #330 Closes #331 Closes #332 - Cursor-based keyset pagination for notifications and bookings; useNotifications and useDashboard updated for infinite scroll (#329) - Sanitize all UGC on write: property descriptions, review comments, host responses; strips HTML/dangerous protocols, enforces length limits (#330) - Obfuscate exact coordinates on public property responses; reveal precise pin only to confirmed tenants and hosts; PropertyMap renders area circle vs pin (#331) - hCaptcha middleware on register/login/password-reset; server-side verification; HCAPTCHA_ENABLED=false bypass for dev; fail-closed in production (#332) --- apps/backend/.env.example | 6 + apps/backend/src/__tests__/captcha.test.ts | 201 ++++++++++++++++ .../src/__tests__/cursor-pagination.test.ts | 217 +++++++++++++++++ .../src/__tests__/location-privacy.test.ts | 170 +++++++++++++ apps/backend/src/__tests__/sanitize.test.ts | 225 ++++++++++++++++++ apps/backend/src/config/env.ts | 3 + .../src/controllers/booking.controller.ts | 24 ++ .../controllers/notification.controller.ts | 24 ++ .../src/controllers/property.controller.ts | 157 ++++++++---- .../src/middleware/captcha.middleware.ts | 111 +++++++++ apps/backend/src/routes/auth.routes.ts | 13 +- apps/backend/src/routes/booking.routes.ts | 4 + apps/backend/src/services/booking.service.ts | 51 ++++ .../src/services/notification.service.ts | 50 ++++ apps/backend/src/services/property.service.ts | 30 ++- apps/backend/src/services/review.service.ts | 21 +- apps/backend/src/utils/cursor.ts | 69 ++++++ apps/backend/src/utils/locationPrivacy.ts | 89 +++++++ apps/backend/src/utils/sanitize.ts | 130 ++++++++++ apps/web/src/components/auth/HCaptcha.tsx | 95 ++++++++ apps/web/src/components/auth/LoginForm.tsx | 32 ++- apps/web/src/components/auth/RegisterForm.tsx | 32 ++- .../features/properties/PropertyMap.tsx | 164 ++++++++----- apps/web/src/hooks/useDashboard.ts | 110 +++++++-- apps/web/src/hooks/useNotifications.ts | 137 ++++++++--- 25 files changed, 1993 insertions(+), 172 deletions(-) create mode 100644 apps/backend/src/__tests__/captcha.test.ts create mode 100644 apps/backend/src/__tests__/cursor-pagination.test.ts create mode 100644 apps/backend/src/__tests__/location-privacy.test.ts create mode 100644 apps/backend/src/__tests__/sanitize.test.ts create mode 100644 apps/backend/src/middleware/captcha.middleware.ts create mode 100644 apps/backend/src/utils/cursor.ts create mode 100644 apps/backend/src/utils/locationPrivacy.ts create mode 100644 apps/backend/src/utils/sanitize.ts create mode 100644 apps/web/src/components/auth/HCaptcha.tsx diff --git a/apps/backend/.env.example b/apps/backend/.env.example index e5cb035..947c80f 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -33,6 +33,12 @@ REDIS_URL=redis://localhost:6379 # Geocoding (optional — defaults to OpenStreetMap Nominatim when not set) GEOCODING_API_KEY=your_geocoding_api_key +# hCaptcha bot protection +# Get keys at https://dashboard.hcaptcha.com/ +# Set HCAPTCHA_ENABLED=false to bypass verification in local/dev/CI environments +HCAPTCHA_SECRET_KEY=your_hcaptcha_secret_key +HCAPTCHA_ENABLED=true + # Email (optional — transactional emails via SMTP) SMTP_HOST=smtp.example.com SMTP_PORT=587 diff --git a/apps/backend/src/__tests__/captcha.test.ts b/apps/backend/src/__tests__/captcha.test.ts new file mode 100644 index 0000000..d40f434 --- /dev/null +++ b/apps/backend/src/__tests__/captcha.test.ts @@ -0,0 +1,201 @@ +/** + * Tests for the CAPTCHA verification middleware. + * + * Mocks the hCaptcha siteverify endpoint and asserts: + * - Requests with valid tokens pass through + * - Requests with invalid/missing tokens are rejected with 422 + * - HCAPTCHA_ENABLED=false bypasses verification (dev mode) + * - Missing secret key in production rejects all requests (fail-closed) + * - Network errors in production reject requests (fail-closed) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Request, Response, NextFunction } from 'express'; +import { captchaMiddleware } from '../middleware/captcha.middleware.js'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeReq(body: Record = {}): Request { + return { body } as unknown as Request; +} + +function makeRes() { + const json = vi.fn(); + const status = vi.fn().mockReturnValue({ json }); + return { status, json, _json: json, _status: status } as unknown as Response & { + _json: typeof json; + _status: typeof status; + }; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('captchaMiddleware', () => { + const originalEnv = { ...process.env }; + const next = vi.fn() as NextFunction; + + beforeEach(() => { + vi.clearAllMocks(); + // Default: enabled with a fake secret key + process.env.HCAPTCHA_ENABLED = 'true'; + process.env.HCAPTCHA_SECRET_KEY = 'test-secret'; + process.env.NODE_ENV = 'test'; + }); + + afterEach(() => { + // Restore env + process.env.HCAPTCHA_ENABLED = originalEnv.HCAPTCHA_ENABLED; + process.env.HCAPTCHA_SECRET_KEY = originalEnv.HCAPTCHA_SECRET_KEY; + process.env.NODE_ENV = originalEnv.NODE_ENV; + vi.restoreAllMocks(); + }); + + // ── Dev bypass ────────────────────────────────────────────────────────────── + + it('bypasses verification when HCAPTCHA_ENABLED=false', async () => { + process.env.HCAPTCHA_ENABLED = 'false'; + const req = makeReq(); + const res = makeRes(); + + captchaMiddleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.status).not.toHaveBeenCalled(); + }); + + // ── Missing token ─────────────────────────────────────────────────────────── + + it('rejects with 422 when captchaToken is missing from body', async () => { + const req = makeReq({ email: 'user@example.com' }); // no captchaToken + const res = makeRes(); + + captchaMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(422); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects with 422 when captchaToken is empty string', () => { + const req = makeReq({ captchaToken: ' ' }); + const res = makeRes(); + + captchaMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(422); + expect(next).not.toHaveBeenCalled(); + }); + + // ── hCaptcha API responses ────────────────────────────────────────────────── + + it('calls next() when hCaptcha returns success:true', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + } as Response); + + const req = makeReq({ captchaToken: 'valid-token' }); + const res = makeRes(); + + captchaMiddleware(req, res, next); + + // wait for the async promise + await new Promise((r) => setTimeout(r, 0)); + + expect(next).toHaveBeenCalledOnce(); + // Token should be stripped from body + expect((req as Request).body.captchaToken).toBeUndefined(); + }); + + it('rejects with 422 when hCaptcha returns success:false', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: false, 'error-codes': ['invalid-input-response'] }), + } as Response); + + const req = makeReq({ captchaToken: 'bad-token' }); + const res = makeRes(); + + captchaMiddleware(req, res, next); + await new Promise((r) => setTimeout(r, 0)); + + expect(res.status).toHaveBeenCalledWith(422); + expect(next).not.toHaveBeenCalled(); + }); + + // ── Fail-closed: no secret key ────────────────────────────────────────────── + + it('rejects with 503 when HCAPTCHA_SECRET_KEY is not set (fail-closed)', () => { + delete process.env.HCAPTCHA_SECRET_KEY; + const req = makeReq({ captchaToken: 'some-token' }); + const res = makeRes(); + + captchaMiddleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + }); + + // ── Fail-closed: network error in production ──────────────────────────────── + + it('rejects with 503 on network error in production', async () => { + process.env.NODE_ENV = 'production'; + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network failure')); + + const req = makeReq({ captchaToken: 'some-token' }); + const res = makeRes(); + + captchaMiddleware(req, res, next); + await new Promise((r) => setTimeout(r, 0)); + + expect(res.status).toHaveBeenCalledWith(503); + expect(next).not.toHaveBeenCalled(); + }); + + it('passes through on network error outside production (dev/test)', async () => { + process.env.NODE_ENV = 'test'; + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network failure')); + + const req = makeReq({ captchaToken: 'some-token' }); + const res = makeRes(); + + captchaMiddleware(req, res, next); + await new Promise((r) => setTimeout(r, 0)); + + // In test/dev mode, network errors don't block the request + expect(next).toHaveBeenCalledOnce(); + }); + + // ── Response body shape ───────────────────────────────────────────────────── + + it('includes a CAPTCHA_INVALID code on invalid token response', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: false }), + } as Response); + + const jsonCaptor = vi.fn(); + const res = { + status: vi.fn().mockReturnValue({ json: jsonCaptor }), + } as unknown as Response; + + captchaMiddleware(makeReq({ captchaToken: 'bad' }), res, next); + await new Promise((r) => setTimeout(r, 0)); + + expect(jsonCaptor).toHaveBeenCalledWith( + expect.objectContaining({ code: 'CAPTCHA_INVALID' }), + ); + }); + + it('includes a CAPTCHA_TOKEN_MISSING code when token absent', () => { + const jsonCaptor = vi.fn(); + const res = { + status: vi.fn().mockReturnValue({ json: jsonCaptor }), + } as unknown as Response; + + captchaMiddleware(makeReq({}), res, next); + + expect(jsonCaptor).toHaveBeenCalledWith( + expect.objectContaining({ code: 'CAPTCHA_TOKEN_MISSING' }), + ); + }); +}); diff --git a/apps/backend/src/__tests__/cursor-pagination.test.ts b/apps/backend/src/__tests__/cursor-pagination.test.ts new file mode 100644 index 0000000..091d33b --- /dev/null +++ b/apps/backend/src/__tests__/cursor-pagination.test.ts @@ -0,0 +1,217 @@ +/** + * Tests for cursor-based (keyset) pagination utilities and service integration. + * + * Verifies: + * - encodeCursor / decodeCursor round-trip + * - buildCursorPage correctly detects hasMore and emits nextCursor + * - Pages are non-overlapping and complete even with interleaved inserts + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { encodeCursor, decodeCursor, buildCursorPage } from '../utils/cursor.js'; +import { BookingService } from '../services/booking.service.js'; + +// ─── cursor utilities ───────────────────────────────────────────────────────── + +describe('encodeCursor / decodeCursor', () => { + it('round-trips a valid payload', () => { + const payload = { created_at: '2027-01-15T10:00:00Z', id: 'abc-123' }; + const encoded = encodeCursor(payload); + expect(typeof encoded).toBe('string'); + expect(encoded).not.toContain('{'); // must be opaque + expect(decodeCursor(encoded)).toEqual(payload); + }); + + it('returns null for undefined / empty cursor', () => { + expect(decodeCursor(undefined)).toBeNull(); + expect(decodeCursor(null)).toBeNull(); + expect(decodeCursor('')).toBeNull(); + }); + + it('returns null for malformed base64', () => { + expect(decodeCursor('!!!not-base64!!!')).toBeNull(); + }); + + it('returns null for valid base64 that decodes to wrong shape', () => { + const bad = Buffer.from(JSON.stringify({ foo: 'bar' }), 'utf8').toString('base64url'); + expect(decodeCursor(bad)).toBeNull(); + }); + + it('returns null for base64 that decodes to non-JSON', () => { + const bad = Buffer.from('not json', 'utf8').toString('base64url'); + expect(decodeCursor(bad)).toBeNull(); + }); +}); + +// ─── buildCursorPage ────────────────────────────────────────────────────────── + +describe('buildCursorPage', () => { + function makeRows(n: number) { + return Array.from({ length: n }, (_, i) => ({ + id: `id-${String(i).padStart(3, '0')}`, + created_at: `2027-01-${String(15 - i).padStart(2, '0')}T10:00:00Z`, + })); + } + + it('returns all rows and null nextCursor when rows <= limit', () => { + const rows = makeRows(5); + const page = buildCursorPage(rows, 5); + expect(page.data).toHaveLength(5); + expect(page.nextCursor).toBeNull(); + }); + + it('returns limit rows and a nextCursor when rows > limit (hasMore)', () => { + // Service fetches limit+1; if we got limit+1 back there is a next page + const rows = makeRows(21); // limit=20, service fetched 21 + const page = buildCursorPage(rows, 20); + expect(page.data).toHaveLength(20); + expect(page.nextCursor).not.toBeNull(); + }); + + it('nextCursor decodes to the last row of the returned page', () => { + const rows = makeRows(21); + const page = buildCursorPage(rows, 20); + const decoded = decodeCursor(page.nextCursor!); + expect(decoded).not.toBeNull(); + // Last row in data is index 19 (id-019) + expect(decoded!.id).toBe(rows[19].id); + expect(decoded!.created_at).toBe(rows[19].created_at); + }); + + it('pages are non-overlapping (cursor filters correct rows)', () => { + // Simulate 3 pages of 3 rows each from a 9-row dataset + const allRows = makeRows(9); + const LIMIT = 3; + + // Page 1: no cursor — rows 0..2, nextCursor points after row 2 + const page1 = buildCursorPage([...allRows.slice(0, 3), allRows[3]], LIMIT); + expect(page1.data.map((r) => r.id)).toEqual(['id-000', 'id-001', 'id-002']); + expect(page1.nextCursor).not.toBeNull(); + + // Page 2: cursor after row 2 — rows 3..5 + const page2 = buildCursorPage([...allRows.slice(3, 6), allRows[6]], LIMIT); + expect(page2.data.map((r) => r.id)).toEqual(['id-003', 'id-004', 'id-005']); + expect(page2.nextCursor).not.toBeNull(); + + // Page 3: cursor after row 5 — rows 6..8, no more + const page3 = buildCursorPage(allRows.slice(6, 9), LIMIT); + expect(page3.data.map((r) => r.id)).toEqual(['id-006', 'id-007', 'id-008']); + expect(page3.nextCursor).toBeNull(); + + // Verify no id appears in more than one page + const allIds = [ + ...page1.data.map((r) => r.id), + ...page2.data.map((r) => r.id), + ...page3.data.map((r) => r.id), + ]; + const uniqueIds = new Set(allIds); + expect(uniqueIds.size).toBe(allIds.length); + }); +}); + +// ─── BookingService.getUserBookings (cursor) ────────────────────────────────── + +// Minimal Supabase mock +const mockSingle = vi.fn(); +const mockLimit = vi.fn(); +const mockOrder2 = vi.fn(() => ({ limit: mockLimit })); +const mockOrder1 = vi.fn(() => ({ order: mockOrder2, limit: mockLimit })); +const mockOr = vi.fn(() => ({ order: mockOrder1, limit: mockLimit })); +const mockEq = vi.fn(() => ({ + order: mockOrder1, + or: mockOr, +})); +const mockSelect = vi.fn(() => ({ eq: mockEq })); +const mockFrom = vi.fn(() => ({ select: mockSelect })); + +vi.mock('../config/supabase.js', () => ({ supabase: { from: mockFrom } })); +vi.mock('../blockchain/bookingContract.js', () => ({ + checkAvailability: vi.fn(), + cancelBookingOnChain: vi.fn(), + createBookingOnChain: vi.fn(), + updateBookingStatusOnChain: vi.fn(), +})); +vi.mock('../blockchain/trustlessWork.js', () => ({ + trustlessWorkClient: { + createBookingEscrow: vi.fn(), + cancelEscrow: vi.fn(), + releaseEscrow: vi.fn(), + }, +})); +vi.mock('../services/logging.service.js', () => ({ + loggingService: { logBlockchainOperation: vi.fn() }, +})); +vi.mock('../services/notification.service.js', () => ({ + createNotification: vi.fn(), +})); + +describe('BookingService.getUserBookings', () => { + let service: BookingService; + + const makeBooking = (index: number) => ({ + id: `booking-${String(index).padStart(3, '0')}`, + tenant_id: 'user-1', + created_at: `2027-01-${String(30 - index).padStart(2, '0')}T10:00:00Z`, + status: 'Confirmed', + }); + + beforeEach(() => { + vi.clearAllMocks(); + service = new BookingService(); + + // Wire the fluent builder so .limit() returns the rows + mockLimit.mockImplementation(() => Promise.resolve({ data: null, error: null })); + mockOrder2.mockReturnValue({ limit: mockLimit }); + mockOrder1.mockReturnValue({ order: mockOrder2, limit: mockLimit }); + mockOr.mockReturnValue({ order: mockOrder1, limit: mockLimit }); + mockEq.mockReturnValue({ order: mockOrder1, or: mockOr }); + mockSelect.mockReturnValue({ eq: mockEq }); + mockFrom.mockReturnValue({ select: mockSelect }); + }); + + it('returns first page with nextCursor when more rows exist', async () => { + // Return 21 rows for a limit-20 request (limit+1 trick) + const rows = Array.from({ length: 21 }, (_, i) => makeBooking(i)); + mockLimit.mockResolvedValueOnce({ data: rows, error: null }); + + const result = await service.getUserBookings('user-1', null, 20); + + expect(result.success).toBe(true); + expect(result.data!.data).toHaveLength(20); + expect(result.data!.nextCursor).not.toBeNull(); + }); + + it('returns last page with null nextCursor when no more rows', async () => { + const rows = Array.from({ length: 5 }, (_, i) => makeBooking(i)); + mockLimit.mockResolvedValueOnce({ data: rows, error: null }); + + const result = await service.getUserBookings('user-1', null, 20); + + expect(result.success).toBe(true); + expect(result.data!.data).toHaveLength(5); + expect(result.data!.nextCursor).toBeNull(); + }); + + it('passes cursor to the or() filter on subsequent pages', async () => { + const cursor = encodeCursor({ created_at: '2027-01-20T10:00:00Z', id: 'booking-010' }); + mockLimit.mockResolvedValueOnce({ data: [], error: null }); + + await service.getUserBookings('user-1', cursor, 20); + + // .or() should have been called with the cursor-based keyset filter + expect(mockOr).toHaveBeenCalledWith(expect.stringContaining('2027-01-20T10:00:00Z')); + }); + + it('returns error when userId is empty', async () => { + const result = await service.getUserBookings(''); + expect(result.success).toBe(false); + expect(result.error).toMatch(/user id is required/i); + }); + + it('respects the max limit cap of 100', async () => { + mockLimit.mockResolvedValueOnce({ data: [], error: null }); + await service.getUserBookings('user-1', null, 999); + // limit(101) should have been called, not limit(1000) + expect(mockLimit).toHaveBeenCalledWith(101); // 100 + 1 for hasMore detection + }); +}); diff --git a/apps/backend/src/__tests__/location-privacy.test.ts b/apps/backend/src/__tests__/location-privacy.test.ts new file mode 100644 index 0000000..8aaf4db --- /dev/null +++ b/apps/backend/src/__tests__/location-privacy.test.ts @@ -0,0 +1,170 @@ +/** + * Tests for location privacy utilities and the property controller's + * coordinate-redaction behaviour. + * + * Verifies: + * - Exact coordinates are never returned to unauthorised viewers + * - Approximate coordinates differ from exact by at most the grid step + * - Hosts and confirmed tenants receive exact coordinates + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + approximateCoordinate, + toApproximateLocation, + redactExactCoordinates, + PUBLIC_LOCATION_RADIUS_M, +} from '../utils/locationPrivacy.js'; + +// ─── approximateCoordinate ──────────────────────────────────────────────────── + +describe('approximateCoordinate', () => { + const GRID = 0.005; // ~500 m + + it('returns a value within GRID * 1.5 of the original', () => { + const samples = [40.7128, -74.006, 51.5074, -0.1278, 35.6762, 139.6503, -33.8688, 151.2093]; + for (const coord of samples) { + const approx = approximateCoordinate(coord); + expect(Math.abs(approx - coord)).toBeLessThanOrEqual(GRID * 1.5); + } + }); + + it('is deterministic (same input → same output)', () => { + const a = approximateCoordinate(40.7128); + const b = approximateCoordinate(40.7128); + expect(a).toBe(b); + }); + + it('differs from the exact coordinate for non-grid-aligned values', () => { + // 40.71284 is not snapped to the 0.005 grid + const exact = 40.71284; + const approx = approximateCoordinate(exact); + expect(approx).not.toBe(exact); + }); +}); + +// ─── toApproximateLocation ──────────────────────────────────────────────────── + +describe('toApproximateLocation', () => { + it('returns approximate_latitude and approximate_longitude', () => { + const result = toApproximateLocation({ latitude: 40.7128, longitude: -74.006 }); + expect(result).toHaveProperty('approximate_latitude'); + expect(result).toHaveProperty('approximate_longitude'); + expect(result).toHaveProperty('location_radius_m', PUBLIC_LOCATION_RADIUS_M); + }); + + it('approximate coordinates differ from exact', () => { + const exact = { latitude: 40.71284, longitude: -74.00617 }; + const approx = toApproximateLocation(exact); + // At least one coordinate should differ + const latDiff = Math.abs(approx.approximate_latitude - exact.latitude); + const lngDiff = Math.abs(approx.approximate_longitude - exact.longitude); + expect(latDiff + lngDiff).toBeGreaterThan(0); + }); +}); + +// ─── redactExactCoordinates ─────────────────────────────────────────────────── + +describe('redactExactCoordinates', () => { + const property = { + id: 'prop-1', + title: 'Beach House', + owner_id: 'owner-1', + latitude: 40.71284, + longitude: -74.00617, + price_per_night: 150, + }; + + it('removes latitude and longitude fields', () => { + const redacted = redactExactCoordinates(property); + expect(redacted.latitude).toBeUndefined(); + expect(redacted.longitude).toBeUndefined(); + }); + + it('adds approximate_latitude, approximate_longitude, location_radius_m', () => { + const redacted = redactExactCoordinates(property); + expect(typeof redacted.approximate_latitude).toBe('number'); + expect(typeof redacted.approximate_longitude).toBe('number'); + expect(redacted.location_radius_m).toBe(PUBLIC_LOCATION_RADIUS_M); + }); + + it('preserves non-location fields', () => { + const redacted = redactExactCoordinates(property); + expect(redacted.id).toBe('prop-1'); + expect(redacted.title).toBe('Beach House'); + expect(redacted.price_per_night).toBe(150); + }); + + it('approximate coords differ from exact coords', () => { + const redacted = redactExactCoordinates(property); + // Approximate lat/lng must not equal the exact values + const latMatch = redacted.approximate_latitude === property.latitude; + const lngMatch = redacted.approximate_longitude === property.longitude; + // At least one must differ + expect(latMatch && lngMatch).toBe(false); + }); + + it('handles property with no coordinates gracefully', () => { + const noCoord = { id: 'p2', title: 'Mystery Place', owner_id: 'o1' }; + const redacted = redactExactCoordinates(noCoord); + expect(redacted.latitude).toBeUndefined(); + expect(redacted.longitude).toBeUndefined(); + expect(redacted.location_radius_m).toBe(PUBLIC_LOCATION_RADIUS_M); + }); +}); + +// ─── viewerHasExactLocationAccess (integration via Supabase mock) ───────────── + +// Mock Supabase for the controller tests +const mockLimit = vi.fn(); +const mockEq3 = vi.fn(() => ({ limit: mockLimit })); +const mockEq2 = vi.fn(() => ({ eq: mockEq3 })); +const mockEq1 = vi.fn(() => ({ eq: mockEq2 })); +const mockSelect = vi.fn(() => ({ eq: mockEq1 })); +const mockFrom = vi.fn(() => ({ select: mockSelect })); + +vi.mock('../config/supabase.js', () => ({ supabase: { from: mockFrom } })); + +describe('Location privacy — coordinator is not leaked to unauthorized viewers', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLimit.mockReturnValue({ data: [], error: null }); + mockEq3.mockReturnValue({ limit: mockLimit }); + mockEq2.mockReturnValue({ eq: mockEq3 }); + mockEq1.mockReturnValue({ eq: mockEq2 }); + mockSelect.mockReturnValue({ eq: mockEq1 }); + mockFrom.mockReturnValue({ select: mockSelect }); + }); + + it('redactExactCoordinates never exposes exact latitude', () => { + const prop = { + id: 'p1', + title: 'Test', + owner_id: 'owner-1', + latitude: 51.5074, + longitude: -0.1278, + }; + const redacted = redactExactCoordinates(prop); + + // exact values must not appear in the output + expect(redacted.latitude).toBeUndefined(); + expect(redacted.longitude).toBeUndefined(); + expect(JSON.stringify(redacted)).not.toContain('51.5074'); + expect(JSON.stringify(redacted)).not.toContain('-0.1278'); + }); + + it('multiple different properties produce stable (deterministic) approximate coords', () => { + const coords = [ + { latitude: 40.7128, longitude: -74.006 }, + { latitude: 51.5074, longitude: -0.1278 }, + { latitude: 35.6762, longitude: 139.6503 }, + ]; + + for (const c of coords) { + const a1 = toApproximateLocation(c); + const a2 = toApproximateLocation(c); + expect(a1.approximate_latitude).toBe(a2.approximate_latitude); + expect(a1.approximate_longitude).toBe(a2.approximate_longitude); + } + }); +}); diff --git a/apps/backend/src/__tests__/sanitize.test.ts b/apps/backend/src/__tests__/sanitize.test.ts new file mode 100644 index 0000000..336e9c0 --- /dev/null +++ b/apps/backend/src/__tests__/sanitize.test.ts @@ -0,0 +1,225 @@ +/** + * Tests for the UGC sanitization utilities. + * + * Injects a variety of XSS and markup payloads and asserts they are + * neutralised in output. + */ + +import { describe, it, expect } from 'vitest'; +import { + sanitizeText, + sanitizeShortText, + sanitizeLongText, + sanitizeResponse, +} from '../utils/sanitize.js'; + +// ─── Basic stripping ────────────────────────────────────────────────────────── + +describe('sanitizeText — basic HTML stripping', () => { + it('removes a simple script tag', () => { + const out = sanitizeText(''); + expect(out).not.toContain(''); + expect(out).toBe(''); + }); + + it('strips vbscript: protocol', () => { + const out = sanitizeText('vbscript:msgbox(1)'); + expect(out).not.toContain('vbscript:'); + }); + + it('allows https:// URLs in text (only stripping if whole value is dangerous)', () => { + // A full sentence mentioning a URL should keep the URL visible + const out = sanitizeText('Visit https://example.com for more info.'); + expect(out).toContain('https://example.com'); + }); +}); + +// ─── Length limits ──────────────────────────────────────────────────────────── + +describe('sanitizeText — length enforcement', () => { + it('truncates to default maxLength (10000)', () => { + const long = 'A'.repeat(15_000); + const out = sanitizeText(long); + expect(out.length).toBe(10_000); + }); + + it('truncates to custom maxLength', () => { + const out = sanitizeText('Hello world', { maxLength: 5 }); + expect(out).toBe('Hello'); + }); + + it('does not truncate content within the limit', () => { + const text = 'Short text'; + expect(sanitizeText(text)).toBe('Short text'); + }); +}); + +// ─── Whitespace and control characters ─────────────────────────────────────── + +describe('sanitizeText — whitespace normalisation', () => { + it('normalises CRLF to LF', () => { + const out = sanitizeText('line1\r\nline2\r\nline3'); + expect(out).not.toContain('\r'); + expect(out).toContain('\n'); + }); + + it('removes null bytes', () => { + const out = sanitizeText('hello\x00world'); + expect(out).not.toContain('\x00'); + }); + + it('removes other control characters', () => { + const out = sanitizeText('abc\x01\x02\x03def'); + expect(out).toBe('abcdef'); + }); + + it('preserves tab characters (legitimate whitespace)', () => { + const out = sanitizeLongText('column1\tcolumn2'); + expect(out).toContain('\t'); + }); +}); + +// ─── Null / non-string input ────────────────────────────────────────────────── + +describe('sanitizeText — edge inputs', () => { + it('returns empty string for null', () => { + expect(sanitizeText(null)).toBe(''); + }); + + it('returns empty string for undefined', () => { + expect(sanitizeText(undefined)).toBe(''); + }); + + it('returns empty string for non-string number', () => { + expect(sanitizeText(42)).toBe(''); + }); + + it('trims leading/trailing whitespace', () => { + expect(sanitizeText(' hello ')).toBe('hello'); + }); +}); + +// ─── sanitizeShortText ──────────────────────────────────────────────────────── + +describe('sanitizeShortText', () => { + it('collapses newlines to spaces', () => { + const out = sanitizeShortText('line1\nline2'); + expect(out).not.toContain('\n'); + expect(out).toContain('line1'); + expect(out).toContain('line2'); + }); + + it('enforces default 500-char limit', () => { + const out = sanitizeShortText('X'.repeat(600)); + expect(out.length).toBe(500); + }); +}); + +// ─── sanitizeResponse ───────────────────────────────────────────────────────── + +describe('sanitizeResponse', () => { + it('strips script tags from host response', () => { + const out = sanitizeResponse('Thanks for staying!'); + expect(out).not.toContain('', + "'>", + '', + '<', + 'ipt>alert(1)ipt>', + '