Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 7 additions & 8 deletions .github/workflows/backend-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,14 @@ jobs:
path: coverage/
retention-days: 7 # Keep artifacts for 7 days

# Job for Continuous Deployment: Deploys the backend to Render.
cd:
name: Deploy to Production
# Security: Dependency Review
dependency-review:
runs-on: ubuntu-latest
needs: ci-unit-tests # This job depends on the 'ci-unit-tests' job passing
# Only run this deployment job if:
# 1. The event is a push to the 'main' branch.
# 2. The previous 'ci-unit-tests' job was successful.
if: github.ref == 'refs/heads/main' && github.event_name == 'push' && success()
steps:
- name: Checkout Repository
uses: actions/checkout@v6
- name: Dependency Review
uses: actions/dependency-review-action@v4
steps:
# Step 1: Deploy the backend application to Render
- name: Deploy to Render and wait for completion
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: "CodeQL"

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '0 6 * * 1' # Weekly on Mondays at 6 AM UTC

jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write

strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}

- name: Autobuild
uses: github/codeql-action/autobuild@v3

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
6 changes: 6 additions & 0 deletions tests/integration/place.read.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ describe('Place Controller - Search Tests', () => {
expect(response.status).toBe(200);
expect(response.body.data.results.length).toBeGreaterThanOrEqual(1);
});

it('should reject invalid characters in keywords', async () => {
const response = await api.get('/places/search?keywords=bad;keyword');
expect(response.status).toBe(400);
expect(response.body.error).toBe('INVALID_INPUT');
});
});
});
});
210 changes: 210 additions & 0 deletions tests/unit/adminController.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* @fileoverview Unit Tests for Admin Controller
* @module tests/unit/adminController.test
* Tests controller error handling by mocking dependencies
* to cover error paths and edge cases.
*/

import { jest } from '@jest/globals';

// Mock dependencies
const mockGetReportsForPlace = jest.fn();
const mockUpdatePlace = jest.fn();
const mockGetReviewsForPlace = jest.fn();
const mockRequirePlace = jest.fn();
const mockBuildHateoasLinks = {
adminReportsCollection: jest.fn(),
adminPlace: jest.fn()
};
const mockR = {
success: jest.fn(),
forbidden: jest.fn(),
unauthorized: jest.fn()
};

jest.unstable_mockModule('../../config/db.js', () => ({
default: {
getReportsForPlace: mockGetReportsForPlace,
updatePlace: mockUpdatePlace,
getReviewsForPlace: mockGetReviewsForPlace
}
}));

jest.unstable_mockModule('../../utils/controllerValidators.js', () => ({
requirePlace: mockRequirePlace
}));

jest.unstable_mockModule('../../utils/hateoasBuilder.js', () => ({
default: mockBuildHateoasLinks
}));

jest.unstable_mockModule('../../utils/responseBuilder.js', () => ({
default: mockR
}));

// Mock jwt
jest.unstable_mockModule('jsonwebtoken', () => ({
default: {
sign: jest.fn()
}
}));

// Import controller after mocking
const adminController = (await import('../../controllers/adminController.js')).default;

/**
* Test suite for admin controller.
*/
describe('Admin Controller - Unit Tests', () => {
let mockReq;
let mockRes;
let mockNext;

beforeEach(() => {
jest.clearAllMocks();

mockReq = {
params: { adminId: '1', placeId: '1' },
body: {}
};

mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn()
};

mockNext = jest.fn();
});

describe('getPlaceReports', () => {
it('should return reports when place exists', async () => {
const mockPlace = { placeId: 1 };
const mockReports = [{ reportId: 1 }];
const mockEnrichedReports = [{ reportId: 1, links: {} }];

mockRequirePlace.mockResolvedValue(mockPlace);
mockGetReportsForPlace.mockResolvedValue(mockReports);
mockEnrichReportsWithLinks.mockReturnValue(mockEnrichedReports);
mockBuildHateoasLinks.adminReportsCollection = jest.fn().mockReturnValue({});
mockR.success.mockReturnValue(undefined);

await adminController.getPlaceReports(mockReq, mockRes, mockNext);

expect(mockRequirePlace).toHaveBeenCalledWith(mockRes, 1);
expect(mockGetReportsForPlace).toHaveBeenCalledWith(1);
expect(mockR.success).toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});

it('should return early when place does not exist', async () => {
mockRequirePlace.mockResolvedValue(null);

await adminController.getPlaceReports(mockReq, mockRes, mockNext);

expect(mockRequirePlace).toHaveBeenCalledWith(mockRes, 1);
expect(mockGetReportsForPlace).not.toHaveBeenCalled();
expect(mockR.success).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});

it('should call next on error', async () => {
const error = new Error('DB error');
mockRequirePlace.mockRejectedValue(error);

await adminController.getPlaceReports(mockReq, mockRes, mockNext);

expect(mockNext).toHaveBeenCalledWith(error);
});
});

describe('updatePlace', () => {
it('should update place when exists', async () => {
const mockPlace = { placeId: 1, toObject: () => ({ placeId: 1, location: {} }) };
const mockUpdatedPlace = { placeId: 1, toObject: () => ({ placeId: 1 }) };
const mockReviews = [];

mockRequirePlace.mockResolvedValue(mockPlace);
mockPreparePlaceUpdateDTO.mockReturnValue({ name: 'Updated' });
mockUpdatePlace.mockResolvedValue(mockUpdatedPlace);
mockGetReviewsForPlace.mockResolvedValue(mockReviews);
mockBuildHateoasLinks.adminPlace = jest.fn().mockReturnValue({});
mockR.success.mockReturnValue(undefined);

await adminController.updatePlace(mockReq, mockRes, mockNext);

expect(mockRequirePlace).toHaveBeenCalledWith(mockRes, 1);
expect(mockUpdatePlace).toHaveBeenCalled();
expect(mockR.success).toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});

it('should return early when place does not exist', async () => {
mockRequirePlace.mockResolvedValue(null);

await adminController.updatePlace(mockReq, mockRes, mockNext);

expect(mockRequirePlace).toHaveBeenCalledWith(mockRes, 1);
expect(mockUpdatePlace).not.toHaveBeenCalled();
expect(mockR.success).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});

it('should call next on error', async () => {
const error = new Error('Update error');
mockRequirePlace.mockRejectedValue(error);

await adminController.updatePlace(mockReq, mockRes, mockNext);

expect(mockNext).toHaveBeenCalledWith(error);
});
});

describe('generateAdminToken', () => {
it('should return forbidden in production', () => {
process.env.NODE_ENV = 'production';
mockR.forbidden.mockReturnValue(undefined);

adminController.generateAdminToken(mockReq, mockRes, mockNext);

expect(mockR.forbidden).toHaveBeenCalledWith(mockRes, 'DISABLED_IN_PRODUCTION', 'Admin token generation endpoint is disabled in production.');
expect(mockNext).not.toHaveBeenCalled();
});

it('should return unauthorized for invalid credentials', () => {
process.env.NODE_ENV = 'development';
mockReq.body = { username: 'wrong', password: 'wrong' };
mockR.unauthorized.mockReturnValue(undefined);

adminController.generateAdminToken(mockReq, mockRes, mockNext);

expect(mockR.unauthorized).toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});

it('should generate token for valid credentials', () => {
process.env.NODE_ENV = 'development';
process.env.JWT_SECRET = 'secret';
mockReq.body = { username: 'admin', password: 'admin123' };
const jwt = (await import('jsonwebtoken')).default;
jwt.sign.mockReturnValue('token');
mockR.success.mockReturnValue(undefined);

adminController.generateAdminToken(mockReq, mockRes, mockNext);

expect(jwt.sign).toHaveBeenCalled();
expect(mockR.success).toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});

it('should call next on error', () => {
process.env.NODE_ENV = 'development';
mockReq.body = { username: 'admin', password: 'admin123' };
const jwt = (await import('jsonwebtoken')).default;
jwt.sign.mockImplementation(() => { throw new Error('JWT error'); });

adminController.generateAdminToken(mockReq, mockRes, mockNext);

expect(mockNext).toHaveBeenCalledWith(expect.any(Error));
});
});
});
Loading