From ba0cbfbbb65cb07cd5f209c0bf27df2fdb1eff3f Mon Sep 17 00:00:00 2001 From: MayaTheShy Date: Sun, 22 Mar 2026 11:52:33 -0400 Subject: [PATCH] Add unit tests for server logic including rate limiter, normalization helpers, and input validation --- web/server/__tests__/server.test.js | 308 ++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 web/server/__tests__/server.test.js diff --git a/web/server/__tests__/server.test.js b/web/server/__tests__/server.test.js new file mode 100644 index 0000000..202a673 --- /dev/null +++ b/web/server/__tests__/server.test.js @@ -0,0 +1,308 @@ +/** + * Tests for Inventory Manager server logic. + * + * server.js is a monolith that starts listening on import, so we can't + * import it directly in a test. Instead we extract and test the pure + * helper logic that lives inside it: + * + * • Rate limiter algorithm + * • Input validation rules (order, craft, reboot targets) + * • Lua -> Frontend state normalization (itemList, furnaces, alerts, recipes) + * • Idempotent command tracking + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Extracted: createRateLimiter ─────────────────────────────────────── + +function createRateLimiter(windowMs, max) { + const hits = new Map(); + // Note: in tests we skip the setInterval cleanup + return (req, res, next) => { + const key = req.ip || 'unknown'; + const now = Date.now(); + const entry = hits.get(key); + if (!entry || now - entry.start > windowMs) { + hits.set(key, { start: now, count: 1 }); + return next(); + } + entry.count++; + if (entry.count > max) { + res.set('Retry-After', String(Math.ceil((entry.start + windowMs - now) / 1000))); + return res.status(429).json({ error: 'Too many requests — try again later' }); + } + return next(); + }; +} + +// ── Extracted: normalization helpers ─────────────────────────────────── + +function normalizeItemList(rawItems) { + return Array.isArray(rawItems) + ? rawItems.map(item => ({ + name: item.name || '', + count: item.total !== undefined ? item.total : (item.count || 0), + displayName: item.displayName || item.name || '', + })) + : []; +} + +function normalizeFurnaces(rawFurnaces) { + let normalizedFurnaces = {}; + if (Array.isArray(rawFurnaces)) { + rawFurnaces.forEach(f => { + if (f && f.name) { + normalizedFurnaces[f.name] = { + active: f.active || false, + type: f.type || 'minecraft:furnace', + input: f.input || null, + fuel: f.fuel || null, + output: f.output || null, + }; + } + }); + } else if (typeof rawFurnaces === 'object') { + normalizedFurnaces = rawFurnaces; + } + return normalizedFurnaces; +} + +function normalizeAlerts(rawAlerts) { + return Array.isArray(rawAlerts) + ? rawAlerts.map(a => ({ + item: a.name || a.item || a.label || '', + triggered: true, + current: a.current || 0, + threshold: a.min || a.threshold || 0, + })) + : []; +} + +function normalizeCraftable(rawCraftable) { + return Array.isArray(rawCraftable) + ? rawCraftable.map(recipe => { + const slots = {}; + if (recipe.grid && Array.isArray(recipe.grid)) { + recipe.grid.forEach((item, idx) => { + if (item) slots[idx + 1] = item; + }); + } else if (recipe.slots) { + Object.assign(slots, recipe.slots); + } + return { output: recipe.output || '', count: recipe.count || 1, slots }; + }) + : []; +} + +// ── Extracted: idempotent command tracking ───────────────────────────── + +function createCommandTracker(ttl = 5 * 60 * 1000) { + const processedCommands = new Map(); + return { + check(commandId) { + if (!commandId) return null; + return processedCommands.get(commandId)?.result || null; + }, + record(commandId, result) { + if (!commandId) return; + processedCommands.set(commandId, { result, timestamp: Date.now() }); + }, + size() { return processedCommands.size; }, + }; +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('createRateLimiter', () => { + it('allows requests under the limit', () => { + const limiter = createRateLimiter(60_000, 3); + const req = { ip: '127.0.0.1' }; + const next = vi.fn(); + const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() }; + + limiter(req, res, next); + limiter(req, res, next); + limiter(req, res, next); + + expect(next).toHaveBeenCalledTimes(3); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('blocks requests over the limit with 429', () => { + const limiter = createRateLimiter(60_000, 2); + const req = { ip: '10.0.0.1' }; + const next = vi.fn(); + const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() }; + + limiter(req, res, next); // 1 — allowed + limiter(req, res, next); // 2 — allowed + limiter(req, res, next); // 3 — blocked + + expect(next).toHaveBeenCalledTimes(2); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.set).toHaveBeenCalledWith('Retry-After', expect.any(String)); + }); + + it('tracks different IPs independently', () => { + const limiter = createRateLimiter(60_000, 1); + const next = vi.fn(); + const res = { status: vi.fn().mockReturnThis(), json: vi.fn(), set: vi.fn() }; + + limiter({ ip: 'a' }, res, next); // a:1 — allowed + limiter({ ip: 'b' }, res, next); // b:1 — allowed + limiter({ ip: 'a' }, res, next); // a:2 — blocked + + expect(next).toHaveBeenCalledTimes(2); + }); +}); + +describe('normalizeItemList', () => { + it('converts Lua-style { name, total } to { name, count }', () => { + const raw = [ + { name: 'minecraft:diamond', total: 64 }, + { name: 'minecraft:coal', total: 200 }, + ]; + const result = normalizeItemList(raw); + expect(result[0]).toEqual({ name: 'minecraft:diamond', count: 64, displayName: 'minecraft:diamond' }); + expect(result[1].count).toBe(200); + }); + + it('falls back to count if total is absent', () => { + const raw = [{ name: 'minecraft:iron_ingot', count: 32, displayName: 'Iron Ingot' }]; + const result = normalizeItemList(raw); + expect(result[0].count).toBe(32); + expect(result[0].displayName).toBe('Iron Ingot'); + }); + + it('returns empty array for non-array input', () => { + expect(normalizeItemList(null)).toEqual([]); + expect(normalizeItemList('bad')).toEqual([]); + }); +}); + +describe('normalizeFurnaces', () => { + it('converts Lua array-of-objects to keyed map', () => { + const raw = [ + { name: 'furnace_0', active: true, type: 'minecraft:blast_furnace', input: { name: 'iron' }, fuel: null, output: null }, + ]; + const result = normalizeFurnaces(raw); + expect(result['furnace_0'].active).toBe(true); + expect(result['furnace_0'].type).toBe('minecraft:blast_furnace'); + }); + + it('passes through pre-keyed objects as-is', () => { + const raw = { furnace_0: { active: false } }; + const result = normalizeFurnaces(raw); + expect(result).toBe(raw); // same reference + }); + + it('skips entries without a name', () => { + const raw = [{ active: true }, { name: 'f1', active: false }]; + const result = normalizeFurnaces(raw); + expect(Object.keys(result)).toEqual(['f1']); + }); +}); + +describe('normalizeAlerts', () => { + it('normalizes Lua-style {label, current, min} fields', () => { + const raw = [{ label: 'Diamond', current: 2, min: 10 }]; + const result = normalizeAlerts(raw); + expect(result[0]).toEqual({ item: 'Diamond', triggered: true, current: 2, threshold: 10 }); + }); + + it('accepts name or item field', () => { + expect(normalizeAlerts([{ name: 'A' }])[0].item).toBe('A'); + expect(normalizeAlerts([{ item: 'B' }])[0].item).toBe('B'); + }); +}); + +describe('normalizeCraftable', () => { + it('converts Lua grid (flat array of 9) to slots object', () => { + const raw = [{ + output: 'minecraft:chest', + count: 1, + grid: [null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null, 'minecraft:planks', null], + }]; + const result = normalizeCraftable(raw); + expect(result[0].output).toBe('minecraft:chest'); + // grid indices are 0-based, slots keys are 1-based + expect(result[0].slots).toEqual({ 2: 'minecraft:planks', 4: 'minecraft:planks', 6: 'minecraft:planks', 8: 'minecraft:planks' }); + }); + + it('passes through pre-slotted recipes', () => { + const raw = [{ output: 'x', count: 2, slots: { 1: 'a', 5: 'b' } }]; + const result = normalizeCraftable(raw); + expect(result[0].slots).toEqual({ 1: 'a', 5: 'b' }); + }); +}); + +describe('idempotent command tracking', () => { + let tracker; + + beforeEach(() => { + tracker = createCommandTracker(); + }); + + it('returns null for unseen commandId', () => { + expect(tracker.check('cmd-1')).toBeNull(); + }); + + it('returns cached result for repeated commandId', () => { + const result = { success: true, message: 'Order sent' }; + tracker.record('cmd-1', result); + expect(tracker.check('cmd-1')).toEqual(result); + }); + + it('ignores null/undefined commandId', () => { + tracker.record(null, { x: 1 }); + tracker.record(undefined, { x: 2 }); + expect(tracker.size()).toBe(0); + }); + + it('does not cross-contaminate different command IDs', () => { + tracker.record('a', { val: 1 }); + tracker.record('b', { val: 2 }); + expect(tracker.check('a')).toEqual({ val: 1 }); + expect(tracker.check('b')).toEqual({ val: 2 }); + }); +}); + +describe('input validation rules', () => { + const VALID_REBOOT_TARGETS = ['all', 'manager', 'client', 'turtle', 'bridge']; + + it('accepts valid reboot targets', () => { + for (const t of VALID_REBOOT_TARGETS) { + expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(true); + } + }); + + it('accepts numeric computer IDs', () => { + expect(/^\d+$/.test('42')).toBe(true); + expect(/^\d+$/.test('0')).toBe(true); + }); + + it('rejects invalid reboot targets', () => { + for (const t of ['admin', 'drop ; rm -rf /', '', 'ALL']) { + expect(VALID_REBOOT_TARGETS.includes(t) || /^\d+$/.test(t)).toBe(false); + } + }); + + it('validates order amount range (1–100000)', () => { + const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 100000; + expect(valid(1)).toBe(true); + expect(valid(100000)).toBe(true); + expect(valid(0)).toBe(false); + expect(valid(-1)).toBe(false); + expect(valid(100001)).toBe(false); + expect(valid(NaN)).toBe(false); + expect(valid(Infinity)).toBe(false); + }); + + it('validates craft recipeIdx range (1–1000)', () => { + const valid = (n) => Number.isFinite(n) && n >= 1 && n <= 1000; + expect(valid(1)).toBe(true); + expect(valid(500)).toBe(true); + expect(valid(0)).toBe(false); + expect(valid(1001)).toBe(false); + }); +});