diff options
| -rw-r--r-- | CHANGELOG.md | 5 | ||||
| -rw-r--r-- | package-lock.json | 106 | ||||
| -rw-r--r-- | package.json | 3 | ||||
| -rw-r--r-- | src/index.ts | 35 | ||||
| -rw-r--r-- | src/routes.test.ts | 239 | ||||
| -rw-r--r-- | src/routes.ts | 34 |
6 files changed, 387 insertions, 35 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index ff2655a..818c383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- landing page provided at / based on template + ### Changed - More descriptive output from bash template, with links to the files +- Moved template processing to router, so unit tests can be run without build ## [0.3.3] diff --git a/package-lock.json b/package-lock.json index 2416ed2..8c5a11c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -487,6 +487,12 @@ "@types/node": "*" } }, + "@types/cookiejar": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz", + "integrity": "sha512-aRnpPa7ysx3aNW60hTiCtLHlQaIFsXFCgQlpakNgDNVFzbtusSY8PwjAQgRWfSk0ekNoBjO51eQRB6upA9uuyw==", + "dev": true + }, "@types/eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -608,6 +614,25 @@ "integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==", "dev": true }, + "@types/superagent": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-4.1.4.tgz", + "integrity": "sha512-SRH2q6/5/nhOkAuLXm3azRGjBYpoKCZWh138Rt1AxSIyE6/1b9uClIH2V+JfyDtjIvgr5yQqYgNUmdpbneJoZQ==", + "dev": true, + "requires": { + "@types/cookiejar": "*", + "@types/node": "*" + } + }, + "@types/supertest": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-2.0.8.tgz", + "integrity": "sha512-wcax7/ip4XSSJRLbNzEIUVy2xjcBIZZAuSd2vtltQfRK7kxhx5WMHbLHkYdxN3wuQCrwpYrg86/9byDjPXoGMA==", + "dev": true, + "requires": { + "@types/superagent": "*" + } + }, "@types/tough-cookie": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.5.tgz", @@ -1410,6 +1435,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "cookiejar": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.2.tgz", + "integrity": "sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA==", + "dev": true + }, "copy-descriptor": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", @@ -2328,6 +2359,12 @@ "mime-types": "^2.1.12" } }, + "formidable": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-1.2.1.tgz", + "integrity": "sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg==", + "dev": true + }, "forwarded": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.2.tgz", @@ -5908,6 +5945,75 @@ "integrity": "sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw==", "dev": true }, + "superagent": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-3.8.3.tgz", + "integrity": "sha512-GLQtLMCoEIK4eDv6OGtkOoSMt3D+oq0y3dsxMuYuDvaNUvuT8eFBuLmfR0iYYzHC1e8hpzC6ZsxbuP6DIalMFA==", + "dev": true, + "requires": { + "component-emitter": "^1.2.0", + "cookiejar": "^2.1.0", + "debug": "^3.1.0", + "extend": "^3.0.0", + "form-data": "^2.3.1", + "formidable": "^1.2.0", + "methods": "^1.1.1", + "mime": "^1.4.1", + "qs": "^6.5.1", + "readable-stream": "^2.3.5" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "supertest": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-4.0.2.tgz", + "integrity": "sha512-1BAbvrOZsGA3YTCWqbmh14L0YEq0EGICX/nBnfkfVJn7SrxQV1I3pMYjSzG9y/7ZU2V9dWqyqk2POwxlb09duQ==", + "dev": true, + "requires": { + "methods": "^1.1.2", + "superagent": "^3.8.3" + } + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", diff --git a/package.json b/package.json index 7a974f3..87068f8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@types/jest": "^24.0.24", + "@types/supertest": "^2.0.8", "@types/triple-beam": "^1.3.0", "@typescript-eslint/eslint-plugin": "^2.12.0", "@typescript-eslint/parser": "^2.12.0", @@ -44,6 +45,7 @@ "eslint-plugin-prettier": "^3.1.2", "jest": "^24.9.0", "prettier": "^1.19.1", + "supertest": "^4.0.2", "triple-beam": "^1.3.0", "ts-jest": "^24.2.0", "tsc-watch": "^4.0.0" @@ -54,6 +56,7 @@ "collectCoverageFrom": [ "src/**/*.ts", "!src/**/*.test.ts", + "!src/**/__tests__/**/*.ts", "!src/index.ts" ], "roots": [ diff --git a/src/index.ts b/src/index.ts index 71da73c..76620e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,6 @@ import expressWinston from "express-winston"; dotenv.config(); -import processTemplate, { Template } from "./templates"; import routes from "./routes"; import Metadata from "./metadata"; import loggerConfig from "./util/logger"; @@ -18,7 +17,6 @@ import { configOrError, handleShutdown } from "./util/config"; // Start-up configuration const BIND_ADDRESS = process.env.BIND_ADDRESS ?? "localhost"; const PORT = Number(process.env.PORT ?? 3000); -const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:3000"; const logger = winston.createLogger(loggerConfig("ROOT")); @@ -32,39 +30,6 @@ if (!path.isAbsolute(HOST_DIR)) { process.exit(1); } -// prepare template files -const bashTemplate = { - inputFile: path.join(__dirname, "..", "public", "templates", "bash.template"), - outputFile: path.join(HOST_DIR, "bash"), - context: { TARGET_URL } -} as Template; -const indexTemplate = { - inputFile: path.join(__dirname, "..", "public", "templates", "index.html.template"), - outputFile: path.join(HOST_DIR, "index.html"), - context: { TARGET_URL } -} as Template; - -processTemplate(bashTemplate) - .then(template => { - logger.debug("Generated '%s' from template file", template.outputFile); - }) - .then(() => processTemplate(indexTemplate)) - .then(template => { - logger.debug("Generated '%s' from template file", template.outputFile); - }) - .catch(err => { - logger.error("Unable to process template file: %s", err); - - // if the output file exists, then we are fine with continuing without - return fs.promises.access(bashTemplate.outputFile, fs.constants.R_OK); - }) - .then(() => fs.promises.access(indexTemplate.outputFile, fs.constants.R_OK)) - .catch(err => { - logger.error("Cannot proceed: %s", err); - - process.exit(1); - }); - new MongoClient(MONGO_URI, { useUnifiedTopology: true }).connect( (err, mongo) => { if (err !== null) { diff --git a/src/routes.test.ts b/src/routes.test.ts new file mode 100644 index 0000000..b4979d6 --- /dev/null +++ b/src/routes.test.ts @@ -0,0 +1,239 @@ +import _request from "supertest"; +import express from "express"; +import dotenv from "dotenv"; +import fs from "fs"; +import path from "path"; + +dotenv.config(); + +process.env.UPLOAD_LIMIT = "40000"; + +import { configOrError } from "./util/config"; +import routes from "./routes"; +import Metadata from "./metadata"; +import { Db } from "mongodb"; +import { badgen } from "badgen"; +import { BranchNotFoundError } from "./errors"; + +type MetadataMockType = { + database: Db; + getHeadCommit: jest.Mock; + updateBranch: jest.Mock; + createRepository: jest.Mock; +}; + +const mock = (headCommit: jest.Mock = jest.fn(() => new Promise(solv => solv("testcommit"))), updateBranch: jest.Mock = jest.fn(() => new Promise(solv => solv(true)))): MetadataMockType => ({ + database: {} as Db, + getHeadCommit: headCommit, + updateBranch: updateBranch, + createRepository: jest.fn() +}); + +const request = (mockMeta: MetadataMockType = mock()) => { + const app = express(); + + app.use(routes(mockMeta as Metadata)); + return _request(app); +} + +const HOST_DIR = configOrError("HOST_DIR"); +const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:3000"; +const TOKEN = process.env.TOKEN ?? ""; + +describe("templates", () => { + + describe("GET /bash", () => { + it("should return the bash file containing tbe curl command", async () => { + const res = await request() + .get("/bash") + .expect(200); + expect(res.text).toMatch("curl -X POST"); + expect(res.text).toMatch(`url="${TARGET_URL}"`); + }); + }); + + describe("GET /", () => { + it("should return the index HTML file containing the bash command", async () => { + const res = await request() + .get("/") + .expect("Content-Type", /html/) + .expect(200); + expect(res.text).toMatch(`bash <(curl -s ${TARGET_URL}/bash)`); + }) + }); +}); + +describe("Badges and reports", () => { + + const report_path = path.join(HOST_DIR, "testorg", "testrepo", "testbranch", "testcommit"); + const actual_report = path.join(__dirname, "..", "example_reports", "tarpaulin-report.html"); + const fake_badge = badgen({ + label: "coverage", + status: "120%", + color: "#E1C" + }); + + beforeAll(async () => { + // place test files on HOST_DIR + await fs.promises.mkdir(report_path, { recursive: true }); + await fs.promises.copyFile(actual_report, path.join(report_path, "index.html")); + await fs.promises.writeFile(path.join(report_path, "badge.svg"), fake_badge); + }); + + describe("GET /v1/:org/:repo/:branch/:commit.html", () => { + it("should retrieve the stored report file", async () => { + const res = await request() + .get("/v1/testorg/testrepo/testbranch/testcommit.html") + .expect("Content-Type", /html/) + .expect(200); + const buffer = await fs.promises.readFile(actual_report); + + expect(res.text).toEqual(buffer.toString("utf-8")); + }); + + it("should return 404 if file does not exist", async () => { + await request().get("/v1/neorg/nerepo/nebranch/necommit.html").expect(404); + }); + }); + + describe("GET /v1/:org/:repo/:branch.html", () => { + it("should retrieve the stored report file with the associated head commit", async () => { + const mockMeta = mock(); + const res = await request(mockMeta) + .get("/v1/testorg/testrepo/testbranch.html") + .expect("Content-Type", /html/) + .expect(200); + const buffer = await fs.promises.readFile(actual_report); + + expect(mockMeta.getHeadCommit).toHaveBeenCalledTimes(1); + expect(res.text).toEqual(buffer.toString("utf-8")); + }); + + it("should return 404 if file does not exist", async () => { + await request().get("/v1/neorg/nerepo/nebranch.html").expect(404); + }); + + it("should return 404 if head commit not found", async () => { + const head = jest.fn(() => new Promise(solv => solv(new BranchNotFoundError()))); + await request(mock(head)).get("/v1/testorg/testrepo/testbranch.html").expect(404); + }); + + it("should return 500 if promise is rejected", async () => { + const head = jest.fn(() => new Promise((_, rej) => rej("fooey"))); + await request(mock(head)).get("/v1/testorg/testrepo/testbranch.html").expect(500); + }); + }); + + describe("GET /v1/:org/:repo/:branch/:commit.svg", () => { + it("should retrieve the stored report badge", async () => { + const res = await request() + .get("/v1/testorg/testrepo/testbranch/testcommit.svg") + .expect("Content-Type", /svg/) + .expect(200); + + expect(res.body.toString("utf-8")).toEqual(fake_badge); + }); + + it("should return 404 if file does not exist", async () => { + await request().get("/v1/neorg/nerepo/nebranch/necommit.svg").expect(404); + }); + }); + + describe("GET /v1/:org/:repo/:branch.svg", () => { + it("should retrieve the stored report badge with the associated head commit", async () => { + const mockMeta = mock(); + const res = await request(mockMeta) + .get("/v1/testorg/testrepo/testbranch.svg") + .expect("Content-Type", /svg/) + .expect(200); + + expect(mockMeta.getHeadCommit).toHaveBeenCalledTimes(1); + expect(res.body.toString("utf-8")).toEqual(fake_badge); + }); + + it("should return 404 if file does not exist", async () => { + await request().get("/v1/neorg/nerepo/nebranch.svg").expect(404); + }); + + it("should return 404 if head commit not found", async () => { + const head = jest.fn(() => new Promise(solv => solv(new BranchNotFoundError()))); + await request(mock(head)).get("/v1/testorg/testrepo/testbranch.svg").expect(404); + }); + + it("should return 500 if promise is rejected", async () => { + const head = jest.fn(() => new Promise((_, rej) => rej("fooey"))); + await request(mock(head)).get("/v1/testorg/testrepo/testbranch.svg").expect(500); + }); + }); +}); + +describe("Uploads", () => { + + const report_path = path.join(HOST_DIR, "testorg", "testrepo", "newthis", "newthat"); + const data = fs.promises.readFile(path.join(__dirname, "..", "example_reports", "tarpaulin-report.html")); + + beforeEach(async () => { + await fs.promises.rmdir(report_path).catch(() => { }); + }); + + describe("POST /v1/:org/:repo/:branch/:commit.html", () => { + it("should upload the report and generate a badge", async () => { + const mockMeta = mock(); + await request(mockMeta) + .post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`) + .send(await data) + .expect(200); + + expect(mockMeta.updateBranch).toBeCalledWith({ organization: "testorg", repository: "testrepo", branch: "newthis", head: "newthat" }); + expect(mockMeta.updateBranch).toHaveBeenCalledTimes(1); + await fs.promises.access(path.join(report_path, "index.html"), fs.constants.R_OK); + await fs.promises.access(path.join(report_path, "badge.svg"), fs.constants.R_OK); + }); + + it("should return 401 when token is not correct", async () => { + await request() + .post(`/v1/testorg/testrepo/newthis/newthat.html?token=wrong&format=tarpaulin`) + .send(await data) + .expect(401); + }); + + it("should return 406 with an invalid format", async () => { + await request() + .post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=pepperoni`) + .send(await data) + .expect(406); + }); + + it("should return 400 when request body is not the appropriate format", async () => { + await request() + .post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`) + .send("This is not a file") + .expect(400); + }); + + it("should return 413 when request body is not the appropriate format", async () => { + const file = await data; + const big_data = Buffer.concat([file, file]); + await request() + .post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`) + .send(big_data) + .expect(413); + }); + + it("should return 500 when Metadata does not create branch", async () => { + const update = jest.fn(() => new Promise(solv => solv(false))); + await request(mock(jest.fn(), update)) + .post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`) + .send(await data) + .expect(500); + }); + + it("should return 500 when promise chain is rejected", async () => { + const update = jest.fn(() => new Promise((_, rej) => rej("fooey 2"))); + await request(mock(jest.fn(), update)) + .post(`/v1/testorg/testrepo/newthis/newthat.html?token=${TOKEN}&format=tarpaulin`) + .send(await data) + .expect(500); + }); + }); +});
\ No newline at end of file diff --git a/src/routes.ts b/src/routes.ts index 9ce3bbc..92d3542 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -5,6 +5,7 @@ import winston from "winston"; import path from "path"; import fs from "fs"; +import processTemplate, { Template } from "./templates"; import formats, { GradientStyle } from "./formats"; import Metadata, { HeadIdentity } from "./metadata"; import { configOrError } from "./util/config"; @@ -14,12 +15,45 @@ import { Messages } from "./errors"; const TOKEN = process.env.TOKEN ?? ""; const UPLOAD_LIMIT = Number(process.env.UPLOAD_LIMIT ?? 4194304); const HOST_DIR = configOrError("HOST_DIR"); +const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:3000"; const logger = winston.createLogger(loggerConfig("HTTP")); export default (metadata: Metadata): Router => { const router = Router(); + const bashTemplate = { + inputFile: path.join(__dirname, "..", "public", "templates", "bash.template"), + outputFile: path.join(HOST_DIR, "bash"), + context: { TARGET_URL } + } as Template; + const indexTemplate = { + inputFile: path.join(__dirname, "..", "public", "templates", "index.html.template"), + outputFile: path.join(HOST_DIR, "index.html"), + context: { TARGET_URL } + } as Template; + + processTemplate(bashTemplate) + .then(template => { + logger.debug("Generated '%s' from template file", template.outputFile); + }) + .then(() => processTemplate(indexTemplate)) + .then(template => { + logger.debug("Generated '%s' from template file", template.outputFile); + }) + .catch(err => { + logger.error("Unable to process template file: %s", err); + + // if the output file exists, then we are fine with continuing without + return fs.promises.access(bashTemplate.outputFile, fs.constants.R_OK); + }) + .then(() => fs.promises.access(indexTemplate.outputFile, fs.constants.R_OK)) + .catch(err => { + logger.error("Cannot proceed: %s", err); + + process.exit(1); + }); + // serve landing page router.get("/", (_, res) => { res.sendFile(path.join(HOST_DIR, "index.html")) |
