Admin Dashboard Backend (Phases 1-4) #5

Merged
stephen merged 9 commits from admin into main 2026-01-22 14:11:43 -07:00
16 changed files with 11215 additions and 31 deletions

View File

@ -0,0 +1,156 @@
const { MongoClient, ObjectId } = require('mongodb');
const jwt = require('jsonwebtoken');
// Counter for generating unique identifiers within the same millisecond
let uniqueCounter = 0;
/**
* Create a test Express app instance with database connection
* This creates a minimal Express app for testing admin routes
*/
async function createTestApp(db) {
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(express.json());
app.use(cookieParser());
// Store db in app.locals for middleware access
app.locals.db = db;
// Mount the auth middleware module
const { requireAuth, requireAdmin } = require('../../middleware/auth');
// Health check endpoint (should remain public)
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
// Try to mount admin routes if they exist
try {
const adminRoutes = require('../../routes/admin');
app.use('/api/admin', requireAuth, requireAdmin, adminRoutes);
} catch (error) {
// Admin routes don't exist yet - this is expected for failing tests
// Create placeholder routes that will 404
app.use('/api/admin', requireAuth, requireAdmin, (req, res) => {
res.status(404).json({ error: 'Admin routes not implemented' });
});
}
return app;
}
/**
* Generate a valid JWT token for a test user
* @param {string|ObjectId} userId - The user's MongoDB _id
* @returns {string} JWT token
*/
function generateTestToken(userId) {
const secret = process.env.JWT_SECRET || 'test-jwt-secret-for-testing-only';
return jwt.sign(
{ userId: userId.toString() },
secret,
{ expiresIn: '7d' }
);
}
/**
* Create a test user document
* @param {Object} overrides - Fields to override in the default user
* @returns {Object} User document
*/
function createTestUser(overrides = {}) {
const now = new Date();
const uniqueId = `${Date.now()}-${++uniqueCounter}-${Math.random().toString(36).substr(2, 9)}`;
return {
_id: new ObjectId(),
googleId: `google-${uniqueId}`,
email: `test-${uniqueId}@example.com`,
name: 'Test User',
picture: 'https://example.com/photo.jpg',
role: 'user',
isActive: true,
loginCount: 1,
createdAt: now,
lastLoginAt: now,
disabledAt: null,
disabledBy: null,
...overrides
};
}
/**
* Create an admin user document
* @param {Object} overrides - Fields to override in the default admin
* @returns {Object} Admin user document
*/
function createTestAdmin(overrides = {}) {
const uniqueId = `${Date.now()}-${++uniqueCounter}-${Math.random().toString(36).substr(2, 9)}`;
return createTestUser({
role: 'admin',
email: `admin-${uniqueId}@example.com`,
name: 'Test Admin',
...overrides
});
}
/**
* Insert a user into the database
* @param {Db} db - MongoDB database instance
* @param {Object} user - User document to insert
* @returns {Promise<Object>} Inserted user with _id
*/
async function insertTestUser(db, user) {
const result = await db.collection('users').insertOne(user);
return { ...user, _id: result.insertedId };
}
/**
* Clean up test users from the database
* @param {Db} db - MongoDB database instance
*/
async function cleanupTestUsers(db) {
await db.collection('users').deleteMany({});
}
/**
* Clean up all test data from the database
* @param {Db} db - MongoDB database instance
*/
async function cleanupTestData(db) {
await Promise.all([
db.collection('users').deleteMany({}),
db.collection('user_activity').deleteMany({})
]);
}
/**
* Create a test activity document
* @param {Object} overrides - Fields to override in the default activity
* @returns {Object} Activity document
*/
function createTestActivity(overrides = {}) {
const now = new Date();
return {
_id: new ObjectId(),
userId: new ObjectId(),
action: 'PAGE_VIEW',
metadata: { path: '/test' },
timestamp: now,
sessionId: `session-${Date.now()}`,
...overrides
};
}
module.exports = {
createTestApp,
generateTestToken,
createTestUser,
createTestAdmin,
insertTestUser,
cleanupTestUsers,
cleanupTestData,
createTestActivity
};

View File

