From 7e696ce87953f9944da4ec1ab756b63f79b5494b Mon Sep 17 00:00:00 2001 From: fullsend-code Date: Mon, 1 Jun 2026 15:26:12 +0000 Subject: [PATCH 1/5] fix(#1): reject whitespace-only titles in POST and PATCH /tasks The POST /tasks endpoint used `if (!title)` to validate titles, which only catches falsy values. Whitespace-only strings like " " are truthy in JavaScript and bypassed this check. The PATCH /tasks/:id endpoint had no title validation at all. Changes: - POST handler: add `.trim()` check so whitespace-only titles are rejected with 400. Trim accepted titles before storing. - PATCH handler: add title validation when `updates.title` is provided, rejecting empty/whitespace-only values with 400. Trim accepted title updates before storing. - Add integration tests covering both endpoints for empty, whitespace-only, null, and valid title inputs. Closes #1 --- src/index.js | 12 +++- src/index.test.js | 137 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 src/index.test.js diff --git a/src/index.js b/src/index.js index 60221e6..214b175 100644 --- a/src/index.js +++ b/src/index.js @@ -23,12 +23,12 @@ const server = http.createServer((req, res) => { req.on('end', () => { try { const { title, description } = JSON.parse(body); - if (!title) { + if (!title || !title.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'title is required' })); return; } - const task = store.create(title, description); + const task = store.create(title.trim(), description); res.writeHead(201); res.end(JSON.stringify(task)); } catch (e) { @@ -61,6 +61,14 @@ const server = http.createServer((req, res) => { req.on('end', () => { try { const updates = JSON.parse(body); + if (updates.title !== undefined && (!updates.title || !updates.title.trim())) { + res.writeHead(400); + res.end(JSON.stringify({ error: 'title is required' })); + return; + } + if (updates.title) { + updates.title = updates.title.trim(); + } const task = store.update(id, updates); if (!task) { res.writeHead(404); diff --git a/src/index.test.js b/src/index.test.js new file mode 100644 index 0000000..55e6c20 --- /dev/null +++ b/src/index.test.js @@ -0,0 +1,137 @@ +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert'); +const http = require('node:http'); + +let server; +let baseUrl; + +function request(method, path, body) { + return new Promise((resolve, reject) => { + const url = new URL(path, baseUrl); + const options = { + hostname: url.hostname, + port: url.port, + path: url.pathname, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: data ? JSON.parse(data) : null, + }); + }); + }); + req.on('error', reject); + if (body !== undefined) { + req.write(JSON.stringify(body)); + } + req.end(); + }); +} + +// Use port 0 to get a random available port +before(() => { + process.env.PORT = '0'; + // Clear require cache so index.js binds to port 0 + delete require.cache[require.resolve('./index')]; + const mod = require('./index'); + server = mod.server; + return new Promise((resolve) => { + if (server.listening) { + baseUrl = `http://localhost:${server.address().port}`; + resolve(); + } else { + server.on('listening', () => { + baseUrl = `http://localhost:${server.address().port}`; + resolve(); + }); + } + }); +}); + +after(() => { + return new Promise((resolve) => { + server.close(resolve); + }); +}); + +describe('POST /tasks title validation', () => { + it('rejects empty string title with 400', async () => { + const res = await request('POST', '/tasks', { title: '' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects whitespace-only title with 400', async () => { + const res = await request('POST', '/tasks', { title: ' ' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects tab-only title with 400', async () => { + const res = await request('POST', '/tasks', { title: '\t' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects null title with 400', async () => { + const res = await request('POST', '/tasks', { title: null }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('accepts valid title and trims whitespace', async () => { + const res = await request('POST', '/tasks', { title: ' Buy groceries ' }); + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.body.title, 'Buy groceries'); + }); + + it('accepts valid title without extra whitespace', async () => { + const res = await request('POST', '/tasks', { title: 'Clean house' }); + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.body.title, 'Clean house'); + }); +}); + +describe('PATCH /tasks/:id title validation', () => { + it('rejects whitespace-only title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: ' ' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects empty string title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: '' }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('accepts valid title update and trims whitespace', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: ' Updated ' }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body.title, 'Updated'); + }); + + it('allows updates without title field', async () => { + const createRes = await request('POST', '/tasks', { title: 'Keep this' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { completed: true }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body.title, 'Keep this'); + assert.strictEqual(res.body.completed, true); + }); +}); From ec9f2e712e6b6452c96dc3b041f2e8ecbe8b7388 Mon Sep 17 00:00:00 2001 From: fullsend-fix Date: Mon, 1 Jun 2026 16:35:51 +0000 Subject: [PATCH 2/5] fix: add typeof string guard for title validation on PR #3 Replace `!title` checks with `typeof title !== 'string'` guards in both POST and PATCH /tasks handlers so non-string values (numbers, booleans) return 400 with "title is required" instead of falling through to the catch block with a misleading "invalid JSON" error. Add tests for numeric and boolean title inputs in both endpoints. Addresses review feedback on #3 Co-Authored-By: Claude Opus 4.6 Signed-off-by: fullsend-fix --- src/index.js | 4 ++-- src/index.test.js | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/index.js b/src/index.js index 214b175..756a251 100644 --- a/src/index.js +++ b/src/index.js @@ -23,7 +23,7 @@ const server = http.createServer((req, res) => { req.on('end', () => { try { const { title, description } = JSON.parse(body); - if (!title || !title.trim()) { + if (typeof title !== 'string' || !title.trim()) { res.writeHead(400); res.end(JSON.stringify({ error: 'title is required' })); return; @@ -61,7 +61,7 @@ const server = http.createServer((req, res) => { req.on('end', () => { try { const updates = JSON.parse(body); - if (updates.title !== undefined && (!updates.title || !updates.title.trim())) { + if (updates.title !== undefined && (typeof updates.title !== 'string' || !updates.title.trim())) { res.writeHead(400); res.end(JSON.stringify({ error: 'title is required' })); return; diff --git a/src/index.test.js b/src/index.test.js index 55e6c20..c69b7e5 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -84,6 +84,18 @@ describe('POST /tasks title validation', () => { assert.strictEqual(res.body.error, 'title is required'); }); + it('rejects numeric title with 400', async () => { + const res = await request('POST', '/tasks', { title: 123 }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + + it('rejects boolean title with 400', async () => { + const res = await request('POST', '/tasks', { title: true }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + it('accepts valid title and trims whitespace', async () => { const res = await request('POST', '/tasks', { title: ' Buy groceries ' }); assert.strictEqual(res.statusCode, 201); @@ -116,6 +128,15 @@ describe('PATCH /tasks/:id title validation', () => { assert.strictEqual(res.body.error, 'title is required'); }); + it('rejects numeric title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: 42 }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + it('accepts valid title update and trims whitespace', async () => { const createRes = await request('POST', '/tasks', { title: 'Original' }); const id = createRes.body.id; From 3080a7b93bb21bc9ce787dafe3cbc4a4133ef05d Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:44:37 +0000 Subject: [PATCH 3/5] fix: guard URL parsing with try/catch to prevent server crash (#2) Wrap `new URL()` constructor in the request handler with try/catch so malformed URL paths return 400 Bad Request instead of crashing the server process. Add integration test for the malformed URL scenario. Addresses review feedback on #3 --- src/index.js | 10 +++++++++- src/index.test.js | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 756a251..29ec4b5 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,15 @@ const store = new TaskStore(); const PORT = process.env.PORT || 3000; const server = http.createServer((req, res) => { - const url = new URL(req.url, `http://${req.headers.host}`); + let url; + try { + url = new URL(req.url, `http://${req.headers.host}`); + } catch (e) { + res.setHeader('Content-Type', 'application/json'); + res.writeHead(400); + res.end(JSON.stringify({ error: 'bad request' })); + return; + } const method = req.method; res.setHeader('Content-Type', 'application/json'); diff --git a/src/index.test.js b/src/index.test.js index c69b7e5..15ba9de 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -59,6 +59,39 @@ after(() => { }); }); +function rawRequest(method, rawPath) { + return new Promise((resolve, reject) => { + const parsedBase = new URL(baseUrl); + const options = { + hostname: parsedBase.hostname, + port: parsedBase.port, + path: rawPath, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + resolve({ + statusCode: res.statusCode, + body: data ? JSON.parse(data) : null, + }); + }); + }); + req.on('error', reject); + req.end(); + }); +} + +describe('Malformed URL handling', () => { + it('returns 400 for URL that triggers parsing failure instead of crashing', async () => { + const res = await rawRequest('GET', '///'); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'bad request'); + }); +}); + describe('POST /tasks title validation', () => { it('rejects empty string title with 400', async () => { const res = await request('POST', '/tasks', { title: '' }); From a002d760c90a236a17a304e66dff7c1b451ae0ce Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:01:53 +0000 Subject: [PATCH 4/5] fix: address review feedback on PR #3 - Change PATCH trim guard from truthiness check to strict undefined check for consistency with validation gate (logic-error finding) - Add missing test for PATCH with null title (test-inadequate finding) Addresses review feedback on #3 --- src/index.js | 2 +- src/index.test.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/index.js b/src/index.js index 29ec4b5..64be478 100644 --- a/src/index.js +++ b/src/index.js @@ -74,7 +74,7 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ error: 'title is required' })); return; } - if (updates.title) { + if (updates.title !== undefined) { updates.title = updates.title.trim(); } const task = store.update(id, updates); diff --git a/src/index.test.js b/src/index.test.js index 15ba9de..d57598c 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -170,6 +170,15 @@ describe('PATCH /tasks/:id title validation', () => { assert.strictEqual(res.body.error, 'title is required'); }); + it('rejects null title update with 400', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { title: null }); + assert.strictEqual(res.statusCode, 400); + assert.strictEqual(res.body.error, 'title is required'); + }); + it('accepts valid title update and trims whitespace', async () => { const createRes = await request('POST', '/tasks', { title: 'Original' }); const id = createRes.body.id; From 75b4a79d567fc9d1f01a0f8db830cca85e1e8b3f Mon Sep 17 00:00:00 2001 From: fullsend-fix <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:18:11 +0000 Subject: [PATCH 5/5] fix: address review feedback on PR #3 - Add engines field (node >=20) to package.json to document the minimum Node version, addressing the edge-case finding about the malformed URL test relying on Node-version-dependent behavior. - Add description passthrough tests for both POST and PATCH endpoints to guard against future regressions where description might be inadvertently trimmed. Addresses review feedback on #3 --- package.json | 5 ++++- src/index.test.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 4ba7a49..6401967 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,8 @@ "test": "node --test src/**/*.test.js" }, "keywords": ["api", "rest", "tasks"], - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=20" + } } diff --git a/src/index.test.js b/src/index.test.js index d57598c..349800b 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -140,6 +140,12 @@ describe('POST /tasks title validation', () => { assert.strictEqual(res.statusCode, 201); assert.strictEqual(res.body.title, 'Clean house'); }); + + it('preserves description with leading/trailing whitespace', async () => { + const res = await request('POST', '/tasks', { title: 'Task', description: ' spaced out ' }); + assert.strictEqual(res.statusCode, 201); + assert.strictEqual(res.body.description, ' spaced out '); + }); }); describe('PATCH /tasks/:id title validation', () => { @@ -197,4 +203,13 @@ describe('PATCH /tasks/:id title validation', () => { assert.strictEqual(res.body.title, 'Keep this'); assert.strictEqual(res.body.completed, true); }); + + it('preserves description with leading/trailing whitespace on update', async () => { + const createRes = await request('POST', '/tasks', { title: 'Original' }); + const id = createRes.body.id; + + const res = await request('PATCH', `/tasks/${id}`, { description: ' spaced out ' }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(res.body.description, ' spaced out '); + }); });