From e7563eb0622b89912ff8f8fd77ae0a6872b114e4 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 5 Apr 2026 13:07:30 -0400 Subject: [PATCH] justobot: heartbeat-heartbeat-mnm0iav3 --- backend/src/server.js | 8 +++ backend/test/api.test.js | 141 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/backend/src/server.js b/backend/src/server.js index df90f75..2d8b188 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -85,7 +85,15 @@ app.use((req, res, next) => { // Protected write endpoints — auth applied per-route so GET endpoints remain public +// Farm writes: POST /farm (admin only) +app.post('/farm', requireAuth('admin'), (req, res, next) => next()); app.use('/farm', farmRoutes); + +// Worker writes: POST /worker (foreman+admin), PUT /worker/:id (foreman+admin), +// PUT /worker/:id/pay-rate (admin only) +app.post('/worker', requireAuth('foreman'), (req, res, next) => next()); +app.put('/worker/:id', requireAuth('foreman'), (req, res, next) => next()); +app.put('/worker/:id/pay-rate', requireAuth('admin'), (req, res, next) => next()); app.use('/worker', workerRoutes); // Shift writes: POST /shift (foreman+admin), POST /shift/:id/close (foreman+admin) diff --git a/backend/test/api.test.js b/backend/test/api.test.js index 7e5fe49..2466121 100644 --- a/backend/test/api.test.js +++ b/backend/test/api.test.js @@ -867,6 +867,147 @@ describe('API authentication middleware', () => { const res = await get('/farm'); assert.equal(res.status, 200); }); + + test('GET /worker is accessible without auth (read endpoint)', async () => { + const res = await get('/worker'); + assert.equal(res.status, 200); + }); + + test('GET /worker/:id/today is accessible without auth', async () => { + const res = await get(`/worker/${workerId}/today`); + assert.equal(res.status, 200); + }); + + test('GET /worker/:id/payments is accessible without auth', async () => { + const res = await get(`/worker/${workerId}/payments`); + assert.equal(res.status, 200); + }); +}); + +// ------------------------------------------------------------------ Auth enforcement on write endpoints + +describe('Auth enforcement on write endpoints (with keys enabled)', () => { + /** + * These tests spin up a SECOND server instance with auth keys configured, + * verifying that write endpoints properly reject unauthenticated requests + * while read endpoints remain open. + */ + let authServer, authBaseUrl; + + function authRequest(method, urlPath, body, token) { + return new Promise((resolve, reject) => { + const url = new URL(urlPath, authBaseUrl); + const payload = body ? JSON.stringify(body) : null; + + const headers = { 'Content-Type': 'application/json' }; + if (payload) headers['Content-Length'] = Buffer.byteLength(payload); + if (token) headers['Authorization'] = `Bearer ${token}`; + + const req = http.request({ + hostname: url.hostname, + port: url.port, + path: url.pathname + url.search, + method, + headers, + }, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + try { + resolve({ status: res.statusCode, body: JSON.parse(data) }); + } catch { + resolve({ status: res.statusCode, body: data }); + } + }); + }); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); + } + + before(async () => { + // Set auth keys and load a fresh app instance + process.env.ADMIN_KEY = 'test-admin-key'; + process.env.FOREMAN_KEY = 'test-foreman-key'; + process.env.WORKER_KEY = 'test-worker-key'; + + // Clear module cache so auth module picks up new env vars + const serverPath = require.resolve('../src/server'); + const authPath = require.resolve('../src/middleware/auth'); + delete require.cache[serverPath]; + delete require.cache[authPath]; + + const authApp = require('../src/server'); + + await new Promise((resolve) => { + authServer = authApp.listen(0, () => { + authBaseUrl = `http://127.0.0.1:${authServer.address().port}`; + resolve(); + }); + }); + }); + + after(async () => { + await new Promise((resolve) => authServer.close(resolve)); + // Restore env + delete process.env.ADMIN_KEY; + delete process.env.FOREMAN_KEY; + delete process.env.WORKER_KEY; + }); + + // -- POST /farm requires admin + test('POST /farm returns 401 without token', async () => { + const res = await authRequest('POST', '/farm', { + name: 'Unauthorized Farm', location: 'Nowhere', altitude_m: 100, owner_name: 'Hacker', + }); + assert.equal(res.status, 401); + }); + + test('POST /farm returns 403 with foreman key', async () => { + const res = await authRequest('POST', '/farm', { + name: 'Unauthorized Farm', location: 'Nowhere', altitude_m: 100, owner_name: 'Hacker', + }, 'test-foreman-key'); + assert.equal(res.status, 403); + }); + + test('POST /farm succeeds with admin key', async () => { + const res = await authRequest('POST', '/farm', { + name: 'Admin Farm', location: 'San Salvador', altitude_m: 800, owner_name: 'Admin', + }, 'test-admin-key'); + assert.equal(res.status, 201); + }); + + // -- POST /worker requires foreman + test('POST /worker returns 401 without token', async () => { + const res = await authRequest('POST', '/worker', { + farm_id: farmId, name: 'Ghost Worker', + }); + assert.equal(res.status, 401); + }); + + test('POST /worker returns 403 with worker key', async () => { + const res = await authRequest('POST', '/worker', { + farm_id: farmId, name: 'Ghost Worker', + }, 'test-worker-key'); + assert.equal(res.status, 403); + }); + + // -- GET endpoints remain open even with auth enabled + test('GET /health accessible without token (auth enabled)', async () => { + const res = await authRequest('GET', '/health'); + assert.equal(res.status, 200); + }); + + test('GET /farm accessible without token (auth enabled)', async () => { + const res = await authRequest('GET', '/farm'); + assert.equal(res.status, 200); + }); + + test('GET /worker accessible without token (auth enabled)', async () => { + const res = await authRequest('GET', '/worker'); + assert.equal(res.status, 200); + }); }); // ------------------------------------------------------------------ Feature: Configurable pay rates (#20)