From a87eb1608d354a6e9c8170e0fdc432a5f556c445 Mon Sep 17 00:00:00 2001 From: Kevin J Hoerr Date: Mon, 4 Apr 2022 01:13:14 +0000 Subject: Refactor dependency on env and add comments --- src/config.test.ts | 475 ++++++++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 137 ++++++++++++++ src/index.ts | 53 +++--- src/metadata.ts | 118 ++++++++---- src/routes.test.ts | 25 ++- src/routes.ts | 18 +- src/templates.ts | 6 + src/util/config.test.ts | 472 ----------------------------------------------- src/util/config.ts | 122 ------------- src/util/logger.test.ts | 6 +- src/util/logger.ts | 9 +- 11 files changed, 775 insertions(+), 666 deletions(-) create mode 100644 src/config.test.ts create mode 100644 src/config.ts delete mode 100644 src/util/config.test.ts delete mode 100644 src/util/config.ts diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..c7a8be4 --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,475 @@ +const exit = jest + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + +import { Writable } from "stream"; +import winston from "winston"; +import loggerConfig from "./util/logger"; + +let output = ""; +const logger = winston.createLogger(loggerConfig("TEST", "debug")); + +jest.mock("./util/logger", () => { + const stream = new Writable(); + stream._write = (chunk, _encoding, next) => { + output = output += chunk.toString(); + next(); + }; + const streamTransport = new winston.transports.Stream({ stream }); + + return { + __esModule: true, + default: () => ({ + format: winston.format.combine( + winston.format.splat(), + winston.format.simple() + ), + transports: [streamTransport], + }), + }; +}); + +import { + configOrError, + persistTemplate, + handleStartup, + handleShutdown, + initializeToken, +} from "./config"; +import { + Logger, + MongoClient, + MongoError, + ReadConcern, + ReadPreference, + WriteConcern, +} from "mongodb"; +import { Server } from "http"; +import path from "path"; +import fs from "fs"; +import * as templates from "./templates"; +import { EnvConfig } from "./metadata"; + +const CommonMocks = { + connect: jest.fn(), + isConnected: jest.fn(), + logout: jest.fn(), + watch: jest.fn(), + startSession: jest.fn(), + withSession: jest.fn(), + addListener: jest.fn(), + on: jest.fn(), + once: jest.fn(), + prependListener: jest.fn(), + prependOnceListener: jest.fn(), + removeAllListeners: jest.fn(), + removeListener: jest.fn(), + off: jest.fn(), + setMaxListeners: jest.fn(), + getMaxListeners: jest.fn(), + listeners: jest.fn(), + listenerCount: jest.fn(), + rawListeners: jest.fn(), + emit: jest.fn(), + eventNames: jest.fn(), +}; + +const MongoMock = (p: Promise): jest.Mock => + jest.fn(() => ({ + ...CommonMocks, + close: jest.fn(() => p), + readPreference: ReadPreference.nearest, + bsonOptions: {}, + logger: new Logger("a"), + getLogger: jest.fn(), + options: { + hosts: [], + readPreference: ReadPreference.nearest, + readConcern: new ReadConcern("local"), + loadBalanced: true, + serverApi: { version: "1" }, + compressors: [], + writeConcern: new WriteConcern(), + dbName: "", + metadata: { + driver: { name: "", version: "" }, + os: { type: "", name: "linux", architecture: "", version: "" }, + platform: "linx", + }, + tls: true, + toURI: jest.fn(), + autoEncryption: {}, + connectTimeoutMS: 0, + directConnection: true, + driverInfo: {}, + forceServerObjectId: true, + minHeartbeatFrequencyMS: 0, + heartbeatFrequencyMS: 0, + keepAlive: false, + keepAliveInitialDelay: 0, + localThresholdMS: 0, + logger: new Logger("a"), + maxIdleTimeMS: 0, + maxPoolSize: 0, + minPoolSize: 0, + monitorCommands: true, + noDelay: true, + pkFactory: { createPk: jest.fn() }, + promiseLibrary: {}, + raw: true, + replicaSet: "", + retryReads: true, + retryWrites: true, + serverSelectionTimeoutMS: 0, + socketTimeoutMS: 0, + tlsAllowInvalidCertificates: true, + tlsAllowInvalidHostnames: true, + tlsInsecure: false, + waitQueueTimeoutMS: 0, + zlibCompressionLevel: 0, + srvMaxHosts: 1, + srvServiceName: "", + }, + serverApi: { version: "1" }, + autoEncrypter: undefined, + readConcern: new ReadConcern("local"), + writeConcern: new WriteConcern(), + db: jest.fn(), + })); +const ServerMock = (mockErr: Error | undefined): jest.Mock => + jest.fn(() => ({ + ...CommonMocks, + connections: 0, + setTimeout: jest.fn(), + timeout: 0, + headersTimeout: 0, + keepAliveTimeout: 0, + close: function (c: (err?: Error | undefined) => void): Server { + c(mockErr); + return this; + }, + maxHeadersCount: 0, + maxConnections: 0, + maxRequestsPerSocket: 0, + listen: jest.fn(), + listening: true, + address: jest.fn(), + getConnections: jest.fn(), + ref: jest.fn(), + requestTimeout: 3600, + unref: jest.fn(), + })); + +describe("initializeToken", () => { + it("Should generate a UUID", () => { + // Arrange + output = ""; + + // Act + const result = initializeToken(logger); + + // Assert + expect(result).toMatch(/([a-f0-9]{8}(-[a-f0-9]{4}){4}[a-f0-9]{8})/); + expect(output).toContain(result); + }); +}); + +describe("configOrError", () => { + beforeEach(() => { + exit.mockClear(); + }); + + it("should exit when a env var does not exist", () => { + // Arrange + + // Act + let result; + try { + result = configOrError("APPLESAUCE", logger); + } catch (err) { + // + } + + // Assert + expect(result).toBeUndefined(); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("should return the expected env var", () => { + // Arrange + process.env.CHRYSANTHEMUM = "hello"; + + // Act + const result = configOrError("CHRYSANTHEMUM", logger); + + // Assert + expect(result).toEqual(process.env.CHRYSANTHEMUM); + expect(exit).toHaveBeenCalledTimes(0); + }); +}); + +describe("persistTemplate", () => { + beforeEach(() => { + exit.mockClear(); + }); + + it("should generate a template without error", async () => { + const template = { + inputFile: "/mnt/c/Windows/System32", + outputFile: "./helloworld.txt", + context: { EXAMPLE: "this" }, + } as templates.Template; + const processTemplate = jest + .spyOn(templates, "default") + .mockImplementation( + (template: templates.Template) => new Promise((res) => res(template)) + ); + const fsAccess = jest.spyOn(fs.promises, "access").mockResolvedValue(); + + await persistTemplate(template, logger); + + expect(processTemplate).toHaveBeenCalledWith(template); + expect(fsAccess).not.toHaveBeenCalled(); + expect(exit).not.toHaveBeenCalled(); + processTemplate.mockRestore(); + fsAccess.mockRestore(); + }); + + it("should exit without error if template does not generate but file already exists", async () => { + const template = { + inputFile: "/mnt/c/Windows/System32", + outputFile: "./helloworld.txt", + context: { EXAMPLE: "this" }, + } as templates.Template; + const processTemplate = jest + .spyOn(templates, "default") + .mockRejectedValue("baa"); + const fsAccess = jest.spyOn(fs.promises, "access").mockResolvedValue(); + + await persistTemplate(template, logger); + + expect(processTemplate).toHaveBeenCalledWith(template); + expect(fsAccess).toHaveBeenCalledWith( + template.outputFile, + fs.constants.R_OK + ); + expect(exit).not.toHaveBeenCalled(); + processTemplate.mockRestore(); + fsAccess.mockRestore(); + }); + + it("should exit with error if template does not generate and file does not exist", async () => { + const template = { + inputFile: "/mnt/c/Windows/System32", + outputFile: "./helloworld.txt", + context: { EXAMPLE: "this" }, + } as templates.Template; + const processTemplate = jest + .spyOn(templates, "default") + .mockRejectedValue("baz"); + const fsAccess = jest.spyOn(fs.promises, "access").mockRejectedValue("bar"); + + await persistTemplate(template, logger); + + expect(processTemplate).toHaveBeenCalledWith(template); + expect(fsAccess).toHaveBeenCalledWith( + template.outputFile, + fs.constants.R_OK + ); + expect(exit).toHaveBeenCalledWith(1); + processTemplate.mockRestore(); + fsAccess.mockRestore(); + }); +}); + +describe("handleStartup", () => { + beforeEach(() => { + exit.mockClear(); + }); + + const config = { + hostDir: "/apple", + publicDir: "/public", + targetUrl: "localhost" + } as EnvConfig; + const confStartup = (): Promise => + handleStartup(config, logger); + + it("should pass back MongoClient", async () => { + const superClient = {} as MongoClient; + const fsAccess = jest.spyOn(fs.promises, "access").mockResolvedValue(); + const pathAbsolute = jest.spyOn(path, "isAbsolute").mockReturnValue(true); + const pathJoin = jest.spyOn(path, "join").mockReturnValue("path"); + const mongoClient = jest + .spyOn(MongoClient, "connect") + .mockImplementation( + () => new Promise((res) => res(superClient)) + ); + const processTemplate = jest + .spyOn(templates, "default") + .mockImplementation( + (template: templates.Template) => new Promise((res) => res(template)) + ); + + const result = await confStartup(); + + expect(fsAccess).toHaveBeenCalledTimes(1); + expect(pathAbsolute).toHaveBeenCalledTimes(1); + expect(mongoClient).toHaveBeenCalledTimes(1); + expect(processTemplate).toHaveBeenCalledTimes(2); + expect(exit).not.toHaveBeenCalled(); + expect(result).toEqual(superClient); + processTemplate.mockRestore(); + mongoClient.mockRestore(); + pathAbsolute.mockRestore(); + pathJoin.mockRestore(); + fsAccess.mockRestore(); + }); + + it("should exit if HOST_DIR is not read/write accessible", async () => { + const superClient = {} as MongoClient; + const fsAccess = jest.spyOn(fs.promises, "access").mockRejectedValue("boo"); + const pathAbsolute = jest.spyOn(path, "isAbsolute").mockReturnValue(true); + const pathJoin = jest.spyOn(path, "join").mockReturnValue("path"); + const mongoClient = jest + .spyOn(MongoClient, "connect") + .mockImplementation( + () => new Promise((res) => res(superClient)) + ); + const processTemplate = jest + .spyOn(templates, "default") + .mockImplementation( + (template: templates.Template) => new Promise((res) => res(template)) + ); + + const result = await confStartup(); + + expect(fsAccess).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + expect(pathAbsolute).not.toHaveBeenCalled(); + expect(mongoClient).not.toHaveBeenCalled(); + expect(processTemplate).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + processTemplate.mockRestore(); + mongoClient.mockRestore(); + pathAbsolute.mockRestore(); + pathJoin.mockRestore(); + fsAccess.mockRestore(); + }); + + it("should exit if HOST_DIR is not absolute path", async () => { + const superClient = {} as MongoClient; + const fsAccess = jest.spyOn(fs.promises, "access").mockResolvedValue(); + const pathAbsolute = jest.spyOn(path, "isAbsolute").mockReturnValue(false); + const pathJoin = jest.spyOn(path, "join").mockReturnValue("path"); + const mongoClient = jest + .spyOn(MongoClient, "connect") + .mockImplementation( + () => new Promise((res) => res(superClient)) + ); + const processTemplate = jest + .spyOn(templates, "default") + .mockImplementation( + (template: templates.Template) => new Promise((res) => res(template)) + ); + + const result = await confStartup(); + + expect(fsAccess).toHaveBeenCalledTimes(1); + expect(pathAbsolute).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + expect(mongoClient).not.toHaveBeenCalled(); + expect(processTemplate).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + processTemplate.mockRestore(); + mongoClient.mockRestore(); + pathAbsolute.mockRestore(); + pathJoin.mockRestore(); + fsAccess.mockRestore(); + }); + + it("should exit if MongoClient has error", async () => { + const fsAccess = jest.spyOn(fs.promises, "access").mockResolvedValue(); + const pathAbsolute = jest.spyOn(path, "isAbsolute").mockReturnValue(true); + const pathJoin = jest.spyOn(path, "join").mockReturnValue("path"); + const mongoClient = jest + .spyOn(MongoClient, "connect") + .mockImplementation( + () => new Promise((_, rej) => rej({ message: "aaahhh" } as MongoError)) + ); + const processTemplate = jest + .spyOn(templates, "default") + .mockImplementation( + (template: templates.Template) => new Promise((res) => res(template)) + ); + + const result = await confStartup(); + + expect(fsAccess).toHaveBeenCalledTimes(1); + expect(pathAbsolute).toHaveBeenCalledTimes(1); + expect(mongoClient).toHaveBeenCalledTimes(1); + expect(exit).toHaveBeenCalledWith(1); + expect(processTemplate).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + processTemplate.mockRestore(); + mongoClient.mockRestore(); + pathAbsolute.mockRestore(); + pathJoin.mockRestore(); + fsAccess.mockRestore(); + }); +}); + +describe("handleShutdown", () => { + beforeEach(() => { + exit.mockClear(); + // we don't use the MongoMock or ServerMock to directly test, so no mockClear needed + }); + + it("should exit gracefully without error", async () => { + // Arrange + const mongo = MongoMock(new Promise((r) => r()))(); + const server = ServerMock(undefined)(); + + // Act + try { + await handleShutdown(mongo, server, logger)("SIGINT"); + } catch (err) { + // + } + + // Assert + expect(exit).toHaveBeenCalledWith(0); + }); + + it("should exit with error with Mongo error", async () => { + // Arrange + const mongo = MongoMock(new Promise((_, r) => r()))(); + const server = ServerMock(undefined)(); + + // Act + try { + await handleShutdown(mongo, server, logger)("SIGINT"); + } catch (err) { + // + } + + // Assert + expect(exit).toHaveBeenCalledWith(1); + }); + + it("should exit with error with Server error", async () => { + // Arrange + const mongo = MongoMock(new Promise((r) => r()))(); + const server = ServerMock(Error("oh noooo"))(); + + // Act + try { + await handleShutdown(mongo, server, logger)("SIGINT"); + } catch (err) { + // + } + + // Assert + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e836552 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,137 @@ +import winston from "winston"; +import { MongoClient, MongoError } from "mongodb"; +import { Server } from "http"; +import path from "path"; +import fs from "fs"; +import { v4 as uuid } from "uuid"; + +import processTemplate, { Template } from "./templates"; +import { EnvConfig } from "./metadata"; + +/** + * Generate a token for use as the user self-identifier + */ +export const initializeToken = (logger: winston.Logger): string => { + //TODO check for token in hostDir/persist created token in hostDir so it's not regenerated on startup + const newToken = uuid(); + + logger.warn( + "TOKEN variable not provided, using this value instead: %s", + newToken + ); + logger.warn( + "Use this provided token to push your coverage reports to the server." + ); + + return newToken; +}; + +/** + * Get environment variable or exit application if it doesn't exist + */ +export const configOrError = (varName: string, logger: winston.Logger): string => { + const value = process.env[varName]; + if (value !== undefined) { + return value; + } else { + logger.error("%s must be defined", varName); + process.exit(1); + } +}; + +/** + * Process a template and persist based on template. + */ +export const persistTemplate = async (input: Template, logger: winston.Logger): Promise => { + try { + const template = await processTemplate(input); + logger.debug("Generated '%s' from template file", template.outputFile); + } catch (err1) { + try { + await fs.promises.access(input.outputFile, fs.constants.R_OK); + } catch (err2) { + logger.error( + "Error while generating '%s' from template file: %s", + input.outputFile, + err1 + ); + logger.error("Cannot proceed due to error: %s", err2); + + process.exit(1); + } + // if the output file exists, then we are fine with continuing without + logger.warn( + "Could not generate '%s' from template file, but file already exists: %s", + input.outputFile, + err1 + ); + } +}; + +/** + * Handle server start-up functions: + * + * - Open database connection + * - Generate documents from templates based on configuration + * + * If one of these actions cannot be performed, it will call `process.exit(1)`. + */ +export const handleStartup = async (config: EnvConfig, logger: winston.Logger): Promise => { + try { + const { hostDir, publicDir, dbUri, targetUrl } = config; + await fs.promises.access(hostDir, fs.constants.R_OK | fs.constants.W_OK); + if (!path.isAbsolute(hostDir)) { + await Promise.reject("hostDir must be an absolute path"); + } + + const mongo = await MongoClient.connect(dbUri).catch((err: MongoError) => + Promise.reject(err.message ?? "Unable to connect to database") + ); + + await persistTemplate({ + inputFile: path.join(publicDir, "templates", "sh.tmpl"), + outputFile: path.join(hostDir, "sh"), + context: { TARGET_URL: targetUrl }, + } as Template, logger); + await persistTemplate({ + inputFile: path.join(publicDir, "templates", "index.html.tmpl"), + outputFile: path.join(hostDir, "index.html"), + context: { + TARGET_URL: targetUrl, + CURL_HTTPS: targetUrl.includes("https") + ? "--proto '=https' --tlsv1.2 " + : "", + }, + } as Template, logger); + + return mongo; + } catch (err) { + logger.error("Error occurred during startup: %s", err); + process.exit(1); + } +}; + +/** + * Callback for NodeJS `process.on()` to handle shutdown signals + * and close open connections. + */ +export const handleShutdown = + (mongo: MongoClient, server: Server, logger: winston.Logger) => + async (signal: NodeJS.Signals): Promise => { + logger.warn("%s signal received. Closing shop.", signal); + + try { + await mongo.close(); + logger.info("MongoDB client connection closed."); + + // must await for callback - wrapped in Promise + await new Promise((res, rej) => server.close((err) => { + logger.info("Express down."); + (err ? rej : res)(err); + })); + + process.exit(0); + } catch (e) { + process.exit(1); + } + }; diff --git a/src/index.ts b/src/index.ts index 1de4b10..d101ba3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,8 +4,6 @@ import winston from "winston"; import expressWinston from "express-winston"; import path from "path"; -dotenv.config(); - import routes from "./routes"; import Metadata, { EnvConfig } from "./metadata"; import loggerConfig from "./util/logger"; @@ -14,32 +12,45 @@ import { handleStartup, handleShutdown, initializeToken, -} from "./util/config"; - -// Start-up configuration -const BIND_ADDRESS = process.env.BIND_ADDRESS ?? "localhost"; -const MONGO_DB = process.env.MONGO_DB ?? "ao-coverage"; -const PORT = Number(process.env.PORT ?? 3000); -const MONGO_URI = configOrError("MONGO_URI"); -const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:3000"; +} from "./config"; + +dotenv.config(); + +const LOG_LEVEL = process.env.LOG_LEVEL ?? "info"; +const logger = winston.createLogger(loggerConfig("ROOT", LOG_LEVEL)); + +// Application-wide configuration settings const ENV_CONFIG: EnvConfig = { - token: process.env.TOKEN ?? initializeToken(), + // Express configuration + bindAddress: process.env.BIND_ADDRESS ?? "localhost", + port: Number(process.env.PORT ?? 3000), uploadLimit: Number(process.env.UPLOAD_LIMIT ?? 4194304), + + // Database connection information + dbName: process.env.MONGO_DB ?? "ao-coverage", + dbUri: configOrError("MONGO_URI", logger), + + // Where the server should say it's located + targetUrl: process.env.TARGET_URL ?? "http://localhost:3000", + + // Directories used for serving static or uploaded files publicDir: path.join(__dirname, "..", "public"), - hostDir: configOrError("HOST_DIR"), + hostDir: configOrError("HOST_DIR", logger), + + // Application configuration + token: process.env.TOKEN ?? initializeToken(logger), stage1: Number(process.env.STAGE_1 ?? 95), stage2: Number(process.env.STAGE_2 ?? 80), + logLevel: LOG_LEVEL, }; -const logger = winston.createLogger(loggerConfig("ROOT")); - -handleStartup(MONGO_URI, ENV_CONFIG, TARGET_URL).then((mongo) => { +handleStartup(ENV_CONFIG, logger).then((mongo) => { const app: express.Application = express(); - const metadata = new Metadata(mongo.db(MONGO_DB), ENV_CONFIG); + const metadata = new Metadata(mongo.db(ENV_CONFIG.dbName), ENV_CONFIG); app.use( expressWinston.logger({ - ...loggerConfig("HTTP"), + ...loggerConfig("HTTP", ENV_CONFIG.logLevel), colorize: true, // filter out token query param from URL msg: '{{req.method}} {{req.url.replace(/token=[-\\w.~]*(&*)/, "token=$1")}} - {{res.statusCode}} {{res.responseTime}}ms', @@ -49,15 +60,15 @@ handleStartup(MONGO_URI, ENV_CONFIG, TARGET_URL).then((mongo) => { // actual app routes app.use(routes(metadata)); - app.use(expressWinston.errorLogger(loggerConfig("_ERR"))); + app.use(expressWinston.errorLogger(loggerConfig("_ERR", ENV_CONFIG.logLevel))); - const server = app.listen(PORT, BIND_ADDRESS, () => { - logger.info("Express has started: http://%s:%d/", BIND_ADDRESS, PORT); + const server = app.listen(ENV_CONFIG.port, ENV_CONFIG.bindAddress, () => { + logger.info("Express has started: http://%s:%d/", ENV_CONFIG.bindAddress, ENV_CONFIG.port); }); // application exit handling const signalCodes: NodeJS.Signals[] = ["SIGTERM", "SIGHUP", "SIGINT"]; signalCodes.map((code: NodeJS.Signals) => { - process.on(code, handleShutdown(mongo, server)); + process.on(code, handleShutdown(mongo, server, logger)); }); }); diff --git a/src/metadata.ts b/src/metadata.ts index 3cba605..6f6d401 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -10,12 +10,6 @@ interface HeadContext { format: string; } -export const isError = ( - obj: HeadContext | BranchNotFoundError -): obj is BranchNotFoundError => { - return Object.keys(obj).includes("name"); -}; - interface Branch { head: HeadContext | string; } @@ -37,49 +31,62 @@ export interface Repository { branches: BranchList; } -const logger = winston.createLogger(loggerConfig("META")); - +/** + * Holds environment configuration items for the application + */ export interface EnvConfig { - token: string; + /** Express value to bind to a given address */ + bindAddress: string; + /** Express value for port */ + port: number; + /** Express value to limit uploads to server */ uploadLimit: number; + /** Configuration for the database name */ + dbName: string; + /** Configuration for the database URI */ + dbUri: string; + /** The address given for communicating back to the server */ + targetUrl: string; + /** The host directory for uploaded files */ hostDir: string; + /** The public directory for static files */ publicDir: string; + /** The application server token to serve as user self-identifier */ + token: string; + /** Gradient setting 1 */ stage1: number; + /** Gradient setting 2 */ stage2: number; + /** Log level across application */ + logLevel: string; } +/** + * Check if provided response is a known application error + */ +export const isError = ( + obj: HeadContext | BranchNotFoundError +): obj is BranchNotFoundError => { + return Object.keys(obj).includes("name"); +}; + +/** + * Handles data routing for application + */ class Metadata { database: Db; config: EnvConfig; + logger: winston.Logger; constructor(client: Db, data: EnvConfig) { + this.logger = winston.createLogger(loggerConfig("META", data.logLevel)); this.database = client; this.config = data; } - getToken(): string { - return this.config.token; - } - - getUploadLimit(): number { - return this.config.uploadLimit; - } - - getHostDir(): string { - return this.config.hostDir; - } - - getPublicDir(): string { - return this.config.publicDir; - } - - getGradientStyle(): GradientStyle { - return { - stage1: this.config.stage1, - stage2: this.config.stage2, - }; - } - + /** + * Retrieve the latest commit to the given branch + */ async getHeadCommit( organization: string, repository: string, @@ -98,7 +105,7 @@ class Metadata { const head = typeof limb.head === "string" ? limb.head : limb.head.commit; const format = typeof limb.head === "string" ? "tarpaulin" : limb.head.format; - logger.debug( + this.logger.debug( "Found commit %s for ORB %s/%s/%s (format %s)", head, organization, @@ -112,6 +119,9 @@ class Metadata { } } + /** + * Update the database with the latest commit to a branch + */ async updateBranch(identity: HeadIdentity): Promise { const { organization, repository: name, branch, head } = identity; const result = await this.database @@ -128,6 +138,9 @@ class Metadata { return result.ok === 1; } + /** + * Add a repository metadata document to the database + */ async createRepository(identity: HeadIdentity): Promise { const { organization, repository: name, branch, head } = identity; const repo: Repository = { @@ -142,6 +155,45 @@ class Metadata { return result.acknowledged; } + + /** + * Retrieve the application token from configuration + */ + getToken(): string { + return this.config.token; + } + + /** + * Retrieve the upload limit for files from configuration + */ + getUploadLimit(): number { + return this.config.uploadLimit; + } + + /** + * Retrieve the host for uploaded documents directory from configuration + */ + getHostDir(): string { + return this.config.hostDir; + } + + /** + * Retrieve the public static file directory from configuration + */ + getPublicDir(): string { + return this.config.publicDir; + } + + /** + * Retrieve the gradient style from configuration + */ + getGradientStyle(): GradientStyle { + return { + stage1: this.config.stage1, + stage2: this.config.stage2, + }; + } + } export default Metadata; diff --git a/src/routes.test.ts b/src/routes.test.ts index 12210bf..5d7839c 100644 --- a/src/routes.test.ts +++ b/src/routes.test.ts @@ -8,6 +8,8 @@ import express from "express"; import dotenv from "dotenv"; import fs from "fs"; import path from "path"; +import { Writable } from "stream"; +import winston from "winston"; dotenv.config(); @@ -23,9 +25,6 @@ test("HOST_DIR must be readable and writable", () => { ).not.toThrowError(); }); -import { Writable } from "stream"; -import winston from "winston"; - let output = ""; jest.mock("./util/logger", () => { @@ -48,7 +47,8 @@ jest.mock("./util/logger", () => { }; }); -import { configOrError, persistTemplate } from "./util/config"; +import { configOrError, persistTemplate } from "./config"; +import loggerConfig from "./util/logger"; import routes from "./routes"; import Metadata, { EnvConfig } from "./metadata"; import { Template } from "./templates"; @@ -57,6 +57,7 @@ import { badgen } from "badgen"; import { BranchNotFoundError } from "./errors"; type MetadataMockType = { + logger: winston.Logger; database: Db; config: EnvConfig; getHeadCommit: jest.Mock; @@ -68,15 +69,22 @@ type MetadataMockType = { updateBranch: jest.Mock; createRepository: jest.Mock; }; +const logger = winston.createLogger(loggerConfig("TEST", "debug")); const config = { token: "THISISCORRECT", // should be just larger than the example report used uploadLimit: Number(40000), - hostDir: configOrError("HOST_DIR"), + hostDir: configOrError("HOST_DIR", logger), publicDir: path.join(__dirname, "..", "public"), stage1: 95, stage2: 80, + bindAddress: "localhost", + targetUrl: "http://localhost:3000/", + port: 3000, + dbName: "ao-coverage", + dbUri: "localhost", + logLevel: "debug", }; const mock = ( headCommit: jest.Mock = jest.fn( @@ -85,6 +93,7 @@ const mock = ( ), updateBranch: jest.Mock = jest.fn(() => new Promise((solv) => solv(true))) ): MetadataMockType => ({ + logger, database: {} as Db, config: config, getToken: jest.fn(() => config.token), @@ -109,7 +118,7 @@ const request = async ( return _request(app); }; -const HOST_DIR = configOrError("HOST_DIR"); +const HOST_DIR = configOrError("HOST_DIR", logger); const TARGET_URL = "https://localhost:3000"; describe("templates", () => { @@ -119,7 +128,7 @@ describe("templates", () => { inputFile: path.join(__dirname, "..", "public", "templates", "sh.tmpl"), outputFile: path.join(HOST_DIR, "sh"), context: { TARGET_URL }, - } as Template); + } as Template, logger); const res = await (await request()).get("/sh").expect(200); expect(exit).not.toHaveBeenCalled(); @@ -140,7 +149,7 @@ describe("templates", () => { ), outputFile: path.join(HOST_DIR, "index.html"), context: { TARGET_URL, CURL_HTTPS: "--https " }, - } as Template); + } as Template, logger); const res = await (await request()) .get("/") diff --git a/src/routes.ts b/src/routes.ts index a8360ca..0998345 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -9,11 +9,16 @@ import Metadata, { HeadIdentity, isError } from "./metadata"; import loggerConfig from "./util/logger"; import { InvalidReportDocumentError, Messages } from "./errors"; -const logger = winston.createLogger(loggerConfig("HTTP")); - -export default (metadata: Metadata): Router => { +/** + * Provide routes from application state + */ +const routes = (metadata: Metadata): Router => { const router = Router(); + const logger = winston.createLogger(loggerConfig("HTTP", metadata.logger.level)); + /** + * Persist uploaded coverage report and creates coverage badge + */ const commitFormatDocs = async ( contents: string, identity: HeadIdentity, @@ -138,6 +143,9 @@ export default (metadata: Metadata): Router => { }); }); + /** + * Read a file from the host directory. + */ const retrieveFile = ( res: express.Response, identity: HeadIdentity, @@ -238,6 +246,7 @@ export default (metadata: Metadata): Router => { retrieveFile(res, identity, format.fileName); }); + // provide hard link for commit router.get("/v1/:org/:repo/:branch/:commit.xml", (req, res) => { const { org, repo, branch, commit } = req.params; const format = formats.formats.cobertura; @@ -250,6 +259,7 @@ export default (metadata: Metadata): Router => { retrieveFile(res, identity, format.fileName); }); + // return 404 for all other routes router.use((_, res) => { res.status(404); res.sendFile(path.join(metadata.getPublicDir(), "static", "404.html")); @@ -257,3 +267,5 @@ export default (metadata: Metadata): Router => { return router; }; + +export default routes; diff --git a/src/templates.ts b/src/templates.ts index 648bb56..26451cf 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,6 +1,9 @@ import handlebars from "handlebars"; import fs from "fs"; +/** + * Information for processing a template file into the output file + */ export interface Template { inputFile: string; outputFile: string; @@ -8,6 +11,9 @@ export interface Template { data?: string; } +/** + * Process input file and produce file at given output location + */ export default async (_template: Template): Promise