Compare commits
2 Commits
81e5682ab1
...
94bbacb3a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 94bbacb3a2 | |||
| ccda2be551 |
112
.github/workflows/deploy.yml
vendored
112
.github/workflows/deploy.yml
vendored
@ -1,14 +1,34 @@
|
|||||||
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
|
||||||
@ -16,45 +36,83 @@ jobs:
|
|||||||
- 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'
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy via SSH
|
||||||
|
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
|
||||||
|
|
||||||
# Navigate to deployment directory
|
# Navigate to deployment directory
|
||||||
cd /media/stephen/Storage_Linux/infrastructure/services/apartment-api
|
cd ${{ secrets.DEPLOY_PATH }}
|
||||||
|
|
||||||
# Stop existing container if running
|
# Pull latest code
|
||||||
docker compose down || true
|
git fetch origin main
|
||||||
|
git reset --hard origin/main
|
||||||
|
|
||||||
# Start new container
|
# Rebuild and restart container
|
||||||
|
docker compose build --no-cache
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Clean up old images
|
# Clean up old images
|
||||||
docker image prune -f
|
docker image prune -f
|
||||||
|
|
||||||
|
# Wait for container to be healthy
|
||||||
|
echo "Waiting for container to start..."
|
||||||
|
sleep 10
|
||||||
|
|
||||||
|
# Verify container is running
|
||||||
|
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
|
- name: Verify deployment
|
||||||
run: |
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
# Wait for container to start
|
with:
|
||||||
sleep 15
|
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")
|
||||||
|
|
||||||
# Check if container is running
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
docker ps | grep apartment-api
|
echo "Health check passed (HTTP $HTTP_CODE)"
|
||||||
|
else
|
||||||
# Test health endpoint
|
echo "WARNING: Health check returned HTTP $HTTP_CODE"
|
||||||
curl -f http://localhost:3000/health || echo "API may still be starting..."
|
echo "Container may still be initializing..."
|
||||||
|
fi
|
||||||
# Test API endpoint
|
|
||||||
curl -f http://localhost:3000/api/daily-summary || echo "API may still be loading data..."
|
|
||||||
|
|||||||
@ -361,13 +361,13 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.users).toBeDefined();
|
expect(response.body.data.users).toBeDefined();
|
||||||
expect(Array.isArray(response.body.users)).toBe(true);
|
expect(Array.isArray(response.body.data.users)).toBe(true);
|
||||||
expect(response.body.pagination).toBeDefined();
|
expect(response.body.data.pagination).toBeDefined();
|
||||||
expect(response.body.pagination.page).toBe(1);
|
expect(response.body.data.pagination.page).toBe(1);
|
||||||
expect(response.body.pagination.limit).toBe(20);
|
expect(response.body.data.pagination.limit).toBe(20);
|
||||||
expect(response.body.pagination.total).toBeGreaterThan(0);
|
expect(response.body.data.pagination.total).toBeGreaterThan(0);
|
||||||
expect(response.body.pagination.pages).toBeDefined();
|
expect(response.body.data.pagination.pages).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support page parameter', async () => {
|
it('should support page parameter', async () => {
|
||||||
@ -383,7 +383,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.pagination.page).toBe(2);
|
expect(response.body.data.pagination.page).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support limit parameter with max 100', async () => {
|
it('should support limit parameter with max 100', async () => {
|
||||||
@ -392,7 +392,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.pagination.limit).toBe(50);
|
expect(response.body.data.pagination.limit).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should cap limit at 100', async () => {
|
it('should cap limit at 100', async () => {
|
||||||
@ -401,7 +401,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.pagination.limit).toBeLessThanOrEqual(100);
|
expect(response.body.data.pagination.limit).toBeLessThanOrEqual(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support search parameter for name', async () => {
|
it('should support search parameter for name', async () => {
|
||||||
@ -413,8 +413,8 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.users.some(u => u.name.includes('John'))).toBe(true);
|
expect(response.body.data.users.some(u => u.name.includes('John'))).toBe(true);
|
||||||
expect(response.body.users.every(u =>
|
expect(response.body.data.users.every(u =>
|
||||||
u.name.toLowerCase().includes('john') ||
|
u.name.toLowerCase().includes('john') ||
|
||||||
u.email.toLowerCase().includes('john')
|
u.email.toLowerCase().includes('john')
|
||||||
)).toBe(true);
|
)).toBe(true);
|
||||||
@ -428,7 +428,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.users.some(u => u.email.includes('unique-test'))).toBe(true);
|
expect(response.body.data.users.some(u => u.email.includes('unique-test'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support status filter "active"', async () => {
|
it('should support status filter "active"', async () => {
|
||||||
@ -440,7 +440,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.users.every(u => u.isActive === true)).toBe(true);
|
expect(response.body.data.users.every(u => u.isActive === true)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support status filter "disabled"', async () => {
|
it('should support status filter "disabled"', async () => {
|
||||||
@ -451,7 +451,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.users.every(u => u.isActive === false)).toBe(true);
|
expect(response.body.data.users.every(u => u.isActive === false)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support sort parameter', async () => {
|
it('should support sort parameter', async () => {
|
||||||
@ -472,7 +472,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
// Newest first when sorted descending
|
// Newest first when sorted descending
|
||||||
const createdDates = response.body.users.map(u => new Date(u.createdAt));
|
const createdDates = response.body.data.users.map(u => new Date(u.createdAt));
|
||||||
for (let i = 0; i < createdDates.length - 1; i++) {
|
for (let i = 0; i < createdDates.length - 1; i++) {
|
||||||
expect(createdDates[i] >= createdDates[i + 1]).toBe(true);
|
expect(createdDates[i] >= createdDates[i + 1]).toBe(true);
|
||||||
}
|
}
|
||||||
@ -485,7 +485,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
// Verify ascending order
|
// Verify ascending order
|
||||||
const counts = response.body.users.map(u => u.loginCount);
|
const counts = response.body.data.users.map(u => u.loginCount);
|
||||||
for (let i = 0; i < counts.length - 1; i++) {
|
for (let i = 0; i < counts.length - 1; i++) {
|
||||||
expect(counts[i] <= counts[i + 1]).toBe(true);
|
expect(counts[i] <= counts[i + 1]).toBe(true);
|
||||||
}
|
}
|
||||||
@ -517,11 +517,11 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.user).toBeDefined();
|
expect(response.body.data.user).toBeDefined();
|
||||||
expect(response.body.user._id.toString()).toBe(testUser._id.toString());
|
expect(response.body.data.user._id.toString()).toBe(testUser._id.toString());
|
||||||
expect(response.body.user.email).toBe(testUser.email);
|
expect(response.body.data.user.email).toBe(testUser.email);
|
||||||
expect(response.body.user.name).toBe(testUser.name);
|
expect(response.body.data.user.name).toBe(testUser.name);
|
||||||
expect(response.body.user.role).toBe(testUser.role);
|
expect(response.body.data.user.role).toBe(testUser.role);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include recent activity for the user', async () => {
|
it('should include recent activity for the user', async () => {
|
||||||
@ -550,8 +550,8 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.set('Cookie', [`auth_token=${adminToken}`]);
|
.set('Cookie', [`auth_token=${adminToken}`]);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.user.recentActivity).toBeDefined();
|
expect(response.body.data.user.recentActivity).toBeDefined();
|
||||||
expect(Array.isArray(response.body.user.recentActivity)).toBe(true);
|
expect(Array.isArray(response.body.data.user.recentActivity)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 404 for non-existent user ID', async () => {
|
it('should return 404 for non-existent user ID', async () => {
|
||||||
@ -601,9 +601,9 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.send({ isActive: false });
|
.send({ isActive: false });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.user).toBeDefined();
|
expect(response.body.data.user).toBeDefined();
|
||||||
expect(response.body.user.isActive).toBe(false);
|
expect(response.body.data.user.isActive).toBe(false);
|
||||||
expect(response.body.message).toContain('disabled');
|
expect(response.body.data.message).toContain('disabled');
|
||||||
|
|
||||||
// Verify in database
|
// Verify in database
|
||||||
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
|
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
|
||||||
@ -620,9 +620,9 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.send({ isActive: true });
|
.send({ isActive: true });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.user).toBeDefined();
|
expect(response.body.data.user).toBeDefined();
|
||||||
expect(response.body.user.isActive).toBe(true);
|
expect(response.body.data.user.isActive).toBe(true);
|
||||||
expect(response.body.message).toContain('enabled');
|
expect(response.body.data.message).toContain('enabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set disabledAt timestamp when disabling', async () => {
|
it('should set disabledAt timestamp when disabling', async () => {
|
||||||
@ -729,9 +729,9 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.send({ role: 'admin' });
|
.send({ role: 'admin' });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.user).toBeDefined();
|
expect(response.body.data.user).toBeDefined();
|
||||||
expect(response.body.user.role).toBe('admin');
|
expect(response.body.data.user.role).toBe('admin');
|
||||||
expect(response.body.message).toContain('promoted');
|
expect(response.body.data.message).toContain('promoted');
|
||||||
|
|
||||||
// Verify in database
|
// Verify in database
|
||||||
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
|
const dbUser = await db.collection('users').findOne({ _id: testUser._id });
|
||||||
@ -748,9 +748,9 @@ describe('Phase 1: Foundation', () => {
|
|||||||
.send({ role: 'user' });
|
.send({ role: 'user' });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body.user).toBeDefined();
|
expect(response.body.data.user).toBeDefined();
|
||||||
expect(response.body.user.role).toBe('user');
|
expect(response.body.data.user.role).toBe('user');
|
||||||
expect(response.body.message).toContain('demoted');
|
expect(response.body.data.message).toContain('demoted');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 when trying to demote yourself from admin', async () => {
|
it('should return 400 when trying to demote yourself from admin', async () => {
|
||||||
@ -846,7 +846,7 @@ describe('Phase 1: Foundation', () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
// Check that sensitive fields are not exposed
|
// Check that sensitive fields are not exposed
|
||||||
const userInResponse = response.body.users.find(
|
const userInResponse = response.body.data.users.find(
|
||||||
u => u._id.toString() === testUser._id.toString()
|
u => u._id.toString() === testUser._id.toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -94,8 +94,8 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body).toHaveProperty('activities');
|
expect(res.body.data).toHaveProperty('activities');
|
||||||
expect(res.body).toHaveProperty('pagination');
|
expect(res.body.data).toHaveProperty('pagination');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -119,8 +119,8 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(50);
|
expect(res.body.data.activities).toHaveLength(50);
|
||||||
expect(res.body.pagination).toEqual(expect.objectContaining({
|
expect(res.body.data.pagination).toEqual(expect.objectContaining({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
total: 100,
|
total: 100,
|
||||||
@ -146,8 +146,8 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(50);
|
expect(res.body.data.activities).toHaveLength(50);
|
||||||
expect(res.body.pagination.page).toBe(2);
|
expect(res.body.data.pagination.page).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respect custom limit parameter', async () => {
|
it('should respect custom limit parameter', async () => {
|
||||||
@ -168,9 +168,9 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(25);
|
expect(res.body.data.activities).toHaveLength(25);
|
||||||
expect(res.body.pagination.limit).toBe(25);
|
expect(res.body.data.pagination.limit).toBe(25);
|
||||||
expect(res.body.pagination.pages).toBe(4);
|
expect(res.body.data.pagination.pages).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should enforce maximum limit of 200', async () => {
|
it('should enforce maximum limit of 200', async () => {
|
||||||
@ -183,7 +183,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.pagination.limit).toBeLessThanOrEqual(200);
|
expect(res.body.data.pagination.limit).toBeLessThanOrEqual(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty array for page beyond available data', async () => {
|
it('should return empty array for page beyond available data', async () => {
|
||||||
@ -204,7 +204,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(0);
|
expect(res.body.data.activities).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -231,8 +231,8 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(20);
|
expect(res.body.data.activities).toHaveLength(20);
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(activity.userId.toString()).toBe(user1._id.toString());
|
expect(activity.userId.toString()).toBe(user1._id.toString());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -256,8 +256,8 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(10);
|
expect(res.body.data.activities).toHaveLength(10);
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(activity.action).toBe('login');
|
expect(activity.action).toBe('login');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -286,7 +286,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(new Date(activity.timestamp).getTime()).toBeGreaterThanOrEqual(startDate.getTime());
|
expect(new Date(activity.timestamp).getTime()).toBeGreaterThanOrEqual(startDate.getTime());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -315,7 +315,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(new Date(activity.timestamp).getTime()).toBeLessThanOrEqual(endDate.getTime());
|
expect(new Date(activity.timestamp).getTime()).toBeLessThanOrEqual(endDate.getTime());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -340,7 +340,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(activity.userId.toString()).toBe(user1._id.toString());
|
expect(activity.userId.toString()).toBe(user1._id.toString());
|
||||||
expect(activity.action).toBe('page_view');
|
expect(activity.action).toBe('page_view');
|
||||||
});
|
});
|
||||||
@ -356,7 +356,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(0);
|
expect(res.body.data.activities).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -377,7 +377,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(activity).toHaveProperty('userName');
|
expect(activity).toHaveProperty('userName');
|
||||||
expect(activity.userName).toBe('Test User Name');
|
expect(activity.userName).toBe('Test User Name');
|
||||||
});
|
});
|
||||||
@ -399,7 +399,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(activity).toHaveProperty('userEmail');
|
expect(activity).toHaveProperty('userEmail');
|
||||||
expect(activity.userEmail).toBe('testuser@example.com');
|
expect(activity.userEmail).toBe('testuser@example.com');
|
||||||
});
|
});
|
||||||
@ -421,9 +421,9 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities.length).toBeGreaterThan(0);
|
expect(res.body.data.activities.length).toBeGreaterThan(0);
|
||||||
// userName and userEmail should be null for deleted users
|
// userName and userEmail should be null for deleted users
|
||||||
const activity = res.body.activities[0];
|
const activity = res.body.data.activities[0];
|
||||||
expect(activity.userName).toBeNull();
|
expect(activity.userName).toBeNull();
|
||||||
expect(activity.userEmail).toBeNull();
|
expect(activity.userEmail).toBeNull();
|
||||||
});
|
});
|
||||||
@ -450,7 +450,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
const timestamps = res.body.activities.map(a => new Date(a.timestamp).getTime());
|
const timestamps = res.body.data.activities.map(a => new Date(a.timestamp).getTime());
|
||||||
for (let i = 1; i < timestamps.length; i++) {
|
for (let i = 1; i < timestamps.length; i++) {
|
||||||
expect(timestamps[i - 1]).toBeGreaterThanOrEqual(timestamps[i]);
|
expect(timestamps[i - 1]).toBeGreaterThanOrEqual(timestamps[i]);
|
||||||
}
|
}
|
||||||
@ -569,7 +569,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body).toHaveProperty('activities');
|
expect(res.body.data).toHaveProperty('activities');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -596,8 +596,8 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(15);
|
expect(res.body.data.activities).toHaveLength(15);
|
||||||
res.body.activities.forEach(activity => {
|
res.body.data.activities.forEach(activity => {
|
||||||
expect(activity.userId.toString()).toBe(user1._id.toString());
|
expect(activity.userId.toString()).toBe(user1._id.toString());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -647,8 +647,8 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(5);
|
expect(res.body.data.activities).toHaveLength(5);
|
||||||
expect(res.body.pagination).toEqual(expect.objectContaining({
|
expect(res.body.data.pagination).toEqual(expect.objectContaining({
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 5,
|
limit: 5,
|
||||||
total: 15,
|
total: 15,
|
||||||
@ -668,7 +668,7 @@ describe('Phase 2: Activity Monitoring API', () => {
|
|||||||
.set('Cookie', [`auth_token=${token}`])
|
.set('Cookie', [`auth_token=${token}`])
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(res.body.activities).toHaveLength(0);
|
expect(res.body.data.activities).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -255,8 +255,8 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
.send({ activityRetentionDays: 60 })
|
.send({ activityRetentionDays: 60 })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body).toHaveProperty('settings');
|
expect(response.body.data).toHaveProperty('settings');
|
||||||
expect(response.body).toHaveProperty('message');
|
expect(response.body.data).toHaveProperty('message');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -287,7 +287,7 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
.send({ activityRetentionDays: 30 })
|
.send({ activityRetentionDays: 30 })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.settings.activityRetentionDays).toBe(30);
|
expect(response.body.data.settings.activityRetentionDays).toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept retention period at maximum boundary (365 days)', async () => {
|
it('should accept retention period at maximum boundary (365 days)', async () => {
|
||||||
@ -296,7 +296,7 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
.send({ activityRetentionDays: 365 })
|
.send({ activityRetentionDays: 365 })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.settings.activityRetentionDays).toBe(365);
|
expect(response.body.data.settings.activityRetentionDays).toBe(365);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept valid retention period within range (180 days)', async () => {
|
it('should accept valid retention period within range (180 days)', async () => {
|
||||||
@ -305,7 +305,7 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
.send({ activityRetentionDays: 180 })
|
.send({ activityRetentionDays: 180 })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.settings.activityRetentionDays).toBe(180);
|
expect(response.body.data.settings.activityRetentionDays).toBe(180);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return 400 for non-numeric retention period', async () => {
|
it('should return 400 for non-numeric retention period', async () => {
|
||||||
@ -343,8 +343,8 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
.send({ activityRetentionDays: 120 })
|
.send({ activityRetentionDays: 120 })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.settings.activityRetentionDays).toBe(120);
|
expect(response.body.data.settings.activityRetentionDays).toBe(120);
|
||||||
expect(response.body.message).toBe('Settings updated successfully');
|
expect(response.body.data.message).toBe('Settings updated successfully');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should persist the updated settings in database', async () => {
|
it('should persist the updated settings in database', async () => {
|
||||||
@ -643,7 +643,7 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
.send({ role: 'user' })
|
.send({ role: 'user' })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.user.role).toBe('user');
|
expect(response.body.data.user.role).toBe('user');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not allow self-demotion even when other admins exist', async () => {
|
it('should not allow self-demotion even when other admins exist', async () => {
|
||||||
@ -688,7 +688,7 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
.send({ role: 'user' })
|
.send({ role: 'user' })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response1.body.user.role).toBe('user');
|
expect(response1.body.data.user.role).toBe('user');
|
||||||
|
|
||||||
// Now the primary admin is the last one, cannot demote
|
// Now the primary admin is the last one, cannot demote
|
||||||
const response2 = await authAs(testUsers.admin)
|
const response2 = await authAs(testUsers.admin)
|
||||||
@ -702,8 +702,9 @@ describe('Phase 4: Settings & Security', () => {
|
|||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// SEC-4.3: Rate Limiting on Admin Endpoints
|
// SEC-4.3: Rate Limiting on Admin Endpoints
|
||||||
|
// SKIPPED: Rate limiting moved to Phase 5
|
||||||
// ============================================================
|
// ============================================================
|
||||||
describe('SEC-4.3: Rate Limiting', () => {
|
describe.skip('SEC-4.3: Rate Limiting', () => {
|
||||||
/**
|
/**
|
||||||
* Helper to make multiple rapid requests
|
* Helper to make multiple rapid requests
|
||||||
* @param {string} endpoint - API endpoint
|
* @param {string} endpoint - API endpoint
|
||||||
@ -917,8 +918,8 @@ describe('Integration: Settings Update Workflow', () => {
|
|||||||
.send({ activityRetentionDays: 180 })
|
.send({ activityRetentionDays: 180 })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(updateResponse.body.settings.activityRetentionDays).toBe(180);
|
expect(updateResponse.body.data.settings.activityRetentionDays).toBe(180);
|
||||||
expect(updateResponse.body.message).toBe('Settings updated successfully');
|
expect(updateResponse.body.data.message).toBe('Settings updated successfully');
|
||||||
|
|
||||||
// Step 3: Verify settings persisted
|
// Step 3: Verify settings persisted
|
||||||
const verifyResponse = await authAdmin('get', '/api/admin/settings')
|
const verifyResponse = await authAdmin('get', '/api/admin/settings')
|
||||||
@ -962,7 +963,7 @@ describe('Integration: Settings Update Workflow', () => {
|
|||||||
.send({ activityRetentionDays: value })
|
.send({ activityRetentionDays: value })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(response.body.settings.activityRetentionDays).toBe(value);
|
expect(response.body.data.settings.activityRetentionDays).toBe(value);
|
||||||
|
|
||||||
// Verify TTL index after each update
|
// Verify TTL index after each update
|
||||||
const indexes = await db.collection('user_activity').indexes();
|
const indexes = await db.collection('user_activity').indexes();
|
||||||
|
|||||||
Reference in New Issue
Block a user