diff --git a/__fixtures__/index.js b/__fixtures__/index.js index 768a3ef..810062e 100644 --- a/__fixtures__/index.js +++ b/__fixtures__/index.js @@ -920,10 +920,121 @@ const sampleBulkImportFileContent = [{ is_subscribed: true }] +// Sample project data for testing validateProjectData +const sampleProjectData = { + project: { + id: 1, + name: 'Test Project', + description: 'A test project', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + }, + checks: [ + { + id: 1, + checklist_id: 1, + description: 'Test check', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + results: [ + { + id: 1, + project_id: 1, + name: 'Test result', + score: 8.5, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + tasks: [ + { + id: 1, + project_id: 1, + description: 'Test task', + implementation_status: 'pending', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + alerts: [ + { + id: 1, + project_id: 1, + title: 'Test alert', + description: 'This is a test alert', + severity: 'medium', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + githubOrgs: [ + { + id: 1, + login: 'test-org', + name: 'Test Organization', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + githubRepos: [ + { + id: 1, + name: 'test-repo', + full_name: 'test-org/test-repo', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + ossfScorecardResults: [ + { + id: 1, + repository_id: 1, + score: 8.5, + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ] +} + +// Sample index data for testing validateIndexData +const sampleIndexData = { + projects: [ + { + id: 1, + name: 'Test Project', + description: 'A test project', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + checklists: [ + { + id: 1, + name: 'Test Checklist', + type: 'security', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + checks: [ + { + id: 1, + checklist_id: 1, + description: 'Test check', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ] +} + module.exports = { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository, sampleOSSFScorecardResult, - sampleBulkImportFileContent + sampleBulkImportFileContent, + sampleProjectData, + sampleIndexData } diff --git a/__tests__/httpServer.test.js b/__tests__/httpServer/apiV1.test.js similarity index 93% rename from __tests__/httpServer.test.js rename to __tests__/httpServer/apiV1.test.js index 652058f..3cbeb55 100644 --- a/__tests__/httpServer.test.js +++ b/__tests__/httpServer/apiV1.test.js @@ -1,12 +1,12 @@ const request = require('supertest') -const { generateStaticReports } = require('../src/reports') -const serverModule = require('../src/httpServer') +const { generateStaticReports } = require('../../src/reports') +const serverModule = require('../../src/httpServer') let server let serverStop let app // Mocks -jest.mock('../src/reports', () => ({ +jest.mock('../../src/reports', () => ({ generateStaticReports: jest.fn() })) @@ -27,7 +27,7 @@ beforeEach(() => { jest.clearAllMocks() }) -describe('HTTP Server API', () => { +describe('HTTP Server API V1', () => { describe('GET /api/v1/__health', () => { test('should return status ok', async () => { const response = await app.get('/api/v1/__health') diff --git a/__tests__/httpServer/website.test.js b/__tests__/httpServer/website.test.js new file mode 100644 index 0000000..fd8a401 --- /dev/null +++ b/__tests__/httpServer/website.test.js @@ -0,0 +1,69 @@ +const request = require('supertest') +const knexInit = require('knex') +const serverModule = require('../../src/httpServer') +const { getConfig } = require('../../src/config') +const { resetDatabase, initializeStore } = require('../../__utils__') + +const { dbSettings } = getConfig('test') + +let server +let serverStop +let app +let knex +let addProject +let testProjectId + +beforeAll(async () => { + // Initialize database + knex = knexInit(dbSettings); + ({ addProject } = initializeStore(knex)) + + // Reset database and add test project + await resetDatabase(knex) + const testProject = await addProject({ name: 'Test Project' }) + testProjectId = testProject.id + + // Initialize server asynchronously + const serverInstance = serverModule() + server = await serverInstance.start() + serverStop = serverInstance.stop + app = request(server) +}) + +afterAll(async () => { + // Cleanup after all tests + await serverStop?.() + await resetDatabase(knex) + await knex.destroy() +}) + +describe('HTTP Server WEBSITE', () => { + describe('GET /', () => { + it('should render index page', async () => { + const response = await app.get('/') + expect(response.status).toBe(200) + expect(response.header['content-type']).toMatch(/text\/html/) + }) + }) + + describe('GET /projects/:id', () => { + it('should render project page for valid project ID', async () => { + const response = await app.get(`/projects/${testProjectId}`) + expect(response.status).toBe(200) + expect(response.header['content-type']).toMatch(/text\/html/) + }) + + it('should render notFound page for invalid project ID format', async () => { + const response = await app.get('/projects/invalid') + expect(response.status).toBe(404) + expect(response.header['content-type']).toMatch(/text\/html/) + }) + + it('should render notFound page for non-existent project ID', async () => { + const nonExistentId = 9999 + const response = await app.get(`/projects/${nonExistentId}`) + expect(response.status).toBe(404) + expect(response.header['content-type']).toMatch(/text\/html/) + }) + }) +}) diff --git a/__tests__/reports.test.js b/__tests__/reports.test.js new file mode 100644 index 0000000..6746fcf --- /dev/null +++ b/__tests__/reports.test.js @@ -0,0 +1,47 @@ +const { internalLinkBuilder } = require('../src/reports') + +describe('internalLinkBuilder', () => { + describe('static mode', () => { + const staticLinkBuilder = internalLinkBuilder('static') + + test('should handle empty reference', () => { + expect(staticLinkBuilder('', null)).toBe('index.html') + }) + + test('should remove leading slash in static mode', () => { + expect(staticLinkBuilder('/assets/favicon.ico', null)).toBe('assets/favicon.ico') + }) + + test('should handle project references in static mode', () => { + const project = { name: 'testproject', id: 123 } + expect(staticLinkBuilder('', project)).toBe('testproject.html') + }) + + test('should prioritize project reference over path', () => { + const project = { name: 'testproject', id: 123 } + expect(staticLinkBuilder('/some/path', project)).toBe('testproject.html') + }) + }) + + describe('server mode', () => { + const serverLinkBuilder = internalLinkBuilder('server') + + test('should handle empty reference', () => { + expect(serverLinkBuilder('', null)).toBe('index.html') + }) + + test('should preserve leading slash in server mode', () => { + expect(serverLinkBuilder('/assets/favicon.ico', null)).toBe('/assets/favicon.ico') + }) + + test('should handle project references in server mode', () => { + const project = { name: 'testproject', id: 123 } + expect(serverLinkBuilder('', project)).toBe('/projects/123') + }) + + test('should prioritize project reference over path', () => { + const project = { name: 'testproject', id: 123 } + expect(serverLinkBuilder('/some/path', project)).toBe('/projects/123') + }) + }) +}) diff --git a/__tests__/schemas.test.js b/__tests__/schemas.test.js index b1e52d5..1ab2033 100644 --- a/__tests__/schemas.test.js +++ b/__tests__/schemas.test.js @@ -1,5 +1,21 @@ -const { sampleGithubOrg, sampleGithubListOrgRepos, sampleGithubRepository, sampleOSSFScorecardResult, sampleBulkImportFileContent } = require('../__fixtures__') -const { validateGithubOrg, validateGithubListOrgRepos, validateGithubRepository, validateOSSFResult, validateBulkImport } = require('../src/schemas') +const { + sampleGithubOrg, + sampleGithubListOrgRepos, + sampleGithubRepository, + sampleOSSFScorecardResult, + sampleBulkImportFileContent, + sampleProjectData, + sampleIndexData +} = require('../__fixtures__') +const { + validateGithubOrg, + validateGithubListOrgRepos, + validateGithubRepository, + validateOSSFResult, + validateBulkImport, + validateProjectData, + validateIndexData +} = require('../src/schemas') describe('schemas', () => { describe('validateGithubOrg', () => { @@ -81,4 +97,156 @@ describe('schemas', () => { expect(() => validateBulkImport(invalidData)).toThrow() }) }) + + describe('validateProjectData', () => { + test('Should not throw an error with valid data', () => { + expect(() => validateProjectData(sampleProjectData)).not.toThrow() + }) + + test('Should not throw an error with additional data', () => { + // Create a valid project data object with additional properties + const validProjectDataWithAdditionalProps = { + ...sampleProjectData, + project: { + ...sampleProjectData.project, + additionalProperty: 'value' + }, + additionalProperty: 'value' + } + + expect(() => validateProjectData(validProjectDataWithAdditionalProps)).not.toThrow() + }) + + test('Should throw an error with invalid data', () => { + // Create an invalid project data object (missing required field) + const invalidProjectData = { + project: { + // Missing id field + name: 'Test Project' + }, + checks: [], + results: [], + tasks: [], + alerts: [], + githubOrgs: [], + githubRepos: [], + ossfScorecardResults: [] + } + + expect(() => validateProjectData(invalidProjectData)).toThrow('Error when validating project data') + }) + + test('Should throw an error with invalid nested data', () => { + // Create an invalid project data object (invalid field type) + const invalidProjectData = { + project: { + id: 1, + name: 'Test Project' + }, + checks: [], + results: [ + { + id: 1, + project_id: 1, + name: 'Test result', + score: 'not-a-number', // Should be a number + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + tasks: [], + alerts: [], + githubOrgs: [], + githubRepos: [], + ossfScorecardResults: [] + } + + expect(() => validateProjectData(invalidProjectData)).toThrow('Error when validating project data') + }) + + test('Should throw an error when required fields are missing', () => { + // Missing required fields in the data object + const missingFieldsData = { + // Missing project field + checks: [], + results: [], + tasks: [], + alerts: [], + githubOrgs: [], + githubRepos: [], + ossfScorecardResults: [] + } + + expect(() => validateProjectData(missingFieldsData)).toThrow('Error when validating project data') + }) + }) + + describe('validateIndexData', () => { + test('Should not throw an error with valid data', () => { + expect(() => validateIndexData(sampleIndexData)).not.toThrow() + }) + + test('Should not throw an error with additional data', () => { + // Create a valid index data object with additional properties + const validIndexDataWithAdditionalProps = { + ...sampleIndexData, + projects: [ + { + ...sampleIndexData.projects[0], + additionalProperty: 'value' + } + ], + additionalProperty: 'value' + } + + expect(() => validateIndexData(validIndexDataWithAdditionalProps)).not.toThrow() + }) + + test('Should throw an error with invalid data', () => { + // Create an invalid index data object (missing required field) + const invalidIndexData = { + projects: [ + { + id: 1, + // Missing name field + description: 'A test project' + } + ], + checklists: [], + checks: [] + } + + expect(() => validateIndexData(invalidIndexData)).toThrow('Error when validating index data') + }) + + test('Should throw an error with invalid nested data', () => { + // Create an invalid index data object (invalid field type) + const invalidIndexData = { + projects: [], + checklists: [ + { + id: '1', // Should be a number + name: 'Test Checklist', + type: 'security', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-02T00:00:00Z' + } + ], + checks: [] + } + + expect(() => validateIndexData(invalidIndexData)).toThrow('Error when validating index data') + }) + + test('Should throw an error when required fields are missing', () => { + // Missing required fields in the data object + const missingFieldsData = { + // Missing projects field + checklists: [] + // Missing checks field + } + + expect(() => validateIndexData(missingFieldsData)).toThrow('Error when validating index data') + }) + }) }) diff --git a/package-lock.json b/package-lock.json index 5138eb7..6687b02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "pg": "8.13.1", "pino": "9.5.0", "pino-pretty": "13.0.0", - "serve-index": "1.9.1", "serve-static": "1.16.2", "validator": "13.12.0" }, @@ -2141,19 +2140,6 @@ "dev": true, "license": "ISC" }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -2620,12 +2606,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "license": "MIT" - }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -6975,6 +6955,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -6984,6 +6965,7 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -7045,15 +7027,6 @@ "dev": true, "license": "MIT" }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/nock": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.1.tgz", @@ -8443,84 +8416,6 @@ "node": ">= 0.8" } }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "license": "ISC" - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "license": "ISC" - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/serve-static": { "version": "1.16.2", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", diff --git a/package.json b/package.json index 971aa03..e498448 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,6 @@ "pg": "8.13.1", "pino": "9.5.0", "pino-pretty": "13.0.0", - "serve-index": "1.9.1", "serve-static": "1.16.2", "validator": "13.12.0" }, diff --git a/server.js b/server.js index e74a82a..b908de2 100644 --- a/server.js +++ b/server.js @@ -13,11 +13,26 @@ const server = require('./src/httpServer'); // Handle graceful shutdown const shutdown = async (signal) => { console.log(`Received ${signal}, shutting down gracefully...`) + if (!serverInstance) { + console.log('No server instance running, exiting') + process.exit(0) + return + } + + // Set a hard timeout to force exit if graceful shutdown takes too long + const forceExitTimeout = setTimeout(() => { + console.error('Shutdown timed out after 10 seconds, forcing exit') + process.exit(1) + }, 10000) + try { await serverInstance.stop() + clearTimeout(forceExitTimeout) console.log('Server stopped successfully') - process.exit(0) + // Small delay to ensure logs are flushed + setTimeout(() => process.exit(0), 100) } catch (error) { + clearTimeout(forceExitTimeout) console.error('Error during shutdown:', error) process.exit(1) } diff --git a/src/httpServer/index.js b/src/httpServer/index.js index 3472305..765a7a1 100644 --- a/src/httpServer/index.js +++ b/src/httpServer/index.js @@ -1,30 +1,36 @@ const express = require('express') const http = require('http') const serveStatic = require('serve-static') -const serveIndex = require('serve-index') const { join } = require('path') const { getConfig } = require('../config') const { logger, checkDatabaseConnection } = require('../utils') -const { createApiRouter } = require('./apiV1') - -const publicPath = join(process.cwd(), 'output') +const { createApiRouter } = require('./routers/apiV1') +const { createWebRouter } = require('./routers/website') const { staticServer, dbSettings } = getConfig() const knex = require('knex')(dbSettings) // Create Express app const app = express() +const basePath = join(__dirname, '..', 'reports') +const publicPath = join(basePath, 'assets') +const templatePath = join(basePath, 'templates') + +// Middleware +app.set('view engine', 'ejs') +app.set('views', templatePath) // API Routes app.use('/api/v1', createApiRouter(knex, express)) -// Static file serving -app.use(serveStatic(publicPath, { - index: false, - dotfiles: 'deny' -})) +// Web Routes +app.use('/', createWebRouter(knex, express)) // Directory listing for static files -app.use(serveIndex(publicPath, { icons: true })) +app.use('/assets', serveStatic(publicPath, { + index: false, + dotfiles: 'deny', + icons: true +})) // Error handling middleware app.use((err, req, res, next) => { @@ -38,6 +44,9 @@ app.use((err, req, res, next) => { // Create HTTP server const server = http.createServer(app) +// Track all active connections +const connections = new Set() + module.exports = () => ({ start: async () => { const isDbConnected = await checkDatabaseConnection(knex) @@ -54,19 +63,66 @@ module.exports = () => ({ resolve() }) }) + + // Track connections + server.on('connection', connection => { + connections.add(connection) + connection.on('close', () => { + connections.delete(connection) + }) + }) return server }, stop: async () => { - await knex.destroy() - await new Promise((resolve, reject) => { - server.close(err => { - if (err) { - logger.error('Failed to stop server', { err }) - return reject(err) + logger.info('Starting server shutdown sequence') + try { + // Check if server is listening before attempting to close + if (!server.listening) { + logger.info('Server was not listening, skipping server.close()') + } else { + logger.info('Closing HTTP server - stop accepting new connections') + // Terminate all existing connections if needed + if (connections.size > 0) { + logger.info(`${connections.size} active connections will be allowed to complete`) } - logger.info('Server stopped') - resolve() - }) - }) + + // First, stop accepting new connections and wait for existing ones to complete + await new Promise((resolve, reject) => { + // Add a timeout to prevent hanging indefinitely + const timeout = setTimeout(() => { + logger.warn('Server close timed out after 5 seconds, forcing active connections to close') + // Force close any remaining connections + if (connections.size > 0) { + logger.info(`Forcibly closing ${connections.size} remaining connections`) + for (const connection of connections) { + connection.destroy() + } + connections.clear() + } + resolve() + }, 5000) + + server.close(err => { + clearTimeout(timeout) + if (err) { + logger.error('Failed to stop server', { err }) + return reject(err) + } + logger.info('HTTP server closed successfully') + resolve() + }) + }) + } + + // Only after the server is closed, close database connections + logger.info('Closing database connections') + await knex.destroy() + logger.info('Database connections closed') + + logger.info('Server shutdown complete') + } catch (error) { + logger.error('Error during server shutdown', { error }) + throw error + } } }) diff --git a/src/httpServer/apiV1.js b/src/httpServer/routers/apiV1.js similarity index 88% rename from src/httpServer/apiV1.js rename to src/httpServer/routers/apiV1.js index cfdfeaa..0894c0d 100644 --- a/src/httpServer/apiV1.js +++ b/src/httpServer/routers/apiV1.js @@ -1,5 +1,5 @@ -const { generateStaticReports } = require('../reports') -const { logger } = require('../utils') +const { generateStaticReports } = require('../../reports') +const { logger } = require('../../utils') function createApiRouter (knex, express) { const router = express.Router() diff --git a/src/httpServer/routers/website.js b/src/httpServer/routers/website.js new file mode 100644 index 0000000..625ed64 --- /dev/null +++ b/src/httpServer/routers/website.js @@ -0,0 +1,45 @@ +const { logger } = require('../../utils') +const { collectIndexData, collectProjectData } = require('../../reports') +const { initializeStore } = require('../../store') + +const indexTemplate = 'index' +const projectTemplate = 'project' +const notFoundTemplate = 'notFound' + +function createWebRouter (knex, express) { + const router = express.Router() + const { getProjectById } = initializeStore(knex) + + router.get('/', async (req, res) => { + try { + const data = await collectIndexData(knex) + res.render(indexTemplate, data) + } catch (error) { + logger.error(error) + res.status(500).send('Error rendering index page') + } + }) + + router.get('/projects/:id', async (req, res) => { + const projectId = parseInt(req.params.id) + if (isNaN(projectId)) { + logger.error(`Invalid project ID: ${req.params.id}`) + return res.status(404).render(notFoundTemplate) + } + try { + const project = await getProjectById(projectId) + if (!project) { + logger.error(`Project not found: ${projectId}`) + return res.status(404).render(notFoundTemplate) + } + const data = await collectProjectData(knex, projectId) + res.render(projectTemplate, data) + } catch (error) { + logger.error(error) + res.status(500).send('Error rendering project page') + } + }) + return router +} + +module.exports = { createWebRouter } diff --git a/src/reports/assets/logo.png b/src/reports/assets/logo.png new file mode 100644 index 0000000..29a82a5 Binary files /dev/null and b/src/reports/assets/logo.png differ diff --git a/src/reports/index.js b/src/reports/index.js index d233521..6d71099 100644 --- a/src/reports/index.js +++ b/src/reports/index.js @@ -3,9 +3,10 @@ const ejs = require('ejs') const { mkdir, readdir, copyFile, readFile, writeFile, rm } = require('node:fs').promises const { join } = require('path') const { initializeStore } = require('../store') +const { validateProjectData, validateIndexData } = require('../schemas') -const indexTemplatePath = join(process.cwd(), 'src', 'reports', 'templates', 'index.html.ejs') -const projectTemplatePath = join(process.cwd(), 'src', 'reports', 'templates', 'project.html.ejs') +const indexTemplatePath = join(process.cwd(), 'src', 'reports', 'templates', 'index.ejs') +const projectTemplatePath = join(process.cwd(), 'src', 'reports', 'templates', 'project.ejs') const assetsFolder = join(process.cwd(), 'src', 'reports', 'assets') const destinationFolder = join(process.cwd(), 'output') const copyFolder = async (from, to) => { @@ -37,6 +38,90 @@ const copyFolder = async (from, to) => { } } +const collectIndexData = async (knex) => { + const { getAllProjects, getAllChecklists, getAllComplianceChecks } = initializeStore(knex) + try { + const [projects, checklists, checks] = await Promise.all([ + getAllProjects(), + getAllChecklists(), + getAllComplianceChecks() + ]) + const getLink = internalLinkBuilder('server') + + // Create the data object + const data = { projects, checklists, checks, getLink } + + // Validate the data against the schema + validateIndexData(data) + + return data + } catch (error) { + logger.error(`Error collecting index data: ${error.message}`) + throw error + } +} + +// @TODO: use new store functions to collect project data individually more accurately and avoid loops +const collectProjectData = async (knex, projectId) => { + const { getAllComplianceChecks, getProjectById, getAllGithubRepositories, getAllOSSFResults, getAllAlerts, getAllResults, getAllTasks, getAllGithubOrganizationsByProjectsId } = initializeStore(knex) + try { + const project = await getProjectById(projectId) + if (!project) { + throw new Error(`Project not found: ${projectId}`) + } + const [checks, ossfScorecardResults, alerts, results, tasks, githubRepos, githubOrgs] = await Promise.all([ + getAllComplianceChecks(), + getAllOSSFResults(), + getAllAlerts(), + getAllResults(), + getAllTasks(), + getAllGithubRepositories(), + getAllGithubOrganizationsByProjectsId([projectId]) + ]) + + // Process the data + const githubOrgsIds = githubOrgs.map(org => org.id) + const githubReposInScope = githubRepos.filter(repo => githubOrgsIds.includes(repo.github_organization_id)) + const githubReposInScopeIds = githubReposInScope.map(repo => repo.id) + const getLink = internalLinkBuilder('server') + + // Create the data object + const data = { + project, + checks, + alerts: alerts.filter(alert => alert.project_id === project.id), + results: results.filter(result => result.project_id === project.id), + tasks: tasks.filter(task => task.project_id === project.id), + githubOrgs, + githubRepos: githubReposInScope, + ossfScorecardResults: ossfScorecardResults.filter(ossfResult => githubReposInScopeIds.includes(ossfResult.github_repository_id)), + getLink + } + + // Validate the data against the schema + validateProjectData(data) + + return data + } catch (error) { + logger.error(`Error collecting project data: ${error.message}`) + throw error + } +} + +const internalLinkBuilder = (mode = 'static') => (ref, project) => { + let finalRef = ref + // remove leading slash + if (mode === 'static') { + finalRef = finalRef.replace(/^\//, '') + } + // project specific paths + if (project) { + finalRef = mode === 'static' ? `${project.name}.html` : `/projects/${project.id}` + } + + return finalRef.length > 0 ? finalRef : 'index.html' +} + const generateStaticReports = async (knex, options = { clearPreviousReports: false }) => { const { clearPreviousReports } = options if (clearPreviousReports) { @@ -67,19 +152,24 @@ const generateStaticReports = async (knex, options = { clearPreviousReports: fal getAllGithubRepositories() ]) - // @TODO: Read the files in parallel - const indexTemplate = await readFile(indexTemplatePath, 'utf8') - const projectTemplate = await readFile(projectTemplatePath, 'utf8') - await mkdir(join(destinationFolder, 'projects'), { recursive: true }) + const [indexTemplate, projectTemplate] = await Promise.all([ + readFile(indexTemplatePath, 'utf8'), + readFile(projectTemplatePath, 'utf8') + ]) + await mkdir(destinationFolder, { recursive: true }) // Collecting data from the database const indexData = { projects, checklists, - checks + checks, + getLink: internalLinkBuilder('static') } - const projectsData = {} + // Validate index data + validateIndexData(indexData) + + const projectsData = { } for (const project of projects) { const githubOrgs = await getAllGithubOrganizationsByProjectsId([project.id]) @@ -87,7 +177,7 @@ const generateStaticReports = async (knex, options = { clearPreviousReports: fal const githubReposInScope = githubRepos.filter(repo => githubOrgsIds.includes(repo.github_organization_id)) const githubReposInScopeIds = githubReposInScope.map(repo => repo.id) - projectsData[project.name] = { + const projectData = { project, checks, alerts: alerts.filter(alert => alert.project_id === project.id), @@ -95,26 +185,41 @@ const generateStaticReports = async (knex, options = { clearPreviousReports: fal tasks: tasks.filter(task => task.project_id === project.id), githubOrgs, githubRepos: githubReposInScope, - ossfScorecardResults: ossfScorecardResults.filter(ossfResult => githubReposInScopeIds.includes(ossfResult.github_repository_id)) + ossfScorecardResults: ossfScorecardResults.filter(ossfResult => githubReposInScopeIds.includes(ossfResult.github_repository_id)), + getLink: internalLinkBuilder('static') } + // Validate each project's data + validateProjectData(projectData) + + projectsData[project.name] = projectData + // Populate the project HTML template - const projectHtml = ejs.render(projectTemplate, projectsData[project.name]) - const projectFilename = join(destinationFolder, 'projects', `${project.name}.html`) - await writeFile(projectFilename, projectHtml) + const projectHtml = ejs.render(projectTemplate, projectsData[project.name], { + filename: projectTemplatePath, + views: [join(process.cwd(), 'src', 'reports', 'templates')] + }) + // @TODO: Prevent overwriting (edge case) at creation level + if (project.name !== 'index') { + const safeName = encodeURIComponent(project.name) + const projectFilename = join(destinationFolder, `${safeName}.html`) + await writeFile(projectFilename, projectHtml) + } } - // @TODO: Validate against JSON Schemas - - // @TODO: Save the files in parallel - await writeFile('output/index_data.json', JSON.stringify(indexData, null, 2)) - await writeFile('output/projects/projects_data.json', JSON.stringify(projectsData, null, 2)) + await Promise.all([ + writeFile('output/index_data.json', JSON.stringify(indexData, null, 2)), + writeFile('output/projects_data.json', JSON.stringify(projectsData, null, 2)) + ]) // copy assets folder await copyFolder(assetsFolder, join(destinationFolder, 'assets')) // Populate the index HTML template - const indexHtml = ejs.render(indexTemplate, indexData) + const indexHtml = ejs.render(indexTemplate, indexData, { + filename: indexTemplatePath, + views: [join(process.cwd(), 'src', 'reports', 'templates')] + }) // Save the index HTML file await writeFile('output/index.html', indexHtml) @@ -122,5 +227,8 @@ const generateStaticReports = async (knex, options = { clearPreviousReports: fal } module.exports = { - generateStaticReports + generateStaticReports, + collectIndexData, + collectProjectData, + internalLinkBuilder } diff --git a/src/reports/templates/index.html.ejs b/src/reports/templates/index.ejs similarity index 85% rename from src/reports/templates/index.html.ejs rename to src/reports/templates/index.ejs index ce1d3aa..36015ba 100644 --- a/src/reports/templates/index.html.ejs +++ b/src/reports/templates/index.ejs @@ -1,24 +1,16 @@ +<% function resolveLink(path, data) { return typeof getLink !== 'function' ? path : getLink(path, data); } %> - - - VisionBoard Reports - - + <%- include('partials/head') %> - - -
-
-

VisionBoard Reports

-
-
+ + <%- include('partials/navigation') %> -
-
+
+

Welcome!

In this dashboard, you can find the status of all the projects that you registered. Every project listed includes additional reports with its own dashboard, tasks, and alerts. @@ -29,7 +21,7 @@

+
+ <%- include('partials/footer') %> +
\ No newline at end of file diff --git a/src/reports/templates/notFound.ejs b/src/reports/templates/notFound.ejs new file mode 100644 index 0000000..2f5387a --- /dev/null +++ b/src/reports/templates/notFound.ejs @@ -0,0 +1,23 @@ + + + + + <%- include('partials/head') %> + + + + <%- include('partials/navigation') %> + +
+
+

Not Found!

+

The page you are looking for does not exist.

+ Return to the home page +
+
+
+ <%- include('partials/footer') %> +
+ + + \ No newline at end of file diff --git a/src/reports/templates/partials/footer.ejs b/src/reports/templates/partials/footer.ejs new file mode 100644 index 0000000..80e6b5a --- /dev/null +++ b/src/reports/templates/partials/footer.ejs @@ -0,0 +1,5 @@ +
+
+

VisionBoard. MIT License.

+
+
diff --git a/src/reports/templates/partials/head.ejs b/src/reports/templates/partials/head.ejs new file mode 100644 index 0000000..22a431c --- /dev/null +++ b/src/reports/templates/partials/head.ejs @@ -0,0 +1,7 @@ + + +VisionBoard Reports +<% function resolveLink(path) { return typeof getLink !== 'function' ? path : getLink(path); } %> + + + \ No newline at end of file diff --git a/src/reports/templates/partials/navigation.ejs b/src/reports/templates/partials/navigation.ejs new file mode 100644 index 0000000..61caa8f --- /dev/null +++ b/src/reports/templates/partials/navigation.ejs @@ -0,0 +1,11 @@ + +<% function resolveLink(path) { return typeof getLink !== 'function' ? path : getLink(path); } %> + + \ No newline at end of file diff --git a/src/reports/templates/project.html.ejs b/src/reports/templates/project.ejs similarity index 94% rename from src/reports/templates/project.html.ejs rename to src/reports/templates/project.ejs index e3cb363..5a89318 100644 --- a/src/reports/templates/project.html.ejs +++ b/src/reports/templates/project.ejs @@ -2,22 +2,13 @@ - - - VisionBoard Reports - - + <%- include('partials/head') %> - - -
-
-

