Compare commits
6 Commits
add-soones
...
94bbacb3a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 94bbacb3a2 | |||
| ccda2be551 | |||
| 81e5682ab1 | |||
| d6e56a6e40 | |||
| ef4ddc0de0 | |||
| 04a78a21cc |
25
.dockerignore
Normal file
25
.dockerignore
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Environment and secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
test-endpoints.js
|
||||||
|
*.test.js
|
||||||
|
__tests__
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
20
.env.example
Normal file
20
.env.example
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
# MongoDB Connection
|
||||||
|
MONGO_URI=mongodb://username:password@host:27017
|
||||||
|
|
||||||
|
# Google OAuth Credentials (from Google Cloud Console)
|
||||||
|
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=your-client-secret
|
||||||
|
GOOGLE_CALLBACK_URL=https://apartments.maverickapplications.com/api/auth/google/callback
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
JWT_SECRET=generate-a-secure-random-string-minimum-32-characters
|
||||||
|
|
||||||
|
# Application URLs
|
||||||
|
FRONTEND_URL=https://apartments.maverickapplications.com
|
||||||
|
|
||||||
|
# Activity Logging Level: all, navigation, none
|
||||||
|
ACTIVITY_LOG_LEVEL=all
|
||||||
|
|
||||||
|
# Node Environment
|
||||||
|
NODE_ENV=production
|
||||||
134
.github/workflows/deploy.yml
vendored
134
.github/workflows/deploy.yml
vendored
@ -1,60 +1,118 @@
|
|||||||
name: Deploy Apartment API
|
name: CI/CD Pipeline - Apartment API
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ main ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_VERSION: '20'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
# ============================================================
|
||||||
|
# Test Job - Runs first, blocks deployment if tests fail
|
||||||
|
# ============================================================
|
||||||
|
test:
|
||||||
|
name: Run Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongodb:
|
||||||
|
image: mongo:7
|
||||||
|
ports:
|
||||||
|
- 27017:27017
|
||||||
|
options: >-
|
||||||
|
--health-cmd "mongosh --eval 'db.runCommand(\"ping\").ok'"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Run tests
|
||||||
run: |
|
run: npm test -- --runInBand
|
||||||
docker build -t apartment-api:${{ github.sha }} .
|
env:
|
||||||
docker tag apartment-api:${{ github.sha }} apartment-api:latest
|
MONGO_URI: mongodb://localhost:27017
|
||||||
|
JWT_SECRET: test-jwt-secret-for-ci
|
||||||
|
NODE_ENV: test
|
||||||
|
|
||||||
- name: Deploy to server
|
# ============================================================
|
||||||
run: |
|
# Deploy Job - Only runs on main branch after tests pass
|
||||||
# Copy files to deployment directory
|
# ============================================================
|
||||||
mkdir -p /media/stephen/Storage_Linux/infrastructure/services/apartment-api
|
deploy:
|
||||||
cp -r . /media/stephen/Storage_Linux/infrastructure/services/apartment-api/
|
name: Deploy to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||||
|
|
||||||
# Navigate to deployment directory
|
steps:
|
||||||
cd /media/stephen/Storage_Linux/infrastructure/services/apartment-api
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
# Stop existing container if running
|
- name: Deploy via SSH
|
||||||
docker compose down || true
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT || 22 }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
|
||||||
# Start new container
|
# Navigate to deployment directory
|
||||||
docker compose up -d
|
cd ${{ secrets.DEPLOY_PATH }}
|
||||||
|
|
||||||
# Clean up old images
|
# Pull latest code
|
||||||
docker image prune -f
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
|
||||||
- name: Verify deployment
|
# Rebuild and restart container
|
||||||
run: |
|
docker compose build --no-cache
|
||||||
# Wait for container to start
|
docker compose up -d
|
||||||
sleep 15
|
|
||||||
|
|
||||||
# Check if container is running
|
# Clean up old images
|
||||||
docker ps | grep apartment-api
|
docker image prune -f
|
||||||
|
|
||||||
# Test health endpoint
|
# Wait for container to be healthy
|
||||||
curl -f http://localhost:3000/health || echo "API may still be starting..."
|
echo "Waiting for container to start..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
# Test API endpoint
|
# Verify container is running
|
||||||
curl -f http://localhost:3000/api/daily-summary || echo "API may still be loading data..."
|
if docker compose ps | grep -q "Up"; then
|
||||||
|
echo "Container is running successfully"
|
||||||
|
else
|
||||||
|
echo "ERROR: Container failed to start"
|
||||||
|
docker compose logs --tail=50
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Verify deployment
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT || 22 }}
|
||||||
|
script: |
|
||||||
|
# Test health endpoint via Traefik
|
||||||
|
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" https://apartments.maverickapplications.com/api/health || echo "000")
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "Health check passed (HTTP $HTTP_CODE)"
|
||||||
|
else
|
||||||
|
echo "WARNING: Health check returned HTTP $HTTP_CODE"
|
||||||
|
echo "Container may still be initializing..."
|
||||||
|
fi
|
||||||
|
|||||||
717
AUTH.md
Normal file
717
AUTH.md
Normal file
@ -0,0 +1,717 @@
|
|||||||
|
# Google OAuth Authentication Implementation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document details the Google OAuth authentication implementation for the Apartment Dashboard application. **All pages and API endpoints require authentication** - unauthenticated users are redirected to a login page.
|
||||||
|
|
||||||
|
> **Status:** Core authentication is complete and deployed. Admin dashboard and future enhancements are documented but not yet implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
0. [Project Context](#0-project-context)
|
||||||
|
1. [Implementation Status](#1-implementation-status)
|
||||||
|
2. [Authentication Scope](#2-authentication-scope)
|
||||||
|
3. [Technical Architecture](#3-technical-architecture)
|
||||||
|
4. [Implementation Details](#4-implementation-details)
|
||||||
|
5. [User Activity Logging](#5-user-activity-logging)
|
||||||
|
6. [Security Measures](#6-security-measures)
|
||||||
|
7. [Deployment Configuration](#7-deployment-configuration)
|
||||||
|
8. [Test Checklist](#8-test-checklist)
|
||||||
|
9. [Future: Admin Dashboard](#9-future-admin-dashboard)
|
||||||
|
10. [Future: Adding Apple Sign-In](#10-future-adding-apple-sign-in)
|
||||||
|
11. [Future: Partial Public Access](#11-future-partial-public-access)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Project Context
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
TowersPriceWebApp/
|
||||||
|
├── apartment-dashboard/ # Frontend (React/Vite)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── App.jsx # Main app (~1300 lines)
|
||||||
|
│ │ ├── main.jsx # Entry point (wrapped with AuthProvider)
|
||||||
|
│ │ ├── index.css # Tailwind imports
|
||||||
|
│ │ ├── context/
|
||||||
|
│ │ │ └── AuthContext.jsx # Auth state management
|
||||||
|
│ │ ├── hooks/
|
||||||
|
│ │ │ ├── useAuth.js # Auth hook
|
||||||
|
│ │ │ └── useActivityTracker.js # Activity tracking hook
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ │ └── Login.jsx # Login page with Google button
|
||||||
|
│ │ └── components/
|
||||||
|
│ │ └── ProtectedRoute.jsx # Route guard component
|
||||||
|
│ ├── vite.config.js
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── nginx.conf
|
||||||
|
│
|
||||||
|
├── apartment-dashboard-api/ # Backend (Express.js)
|
||||||
|
│ ├── server.js # Main server (~1700 lines)
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── auth.js # JWT, cookie, OAuth config
|
||||||
|
│ ├── models/
|
||||||
|
│ │ └── user.js # User CRUD operations
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── passport.js # Google OAuth strategy
|
||||||
|
│ │ └── auth.js # JWT verification middleware
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ ├── auth.js # OAuth routes
|
||||||
|
│ │ └── activity.js # Activity logging routes
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── activityLogger.js # Activity logging service
|
||||||
|
│ ├── .env.example # Environment template
|
||||||
|
│ ├── .dockerignore # Prevents secrets in image
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── Dockerfile
|
||||||
|
│ └── docker-compose.yml
|
||||||
|
│
|
||||||
|
└── AUTH.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Version |
|
||||||
|
|-------|------------|---------|
|
||||||
|
| Frontend Framework | React | 19.1.0 |
|
||||||
|
| Frontend Build | Vite | 7.0.4 |
|
||||||
|
| Frontend Routing | React Router DOM | 7.12.0 |
|
||||||
|
| Frontend Styling | Tailwind CSS | 3.4.17 |
|
||||||
|
| Frontend Charts | Recharts | 3.1.0 |
|
||||||
|
| Backend Framework | Express.js | 4.18.2 |
|
||||||
|
| Database | MongoDB | 6.3.0 (driver) |
|
||||||
|
| Auth Library | Passport.js | passport-google-oauth20 |
|
||||||
|
| Token Management | jsonwebtoken | HTTP-only cookies |
|
||||||
|
| Reverse Proxy | Traefik | (with Let's Encrypt SSL) |
|
||||||
|
| Static Server | Nginx | (serves built React app) |
|
||||||
|
|
||||||
|
### URLs and Domains
|
||||||
|
|
||||||
|
| Environment | Frontend URL | API URL |
|
||||||
|
|-------------|--------------|---------|
|
||||||
|
| Production | `https://apartments.maverickapplications.com` | `https://apartments.maverickapplications.com/api` |
|
||||||
|
| Development | `http://localhost:5173` (Vite) | `http://localhost:3000` |
|
||||||
|
|
||||||
|
### MongoDB Details
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|---------|-------|
|
||||||
|
| Database name | `apartments` |
|
||||||
|
| Data collections | `units_migration_test`, `unit_prices_migration_test`, `daily_summaries` |
|
||||||
|
| Auth collections | `users`, `user_activity` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Implementation Status
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Google OAuth login | Done | Passport.js with state parameter CSRF protection |
|
||||||
|
| JWT in HTTP-only cookies | Done | 7-day expiry with sliding window refresh |
|
||||||
|
| User model with upsert | Done | findOneAndUpdate with $setOnInsert pattern |
|
||||||
|
| Protected API endpoints | Done | All data endpoints require valid JWT |
|
||||||
|
| Activity logging | Done | Configurable levels, TTL indexes for cleanup |
|
||||||
|
| Frontend auth context | Done | React Context with useAuth hook |
|
||||||
|
| Protected routes | Done | ProtectedRoute component with redirect |
|
||||||
|
| Login page | Done | Google Sign-In button with error display |
|
||||||
|
| Security hardening | Done | OAuth state, input validation, .dockerignore |
|
||||||
|
| Docker deployment | Done | env_file configuration, production settings |
|
||||||
|
|
||||||
|
### Not Yet Implemented
|
||||||
|
|
||||||
|
| Feature | Priority | Notes |
|
||||||
|
|---------|----------|-------|
|
||||||
|
| Admin dashboard | Low | User management, activity viewer |
|
||||||
|
| Apple Sign-In | Future | Requires Apple Developer account |
|
||||||
|
| Partial public access | Future | Blurred preview for unauthenticated users |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication Scope
|
||||||
|
|
||||||
|
### What Requires Authentication
|
||||||
|
|
||||||
|
**All pages and API endpoints require authentication.** Unauthenticated users see only the login page.
|
||||||
|
|
||||||
|
| Page/Feature | Unauthenticated | Authenticated |
|
||||||
|
|--------------|-----------------|---------------|
|
||||||
|
| Login Page (`/login`) | Accessible | Redirects to `/` |
|
||||||
|
| Overview Page (`/`) | Redirect to login | Fully accessible |
|
||||||
|
| Units Page (`/units`) | Redirect to login | Fully accessible |
|
||||||
|
| Analytics Page (`/analytics`) | Redirect to login | Fully accessible |
|
||||||
|
| All API Endpoints | 401 Unauthorized | Accessible |
|
||||||
|
|
||||||
|
### Protected API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/health → Public (health check only)
|
||||||
|
GET /api/daily-summary → Protected
|
||||||
|
GET /api/price-history → Protected
|
||||||
|
GET /api/available-units → Protected
|
||||||
|
GET /api/recent-activity → Protected
|
||||||
|
GET /api/plan-stats → Protected
|
||||||
|
GET /api/unit/:code/history → Protected
|
||||||
|
GET /api/analytics → Protected
|
||||||
|
GET /api/best-deals → Protected
|
||||||
|
GET /api/price-drops → Protected
|
||||||
|
GET /api/stale-inventory → Protected
|
||||||
|
GET /api/market-insights → Protected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auth Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /auth/google → Initiates OAuth flow (sets state cookie)
|
||||||
|
GET /auth/google/callback → Handles callback (validates state, sets JWT)
|
||||||
|
GET /auth/me → Returns current user info
|
||||||
|
GET /auth/status → Returns { authenticated: true/false }
|
||||||
|
POST /auth/logout → Clears auth cookie
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Technical Architecture
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Google OAuth Flow │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. User clicks "Sign in with Google" │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 2. Frontend redirects to: /api/auth/google │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 3. Backend generates state parameter, stores in cookie │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 4. Backend redirects to Google's OAuth consent screen │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 5. User authenticates with Google │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 6. Google redirects to: /api/auth/google/callback?code=XXX&state=YYY │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 7. Backend validates state parameter against cookie (CSRF protection) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 8. Backend exchanges code for tokens, fetches user profile │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 9. Backend creates/updates user in MongoDB (upsert) │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 10. Backend creates JWT, sets HTTP-only cookie │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 11. Backend redirects to frontend │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 12. Frontend AuthContext checks /auth/status, renders authenticated UI │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Refresh Strategy (Sliding Window)
|
||||||
|
|
||||||
|
Active users get their tokens refreshed automatically to avoid abrupt logouts.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Sliding Window Refresh │
|
||||||
|
├─────────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ 1. User makes authenticated request │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 2. Auth middleware validates JWT │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 3. Check: Is user still active? (isActive field) │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────┴────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ No ▼ Yes ▼ │
|
||||||
|
│ Clear cookie 4. Check token age: (now - iat) > 1 day? │
|
||||||
|
│ Return 401 │ │
|
||||||
|
│ ┌────┴────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ No ▼ Yes ▼ │
|
||||||
|
│ Continue 5. Issue new JWT with fresh 7-day expiry │
|
||||||
|
│ normally │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 6. Set new cookie in response │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Scenario | Token Age | Action |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| User active daily | < 1 day since last refresh | No refresh |
|
||||||
|
| User returns after 2 days | 2 days old | Refresh token |
|
||||||
|
| User returns after 8 days | Expired | 401, redirect to login |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation Details
|
||||||
|
|
||||||
|
### Auth Configuration (`config/auth.js`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
google: {
|
||||||
|
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||||
|
scope: ['profile', 'email']
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET,
|
||||||
|
expiresIn: '7d',
|
||||||
|
refreshThreshold: 24 * 60 * 60 // 1 day in seconds
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
name: 'auth_token',
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
|
domain: process.env.NODE_ENV === 'production'
|
||||||
|
? '.maverickapplications.com'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### User Model (`models/user.js`)
|
||||||
|
|
||||||
|
Uses MongoDB native driver with upsert pattern:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function findOrCreateUser(db, profile) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await db.collection('users').findOneAndUpdate(
|
||||||
|
{ googleId: profile.googleId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email: profile.email,
|
||||||
|
name: profile.name,
|
||||||
|
picture: profile.picture || null,
|
||||||
|
lastLoginAt: now
|
||||||
|
},
|
||||||
|
$inc: { loginCount: 1 },
|
||||||
|
$setOnInsert: {
|
||||||
|
googleId: profile.googleId,
|
||||||
|
isActive: true,
|
||||||
|
role: 'user',
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ upsert: true, returnDocument: 'after' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** `loginCount` must NOT be in `$setOnInsert` - it conflicts with `$inc`.
|
||||||
|
|
||||||
|
### Passport Strategy (`middleware/passport.js`)
|
||||||
|
|
||||||
|
Safely extracts profile data from Google:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async (accessToken, refreshToken, profile, done) => {
|
||||||
|
try {
|
||||||
|
const email = profile.emails?.[0]?.value;
|
||||||
|
if (!email) {
|
||||||
|
return done(new Error('No email found in Google profile'), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProfile = {
|
||||||
|
googleId: profile.id,
|
||||||
|
email: email,
|
||||||
|
name: profile.displayName,
|
||||||
|
picture: profile.photos?.[0]?.value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await findOrCreateUser(db, userProfile);
|
||||||
|
done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Passport strategy error:', error);
|
||||||
|
done(error, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### OAuth State Validation (`routes/auth.js`)
|
||||||
|
|
||||||
|
CSRF protection using state parameter:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Initiate OAuth - generate and store state
|
||||||
|
router.get('/google', (req, res, next) => {
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
res.cookie('oauth_state', state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 5 * 60 * 1000 // 5 minutes
|
||||||
|
});
|
||||||
|
passport.authenticate('google', {
|
||||||
|
scope: authConfig.google.scope,
|
||||||
|
session: false,
|
||||||
|
state: state
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Callback - validate state before processing
|
||||||
|
router.get('/google/callback', (req, res, next) => {
|
||||||
|
const stateFromCookie = req.cookies.oauth_state;
|
||||||
|
const stateFromQuery = req.query.state;
|
||||||
|
res.clearCookie('oauth_state');
|
||||||
|
|
||||||
|
if (!stateFromCookie || !stateFromQuery || stateFromCookie !== stateFromQuery) {
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/login?error=invalid_state`);
|
||||||
|
}
|
||||||
|
// ... proceed with authentication
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Auth Context (`context/AuthContext.jsx`)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkAuthStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const checkAuthStatus = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/auth/status`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.authenticated) {
|
||||||
|
setUser(data.user);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = () => {
|
||||||
|
window.location.href = `${API_BASE}/auth/google`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Indexes
|
||||||
|
|
||||||
|
Created automatically on server startup:
|
||||||
|
|
||||||
|
**Users Collection:**
|
||||||
|
- `{ googleId: 1 }` - unique
|
||||||
|
- `{ email: 1 }` - unique
|
||||||
|
- `{ isActive: 1, lastLoginAt: -1 }` - compound for queries
|
||||||
|
|
||||||
|
**User Activity Collection:**
|
||||||
|
- `{ userId: 1, timestamp: -1 }` - user activity queries
|
||||||
|
- `{ timestamp: 1 }` - TTL index (90-day auto-cleanup)
|
||||||
|
- `{ action: 1, timestamp: -1 }` - action type queries
|
||||||
|
- `{ sessionId: 1 }` - session grouping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. User Activity Logging
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Controlled by `ACTIVITY_LOG_LEVEL` environment variable:
|
||||||
|
|
||||||
|
| Level | What's Logged |
|
||||||
|
|-------|---------------|
|
||||||
|
| `all` | All user interactions (default) |
|
||||||
|
| `navigation` | Only page views and route changes |
|
||||||
|
| `none` | Logging disabled |
|
||||||
|
|
||||||
|
### Activity Types
|
||||||
|
|
||||||
|
| Action | When Logged | Metadata |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| `LOGIN` | User completes OAuth | `{ method: 'google' }` |
|
||||||
|
| `LOGOUT` | User logs out | `{}` |
|
||||||
|
| `PAGE_VIEW` | Page load | `{ path }` |
|
||||||
|
| `NAVIGATION` | Route change | `{ from, to }` |
|
||||||
|
|
||||||
|
### Activity Schema
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
{
|
||||||
|
_id: ObjectId,
|
||||||
|
userId: ObjectId,
|
||||||
|
sessionId: String, // UUID for grouping
|
||||||
|
action: String,
|
||||||
|
metadata: Object,
|
||||||
|
page: String,
|
||||||
|
timestamp: Date,
|
||||||
|
userAgent: String,
|
||||||
|
ip: String
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Cleanup
|
||||||
|
|
||||||
|
Activity records are automatically deleted after 90 days via MongoDB TTL index.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Security Measures
|
||||||
|
|
||||||
|
### Implemented
|
||||||
|
|
||||||
|
| Security Feature | Implementation |
|
||||||
|
|------------------|----------------|
|
||||||
|
| OAuth state parameter | Random 32-byte hex, stored in cookie, validated on callback |
|
||||||
|
| HTTP-only cookies | JWT not accessible via JavaScript |
|
||||||
|
| Secure cookies (production) | Only sent over HTTPS |
|
||||||
|
| SameSite=Lax | CSRF protection for cookies |
|
||||||
|
| Input validation | Unit codes validated with regex pattern |
|
||||||
|
| isActive check | Disabled users rejected on every request |
|
||||||
|
| .dockerignore | Prevents .env and secrets from being copied into images |
|
||||||
|
| Environment validation | Server exits if MONGO_URI missing in production |
|
||||||
|
|
||||||
|
### Input Validation
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const isValidUnitCode = (unitCode) => {
|
||||||
|
return typeof unitCode === 'string' &&
|
||||||
|
unitCode.length > 0 &&
|
||||||
|
unitCode.length <= 20 &&
|
||||||
|
/^[A-Za-z0-9_-]+$/.test(unitCode);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS Configuration
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const corsOptions = {
|
||||||
|
origin: process.env.FRONTEND_URL, // Exact origin, not '*'
|
||||||
|
credentials: true, // Required for cookies
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
exposedHeaders: ['set-cookie']
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Deployment Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Backend `.env` (see `.env.example` for template):
|
||||||
|
|
||||||
|
```env
|
||||||
|
# MongoDB
|
||||||
|
MONGO_URI=mongodb+srv://...
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=8080
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
|
||||||
|
GOOGLE_CLIENT_SECRET=xxx
|
||||||
|
GOOGLE_CALLBACK_URL=https://apartments.maverickapplications.com/api/auth/google/callback
|
||||||
|
|
||||||
|
# JWT (generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))")
|
||||||
|
JWT_SECRET=your-secure-random-string
|
||||||
|
|
||||||
|
# App
|
||||||
|
FRONTEND_URL=https://apartments.maverickapplications.com
|
||||||
|
|
||||||
|
# Activity Logging
|
||||||
|
ACTIVITY_LOG_LEVEL=all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
apartment-api:
|
||||||
|
build: .
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Google Cloud Console Setup
|
||||||
|
|
||||||
|
Required redirect URIs:
|
||||||
|
```
|
||||||
|
https://apartments.maverickapplications.com/api/auth/google/callback
|
||||||
|
```
|
||||||
|
|
||||||
|
OAuth consent screen:
|
||||||
|
- User type: External
|
||||||
|
- Scopes: `email`, `profile` (non-sensitive)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Test Checklist
|
||||||
|
|
||||||
|
### Completed Tests
|
||||||
|
|
||||||
|
- [x] Google OAuth login flow works end-to-end
|
||||||
|
- [x] JWT cookie is set with correct flags (httpOnly, secure, sameSite)
|
||||||
|
- [x] Protected endpoints return 401 without valid token
|
||||||
|
- [x] User data persists across sessions (cookie survives browser close)
|
||||||
|
- [x] Activity logging captures LOGIN/LOGOUT events
|
||||||
|
- [x] Data loads correctly regardless of server timezone
|
||||||
|
- [x] OAuth state parameter prevents CSRF
|
||||||
|
- [x] User upsert creates new users and updates existing
|
||||||
|
- [x] Sliding window token refresh extends sessions
|
||||||
|
- [x] Logout clears cookie and redirects to login
|
||||||
|
|
||||||
|
### Production Verification
|
||||||
|
|
||||||
|
- [x] OAuth redirect URIs match production domain
|
||||||
|
- [x] HTTPS enforced
|
||||||
|
- [x] Environment variables properly set
|
||||||
|
- [x] MongoDB indexes created on startup
|
||||||
|
- [x] No sensitive data in console logs
|
||||||
|
- [x] .dockerignore prevents secrets in image
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Future: Admin Dashboard
|
||||||
|
|
||||||
|
**Priority: Low**
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
|
||||||
|
1. **User Management**
|
||||||
|
- View all registered users
|
||||||
|
- See last login, login count
|
||||||
|
- Enable/disable users
|
||||||
|
|
||||||
|
2. **Activity Viewer**
|
||||||
|
- Recent activity stream
|
||||||
|
- Filter by user, action, date
|
||||||
|
|
||||||
|
3. **Usage Statistics**
|
||||||
|
- Active users (daily/weekly/monthly)
|
||||||
|
- Most common actions
|
||||||
|
- Peak usage times
|
||||||
|
|
||||||
|
### Access Control
|
||||||
|
|
||||||
|
- Add `role: 'admin'` to user document
|
||||||
|
- Create `requireAdmin` middleware
|
||||||
|
- Manually set initial admin via database
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Future: Adding Apple Sign-In
|
||||||
|
|
||||||
|
**Priority: Future**
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
1. Apple Developer account ($99/year)
|
||||||
|
2. Service ID for web authentication
|
||||||
|
3. Private key for token verification
|
||||||
|
|
||||||
|
### Changes Needed
|
||||||
|
|
||||||
|
1. Install `passport-apple`
|
||||||
|
2. Add Apple strategy to passport
|
||||||
|
3. Add routes: `/auth/apple`, `/auth/apple/callback`
|
||||||
|
4. Update user model for multiple providers
|
||||||
|
5. Add "Sign in with Apple" button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Future: Partial Public Access
|
||||||
|
|
||||||
|
**Priority: Future**
|
||||||
|
|
||||||
|
Allow unauthenticated users to see a blurred preview of the overview page.
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
- Summary cards visible
|
||||||
|
- Charts and detailed data blurred with CSS
|
||||||
|
- Overlay with "Sign in to view" prompt
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
1. Make `/api/daily-summary` public
|
||||||
|
2. Create `AuthBlurOverlay` component
|
||||||
|
3. Wrap protected sections on overview page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Decisions Log
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|--------|-----------|
|
||||||
|
| Token storage | HTTP-only cookie | More secure than localStorage |
|
||||||
|
| Token expiry | 7 days with sliding refresh | Balance security and UX |
|
||||||
|
| Disabled user handling | Silent logout | No error messaging needed |
|
||||||
|
| State parameter | Random 32-byte hex | Industry standard CSRF protection |
|
||||||
|
| Activity cleanup | 90-day TTL | Automatic via MongoDB index |
|
||||||
|
| Date handling | Use latest database date | Handles server timezone mismatch |
|
||||||
|
| User upsert | findOneAndUpdate | Atomic create/update operation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
| Issue | Cause | Solution |
|
||||||
|
|-------|-------|----------|
|
||||||
|
| 401 on all endpoints | Missing/invalid JWT | Check cookie is set, not expired |
|
||||||
|
| OAuth callback 500 | Profile field access error | Use optional chaining on profile fields |
|
||||||
|
| Empty data arrays | Timezone mismatch | Server uses latest date from database |
|
||||||
|
| MongoDB conflict error | loginCount in $setOnInsert | Remove from $setOnInsert, use only $inc |
|
||||||
|
| Cookie not set | CORS misconfiguration | Ensure credentials: 'include' on all fetches |
|
||||||
|
| State validation fails | Cookie expired or cleared | State cookie has 5-minute lifetime |
|
||||||
156
__tests__/helpers/testHelpers.js
Normal file
156
__tests__/helpers/testHelpers.js
Normal 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
|
||||||
|
};
|
||||||
905
__tests__/phase1-foundation.test.js
Normal file
905
__tests__/phase1-foundation.test.js
Normal 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.data.users).toBeDefined();
|
||||||
|
expect(Array.isArray(response.body.data.users)).toBe(true);
|
||||||
|
expect(response.body.data.pagination).toBeDefined();
|
||||||
|
expect(response.body.data.pagination.page).toBe(1);
|
||||||
|
expect(response.body.data.pagination.limit).toBe(20);
|
||||||
|
expect(response.body.data.pagination.total).toBeGreaterThan(0);
|
||||||
|
expect(response.body.data.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.data.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.data.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.data.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.data.users.some(u => u.name.includes('John'))).toBe(true);
|
||||||
|
expect(response.body.data.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.data.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.data.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.data.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.data.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.data.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.data.user).toBeDefined();
|
||||||
|
expect(response.body.data.user._id.toString()).toBe(testUser._id.toString());
|
||||||
|
expect(response.body.data.user.email).toBe(testUser.email);
|
||||||
|
expect(response.body.data.user.name).toBe(testUser.name);
|
||||||
|
expect(response.body.data.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.data.user.recentActivity).toBeDefined();
|
||||||
|
expect(Array.isArray(response.body.data.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.data.user).toBeDefined();
|
||||||
|
expect(response.body.data.user.isActive).toBe(false);
|
||||||
|
expect(response.body.data.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.data.user).toBeDefined();
|
||||||
|
expect(response.body.data.user.isActive).toBe(true);
|
||||||
|
expect(response.body.data.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.data.user).toBeDefined();
|
||||||
|
expect(response.body.data.user.role).toBe('admin');
|
||||||
|
expect(response.body.data.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.data.user).toBeDefined();
|
||||||
|
expect(response.body.data.user.role).toBe('user');
|
||||||
|
expect(response.body.data.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.data.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1050
__tests__/phase2-activity.test.js
Normal file
1050
__tests__/phase2-activity.test.js
Normal file
File diff suppressed because it is too large
Load Diff
1036
__tests__/phase3-statistics.test.js
Normal file
1036
__tests__/phase3-statistics.test.js
Normal file
File diff suppressed because it is too large
Load Diff
980
__tests__/phase4-settings-security.test.js
Normal file
980
__tests__/phase4-settings-security.test.js
Normal file
@ -0,0 +1,980 @@
|
|||||||
|
/**
|
||||||
|
* 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.data).toHaveProperty('settings');
|
||||||
|
expect(response.body.data).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.data.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.data.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.data.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.data.settings.activityRetentionDays).toBe(120);
|
||||||
|
expect(response.body.data.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.data.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.data.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
|
||||||
|
// SKIPPED: Rate limiting moved to Phase 5
|
||||||
|
// ============================================================
|
||||||
|
describe.skip('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.data.settings.activityRetentionDays).toBe(180);
|
||||||
|
expect(updateResponse.body.data.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.data.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
13
__tests__/setup.js
Normal 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';
|
||||||
|
};
|
||||||
11
__tests__/setupAfterEnv.js
Normal file
11
__tests__/setupAfterEnv.js
Normal 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
6
__tests__/teardown.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = async () => {
|
||||||
|
// Stop the in-memory MongoDB instance
|
||||||
|
if (global.__MONGOD__) {
|
||||||
|
await global.__MONGOD__.stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
24
config/auth.js
Normal file
24
config/auth.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// Google OAuth and JWT configuration
|
||||||
|
module.exports = {
|
||||||
|
google: {
|
||||||
|
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||||
|
callbackURL: process.env.GOOGLE_CALLBACK_URL,
|
||||||
|
scope: ['profile', 'email']
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
secret: process.env.JWT_SECRET,
|
||||||
|
expiresIn: '7d',
|
||||||
|
refreshThreshold: 24 * 60 * 60 // Refresh if token is older than 1 day (in seconds)
|
||||||
|
},
|
||||||
|
cookie: {
|
||||||
|
name: 'auth_token',
|
||||||
|
options: {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||||
|
domain: process.env.NODE_ENV === 'production' ? '.maverickapplications.com' : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -3,10 +3,18 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: apartment-api
|
container_name: apartment-api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=8080 # Changed from 3000 to 8080
|
- PORT=8080
|
||||||
- MONGO_URI=mongodb://admin:password123@mongodb:27017
|
- MONGO_URI=${MONGO_URI}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
|
||||||
|
- GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- FRONTEND_URL=${FRONTEND_URL}
|
||||||
|
- ACTIVITY_LOG_LEVEL=${ACTIVITY_LOG_LEVEL:-all}
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.docker.network=traefik"
|
- "traefik.docker.network=traefik"
|
||||||
|
|||||||
17
jest.config.js
Normal file
17
jest.config.js
Normal 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']
|
||||||
|
};
|
||||||
111
middleware/auth.js
Normal file
111
middleware/auth.js
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const authConfig = require('../config/auth');
|
||||||
|
const { findById } = require('../models/user');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a token should be refreshed based on its age
|
||||||
|
* @param {Object} decoded - Decoded JWT payload
|
||||||
|
* @returns {boolean} True if token should be refreshed
|
||||||
|
*/
|
||||||
|
const shouldRefreshToken = (decoded) => {
|
||||||
|
const tokenAge = Math.floor(Date.now() / 1000) - decoded.iat;
|
||||||
|
return tokenAge > authConfig.jwt.refreshThreshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new JWT token for a user
|
||||||
|
* @param {string|ObjectId} userId - User's MongoDB _id
|
||||||
|
* @returns {string} JWT token
|
||||||
|
*/
|
||||||
|
const generateToken = (userId) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{ userId: userId.toString() },
|
||||||
|
authConfig.jwt.secret,
|
||||||
|
{ expiresIn: authConfig.jwt.expiresIn }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication middleware
|
||||||
|
* Validates JWT token from cookie and attaches user to request
|
||||||
|
* Implements sliding window token refresh
|
||||||
|
*
|
||||||
|
* @param {Request} req - Express request object
|
||||||
|
* @param {Response} res - Express response object
|
||||||
|
* @param {Function} next - Express next function
|
||||||
|
*/
|
||||||
|
const requireAuth = async (req, res, next) => {
|
||||||
|
const token = req.cookies[authConfig.cookie.name];
|
||||||
|
|
||||||
|
// No token present
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify the token
|
||||||
|
const decoded = jwt.verify(token, authConfig.jwt.secret);
|
||||||
|
|
||||||
|
// Get database instance
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
|
||||||
|
// Fetch user from database
|
||||||
|
const user = await findById(db, decoded.userId);
|
||||||
|
|
||||||
|
// User not found
|
||||||
|
if (!user) {
|
||||||
|
res.clearCookie(authConfig.cookie.name);
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is disabled (silent logout)
|
||||||
|
if (!user.isActive) {
|
||||||
|
res.clearCookie(authConfig.cookie.name);
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user to request
|
||||||
|
req.user = user;
|
||||||
|
|
||||||
|
// Sliding window token refresh
|
||||||
|
if (shouldRefreshToken(decoded)) {
|
||||||
|
const newToken = generateToken(user._id);
|
||||||
|
res.cookie(authConfig.cookie.name, newToken, authConfig.cookie.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
// Token verification failed (invalid or expired)
|
||||||
|
res.clearCookie(authConfig.cookie.name);
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin authorization middleware
|
||||||
|
* Must be used after requireAuth middleware
|
||||||
|
* Checks if authenticated user has admin role
|
||||||
|
*
|
||||||
|
* @param {Request} req - Express request object
|
||||||
|
* @param {Response} res - Express response object
|
||||||
|
* @param {Function} next - Express next function
|
||||||
|
*/
|
||||||
|
const requireAdmin = (req, res, next) => {
|
||||||
|
// Check if user is attached (requireAuth should be called first)
|
||||||
|
if (!req.user) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin role
|
||||||
|
if (req.user.role !== 'admin') {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
requireAuth,
|
||||||
|
requireAdmin,
|
||||||
|
generateToken
|
||||||
|
};
|
||||||
36
middleware/passport.js
Normal file
36
middleware/passport.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const passport = require('passport');
|
||||||
|
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||||
|
const authConfig = require('../config/auth');
|
||||||
|
const { findOrCreateUser } = require('../models/user');
|
||||||
|
|
||||||
|
const configurePassport = (passport, db) => {
|
||||||
|
passport.use(new GoogleStrategy({
|
||||||
|
clientID: authConfig.google.clientID,
|
||||||
|
clientSecret: authConfig.google.clientSecret,
|
||||||
|
callbackURL: authConfig.google.callbackURL
|
||||||
|
},
|
||||||
|
async (accessToken, refreshToken, profile, done) => {
|
||||||
|
try {
|
||||||
|
// Safely extract email
|
||||||
|
const email = profile.emails?.[0]?.value;
|
||||||
|
if (!email) {
|
||||||
|
return done(new Error('No email found in Google profile'), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userProfile = {
|
||||||
|
googleId: profile.id,
|
||||||
|
email: email,
|
||||||
|
name: profile.displayName,
|
||||||
|
picture: profile.photos?.[0]?.value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const user = await findOrCreateUser(db, userProfile);
|
||||||
|
done(null, user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Passport strategy error:', error);
|
||||||
|
done(error, null);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { configurePassport };
|
||||||
253
models/user.js
Normal file
253
models/user.js
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
|
||||||
|
const USER_COLLECTION = 'users';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find or create a user based on Google OAuth profile
|
||||||
|
* Uses upsert pattern to handle both new and returning users
|
||||||
|
*
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @param {Object} profile - Google OAuth profile
|
||||||
|
* @param {string} profile.id - Google's unique user ID
|
||||||
|
* @param {string} profile.displayName - User's display name
|
||||||
|
* @param {Array} profile.emails - Array of email objects
|
||||||
|
* @param {Array} profile.photos - Array of photo objects
|
||||||
|
* @returns {Promise<Object>} User document
|
||||||
|
*/
|
||||||
|
async function findOrCreateUser(db, profile) {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const result = await db.collection(USER_COLLECTION).findOneAndUpdate(
|
||||||
|
{ googleId: profile.googleId },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
email: profile.email,
|
||||||
|
name: profile.name,
|
||||||
|
picture: profile.picture || null,
|
||||||
|
lastLoginAt: now
|
||||||
|
},
|
||||||
|
$inc: { loginCount: 1 },
|
||||||
|
$setOnInsert: {
|
||||||
|
googleId: profile.googleId,
|
||||||
|
isActive: true,
|
||||||
|
role: 'user',
|
||||||
|
createdAt: now
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
upsert: true,
|
||||||
|
returnDocument: 'after'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a user by Google ID
|
||||||
|
*
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @param {string} googleId - Google's unique user ID
|
||||||
|
* @returns {Promise<Object|null>} User document or null
|
||||||
|
*/
|
||||||
|
async function findByGoogleId(db, googleId) {
|
||||||
|
return await db.collection(USER_COLLECTION).findOne({ googleId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a user by MongoDB ObjectId
|
||||||
|
*
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @param {string|ObjectId} id - User's MongoDB _id
|
||||||
|
* @returns {Promise<Object|null>} User document or null
|
||||||
|
*/
|
||||||
|
async function findById(db, id) {
|
||||||
|
// Convert string to ObjectId if needed
|
||||||
|
const objectId = typeof id === 'string' ? new ObjectId(id) : id;
|
||||||
|
return await db.collection(USER_COLLECTION).findOne({ _id: objectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable a user account
|
||||||
|
*
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @param {string|ObjectId} id - User's MongoDB _id
|
||||||
|
* @param {boolean} isActive - New active status
|
||||||
|
* @param {string|ObjectId|null} adminId - Admin performing the action (required when disabling)
|
||||||
|
* @returns {Promise<Object>} Update result
|
||||||
|
*/
|
||||||
|
async function setUserActive(db, id, isActive, adminId = null) {
|
||||||
|
// Convert string to ObjectId if needed
|
||||||
|
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(
|
||||||
|
{ _id: objectId },
|
||||||
|
updateDoc,
|
||||||
|
{ returnDocument: 'after' }
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create required indexes for the users collection
|
||||||
|
* Should be called once during application startup
|
||||||
|
*
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function createIndexes(db) {
|
||||||
|
const collection = db.collection(USER_COLLECTION);
|
||||||
|
|
||||||
|
// Unique index on googleId for OAuth lookups
|
||||||
|
await collection.createIndex(
|
||||||
|
{ googleId: 1 },
|
||||||
|
{ unique: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unique index on email for user identification
|
||||||
|
await collection.createIndex(
|
||||||
|
{ email: 1 },
|
||||||
|
{ unique: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Compound index for querying active users sorted by last login
|
||||||
|
await collection.createIndex(
|
||||||
|
{ 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = {
|
||||||
|
USER_COLLECTION,
|
||||||
|
findOrCreateUser,
|
||||||
|
findByGoogleId,
|
||||||
|
findById,
|
||||||
|
setUserActive,
|
||||||
|
createIndexes,
|
||||||
|
getPaginatedUsers,
|
||||||
|
updateUserRole,
|
||||||
|
isValidObjectId
|
||||||
|
};
|
||||||
5246
package-lock.json
generated
5246
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
38
package.json
38
package.json
@ -4,28 +4,38 @@
|
|||||||
"description": "API backend for Country Club Towers & Gardens apartment dashboard",
|
"description": "API backend for Country Club Towers & Gardens apartment dashboard",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"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",
|
||||||
"api",
|
"api",
|
||||||
"real-estate",
|
"real-estate",
|
||||||
"monitoring"
|
"monitoring"
|
||||||
],
|
],
|
||||||
"author": "Stephen",
|
"author": "Stephen",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"cookie-parser": "^1.4.7",
|
||||||
"mongodb": "^6.3.0",
|
"cors": "^2.8.5",
|
||||||
"cors": "^2.8.5",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^7.1.5"
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mongodb": "^6.3.0",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
179
routes/activity.js
Normal file
179
routes/activity.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
const { requireAuth, requireAdmin } = require('../middleware/auth');
|
||||||
|
const { logActivity, ACTIONS, ACTIVITY_COLLECTION } = require('../services/activityLogger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /log - Log frontend activity
|
||||||
|
* Protected with requireAuth
|
||||||
|
*/
|
||||||
|
router.post('/log', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { action, metadata } = req.body;
|
||||||
|
|
||||||
|
// Validate action is in ACTIONS object
|
||||||
|
if (!action || !Object.values(ACTIONS).includes(action)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid action type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
|
||||||
|
// Log the activity with merged metadata
|
||||||
|
await logActivity(
|
||||||
|
db,
|
||||||
|
req.user._id,
|
||||||
|
action,
|
||||||
|
{ ...metadata, page: metadata?.page },
|
||||||
|
req
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging activity:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to log activity' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /user/:userId - Get activity for specific user
|
||||||
|
* Protected with requireAuth and requireAdmin
|
||||||
|
*/
|
||||||
|
router.get('/user/:userId', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
|
const skip = parseInt(req.query.skip) || 0;
|
||||||
|
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
|
||||||
|
// Convert userId to ObjectId
|
||||||
|
let userObjectId;
|
||||||
|
try {
|
||||||
|
userObjectId = new ObjectId(userId);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: 'Invalid user ID format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find activities for the user
|
||||||
|
const activities = await db
|
||||||
|
.collection(ACTIVITY_COLLECTION)
|
||||||
|
.find({ userId: userObjectId })
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await db
|
||||||
|
.collection(ACTIVITY_COLLECTION)
|
||||||
|
.countDocuments({ userId: userObjectId });
|
||||||
|
|
||||||
|
res.json({ activities, total });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user activity:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch user activity' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /recent - Get recent activity across all users
|
||||||
|
* Protected with requireAuth and requireAdmin
|
||||||
|
*/
|
||||||
|
router.get('/recent', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
|
const skip = parseInt(req.query.skip) || 0;
|
||||||
|
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
|
||||||
|
// Find all activities sorted by timestamp
|
||||||
|
const activities = await db
|
||||||
|
.collection(ACTIVITY_COLLECTION)
|
||||||
|
.find({})
|
||||||
|
.sort({ timestamp: -1 })
|
||||||
|
.skip(skip)
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await db.collection(ACTIVITY_COLLECTION).countDocuments({});
|
||||||
|
|
||||||
|
res.json({ activities, total });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent activity:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch recent activity' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /stats - Get activity statistics
|
||||||
|
* Protected with requireAuth and requireAdmin
|
||||||
|
*/
|
||||||
|
router.get('/stats', requireAuth, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
|
||||||
|
// Calculate date 7 days ago
|
||||||
|
const sevenDaysAgo = new Date();
|
||||||
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||||
|
|
||||||
|
// Aggregate activity counts by action type in last 7 days
|
||||||
|
const actionCountsResult = await db
|
||||||
|
.collection(ACTIVITY_COLLECTION)
|
||||||
|
.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
timestamp: { $gte: sevenDaysAgo }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$action',
|
||||||
|
count: { $sum: 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Convert array to object
|
||||||
|
const actionCounts = {};
|
||||||
|
actionCountsResult.forEach(item => {
|
||||||
|
actionCounts[item._id] = item.count;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count unique active users in last 7 days
|
||||||
|
const activeUsersResult = await db
|
||||||
|
.collection(ACTIVITY_COLLECTION)
|
||||||
|
.aggregate([
|
||||||
|
{
|
||||||
|
$match: {
|
||||||
|
timestamp: { $gte: sevenDaysAgo }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$userId'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$count: 'total'
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
const activeUsers = activeUsersResult.length > 0 ? activeUsersResult[0].total : 0;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
actionCounts,
|
||||||
|
activeUsers,
|
||||||
|
period: '7d'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching activity stats:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch activity statistics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
1146
routes/admin.js
Normal file
1146
routes/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
154
routes/auth.js
Normal file
154
routes/auth.js
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const passport = require('passport');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const authConfig = require('../config/auth');
|
||||||
|
const { requireAuth, generateToken } = require('../middleware/auth');
|
||||||
|
const { logActivity, ACTIONS } = require('../services/activityLogger');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/google
|
||||||
|
* Initiates Google OAuth flow with state parameter for CSRF protection
|
||||||
|
*/
|
||||||
|
router.get('/google', (req, res, next) => {
|
||||||
|
// Generate cryptographically secure state parameter
|
||||||
|
const state = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
// Store state in a short-lived cookie for validation
|
||||||
|
res.cookie('oauth_state', state, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 5 * 60 * 1000 // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.authenticate('google', {
|
||||||
|
scope: authConfig.google.scope,
|
||||||
|
session: false,
|
||||||
|
state: state
|
||||||
|
})(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/google/callback
|
||||||
|
* Handles OAuth callback from Google
|
||||||
|
* Validates state parameter for CSRF protection
|
||||||
|
* On success: generates JWT, sets cookie, redirects to frontend
|
||||||
|
* On failure: redirects to login with error
|
||||||
|
*/
|
||||||
|
router.get('/google/callback', (req, res, next) => {
|
||||||
|
// Validate state parameter to prevent CSRF
|
||||||
|
const stateFromCookie = req.cookies.oauth_state;
|
||||||
|
const stateFromQuery = req.query.state;
|
||||||
|
|
||||||
|
// Clear the state cookie immediately
|
||||||
|
res.clearCookie('oauth_state');
|
||||||
|
|
||||||
|
if (!stateFromCookie || !stateFromQuery || stateFromCookie !== stateFromQuery) {
|
||||||
|
console.warn('OAuth state mismatch - potential CSRF attack');
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/login?error=invalid_state`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State is valid, proceed with authentication
|
||||||
|
passport.authenticate('google', {
|
||||||
|
session: false,
|
||||||
|
failureRedirect: `${process.env.FRONTEND_URL}/login?error=auth_failed`
|
||||||
|
})(req, res, next);
|
||||||
|
}, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
// Check if email is verified (if available in profile)
|
||||||
|
if (req.authInfo && req.authInfo.emails && req.authInfo.emails[0]) {
|
||||||
|
const emailVerified = req.authInfo.emails[0].verified !== false; // Default to true if not present
|
||||||
|
if (!emailVerified) {
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/login?error=unverified`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user account is active
|
||||||
|
if (!user.isActive) {
|
||||||
|
return res.redirect(`${process.env.FRONTEND_URL}/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate JWT token
|
||||||
|
const token = generateToken(user._id);
|
||||||
|
|
||||||
|
// Set auth cookie
|
||||||
|
res.cookie(authConfig.cookie.name, token, authConfig.cookie.options);
|
||||||
|
|
||||||
|
// Log login activity
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
await logActivity(db, user._id, ACTIONS.LOGIN, { method: 'google' }, req);
|
||||||
|
|
||||||
|
// Redirect to frontend
|
||||||
|
res.redirect(process.env.FRONTEND_URL);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('OAuth callback error:', err);
|
||||||
|
res.redirect(`${process.env.FRONTEND_URL}/login?error=auth_failed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/me
|
||||||
|
* Returns current authenticated user's data
|
||||||
|
* Protected route - requires valid JWT
|
||||||
|
*/
|
||||||
|
router.get('/me', requireAuth, (req, res) => {
|
||||||
|
res.json({
|
||||||
|
user: {
|
||||||
|
id: req.user._id,
|
||||||
|
email: req.user.email,
|
||||||
|
name: req.user.name,
|
||||||
|
picture: req.user.picture,
|
||||||
|
role: req.user.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/logout
|
||||||
|
* Clears authentication cookie
|
||||||
|
* Protected route - requires valid JWT
|
||||||
|
*/
|
||||||
|
router.post('/logout', requireAuth, async (req, res) => {
|
||||||
|
// Log logout activity before clearing cookie
|
||||||
|
const db = req.app.locals.db;
|
||||||
|
await logActivity(db, req.user._id, ACTIONS.LOGOUT, {}, req);
|
||||||
|
|
||||||
|
// Clear the auth cookie
|
||||||
|
res.clearCookie(authConfig.cookie.name, {
|
||||||
|
...authConfig.cookie.options,
|
||||||
|
maxAge: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Logged out successfully' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /auth/status
|
||||||
|
* Returns authentication status without requiring middleware
|
||||||
|
* Used for quick frontend checks
|
||||||
|
*/
|
||||||
|
router.get('/status', (req, res) => {
|
||||||
|
const token = req.cookies[authConfig.cookie.name];
|
||||||
|
|
||||||
|
// No token present
|
||||||
|
if (!token) {
|
||||||
|
return res.json({ authenticated: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify the token
|
||||||
|
jwt.verify(token, authConfig.jwt.secret);
|
||||||
|
return res.json({ authenticated: true });
|
||||||
|
} catch (err) {
|
||||||
|
// Token invalid or expired
|
||||||
|
return res.json({ authenticated: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
225
server.js
225
server.js
@ -2,12 +2,36 @@ const express = require('express');
|
|||||||
const { MongoClient } = require('mongodb');
|
const { MongoClient } = require('mongodb');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const passport = require('passport');
|
||||||
|
const { configurePassport } = require('./middleware/passport');
|
||||||
|
const { requireAuth } = require('./middleware/auth');
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const activityRoutes = require('./routes/activity');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
const { createIndexes } = require('./models/user');
|
||||||
|
const { createActivityIndexes } = require('./services/activityLogger');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const MONGO_URI = process.env.MONGO_URI || "mongodb://admin:password123@localhost:27017";
|
if (!process.env.MONGO_URI && process.env.NODE_ENV === 'production') {
|
||||||
|
console.error('❌ MONGO_URI environment variable is required in production');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const MONGO_URI = process.env.MONGO_URI || "mongodb://localhost:27017";
|
||||||
|
|
||||||
|
// Log environment configuration status (safe - no secret values)
|
||||||
|
console.log('📋 Environment Configuration:');
|
||||||
|
console.log(` NODE_ENV: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
console.log(` MONGO_URI: ${MONGO_URI ? '✓ Set' : '✗ Missing'}`);
|
||||||
|
console.log(` GOOGLE_CLIENT_ID: ${process.env.GOOGLE_CLIENT_ID ? '✓ Set' : '✗ Missing'}`);
|
||||||
|
console.log(` GOOGLE_CLIENT_SECRET: ${process.env.GOOGLE_CLIENT_SECRET ? '✓ Set' : '✗ Missing'}`);
|
||||||
|
console.log(` GOOGLE_CALLBACK_URL: ${process.env.GOOGLE_CALLBACK_URL || '✗ Missing'}`);
|
||||||
|
console.log(` JWT_SECRET: ${process.env.JWT_SECRET ? `✓ Set (${process.env.JWT_SECRET.length} chars)` : '✗ Missing'}`);
|
||||||
|
console.log(` FRONTEND_URL: ${process.env.FRONTEND_URL || 'http://localhost:5173 (default)'}`);
|
||||||
|
console.log(` ACTIVITY_LOG_LEVEL: ${process.env.ACTIVITY_LOG_LEVEL || 'all (default)'}`);
|
||||||
const DB_NAME = "apartments";
|
const DB_NAME = "apartments";
|
||||||
const UNITS_COLLECTION = "units_migration_test";
|
const UNITS_COLLECTION = "units_migration_test";
|
||||||
const PRICES_COLLECTION = "unit_prices_migration_test";
|
const PRICES_COLLECTION = "unit_prices_migration_test";
|
||||||
@ -16,7 +40,17 @@ const DAILY_SUMMARIES_COLLECTION = "daily_summaries";
|
|||||||
console.log(`🔗 Connecting to MongoDB: ${MONGO_URI.includes('localhost') ? 'Local MongoDB' : 'Atlas MongoDB'}`);
|
console.log(`🔗 Connecting to MongoDB: ${MONGO_URI.includes('localhost') ? 'Local MongoDB' : 'Atlas MongoDB'}`);
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
|
||||||
|
|
||||||
|
const corsOptions = {
|
||||||
|
origin: FRONTEND_URL,
|
||||||
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
exposedHeaders: ['set-cookie']
|
||||||
|
};
|
||||||
|
app.use(cors(corsOptions));
|
||||||
|
app.use(cookieParser());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
@ -26,6 +60,9 @@ const limiter = rateLimit({
|
|||||||
});
|
});
|
||||||
app.use(limiter);
|
app.use(limiter);
|
||||||
|
|
||||||
|
// Initialize Passport
|
||||||
|
app.use(passport.initialize());
|
||||||
|
|
||||||
// MongoDB connection
|
// MongoDB connection
|
||||||
let db;
|
let db;
|
||||||
let client;
|
let client;
|
||||||
@ -35,23 +72,84 @@ async function connectToMongoDB() {
|
|||||||
client = new MongoClient(MONGO_URI);
|
client = new MongoClient(MONGO_URI);
|
||||||
await client.connect();
|
await client.connect();
|
||||||
db = client.db(DB_NAME);
|
db = client.db(DB_NAME);
|
||||||
|
app.locals.db = db;
|
||||||
console.log('✅ Successfully connected to MongoDB');
|
console.log('✅ Successfully connected to MongoDB');
|
||||||
|
|
||||||
|
// Configure Passport and create indexes after DB connection
|
||||||
|
configurePassport(passport, db);
|
||||||
|
await createIndexes(db);
|
||||||
|
await createActivityIndexes(db);
|
||||||
|
console.log('✅ Passport configured and indexes created');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to connect to MongoDB:', error);
|
console.error('❌ Failed to connect to MongoDB:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get today's date
|
// Helper function to get today's date in UTC
|
||||||
const getTodayDate = () => new Date().toISOString().split('T')[0];
|
const getTodayDateUTC = () => new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
// Helper function to get yesterday's date
|
// Cache for the most recent date with data
|
||||||
const getYesterdayDate = () => {
|
let cachedLatestDate = null;
|
||||||
const yesterday = new Date();
|
let cachedLatestDateTimestamp = 0;
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
return yesterday.toISOString().split('T')[0];
|
|
||||||
|
// Helper function to get the most recent date with data in the database
|
||||||
|
// This handles timezone mismatches between server and data collection
|
||||||
|
const getLatestDateWithData = async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Return cached value if still valid
|
||||||
|
if (cachedLatestDate && (now - cachedLatestDateTimestamp) < CACHE_TTL) {
|
||||||
|
return cachedLatestDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.collection(PRICES_COLLECTION)
|
||||||
|
.find({})
|
||||||
|
.sort({ date_checked: -1 })
|
||||||
|
.limit(1)
|
||||||
|
.project({ date_checked: 1 })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
cachedLatestDate = result[0].date_checked;
|
||||||
|
cachedLatestDateTimestamp = now;
|
||||||
|
return cachedLatestDate;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching latest date with data:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to UTC today if no data found
|
||||||
|
return getTodayDateUTC();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function to get yesterday relative to the latest date with data
|
||||||
|
const getYesterdayDate = (today) => {
|
||||||
|
const todayDate = new Date(today + 'T00:00:00Z');
|
||||||
|
todayDate.setDate(todayDate.getDate() - 1);
|
||||||
|
return todayDate.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to validate unit code (prevents NoSQL injection)
|
||||||
|
const isValidUnitCode = (unitCode) => {
|
||||||
|
// Allow alphanumeric characters, hyphens, and underscores, max 20 chars
|
||||||
|
return typeof unitCode === 'string' &&
|
||||||
|
unitCode.length > 0 &&
|
||||||
|
unitCode.length <= 20 &&
|
||||||
|
/^[A-Za-z0-9_-]+$/.test(unitCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mount auth routes
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
|
||||||
|
// Mount activity routes
|
||||||
|
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({
|
||||||
@ -62,7 +160,7 @@ app.get('/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get last scrape time
|
// Get last scrape time
|
||||||
app.get('/last-scrape', async (req, res) => {
|
app.get('/last-scrape', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get the most recent price record using _id (ObjectId contains timestamp)
|
// Get the most recent price record using _id (ObjectId contains timestamp)
|
||||||
const lastRecord = await db.collection(PRICES_COLLECTION)
|
const lastRecord = await db.collection(PRICES_COLLECTION)
|
||||||
@ -94,9 +192,9 @@ app.get('/last-scrape', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get daily summary (matches your daily_summaries collection)
|
// Get daily summary (matches your daily_summaries collection)
|
||||||
app.get('/daily-summary', async (req, res) => {
|
app.get('/daily-summary', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
|
|
||||||
// Try to get today's summary first
|
// Try to get today's summary first
|
||||||
let summary = await db.collection(DAILY_SUMMARIES_COLLECTION)
|
let summary = await db.collection(DAILY_SUMMARIES_COLLECTION)
|
||||||
@ -133,14 +231,16 @@ app.get('/daily-summary', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get price history (last 15 days of average prices)
|
// Get price history (last 15 days of average prices)
|
||||||
app.get('/price-history', async (req, res) => {
|
app.get('/price-history', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const days = parseInt(req.query.days) || 15;
|
const days = parseInt(req.query.days) || 15;
|
||||||
|
const latestDate = await getLatestDateWithData();
|
||||||
|
|
||||||
// Generate date range
|
// Generate date range ending at the latest date with data
|
||||||
const dates = [];
|
const dates = [];
|
||||||
|
const baseDate = new Date(latestDate + 'T00:00:00Z');
|
||||||
for (let i = days - 1; i >= 0; i--) {
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
const date = new Date();
|
const date = new Date(baseDate);
|
||||||
date.setDate(date.getDate() - i);
|
date.setDate(date.getDate() - i);
|
||||||
dates.push(date.toISOString().split('T')[0]);
|
dates.push(date.toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
@ -188,9 +288,9 @@ app.get('/price-history', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get available units with current prices
|
// Get available units with current prices
|
||||||
app.get('/available-units', async (req, res) => {
|
app.get('/available-units', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
|
|
||||||
// Get units that have prices today (excluding furnished units)
|
// Get units that have prices today (excluding furnished units)
|
||||||
const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([
|
const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([
|
||||||
@ -273,21 +373,36 @@ app.get('/available-units', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
|
// Filter prices to only include those from availability start date onwards
|
||||||
|
availabilityPrices: {
|
||||||
|
$filter: {
|
||||||
|
input: "$all_prices",
|
||||||
|
cond: { $gte: ["$$this.date_checked", "$availabilityCalc.startDate"] }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
priceHistory: {
|
||||||
|
$slice: [
|
||||||
|
{ $sortArray: { input: "$all_prices", sortBy: { date_checked: -1 } } },
|
||||||
|
30
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$addFields: {
|
||||||
priceStats: {
|
priceStats: {
|
||||||
$let: {
|
$let: {
|
||||||
vars: {
|
vars: {
|
||||||
prices: { $map: { input: "$all_prices", as: "p", in: "$$p.price" } }
|
prices: { $map: { input: "$availabilityPrices", as: "p", in: "$$p.price" } }
|
||||||
},
|
},
|
||||||
in: {
|
in: {
|
||||||
minPrice: { $min: "$$prices" },
|
minPrice: { $min: "$$prices" },
|
||||||
maxPrice: { $max: "$$prices" },
|
maxPrice: { $max: "$$prices" }
|
||||||
priceHistory: {
|
|
||||||
$slice: [
|
|
||||||
{ $sortArray: { input: "$all_prices", sortBy: { date_checked: -1 } } },
|
|
||||||
30
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -315,7 +430,7 @@ app.get('/available-units', 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",
|
||||||
@ -336,10 +451,15 @@ app.get('/available-units', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get full price history for a specific unit
|
// Get full price history for a specific unit
|
||||||
app.get('/unit/:unitCode/price-history', async (req, res) => {
|
app.get('/unit/:unitCode/price-history', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { unitCode } = req.params;
|
const { unitCode } = req.params;
|
||||||
|
|
||||||
|
// Validate unitCode to prevent NoSQL injection
|
||||||
|
if (!isValidUnitCode(unitCode)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid unit code format' });
|
||||||
|
}
|
||||||
|
|
||||||
const priceHistory = await db.collection(PRICES_COLLECTION)
|
const priceHistory = await db.collection(PRICES_COLLECTION)
|
||||||
.find({ unit_code: unitCode })
|
.find({ unit_code: unitCode })
|
||||||
.sort({ date_checked: 1 })
|
.sort({ date_checked: 1 })
|
||||||
@ -363,9 +483,9 @@ app.get('/unit/:unitCode/price-history', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get comprehensive analytics data
|
// Get comprehensive analytics data
|
||||||
app.get('/analytics', async (req, res) => {
|
app.get('/analytics', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
|
|
||||||
// 1. Monthly Price Trends - aggregate all prices by month
|
// 1. Monthly Price Trends - aggregate all prices by month
|
||||||
const monthlyTrends = await db.collection(PRICES_COLLECTION).aggregate([
|
const monthlyTrends = await db.collection(PRICES_COLLECTION).aggregate([
|
||||||
@ -605,10 +725,10 @@ app.get('/analytics', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get recent activity (new units, rented units, price changes)
|
// Get recent activity (new units, rented units, price changes)
|
||||||
app.get('/recent-activity', async (req, res) => {
|
app.get('/recent-activity', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
const yesterday = getYesterdayDate();
|
const yesterday = getYesterdayDate(today);
|
||||||
|
|
||||||
// Get today's summary for new/rented units
|
// Get today's summary for new/rented units
|
||||||
const todaySummary = await db.collection(DAILY_SUMMARIES_COLLECTION)
|
const todaySummary = await db.collection(DAILY_SUMMARIES_COLLECTION)
|
||||||
@ -734,9 +854,9 @@ app.get('/recent-activity', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get plan statistics
|
// Get plan statistics
|
||||||
app.get('/plan-stats', async (req, res) => {
|
app.get('/plan-stats', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
|
|
||||||
const planStats = await db.collection(UNITS_COLLECTION).aggregate([
|
const planStats = await db.collection(UNITS_COLLECTION).aggregate([
|
||||||
{
|
{
|
||||||
@ -797,9 +917,9 @@ app.get('/plan-stats', async (req, res) => {
|
|||||||
|
|
||||||
// Expanded api
|
// Expanded api
|
||||||
// Get best deals (units priced below their historical average)
|
// Get best deals (units priced below their historical average)
|
||||||
app.get('/best-deals', async (req, res) => {
|
app.get('/best-deals', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
const limit = parseInt(req.query.limit) || 5;
|
const limit = parseInt(req.query.limit) || 5;
|
||||||
|
|
||||||
const bestDeals = await db.collection(UNITS_COLLECTION).aggregate([
|
const bestDeals = await db.collection(UNITS_COLLECTION).aggregate([
|
||||||
@ -922,16 +1042,17 @@ app.get('/best-deals', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get price drops (units with recent price decreases)
|
// Get price drops (units with recent price decreases)
|
||||||
app.get('/price-drops', async (req, res) => {
|
app.get('/price-drops', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
const daysBack = parseInt(req.query.days) || 7;
|
const daysBack = parseInt(req.query.days) || 7;
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
// Generate date range for the last N days
|
// Generate date range for the last N days relative to latest data date
|
||||||
const dates = [];
|
const dates = [];
|
||||||
|
const baseDate = new Date(today + 'T00:00:00Z');
|
||||||
for (let i = 0; i < daysBack; i++) {
|
for (let i = 0; i < daysBack; i++) {
|
||||||
const date = new Date();
|
const date = new Date(baseDate);
|
||||||
date.setDate(date.getDate() - i);
|
date.setDate(date.getDate() - i);
|
||||||
dates.push(date.toISOString().split('T')[0]);
|
dates.push(date.toISOString().split('T')[0]);
|
||||||
}
|
}
|
||||||
@ -1019,9 +1140,9 @@ app.get('/price-drops', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get stale inventory (units on market for a long time)
|
// Get stale inventory (units on market for a long time)
|
||||||
app.get('/stale-inventory', async (req, res) => {
|
app.get('/stale-inventory', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
const minDays = parseInt(req.query.minDays) || 10;
|
const minDays = parseInt(req.query.minDays) || 10;
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
@ -1145,9 +1266,9 @@ app.get('/stale-inventory', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get market insights and predictions
|
// Get market insights and predictions
|
||||||
app.get('/market-insights', async (req, res) => {
|
app.get('/market-insights', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
|
|
||||||
// Get various market metrics
|
// Get various market metrics
|
||||||
const [
|
const [
|
||||||
@ -1355,10 +1476,10 @@ app.get('/market-insights', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Enhanced available units with more details
|
// Enhanced available units with more details
|
||||||
app.get('/available-units-enhanced', async (req, res) => {
|
app.get('/available-units-enhanced', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const today = getTodayDate();
|
const today = await getLatestDateWithData();
|
||||||
const yesterday = getYesterdayDate();
|
const yesterday = getYesterdayDate(today);
|
||||||
|
|
||||||
const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([
|
const availableUnits = await db.collection(UNITS_COLLECTION).aggregate([
|
||||||
{
|
{
|
||||||
@ -1541,10 +1662,16 @@ app.get('/available-units-enhanced', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get unit details by unit code
|
// Get unit details by unit code
|
||||||
app.get('/unit/:unitCode', async (req, res) => {
|
app.get('/unit/:unitCode', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { unitCode } = req.params;
|
const { unitCode } = req.params;
|
||||||
const today = getTodayDate();
|
|
||||||
|
// Validate unitCode to prevent NoSQL injection
|
||||||
|
if (!isValidUnitCode(unitCode)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid unit code format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = await getLatestDateWithData();
|
||||||
|
|
||||||
const unit = await db.collection(UNITS_COLLECTION).aggregate([
|
const unit = await db.collection(UNITS_COLLECTION).aggregate([
|
||||||
{ $match: { unit_code: unitCode } },
|
{ $match: { unit_code: unitCode } },
|
||||||
|
|||||||
192
services/activityLogger.js
Normal file
192
services/activityLogger.js
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
const { ObjectId } = require('mongodb');
|
||||||
|
|
||||||
|
// Collection name
|
||||||
|
const ACTIVITY_COLLECTION = 'user_activity';
|
||||||
|
|
||||||
|
// Activity action constants
|
||||||
|
const ACTIONS = {
|
||||||
|
LOGIN: 'login',
|
||||||
|
LOGOUT: 'logout',
|
||||||
|
PAGE_VIEW: 'page_view',
|
||||||
|
NAVIGATION: 'navigation',
|
||||||
|
UNIT_VIEW: 'unit_view',
|
||||||
|
UNIT_WATCH: 'unit_watch',
|
||||||
|
FILTER_CHANGE: 'filter_change',
|
||||||
|
SORT_CHANGE: 'sort_change',
|
||||||
|
SEARCH: 'search',
|
||||||
|
EXPORT: 'export',
|
||||||
|
VIEW_TOGGLE: 'view_toggle'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Log levels define which actions should be logged
|
||||||
|
const LOG_LEVELS = {
|
||||||
|
all: [
|
||||||
|
'login',
|
||||||
|
'logout',
|
||||||
|
'page_view',
|
||||||
|
'navigation',
|
||||||
|
'filter_change',
|
||||||
|
'sort_change',
|
||||||
|
'search',
|
||||||
|
'unit_view',
|
||||||
|
'unit_watch',
|
||||||
|
'export',
|
||||||
|
'view_toggle'
|
||||||
|
],
|
||||||
|
navigation: [
|
||||||
|
'login',
|
||||||
|
'logout',
|
||||||
|
'page_view',
|
||||||
|
'navigation'
|
||||||
|
],
|
||||||
|
none: []
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current activity log level from environment variable
|
||||||
|
* @returns {string} Current log level ('all', 'navigation', or 'none')
|
||||||
|
*/
|
||||||
|
function getActivityLevel() {
|
||||||
|
const level = process.env.ACTIVITY_LOG_LEVEL || 'all';
|
||||||
|
|
||||||
|
// Validate level exists, default to 'all' if invalid
|
||||||
|
if (!LOG_LEVELS[level]) {
|
||||||
|
console.warn(`Invalid ACTIVITY_LOG_LEVEL: ${level}, defaulting to 'all'`);
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an action should be logged based on current log level
|
||||||
|
* @param {string} action - Action to check
|
||||||
|
* @returns {boolean} True if action should be logged
|
||||||
|
*/
|
||||||
|
function shouldLog(action) {
|
||||||
|
const level = getActivityLevel();
|
||||||
|
const allowedActions = LOG_LEVELS[level];
|
||||||
|
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
|
||||||
|
* 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 {string|Object} userIdOrOptions - User ID string or options object
|
||||||
|
* @param {string} [action] - Action type (use ACTIONS constants)
|
||||||
|
* @param {Object} [metadata] - Additional action-specific data
|
||||||
|
* @param {Object} [req] - Express request object (for IP and user agent)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function logActivity(db, userIdOrOptions, action, metadata = {}, req = null) {
|
||||||
|
let userId;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract IP address (handle proxy forwarding)
|
||||||
|
let ip = null;
|
||||||
|
if (actualReq) {
|
||||||
|
ip = actualReq.ip || actualReq.headers?.['x-forwarded-for']?.split(',')[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract user agent
|
||||||
|
const userAgent = actualReq?.headers?.['user-agent'] || null;
|
||||||
|
|
||||||
|
// Create activity document
|
||||||
|
const activityDoc = {
|
||||||
|
userId: new ObjectId(userId),
|
||||||
|
action: actualAction,
|
||||||
|
metadata: actualMetadata || {},
|
||||||
|
page: actualMetadata?.page || null,
|
||||||
|
timestamp: new Date(),
|
||||||
|
userAgent: userAgent,
|
||||||
|
ip: ip
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert into user_activity collection
|
||||||
|
await db.collection(ACTIVITY_COLLECTION).insertOne(activityDoc);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't throw - activity logging should not break the main flow
|
||||||
|
console.error('Error logging activity:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create required indexes for the user_activity collection
|
||||||
|
* @param {Db} db - MongoDB database instance
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function createActivityIndexes(db) {
|
||||||
|
try {
|
||||||
|
const collection = db.collection(ACTIVITY_COLLECTION);
|
||||||
|
|
||||||
|
// Index for user activity queries (get all activities for a user, sorted by time)
|
||||||
|
await collection.createIndex(
|
||||||
|
{ userId: 1, timestamp: -1 },
|
||||||
|
{ name: 'userId_timestamp' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Index for action type queries (get all activities of a certain type)
|
||||||
|
await collection.createIndex(
|
||||||
|
{ action: 1, timestamp: -1 },
|
||||||
|
{ name: 'action_timestamp' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// TTL index to automatically delete activities older than 90 days
|
||||||
|
await collection.createIndex(
|
||||||
|
{ timestamp: 1 },
|
||||||
|
{
|
||||||
|
name: 'timestamp_ttl',
|
||||||
|
expireAfterSeconds: 7776000 // 90 days = 90 * 24 * 60 * 60
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Activity collection indexes created successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating activity indexes:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ACTIVITY_COLLECTION,
|
||||||
|
ACTIONS,
|
||||||
|
LOG_LEVELS,
|
||||||
|
getActivityLevel,
|
||||||
|
shouldLog,
|
||||||
|
logActivity,
|
||||||
|
createActivityIndexes
|
||||||
|
};
|
||||||
680
test-endpoints.js
Normal file
680
test-endpoints.js
Normal 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();
|
||||||
Reference in New Issue
Block a user