diff --git a/app.js b/app.js index 03695ca..6ea7cd6 100644 --- a/app.js +++ b/app.js @@ -26,6 +26,85 @@ function writeData(data) { fs.writeFileSync(dataFile, JSON.stringify(data, null, 2), 'utf8'); } +const HEX_COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; +const WRITE_WINDOW_MS = 60 * 1000; +const MAX_WRITES_PER_WINDOW = 120; +const writeRequests = new Map(); + +function validateProjectInput(input, { partial = false } = {}) { + const errors = []; + + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return ['Invalid request body']; + } + + if (!partial || Object.prototype.hasOwnProperty.call(input, 'name')) { + if (typeof input.name !== 'string' || input.name.trim().length < 2 || input.name.trim().length > 120) { + errors.push('name must be a string (2-120 chars)'); + } + } + + if (Object.prototype.hasOwnProperty.call(input, 'description')) { + if (typeof input.description !== 'string' || input.description.length > 4000) { + errors.push('description must be a string (max 4000 chars)'); + } + } + + if (Object.prototype.hasOwnProperty.call(input, 'docs')) { + if (typeof input.docs !== 'string' || input.docs.length > 100000) { + errors.push('docs must be a string (max 100000 chars)'); + } + } + + if (Object.prototype.hasOwnProperty.call(input, 'color')) { + if (typeof input.color !== 'string' || !HEX_COLOR_REGEX.test(input.color)) { + errors.push('color must be a valid hex color (#RRGGBB)'); + } + } + + if (Object.prototype.hasOwnProperty.call(input, 'projectPath')) { + if (typeof input.projectPath !== 'string' || input.projectPath.trim().length === 0 || input.projectPath.length > 1024) { + errors.push('projectPath must be a non-empty string (max 1024 chars)'); + } + } + + return errors; +} + +function writeRateLimit(req, res, next) { + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next(); + if (!req.path.startsWith('/api/')) return next(); + + const now = Date.now(); + const key = req.ip || req.socket?.remoteAddress || 'unknown'; + const entry = writeRequests.get(key) || { count: 0, resetAt: now + WRITE_WINDOW_MS }; + + if (now > entry.resetAt) { + entry.count = 0; + entry.resetAt = now + WRITE_WINDOW_MS; + } + + entry.count += 1; + writeRequests.set(key, entry); + + if (entry.count > MAX_WRITES_PER_WINDOW) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + res.set('Retry-After', String(retryAfter)); + return res.status(429).json({ error: 'Too many write requests. Please retry shortly.' }); + } + + next(); +} + +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of writeRequests.entries()) { + if (now > entry.resetAt) writeRequests.delete(key); + } +}, WRITE_WINDOW_MS).unref(); + +app.use(writeRateLimit); + // GET all projects app.get('/api/projects', (req, res) => { const data = readData(); @@ -34,27 +113,35 @@ app.get('/api/projects', (req, res) => { // POST new project app.post('/api/projects', (req, res) => { - const { name, description, docs } = req.body; + const { name, description, docs, color, projectPath } = req.body || {}; - if (!name) { - return res.status(400).json({ error: 'Project name required' }); + const inputErrors = validateProjectInput({ name, description, docs, color, projectPath }, { partial: false }); + if (inputErrors.length > 0) { + return res.status(400).json({ error: 'Validation failed', details: inputErrors }); } + const normalizedName = name.trim(); + const normalizedPath = typeof projectPath === 'string' ? projectPath.trim() : undefined; + const data = readData(); const newProject = { id: `proj-${uuidv4().slice(0, 8)}`, - name, + name: normalizedName, description: description || '', - docs: docs || '# ' + name, - color: `#${Math.floor(Math.random() * 16777215).toString(16)}`, + docs: docs || '# ' + normalizedName, + color: color || `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`, tasks: [], createdAt: new Date().toISOString() }; + if (normalizedPath) { + newProject.projectPath = normalizedPath; + } + data.projects.push(newProject); writeData(data); - console.log(`[PROJECT] Created: "${name}" (${newProject.id})`); + console.log(`[PROJECT] Created: "${normalizedName}" (${newProject.id})`); res.status(201).json(newProject); }); @@ -63,6 +150,26 @@ app.put('/api/projects/:id', (req, res) => { const { id } = req.params; const updates = req.body; + if (!updates || typeof updates !== 'object' || Array.isArray(updates)) { + return res.status(400).json({ error: 'Invalid request body' }); + } + + const allowedFields = ['name', 'description', 'docs', 'color', 'projectPath']; + const unknownFields = Object.keys(updates).filter(key => !allowedFields.includes(key)); + + if (unknownFields.length > 0) { + return res.status(400).json({ + error: 'Unsupported project field(s)', + unsupported: unknownFields, + allowed: allowedFields + }); + } + + const inputErrors = validateProjectInput(updates, { partial: true }); + if (inputErrors.length > 0) { + return res.status(400).json({ error: 'Validation failed', details: inputErrors }); + } + const data = readData(); const projectIndex = data.projects.findIndex(p => p.id === id); @@ -70,10 +177,26 @@ app.put('/api/projects/:id', (req, res) => { return res.status(404).json({ error: 'Project not found' }); } - data.projects[projectIndex] = { ...data.projects[projectIndex], ...updates }; + const safeUpdates = Object.fromEntries( + Object.entries(updates) + .filter(([key]) => allowedFields.includes(key)) + .map(([key, value]) => { + if ((key === 'name' || key === 'projectPath') && typeof value === 'string') { + return [key, value.trim()]; + } + return [key, value]; + }) + ); + + data.projects[projectIndex] = { ...data.projects[projectIndex], ...safeUpdates }; writeData(data); - res.json(data.projects[projectIndex]); + const responsePayload = { ...data.projects[projectIndex] }; + if (safeUpdates.projectPath && !fs.existsSync(safeUpdates.projectPath)) { + responsePayload.warnings = ['projectPath does not exist on server filesystem']; + } + + res.json(responsePayload); }); // DELETE project @@ -821,8 +944,12 @@ app.put('/api/projects/:id/files/*', (req, res) => { }); // Start server -app.listen(PORT, HOST, () => { - console.log(`\nšŸ¦ž OpenClaw Board v2\n`); - console.log(` 🌐 http://0.0.0.0:${PORT}`); - console.log(` šŸ“” API: http://localhost:${PORT}/api/projects\n`); -}); +if (require.main === module) { + app.listen(PORT, HOST, () => { + console.log(`\nšŸ¦ž OpenClaw Board v2\n`); + console.log(` 🌐 http://0.0.0.0:${PORT}`); + console.log(` šŸ“” API: http://localhost:${PORT}/api/projects\n`); + }); +} + +module.exports = app; diff --git a/package-lock.json b/package-lock.json index 1d42331..38a361d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,45 @@ { - "name": "molt-kanban-board", - "version": "1.0.0", + "name": "openclaw-react-board", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "molt-kanban-board", - "version": "1.0.0", + "name": "openclaw-react-board", + "version": "1.1.0", "license": "MIT", "dependencies": { "express": "^4.18.2", "uuid": "^9.0.0" + }, + "devDependencies": { + "supertest": "^7.1.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" } }, "node_modules/accepts": { @@ -32,6 +61,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -94,6 +137,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -130,6 +196,13 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -139,6 +212,16 @@ "ms": "2.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -158,6 +241,17 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -217,6 +311,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -278,6 +388,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -296,6 +413,41 @@ "node": ">= 0.8" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -384,6 +536,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -551,6 +719,16 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -776,6 +954,90 @@ "node": ">= 0.8" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -837,6 +1099,13 @@ "engines": { "node": ">= 0.8" } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" } } } diff --git a/package.json b/package.json index f548e68..6e26729 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "app.js", "scripts": { "start": "node app.js", - "dev": "node app.js" + "dev": "node app.js", + "test": "node --test" }, "keywords": ["kanban", "tasks", "productivity", "project-management", "file-browser"], "author": "OpenClaw", @@ -20,5 +21,8 @@ "dependencies": { "express": "^4.18.2", "uuid": "^9.0.0" + }, + "devDependencies": { + "supertest": "^7.1.1" } } diff --git a/test/project-update.test.js b/test/project-update.test.js new file mode 100644 index 0000000..d063798 --- /dev/null +++ b/test/project-update.test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); + +const app = require('../app'); + +const tasksPath = path.join(__dirname, '..', 'tasks.json'); +let originalTasksRaw = null; + +function seedData() { + const seeded = { + projects: [ + { + id: 'proj-test01', + name: 'Seed Project', + description: 'seed', + docs: '# Seed', + color: '#112233', + tasks: [], + createdAt: '2026-01-01T00:00:00.000Z' + } + ] + }; + fs.writeFileSync(tasksPath, JSON.stringify(seeded, null, 2), 'utf8'); +} + +test.before(() => { + originalTasksRaw = fs.readFileSync(tasksPath, 'utf8'); +}); + +test.after(() => { + if (originalTasksRaw !== null) { + fs.writeFileSync(tasksPath, originalTasksRaw, 'utf8'); + } +}); + +test('PUT /api/projects/:id accepts allowlisted field updates', async () => { + seedData(); + + const response = await request(app) + .put('/api/projects/proj-test01') + .send({ name: 'Renamed Project', color: '#aabbcc' }) + .expect(200); + + assert.equal(response.body.name, 'Renamed Project'); + assert.equal(response.body.color, '#aabbcc'); + + const data = JSON.parse(fs.readFileSync(tasksPath, 'utf8')); + assert.equal(data.projects[0].name, 'Renamed Project'); +}); + +test('PUT /api/projects/:id rejects unsupported fields', async () => { + seedData(); + + const response = await request(app) + .put('/api/projects/proj-test01') + .send({ tasks: [{ id: 'bad' }] }) + .expect(400); + + assert.equal(response.body.error, 'Unsupported project field(s)'); + assert.ok(Array.isArray(response.body.unsupported)); + assert.ok(response.body.unsupported.includes('tasks')); +}); + +test('PUT /api/projects/:id rejects invalid body type', async () => { + seedData(); + + const response = await request(app) + .put('/api/projects/proj-test01') + .send([]) + .expect(400); + + assert.equal(response.body.error, 'Invalid request body'); +});