Add unit tests for server logic including rate limiter, normalization helpers, and input validation
This commit is contained in:
308
web/server/__tests__/server.test.js
Normal file
308
web/server/__tests__/server.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user