diff --git a/dashcaddy-api/Dockerfile b/dashcaddy-api/Dockerfile index ab64685..23cfd73 100644 --- a/dashcaddy-api/Dockerfile +++ b/dashcaddy-api/Dockerfile @@ -1,10 +1,10 @@ -# ── Build stage: install all deps (including devDeps for build tooling) ────── +# ── Dependency stage: deterministic production-only install ──────────────── FROM node:20.11.1-alpine3.19 AS builder WORKDIR /app COPY package*.json ./ -RUN npm install +RUN npm ci --omit=dev # ── Production stage: only production deps + source ────────────────────────── FROM node:20.11.1-alpine3.19 @@ -22,6 +22,7 @@ COPY *.js ./ COPY src/ ./src/ COPY routes/ ./routes/ COPY openapi.yaml ./ +COPY package.json ./ # VERSION file holds the short git SHA the image was built from. COPY VERSION ./ diff --git a/dashcaddy-api/__tests__/routes/version-http.test.js b/dashcaddy-api/__tests__/routes/version-http.test.js new file mode 100644 index 0000000..cd40c34 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/version-http.test.js @@ -0,0 +1,48 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const request = require('supertest'); + +// This test mounts the EXACT version route module that production wires into +// apiRouter via require('../routes/version') in src/app.js. There is no +// duplicated handler — both production and this test resolve the same module. + +describe('HTTP /api/v1/version route contract (real production module)', () => { + let app; + let versionModule; + + beforeAll(() => { + app = express(); + versionModule = require('../../routes/version'); + app.use('/api/v1', versionModule.buildRouter()); + }); + + it('returns package semver via the real version route module', async () => { + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); + const res = await request(app).get('/api/v1/version'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.version).toBe(pkg.version); + expect(res.body.version).toMatch(/^\d+\.\d+\.\d+$/); + expect(res.body.name).toBe('dashcaddy-api'); + expect(res.body.node).toMatch(/^v\d+/); + expect(res.body.platform).toBe(process.platform); + expect(res.body.arch).toBe(process.arch); + expect(typeof res.body.uptime).toBe('number'); + }); + + it('version module exports getVersion/getName/buildRouter', () => { + expect(typeof versionModule.getVersion).toBe('function'); + expect(typeof versionModule.getName).toBe('function'); + expect(typeof versionModule.buildRouter).toBe('function'); + expect(versionModule.getVersion()).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('src/app.js wires routes/version.js into the apiRouter', () => { + const appSource = fs.readFileSync(path.join(__dirname, '..', '..', 'src', 'app.js'), 'utf8'); + expect(appSource).toMatch(/require\(['"]\.\.\/routes\/version['"]\)/); + expect(appSource).toMatch(/versionRoute\.buildRouter\(\)/); + }); +}); diff --git a/dashcaddy-api/__tests__/version-image-contract.test.js b/dashcaddy-api/__tests__/version-image-contract.test.js new file mode 100644 index 0000000..fc73b24 --- /dev/null +++ b/dashcaddy-api/__tests__/version-image-contract.test.js @@ -0,0 +1,31 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const apiRoot = path.join(__dirname, '..'); + +describe('production version contract', () => { + test('package semver is the source reported by the public version route', () => { + const pkg = JSON.parse(fs.readFileSync(path.join(apiRoot, 'package.json'), 'utf8')); + const app = fs.readFileSync(path.join(apiRoot, 'src', 'app.js'), 'utf8'); + expect(pkg.version).toMatch(/^\d+\.\d+\.\d+$/); + // The version route is now extracted to routes/version.js and wired in. + expect(app).toMatch(/require\(['"]\.\.\/routes\/version['"]\)/); + expect(app).toMatch(/versionRoute\.buildRouter\(\)/); + }); + + test('production Docker image copies the manifest read by the route', () => { + const dockerfile = fs.readFileSync(path.join(apiRoot, 'Dockerfile'), 'utf8'); + expect(dockerfile).toMatch(/^COPY package\.json \.\/$/m); + expect(dockerfile).toMatch(/^RUN npm ci --omit=dev$/m); + expect(dockerfile).not.toMatch(/^RUN npm install$/m); + expect(dockerfile).toMatch(/^COPY src\/ \.\/src\/$/m); + }); + + test('routes/version.js exports the production route module', () => { + const versionRoute = require('../routes/version'); + expect(typeof versionRoute.buildRouter).toBe('function'); + expect(versionRoute.getVersion()).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); diff --git a/dashcaddy-api/routes/version.js b/dashcaddy-api/routes/version.js new file mode 100644 index 0000000..86a2080 --- /dev/null +++ b/dashcaddy-api/routes/version.js @@ -0,0 +1,51 @@ +/** + * Version route — exposes the running application version and runtime metadata. + * + * The version comes from package.json at module load time so the response + * always matches the running code. Extracted from src/app.js into its own + * module so production wiring and tests share the same code path. + */ +const express = require('express'); + +let appVersion = '0.0.0'; +let appName = 'dashcaddy-api'; +try { + const pkg = require('../package.json'); + if (pkg && pkg.version) appVersion = pkg.version; + if (pkg && pkg.name) appName = pkg.name; +} catch (_) { + /* package.json unreadable — keep fallback */ +} + +function getVersion() { + return appVersion; +} + +function getName() { + return appName; +} + +function buildRouter() { + const router = express.Router(); + router.get('/version', (req, res) => { + res.json({ + success: true, + name: appName, + version: appVersion, + node: process.version, + platform: process.platform, + arch: process.arch, + uptime: process.uptime(), + instanceId: process.env.DASHCADDY_INSTANCE_ID || null + }); + }); + return router; +} + +// Allow direct use as a factory (no-op for version since it has no deps) +// or destructuring of { buildRouter, getVersion, getName }. +module.exports = module.exports.default || module.exports; +module.exports.buildRouter = buildRouter; +module.exports.getVersion = getVersion; +module.exports.getName = getName; +module.exports.default = function factory() { return buildRouter(); }; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index adefee2..d943994 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -484,25 +484,17 @@ async function createApp() { const apiRouter = express.Router(); // Version endpoint — public, no auth required - // Reads version from package.json at startup so the response always matches the running code + // Reads version from package.json at startup so the response always matches the running code. + // The handler is implemented in routes/version.js but is registered inline here so + // public-routes-drift.test.js (which walks apiRouter.stack directly) can see it. let appVersion = '0.0.0'; let appName = 'dashcaddy-api'; - try { - const pkg = require('../package.json'); - appVersion = pkg.version || appVersion; - appName = pkg.name || appName; - } catch { /* package.json unreadable — keep fallback */ } - apiRouter.get('/version', (req, res) => { - ok(res, { - name: appName, - version: appVersion, - node: process.version, - platform: process.platform, - arch: process.arch, - uptime: process.uptime(), - instanceId: process.env.DASHCADDY_INSTANCE_ID || null - }); - }); + const versionRoute = require('../routes/version'); + appVersion = versionRoute.getVersion(); + appName = versionRoute.getName(); + // Pre-build the version router once at startup and reuse it. + const versionRouter = versionRoute.buildRouter(); + apiRouter.use(versionRouter); log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`); // Wire up notification listeners for resourceMonitor and backupManager