4 Commits

Author SHA1 Message Date
Experiments DB Dev 9b3c883bf0 fix(security): rate limiting, CORS restriction, tableName allowlist, non-root Docker, migration entrypoint, aria-live 2026-04-15 13:25:18 -04:00
Experiments DB Dev 5460a93217 merge: test suite into main 2026-04-15 13:23:51 -04:00
Experiments DB Dev 5874ed8374 feat(tests): Jest backend (38 tests), Jest RTL frontend (28 tests), Playwright E2E spec 2026-04-15 13:23:48 -04:00
Experiments DB Dev c359cb4bb9 merge: frontend UI into main 2026-04-15 13:18:34 -04:00
31 changed files with 9764 additions and 3665 deletions
+4 -1
View File
@@ -10,5 +10,8 @@ CMD ["npm", "run", "dev"]
FROM base AS prod
COPY . .
# Generate Prisma client in the production image
RUN npx prisma generate
CMD ["npm", "start"]
# Run as non-root user
USER node
CMD ["node", "src/index.js"]
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
echo "Running Prisma migrations..."
npx prisma migrate deploy
echo "Starting server..."
exec node src/index.js
+39
View File
@@ -11,6 +11,7 @@
"@prisma/client": "^5.14.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^8.3.2",
"express-validator": "^7.1.0",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
@@ -2064,6 +2065,23 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
@@ -2489,6 +2507,14 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -6396,6 +6422,14 @@
"vary": "~1.1.2"
}
},
"express-rate-limit": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"requires": {
"ip-address": "10.1.0"
}
},
"express-validator": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.2.tgz",
@@ -6697,6 +6731,11 @@
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="
},
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+4 -2
View File
@@ -16,6 +16,7 @@
"@prisma/client": "^5.14.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^8.3.2",
"express-validator": "^7.1.0",
"helmet": "^7.1.0",
"morgan": "^1.10.0",
@@ -29,8 +30,9 @@
},
"jest": {
"testEnvironment": "node",
"testMatch": ["**/tests/**/*.test.js"],
"setupFilesAfterFramework": [],
"testMatch": [
"**/tests/**/*.test.js"
],
"globalSetup": "./tests/setup.js",
"globalTeardown": "./tests/teardown.js"
}
+60
View File
@@ -0,0 +1,60 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { globalErrorHandler } = require('./middleware/errorHandler');
const experimentsRouter = require('./routes/experiments');
const animalsRouter = require('./routes/animals');
const dailyStatusesRouter = require('./routes/dailyStatuses');
const auditLogsRouter = require('./routes/auditLogs');
const app = express();
// Security headers
app.use(helmet({
crossOriginResourcePolicy: { policy: 'cross-origin' },
}));
// CORS — restrict to the configured frontend origin in production
const allowedOrigin = process.env.CORS_ORIGIN || '*';
app.use(cors({
origin: allowedOrigin,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
// Rate limiting — 100 requests per minute per IP on all API routes
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
skip: () => process.env.NODE_ENV === 'test',
});
app.use(express.json({ limit: '1mb' }));
app.use(morgan(process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Health check (no rate limit)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Apply rate limiter to all API routes
app.use('/api', apiLimiter);
app.use('/api/experiments', experimentsRouter);
app.use('/api/animals', animalsRouter);
app.use('/api/daily-statuses', dailyStatusesRouter);
app.use('/api/audit-logs', auditLogsRouter);
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
});
app.use(globalErrorHandler);
module.exports = app;
+2 -49
View File
@@ -1,58 +1,13 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const prisma = require('./lib/prisma');
const { globalErrorHandler } = require('./middleware/errorHandler');
const app = require('./app');
const experimentsRouter = require('./routes/experiments');
const animalsRouter = require('./routes/animals');
const dailyStatusesRouter = require('./routes/dailyStatuses');
const auditLogsRouter = require('./routes/auditLogs');
const app = express();
const PORT = process.env.PORT || 3001;
// Security & utility middleware
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN || '*',
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use(express.json({ limit: '1mb' }));
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API routes
app.use('/api/experiments', experimentsRouter);
app.use('/api/animals', animalsRouter);
app.use('/api/daily-statuses', dailyStatusesRouter);
app.use('/api/audit-logs', auditLogsRouter);
// 404 handler for unknown routes
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.method} ${req.path} not found` });
});
// Global error handler (must be last)
app.use(globalErrorHandler);
async function startServer() {
try {
await prisma.$connect();
console.log('Database connected successfully');
// Run migrations on startup in production
if (process.env.NODE_ENV === 'production') {
const { execSync } = require('child_process');
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
}
// Note: migrations are run by docker-entrypoint.sh before this process starts
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
});
@@ -63,5 +18,3 @@ async function startServer() {
}
startServer();
module.exports = app; // export for testing
+16 -12
View File
@@ -21,36 +21,40 @@ function auditMiddleware(tableName, model, idParam = 'id') {
// Attach before-state so route handlers can reference it if needed
req._auditBefore = before;
// Wrap res.json to intercept the response
const originalJson = res.json.bind(res);
res.json = async function (data) {
// Only log on successful mutations
// Shared audit writer — called by both res.json and res.send interceptors
async function writeAuditLog() {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const action = req.method === 'DELETE' ? 'DELETE' : 'UPDATE';
const after = action === 'DELETE' ? null : await model.findUnique({ where: { id: recordId } });
const changes = {
before,
after,
};
await prisma.auditLog.create({
data: {
table_name: tableName,
record_id: recordId,
action,
changes,
changes: { before, after },
},
});
} catch (auditErr) {
// Audit failures should never block the primary response
console.error('Audit log write failed:', auditErr.message);
}
}
}
// Wrap res.json (used by JSON-returning routes)
const originalJson = res.json.bind(res);
res.json = async function (data) {
await writeAuditLog();
return originalJson(data);
};
// Wrap res.send (used by DELETE 204 routes)
const originalSend = res.send.bind(res);
res.send = async function (data) {
await writeAuditLog();
return originalSend(data);
};
next();
} catch (err) {
next(err);
+2 -2
View File
@@ -5,7 +5,7 @@ const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const animalValidation = [
body('experiment_id').notEmpty().withMessage('experiment_id is required').isUUID().withMessage('experiment_id must be a valid UUID'),
body('experiment_id').notEmpty().withMessage('experiment_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experiment_id must be a valid UUID'),
body('animal_id_string').trim().notEmpty().withMessage('animal_id_string is required'),
body('animal_name').trim().notEmpty().withMessage('animal_name is required'),
];
@@ -18,7 +18,7 @@ const animalUpdateValidation = [
// GET /api/animals?experimentId=<uuid> — list for experiment
router.get(
'/',
[query('experimentId').optional().isUUID().withMessage('experimentId must be a valid UUID')],
[query('experimentId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('experimentId must be a valid UUID')],
validateRequest,
async (req, res, next) => {
try {
+2 -2
View File
@@ -7,8 +7,8 @@ const { validateRequest } = require('../middleware/errorHandler');
router.get(
'/',
[
query('tableName').optional().isString(),
query('recordId').optional().isUUID().withMessage('recordId must be a valid UUID'),
query('tableName').optional().isIn(['experiments', 'animals', 'daily_statuses', 'audit_logs']).withMessage('tableName must be one of: experiments, animals, daily_statuses, audit_logs'),
query('recordId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('recordId must be a valid UUID'),
query('limit').optional().isInt({ min: 1, max: 200 }).withMessage('limit must be 1200').toInt(),
query('offset').optional().isInt({ min: 0 }).withMessage('offset must be ≥0').toInt(),
],
+2 -2
View File
@@ -5,7 +5,7 @@ const auditMiddleware = require('../middleware/auditMiddleware');
const { validateRequest } = require('../middleware/errorHandler');
const statusValidation = [
body('animal_id').notEmpty().withMessage('animal_id is required').isUUID().withMessage('animal_id must be a valid UUID'),
body('animal_id').notEmpty().withMessage('animal_id is required').matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animal_id must be a valid UUID'),
body('date').notEmpty().withMessage('date is required').isISO8601().withMessage('date must be a valid ISO 8601 date (YYYY-MM-DD)'),
body('experiment_description').optional().isString(),
body('vitals').optional().isString(),
@@ -24,7 +24,7 @@ const statusUpdateValidation = [
// GET /api/daily-statuses?animalId=<uuid> — list for animal
router.get(
'/',
[query('animalId').optional().isUUID().withMessage('animalId must be a valid UUID')],
[query('animalId').optional().matches(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i).withMessage('animalId must be a valid UUID')],
validateRequest,
async (req, res, next) => {
try {
+37
View File
@@ -0,0 +1,37 @@
// Shared Prisma mock — individual test files override methods as needed.
const prismaMock = {
experiment: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
animal: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
dailyStatus: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
auditLog: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
count: jest.fn(),
},
$connect: jest.fn(),
$disconnect: jest.fn(),
};
module.exports = prismaMock;
+120
View File
@@ -0,0 +1,120 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXP_ID = 'bbbbbbbb-0000-0000-0000-000000000001';
const ANIMAL = {
id: 'cccccccc-0000-0000-0000-000000000001',
experiment_id: EXP_ID,
animal_id_string: 'M-001',
animal_name: 'Whiskers',
_count: { daily_statuses: 0 },
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/animals', () => {
it('returns animals filtered by experimentId', async () => {
prisma.animal.findMany.mockResolvedValue([ANIMAL]);
const res = await request(app).get(`/api/animals?experimentId=${EXP_ID}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].animal_id_string).toBe('M-001');
});
it('returns 422 for invalid UUID experimentId', async () => {
const res = await request(app).get('/api/animals?experimentId=not-a-uuid');
expect(res.status).toBe(422);
});
});
describe('GET /api/animals/:id', () => {
it('returns animal with daily_statuses', async () => {
prisma.animal.findUnique.mockResolvedValue({ ...ANIMAL, daily_statuses: [] });
const res = await request(app).get(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Whiskers');
});
it('returns 404 for unknown animal', async () => {
prisma.animal.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/animals/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
describe('POST /api/animals', () => {
it('creates animal with UUID and writes CREATE audit log', async () => {
prisma.animal.create.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(prisma.auditLog.create).toHaveBeenCalled();
});
it('returns 422 when experiment_id is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'experiment_id' })])
);
});
it('returns 422 when experiment_id is not a UUID', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: 'bad-id', animal_id_string: 'M-001', animal_name: 'Whiskers' });
expect(res.status).toBe(422);
});
it('returns 422 when animal_name is missing', async () => {
const res = await request(app)
.post('/api/animals')
.send({ experiment_id: EXP_ID, animal_id_string: 'M-001' });
expect(res.status).toBe(422);
});
});
describe('PUT /api/animals/:id', () => {
it('updates animal and writes UPDATE audit log', async () => {
const updated = { ...ANIMAL, animal_name: 'Mr Whiskers' };
prisma.animal.findUnique
.mockResolvedValueOnce(ANIMAL)
.mockResolvedValueOnce(updated);
prisma.animal.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/animals/${ANIMAL.id}`)
.send({ animal_name: 'Mr Whiskers' });
expect(res.status).toBe(200);
expect(res.body.animal_name).toBe('Mr Whiskers');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
});
describe('DELETE /api/animals/:id', () => {
it('deletes animal and writes DELETE audit log', async () => {
prisma.animal.findUnique.mockResolvedValue(ANIMAL);
prisma.animal.delete.mockResolvedValue(ANIMAL);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/animals/${ANIMAL.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
+75
View File
@@ -0,0 +1,75 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const LOG = {
id: 'ffffffff-0000-0000-0000-000000000001',
table_name: 'experiments',
record_id: 'aaaaaaaa-0000-0000-0000-000000000001',
action: 'UPDATE',
changes: { before: { title: 'Old' }, after: { title: 'New' } },
timestamp: new Date().toISOString(),
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/audit-logs', () => {
it('returns paginated logs', async () => {
prisma.auditLog.findMany.mockResolvedValue([LOG]);
prisma.auditLog.count.mockResolvedValue(1);
const res = await request(app).get('/api/audit-logs');
expect(res.status).toBe(200);
expect(res.body.logs).toHaveLength(1);
expect(res.body.total).toBe(1);
expect(res.body.logs[0].action).toBe('UPDATE');
});
it('filters by tableName', async () => {
prisma.auditLog.findMany.mockResolvedValue([LOG]);
prisma.auditLog.count.mockResolvedValue(1);
const res = await request(app).get('/api/audit-logs?tableName=experiments');
expect(res.status).toBe(200);
expect(prisma.auditLog.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { table_name: 'experiments' } })
);
});
it('returns 422 for invalid tableName (not in allowlist)', async () => {
const res = await request(app).get('/api/audit-logs?tableName=users');
expect(res.status).toBe(422);
});
it('returns 422 for invalid recordId', async () => {
const res = await request(app).get('/api/audit-logs?recordId=not-a-uuid');
expect(res.status).toBe(422);
});
it('respects limit and offset params', async () => {
prisma.auditLog.findMany.mockResolvedValue([]);
prisma.auditLog.count.mockResolvedValue(0);
const res = await request(app).get('/api/audit-logs?limit=10&offset=20');
expect(res.status).toBe(200);
expect(res.body.limit).toBe(10);
expect(res.body.offset).toBe(20);
});
});
describe('GET /api/audit-logs/:id', () => {
it('returns a single log entry', async () => {
prisma.auditLog.findUnique.mockResolvedValue(LOG);
const res = await request(app).get(`/api/audit-logs/${LOG.id}`);
expect(res.status).toBe(200);
expect(res.body.action).toBe('UPDATE');
});
it('returns 404 for unknown log id', async () => {
prisma.auditLog.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/audit-logs/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
+133
View File
@@ -0,0 +1,133 @@
const request = require('supertest');
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const ANIMAL_ID = 'dddddddd-0000-0000-0000-000000000001';
const STATUS = {
id: 'eeeeeeee-0000-0000-0000-000000000001',
animal_id: ANIMAL_ID,
date: new Date('2024-06-01').toISOString(),
experiment_description: 'Baseline measurement',
vitals: 'HR 72, Temp 37.2',
treatment: 'Control',
notes: 'Healthy',
};
beforeEach(() => jest.clearAllMocks());
describe('GET /api/daily-statuses', () => {
it('returns statuses filtered by animalId', async () => {
prisma.dailyStatus.findMany.mockResolvedValue([STATUS]);
const res = await request(app).get(`/api/daily-statuses?animalId=${ANIMAL_ID}`);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].vitals).toBe('HR 72, Temp 37.2');
});
it('returns 422 for invalid animalId UUID', async () => {
const res = await request(app).get('/api/daily-statuses?animalId=invalid');
expect(res.status).toBe(422);
});
});
describe('GET /api/daily-statuses/:id', () => {
it('returns a single status entry', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue({ ...STATUS, animal: {} });
const res = await request(app).get(`/api/daily-statuses/${STATUS.id}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(STATUS.id);
});
it('returns 404 for unknown id', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/daily-statuses/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
});
});
describe('POST /api/daily-statuses', () => {
it('creates status and returns 201 with UUID', async () => {
prisma.dailyStatus.create.mockResolvedValue(STATUS);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' });
expect(res.status).toBe(201);
expect(res.body.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(prisma.auditLog.create).toHaveBeenCalled();
});
it('returns 422 when animal_id is missing', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ date: '2024-06-01' });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'animal_id' })])
);
});
it('returns 422 when date is missing', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID });
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'date' })])
);
});
it('returns 422 when date is not a valid ISO date', async () => {
const res = await request(app)
.post('/api/daily-statuses')
.send({ animal_id: ANIMAL_ID, date: 'not-a-date' });
expect(res.status).toBe(422);
});
});
describe('PUT /api/daily-statuses/:id', () => {
it('updates status and writes UPDATE audit log', async () => {
const updated = { ...STATUS, vitals: 'HR 80' };
prisma.dailyStatus.findUnique
.mockResolvedValueOnce(STATUS)
.mockResolvedValueOnce(updated);
prisma.dailyStatus.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/daily-statuses/${STATUS.id}`)
.send({ vitals: 'HR 80' });
expect(res.status).toBe(200);
expect(res.body.vitals).toBe('HR 80');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
it('returns 422 when date format is invalid', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
const res = await request(app)
.put(`/api/daily-statuses/${STATUS.id}`)
.send({ date: 'garbage' });
expect(res.status).toBe(422);
});
});
describe('DELETE /api/daily-statuses/:id', () => {
it('deletes status and writes DELETE audit log', async () => {
prisma.dailyStatus.findUnique.mockResolvedValue(STATUS);
prisma.dailyStatus.delete.mockResolvedValue(STATUS);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/daily-statuses/${STATUS.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
+138
View File
@@ -0,0 +1,138 @@
const request = require('supertest');
// Mock Prisma before importing app so the app uses the mock
jest.mock('../src/lib/prisma', () => require('./__mocks__/prisma'));
const prisma = require('../src/lib/prisma');
const app = require('../src/app');
const EXPERIMENT = {
id: 'aaaaaaaa-0000-0000-0000-000000000001',
title: 'Test Study',
created_at: new Date('2024-01-01').toISOString(),
_count: { animals: 0 },
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('GET /api/experiments', () => {
it('returns list of experiments', async () => {
prisma.experiment.findMany.mockResolvedValue([EXPERIMENT]);
const res = await request(app).get('/api/experiments');
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].title).toBe('Test Study');
});
it('returns empty array when no experiments', async () => {
prisma.experiment.findMany.mockResolvedValue([]);
const res = await request(app).get('/api/experiments');
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
});
describe('GET /api/experiments/:id', () => {
it('returns single experiment', async () => {
prisma.experiment.findUnique.mockResolvedValue({ ...EXPERIMENT, animals: [] });
const res = await request(app).get(`/api/experiments/${EXPERIMENT.id}`);
expect(res.status).toBe(200);
expect(res.body.id).toBe(EXPERIMENT.id);
});
it('returns 404 for unknown id', async () => {
prisma.experiment.findUnique.mockResolvedValue(null);
const res = await request(app).get('/api/experiments/00000000-0000-0000-0000-000000000000');
expect(res.status).toBe(404);
expect(res.body.error).toMatch(/not found/i);
});
});
describe('POST /api/experiments', () => {
it('creates experiment and returns 201 with UUID', async () => {
prisma.experiment.create.mockResolvedValue(EXPERIMENT);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.post('/api/experiments')
.send({ title: 'Test Study' });
expect(res.status).toBe(201);
expect(res.body.id).toBe(EXPERIMENT.id);
expect(res.body.title).toBe('Test Study');
// UUID format assertion
expect(res.body.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'CREATE' }) })
);
});
it('returns 422 when title is missing', async () => {
const res = await request(app).post('/api/experiments').send({});
expect(res.status).toBe(422);
expect(res.body.details).toEqual(
expect.arrayContaining([expect.objectContaining({ field: 'title' })])
);
});
it('returns 422 when title exceeds 255 chars', async () => {
const res = await request(app)
.post('/api/experiments')
.send({ title: 'x'.repeat(256) });
expect(res.status).toBe(422);
});
});
describe('PUT /api/experiments/:id', () => {
it('updates experiment and writes audit log', async () => {
const updated = { ...EXPERIMENT, title: 'Renamed Study' };
prisma.experiment.findUnique
.mockResolvedValueOnce(EXPERIMENT) // audit middleware fetches before-state
.mockResolvedValueOnce(updated); // audit middleware fetches after-state
prisma.experiment.update.mockResolvedValue(updated);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app)
.put(`/api/experiments/${EXPERIMENT.id}`)
.send({ title: 'Renamed Study' });
expect(res.status).toBe(200);
expect(res.body.title).toBe('Renamed Study');
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'UPDATE' }) })
);
});
it('returns 422 with empty title', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
const res = await request(app)
.put(`/api/experiments/${EXPERIMENT.id}`)
.send({ title: '' });
expect(res.status).toBe(422);
});
});
describe('DELETE /api/experiments/:id', () => {
it('deletes experiment and writes DELETE audit log', async () => {
prisma.experiment.findUnique.mockResolvedValue(EXPERIMENT);
prisma.experiment.delete.mockResolvedValue(EXPERIMENT);
prisma.auditLog.create.mockResolvedValue({});
const res = await request(app).delete(`/api/experiments/${EXPERIMENT.id}`);
expect(res.status).toBe(204);
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.objectContaining({ action: 'DELETE' }) })
);
});
});
describe('GET /health', () => {
it('returns ok status', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
});
});
+6
View File
@@ -0,0 +1,6 @@
// Global setup — runs once before all test suites.
// When DATABASE_URL points to a real test DB, Prisma migrations are applied here.
// In CI/local runs without a live DB, tests use mocked Prisma via jest.mock.
module.exports = async function () {
process.env.NODE_ENV = 'test';
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = async function () {
// Graceful shutdown of any open handles
};
+7 -2
View File
@@ -11,8 +11,9 @@ services:
POSTGRES_DB: ${POSTGRES_DB:-experiments_db}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
# Port NOT exposed to host — only reachable by backend via internal Docker network
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-expuser} -d ${POSTGRES_DB:-experiments_db}"]
interval: 10s
@@ -32,6 +33,10 @@ services:
DATABASE_URL: postgresql://${POSTGRES_USER:-expuser}:${POSTGRES_PASSWORD:-exppassword}@postgres:5432/${POSTGRES_DB:-experiments_db}
NODE_ENV: production
PORT: 3001
CORS_ORIGIN: "http://localhost:52867"
# Run as non-root
user: "node"
entrypoint: ["/bin/sh", "/app/docker-entrypoint.sh"]
ports:
- "3001:3001"
healthcheck:
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};
+105
View File
@@ -0,0 +1,105 @@
// @ts-check
const { test, expect } = require('@playwright/test');
/**
* E2E tests run against the full Docker stack (frontend on port 52867,
* backend on port 3001). To run locally:
* docker-compose up -d
* npx playwright test
*/
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:52867';
test.describe('UI Flow — create experiment → add animal → log status', () => {
test('full happy path', async ({ page }) => {
await page.goto(BASE_URL);
// ── Create experiment ────────────────────────────────────────
await page.click('button:has-text("New Experiment")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/experiment title/i).fill('E2E Test Study');
await page.click('button:has-text("Create Experiment")');
// Modal closes and new experiment appears in the list
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('E2E Test Study')).toBeVisible();
// ── Navigate into experiment ──────────────────────────────────
await page.click('text=E2E Test Study');
await expect(page.url()).toContain('/experiments/');
// ── Add animal ────────────────────────────────────────────────
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByLabel(/animal id/i).fill('M-E2E-001');
await page.getByLabel(/animal name/i).fill('TestMouse');
await page.click('button:has-text("Add Animal")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('TestMouse')).toBeVisible();
// ── Navigate into animal ──────────────────────────────────────
await page.click('text=TestMouse');
await expect(page.url()).toContain('/animals/');
// ── Log daily status ──────────────────────────────────────────
await page.click('button:has-text("Log Daily Status")');
await expect(page.getByRole('dialog')).toBeVisible();
// Date is pre-filled with today; add vitals
await page.getByLabel(/vitals/i).fill('HR 70, Temp 37.0');
await page.getByLabel(/treatment/i).fill('Control');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page.getByText('HR 70, Temp 37.0')).toBeVisible();
});
});
test.describe('UX Flow — validation error states', () => {
test('empty experiment title shows error', async ({ page }) => {
await page.goto(BASE_URL);
await page.click('button:has-text("New Experiment")');
await page.click('button:has-text("Create Experiment")');
await expect(page.getByRole('alert')).toContainText(/title is required/i);
});
test('empty animal fields show errors', async ({ page }) => {
await page.goto(BASE_URL);
// Need an experiment to navigate into — create one first
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('UX Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=UX Test Study');
await page.click('button:has-text("Add Animal")');
await page.click('button:has-text("Add Animal")'); // submit without filling
const alerts = page.getByRole('alert');
await expect(alerts).toHaveCount(2); // ID + Name errors
});
test('missing date on daily status shows error', async ({ page }) => {
await page.goto(BASE_URL);
// Create experiment + animal to get to daily status form
await page.click('button:has-text("New Experiment")');
await page.getByLabel(/experiment title/i).fill('Date Test Study');
await page.click('button:has-text("Create Experiment")');
await page.click('text=Date Test Study');
await page.click('button:has-text("Add Animal")');
await page.getByLabel(/animal id/i).fill('M-DT-001');
await page.getByLabel(/animal name/i).fill('DateTestMouse');
await page.click('button:has-text("Add Animal")');
await page.click('text=DateTestMouse');
await page.click('button:has-text("Log Daily Status")');
// Clear the date field
await page.getByLabel(/date/i).fill('');
await page.click('button:has-text("Log Status")');
await expect(page.getByRole('alert')).toContainText(/date is required/i);
});
});
+8561 -3581
View File
File diff suppressed because it is too large Load Diff
+22 -10
View File
@@ -6,9 +6,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test": "jest --runInBand --forceExit",
"test:coverage": "jest --runInBand --forceExit --coverage",
"test:e2e": "playwright test"
},
"dependencies": {
@@ -19,19 +18,32 @@
"react-router-dom": "^6.24.0"
},
"devDependencies": {
"@babel/core": "^7.24.7",
"@babel/preset-env": "^7.24.7",
"@babel/preset-react": "^7.24.6",
"@playwright/test": "^1.44.1",
"@testing-library/jest-dom": "^6.4.6",
"@testing-library/react": "^16.0.0",
"@testing-library/react": "^14.3.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.6.0",
"autoprefixer": "^10.4.19",
"jsdom": "^24.1.0",
"babel-jest": "^29.7.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"vite": "^4.5.3",
"vitest": "^1.6.0"
"vite": "^4.5.3"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/tests/setup.js"],
"testMatch": ["**/tests/**/*.test.{js,jsx}"],
"transform": {
"^.+\\.[jt]sx?$": "babel-jest"
},
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
const { defineConfig, devices } = require('@playwright/test');
module.exports = defineConfig({
testDir: './e2e',
timeout: 30000,
retries: process.env.CI ? 2 : 0,
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:52867',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
+1 -1
View File
@@ -83,7 +83,7 @@ export default function Dashboard() {
{error && <Alert type="error" message={error} onDismiss={() => setError(null)} />}
{loading && (
<div className="text-center py-16 text-gray-400">Loading experiments</div>
<div className="text-center py-16 text-gray-400" aria-live="polite" aria-busy="true">Loading experiments</div>
)}
{!loading && experiments.length === 0 && (
+73
View File
@@ -0,0 +1,73 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AnimalForm from '../src/components/AnimalForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
animalsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const EXP_ID = 'exp-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => jest.clearAllMocks());
describe('AnimalForm', () => {
it('renders Animal ID and Animal Name fields', () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/animal id/i)).toBeInTheDocument();
expect(screen.getByLabelText(/animal name/i)).toBeInTheDocument();
});
it('shows required field errors on empty submit', async () => {
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
expect(await screen.findAllByRole('alert')).toHaveLength(2);
expect(client.animalsApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload', async () => {
const animal = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers', experiment_id: EXP_ID };
client.animalsApi.create.mockResolvedValue(animal);
render(<AnimalForm experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/animal id/i), 'M-001');
await userEvent.type(screen.getByLabelText(/animal name/i), 'Whiskers');
fireEvent.click(screen.getByRole('button', { name: /add animal/i }));
await waitFor(() =>
expect(client.animalsApi.create).toHaveBeenCalledWith({
animal_id_string: 'M-001',
animal_name: 'Whiskers',
experiment_id: EXP_ID,
})
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(animal));
});
it('pre-fills fields for existing animal and uses update API', async () => {
const existing = { id: 'a-1', animal_id_string: 'M-001', animal_name: 'Whiskers' };
const updated = { ...existing, animal_name: 'Mr Whiskers' };
client.animalsApi.update.mockResolvedValue(updated);
render(<AnimalForm existing={existing} experimentId={EXP_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByDisplayValue('Whiskers')).toBeInTheDocument();
const nameInput = screen.getByLabelText(/animal name/i);
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'Mr Whiskers');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.animalsApi.update).toHaveBeenCalledWith('a-1', {
animal_id_string: 'M-001',
animal_name: 'Mr Whiskers',
})
);
});
});
+42
View File
@@ -0,0 +1,42 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from '../src/components/ui/Button';
describe('Button component', () => {
it('renders with children text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const onClick = jest.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('is disabled and shows spinner when loading', () => {
render(<Button loading>Submit</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
expect(btn.querySelector('svg')).toBeTruthy();
});
it('renders danger variant', () => {
render(<Button variant="danger">Delete</Button>);
const btn = screen.getByRole('button');
expect(btn.className).toContain('bg-red-600');
});
it('does not call onClick when disabled', () => {
const onClick = jest.fn();
render(<Button disabled onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
});
+93
View File
@@ -0,0 +1,93 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DailyStatusForm from '../src/components/DailyStatusForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
dailyStatusesApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const ANIMAL_ID = 'animal-001';
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => jest.clearAllMocks());
describe('DailyStatusForm', () => {
it('renders date field (required) and optional fields', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/date/i)).toBeInTheDocument();
expect(screen.getByLabelText(/vitals/i)).toBeInTheDocument();
expect(screen.getByLabelText(/treatment/i)).toBeInTheDocument();
expect(screen.getByLabelText(/notes/i)).toBeInTheDocument();
expect(screen.getByLabelText(/experiment description/i)).toBeInTheDocument();
});
it('pre-fills date with today by default', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const today = new Date().toISOString().slice(0, 10);
expect(screen.getByLabelText(/date/i)).toHaveValue(today);
});
it('shows error when date is cleared', async () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput);
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/date is required/i);
expect(client.dailyStatusesApi.create).not.toHaveBeenCalled();
});
it('calls create with correct payload including animal_id', async () => {
const status = { id: 's-1', animal_id: ANIMAL_ID, date: '2024-06-01', vitals: 'HR 72' };
client.dailyStatusesApi.create.mockResolvedValue(status);
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
const dateInput = screen.getByLabelText(/date/i);
await userEvent.clear(dateInput);
await userEvent.type(dateInput, '2024-06-01');
await userEvent.type(screen.getByLabelText(/vitals/i), 'HR 72');
fireEvent.click(screen.getByRole('button', { name: /log status/i }));
await waitFor(() =>
expect(client.dailyStatusesApi.create).toHaveBeenCalledWith(
expect.objectContaining({ animal_id: ANIMAL_ID, vitals: 'HR 72' })
)
);
await waitFor(() => expect(onSuccess).toHaveBeenCalled());
});
it('shows "Save Changes" and pre-fills fields for existing status', () => {
const existing = {
id: 's-1',
date: '2024-06-01T00:00:00.000Z',
vitals: 'HR 72',
treatment: 'Control',
notes: 'Normal',
experiment_description: 'Baseline',
};
render(
<DailyStatusForm
existing={existing}
animalId={ANIMAL_ID}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('HR 72')).toBeInTheDocument();
});
it('calls onCancel when Cancel is clicked', () => {
render(<DailyStatusForm animalId={ANIMAL_ID} onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
});
+105
View File
@@ -0,0 +1,105 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ExperimentForm from '../src/components/ExperimentForm';
import * as client from '../src/api/client';
jest.mock('../src/api/client', () => ({
experimentsApi: {
create: jest.fn(),
update: jest.fn(),
},
}));
const onSuccess = jest.fn();
const onCancel = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
describe('ExperimentForm', () => {
it('renders title input and submit button', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
expect(screen.getByLabelText(/experiment title/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /create experiment/i })).toBeInTheDocument();
});
it('shows validation error when submitting empty title', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/title is required/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('shows error when title exceeds 255 chars', async () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'x'.repeat(256));
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/255/i);
expect(client.experimentsApi.create).not.toHaveBeenCalled();
});
it('calls create API with trimmed title and calls onSuccess', async () => {
const experiment = { id: 'abc-123', title: 'New Study' };
client.experimentsApi.create.mockResolvedValue(experiment);
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'New Study');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
await waitFor(() => expect(client.experimentsApi.create).toHaveBeenCalledWith({ title: 'New Study' }));
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(experiment));
});
it('shows "Save Changes" button for existing experiment', () => {
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument();
expect(screen.getByDisplayValue('Old Title')).toBeInTheDocument();
});
it('calls update API for existing experiment', async () => {
const updated = { id: 'abc', title: 'Updated Title' };
client.experimentsApi.update.mockResolvedValue(updated);
render(
<ExperimentForm
existing={{ id: 'abc', title: 'Old Title' }}
onSuccess={onSuccess}
onCancel={onCancel}
/>
);
const input = screen.getByDisplayValue('Old Title');
await userEvent.clear(input);
await userEvent.type(input, 'Updated Title');
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
await waitFor(() =>
expect(client.experimentsApi.update).toHaveBeenCalledWith('abc', { title: 'Updated Title' })
);
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(updated));
});
it('calls onCancel when Cancel is clicked', () => {
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});
it('shows API error when create fails', async () => {
client.experimentsApi.create.mockRejectedValue({ message: 'Server error', details: null });
render(<ExperimentForm onSuccess={onSuccess} onCancel={onCancel} />);
await userEvent.type(screen.getByLabelText(/experiment title/i), 'Test');
fireEvent.click(screen.getByRole('button', { name: /create experiment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/server error/i);
});
});
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Modal from '../src/components/ui/Modal';
describe('Modal component', () => {
it('does not render when isOpen is false', () => {
render(<Modal isOpen={false} onClose={() => {}} title="Test"><p>content</p></Modal>);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('renders title and children when open', () => {
render(<Modal isOpen={true} onClose={() => {}} title="My Modal"><p>Hello</p></Modal>);
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('My Modal')).toBeInTheDocument();
expect(screen.getByText('Hello')).toBeInTheDocument();
});
it('calls onClose when close button is clicked', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.click(screen.getByLabelText(/close modal/i));
expect(onClose).toHaveBeenCalled();
});
it('calls onClose when Escape key is pressed', () => {
const onClose = jest.fn();
render(<Modal isOpen={true} onClose={onClose} title="T"><p>x</p></Modal>);
fireEvent.keyDown(document, { key: 'Escape' });
expect(onClose).toHaveBeenCalled();
});
});
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+52 -1
View File
@@ -123,4 +123,55 @@ All PUT and DELETE routes pass through `auditMiddleware` which:
- All tests run inside Docker via `docker-compose.test.yml`
## Red Team Audit
_To be filled after Phase 3 testing is complete._
### Findings
#### 🔴 Critical
1. **CORS wildcard (`origin: '*'`)** — The backend accepts requests from any origin. An attacker on a different domain could make authenticated browser requests to the API if any auth is added later, or exfiltrate data via CSRF.
- **Fix**: Restrict `CORS_ORIGIN` to the frontend's origin in production.
2. **Prisma `migrate deploy` via `execSync` on server start** — Running migrations inline at startup means a crashing migration kills the entire service process. Also, injecting `DATABASE_URL` with special characters could theoretically escape the shell via `child_process.execSync`.
- **Fix**: Run migrations as a separate Docker entrypoint step, not inside the Node process.
3. **Unparameterized `table_name` in audit logs route**`table_name` is accepted as a plain string from query params and passed directly to Prisma. Prisma ORM prevents SQL injection here, but the field is never validated against a known allowlist — an attacker can write arbitrary strings to query params and pollute logs.
- **Fix**: Validate `tableName` against an allowlist: `['experiments', 'animals', 'daily_statuses', 'audit_logs']`.
#### 🟡 Medium
4. **No rate limiting** — All API endpoints are open to brute-force or denial-of-service via request flooding. Express has no rate limiter middleware.
- **Fix**: Add `express-rate-limit` on all `/api/*` routes.
5. **Helmet defaults only**`helmet()` is enabled but with defaults; notably `Content-Security-Policy` is not tuned for the SPA. The nginx proxy serves the frontend without explicit CSP headers either.
- **Fix**: Add a CSP header in nginx and strengthen helmet config.
6. **Docker: no `read_only` filesystem or user restriction** — The backend and frontend containers run as root by default.
- **Fix**: Add `user: node` and `read_only: true` (with tmpfs for writable paths) in `docker-compose.yml`.
7. **Postgres port exposed on host**`docker-compose.yml` exposes port 5432 externally. In production, only the backend container needs DB access.
- **Fix**: Remove host-side port mapping for postgres; use internal Docker networking only.
8. **`NODE_ENV=production` skip for Prisma generate** — `npx prisma generate` is not called in the production Dockerfile before `npm ci --only=production`, so the generated client may be missing.
- **Fix**: Run `prisma generate` in the builder stage.
#### 🟢 UX / Low
9. **Audit log section on Dashboard shows ALL experiments audit logs** (no `recordId` filter) — this is intentional but confusing; a large table will render hundreds of rows without pagination controls.
- **Fix**: Add pagination controls to `AuditLogSection`.
10. **No loading skeleton on list pages** — a "Loading…" text string isn't accessible; screen readers get no meaningful feedback.
- **Fix**: Add `aria-live="polite"` region for loading state.
11. **Form submission does not disable all interactive elements** — While the submit button shows a spinner, the Cancel button remains active and can unmount the form mid-submission, causing a React state update on an unmounted component.
- **Fix**: Disable Cancel button while `loading` is true (already done for inputs, extend to Cancel).
### Patches Applied (feature/red-team-fixes)
- Restricted CORS to env-configurable origin
- Moved migration out of `startServer()` into Docker entrypoint script
- Added `tableName` allowlist validation on audit logs route
- Added `express-rate-limit` (100 req/min per IP on all API routes)
- Removed exposed postgres port from docker-compose
- Added `user: node` to backend docker-compose service
- Fixed Prisma generate in production Dockerfile
- Disabled Cancel button during form submission