@ -0,0 +1,905 @@
/**
* Phase 1: Foundation Tests for Admin Dashboard
*
* These tests verify the implementation of Phase 1 requirements from ADMIN.md:
* - 1.1 Database Schema (DB-1.x): User role field, disabledAt/disabledBy fields, indexes
* - 1.2 Middleware (MW-1.x): requireAdmin middleware functionality
* - 1.3 User Management API (API-1.x): CRUD operations for admin user management
*
* These tests are designed to FAIL initially as the implementation does not exist yet.
* Run with: npm test
*/
const request = require('supertest');
const { MongoClient, ObjectId } = require('mongodb');
const {
createTestApp,
generateTestToken,
createTestUser,
createTestAdmin,
insertTestUser,
cleanupTestUsers,
cleanupTestData
} = require('./helpers/testHelpers');
describe('Phase 1: Foundation', () => {
let connection;
let db;
let app;
beforeAll(async () => {
// Connect to the in-memory MongoDB instance
const uri = process.env.MONGO_URI;
connection = await MongoClient.connect(uri);
db = connection.db('apartments_test');
});
afterAll(async () => {
if (connection) {
await connection.close();
}
});
beforeEach(async () => {
// Clean up test data before each test
await cleanupTestData(db);
// Create fresh app instance for each test
app = await createTestApp(db);
});
// =============================================================================
// 1.1 DATABASE SCHEMA TESTS
// These tests verify the user schema has been properly updated for admin features
// =============================================================================
describe('1.1 Database Schema', () => {
/**
* DB-1.1: Users must have a `role` field
* Valid values: 'user' | 'admin'
* Default: 'user'
*/
describe('DB-1.1: User role field', () => {
it('should have role field with default value "user" for new users', async () => {
// When a new user is created without specifying role
const user = createTestUser();
delete user.role; // Remove role to test default
const { findOrCreateUser } = require('../models/user');
// Create a user profile to simulate OAuth flow
const profile = {
googleId: user.googleId,
email: user.email,
name: user.name,
picture: user.picture
};
const createdUser = await findOrCreateUser(db, profile);
// Then the role should default to 'user'
expect(createdUser).toBeDefined();
expect(createdUser.role).toBe('user');
});
it('should only allow "user" or "admin" as valid role values', async () => {
// Attempt to insert a user with an invalid role
const userWithInvalidRole = createTestUser({ role: 'superadmin' });
// This test verifies schema validation exists
// The exact implementation depends on whether validation is done at
// the application level or database level
await expect(async () => {
await db.collection('users').insertOne(userWithInvalidRole);
// Query the user back to verify role validation
const inserted = await db.collection('users').findOne({ _id: userWithInvalidRole._id });
// If no validation exists, this should be caught by application logic
if (inserted.role !== 'user' && inserted.role !== 'admin') {
throw new Error('Invalid role should not be allowed');
}
}).rejects.toThrow();
});
});
/**
* DB-1.2: Users must have `disabledAt` field
* Type: Date | null
* Set when user is disabled, null when active
*/
describe('DB-1.2: User disabledAt field', () => {
it('should have disabledAt field set to null for active users', async () => {
const user = createTestUser({ isActive: true });
await insertTestUser(db, user);
const foundUser = await db.collection('users').findOne({ _id: user._id });
expect(foundUser.disabledAt).toBeNull();
});
it('should have disabledAt field set to Date when user is disabled', async () => {
const user = createTestUser({ isActive: true });
await insertTestUser(db, user);
// Import the user model function that handles disabling
const { setUserActive } = require('../models/user');
// Disable the user (this should set disabledAt)
const disabledUser = await setUserActive(db, user._id, false);
expect(disabledUser.isActive).toBe(false);
expect(disabledUser.disabledAt).toBeInstanceOf(Date);
});
});
/**
* DB-1.3: Users must have `disabledBy` field
* Type: ObjectId | null
* References the admin who disabled the user
*/
describe('DB-1.3: User disabledBy field', () => {
it('should have disabledBy field set to null for active users', async () => {
const user = createTestUser({ isActive: true });
await insertTestUser(db, user);
const foundUser = await db.collection('users').findOne({ _id: user._id });
expect(foundUser.disabledBy).toBeNull();
});
it('should have disabledBy field set to admin ObjectId when disabled', async () => {
const admin = createTestAdmin();
const user = createTestUser();
await insertTestUser(db, admin);
await insertTestUser(db, user);
// Import the updated user model function that accepts disabledBy
const { setUserActive } = require('../models/user');
// Disable the user with admin reference
const disabledUser = await setUserActive(db, user._id, false, admin._id);
expect(disabledUser.isActive).toBe(false);
expect(disabledUser.disabledBy).toEqual(admin._id);
});
});
/**
* DB-1.4: Required indexes for admin queries
* Indexes needed:
* - { role: 1 }
* - { isActive: 1, role: 1 }
* - { createdAt: -1 }
* - { lastLoginAt: -1 }
*/
describe('DB-1.4: Required indexes', () => {
beforeEach(async () => {
// Create indexes (this should be done by createIndexes function)
const { createIndexes } = require('../models/user');
await createIndexes(db);
});
it('should have index on role field', async () => {
const indexes = await db.collection('users').indexes();
const roleIndex = indexes.find(idx =>
idx.key && idx.key.role === 1 && Object.keys(idx.key).length === 1
);
expect(roleIndex).toBeDefined();
});
it('should have compound index on isActive and role', async () => {
const indexes = await db.collection('users').indexes();
const compoundIndex = indexes.find(idx =>
idx.key && idx.key.isActive === 1 && idx.key.role === 1
);
expect(compoundIndex).toBeDefined();
});
it('should have index on createdAt descending', async () => {
const indexes = await db.collection('users').indexes();
const createdAtIndex = indexes.find(idx =>
idx.key && idx.key.createdAt === -1
);
expect(createdAtIndex).toBeDefined();
});
it('should have index on lastLoginAt descending', async () => {
const indexes = await db.collection('users').indexes();
const lastLoginIndex = indexes.find(idx =>
idx.key && idx.key.lastLoginAt === -1
);
expect(lastLoginIndex).toBeDefined();
});
});
});
// =============================================================================
// 1.2 MIDDLEWARE TESTS
// These tests verify the requireAdmin middleware behaves correctly
// =============================================================================
describe('1.2 Middleware', () => {
/**
* MW-1.1: requireAdmin middleware must exist
*/
describe('MW-1.1: requireAdmin middleware exists', () => {
it('should export requireAdmin middleware from auth module', () => {
const { requireAdmin } = require('../middleware/auth');
expect(requireAdmin).toBeDefined();
expect(typeof requireAdmin).toBe('function');
});
});
/**
* MW-1.2: requireAdmin returns 403 if user.role !== 'admin'
*/
describe('MW-1.2: requireAdmin authorization', () => {
it('should return 403 with "Admin access required" for non-admin users', async () => {
// Create a regular user (not admin)
const user = createTestUser({ role: 'user' });
await insertTestUser(db, user);
const token = generateTestToken(user._id);
// Attempt to access an admin endpoint
const response = await request(app)
.get('/api/admin/users')
.set('Cookie', [`auth_token=${token}`]);
expect(response.status).toBe(403);
expect(response.body.error).toBe('Admin access required');
});
it('should allow access for users with admin role', async () => {
// Create an admin user
const admin = createTestAdmin();
await insertTestUser(db, admin);
const token = generateTestToken(admin._id);
// Access admin endpoint - should not get 403
const response = await request(app)
.get('/api/admin/users')
.set('Cookie', [`auth_token=${token}`]);
// Should either succeed (200) or 404 if routes not implemented
// but NOT 403 (forbidden)
expect(response.status).not.toBe(403);
});
});
/**
* MW-1.3: requireAdmin must work after requireAuth
*/
describe('MW-1.3: Middleware chaining', () => {
it('should return 401 if no authentication token provided', async () => {
const response = await request(app)
.get('/api/admin/users');
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should return 401 for invalid token before checking admin role', async () => {
const response = await request(app)
.get('/api/admin/users')
.set('Cookie', ['auth_token=invalid-token']);
expect(response.status).toBe(401);
});
it('should return 401 for disabled users even if they have admin role', async () => {
// Create a disabled admin
const disabledAdmin = createTestAdmin({ isActive: false });
await insertTestUser(db, disabledAdmin);
const token = generateTestToken(disabledAdmin._id);
const response = await request(app)
.get('/api/admin/users')
.set('Cookie', [`auth_token=${token}`]);
expect(response.status).toBe(401);
});
});
/**
* MW-1.4: /api/health must remain public
*/
describe('MW-1.4: Health endpoint remains public', () => {
it('should return 200 on /api/health without authentication', async () => {
const response = await request(app)
.get('/api/health');
expect(response.status).toBe(200);
expect(response.body.status).toBe('ok');
});
it('should not require admin role for /api/health', async () => {
// Regular user accessing health endpoint
const user = createTestUser({ role: 'user' });
await insertTestUser(db, user);
const token = generateTestToken(user._id);
const response = await request(app)
.get('/api/health')
.set('Cookie', [`auth_token=${token}`]);
expect(response.status).toBe(200);
});
});
});
// =============================================================================
// 1.3 USER MANAGEMENT API TESTS
// These tests verify the admin user management endpoints
// =============================================================================
describe('1.3 User Management API', () => {
let adminUser;
let adminToken;
beforeEach(async () => {
// Create an admin user for testing admin endpoints
adminUser = createTestAdmin();
await insertTestUser(db, adminUser);
adminToken = generateTestToken(adminUser._id);
});
/**
* API-1.1: GET /api/admin/users - Paginated user list
*/
describe('API-1.1: GET /api/admin/users', () => {
it('should return paginated list of users', async () => {
// Create some test users
for (let i = 0; i < 25; i++) {
await insertTestUser(db, createTestUser({
email: `user${i}@example.com`,
name: `User ${i}`
}));
}
const response = await request(app)
.get('/api/admin/users')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.users).toBeDefined();
expect(Array.isArray(response.body.users)).toBe(true);
expect(response.body.pagination).toBeDefined();
expect(response.body.pagination.page).toBe(1);
expect(response.body.pagination.limit).toBe(20);
expect(response.body.pagination.total).toBeGreaterThan(0);
expect(response.body.pagination.pages).toBeDefined();
});
it('should support page parameter', async () => {
// Create 30 test users
for (let i = 0; i < 30; i++) {
await insertTestUser(db, createTestUser({
email: `user${i}@example.com`
}));
}
const response = await request(app)
.get('/api/admin/users?page=2')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.pagination.page).toBe(2);
});
it('should support limit parameter with max 100', async () => {
const response = await request(app)
.get('/api/admin/users?limit=50')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.pagination.limit).toBe(50);
});
it('should cap limit at 100', async () => {
const response = await request(app)
.get('/api/admin/users?limit=200')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.pagination.limit).toBeLessThanOrEqual(100);
});
it('should support search parameter for name', async () => {
await insertTestUser(db, createTestUser({ name: 'John Smith' }));
await insertTestUser(db, createTestUser({ name: 'Jane Doe' }));
const response = await request(app)
.get('/api/admin/users?search=John')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.users.some(u => u.name.includes('John'))).toBe(true);
expect(response.body.users.every(u =>
u.name.toLowerCase().includes('john') ||
u.email.toLowerCase().includes('john')
)).toBe(true);
});
it('should support search parameter for email', async () => {
await insertTestUser(db, createTestUser({ email: 'unique-test@example.com' }));
const response = await request(app)
.get('/api/admin/users?search=unique-test')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.users.some(u => u.email.includes('unique-test'))).toBe(true);
});
it('should support status filter "active"', async () => {
await insertTestUser(db, createTestUser({ isActive: true }));
await insertTestUser(db, createTestUser({ isActive: false }));
const response = await request(app)
.get('/api/admin/users?status=active')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.users.every(u => u.isActive === true)).toBe(true);
});
it('should support status filter "disabled"', async () => {
await insertTestUser(db, createTestUser({ isActive: false }));
const response = await request(app)
.get('/api/admin/users?status=disabled')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.users.every(u => u.isActive === false)).toBe(true);
});
it('should support sort parameter', async () => {
const oldUser = createTestUser({
createdAt: new Date('2023-01-01'),
email: 'old@example.com'
});
const newUser = createTestUser({
createdAt: new Date('2024-01-01'),
email: 'new@example.com'
});
await insertTestUser(db, oldUser);
await insertTestUser(db, newUser);
const response = await request(app)
.get('/api/admin/users?sort=createdAt&order=desc')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
// Newest first when sorted descending
const createdDates = response.body.users.map(u => new Date(u.createdAt));
for (let i = 0; i < createdDates.length - 1; i++) {
expect(createdDates[i] >= createdDates[i + 1]).toBe(true);
}
});
it('should support order parameter "asc" and "desc"', async () => {
const response = await request(app)
.get('/api/admin/users?sort=loginCount&order=asc')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
// Verify ascending order
const counts = response.body.users.map(u => u.loginCount);
for (let i = 0; i < counts.length - 1; i++) {
expect(counts[i] <= counts[i + 1]).toBe(true);
}
});
it('should require admin role', async () => {
const regularUser = createTestUser({ role: 'user' });
await insertTestUser(db, regularUser);
const userToken = generateTestToken(regularUser._id);
const response = await request(app)
.get('/api/admin/users')
.set('Cookie', [`auth_token=${userToken}`]);
expect(response.status).toBe(403);
});
});
/**
* API-1.2: GET /api/admin/users/:id - User details with recent activity
*/
describe('API-1.2: GET /api/admin/users/:id', () => {
it('should return user details for valid user ID', async () => {
const testUser = createTestUser();
await insertTestUser(db, testUser);
const response = await request(app)
.get(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user._id.toString()).toBe(testUser._id.toString());
expect(response.body.user.email).toBe(testUser.email);
expect(response.body.user.name).toBe(testUser.name);
expect(response.body.user.role).toBe(testUser.role);
});
it('should include recent activity for the user', async () => {
const testUser = createTestUser();
await insertTestUser(db, testUser);
// Create some activity for this user
await db.collection('user_activity').insertMany([
{
userId: testUser._id,
action: 'PAGE_VIEW',
metadata: { path: '/dashboard' },
timestamp: new Date(),
sessionId: 'session-1'
},
{
userId: testUser._id,
action: 'LOGIN',
timestamp: new Date(),
sessionId: 'session-1'
}
]);
const response = await request(app)
.get(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
expect(response.body.user.recentActivity).toBeDefined();
expect(Array.isArray(response.body.user.recentActivity)).toBe(true);
});
it('should return 404 for non-existent user ID', async () => {
const nonExistentId = new ObjectId();
const response = await request(app)
.get(`/api/admin/users/${nonExistentId}`)
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(404);
expect(response.body.error).toBeDefined();
});
it('should return 400 for invalid user ID format', async () => {
const response = await request(app)
.get('/api/admin/users/invalid-id')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should require admin role', async () => {
const regularUser = createTestUser({ role: 'user' });
await insertTestUser(db, regularUser);
const userToken = generateTestToken(regularUser._id);
const response = await request(app)
.get(`/api/admin/users/${regularUser._id}`)
.set('Cookie', [`auth_token=${userToken}`]);
expect(response.status).toBe(403);
});
});
/**
* API-1.3: PATCH /api/admin/users/:id - Enable/disable user
*/
describe('API-1.3: PATCH /api/admin/users/:id (enable/disable)', () => {
it('should disable a user successfully', async () => {
const testUser = createTestUser({ isActive: true });
await insertTestUser(db, testUser);
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: false });
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.isActive).toBe(false);
expect(response.body.message).toContain('disabled');
// Verify in database
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
expect(dbUser.isActive).toBe(false);
});
it('should enable a disabled user successfully', async () => {
const testUser = createTestUser({ isActive: false });
await insertTestUser(db, testUser);
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: true });
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.isActive).toBe(true);
expect(response.body.message).toContain('enabled');
});
it('should set disabledAt timestamp when disabling', async () => {
const testUser = createTestUser({ isActive: true });
await insertTestUser(db, testUser);
const beforeDisable = new Date();
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: false });
expect(response.status).toBe(200);
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
expect(dbUser.disabledAt).toBeInstanceOf(Date);
expect(dbUser.disabledAt.getTime()).toBeGreaterThanOrEqual(beforeDisable.getTime());
});
it('should set disabledBy to admin ID when disabling', async () => {
const testUser = createTestUser({ isActive: true });
await insertTestUser(db, testUser);
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: false });
expect(response.status).toBe(200);
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
expect(dbUser.disabledBy).toEqual(adminUser._id);
});
it('should clear disabledAt and disabledBy when enabling', async () => {
const testUser = createTestUser({
isActive: false,
disabledAt: new Date(),
disabledBy: new ObjectId()
});
await insertTestUser(db, testUser);
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: true });
expect(response.status).toBe(200);
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
expect(dbUser.disabledAt).toBeNull();
expect(dbUser.disabledBy).toBeNull();
});
it('should return 400 when trying to disable yourself', async () => {
const response = await request(app)
.patch(`/api/admin/users/${adminUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: false });
expect(response.status).toBe(400);
expect(response.body.error).toContain('yourself');
});
it('should return 404 for non-existent user', async () => {
const nonExistentId = new ObjectId();
const response = await request(app)
.patch(`/api/admin/users/${nonExistentId}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: false });
expect(response.status).toBe(404);
});
it('should require admin role', async () => {
const regularUser = createTestUser({ role: 'user' });
const targetUser = createTestUser();
await insertTestUser(db, regularUser);
await insertTestUser(db, targetUser);
const userToken = generateTestToken(regularUser._id);
const response = await request(app)
.patch(`/api/admin/users/${targetUser._id}`)
.set('Cookie', [`auth_token=${userToken}`])
.send({ isActive: false });
expect(response.status).toBe(403);
});
});
/**
* API-1.4: PATCH /api/admin/users/:id/role - Promote/demote user role
*/
describe('API-1.4: PATCH /api/admin/users/:id/role (promote/demote)', () => {
it('should promote a user to admin', async () => {
const testUser = createTestUser({ role: 'user' });
await insertTestUser(db, testUser);
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}/role`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ role: 'admin' });
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.role).toBe('admin');
expect(response.body.message).toContain('promoted');
// Verify in database
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
expect(dbUser.role).toBe('admin');
});
it('should demote an admin to user', async () => {
const anotherAdmin = createTestAdmin({ email: 'another@admin.com' });
await insertTestUser(db, anotherAdmin);
const response = await request(app)
.patch(`/api/admin/users/${anotherAdmin._id}/role`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ role: 'user' });
expect(response.status).toBe(200);
expect(response.body.user).toBeDefined();
expect(response.body.user.role).toBe('user');
expect(response.body.message).toContain('demoted');
});
it('should return 400 when trying to demote yourself from admin', async () => {
const response = await request(app)
.patch(`/api/admin/users/${adminUser._id}/role`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ role: 'user' });
expect(response.status).toBe(400);
expect(response.body.error).toContain('yourself');
});
it('should return 400 for invalid role value', async () => {
const testUser = createTestUser();
await insertTestUser(db, testUser);
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}/role`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ role: 'superadmin' });
expect(response.status).toBe(400);
expect(response.body.error).toBeDefined();
});
it('should return 400 when role is not provided', async () => {
const testUser = createTestUser();
await insertTestUser(db, testUser);
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}/role`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({});
expect(response.status).toBe(400);
});
it('should return 404 for non-existent user', async () => {
const nonExistentId = new ObjectId();
const response = await request(app)
.patch(`/api/admin/users/${nonExistentId}/role`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ role: 'admin' });
expect(response.status).toBe(404);
});
it('should require admin role', async () => {
const regularUser = createTestUser({ role: 'user' });
const targetUser = createTestUser();
await insertTestUser(db, regularUser);
await insertTestUser(db, targetUser);
const userToken = generateTestToken(regularUser._id);
const response = await request(app)
.patch(`/api/admin/users/${targetUser._id}/role`)
.set('Cookie', [`auth_token=${userToken}`])
.send({ role: 'admin' });
expect(response.status).toBe(403);
});
it('should only allow "user" or "admin" roles', async () => {
const testUser = createTestUser();
await insertTestUser(db, testUser);
const invalidRoles = ['superuser', 'moderator', 'guest', '', null, 123];
for (const invalidRole of invalidRoles) {
const response = await request(app)
.patch(`/api/admin/users/${testUser._id}/role`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ role: invalidRole });
expect(response.status).toBe(400);
}
});
});
/**
* API-1.5: General API security tests
*/
describe('API-1.5: API Security', () => {
it('should not expose sensitive user data in list response', async () => {
const testUser = createTestUser();
await insertTestUser(db, testUser);
const response = await request(app)
.get('/api/admin/users')
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(200);
// Check that sensitive fields are not exposed
const userInResponse = response.body.users.find(
u => u._id.toString() === testUser._id.toString()
);
if (userInResponse) {
// googleId should potentially be hidden or sanitized in responses
// This depends on your security requirements
expect(userInResponse.password).toBeUndefined();
}
});
it('should validate ObjectId format in URL parameters', async () => {
const invalidIds = ['123', 'abc', '!@#$%', ' ', ''];
for (const invalidId of invalidIds) {
const response = await request(app)
.get(`/api/admin/users/${invalidId}`)
.set('Cookie', [`auth_token=${adminToken}`]);
expect(response.status).toBe(400);
}
});
it('should handle concurrent requests safely', async () => {
const testUser = createTestUser();
await insertTestUser(db, testUser);
// Send multiple concurrent disable/enable requests
const requests = [
request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: false }),
request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: true }),
request(app)
.patch(`/api/admin/users/${testUser._id}`)
.set('Cookie', [`auth_token=${adminToken}`])
.send({ isActive: false })
];
const responses = await Promise.all(requests);
// All requests should complete without errors (200) or with consistent state
responses.forEach(response => {
expect([200, 400, 404, 409]).toContain(response.status);
});
// Final state should be consistent
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
expect(typeof dbUser.isActive).toBe('boolean');
});
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,979 @@
/**
* Phase 4: Settings & Security Tests
*
* Tests for Admin Dashboard Phase 4 implementation as specified in ADMIN.md
* Reference: Implementation Phases -> Phase 4: Settings & Polish
*
* Endpoints tested:
* - GET /api/admin/settings (API-4.1)
* - PATCH /api/admin/settings (API-4.2, API-4.3)
*
* Security features tested:
* - Admin action audit logging (SEC-4.1)
* - Last admin protection (SEC-4.2)
* - Rate limiting on admin endpoints (SEC-4.3)
*
* These tests are designed to FAIL initially as the endpoints do not exist yet.
* They serve as the specification for implementing the Settings & Polish features.
*/
const request = require('supertest');
const { MongoClient, ObjectId } = require('mongodb');
const {
createTestApp,
generateTestToken,
createTestUser,
createTestAdmin,
cleanupTestData
} = require('./helpers/testHelpers');
// Test configuration - uses environment set by global setup
const TEST_JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-for-testing-only';
let app;
let db;
let client;
// Test user fixtures - created fresh for each test
let testUsers = {};
/**
* Helper to make authenticated requests
* @param {string} method - HTTP method (get, post, patch, delete)
* @param {string} url - Request URL
* @param {Object} user - User to authenticate as
* @returns {Object} Supertest request
*/
const authenticatedRequest = (method, url, user) => {
const token = generateTestToken(user._id);
return request(app)[method](url)
.set('Cookie', `auth_token=${token}`);
};
/**
* Helper object for cleaner authenticated request syntax
* @param {Object} user - User to authenticate as
* @returns {Object} Object with HTTP method functions
*/
const authAs = (user) => ({
get: (url) => authenticatedRequest('get', url, user),
post: (url) => authenticatedRequest('post', url, user),
patch: (url) => authenticatedRequest('patch', url, user),
delete: (url) => authenticatedRequest('delete', url, user)
});
describe('Phase 4: Settings & Security', () => {
beforeAll(async () => {
// Connect to test database (MongoDB Memory Server from global setup)
const mongoUri = process.env.MONGO_URI || 'mongodb://localhost:27017';
client = new MongoClient(mongoUri);
await client.connect();
db = client.db('apartments_test');
// Create the test app with our database
app = await createTestApp(db);
});
afterAll(async () => {
if (db) {
await cleanupTestData(db);
await db.collection('settings').deleteMany({});
}
if (client) {
await client.close();
}
});
beforeEach(async () => {
// Clean up all test data
await cleanupTestData(db);
await db.collection('settings').deleteMany({});
// Create fresh test users with new ObjectIds for each test
testUsers = {
admin: createTestAdmin({
_id: new ObjectId(),
email: 'admin@example.com',
name: 'Test Admin'
}),
secondAdmin: createTestAdmin({
_id: new ObjectId(),
email: 'admin2@example.com',
name: 'Second Admin'
}),
regularUser: createTestUser({
_id: new ObjectId(),
email: 'user@example.com',
name: 'Test User'
}),
disabledUser: createTestUser({
_id: new ObjectId(),
email: 'disabled@example.com',
name: 'Disabled User',
isActive: false
})
};
// Insert primary admin and regular user
await db.collection('users').insertMany([
testUsers.admin,
testUsers.regularUser
]);
// Insert default settings document
await db.collection('settings').insertOne({
_id: 'admin_settings',
activityRetentionDays: 90,
activityLogLevel: 'all',
updatedAt: new Date(),
updatedBy: null
});
});
// ============================================================
// API-4.1: GET /api/admin/settings - Get admin settings
// ============================================================
describe('API-4.1: GET /api/admin/settings', () => {
describe('Authorization', () => {
it('should return 401 when no authentication token is provided', async () => {
const response = await request(app)
.get('/api/admin/settings')
.expect(401);
expect(response.body).toHaveProperty('error');
});
it('should return 403 when user is not an admin', async () => {
const response = await authAs(testUsers.regularUser)
.get('/api/admin/settings')
.expect(403);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/admin/i);
});
it('should return 401 when admin user is disabled', async () => {
// Insert disabled admin
const disabledAdmin = createTestAdmin({
_id: new ObjectId(),
email: 'disabled-admin@example.com',
isActive: false
});
await db.collection('users').insertOne(disabledAdmin);
const response = await authAs(disabledAdmin)
.get('/api/admin/settings')
.expect(401);
expect(response.body).toHaveProperty('error');
});
it('should return 200 when user is an active admin', async () => {
const response = await authAs(testUsers.admin)
.get('/api/admin/settings')
.expect(200);
expect(response.body).toHaveProperty('activityRetentionDays');
expect(response.body).toHaveProperty('activityLogLevel');
});
});
describe('Response format', () => {
it('should return activityRetentionDays as a number', async () => {
const response = await authAs(testUsers.admin)
.get('/api/admin/settings')
.expect(200);
expect(typeof response.body.activityRetentionDays).toBe('number');
});
it('should return activityLogLevel as a string', async () => {
const response = await authAs(testUsers.admin)
.get('/api/admin/settings')
.expect(200);
expect(typeof response.body.activityLogLevel).toBe('string');
});
it('should return default values when no settings document exists', async () => {
// Remove settings document
await db.collection('settings').deleteMany({});
const response = await authAs(testUsers.admin)
.get('/api/admin/settings')
.expect(200);
// Should return defaults: 90 days retention, 'all' log level
expect(response.body.activityRetentionDays).toBe(90);
expect(response.body.activityLogLevel).toBe('all');
});
it('should return stored values when settings exist', async () => {
// Update settings to non-default values
await db.collection('settings').updateOne(
{ _id: 'admin_settings' },
{ $set: { activityRetentionDays: 180, activityLogLevel: 'navigation' } }
);
const response = await authAs(testUsers.admin)
.get('/api/admin/settings')
.expect(200);
expect(response.body.activityRetentionDays).toBe(180);
expect(response.body.activityLogLevel).toBe('navigation');
});
});
});
// ============================================================
// API-4.2: PATCH /api/admin/settings - Update settings
// ============================================================
describe('API-4.2: PATCH /api/admin/settings', () => {
describe('Authorization', () => {
it('should return 401 when no authentication token is provided', async () => {
const response = await request(app)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 60 })
.expect(401);
expect(response.body).toHaveProperty('error');
});
it('should return 403 when user is not an admin', async () => {
const response = await authAs(testUsers.regularUser)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 60 })
.expect(403);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/admin/i);
});
it('should return 200 when user is an active admin', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 60 })
.expect(200);
expect(response.body).toHaveProperty('settings');
expect(response.body).toHaveProperty('message');
});
});
describe('Validation - Retention period (API-4.2)', () => {
it('should return 400 for retention period below 30 days', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 29 })
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/30|365|range|invalid/i);
});
it('should return 400 for retention period above 365 days', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 366 })
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/30|365|range|invalid/i);
});
it('should accept retention period at minimum boundary (30 days)', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 30 })
.expect(200);
expect(response.body.settings.activityRetentionDays).toBe(30);
});
it('should accept retention period at maximum boundary (365 days)', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 365 })
.expect(200);
expect(response.body.settings.activityRetentionDays).toBe(365);
});
it('should accept valid retention period within range (180 days)', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 180 })
.expect(200);
expect(response.body.settings.activityRetentionDays).toBe(180);
});
it('should return 400 for non-numeric retention period', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 'ninety' })
.expect(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for negative retention period', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: -30 })
.expect(400);
expect(response.body).toHaveProperty('error');
});
it('should return 400 for decimal retention period', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 90.5 })
.expect(400);
expect(response.body).toHaveProperty('error');
});
});
describe('Successful update', () => {
it('should update retention period and return success message', async () => {
const response = await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 120 })
.expect(200);
expect(response.body.settings.activityRetentionDays).toBe(120);
expect(response.body.message).toBe('Settings updated successfully');
});
it('should persist the updated settings in database', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 150 })
.expect(200);
const settings = await db.collection('settings').findOne({ _id: 'admin_settings' });
expect(settings.activityRetentionDays).toBe(150);
});
it('should update updatedAt timestamp', async () => {
const beforeUpdate = new Date();
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 100 })
.expect(200);
const afterUpdate = new Date();
const settings = await db.collection('settings').findOne({ _id: 'admin_settings' });
expect(new Date(settings.updatedAt).getTime()).toBeGreaterThanOrEqual(beforeUpdate.getTime());
expect(new Date(settings.updatedAt).getTime()).toBeLessThanOrEqual(afterUpdate.getTime());
});
it('should record which admin updated the settings', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 100 })
.expect(200);
const settings = await db.collection('settings').findOne({ _id: 'admin_settings' });
expect(settings.updatedBy.toString()).toBe(testUsers.admin._id.toString());
});
});
});
// ============================================================
// API-4.3: TTL Index Update
// ============================================================
describe('API-4.3: TTL Index Update', () => {
it('should update TTL index when retention period changes to 60 days', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 60 })
.expect(200);
// Check the TTL index on user_activity collection
const indexes = await db.collection('user_activity').indexes();
const ttlIndex = indexes.find(
idx => idx.name === 'timestamp_ttl' || (idx.key && idx.key.timestamp === 1)
);
expect(ttlIndex).toBeDefined();
// 60 days = 60 * 24 * 60 * 60 = 5,184,000 seconds
expect(ttlIndex.expireAfterSeconds).toBe(60 * 24 * 60 * 60);
});
it('should update TTL index when retention period changes to 30 days', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 30 })
.expect(200);
const indexes = await db.collection('user_activity').indexes();
const ttlIndex = indexes.find(
idx => idx.name === 'timestamp_ttl' || (idx.key && idx.key.timestamp === 1)
);
expect(ttlIndex).toBeDefined();
// 30 days = 2,592,000 seconds
expect(ttlIndex.expireAfterSeconds).toBe(30 * 24 * 60 * 60);
});
it('should update TTL index when retention period changes to 365 days', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 365 })
.expect(200);
const indexes = await db.collection('user_activity').indexes();
const ttlIndex = indexes.find(
idx => idx.name === 'timestamp_ttl' || (idx.key && idx.key.timestamp === 1)
);
expect(ttlIndex).toBeDefined();
// 365 days = 31,536,000 seconds
expect(ttlIndex.expireAfterSeconds).toBe(365 * 24 * 60 * 60);
});
it('should correctly calculate TTL seconds for various retention periods', async () => {
const testCases = [
{ days: 30, expectedSeconds: 2592000 },
{ days: 90, expectedSeconds: 7776000 },
{ days: 180, expectedSeconds: 15552000 },
{ days: 365, expectedSeconds: 31536000 }
];
for (const { days, expectedSeconds } of testCases) {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: days })
.expect(200);
const indexes = await db.collection('user_activity').indexes();
const ttlIndex = indexes.find(
idx => idx.name === 'timestamp_ttl' || (idx.key && idx.key.timestamp === 1)
);
expect(ttlIndex).toBeDefined();
expect(ttlIndex.expireAfterSeconds).toBe(expectedSeconds);
}
});
});
// ============================================================
// SEC-4.1: Admin Action Audit Logging
// ============================================================
describe('SEC-4.1: Admin Action Audit Logging', () => {
describe('ADMIN_UPDATE_SETTINGS', () => {
it('should log ADMIN_UPDATE_SETTINGS action when updating settings', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 120 })
.expect(200);
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_UPDATE_SETTINGS'
});
expect(activity).toBeDefined();
expect(activity).not.toBeNull();
});
it('should record admin user ID in audit log', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 120 })
.expect(200);
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_UPDATE_SETTINGS'
});
expect(activity.userId.toString()).toBe(testUsers.admin._id.toString());
});
it('should record new retention value in audit log metadata', async () => {
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 150 })
.expect(200);
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_UPDATE_SETTINGS'
});
expect(activity.metadata).toHaveProperty('activityRetentionDays', 150);
});
it('should include timestamp in audit log', async () => {
const beforeTime = new Date();
await authAs(testUsers.admin)
.patch('/api/admin/settings')
.send({ activityRetentionDays: 100 })
.expect(200);
const afterTime = new Date();
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_UPDATE_SETTINGS'
});
expect(activity.timestamp).toBeDefined();
expect(new Date(activity.timestamp).getTime()).toBeGreaterThanOrEqual(beforeTime.getTime());
expect(new Date(activity.timestamp).getTime()).toBeLessThanOrEqual(afterTime.getTime());
});
});
describe('ADMIN_DISABLE_USER', () => {
it('should log ADMIN_DISABLE_USER action when disabling a user', async () => {
await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.regularUser._id}`)
.send({ isActive: false })
.expect(200);
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_DISABLE_USER'
});
expect(activity).toBeDefined();
expect(activity).not.toBeNull();
expect(activity.userId.toString()).toBe(testUsers.admin._id.toString());
expect(activity.metadata.targetUserId).toBe(testUsers.regularUser._id.toString());
});
});
describe('ADMIN_ENABLE_USER', () => {
it('should log ADMIN_ENABLE_USER action when enabling a user', async () => {
// First disable the user
await db.collection('users').updateOne(
{ _id: testUsers.regularUser._id },
{ $set: { isActive: false } }
);
await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.regularUser._id}`)
.send({ isActive: true })
.expect(200);
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_ENABLE_USER'
});
expect(activity).toBeDefined();
expect(activity).not.toBeNull();
expect(activity.userId.toString()).toBe(testUsers.admin._id.toString());
expect(activity.metadata.targetUserId).toBe(testUsers.regularUser._id.toString());
});
});
describe('ADMIN_PROMOTE_USER', () => {
it('should log ADMIN_PROMOTE_USER action when promoting to admin', async () => {
await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.regularUser._id}/role`)
.send({ role: 'admin' })
.expect(200);
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_PROMOTE_USER'
});
expect(activity).toBeDefined();
expect(activity).not.toBeNull();
expect(activity.userId.toString()).toBe(testUsers.admin._id.toString());
expect(activity.metadata.targetUserId).toBe(testUsers.regularUser._id.toString());
expect(activity.metadata.newRole).toBe('admin');
});
});
describe('ADMIN_DEMOTE_USER', () => {
beforeEach(async () => {
// Insert second admin for demotion tests
await db.collection('users').insertOne(testUsers.secondAdmin);
});
it('should log ADMIN_DEMOTE_USER action when demoting from admin', async () => {
await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.secondAdmin._id}/role`)
.send({ role: 'user' })
.expect(200);
const activity = await db.collection('user_activity').findOne({
action: 'ADMIN_DEMOTE_USER'
});
expect(activity).toBeDefined();
expect(activity).not.toBeNull();
expect(activity.userId.toString()).toBe(testUsers.admin._id.toString());
expect(activity.metadata.targetUserId).toBe(testUsers.secondAdmin._id.toString());
expect(activity.metadata.newRole).toBe('user');
});
});
});
// ============================================================
// SEC-4.2: Last Admin Protection
// ============================================================
describe('SEC-4.2: Last Admin Protection', () => {
it('should return 400 when trying to demote the last admin', async () => {
// Ensure only one admin exists
await db.collection('users').deleteMany({
role: 'admin',
_id: { $ne: testUsers.admin._id }
});
const response = await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.admin._id}/role`)
.send({ role: 'user' })
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/last admin|at least one admin|cannot demote/i);
});
it('should allow demoting an admin when other active admins exist', async () => {
// Insert second admin
await db.collection('users').insertOne(testUsers.secondAdmin);
// Demote second admin (should succeed)
const response = await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.secondAdmin._id}/role`)
.send({ role: 'user' })
.expect(200);
expect(response.body.user.role).toBe('user');
});
it('should not allow self-demotion even when other admins exist', async () => {
// Insert second admin
await db.collection('users').insertOne(testUsers.secondAdmin);
// Try to demote self
const response = await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.admin._id}/role`)
.send({ role: 'user' })
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toMatch(/cannot demote yourself|self|own/i);
});
it('should count only active admins when checking for last admin', async () => {
// Insert a disabled admin (should not count)
const disabledAdmin = createTestAdmin({
_id: new ObjectId(),
email: 'disabled-admin@example.com',
isActive: false
});
await db.collection('users').insertOne(disabledAdmin);
// Try to demote self (should fail - only one active admin)
const response = await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.admin._id}/role`)
.send({ role: 'user' })
.expect(400);
expect(response.body.error).toMatch(/last admin|at least one admin|cannot demote/i);
});
it('should verify admin count after demotion would leave at least one admin', async () => {
// Insert second admin
await db.collection('users').insertOne(testUsers.secondAdmin);
// Verify we can demote the second admin
const response1 = await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.secondAdmin._id}/role`)
.send({ role: 'user' })
.expect(200);
expect(response1.body.user.role).toBe('user');
// Now the primary admin is the last one, cannot demote
const response2 = await authAs(testUsers.admin)
.patch(`/api/admin/users/${testUsers.admin._id}/role`)
.send({ role: 'user' })
.expect(400);
expect(response2.body.error).toMatch(/last admin|at least one admin/i);
});
});
// ============================================================
// SEC-4.3: Rate Limiting on Admin Endpoints
// ============================================================
describe('SEC-4.3: Rate Limiting', () => {
/**
* Helper to make multiple rapid requests
* @param {string} endpoint - API endpoint
* @param {number} count - Number of requests
* @param {string} method - HTTP method
* @param {Object} body - Request body for POST/PATCH
* @returns {Promise<Array>} Array of responses
*/
async function makeRapidRequests(endpoint, count, method = 'get', body = null) {
const requests = [];
for (let i = 0; i < count; i++) {
let req = authAs(testUsers.admin)[method](endpoint);
if (body && (method === 'patch' || method === 'post')) {
req = req.send(body);
}
requests.push(req);
}
return Promise.all(requests);
}
describe('User list endpoint rate limiting (60 req/min)', () => {
it('should allow up to 60 requests per minute to user list', async () => {
const responses = await makeRapidRequests('/api/admin/users', 60);
// All 60 requests should succeed
const successCount = responses.filter(r => r.status === 200).length;
expect(successCount).toBe(60);
});
it('should return 429 when exceeding 60 requests per minute', async () => {
const responses = await makeRapidRequests('/api/admin/users', 65);
// At least some should be rate limited
const rateLimited = responses.filter(r => r.status === 429);
expect(rateLimited.length).toBeGreaterThan(0);
// Rate limited response should have appropriate error
if (rateLimited.length > 0) {
expect(rateLimited[0].body).toHaveProperty('error');
expect(rateLimited[0].body.error).toMatch(/rate limit|too many requests/i);
}
});
});
describe('User updates endpoint rate limiting (30 req/min)', () => {
it('should allow up to 30 requests per minute for user updates', async () => {
const responses = await makeRapidRequests(
`/api/admin/users/${testUsers.regularUser._id}`,
30,
'patch',
{ isActive: true }
);
const successCount = responses.filter(r => r.status === 200).length;
expect(successCount).toBe(30);
});
it('should return 429 when exceeding 30 user update requests per minute', async () => {
const responses = await makeRapidRequests(
`/api/admin/users/${testUsers.regularUser._id}`,
35,
'patch',
{ isActive: true }
);
const rateLimited = responses.filter(r => r.status === 429);
expect(rateLimited.length).toBeGreaterThan(0);
});
});
describe('Stats endpoints rate limiting (30 req/min)', () => {
const statsEndpoints = [
'/api/admin/stats/overview',
'/api/admin/stats/active-users',
'/api/admin/stats/actions',
'/api/admin/stats/peak-times'
];
it('should allow up to 30 requests per minute to stats overview', async () => {
const responses = await makeRapidRequests('/api/admin/stats/overview', 30);
const successCount = responses.filter(r => r.status === 200).length;
expect(successCount).toBe(30);
});
it('should return 429 when exceeding 30 stats requests per minute', async () => {
const responses = await makeRapidRequests('/api/admin/stats/overview', 35);
const rateLimited = responses.filter(r => r.status === 429);
expect(rateLimited.length).toBeGreaterThan(0);
});
it('should share rate limit across all stats endpoints', async () => {
// Make requests across different stats endpoints
const requests = [];
for (let i = 0; i < 40; i++) {
const endpoint = statsEndpoints[i % statsEndpoints.length];
requests.push(authAs(testUsers.admin).get(endpoint));
}
const responses = await Promise.all(requests);
const rateLimited = responses.filter(r => r.status === 429);
// After 30 total requests, should start rate limiting
expect(rateLimited.length).toBeGreaterThan(0);
});
});
describe('Rate limit headers', () => {
it('should include rate limit headers in response', async () => {
const response = await authAs(testUsers.admin)
.get('/api/admin/users')
.expect(200);
// Check for common rate limit header variations
const hasRateLimitHeader =
response.headers['x-ratelimit-limit'] ||
response.headers['ratelimit-limit'] ||
response.headers['x-rate-limit-limit'];
expect(hasRateLimitHeader).toBeDefined();
});
it('should include remaining requests in headers', async () => {
const response = await authAs(testUsers.admin)
.get('/api/admin/users')
.expect(200);
const hasRemainingHeader =
response.headers['x-ratelimit-remaining'] ||
response.headers['ratelimit-remaining'] ||
response.headers['x-rate-limit-remaining'];
expect(hasRemainingHeader).toBeDefined();
});
it('should include reset time in headers', async () => {
const response = await authAs(testUsers.admin)
.get('/api/admin/users')
.expect(200);
const hasResetHeader =
response.headers['x-ratelimit-reset'] ||
response.headers['ratelimit-reset'] ||
response.headers['x-rate-limit-reset'];
expect(hasResetHeader).toBeDefined();
});
});
});
});
// ============================================================
// Integration Test: Full Settings Update Workflow
// ============================================================
describe('Integration: Settings Update Workflow', () => {
let app;
let db;
let client;
let testAdmin;
beforeAll(async () => {
const mongoUri = process.env.MONGO_URI || 'mongodb://localhost:27017';
client = new MongoClient(mongoUri);
await client.connect();
db = client.db('apartments_test');
app = await createTestApp(db);
});
afterAll(async () => {
if (db) {
await cleanupTestData(db);
await db.collection('settings').deleteMany({});
}
if (client) {
await client.close();
}
});
beforeEach(async () => {
await cleanupTestData(db);
await db.collection('settings').deleteMany({});
testAdmin = createTestAdmin({
_id: new ObjectId(),
email: 'integration-admin@example.com'
});
await db.collection('users').insertOne(testAdmin);
await db.collection('settings').insertOne({
_id: 'admin_settings',
activityRetentionDays: 90,
activityLogLevel: 'all',
updatedAt: new Date(),
updatedBy: null
});
});
it('should complete full settings update workflow', async () => {
const authAdmin = (method, url) => {
const token = generateTestToken(testAdmin._id);
return request(app)[method](url).set('Cookie', `auth_token=${token}`);
};
// Step 1: Get current settings
const getResponse = await authAdmin('get', '/api/admin/settings')
.expect(200);
expect(getResponse.body.activityRetentionDays).toBe(90);
// Step 2: Update settings to new value
const updateResponse = await authAdmin('patch', '/api/admin/settings')
.send({ activityRetentionDays: 180 })
.expect(200);
expect(updateResponse.body.settings.activityRetentionDays).toBe(180);
expect(updateResponse.body.message).toBe('Settings updated successfully');
// Step 3: Verify settings persisted
const verifyResponse = await authAdmin('get', '/api/admin/settings')
.expect(200);
expect(verifyResponse.body.activityRetentionDays).toBe(180);
// Step 4: Verify audit log was created
const auditLog = await db.collection('user_activity').findOne({
action: 'ADMIN_UPDATE_SETTINGS'
});
expect(auditLog).toBeDefined();
expect(auditLog).not.toBeNull();
expect(auditLog.metadata.activityRetentionDays).toBe(180);
expect(auditLog.userId.toString()).toBe(testAdmin._id.toString());
// Step 5: Verify TTL index was updated
const indexes = await db.collection('user_activity').indexes();
const ttlIndex = indexes.find(idx => idx.name === 'timestamp_ttl');
expect(ttlIndex).toBeDefined();
expect(ttlIndex.expireAfterSeconds).toBe(180 * 24 * 60 * 60);
// Step 6: Verify database document was updated
const dbSettings = await db.collection('settings').findOne({ _id: 'admin_settings' });
expect(dbSettings.activityRetentionDays).toBe(180);
expect(dbSettings.updatedBy.toString()).toBe(testAdmin._id.toString());
});
it('should handle multiple sequential settings updates', async () => {
const authAdmin = (method, url) => {
const token = generateTestToken(testAdmin._id);
return request(app)[method](url).set('Cookie', `auth_token=${token}`);
};
const retentionValues = [30, 60, 90, 180, 365];
for (const value of retentionValues) {
const response = await authAdmin('patch', '/api/admin/settings')
.send({ activityRetentionDays: value })
.expect(200);
expect(response.body.settings.activityRetentionDays).toBe(value);
// Verify TTL index after each update
const indexes = await db.collection('user_activity').indexes();
const ttlIndex = indexes.find(idx => idx.name === 'timestamp_ttl');
expect(ttlIndex.expireAfterSeconds).toBe(value * 24 * 60 * 60);
}
// Should have 5 audit log entries
const auditLogCount = await db.collection('user_activity').countDocuments({
action: 'ADMIN_UPDATE_SETTINGS'
});
expect(auditLogCount).toBe(5);
});
});

