diff --git a/.githooks/post-receive b/.githooks/post-receive index b44e19a..087a1eb 100644 --- a/.githooks/post-receive +++ b/.githooks/post-receive @@ -1,23 +1,52 @@ #!/bin/bash +set -euo pipefail + +# Function to wait for a service to become available +wait_for_service() { + local url="$1" + local timeout=30 # seconds + local start_time=$(date +%s) + echo "Waiting for service at $url to become available (timeout: ${timeout}s)..." + while :; do + if curl --silent --fail "$url" >/dev/null; then + echo "Service at $url is up!" + return 0 + fi + current_time=$(date +%s) + if ((current_time - start_time >= timeout)); then + echo "Error: Service at $url did not become available within ${timeout}s." + return 1 + fi + sleep 1 + done +} deploy_expressjs_blog() { local ref="$1" + local custom_path="${2:-}" + local custom_env="${3:-}" local branch="${ref#refs/heads/}" - local path="/srv/jasonpoage.com/expressjs-blog-$branch" + local path="${custom_path:-/srv/jasonpoage.com/expressjs-blog-$branch}" + local envfile="${custom_env:-/srv/jasonpoage.com/$branch.env}" set -x + + GIT_DIR="/srv/jasonpoage.com/expressjs-blog.git" + if [[ "$branch" == "production" || "$branch" == "main" || "$branch" == "testing" ]]; then + GIT_WORK_TREE="$path" git checkout -f "$branch" cd "$path" || return 1 + [[ -z "$custom_path" ]] && ln -f "$envfile" "$path/.env" || { + cp -f "$envfile" "$path/.env" + } yarn yarn combine:css - systemctl --user restart express-blog@"$branch".service - ln -f /srv/jasonpoage.com/"$branch".env "$path"/.env + [[ -z "$custom_path" ]] && systemctl --user restart express-blog@"$branch".service - # Blog content - GIT_DIR="/srv/jasonpoage.com/expressjs-blog-posts.git" - GIT_WORK_TREE="$path/content" git checkout -f main + git --git-dir=/srv/jasonpoage.com/expressjs-blog-posts.git --work-tree="$path/content" checkout -f main + unset GIT_DIR GIT_WORK_TREE fi set +x } @@ -28,21 +57,29 @@ tmpdir=$(mktemp -d) pidfile="$tmpdir/test.pid" logfile="$tmpdir/test.log" - trap 'kill $(cat "$pidfile" 2>/dev/null) 2>/dev/null; rm -rf "$tmpdir"' EXIT + trap "kill $(cat "$pidfile" 2>/dev/null) 2>/dev/null; rm -rf \"$tmpdir\"" EXIT - git clone . "$tmpdir" --quiet + git clone . "$tmpdir" --quiet --branch "$branch" + + deploy_expressjs_blog "refs/heads/$branch" "$tmpdir" "/srv/jasonpoage.com/${branch}.env" + cd "$tmpdir" || return 1 - cp /srv/jasonpoage.com/"${branch}".env .env + export TEST_PORT=4123 - yarn install --silent - export PORT=4123 - + export NODE_ENV=testing nohup yarn start >>"$logfile" 2>&1 & echo $! >"$pidfile" - sleep 2 + # Wait for the application to become responsive + if ! wait_for_service "http://127.0.0.1:$TEST_PORT"; then + echo "Application did not start or respond. Check logs:" + cat "$logfile" # Display logs on failure + return 1 + fi + if ! npm run test:postreceive; then kill "$(cat "$pidfile")" 2>/dev/null + cat "$logfile" return 1 fi diff --git a/package.json b/package.json index f582471..e210a56 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "marked": "^15.0.11", "morgan": "^1.10.0", "node-disk-info": "^1.3.0", - "node-fetch": "^2.7.0", "nodemailer": "^7.0.3", "nodemon": "^3.1.10", "path": "^0.12.7", @@ -56,6 +55,7 @@ "@faker-js/faker": "^9.8.0", "chai": "^5.2.1", "mocha": "^11.7.1", + "node-fetch": "^2.7.0", "pm2": "^6.0.6", "postcss": "^8.5.6", "postcss-import": "^16.1.1" diff --git a/src/app.js b/src/app.js index c2b0aab..2ed0a2c 100644 --- a/src/app.js +++ b/src/app.js @@ -5,7 +5,7 @@ const { manualLogger } = require("./utils/logging"); const { startTokenCleanup } = require("./utils/tokenCleanup"); -const PORT = process.env.PORT || 3400; +const SERVER_PORT = process.env.TEST_PORT || process.env.SERVER_PORT || 3400; const CWD_LOG = `CWD: ${process.cwd()}`; const SERVER_LISTEN_LOG = (port) => `Server listening on http://localhost:${port}`; @@ -27,8 +27,8 @@ const app = setupMiddleware(); -app.listen(PORT, () => { - console.log(SERVER_LISTEN_LOG(PORT)); +app.listen(SERVER_PORT, () => { + console.log(SERVER_LISTEN_LOG(SERVER_PORT)); console.log(NODE_ENV_LOG); }); diff --git a/src/constants/securityConstants.js b/src/constants/securityConstants.js index abb2119..cd0344e 100644 --- a/src/constants/securityConstants.js +++ b/src/constants/securityConstants.js @@ -3,7 +3,7 @@ module.exports = { LOCALHOST_HOSTNAMES: ["127.0.0.1", "localhost"], HEALTHCHECK_METHOD: "HEAD", - HEALTHCHECK_PATH: "/healthcheck", + HEALTHCHECK_PATH: "/health", FORBIDDEN_MESSAGE: "Forbidden", FORBIDDEN_STATUS_CODE: 403, HSTS_MAX_AGE: 63072000, diff --git a/src/middleware/applyProductionSecurity.js b/src/middleware/applyProductionSecurity.js index 3863925..b4fcbe6 100644 --- a/src/middleware/applyProductionSecurity.js +++ b/src/middleware/applyProductionSecurity.js @@ -22,7 +22,10 @@ if (req.method === HEALTHCHECK_METHOD && req.path === HEALTHCHECK_PATH) { return next(); } - if (LOCALHOST_HOSTNAMES.includes(req.hostname)) { + if ( + process.env.NODE_ENV === "production" && + LOCALHOST_HOSTNAMES.includes(req.hostname) + ) { req.log.info(`Method: ${req.method} Path ${req.path}`); return next(new HttpError(FORBIDDEN_MESSAGE, FORBIDDEN_STATUS_CODE)); } diff --git a/src/middleware/index.js b/src/middleware/index.js index 202fa42..16bf3c0 100644 --- a/src/middleware/index.js +++ b/src/middleware/index.js @@ -81,7 +81,10 @@ app.use(baseContext); // Setup production environment - if (process.env.NODE_ENV === "production") { + if ( + process.env.NODE_ENV === "production" || + process.env.NODE_ENV === "testing" + ) { app.use(applyProductionSecurity); } diff --git a/src/routes/blog_index.js b/src/routes/blog_index.js index b89bbdc..1cfb114 100644 --- a/src/routes/blog_index.js +++ b/src/routes/blog_index.js @@ -10,7 +10,10 @@ }); const publishedPosts = allPosts.filter( - (post) => post.published || process.env.NODE_ENV === "production" + (post) => + post.published || + process.env.NODE_ENV === "production" || + process.env.NODE_ENV === "testing" ); // Sort posts descending by date publishedPosts.sort((a, b) => new Date(b.date) - new Date(a.date)); diff --git a/src/routes/index.js b/src/routes/index.js index 91d6ccf..54b0cc6 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -30,10 +30,6 @@ res.sendStatus(200); }); -router.head("/healthcheck", (req, res) => { - res.sendStatus(200); -}); - router.use("/admin", securedMiddleware, securedRoutes); router.use( "/test", @@ -98,18 +94,18 @@ return acc; } -router.use((req, res) => { - const rootStack = req.app._router?.stack || req.app.router?.stack; - if (!rootStack) return res.sendStatus(500); - const flat = flattenRouterLayers(rootStack); - const routes = []; - flat.forEach((l) => { - if (l.route) { - routes.push(l.route.path); - } - }); - res.json(routes).send(200); -}); +// router.use((req, res) => { +// const rootStack = req.app._router?.stack || req.app.router?.stack; +// if (!rootStack) return res.sendStatus(500); +// const flat = flattenRouterLayers(rootStack); +// const routes = []; +// flat.forEach((l) => { +// if (l.route) { +// routes.push(l.route.path); +// } +// }); +// res.json(routes).send(200); +// }); // router.use((req, res) => { // const appStack = req.app._router?.stack || req.app.router?.stack; @@ -127,15 +123,9 @@ res.redirect(301, "/blog"); }); -router.use( - (req, res, next) => { - console.log(path.join(__dirname, "static/favicons")); - next(); - }, - (req, res, next) => { - console.log(req.url); - next(new HttpError("Page not found", 404)); - } -); +router.use((req, res, next) => { + console.log(req.url); + next(new HttpError("Page not found", 404)); +}); module.exports = router; diff --git a/src/routes/pages.js b/src/routes/pages.js index 0a72c7f..9182e92 100644 --- a/src/routes/pages.js +++ b/src/routes/pages.js @@ -8,7 +8,10 @@ const construction = new ConstructionRoutes(); const markdown = new MarkdownRoutes(); -if (process.env.NODE_ENV === "production") { +if ( + process.env.NODE_ENV === "production" || + process.env.NODE_ENV === "testing" +) { // construction.register("/newsletter", "Newsletter"); construction.register("/projects", "Projects"); construction.register("/about/blog", "About this blog"); diff --git a/src/routes/post.js b/src/routes/post.js index ba9a8c7..3ad9680 100644 --- a/src/routes/post.js +++ b/src/routes/post.js @@ -35,7 +35,11 @@ try { const fileContent = await fs.readFile(mdPath, "utf8"); const { data: frontmatter, content } = matter(fileContent); - if (!frontmatter.published && process.env.NODE_ENV === "production") { + if ( + !frontmatter.published && + (process.env.NODE_ENV === "production" || + process.env.NODE_ENV === "testing") + ) { throw new Error("Attempted to access an unpublished page in production"); } const htmlContent = marked(content); diff --git a/src/routes/sitemap.js b/src/routes/sitemap.js index 802769c..76919a1 100644 --- a/src/routes/sitemap.js +++ b/src/routes/sitemap.js @@ -6,7 +6,6 @@ const Handlebars = require("handlebars"); const sitemapService = require("../services/sitemapService"); const { qualifyLink } = require("../utils/qualifyLinks.js"); -// const { baseUrl } = require("../utils/baseUrl"); // Precompile XML template once const xmlTplSrc = fs.readFileSync( @@ -15,14 +14,6 @@ ); const xmlTpl = Handlebars.compile(xmlTplSrc); -// function flatten(entries, out = []) { -// for (const e of entries) { -// if (e.loc) out.push(e.loc); -// if (Array.isArray(e.children)) flatten(e.children, out); -// } -// return out; -// } - // HTML sitemap page router.get("/sitemap", async (req, res) => { const context = { @@ -41,12 +32,9 @@ res.json(context); }); -// const getBaseUrl = require("../utils/baseUrl"); - // XML sitemap endpoint router.get("/sitemap.xml", async (req, res) => { const urls = await sitemapService.getAllUrls(); - // const baseUrl = getBaseUrl({ protocol: req.protocol, host: req.get("host") }); // Format URLs for XML template const formattedUrls = urls.map((url) => ({ diff --git a/src/utils/baseUrl.js b/src/utils/baseUrl.js index 3b102c7..d5c2769 100644 --- a/src/utils/baseUrl.js +++ b/src/utils/baseUrl.js @@ -1,9 +1,9 @@ // src/utils/baseUrl.js -function getBaseUrl({ protocol = null, host = null } = {}) { - const envProtocol = process.env.PROTOCOL; - const envDomain = process.env.DOMAIN; +function getBaseUrl({ schema = null, host = null } = {}) { + const envSchema = process.env.SERVER_SCHEMA; + const envDomain = process.env.SERVER_DOMAIN; - const finalProtocol = envProtocol || protocol || "https"; + const finalProtocol = envSchema || schema || "https"; const finalDomain = (envDomain || host || "localhost") .replace(/^https?:\/\//, "") .replace(/\/$/, ""); diff --git a/src/utils/logging.js b/src/utils/logging.js index fbdd9f4..b9e6d8a 100644 --- a/src/utils/logging.js +++ b/src/utils/logging.js @@ -250,7 +250,10 @@ // Run cleanup on startup cleanupOldSessions(); -if (process.env.NODE_ENV !== "production") { +if ( + process.env.NODE_ENV !== "production" && + process.env.NODE_ENV !== "testing" +) { patchConsole(); } diff --git a/src/utils/postFileUtils.js b/src/utils/postFileUtils.js index 49f3964..2a40792 100644 --- a/src/utils/postFileUtils.js +++ b/src/utils/postFileUtils.js @@ -34,7 +34,8 @@ // Filter unpublished posts in production unless explicitly included if ( !data.published && - process.env.NODE_ENV === "production" && + (process.env.NODE_ENV === "production" || + process.env.NODE_ENV === "testing") && !includeUnpublished ) { return null; diff --git a/src/utils/qualifyLinks.js b/src/utils/qualifyLinks.js index 3b5dcd2..07658a6 100644 --- a/src/utils/qualifyLinks.js +++ b/src/utils/qualifyLinks.js @@ -1,5 +1,4 @@ const { baseUrl } = require("../utils/baseUrl"); -// const baseUrl = getBaseUrl({ protocol: req.protocol, host: req.get("host") }); function qualifyLink(href) { if (!href) return href; @@ -10,7 +9,7 @@ } function qualifyNavLinks(links) { - return links.map(link => { + return links.map((link) => { const qualified = { ...link }; if (qualified.href) { qualified.href = qualifyLink(qualified.href); @@ -22,4 +21,4 @@ }); } -module.exports = { qualifyNavLinks, qualifyLink } +module.exports = { qualifyNavLinks, qualifyLink }; diff --git a/test/env.test.js b/test/env.test.js index 3d63e94..3dd3001 100644 --- a/test/env.test.js +++ b/test/env.test.js @@ -6,16 +6,28 @@ expect(process.env.SITE_OWNER).to.be.a("string").and.not.empty; }); - it("should have DOMAIN defined as a non-empty string", () => { - expect(process.env.DOMAIN).to.be.a("string").and.not.empty; + it("should have SERVER_DOMAIN defined as a non-empty string", () => { + expect(process.env.SERVER_DOMAIN).to.be.a("string").and.not.empty; }); - it("should have NODE_ENV defined and be either 'development' or 'production'", () => { - expect(process.env.NODE_ENV).to.be.oneOf(["development", "production"]); + it("should have SERVER_ADDRESS defined as a non-empty string", () => { + expect(process.env.SERVER_ADDRESS).to.be.a("string").and.not.empty; }); - it("should have PORT defined and be a valid port number", () => { - const port = Number(process.env.PORT); + it("should have SERVER_SCHEMA defined and be either 'http' or 'https'", () => { + expect(process.env.SERVER_SCHEMA).to.be.oneOf(["http", "https"]); + }); + + it("should have NODE_ENV defined and be either 'development', 'testing' or 'production'", () => { + expect(process.env.NODE_ENV).to.be.oneOf([ + "development", + "testing", + "production", + ]); + }); + + it("should have SERVER_PORT defined and be a valid port number", () => { + const port = Number(process.env.SERVER_PORT); expect(port) .to.be.a("number") .and.satisfy((num) => num > 0 && num < 65536); diff --git a/test/routes.test.js b/test/routes.test.js index a439014..9634747 100644 --- a/test/routes.test.js +++ b/test/routes.test.js @@ -3,10 +3,14 @@ const { expect } = require("chai"); const http = require("http"); +let port = process.env.TEST_PORT; require("dotenv").config(); -const domain = process.env.DOMAIN; -const baseUrl = `http://127.0.0.1:3400`; +const domain = process.env.SERVER_DOMAIN; +port = port || process.env.SERVER_PORT; +const server_address = process.env.SERVER_ADDRESS; +const schema = process.env.TEST_SCHEMA || process.env.SERVER_SCHEMA; +const baseUrl = `${schema}://${server_address}:${port}`; // Create a proper HTTP agent const httpAgent = new http.Agent({ @@ -31,7 +35,9 @@ agent: httpAgent, timeout: 5000, method: "HEAD", + redirect: "manual", }); + console.log("Healthcheck body: ", res.text()); expect(res.ok).to.be.true; serverOnline = true; });