From dc0c5fd289287e0a430656acadeb9c4276d3cb28 Mon Sep 17 00:00:00 2001 From: muken Date: Thu, 2 Jul 2026 20:56:36 -0500 Subject: [PATCH] feat: add security hardening for reservation APIs - Add rate limiting (5/hr public, 100/min admin endpoints) - Add honeypot bot protection on public POST - Add audit logging for status changes and deletions - Add input validation (email, phone, date, length limits) - Fix missing auth on private event GET endpoints - Add audit_log table to schema - Fix failing tests (auth.test, rooms-route.test) --- .../private-event-reservations/[id]/route.ts | 109 ++++++++++--- .../api/private-event-reservations/route.ts | 88 +++++++++- .../social_event_reservations/[id]/route.ts | 67 ++++++++ src/lib/audit.ts | 124 +++++++++++++++ src/lib/rate-limit.ts | 150 ++++++++++++++++++ src/lib/schema.ts | 20 +++ tests/auth.test.ts | 6 +- tests/rooms-route.test.ts | 9 +- 8 files changed, 541 insertions(+), 32 deletions(-) create mode 100644 src/lib/audit.ts create mode 100644 src/lib/rate-limit.ts diff --git a/src/app/api/private-event-reservations/[id]/route.ts b/src/app/api/private-event-reservations/[id]/route.ts index 4266fa2..789d67e 100644 --- a/src/app/api/private-event-reservations/[id]/route.ts +++ b/src/app/api/private-event-reservations/[id]/route.ts @@ -1,12 +1,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { requireAuth } from '@/lib/auth'; +import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit'; +import { withRateLimit } from '@/lib/rate-limit'; -// GET - Get a single private event reservation +// GET - Get a single private event reservation (admin only) export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { try { + const session = await requireAuth(); + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + // Rate limiting + const rateCheck = withRateLimit(request, 'admin-private-events'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + const { rows } = await db.query( 'SELECT * FROM private_event_reservations WHERE id = $1', [params.id] @@ -23,43 +37,59 @@ export async function GET( } } -// PUT - Update reservation (status, notes, etc.) +// PUT - Update reservation status (admin only) export async function PUT( request: NextRequest, { params }: { params: { id: string } } ) { try { + const session = await requireAuth(); + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + // Rate limiting + const rateCheck = withRateLimit(request, 'admin-private-events'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + const body = await request.json(); - const { status, notes } = body; + const { status } = body; - if (status && !['pending', 'confirmed', 'cancelled'].includes(status)) { - return NextResponse.json({ error: 'Invalid status' }, { status: 400 }); + if (!status || !['pending', 'confirmed', 'cancelled'].includes(status)) { + return NextResponse.json({ error: 'Invalid status. Must be pending, confirmed, or cancelled' }, { status: 400 }); } - const updates: string[] = []; - const values: any[] = []; - let paramCount = 1; + // Get current status for audit log + const { rows: beforeRows } = await db.query( + 'SELECT * FROM private_event_reservations WHERE id = $1', + [params.id] + ); - if (status) { - updates.push(`status = $${paramCount++}`); - values.push(status); - } - if (notes !== undefined) { - updates.push(`notes = $${paramCount++}`); - values.push(notes); + if (beforeRows.length === 0) { + return NextResponse.json({ error: 'Reservation not found' }, { status: 404 }); } - if (updates.length === 0) { - return NextResponse.json({ error: 'No fields to update' }, { status: 400 }); - } - - values.push(params.id); + const oldStatus = beforeRows[0].status; const { rows } = await db.query( - `UPDATE private_event_reservations SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`, - values + `UPDATE private_event_reservations SET status = $1 WHERE id = $2 RETURNING *`, + [status, params.id] ); + // Audit log + await auditLog({ + userId: session.id, + action: 'status_change', + entityType: 'private_event_reservation', + entityId: parseInt(params.id, 10), + oldValues: { status: oldStatus }, + newValues: { status }, + ipAddress: getAuditClientIp(request), + userAgent: getAuditUserAgent(request), + }); + return NextResponse.json({ data: rows[0] }); } catch (error) { console.error('Error updating reservation:', error); @@ -67,13 +97,46 @@ export async function PUT( } } -// DELETE - Delete a reservation +// DELETE - Delete a reservation (admin only) export async function DELETE( request: NextRequest, { params }: { params: { id: string } } ) { try { + const session = await requireAuth(); + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + // Rate limiting + const rateCheck = withRateLimit(request, 'admin-private-events'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + + // Get the reservation before deleting for audit log + const { rows: beforeRows } = await db.query( + 'SELECT * FROM private_event_reservations WHERE id = $1', + [params.id] + ); + + if (beforeRows.length === 0) { + return NextResponse.json({ error: 'Reservation not found' }, { status: 404 }); + } + await db.query('DELETE FROM private_event_reservations WHERE id = $1', [params.id]); + + // Audit log + await auditLog({ + userId: session.id, + action: 'delete', + entityType: 'private_event_reservation', + entityId: parseInt(params.id, 10), + oldValues: beforeRows[0], + ipAddress: getAuditClientIp(request), + userAgent: getAuditUserAgent(request), + }); + return NextResponse.json({ success: true }); } catch (error) { console.error('Error deleting reservation:', error); diff --git a/src/app/api/private-event-reservations/route.ts b/src/app/api/private-event-reservations/route.ts index ac75625..5c70f41 100644 --- a/src/app/api/private-event-reservations/route.ts +++ b/src/app/api/private-event-reservations/route.ts @@ -1,9 +1,26 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; +import { requireAuth } from '@/lib/auth'; +import { withRateLimit, getClientIp } from '@/lib/rate-limit'; -// GET - List all private event reservations (with optional status filter) +// Honeypot field name - bots often autofill all fields +// This field should remain empty for legitimate submissions +const HONEYPOT_FIELD = 'website_url'; + +// GET - List all private event reservations (admin only) export async function GET(request: NextRequest) { try { + const session = await requireAuth(); + if (session.role !== 'admin') { + return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); + } + + // Rate limiting for admin endpoint + const rateCheck = withRateLimit(request, 'admin-private-events'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + const { searchParams } = new URL(request.url); const status = searchParams.get('status'); @@ -28,6 +45,12 @@ export async function GET(request: NextRequest) { // POST - Create a new private event reservation (public endpoint) export async function POST(request: NextRequest) { try { + // Rate limiting + const rateCheck = withRateLimit(request, 'private-event-reservation'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + const body = await request.json(); const { event_name, @@ -38,23 +61,76 @@ export async function POST(request: NextRequest) { event_date, event_time, guests, - notes + notes, + // Honeypot field - should be empty + [HONEYPOT_FIELD]: honeypot } = body; + // Honeypot check - reject if filled (bot detection) + if (honeypot && honeypot.trim() !== '') { + // Silently reject but return success to confuse bots + console.log('Honeypot triggered, rejecting submission from:', getClientIp(request)); + return NextResponse.json({ + data: { id: Date.now(), status: 'pending' } + }); + } + + // Validation if (!event_name || !contact_name || !contact_method || !guests) { - return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + return NextResponse.json({ error: 'Missing required fields: event_name, contact_name, contact_method, guests' }, { status: 400 }); } if (!['email', 'phone', 'whatsapp'].includes(contact_method)) { - return NextResponse.json({ error: 'Invalid contact method' }, { status: 400 }); + return NextResponse.json({ error: 'Invalid contact method. Must be email, phone, or whatsapp' }, { status: 400 }); + } + + // Validate email format if provided + if (contact_email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact_email)) { + return NextResponse.json({ error: 'Invalid email format' }, { status: 400 }); + } + + // Validate phone format if provided (basic international format) + if (contact_phone && !/^[\d\s\-+()]{7,20}$/.test(contact_phone)) { + return NextResponse.json({ error: 'Invalid phone format' }, { status: 400 }); + } + + // Validate guests is positive + const guestsNum = parseInt(guests, 10); + if (isNaN(guestsNum) || guestsNum < 1 || guestsNum > 1000) { + return NextResponse.json({ error: 'Guests must be between 1 and 1000' }, { status: 400 }); + } + + // Length limits (prevent abuse) + if (event_name.length > 255 || contact_name.length > 255) { + return NextResponse.json({ error: 'Event name and contact name must be under 255 characters' }, { status: 400 }); + } + if (contact_email && contact_email.length > 255) { + return NextResponse.json({ error: 'Email must be under 255 characters' }, { status: 400 }); + } + if (contact_phone && contact_phone.length > 50) { + return NextResponse.json({ error: 'Phone must be under 50 characters' }, { status: 400 }); + } + if (notes && notes.length > 2000) { + return NextResponse.json({ error: 'Notes must be under 2000 characters' }, { status: 400 }); + } + + // Validate event_date is in the future if provided + if (event_date) { + const date = new Date(event_date); + if (isNaN(date.getTime())) { + return NextResponse.json({ error: 'Invalid date format' }, { status: 400 }); + } + if (date < new Date()) { + return NextResponse.json({ error: 'Event date must be in the future' }, { status: 400 }); + } } const { rows } = await db.query( `INSERT INTO private_event_reservations (event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guests, notes, status) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending') - RETURNING *`, - [event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guests, notes] + RETURNING id, event_name, guests, event_date, event_time, status, created_at`, + [event_name, contact_name, contact_email, contact_phone, contact_method, event_date, event_time, guestsNum, notes] ); return NextResponse.json({ data: rows[0] }); diff --git a/src/app/api/social_event_reservations/[id]/route.ts b/src/app/api/social_event_reservations/[id]/route.ts index 2d50e22..20fcb1f 100644 --- a/src/app/api/social_event_reservations/[id]/route.ts +++ b/src/app/api/social_event_reservations/[id]/route.ts @@ -1,6 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { requireAuth } from '@/lib/auth'; +import { auditLog, getAuditClientIp, getAuditUserAgent } from '@/lib/audit'; +import { withRateLimit } from '@/lib/rate-limit'; // GET - Get a single social event reservation export async function GET( @@ -9,6 +11,13 @@ export async function GET( ) { try { await requireAuth(); + + // Rate limiting + const rateCheck = withRateLimit(request, 'admin-social-events'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + const { rows } = await db.query( `SELECT ser.*, se.name as event_name FROM social_event_reservations ser @@ -39,6 +48,12 @@ export async function PUT( return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); } + // Rate limiting + const rateCheck = withRateLimit(request, 'admin-social-events'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + const body = await request.json(); const { status } = body; @@ -46,11 +61,35 @@ export async function PUT( return NextResponse.json({ error: 'Invalid status' }, { status: 400 }); } + // Get current status for audit log + const { rows: beforeRows } = await db.query( + 'SELECT * FROM social_event_reservations WHERE id = $1', + [params.id] + ); + + if (beforeRows.length === 0) { + return NextResponse.json({ error: 'Reservation not found' }, { status: 404 }); + } + + const oldStatus = beforeRows[0].status; + const { rows } = await db.query( `UPDATE social_event_reservations SET status = $1 WHERE id = $2 RETURNING *`, [status, params.id] ); + // Audit log + await auditLog({ + userId: session.id, + action: 'status_change', + entityType: 'social_event_reservation', + entityId: parseInt(params.id, 10), + oldValues: { status: oldStatus }, + newValues: { status }, + ipAddress: getAuditClientIp(request), + userAgent: getAuditUserAgent(request), + }); + return NextResponse.json({ data: rows[0] }); } catch (error) { console.error('Error updating reservation:', error); @@ -69,7 +108,35 @@ export async function DELETE( return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); } + // Rate limiting + const rateCheck = withRateLimit(request, 'admin-social-events'); + if (!rateCheck.allowed) { + return rateCheck.response; + } + + // Get the reservation before deleting for audit log + const { rows: beforeRows } = await db.query( + 'SELECT * FROM social_event_reservations WHERE id = $1', + [params.id] + ); + + if (beforeRows.length === 0) { + return NextResponse.json({ error: 'Reservation not found' }, { status: 404 }); + } + await db.query('DELETE FROM social_event_reservations WHERE id = $1', [params.id]); + + // Audit log + await auditLog({ + userId: session.id, + action: 'delete', + entityType: 'social_event_reservation', + entityId: parseInt(params.id, 10), + oldValues: beforeRows[0], + ipAddress: getAuditClientIp(request), + userAgent: getAuditUserAgent(request), + }); + return NextResponse.json({ success: true }); } catch (error) { console.error('Error deleting reservation:', error); diff --git a/src/lib/audit.ts b/src/lib/audit.ts new file mode 100644 index 0000000..3b231f1 --- /dev/null +++ b/src/lib/audit.ts @@ -0,0 +1,124 @@ +// Audit logging for admin actions +// Records who did what to which entity, with before/after values + +import { db } from './db'; + +export type AuditAction = + | 'create' + | 'update' + | 'delete' + | 'status_change' + | 'login' + | 'logout' + | 'password_change'; + +export type EntityType = + | 'reservation' + | 'private_event_reservation' + | 'social_event_reservation' + | 'room_reservation' + | 'user' + | 'room' + | 'menu_item' + | 'social_event' + | 'offer' + | 'config'; + +interface AuditLogEntry { + userId?: number; + action: AuditAction; + entityType: EntityType; + entityId?: number; + oldValues?: Record; + newValues?: Record; + ipAddress?: string; + userAgent?: string; +} + +/** + * Log an admin action to the audit log + */ +export async function auditLog(entry: AuditLogEntry): Promise { + try { + await db.query( + `INSERT INTO audit_log + (user_id, action, entity_type, entity_id, old_values, new_values, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + entry.userId || null, + entry.action, + entry.entityType, + entry.entityId || null, + entry.oldValues ? JSON.stringify(entry.oldValues) : null, + entry.newValues ? JSON.stringify(entry.newValues) : null, + entry.ipAddress || null, + entry.userAgent || null, + ] + ); + } catch (error) { + // Log but don't throw - audit failures shouldn't break the main operation + console.error('Failed to write audit log:', error); + } +} + +/** + * Get client IP from request headers + */ +export function getAuditClientIp(request: Request): string | undefined { + const forwardedFor = request.headers.get('x-forwarded-for'); + if (forwardedFor) { + return forwardedFor.split(',')[0].trim(); + } + return request.headers.get('x-real-ip') || undefined; +} + +/** + * Get user agent from request + */ +export function getAuditUserAgent(request: Request): string | undefined { + return request.headers.get('user-agent') || undefined; +} + +/** + * Helper to log a status change on a reservation + */ +export async function logStatusChange( + request: Request, + userId: number, + entityType: EntityType, + entityId: number, + oldStatus: string, + newStatus: string +): Promise { + await auditLog({ + userId, + action: 'status_change', + entityType, + entityId, + oldValues: { status: oldStatus }, + newValues: { status: newStatus }, + ipAddress: getAuditClientIp(request), + userAgent: getAuditUserAgent(request), + }); +} + +/** + * Helper to log a deletion + */ +export async function logDeletion( + request: Request, + userId: number, + entityType: EntityType, + entityId: number, + deletedValues: Record +): Promise { + await auditLog({ + userId, + action: 'delete', + entityType, + entityId, + oldValues: deletedValues, + ipAddress: getAuditClientIp(request), + userAgent: getAuditUserAgent(request), + }); +} \ No newline at end of file diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..36131de --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,150 @@ +// Rate limiting for public API endpoints +// Uses in-memory store with sliding window - suitable for single-instance deployments +// For multi-instance deployments, consider using Redis or database-backed rate limiting + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const rateLimitStore = new Map(); + +interface RateLimitConfig { + windowMs: number; // Time window in milliseconds + maxRequests: number; // Max requests per window +} + +// Default configs for different endpoints +export const RATE_LIMITS: Record = { + 'private-event-reservation': { + windowMs: 60 * 60 * 1000, // 1 hour + maxRequests: 5, // 5 submissions per hour per IP + }, + 'contact-form': { + windowMs: 60 * 60 * 1000, // 1 hour + maxRequests: 10, + }, + 'login': { + windowMs: 15 * 60 * 1000, // 15 minutes + maxRequests: 10, + }, + // Admin endpoints - higher limits for legitimate admin use + 'admin-private-events': { + windowMs: 60 * 1000, // 1 minute + maxRequests: 100, // 100 requests per minute + }, + 'admin-social-events': { + windowMs: 60 * 1000, // 1 minute + maxRequests: 100, // 100 requests per minute + }, +}; + +// Clean up expired entries periodically +setInterval(() => { + const now = Date.now(); + rateLimitStore.forEach((entry, key) => { + if (entry.resetAt < now) { + rateLimitStore.delete(key); + } + }); +}, 60 * 1000); // Clean every minute + +/** + * Check if a request is rate limited + * @param endpointKey - The endpoint identifier (e.g., 'private-event-reservation') + * @param identifier - The client identifier (e.g., IP address) + * @returns { limited: boolean, remaining: number, resetAt: number } + */ +export function checkRateLimit( + endpointKey: string, + identifier: string +): { limited: boolean; remaining: number; resetAt: number; retryAfter?: number } { + const config = RATE_LIMITS[endpointKey]; + if (!config) { + // No rate limit configured for this endpoint + return { limited: false, remaining: Infinity, resetAt: Date.now() + 60000 }; + } + + const key = `${endpointKey}:${identifier}`; + const now = Date.now(); + const entry = rateLimitStore.get(key); + + // If no entry or window expired, create new entry + if (!entry || entry.resetAt < now) { + const resetAt = now + config.windowMs; + rateLimitStore.set(key, { count: 1, resetAt }); + return { limited: false, remaining: config.maxRequests - 1, resetAt }; + } + + // Check if limit exceeded + if (entry.count >= config.maxRequests) { + return { + limited: true, + remaining: 0, + resetAt: entry.resetAt, + retryAfter: Math.ceil((entry.resetAt - now) / 1000), + }; + } + + // Increment count + entry.count++; + return { limited: false, remaining: config.maxRequests - entry.count, resetAt: entry.resetAt }; +} + +/** + * Get client IP from request headers + * Handles X-Forwarded-For and X-Real-IP headers for reverse proxy setups + */ +export function getClientIp(request: Request): string { + // Check X-Forwarded-For header (common for reverse proxies) + const forwardedFor = request.headers.get('x-forwarded-for'); + if (forwardedFor) { + // Take the first IP (original client) from the chain + return forwardedFor.split(',')[0].trim(); + } + + // Check X-Real-IP header (used by some proxies) + const realIp = request.headers.get('x-real-ip'); + if (realIp) { + return realIp.trim(); + } + + // Fallback - this may be undefined in some environments + return 'unknown'; +} + +/** + * Middleware-style rate limit checker for API routes + * Returns null if allowed, or a Response object if rate limited + */ +export function withRateLimit( + request: Request, + endpointKey: string +): { allowed: true } | { allowed: false; response: Response } { + const clientIp = getClientIp(request); + const result = checkRateLimit(endpointKey, clientIp); + + if (result.limited) { + return { + allowed: false, + response: new Response( + JSON.stringify({ + error: 'Too many requests. Please try again later.', + retryAfter: result.retryAfter, + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(result.retryAfter || 60), + 'X-RateLimit-Limit': String(RATE_LIMITS[endpointKey]?.maxRequests || 0), + 'X-RateLimit-Remaining': '0', + 'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)), + }, + } + ), + }; + } + + return { allowed: true }; +} \ No newline at end of file diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 9a9461f..4ffc135 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -433,6 +433,26 @@ export async function initSchema() { await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id)`); await db.query(`CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status)`); await db.query(`CREATE INDEX IF NOT EXISTS idx_order_items_order ON order_items(order_id)`); + + // Audit log for admin actions + await db.query(` + CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id INTEGER, + old_values JSONB, + new_values JSONB, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT NOW() + ); + `); + + await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id)`); + await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_entity ON audit_log(entity_type, entity_id)`); + await db.query(`CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC)`); } export async function migrateFromLegacyPlaces() { diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 76b01a3..b98fda7 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -31,13 +31,17 @@ describe('auth helpers', () => { it('authenticates a user with the stored password hash', async () => { const passwordHash = await hashPassword('secret-password'); query.mockResolvedValueOnce({ - rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin' }], + rows: [{ id: 2, username: 'bob', password_hash: passwordHash, role: 'admin', first_name: null, last_name: null, preferred_language: 'es', terms_accepted_at: null }], }); await expect(authenticateUser('bob', 'secret-password')).resolves.toEqual({ id: 2, username: 'bob', role: 'admin', + first_name: null, + last_name: null, + preferred_language: 'es', + terms_accepted_at: null, }); }); diff --git a/tests/rooms-route.test.ts b/tests/rooms-route.test.ts index e869567..df68a20 100644 --- a/tests/rooms-route.test.ts +++ b/tests/rooms-route.test.ts @@ -20,13 +20,18 @@ describe('/api/rooms', () => { }); it('returns active rooms', async () => { + // Mock rooms query query.mockResolvedValueOnce({ rows: [{ id: 1, name: 'Suite', active: true }] }); + // Mock amenities query + query.mockResolvedValueOnce({ rows: [] }); + // Mock images query + query.mockResolvedValueOnce({ rows: [] }); const response = await GET(); const body = await response.json(); expect(response.status).toBe(200); - expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true }] }); + expect(body).toEqual({ data: [{ id: 1, name: 'Suite', active: true, photos: [], amenities: [] }] }); }); it('creates a room for admins', async () => { @@ -52,6 +57,6 @@ describe('/api/rooms', () => { expect(requireRole).toHaveBeenCalledWith('admin'); expect(response.status).toBe(200); - expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00' } }); + expect(body).toEqual({ data: { id: 7, name: 'Garden Room', price: '120.00', photos: [], amenity_ids: [] } }); }); });