13
__tests__/setup.js Normal file
View File

@ -0,0 +1,13 @@
const { MongoMemoryServer } = require('mongodb-memory-server');
module.exports = async () => {
// Create an in-memory MongoDB instance for testing
const mongod = await MongoMemoryServer.create();
const uri = mongod.getUri();
// Store the URI and instance globally so tests can access them
global.__MONGOD__ = mongod;
process.env.MONGO_URI = uri;
process.env.JWT_SECRET = 'test-jwt-secret-for-testing-only';
process.env.NODE_ENV = 'test';
};

View File

@ -0,0 +1,11 @@
// Increase timeout for tests that interact with MongoDB
jest.setTimeout(30000);
// Suppress console logs during tests (optional - comment out for debugging)
// global.console = {
// ...console,
// log: jest.fn(),
// debug: jest.fn(),
// info: jest.fn(),
// warn: jest.fn(),
// };

6
__tests__/teardown.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = async () => {
// Stop the in-memory MongoDB instance
if (global.__MONGOD__) {
await global.__MONGOD__.stop();
}
};

17
jest.config.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.js'],
collectCoverageFrom: [
'**/*.js',
'!**/node_modules/**',
'!**/coverage/**',
'!jest.config.js'
],
coverageDirectory: 'coverage',
verbose: true,
testTimeout: 30000,
// Setup file to handle MongoDB memory server lifecycle
globalSetup: './__tests__/setup.js',
globalTeardown: './__tests__/teardown.js',
setupFilesAfterEnv: ['./__tests__/setupAfterEnv.js']
};

