Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
141 changes: 141 additions & 0 deletions backend/test/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading