diff --git a/.github/workflows/backend-cicd.yml b/.github/workflows/backend-cicd.yml index c9dcfd8..09a170a 100644 --- a/.github/workflows/backend-cicd.yml +++ b/.github/workflows/backend-cicd.yml @@ -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 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a942d46 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -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}}" \ No newline at end of file diff --git a/tests/integration/place.read.test.js b/tests/integration/place.read.test.js index 29d4701..57cc0f0 100644 --- a/tests/integration/place.read.test.js +++ b/tests/integration/place.read.test.js @@ -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'); + }); }); }); }); diff --git a/tests/unit/adminController.test.js b/tests/unit/adminController.test.js new file mode 100644 index 0000000..43490cd --- /dev/null +++ b/tests/unit/adminController.test.js @@ -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)); + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/infrastructure.db.test.js b/tests/unit/infrastructure.db.test.js index c991c9a..03d62f6 100644 --- a/tests/unit/infrastructure.db.test.js +++ b/tests/unit/infrastructure.db.test.js @@ -39,8 +39,15 @@ describe('Infrastructure - Database Connection', () => { // Mock models to avoid real DB operations jest.unstable_mockModule('../../models/index.js', () => ({ default: { - User: { countDocuments: jest.fn().mockResolvedValue(0) }, - Place: { countDocuments: jest.fn().mockResolvedValue(0) } + User: { countDocuments: jest.fn().mockResolvedValue(0), insertMany: jest.fn() }, + Place: { countDocuments: jest.fn().mockResolvedValue(0), insertMany: jest.fn() }, + PreferenceProfile: { insertMany: jest.fn() }, + Review: { insertMany: jest.fn() }, + Report: { insertMany: jest.fn() }, + FavouritePlace: { insertMany: jest.fn() }, + DislikedPlace: { insertMany: jest.fn() }, + Settings: { insertMany: jest.fn() }, + Counter: { insertMany: jest.fn() } } })); @@ -119,6 +126,87 @@ describe('Infrastructure - Database Connection', () => { expect(mockMongoose.connect).toHaveBeenCalledTimes(1); // Still 1 }); + it('must handle disconnectDB error', async () => { + process.env.MONGODB_URI = 'mongodb://localhost:27017/test'; + process.env.DISABLE_SEEDING = 'true'; + + mockMongoose.connect.mockResolvedValue(undefined); + mockMongoose.disconnect.mockRejectedValue(new Error('Disconnect failed')); + + const database = await import('../../config/database.js'); + connectDB = database.connectDB || database.default?.connectDB; + const disconnectDB = database.disconnectDB || database.default?.disconnectDB; + + await connectDB(); + await expect(disconnectDB()).rejects.toThrow('Disconnect failed'); + }); + + it('must handle seedInitialData error when countDocuments fails', async () => { + process.env.MONGODB_URI = 'mongodb://localhost:27017/test'; + process.env.DISABLE_SEEDING = 'false'; + + mockMongoose.connect.mockResolvedValue(undefined); + + // Mock models to throw on countDocuments + jest.unstable_mockModule('../../models/index.js', () => ({ + default: { + User: { countDocuments: jest.fn().mockRejectedValue(new Error('Count failed')) }, + Place: { insertMany: jest.fn() } + } + })); + + const database = await import('../../config/database.js'); + connectDB = database.connectDB || database.default?.connectDB; + + await expect(connectDB()).rejects.toThrow('Count failed'); + }); + + it('must skip seeding when data already exists', async () => { + process.env.MONGODB_URI = 'mongodb://localhost:27017/test'; + process.env.DISABLE_SEEDING = 'false'; + + mockMongoose.connect.mockResolvedValue(undefined); + + // Mock models to have existing data + jest.unstable_mockModule('../../models/index.js', () => ({ + default: { + User: { countDocuments: jest.fn().mockResolvedValue(1) }, + Place: { insertMany: jest.fn() } + } + })); + + const database = await import('../../config/database.js'); + connectDB = database.connectDB || database.default?.connectDB; + + await connectDB(); + + // Should not call insertMany + const models = await import('../../models/index.js'); + expect(models.default.Place.insertMany).not.toHaveBeenCalled(); + }); + + it('must handle clearAllData error', async () => { + process.env.MONGODB_URI = 'mongodb://localhost:27017/test'; + process.env.DISABLE_SEEDING = 'true'; + + mockMongoose.connect.mockResolvedValue(undefined); + mockMongoose.connection.readyState = 1; // Connected + + const database = await import('../../config/database.js'); + connectDB = database.connectDB || database.default?.connectDB; + const clearAllData = database.clearAllData || database.default?.clearAllData; + + await connectDB(); + + // Mock models to fail deleteMany after import + const models = await import('../../models/index.js'); + models.default.User.deleteMany = jest.fn().mockImplementation(() => { + throw new Error('Delete failed'); + }); + + await expect(clearAllData()).rejects.toThrow('Delete failed'); + }); + }); }); @@ -244,3 +332,36 @@ describe('Infrastructure - MongoDB API Wrapper', () => { }); }); + +describe('Infrastructure - Database Selector (db.js)', () => { + + let originalEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + }); + + afterEach(() => { + process.env = originalEnv; + jest.resetModules(); + }); + + it('must export mongoDb when USE_MONGODB=true', async () => { + process.env.USE_MONGODB = 'true'; + + const db = await import('../../config/db.js'); + + expect(db.default).toBeDefined(); + expect(typeof db.default.findPlaceById).toBe('function'); + }); + + it('must export inMemoryDb when USE_MONGODB=false', async () => { + process.env.USE_MONGODB = 'false'; + + const db = await import('../../config/db.js'); + + expect(db.default).toBeDefined(); + expect(typeof db.default.findPlaceById).toBe('function'); + }); + +});