View File

@ -73,15 +73,37 @@ async function findById(db, id) {
* @param {Db} db - MongoDB database instance * @param {Db} db - MongoDB database instance
* @param {string|ObjectId} id - User's MongoDB _id * @param {string|ObjectId} id - User's MongoDB _id
* @param {boolean} isActive - New active status * @param {boolean} isActive - New active status
* @param {string|ObjectId|null} adminId - Admin performing the action (required when disabling)
* @returns {Promise<Object>} Update result * @returns {Promise<Object>} Update result
*/ */
async function setUserActive(db, id, isActive) { async function setUserActive(db, id, isActive, adminId = null) {
// Convert string to ObjectId if needed // Convert string to ObjectId if needed
const objectId = typeof id === 'string' ? new ObjectId(id) : id; const objectId = typeof id === 'string' ? new ObjectId(id) : id;
let updateDoc;
if (isActive) {
// Enabling: clear disabledAt and disabledBy
updateDoc = {
$set: { isActive: true, disabledAt: null, disabledBy: null }
};
} else {
// Disabling: set disabledAt and disabledBy
// Convert string to ObjectId if needed, keep ObjectId as-is
const adminObjectId = adminId && typeof adminId === 'string'
? new ObjectId(adminId)
: adminId;
updateDoc = {
$set: {
isActive: false,
disabledAt: new Date(),
disabledBy: adminObjectId
}
};
}
const result = await db.collection(USER_COLLECTION).findOneAndUpdate( const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
{ _id: objectId }, { _id: objectId },
{ $set: { isActive } }, updateDoc,
{ returnDocument: 'after' } { returnDocument: 'after' }
); );
@ -115,14 +137,117 @@ async function createIndexes(db) {
{ isActive: 1, lastLoginAt: -1 } { isActive: 1, lastLoginAt: -1 }
); );
// Index on role for admin queries
await collection.createIndex({ role: 1 });
// Compound index on isActive and role for filtered admin queries
await collection.createIndex({ isActive: 1, role: 1 });
// Index on createdAt for recent registrations
await collection.createIndex({ createdAt: -1 });
// Index on lastLoginAt for recently active users
await collection.createIndex({ lastLoginAt: -1 });
console.log('User collection indexes created successfully'); console.log('User collection indexes created successfully');
} }
/**
* Get paginated list of users with optional filtering and sorting
*
* @param {Db} db - MongoDB database instance
* @param {Object} options - Query options
* @param {number} options.page - Page number (1-indexed)
* @param {number} options.limit - Items per page (max 100)
* @param {string} options.search - Search term for name/email
* @param {string} options.status - Filter by status: 'all', 'active', 'disabled'
* @param {string} options.sort - Sort field: 'createdAt', 'lastLoginAt', 'loginCount'
* @param {string} options.order - Sort order: 'asc', 'desc'
* @returns {Promise<{users: Array, pagination: Object}>}
*/
async function getPaginatedUsers(db, options = {}) {
const page = Math.max(1, parseInt(options.page) || 1);
const limit = Math.min(100, Math.max(1, parseInt(options.limit) || 20));
const skip = (page - 1) * limit;
// Build filter
const filter = {};
if (options.search) {
const searchRegex = new RegExp(options.search, 'i');
filter.$or = [{ name: searchRegex }, { email: searchRegex }];
}
if (options.status === 'active') {
filter.isActive = true;
} else if (options.status === 'disabled') {
filter.isActive = false;
}
// Build sort
const sortField = ['createdAt', 'lastLoginAt', 'loginCount'].includes(options.sort)
? options.sort
: 'createdAt';
const sortOrder = options.order === 'asc' ? 1 : -1;
const sort = { [sortField]: sortOrder };
const collection = db.collection(USER_COLLECTION);
// Execute queries in parallel
const [users, total] = await Promise.all([
collection.find(filter).sort(sort).skip(skip).limit(limit).toArray(),
collection.countDocuments(filter)
]);
return {
users,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
}
/**
* Update a user's role
*
* @param {Db} db - MongoDB database instance
* @param {string|ObjectId} id - User's MongoDB _id
* @param {string} role - New role ('user' or 'admin')
* @returns {Promise<Object|null>} Updated user or null
*/
async function updateUserRole(db, id, role) {
const objectId = typeof id === 'string' ? new ObjectId(id) : id;
const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
{ _id: objectId },
{ $set: { role } },
{ returnDocument: 'after' }
);
return result;
}
/**
* Check if a string is a valid MongoDB ObjectId
*
* @param {string} id - String to validate
* @returns {boolean} True if valid ObjectId format
*/
function isValidObjectId(id) {
if (typeof id !== 'string') return false;
if (!id || !id.trim()) return false;
return /^[0-9a-fA-F]{24}$/.test(id);
}
module.exports = { module.exports = {
USER_COLLECTION, USER_COLLECTION,
findOrCreateUser, findOrCreateUser,
findByGoogleId, findByGoogleId,
findById, findById,
setUserActive, setUserActive,
createIndexes createIndexes,
getPaginatedUsers,
updateUserRole,
isValidObjectId
}; };

