diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index c1f11fb..ba7ad9c 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -9,8 +9,37 @@ on: - main jobs: - build: - name: Build and Test Backend + # Lint Job + lint: + name: Lint Backend + runs-on: ubuntu-latest + + steps: + # Step 1: Checkout the code + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full-depth clone for SonarQube analysis + + # Step 2: Run Snyk Security Scan + - name: Snyk Security Scan + uses: snyk/actions/node@master + with: + command: test --all-projects + env: + SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + + # Step 3: SonarQube Scan + - name: SonarQube Scan + uses: sonarsource/sonarqube-scan-action@master + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + + # Test Job + test: + name: Test Backend + needs: lint runs-on: ubuntu-latest steps: @@ -31,11 +60,7 @@ jobs: - name: Install Dependencies run: npm ci - # Step 4: Build the source code (if applicable) - - name: Build Source Code - run: npm run build --if-present - - # Step 5: Run tests + # Step 4: Run tests - name: Run Tests env: DB_HOST: ${{ secrets.DB_HOST }} @@ -45,36 +70,36 @@ jobs: DB_NAME: ${{ secrets.DB_NAME }} run: npm test --if-present - lint: - name: Lint Backend - needs: build + # Build Job + build: + name: Build Backend + needs: test runs-on: ubuntu-latest steps: # Step 1: Checkout the code - name: Checkout Code uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full-depth clone for SonarQube analysis - # Step 2: Run Snyk Security Scan using Snyk's cloud service - - name: Snyk Security Scan - uses: snyk/actions/node@master + # Step 2: Set up Node.js environment (latest version) + - name: Set up Node.js + uses: actions/setup-node@v4 with: - command: test --all-projects - env: - SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} + node-version: "22.x" + cache: npm - # Step 3: SonarQube Scan - - name: SonarQube Scan - uses: sonarsource/sonarqube-scan-action@master - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + # Step 3: Install dependencies + - name: Install Dependencies + run: npm ci + + # Step 4: Build the source code + - name: Build Source Code + run: npm run build --if-present + # Deliver Job deliver: name: Deliver Backend - needs: lint + needs: build runs-on: ubuntu-latest steps: @@ -94,11 +119,18 @@ jobs: - name: Push Docker Image run: docker push gregory789/contint-backend-repository:contint-backend-image + # Deploy Job deploy: name: Deploy Backend needs: deliver runs-on: ubuntu-22.04 + steps: + # Step 1: Checkout the code + - name: Checkout Code + uses: actions/checkout@v4 + + # Step 2: Execute remote SSH commands using SSH key - name: Executing remote SSH commands using SSH key uses: appleboy/ssh-action@v1.0.3 with: diff --git a/test/todos.test.js b/test/todos.test.js index 071668a..f64c745 100644 --- a/test/todos.test.js +++ b/test/todos.test.js @@ -13,6 +13,7 @@ jest.mock('../db/db', () => { create: jest.fn(), findAll: jest.fn(), findByPk: jest.fn(), + update: jest.fn(), } } }; @@ -29,6 +30,13 @@ beforeAll(async () => { db.models.todo.findByPk.mockImplementation(id => { return Promise.resolve(mockTodos.find(todo => todo.id === id)); }); + db.models.todo.update.mockImplementation((values, options) => { + const todo = mockTodos.find(todo => todo.id === options.where.id); + if (todo) { + return Promise.resolve([1, [{ ...todo, ...values }]]); + } + return Promise.resolve([0, []]); + }); }); afterAll(async () => { @@ -37,12 +45,37 @@ afterAll(async () => { describe('Todos API', () => { + /////////////////////////////////////////////////////////////////// + //////////// GET Tests ///////////////////////////////////////////// + /////////////////////////////////////////////////////////////////// + test('GET /todos should return all todos', async () => { const res = await request(app).get('/todos'); expect(res.statusCode).toEqual(200); expect(res.body.length).toBe(2); // Should have 2 initial tasks }); + test('GET /todos should return an empty array if no todos exist', async () => { + db.models.todo.findAll.mockResolvedValueOnce([]); + const res = await request(app).get('/todos'); + expect(res.statusCode).toEqual(200); + expect(res.body.length).toBe(0); + }); + + test('GET /todos should return todos with the correct properties', async () => { + const res = await request(app).get('/todos'); + expect(res.statusCode).toEqual(200); + res.body.forEach(todo => { + expect(todo).toHaveProperty('id'); + expect(todo).toHaveProperty('name'); + expect(todo).toHaveProperty('done'); + }); + }); + + /////////////////////////////////////////////////////////////////// + //////////// POST Tests //////////////////////////////////////////// + /////////////////////////////////////////////////////////////////// + test('POST /todos should create a new todo', async () => { const newTodo = { id: 3, name: 'New Task', done: false }; db.models.todo.create.mockResolvedValue(newTodo); @@ -62,62 +95,88 @@ describe('Todos API', () => { expect(res.body.errors[0].msg).toBe('Invalid value'); }); + test('POST /todos should trim whitespace from name', async () => { + const newTodo = { id: 3, name: 'Trimmed Task', done: false }; + db.models.todo.create.mockResolvedValue(newTodo); + const res = await request(app) + .post('/todos') + .send({ name: ' Trimmed Task ' }); + expect(res.statusCode).toEqual(201); + expect(res.body.name).toBe('Trimmed Task'); + }); + + test('POST /todos should allow a todo with "done" status set to true initially', async () => { + const newTodo = { id: 4, name: 'New Task', done: true }; + db.models.todo.create.mockResolvedValue(newTodo); + const res = await request(app) + .post('/todos') + .send({ name: 'New Task', done: true }); + expect(res.statusCode).toEqual(201); + expect(res.body.done).toBe(true); + }); + + //////////////////////////////////////////////////////////////////////// + //////////// bad TODO naming /////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////// + + test('POST /todos should return 400 for a name exceeding max length', async () => { + const longName = 'A'.repeat(256); // 256 characters long + const res = await request(app).post('/todos').send({ name: longName }); + expect(res.statusCode).toEqual(400); + expect(res.body.errors[0].msg).toBe('Invalid value'); + }); + + test('POST /todos should return 400 if name is missing', async () => { + const res = await request(app).post('/todos').send({}); + expect(res.statusCode).toEqual(400); + expect(res.body.errors[0].msg).toBe('Invalid value'); + }); + + //////////////////////////////////////////////////////////////////////// + //////////// PUT Tests ///////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////// + /* test('PUT /todos/:id/done should mark a todo as done', async () => { const todo = { id: 1, name: 'Initial Task 1', done: false }; db.models.todo.findByPk.mockResolvedValueOnce(todo); - todo.done = true; - const res = await request(app) - .put('/todos/1/done'); + const res = await request(app).put('/todos/1/done'); expect(res.statusCode).toEqual(200); expect(res.body.done).toBe(true); }); */ - test('PUT /todos/:id/done should return 404 for a non-existent todo', async () => { + test('PUT /todos/:id/done should return 404 if todo is not found', async () => { db.models.todo.findByPk.mockResolvedValueOnce(null); - const res = await request(app) - .put('/todos/999/done'); + const res = await request(app).put('/todos/999/done'); expect(res.statusCode).toEqual(404); expect(res.text).toBe('Todo not found'); }); + //////////////////////////////////////////////////////////////////////// + //////////// DELETE Tests ////////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////// + /* test('DELETE /todos/:id/done should mark a todo as not done', async () => { - const todo = { id: 1, name: 'Initial Task 1', done: true }; + const todo = { id: 2, name: 'Initial Task 2', done: true }; db.models.todo.findByPk.mockResolvedValueOnce(todo); - todo.done = false; - const res = await request(app) - .delete('/todos/1/done'); + const res = await request(app).delete('/todos/2/done'); expect(res.statusCode).toEqual(200); expect(res.body.done).toBe(false); }); */ - test('PUT /todos/:id/done should return 404 for an invalid ID', async () => { - const res = await request(app).put('/todos/invalid/done'); - expect(res.statusCode).toEqual(404); - expect(res.text).toBe('Todo not found'); - }); - - test('DELETE /todos/:id/done should return 404 for an invalid ID', async () => { - const res = await request(app).delete('/todos/invalid/done'); + test('DELETE /todos/:id/done should return 404 if todo is not found', async () => { + db.models.todo.findByPk.mockResolvedValueOnce(null); + const res = await request(app).delete('/todos/999/done'); expect(res.statusCode).toEqual(404); expect(res.text).toBe('Todo not found'); }); - - test('POST /todos should return 400 for a name exceeding max length', async () => { - const longName = 'A'.repeat(256); // 256 characters long - const res = await request(app).post('/todos').send({ name: longName }); - expect(res.statusCode).toEqual(400); - expect(res.body.errors[0].msg).toBe('Invalid value'); - }); - - test('POST /todos should return 400 if name is missing', async () => { - const res = await request(app).post('/todos').send({}); - expect(res.statusCode).toEqual(400); - expect(res.body.errors[0].msg).toBe('Invalid value'); - }); + + //////////////////////////////////////////////////////////////////////// + //////////// DB connection ///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////// test('Database connection should be established', async () => { await expect(db.authenticate()).resolves.not.toThrow(); @@ -127,4 +186,49 @@ describe('Todos API', () => { expect(db.models.todo).toBeDefined(); }); + ///////////////////////////////////////////////////////////////////// + //////////// CORS tests ///////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////// + + test('should use CORS middleware with correct origin settings', async () => { + const res = await request(app) + .options('/todos') + .set('Origin', 'http://44.219.67.143') + .send(); + + expect(res.headers['access-control-allow-origin']).toBe('http://44.219.67.143'); + expect(res.headers['access-control-allow-credentials']).toBe('true'); + }); + + test('should not allow requests from unauthorized origins', async () => { + const res = await request(app) + .options('/todos') + .set('Origin', 'http://unauthorized-origin.com') + .send(); + + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + }); + + //////////////////////////////////////////////////////////////////////// + //////////// undefined routes ////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////// + + test('should return 404 for undefined routes', async () => { + const res = await request(app).get('/undefined-route'); + expect(res.statusCode).toEqual(404); + }); + + //////////////////////////////////////////////////////////////////////// + //////////// cookie parser ///////////////////////////////////////////// + //////////////////////////////////////////////////////////////////////// + + test('should parse cookies correctly', async () => { + const res = await request(app) + .get('/todos') + .set('Cookie', 'test_cookie=test_value'); + + expect(res.statusCode).toEqual(200); + expect(res.headers['set-cookie']).toBeUndefined(); // Assuming no new cookies are set in this route + }); + });