From ed04be49ccdf154b4255dca75bf4fdf773ea56c6 Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Sun, 15 Jun 2025 15:41:08 +0200 Subject: [PATCH 1/5] deps: add lodash@^4.17.21 --- package-lock.json | 1 + package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/package-lock.json b/package-lock.json index 5556a73..913deb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "express": "^5.1.0", "inquirer": "12.1.0", "knex": "3.1.0", + "lodash": "^4.17.21", "nock": "14.0.1", "octokit": "3.2.1", "pg": "8.13.1", diff --git a/package.json b/package.json index e2e5498..7bc07d9 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "express": "^5.1.0", "inquirer": "12.1.0", "knex": "3.1.0", + "lodash": "^4.17.21", "nock": "14.0.1", "octokit": "3.2.1", "pg": "8.13.1", From 33063be6e4b8f147ce3c4a7f8085dfc499953044 Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Sun, 15 Jun 2025 16:12:41 +0200 Subject: [PATCH 2/5] feat: add Swagger schema for `POST /api/v1/project` --- src/httpServer/swagger/api-v1.yml | 191 +++++++++++++++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/src/httpServer/swagger/api-v1.yml b/src/httpServer/swagger/api-v1.yml index 535b5bd..741ceb9 100644 --- a/src/httpServer/swagger/api-v1.yml +++ b/src/httpServer/swagger/api-v1.yml @@ -40,6 +40,72 @@ paths: - name - version + /api/v1/project: + post: + summary: Create a new project + description: Creates a new project with the provided details + operationId: createProject + tags: + - Projects + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + name: + type: string + pattern: '^[a-zA-Z0-9_-]+$' + example: express + required: + - name + responses: + '201': + description: Project created successfully + headers: + Location: + description: URL of the newly created project + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + '500': + description: Internal server error + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + error: + type: string + example: Internal server error + '409': + description: Project already exists + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + error: + type: string + example: Project already exists + '400': + description: Bad request, invalid input data + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + error: + type: string + example: Invalid project data /api/v1/generate-reports: post: summary: Generate static reports @@ -98,4 +164,127 @@ paths: - finishedAt components: - schemas: {} \ No newline at end of file + schemas: + Project: + type: object + additionalProperties: false + properties: + id: + type: integer + example: 1 + name: + type: string + example: invalid + created_at: + type: string + format: date-time + example: '2025-06-15T13:53:40.103Z' + updated_at: + type: string + format: date-time + example: '2025-06-15T13:53:40.103Z' + has_defineFunctionalRoles_policy: + type: boolean + nullable: true + example: null + has_orgToolingMFA_policy: + type: boolean + nullable: true + example: null + has_softwareArchitectureDocs_policy: + type: boolean + nullable: true + example: null + has_MFAImpersonationDefense_policy: + type: boolean + nullable: true + example: null + has_includeCVEInReleaseNotes_policy: + type: boolean + nullable: true + example: null + has_assignCVEForKnownVulns_policy: + type: boolean + nullable: true + example: null + has_incidentResponsePlan_policy: + type: boolean + nullable: true + example: null + has_regressionTestsForVulns_policy: + type: boolean + nullable: true + example: null + has_vulnResponse14Days_policy: + type: boolean + nullable: true + example: null + has_useCVDToolForVulns_policy: + type: boolean + nullable: true + example: null + has_securityMdMeetsOpenJSCVD_policy: + type: boolean + nullable: true + example: null + has_consistentBuildProcessDocs_policy: + type: boolean + nullable: true + example: null + has_machineReadableDependencies_policy: + type: boolean + nullable: true + example: null + has_identifyModifiedDependencies_policy: + type: boolean + nullable: true + example: null + has_ciAndCdPipelineAsCode_policy: + type: boolean + nullable: true + example: null + has_npmOrgMFA_policy: + type: boolean + nullable: true + example: null + has_npmPublicationMFA_policy: + type: boolean + nullable: true + example: null + has_upgradePathDocs_policy: + type: boolean + nullable: true + example: null + has_patchNonCriticalVulns90Days_policy: + type: boolean + nullable: true + example: null + has_patchCriticalVulns30Days_policy: + type: boolean + nullable: true + example: null + has_twoOrMoreOwnersForAccess_policy: + type: boolean + nullable: true + example: null + has_injectedSecretsAtRuntime_policy: + type: boolean + nullable: true + example: null + has_preventScriptInjection_policy: + type: boolean + nullable: true + example: null + has_resolveLinterWarnings_policy: + type: boolean + nullable: true + example: null + has_annualDependencyRefresh_policy: + type: boolean + nullable: true + example: null + required: + - id + - name + - created_at + - updated_at \ No newline at end of file From ff6b30631eb1b628c7010f93515472bef8d2b848 Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Sun, 15 Jun 2025 16:13:48 +0200 Subject: [PATCH 3/5] feat: add function `getProjectByName` to store --- src/store/index.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/store/index.js b/src/store/index.js index 549bc59..2927bd2 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -43,7 +43,13 @@ const addGithubOrganization = knex => async (organization) => { return knex('github_organizations').insert(organization).returning('*').then(results => results[0]) } +const getProjectByName = knex => (name) => { + debug(`Getting project by name (${name})...`) + return knex('projects').where({ name }).first() +} + const addProject = knex => async (project) => { + // @TODO: Check if the validation is needed after the CLI Migration const { name } = project const projectExists = await knex('projects').where({ name }).first() debug(`Checking if project (${name}) exists...`) @@ -241,7 +247,8 @@ const initializeStore = (knex) => { upsertProjectPolicies: upsertProjectPolicies(knex), upsertOwaspTop10Training: upsertOwaspTop10Training(knex), getAllOSSFResults: () => getAll('ossf_scorecard_results'), - getProjectById: (id) => getOne('projects', id) + getProjectById: (id) => getOne('projects', id), + getProjectByName: getProjectByName(knex) } } From e17a4433fcd455b50c1982ffcea103f65aa84d6f Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Sun, 15 Jun 2025 16:14:24 +0200 Subject: [PATCH 4/5] feat: add `POST /api/v1/project` endpoint --- __tests__/httpServer/apiV1.test.js | 73 +++++++++++++++++++++++++++++- src/httpServer/routers/apiV1.js | 33 ++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/__tests__/httpServer/apiV1.test.js b/__tests__/httpServer/apiV1.test.js index 51d7a97..829c571 100644 --- a/__tests__/httpServer/apiV1.test.js +++ b/__tests__/httpServer/apiV1.test.js @@ -1,10 +1,16 @@ const request = require('supertest') const { generateStaticReports } = require('../../src/reports') +const knexInit = require('knex') +const { getConfig } = require('../../src/config') +const { resetDatabase, initializeStore } = require('../../__utils__') const pkg = require('../../package.json') const serverModule = require('../../src/httpServer') +const { dbSettings } = getConfig('test') let server let serverStop let app +let knex +let getAllProjects // Mocks jest.mock('../../src/reports', () => ({ @@ -17,17 +23,26 @@ beforeAll(async () => { server = await serverInstance.start() serverStop = serverInstance.stop app = request(server) + knex = knexInit(dbSettings); + ({ + getAllProjects + } = initializeStore(knex)) }) afterAll(async () => { // Cleanup after all tests await serverStop?.() + await resetDatabase(knex) + await knex.destroy() }) -beforeEach(() => { +beforeEach(async () => { + await resetDatabase(knex) jest.clearAllMocks() }) +afterEach(jest.clearAllMocks) + describe('HTTP Server API V1', () => { describe('GET /api/v1/__health', () => { test('should return status ok', async () => { @@ -44,6 +59,62 @@ describe('HTTP Server API V1', () => { }) }) + describe('POST /api/v1/project', () => { + test('should create a new project', async () => { + // Initial state + let projects = await getAllProjects() + expect(projects.length).toBe(0) + // Request + const newProject = { name: 'eslint' } + const response = await app.post('/api/v1/project').send(newProject) + // Database changes + projects = await getAllProjects() + expect(projects.length).toBe(1) + expect(projects[0].name).toBe('eslint') + // Response details + expect(response.status).toBe(201) + expect(response.headers).toHaveProperty('location', `/api/v1/project/${projects[0].id}`) + expect(response.body).toHaveProperty('id') + expect(response.body).toHaveProperty('name', newProject.name) + expect(response.body).toHaveProperty('created_at') + expect(response.body).toHaveProperty('updated_at') + }) + test('should return 400 for invalid project name', async () => { + // Initial state + let projects = await getAllProjects() + expect(projects.length).toBe(0) + // Request + const invalidProject = { name: 'Invalid Name!' } + const response = await app.post('/api/v1/project').send(invalidProject) + // Database changes + projects = await getAllProjects() + expect(projects.length).toBe(0) + // Response details + expect(response.status).toBe(400) + expect(response.body).toStrictEqual({ errors: [{ errorCode: 'pattern.openapi.validation', message: 'must match pattern "^[a-zA-Z0-9_-]+$"', path: '/body/name' }], name: 'Bad Request', path: '/api/v1/project', status: 400 }) + }) + test('should return 409 if the project already exists', async () => { + // Initial state + let projects = await getAllProjects() + expect(projects.length).toBe(0) + // Create the project first + const existingProject = { name: 'eslint' } + await app.post('/api/v1/project').send(existingProject) + projects = await getAllProjects() + expect(projects.length).toBe(1) + // Request to create the same project again + const response = await app.post('/api/v1/project').send(existingProject) + // Database changes + projects = await getAllProjects() + expect(projects.length).toBe(1) // Still only one project + // Response details + expect(response.status).toBe(409) + expect(response.body).toStrictEqual({ error: 'Project already exists.' }) + }) + + test.todo('should return 500 for internal server error') + }) + describe('POST /api/v1/generate-reports', () => { test('should return status completed when report generation succeeds', async () => { generateStaticReports.mockResolvedValueOnce() diff --git a/src/httpServer/routers/apiV1.js b/src/httpServer/routers/apiV1.js index 30bf4a5..8a4e926 100644 --- a/src/httpServer/routers/apiV1.js +++ b/src/httpServer/routers/apiV1.js @@ -1,14 +1,47 @@ const { generateStaticReports } = require('../../reports') const pkg = require('../../../package.json') const { logger } = require('../../utils') +const { initializeStore } = require('../../store') +const _ = require('lodash') +const { isSlug } = require('validator') function createApiRouter (knex, express) { + const { addProject, getProjectByName } = initializeStore(knex) + const router = express.Router() router.get('/__health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), version: pkg.version, name: pkg.name }) }) + router.post('/project', async (req, res) => { + try { + // Validate request body + const { name } = req.body + const projectName = _.kebabCase(name) + + // Check data and database + if (!projectName || !isSlug(projectName)) { + return res.status(400).json({ error: 'Invalid project name. Must be a slug.' }) + } + const existingProject = await getProjectByName(projectName) + if (existingProject) { + return res.status(409).json({ error: 'Project already exists.' }) + } + + // Modify database + const project = await addProject({ name: projectName }) + + // Return response + return res.status(201) + .header('Location', `/api/v1/project/${project.id}`) + .json(project) + } catch (err) { + logger.error(err) + return res.status(500).json({ error: 'Internal server error' }) + } + }) + router.post('/generate-reports', async (req, res) => { const startTs = new Date().toISOString() try { From 81e5cb1f409e5d6664a3432bf0ca41a47d93ec61 Mon Sep 17 00:00:00 2001 From: Ulises Gascon Date: Sun, 15 Jun 2025 16:46:07 +0200 Subject: [PATCH 5/5] feat: normalize error responses in v1 API --- __tests__/httpServer/apiV1.test.js | 4 +- src/httpServer/routers/apiV1.js | 6 +-- src/httpServer/swagger/api-v1.yml | 73 ++++++++++++++++++++---------- 3 files changed, 55 insertions(+), 28 deletions(-) diff --git a/__tests__/httpServer/apiV1.test.js b/__tests__/httpServer/apiV1.test.js index 829c571..10a77f1 100644 --- a/__tests__/httpServer/apiV1.test.js +++ b/__tests__/httpServer/apiV1.test.js @@ -60,7 +60,7 @@ describe('HTTP Server API V1', () => { }) describe('POST /api/v1/project', () => { - test('should create a new project', async () => { + test('should return 200 and create a new project', async () => { // Initial state let projects = await getAllProjects() expect(projects.length).toBe(0) @@ -109,7 +109,7 @@ describe('HTTP Server API V1', () => { expect(projects.length).toBe(1) // Still only one project // Response details expect(response.status).toBe(409) - expect(response.body).toStrictEqual({ error: 'Project already exists.' }) + expect(response.body).toStrictEqual({ errors: [{ message: 'Project already exists.' }] }) }) test.todo('should return 500 for internal server error') diff --git a/src/httpServer/routers/apiV1.js b/src/httpServer/routers/apiV1.js index 8a4e926..470e198 100644 --- a/src/httpServer/routers/apiV1.js +++ b/src/httpServer/routers/apiV1.js @@ -22,11 +22,11 @@ function createApiRouter (knex, express) { // Check data and database if (!projectName || !isSlug(projectName)) { - return res.status(400).json({ error: 'Invalid project name. Must be a slug.' }) + return res.status(400).json({ errors: [{ message: 'Invalid project name. Must be a slug.' }] }) } const existingProject = await getProjectByName(projectName) if (existingProject) { - return res.status(409).json({ error: 'Project already exists.' }) + return res.status(409).json({ errors: [{ message: 'Project already exists.' }] }) } // Modify database @@ -38,7 +38,7 @@ function createApiRouter (knex, express) { .json(project) } catch (err) { logger.error(err) - return res.status(500).json({ error: 'Internal server error' }) + return res.status(500).json({ errors: [{ message: 'Internal server error' }] }) } }) diff --git a/src/httpServer/swagger/api-v1.yml b/src/httpServer/swagger/api-v1.yml index 741ceb9..d12c2f8 100644 --- a/src/httpServer/swagger/api-v1.yml +++ b/src/httpServer/swagger/api-v1.yml @@ -73,39 +73,24 @@ paths: application/json: schema: $ref: '#/components/schemas/Project' - '500': - description: Internal server error + '409': + description: Project already exists content: application/json: schema: - type: object - additionalProperties: false - properties: - error: - type: string - example: Internal server error - '409': - description: Project already exists + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error content: application/json: schema: - type: object - additionalProperties: false - properties: - error: - type: string - example: Project already exists + $ref: '#/components/schemas/ErrorResponse' '400': description: Bad request, invalid input data content: application/json: schema: - type: object - additionalProperties: false - properties: - error: - type: string - example: Invalid project data + $ref: '#/components/schemas/ErrorResponse' /api/v1/generate-reports: post: summary: Generate static reports @@ -287,4 +272,46 @@ components: - id - name - created_at - - updated_at \ No newline at end of file + - updated_at + ErrorObject: + type: object + properties: + errorCode: + type: string + description: Optional machine-readable error code + example: some.error.code + message: + type: string + description: Human-readable error message + example: Something went wrong + path: + type: string + description: Optional path to the field or resource + example: /body/field + required: + - message + additionalProperties: true + + ErrorResponse: + type: object + properties: + errors: + type: array + items: + $ref: '#/components/schemas/ErrorObject' + minItems: 1 + name: + type: string + description: Optional error name + example: Error + path: + type: string + description: Optional API path + example: /api/v1/resource + status: + type: integer + description: Optional HTTP status code + example: 400 + required: + - errors + additionalProperties: true \ No newline at end of file