VisionBoard Reports

-
-
-
-
+ + <%- include('partials/navigation') %> +
+

<%= project.name %> Report

@@ -245,5 +236,8 @@ <% } %>
+
+ <%- include('partials/footer') %> +
\ No newline at end of file diff --git a/src/schemas/index.js b/src/schemas/index.js index 5bede21..252fc46 100644 --- a/src/schemas/index.js +++ b/src/schemas/index.js @@ -5,8 +5,12 @@ const githubListOrgReposSchema = require('./githubListOrgRepos.json') const githubRepositorySchema = require('./githubRepository.json') const ossfScorecardResultSchema = require('./ossfScorecardResult.json') const bulkImportSchema = require('./bulkImport.json') +const projectDataSchema = require('./projectData.json') +const indexDataSchema = require('./indexData.json') -const ajv = new Ajv() +const ajv = new Ajv({ + allowUnionTypes: true // Allow union types for fields like Date/string +}) addFormats(ajv) const getReadableErrors = validate => validate.errors.map((error) => `[ERROR: ${error.keyword}]${error.schemaPath}: ${error.message}`).join('\n') @@ -61,10 +65,32 @@ const validateBulkImport = (data) => { return null } +const validateProjectData = (data) => { + const validate = ajv.compile(projectDataSchema) + const valid = validate(data) + if (!valid) { + const readableErrors = getReadableErrors(validate) + throw new Error(`Error when validating project data: ${readableErrors}`) + } + return null +} + +const validateIndexData = (data) => { + const validate = ajv.compile(indexDataSchema) + const valid = validate(data) + if (!valid) { + const readableErrors = getReadableErrors(validate) + throw new Error(`Error when validating index data: ${readableErrors}`) + } + return null +} + module.exports = { validateGithubOrg, validateGithubListOrgRepos, validateGithubRepository, validateOSSFResult, - validateBulkImport + validateBulkImport, + validateProjectData, + validateIndexData } diff --git a/src/schemas/indexData.json b/src/schemas/indexData.json new file mode 100644 index 0000000..46a08ec --- /dev/null +++ b/src/schemas/indexData.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["projects", "checklists", "checks"], + "properties": { + "projects": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "checklists": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "type": { "type": "string" }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "checks": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "integer" }, + "checklist_id": { "type": "integer" }, + "description": { "type": "string" }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + } + } +} diff --git a/src/schemas/projectData.json b/src/schemas/projectData.json new file mode 100644 index 0000000..586e271 --- /dev/null +++ b/src/schemas/projectData.json @@ -0,0 +1,127 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["project", "checks", "results", "tasks", "alerts", "githubOrgs", "githubRepos", "ossfScorecardResults"], + "properties": { + "project": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "description": { "type": ["string", "null"] }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + }, + "checks": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "integer" }, + "checklist_id": { "type": "integer" }, + "description": { "type": "string" }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "results": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id", "project_id", "name", "score"], + "properties": { + "id": { "type": "integer" }, + "project_id": { "type": "integer" }, + "name": { "type": "string" }, + "score": { "type": "number" }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "tasks": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id", "project_id", "description", "implementation_status"], + "properties": { + "id": { "type": "integer" }, + "project_id": { "type": "integer" }, + "description": { "type": "string" }, + "implementation_status": { "type": "string", "enum": ["pending", "in_progress", "completed"] }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "alerts": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id", "project_id", "title", "description", "severity"], + "properties": { + "id": { "type": "integer" }, + "project_id": { "type": "integer" }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "severity": { "type": "string", "enum": ["low", "medium", "high", "critical"] }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "githubOrgs": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id", "login"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": ["string", "null"] }, + "login": { "type": "string" }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "githubRepos": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id", "name", "full_name"], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "full_name": { "type": "string" }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + }, + "ossfScorecardResults": { + "type": "array", + "minItems": 0, + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "integer" }, + "github_repository_id": { "type": "integer" }, + "score": { "type": "number" }, + "created_at": { "type": ["string", "object"], "format": "date-time" }, + "updated_at": { "type": ["string", "object"], "format": "date-time" } + } + } + } + } +} diff --git a/src/store/index.js b/src/store/index.js index acb1a88..549bc59 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -11,6 +11,11 @@ const addFn = knex => (table, record) => { return knex(table).insert(record).returning('*').then(results => results[0]) } +const getFn = knex => (table, id) => { + debug(`Fetching record ${id} from ${table}...`) + return knex(table).where({ id }).first() +} + const upsertRecord = async ({ knex, table, uniqueCriteria, data }) => { const existingRecord = await knex(table).where(uniqueCriteria).first() if (existingRecord) { @@ -199,6 +204,7 @@ const initializeStore = (knex) => { debug('Initializing store...') const getAll = getAllFn(knex) const addTo = addFn(knex) + const getOne = getFn(knex) return { addProject: addProject(knex), addGithubOrganization: addGithubOrganization(knex), @@ -234,7 +240,8 @@ const initializeStore = (knex) => { upsertSoftwareDesignTraining: upsertSoftwareDesignTraining(knex), upsertProjectPolicies: upsertProjectPolicies(knex), upsertOwaspTop10Training: upsertOwaspTop10Training(knex), - getAllOSSFResults: () => getAll('ossf_scorecard_results') + getAllOSSFResults: () => getAll('ossf_scorecard_results'), + getProjectById: (id) => getOne('projects', id) } }