diff --git a/__tests__/httpServer/apiV1.test.js b/__tests__/httpServer/apiV1.test.js index 51d7a97..10a77f1 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 return 200 and 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({ errors: [{ message: '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/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", diff --git a/src/httpServer/routers/apiV1.js b/src/httpServer/routers/apiV1.js index 30bf4a5..470e198 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({ errors: [{ message: 'Invalid project name. Must be a slug.' }] }) + } + const existingProject = await getProjectByName(projectName) + if (existingProject) { + return res.status(409).json({ errors: [{ message: '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({ errors: [{ message: 'Internal server error' }] }) + } + }) + router.post('/generate-reports', async (req, res) => { const startTs = new Date().toISOString() try { diff --git a/src/httpServer/swagger/api-v1.yml b/src/httpServer/swagger/api-v1.yml index 535b5bd..d12c2f8 100644 --- a/src/httpServer/swagger/api-v1.yml +++ b/src/httpServer/swagger/api-v1.yml @@ -40,6 +40,57 @@ 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' + '409': + description: Project already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '400': + description: Bad request, invalid input data + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /api/v1/generate-reports: post: summary: Generate static reports @@ -98,4 +149,169 @@ 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 + 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 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) } }