5013
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,9 @@
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js", "dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1" "test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}, },
"keywords": [ "keywords": [
"apartments", "apartments",
@ -28,7 +30,10 @@
"uuid": "^13.0.0" "uuid": "^13.0.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "jest": "^30.2.0",
"mongodb-memory-server": "^11.0.1",
"nodemon": "^3.0.2",
"supertest": "^7.2.2"
}, },
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"

1146
routes/admin.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ const { configurePassport } = require('./middleware/passport');
const { requireAuth } = require('./middleware/auth'); const { requireAuth } = require('./middleware/auth');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const activityRoutes = require('./routes/activity'); const activityRoutes = require('./routes/activity');
const adminRoutes = require('./routes/admin');
const { createIndexes } = require('./models/user'); const { createIndexes } = require('./models/user');
const { createActivityIndexes } = require('./services/activityLogger'); const { createActivityIndexes } = require('./services/activityLogger');
@ -146,6 +147,9 @@ app.use('/auth', authRoutes);
// Mount activity routes // Mount activity routes
app.use('/activity', activityRoutes); app.use('/activity', activityRoutes);
// Mount admin routes (Traefik strips /api prefix, so /api/admin becomes /admin)
app.use('/admin', adminRoutes);
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {
res.json({ res.json({
@ -369,15 +373,18 @@ app.get('/available-units', requireAuth, async (req, res) => {
} }
} }
} }
}
}
}, },
priceStats: { {
$let: { $addFields: {
vars: { // Filter prices to only include those from availability start date onwards
prices: { $map: { input: "$all_prices", as: "p", in: "$$p.price" } } availabilityPrices: {
$filter: {
input: "$all_prices",
cond: { $gte: ["$$this.date_checked", "$availabilityCalc.startDate"] }
}
}, },
in: {
minPrice: { $min: "$$prices" },
maxPrice: { $max: "$$prices" },
priceHistory: { priceHistory: {
$slice: [ $slice: [
{ $sortArray: { input: "$all_prices", sortBy: { date_checked: -1 } } }, { $sortArray: { input: "$all_prices", sortBy: { date_checked: -1 } } },
@ -385,6 +392,18 @@ app.get('/available-units', requireAuth, async (req, res) => {
] ]
} }
} }
},
{
$addFields: {
priceStats: {
$let: {
vars: {
prices: { $map: { input: "$availabilityPrices", as: "p", in: "$$p.price" } }
},
in: {
minPrice: { $min: "$$prices" },
maxPrice: { $max: "$$prices" }
}
} }
} }
} }
@ -411,7 +430,7 @@ app.get('/available-units', requireAuth, async (req, res) => {
}, },
priceHistory: { priceHistory: {
$map: { $map: {
input: "$priceStats.priceHistory", input: "$priceHistory",
as: "ph", as: "ph",
in: { in: {
date: "$$ph.date_checked", date: "$$ph.date_checked",

View File

@ -69,37 +69,68 @@ function shouldLog(action) {
return allowedActions.includes(action); return allowedActions.includes(action);
} }
/**
* Check if an action is an admin action (always logged regardless of log level)
* @param {string} action - Action to check
* @returns {boolean} True if admin action
*/
function isAdminAction(action) {
return action && action.startsWith('ADMIN_');
}
/** /**
* Log a user activity to the database * Log a user activity to the database
* Supports two calling conventions:
* 1. logActivity(db, userId, action, metadata, req) - positional parameters
* 2. logActivity(db, { userId, action, metadata }) - object parameter for admin actions
*
* @param {Db} db - MongoDB database instance * @param {Db} db - MongoDB database instance
* @param {string} userId - User ID (will be converted to ObjectId) * @param {string|Object} userIdOrOptions - User ID string or options object
* @param {string} action - Action type (use ACTIONS constants) * @param {string} [action] - Action type (use ACTIONS constants)
* @param {Object} metadata - Additional action-specific data * @param {Object} [metadata] - Additional action-specific data
* @param {Object} req - Express request object (for IP and user agent) * @param {Object} [req] - Express request object (for IP and user agent)
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
async function logActivity(db, userId, action, metadata = {}, req = null) { async function logActivity(db, userIdOrOptions, action, metadata = {}, req = null) {
// Only log if this action type is enabled at current log level let userId;
if (!shouldLog(action)) { let actualAction;
let actualMetadata;
let actualReq;
// Support object-based call for admin actions
if (typeof userIdOrOptions === 'object' && userIdOrOptions !== null) {
userId = userIdOrOptions.userId;
actualAction = userIdOrOptions.action;
actualMetadata = userIdOrOptions.metadata || {};
actualReq = userIdOrOptions.req || null;
} else {
userId = userIdOrOptions;
actualAction = action;
actualMetadata = metadata;
actualReq = req;
}
// Admin actions bypass log level filtering
if (!isAdminAction(actualAction) && !shouldLog(actualAction)) {
return; return;
} }
try { try {
// Extract IP address (handle proxy forwarding) // Extract IP address (handle proxy forwarding)
let ip = null; let ip = null;
if (req) { if (actualReq) {
ip = req.ip || req.headers?.['x-forwarded-for']?.split(',')[0] || null; ip = actualReq.ip || actualReq.headers?.['x-forwarded-for']?.split(',')[0] || null;
} }
// Extract user agent // Extract user agent
const userAgent = req?.headers?.['user-agent'] || null; const userAgent = actualReq?.headers?.['user-agent'] || null;
// Create activity document // Create activity document
const activityDoc = { const activityDoc = {
userId: new ObjectId(userId), userId: new ObjectId(userId),
action: action, action: actualAction,
metadata: metadata || {}, metadata: actualMetadata || {},
page: metadata?.page || null, page: actualMetadata?.page || null,
timestamp: new Date(), timestamp: new Date(),
userAgent: userAgent, userAgent: userAgent,
ip: ip ip: ip

680
test-endpoints.js Normal file
View File

@ -0,0 +1,680 @@
#!/usr/bin/env node
/**
* Comprehensive API Endpoint Test Script
* Tests all apartment dashboard endpoints with enhanced redirect handling
* Usage: node test-endpoints.js
*/
const https = require('https');
const http = require('http');
const BASE_URL = 'apartments.maverickapplications.com';
const API_PREFIX = '/api';
// ANSI color codes for console output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
// Test results tracking
let testResults = {
passed: 0,
failed: 0,
total: 0,
details: []
};
// Configuration for different protocols
let currentConfig = {
protocol: 'http',
port: 80,
module: http
};
/**
* Make HTTP/HTTPS request with redirect handling
*/
function makeRequest(path, method = 'GET', maxRedirects = 5) {
return new Promise((resolve, reject) => {
function attemptRequest(currentPath, redirectCount = 0) {
const options = {
hostname: BASE_URL,
port: currentConfig.port,
path: currentPath,
method: method,
headers: {
'User-Agent': 'Apartment-API-Tester/1.0',
'Accept': 'application/json',
'Host': BASE_URL
},
timeout: 15000 // 15 second timeout
};
const req = currentConfig.module.request(options, (res) => {
let data = '';
// Handle redirects
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
if (redirectCount >= maxRedirects) {
resolve({
statusCode: res.statusCode,
headers: res.headers,
data: null,
rawData: `Too many redirects (${redirectCount})`,
redirectLocation: res.headers.location
});
return;
}
let redirectUrl = res.headers.location;
console.log(`${colors.yellow} → Redirect ${res.statusCode} to: ${redirectUrl}${colors.reset}`);
// Handle relative redirects
if (redirectUrl.startsWith('/')) {
attemptRequest(redirectUrl, redirectCount + 1);
return;
}
// Handle absolute redirects (change protocol if needed)
if (redirectUrl.startsWith('https://') && currentConfig.protocol === 'http') {
console.log(`${colors.yellow} → Switching to HTTPS due to redirect${colors.reset}`);
currentConfig = { protocol: 'https', port: 443, module: https };
const urlPath = redirectUrl.replace(`https://${BASE_URL}`, '');
attemptRequest(urlPath, redirectCount + 1);
return;
}
if (redirectUrl.startsWith('http://') && currentConfig.protocol === 'https') {
console.log(`${colors.yellow} → Switching to HTTP due to redirect${colors.reset}`);
currentConfig = { protocol: 'http', port: 80, module: http };
const urlPath = redirectUrl.replace(`http://${BASE_URL}`, '');
attemptRequest(urlPath, redirectCount + 1);
return;
}
// For other absolute URLs, extract the path
try {
const url = new URL(redirectUrl);
if (url.hostname === BASE_URL) {
attemptRequest(url.pathname + url.search, redirectCount + 1);
return;
}
} catch (e) {
// If URL parsing fails, treat as relative
attemptRequest(redirectUrl, redirectCount + 1);
return;
}
}
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
try {
const jsonData = data ? JSON.parse(data) : {};
resolve({
statusCode: res.statusCode,
headers: res.headers,
data: jsonData,
rawData: data,
finalUrl: `${currentConfig.protocol}://${BASE_URL}${currentPath}`
});
} catch (e) {
resolve({
statusCode: res.statusCode,
headers: res.headers,
data: null,
rawData: data,
parseError: e.message,
finalUrl: `${currentConfig.protocol}://${BASE_URL}${currentPath}`
});
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request timeout after 15s for ${currentConfig.protocol}://${BASE_URL}${currentPath}`));
});
req.on('error', (error) => {
reject(new Error(`${error.message} (${currentConfig.protocol}://${BASE_URL}${currentPath})`));
});
req.end();
}
attemptRequest(path);
});
}
/**
* Test connection and protocol detection
*/
async function detectBestProtocol() {
console.log(`${colors.magenta}🔍 Detecting best protocol and configuration...${colors.reset}\n`);
const testConfigs = [
{ protocol: 'http', port: 80, module: http, name: 'HTTP (port 80)' },
{ protocol: 'https', port: 443, module: https, name: 'HTTPS (port 443)' },
{ protocol: 'http', port: 8080, module: http, name: 'HTTP (port 8080)' },
{ protocol: 'http', port: 3000, module: http, name: 'HTTP (port 3000)' }
];
for (const config of testConfigs) {
currentConfig = config;
console.log(`${colors.cyan}Testing ${config.name}...${colors.reset}`);
try {
const response = await makeRequest('/health');
console.log(`${colors.green}✅ ${config.name} - Status: ${response.statusCode}${colors.reset}`);
if (response.statusCode === 200) {
console.log(`${colors.green}🎯 Using ${config.name} for all tests${colors.reset}\n`);
return true;
} else if (response.statusCode >= 300 && response.statusCode < 400) {
console.log(`${colors.yellow}⚠️ ${config.name} redirects to: ${response.headers.location || 'unknown'}${colors.reset}`);
// Don't return, try other configs first
}
} catch (error) {
console.log(`${colors.red}❌ ${config.name} - ${error.message}${colors.reset}`);
}
}
// If no 200 response found, use the first config that doesn't error
console.log(`${colors.yellow}⚠️ No perfect match found, using HTTP as fallback${colors.reset}\n`);
currentConfig = testConfigs[0];
return false;
}
/**
* Test a single endpoint with enhanced debugging
*/
async function testEndpoint(name, path, expectedFields = [], validStatusCodes = [200]) {
testResults.total++;
console.log(`${colors.cyan}Testing: ${colors.bright}${name}${colors.reset}`);
console.log(`${colors.blue} Path: ${currentConfig.protocol}://${BASE_URL}${path}${colors.reset}`);
try {
const response = await makeRequest(path);
const { statusCode, data, rawData, parseError, finalUrl, redirectLocation } = response;
// Show final URL if different from initial
if (finalUrl && finalUrl !== `${currentConfig.protocol}://${BASE_URL}${path}`) {
console.log(`${colors.yellow} Final URL: ${finalUrl}${colors.reset}`);
}
// Show redirect info for debugging
if (statusCode >= 300 && statusCode < 400) {
console.log(`${colors.yellow} Redirect Status: ${statusCode}${colors.reset}`);
if (redirectLocation) {
console.log(`${colors.yellow} Redirect Location: ${redirectLocation}${colors.reset}`);
}
}
// Check status code
if (!validStatusCodes.includes(statusCode)) {
// For 301/302 redirects, provide helpful info
if (statusCode === 301 || statusCode === 302) {
throw new Error(`Redirect ${statusCode} - Server redirecting to: ${redirectLocation || 'unknown'}. This might indicate wrong protocol or path format.`);
}
throw new Error(`Expected status ${validStatusCodes.join(' or ')}, got ${statusCode}`);
}
// Check if response is valid JSON
if (parseError) {
console.log(`${colors.yellow} Raw response: ${rawData.substring(0, 200)}...${colors.reset}`);
throw new Error(`JSON parse error: ${parseError}`);
}
// Check for expected fields if data is an object
if (expectedFields.length > 0 && data && typeof data === 'object') {
const missingFields = [];
if (Array.isArray(data)) {
// If it's an array, check the first item
if (data.length > 0) {
expectedFields.forEach(field => {
if (!(field in data[0])) {
missingFields.push(field);
}
});
}
} else {
// If it's an object, check directly
expectedFields.forEach(field => {
if (!(field in data)) {
missingFields.push(field);
}
});
}
if (missingFields.length > 0) {
console.log(`${colors.yellow} ⚠️ Missing expected fields: ${missingFields.join(', ')}${colors.reset}`);
}
}
// Success
testResults.passed++;
console.log(`${colors.green} ✅ PASS${colors.reset}`);
console.log(`${colors.green} Status: ${statusCode}${colors.reset}`);
console.log(`${colors.green} Protocol: ${currentConfig.protocol.toUpperCase()}${colors.reset}`);
if (Array.isArray(data)) {
console.log(`${colors.green} Records: ${data.length}${colors.reset}`);
} else if (data && typeof data === 'object') {
console.log(`${colors.green} Keys: ${Object.keys(data).length}${colors.reset}`);
}
testResults.details.push({
name,
path,
status: 'PASS',
statusCode,
protocol: currentConfig.protocol,
dataType: Array.isArray(data) ? 'array' : typeof data,
recordCount: Array.isArray(data) ? data.length : null
});
} catch (error) {
testResults.failed++;
console.log(`${colors.red} ❌ FAIL${colors.reset}`);
console.log(`${colors.red} Error: ${error.message}${colors.reset}`);
console.log(`${colors.red} Protocol: ${currentConfig.protocol.toUpperCase()}${colors.reset}`);
testResults.details.push({
name,
path,
status: 'FAIL',
protocol: currentConfig.protocol,
error: error.message
});
}
console.log(''); // Empty line for readability
}
/**
* Test with curl-equivalent settings
*/
async function testWithCurlEquivalent() {
console.log(`${colors.yellow}🔄 Testing with curl-equivalent settings...${colors.reset}\n`);
// Test the exact same way curl would
const curlTests = [
{ url: 'http://apartments.maverickapplications.com/health', description: 'Direct HTTP health check' },
{ url: 'https://apartments.maverickapplications.com/health', description: 'Direct HTTPS health check' },
{ url: 'http://apartments.maverickapplications.com/api/daily-summary', description: 'HTTP API endpoint' },
{ url: 'https://apartments.maverickapplications.com/api/daily-summary', description: 'HTTPS API endpoint' }
];
for (const test of curlTests) {
console.log(`${colors.cyan}Testing: ${test.description}${colors.reset}`);
console.log(`${colors.blue}URL: ${test.url}${colors.reset}`);
try {
const isHttps = test.url.startsWith('https');
const url = new URL(test.url);
currentConfig = {
protocol: isHttps ? 'https' : 'http',
port: isHttps ? 443 : 80,
module: isHttps ? https : http
};
const response = await makeRequest(url.pathname + url.search);
console.log(`${colors.green}✅ Success - Status: ${response.statusCode}${colors.reset}`);
if (response.statusCode === 200 && response.data) {
console.log(`${colors.green} Data type: ${Array.isArray(response.data) ? 'array' : typeof response.data}${colors.reset}`);
if (Array.isArray(response.data)) {
console.log(`${colors.green} Records: ${response.data.length}${colors.reset}`);
} else if (typeof response.data === 'object') {
console.log(`${colors.green} Keys: ${Object.keys(response.data).join(', ')}${colors.reset}`);
}
}
// If we found a working endpoint, use this config for remaining tests
if (response.statusCode === 200) {
console.log(`${colors.green}🎯 Found working configuration: ${currentConfig.protocol.toUpperCase()}${colors.reset}\n`);
return true;
}
} catch (error) {
console.log(`${colors.red}❌ Failed: ${error.message}${colors.reset}`);
}
console.log('');
}
return false;
}
/**
* Test all endpoints
*/
async function runAllTests() {
console.log(`${colors.magenta}${colors.bright}🧪 APARTMENT API ENDPOINT TESTS${colors.reset}`);
console.log(`${colors.magenta}Base URL: ${BASE_URL}${colors.reset}`);
console.log(`${colors.magenta}Testing ${new Date().toISOString()}${colors.reset}\n`);
// First, detect the best protocol
await detectBestProtocol();
// Test a few different path formats to see which works
console.log(`${colors.magenta}🔍 Testing different path formats...${colors.reset}\n`);
const pathVariations = [
'/health',
'/api/health',
'/',
'/api/'
];
for (const testPath of pathVariations) {
try {
console.log(`${colors.cyan}Testing path: ${testPath}${colors.reset}`);
const response = await makeRequest(testPath);
console.log(`${colors.green} Status: ${response.statusCode}${colors.reset}`);
if (response.statusCode === 200 && response.data) {
console.log(`${colors.green} Response preview: ${JSON.stringify(response.data).substring(0, 100)}...${colors.reset}`);
}
if (response.rawData && response.statusCode !== 200) {
console.log(`${colors.yellow} Raw response: ${response.rawData.substring(0, 200)}...${colors.reset}`);
}
} catch (error) {
console.log(`${colors.red} Error: ${error.message}${colors.reset}`);
}
console.log('');
}
console.log(`${colors.magenta}🚀 Starting full endpoint tests...${colors.reset}\n`);
// Health check (try both with and without /api prefix)
await testEndpoint(
'Health Check',
'/health',
['status', 'timestamp'],
[200, 404]
);
// Try health check with /api prefix if first one failed
if (testResults.details[testResults.details.length - 1].status === 'FAIL') {
await testEndpoint(
'Health Check (with /api)',
'/api/health',
['status', 'timestamp'],
[200, 404]
);
}
// Original endpoints
await testEndpoint(
'Daily Summary',
`${API_PREFIX}/daily-summary`,
['date'],
[200, 404]
);
await testEndpoint(
'Price History',
`${API_PREFIX}/price-history`,
['date'],
[200, 404]
);
await testEndpoint(
'Available Units',
`${API_PREFIX}/available-units`,
['unitCode'],
[200, 404]
);
await testEndpoint(
'Recent Activity',
`${API_PREFIX}/recent-activity`,
['type'],
[200, 404]
);
await testEndpoint(
'Plan Statistics',
`${API_PREFIX}/plan-stats`,
['name'],
[200, 404]
);
// Enhanced endpoints (these might not exist yet)
await testEndpoint(
'Best Deals',
`${API_PREFIX}/best-deals`,
['unitCode', 'planName', 'currentPrice'],
[200, 404, 500]
);
await testEndpoint(
'Price Drops',
`${API_PREFIX}/price-drops`,
['unitCode', 'currentPrice'],
[200, 404, 500]
);
await testEndpoint(
'Stale Inventory',
`${API_PREFIX}/stale-inventory`,
['unitCode', 'daysAvailable'],
[200, 404, 500]
);
await testEndpoint(
'Market Insights',
`${API_PREFIX}/market-insights`,
['avgDaysOnMarket'],
[200, 404, 500]
);
await testEndpoint(
'Enhanced Available Units',
`${API_PREFIX}/available-units-enhanced`,
['unitCode', 'amenities'],
[200, 404, 500]
);
// Test with query parameters
await testEndpoint(
'Price History (7 days)',
`${API_PREFIX}/price-history?days=7`,
['date'],
[200, 404]
);
await testEndpoint(
'Best Deals (limit 3)',
`${API_PREFIX}/best-deals?limit=3`,
[],
[200, 404, 500]
);
// Test individual unit endpoint
await testEndpoint(
'Individual Unit Details',
`${API_PREFIX}/unit/A101`,
[],
[200, 404, 500]
);
// Test edge cases
await testEndpoint(
'Invalid Endpoint',
`${API_PREFIX}/nonexistent`,
[],
[404, 405] // Should return 404 or 405
);
// Print final results
printTestSummary();
}
/**
* Print test summary
*/
function printTestSummary() {
console.log(`${colors.magenta}${colors.bright}📊 TEST SUMMARY${colors.reset}`);
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
console.log(`${colors.green}✅ Passed: ${testResults.passed}${colors.reset}`);
console.log(`${colors.red}❌ Failed: ${testResults.failed}${colors.reset}`);
console.log(`${colors.blue}📋 Total: ${testResults.total}${colors.reset}`);
console.log(`${colors.yellow}📈 Success Rate: ${((testResults.passed / testResults.total) * 100).toFixed(1)}%${colors.reset}`);
console.log('');
// Detailed results
console.log(`${colors.magenta}${colors.bright}📋 DETAILED RESULTS${colors.reset}`);
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
testResults.details.forEach((test, index) => {
const status = test.status === 'PASS' ?
`${colors.green}✅ PASS${colors.reset}` :
`${colors.red}❌ FAIL${colors.reset}`;
console.log(`${index + 1}. ${test.name}`);
console.log(` ${status} - ${test.path}`);
if (test.status === 'PASS') {
if (test.recordCount !== null) {
console.log(` 📊 ${test.recordCount} records returned`);
}
} else {
console.log(` 💥 ${test.error}`);
}
console.log('');
});
// Recommendations
console.log(`${colors.magenta}${colors.bright}💡 RECOMMENDATIONS${colors.reset}`);
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
const failedTests = testResults.details.filter(t => t.status === 'FAIL');
const enhancedEndpoints = failedTests.filter(t =>
t.path.includes('best-deals') ||
t.path.includes('price-drops') ||
t.path.includes('stale-inventory') ||
t.path.includes('market-insights') ||
t.path.includes('available-units-enhanced')
);
if (enhancedEndpoints.length > 0) {
console.log(`${colors.yellow}⚠️ Enhanced endpoints not implemented yet:${colors.reset}`);
enhancedEndpoints.forEach(test => {
console.log(` - ${test.path}`);
});
console.log(`${colors.blue} 💡 Add the missing routes from the enhanced server script${colors.reset}`);
console.log('');
}
if (testResults.failed > enhancedEndpoints.length) {
console.log(`${colors.red}🚨 Core endpoints failing - check server configuration${colors.reset}`);
console.log('');
}
if (testResults.passed === testResults.total) {
console.log(`${colors.green}🎉 All tests passed! API is fully functional.${colors.reset}`);
}
}
/**
* Debug curl command generator
*/
function generateCurlCommands() {
console.log(`${colors.magenta}${colors.bright}🔧 EQUIVALENT CURL COMMANDS${colors.reset}`);
console.log(`${colors.cyan}═══════════════════════════════════════${colors.reset}`);
console.log(`${colors.yellow}Try these curl commands to debug:${colors.reset}\n`);
const endpoints = [
'/health',
'/api/health',
'/api/daily-summary',
'/api/price-history',
'/api/available-units'
];
endpoints.forEach(endpoint => {
console.log(`${colors.cyan}# Test ${endpoint}${colors.reset}`);
console.log(`curl -v -H "Accept: application/json" http://${BASE_URL}${endpoint}`);
console.log(`curl -v -H "Accept: application/json" https://${BASE_URL}${endpoint}`);
console.log('');
});
console.log(`${colors.yellow}Look for:${colors.reset}`);
console.log(`${colors.blue}- HTTP status codes (200 = success, 301/302 = redirect, 404 = not found)${colors.reset}`);
console.log(`${colors.blue}- Location headers in redirects${colors.reset}`);
console.log(`${colors.blue}- Response content-type${colors.reset}`);
console.log(`${colors.blue}- Server response headers${colors.reset}\n`);
}
/**
* Main execution
*/
async function main() {
try {
await runAllTests();
// If all tests failed with redirects, try curl-equivalent testing
const allFailed = testResults.passed === 0 && testResults.failed > 0;
const hasRedirectErrors = testResults.details.some(test =>
test.error && test.error.includes('Redirect')
);
if (allFailed && hasRedirectErrors) {
console.log(`${colors.yellow}🔍 All tests failed with redirects. Trying curl-equivalent approach...${colors.reset}\n`);
await testWithCurlEquivalent();
}
// Generate curl commands for manual testing
generateCurlCommands();
} catch (error) {
console.error(`${colors.red}💥 Fatal error: ${error.message}${colors.reset}`);
process.exit(1);
}
// Exit with error code if tests failed
process.exit(testResults.failed > 0 ? 1 : 0);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log(`\n${colors.yellow}🛑 Test interrupted by user${colors.reset}`);
process.exit(1);
});
// Add CLI argument parsing
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(`
${colors.bright}Apartment API Endpoint Tester${colors.reset}
${colors.cyan}Usage:${colors.reset}
node test-endpoints.js [options]
${colors.cyan}Options:${colors.reset}
--help, -h Show this help message
--https Force HTTPS testing
--verbose Show detailed response data
${colors.cyan}Examples:${colors.reset}
node test-endpoints.js
node test-endpoints.js --https
node test-endpoints.js --verbose
`);
process.exit(0);
}
// Run the main function
main();