diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..acac0f1 --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,16 @@ +// Fixed-window rate limiter. Windows start at multiples of windowMs. +export function createRateLimiter({ limit, windowMs, now = Date.now }) { + const hits = new Map(); // key -> { window, count } + + return function allow(key) { + const window = Math.floor(now() / windowMs); + const entry = hits.get(key); + if (!entry || entry.window !== window) { + hits.set(key, { window, count: 1 }); + return limit >= 1; + } + if (entry.count >= limit) return false; + entry.count += 1; + return true; + }; +} diff --git a/src/routes/notes.js b/src/routes/notes.js index 8248dc1..9a2a3b9 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -1,8 +1,27 @@ import { createNote, deleteNote, getNote, listNotes, updateNote } from '../services/notes.js'; import { noteSummary } from '../utils/format.js'; +import { ValidationError } from '../utils/validate.js'; + +const MAX_PAGE_SIZE = 50; + +function positiveInt(field, value, fallback) { + if (value === undefined) return fallback; + const n = Number(value); + if (!Number.isInteger(n) || n < 1) throw new ValidationError(field, 'must be a positive integer'); + return n; +} + +function listPage(req) { + const page = positiveInt('page', req.query?.page, 1); + const pageSize = Math.min(positiveInt('pageSize', req.query?.pageSize, 5), MAX_PAGE_SIZE); + const notes = listNotes(req.user.id); + const start = (page - 1) * pageSize; + const items = notes.slice(start, start + pageSize).map(noteSummary); + return { status: 200, body: { items, page, pageSize, total: notes.length } }; +} export const notesRoutes = { - 'GET /notes': (req) => ({ status: 200, body: listNotes(req.user.id).map(noteSummary) }), + 'GET /notes': listPage, 'GET /notes/:id': (req) => { const note = getNote(req.user.id, req.params.id); return note ? { status: 200, body: note } : { status: 404, body: { error: 'not found' } }; diff --git a/src/utils/format.js b/src/utils/format.js index 24a3a09..4c90f41 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -3,7 +3,7 @@ // Dates are shown as YYYY-MM-DD in UTC. export function formatDate(ms) { const d = new Date(ms); - const month = String(d.getUTCMonth()).padStart(2, '0'); + const month = String(d.getUTCMonth() + 1).padStart(2, '0'); const day = String(d.getUTCDate()).padStart(2, '0'); return `${d.getUTCFullYear()}-${month}-${day}`; } diff --git a/tests/api.test.js b/tests/api.test.js index 32d499e..519f328 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -18,8 +18,26 @@ test('notes need a valid token', () => { test('lists only my notes, newest first', () => { const res = as('u1', { method: 'GET', path: '/notes' }); assert.equal(res.status, 200); - assert.equal(res.body.length, 8); - assert.equal(res.body[0].title, 'Car service'); + assert.equal(res.body.total, 8); + assert.equal(res.body.items[0].title, 'Car service'); +}); + +test('notes are paginated, default page size 5', () => { + const res = as('u1', { method: 'GET', path: '/notes' }); + assert.equal(res.body.items.length, 5); + assert.equal(res.body.page, 1); + assert.equal(res.body.pageSize, 5); + const last = as('u1', { method: 'GET', path: '/notes', query: { page: '2' } }); + assert.equal(last.body.items.length, 3); + assert.equal(as('u1', { method: 'GET', path: '/notes', query: { page: '3' } }).body.items.length, 0); +}); + +test('pageSize is capped at 50 and bad values are 400s', () => { + const big = as('u1', { method: 'GET', path: '/notes', query: { pageSize: '500' } }); + assert.equal(big.body.pageSize, 50); + assert.equal(big.body.items.length, 8); + assert.equal(as('u1', { method: 'GET', path: '/notes', query: { page: '0' } }).status, 400); + assert.equal(as('u1', { method: 'GET', path: '/notes', query: { pageSize: 'abc' } }).status, 400); }); test('creates and deletes a note', () => { @@ -37,3 +55,9 @@ test('search ranks title matches first', () => { const res = as('u1', { method: 'GET', path: '/search', query: { q: 'list' } }); assert.deepEqual(res.body.map((n) => n.title), ['Book list']); }); + +test('formatDate uses 1-based months', async () => { + const { formatDate } = await import('../src/utils/format.js'); + assert.equal(formatDate(Date.UTC(2026, 2, 15)), '2026-03-15'); + assert.equal(formatDate(Date.UTC(2026, 11, 1)), '2026-12-01'); +}); diff --git a/tests/rateLimit.test.js b/tests/rateLimit.test.js new file mode 100644 index 0000000..a6b1987 --- /dev/null +++ b/tests/rateLimit.test.js @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRateLimiter } from '../src/middleware/rateLimit.js'; + +function setup(limit = 3, windowMs = 1000) { + const clock = { t: 0 }; + const allow = createRateLimiter({ limit, windowMs, now: () => clock.t }); + return { clock, allow }; +} + +test('allows up to limit calls per window, then blocks', () => { + const { allow } = setup(); + assert.deepEqual([1, 2, 3, 4, 5].map(() => allow('a')), [true, true, true, false, false]); +}); + +test('keys are counted independently', () => { + const { allow } = setup(1); + assert.equal(allow('a'), true); + assert.equal(allow('a'), false); + assert.equal(allow('b'), true); +}); + +test('counter resets at the next multiple of windowMs', () => { + const { clock, allow } = setup(1); + clock.t = 999; + assert.equal(allow('a'), true); + assert.equal(allow('a'), false); + clock.t = 1000; + assert.equal(allow('a'), true); + assert.equal(allow('a'), false); +}); + +test('calls late in a window do not extend it', () => { + const { clock, allow } = setup(1); + clock.t = 1500; + assert.equal(allow('a'), true); + clock.t = 1999; + assert.equal(allow('a'), false); + clock.t = 2000; + assert.equal(allow('a'), true); +}); + +test('blocked calls do not carry into the next window', () => { + const { clock, allow } = setup(2); + for (let i = 0; i < 10; i++) allow('a'); + clock.t = 1000; + assert.equal(allow('a'), true); + assert.equal(allow('a'), true); + assert.equal(allow('a'), false); +}); + +test('defaults to Date.now', () => { + const allow = createRateLimiter({ limit: 1, windowMs: 60 * 60 * 1000 }); + assert.equal(typeof allow('a'), 'boolean'); +});