audit week 3 [TEST-001]: bootstrap vitest + utils & configSchema smoke tests
Adds vitest@^4.1.5 as a devDependency, an `npm test` script (runs once, non-watch), and tests/ with 87 smoke tests across two suites: - tests/utils.test.js (42 tests) — pure functions in utils.js: stripEmailQuotes, stripMobileFooter, extractRawEmail, escapeHtml, sanitizeEmbedText, truncateEmbedDescription, replaceVariables, getPriorityEmoji, safeEqual, isStaff. Covers normal input, empty input, null/undefined, edge cases (CRLF normalization, oversize truncation, triple-backtick escape, code-block injection). - tests/configSchema.test.js (45 tests) — getValidator type inference and per-validator validate() behavior for boolean / integer / hex_color / url / email / discord_id / discord_id_list / string fallback. Covers ALLOWED_CONFIG_KEYS membership, the ROLE_ID_TO_PING mid-key override, legacy "true"/"false"/numeric coercion in the string fallback, empty input as ok-with-empty, garbage rejection. vitest.config.mjs sets `environment: 'node'`, `globals: false`, and `include: ['tests/**/*.test.js']`. Foundation for the mongoose 6→8 upgrade — these tests don't touch the DB but confirm pure-function behavior is preserved across dependency moves.
This commit is contained in:
1241
package-lock.json
generated
1241
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,8 @@
|
||||
"mongoose": "^6.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mongodb": "^7.1.0"
|
||||
"mongodb": "^7.1.0",
|
||||
"vitest": "^4.1.5"
|
||||
},
|
||||
"name": "broccolini-bot",
|
||||
"version": "1.0.0",
|
||||
@@ -16,6 +17,7 @@
|
||||
"main": "broccolini-discord.js",
|
||||
"scripts": {
|
||||
"start": "node broccolini-discord.js",
|
||||
"test": "vitest run",
|
||||
"test-mongodb": "node scripts/test-mongodb.js"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
263
tests/configSchema.test.js
Normal file
263
tests/configSchema.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ALLOWED_CONFIG_KEYS, getValidator } from '../services/configSchema.js';
|
||||
|
||||
describe('ALLOWED_CONFIG_KEYS', () => {
|
||||
it('is a non-empty Set', () => {
|
||||
expect(ALLOWED_CONFIG_KEYS).toBeInstanceOf(Set);
|
||||
expect(ALLOWED_CONFIG_KEYS.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('includes well-known runtime config keys', () => {
|
||||
for (const k of [
|
||||
'TICKET_CATEGORY_ID',
|
||||
'AUTO_CLOSE_ENABLED',
|
||||
'GMAIL_POLL_INTERVAL_SECONDS',
|
||||
'EMBED_COLOR_OPEN',
|
||||
'GAME_LIST'
|
||||
]) {
|
||||
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not contain stale removed keys', () => {
|
||||
for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) {
|
||||
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValidator: type inference', () => {
|
||||
it('treats *_ENABLED as boolean', () => {
|
||||
const v = getValidator('AUTO_CLOSE_ENABLED');
|
||||
expect(v.type).toBe('boolean');
|
||||
});
|
||||
|
||||
it('treats *_ID as discord_id', () => {
|
||||
expect(getValidator('TICKET_CATEGORY_ID').type).toBe('discord_id');
|
||||
});
|
||||
|
||||
it('overrides ROLE_ID_TO_PING (mid-key _ID) as discord_id', () => {
|
||||
expect(getValidator('ROLE_ID_TO_PING').type).toBe('discord_id');
|
||||
});
|
||||
|
||||
it('treats *_HOURS / *_MINUTES / *_SECONDS as integer', () => {
|
||||
expect(getValidator('AUTO_CLOSE_AFTER_HOURS').type).toBe('integer');
|
||||
expect(getValidator('RATE_LIMIT_WINDOW_MINUTES').type).toBe('integer');
|
||||
expect(getValidator('GMAIL_POLL_INTERVAL_SECONDS').type).toBe('integer');
|
||||
});
|
||||
|
||||
it('treats *_COLOR as hex_color', () => {
|
||||
expect(getValidator('EMBED_COLOR_OPEN').type).toBe('hex_color');
|
||||
});
|
||||
|
||||
it('treats LOGO_URL as url', () => {
|
||||
expect(getValidator('LOGO_URL').type).toBe('url');
|
||||
});
|
||||
|
||||
it('treats *_EMAIL as email', () => {
|
||||
expect(getValidator('SUPPORT_EMAIL').type).toBe('email');
|
||||
});
|
||||
|
||||
it('falls back to string for unknown shapes', () => {
|
||||
expect(getValidator('TICKET_CATEGORY_NAME').type).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('boolean validator', () => {
|
||||
const v = getValidator('AUTO_CLOSE_ENABLED');
|
||||
|
||||
it('accepts the literal true/false', () => {
|
||||
expect(v.validate(true)).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate(false)).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('accepts string "true"/"false"', () => {
|
||||
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('rejects garbage', () => {
|
||||
const res = v.validate('maybe');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/true or false/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integer validator', () => {
|
||||
const v = getValidator('AUTO_CLOSE_AFTER_HOURS');
|
||||
|
||||
it('coerces a numeric string to a number', () => {
|
||||
expect(v.validate('72')).toEqual({ ok: true, coerced: 72 });
|
||||
});
|
||||
|
||||
it('accepts zero', () => {
|
||||
expect(v.validate('0')).toEqual({ ok: true, coerced: 0 });
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
const res = v.validate('abc');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/whole number/);
|
||||
});
|
||||
|
||||
it('rejects floats', () => {
|
||||
expect(v.validate('1.5').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative integers', () => {
|
||||
expect(v.validate('-5').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty input as ok with empty coerced value', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
expect(v.validate(null)).toEqual({ ok: true, coerced: '' });
|
||||
expect(v.validate(undefined)).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hex_color validator', () => {
|
||||
const v = getValidator('EMBED_COLOR_OPEN');
|
||||
|
||||
it('accepts 0xRRGGBB form', () => {
|
||||
expect(v.validate('0xFF00AA')).toEqual({ ok: true, coerced: '0xFF00AA' });
|
||||
});
|
||||
|
||||
it('accepts #RRGGBB form and normalizes to 0xRRGGBB', () => {
|
||||
expect(v.validate('#ff00aa')).toEqual({ ok: true, coerced: '0xFF00AA' });
|
||||
});
|
||||
|
||||
it('accepts bare RRGGBB and normalizes', () => {
|
||||
expect(v.validate('00ff00')).toEqual({ ok: true, coerced: '0x00FF00' });
|
||||
});
|
||||
|
||||
it('rejects 3-digit shorthand', () => {
|
||||
expect(v.validate('#abc').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects garbage', () => {
|
||||
expect(v.validate('purple').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('url validator (LOGO_URL)', () => {
|
||||
const v = getValidator('LOGO_URL');
|
||||
|
||||
it('accepts a full URL', () => {
|
||||
expect(v.validate('https://example.com/logo.png')).toEqual({
|
||||
ok: true,
|
||||
coerced: 'https://example.com/logo.png'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects bare hostnames', () => {
|
||||
expect(v.validate('example.com').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('discord_id validator', () => {
|
||||
const v = getValidator('TICKET_CATEGORY_ID');
|
||||
|
||||
it('accepts an 18-digit snowflake', () => {
|
||||
expect(v.validate('123456789012345678')).toEqual({
|
||||
ok: true,
|
||||
coerced: '123456789012345678'
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a 20-digit snowflake', () => {
|
||||
const id = '12345678901234567890';
|
||||
expect(v.validate(id)).toEqual({ ok: true, coerced: id });
|
||||
});
|
||||
|
||||
it('rejects too-short IDs', () => {
|
||||
expect(v.validate('12345').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
expect(v.validate('not-an-id').ok).toBe(false);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('discord_id_list validator', () => {
|
||||
// ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it
|
||||
// hits the string fallback. discord_id_list only fires for `*_IDS` keys, so
|
||||
// exercise it with a hypothetical name.
|
||||
const v = getValidator('STAFF_USER_IDS');
|
||||
|
||||
it('infers type discord_id_list for *_IDS keys', () => {
|
||||
expect(v.type).toBe('discord_id_list');
|
||||
});
|
||||
|
||||
it('accepts a single ID', () => {
|
||||
expect(v.validate('123456789012345678'))
|
||||
.toEqual({ ok: true, coerced: '123456789012345678' });
|
||||
});
|
||||
|
||||
it('accepts a comma-separated list and trims spaces', () => {
|
||||
expect(v.validate('123456789012345678, 987654321098765432'))
|
||||
.toEqual({ ok: true, coerced: '123456789012345678,987654321098765432' });
|
||||
});
|
||||
|
||||
it('rejects if any segment is not a snowflake', () => {
|
||||
const res = v.validate('123456789012345678,nope');
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error).toMatch(/not a Discord ID/);
|
||||
});
|
||||
|
||||
it('treats empty as ok', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('string validator (fallback)', () => {
|
||||
const v = getValidator('TICKET_CATEGORY_NAME');
|
||||
|
||||
it('coerces "true"/"false" to booleans (legacy)', () => {
|
||||
expect(v.validate('true')).toEqual({ ok: true, coerced: true });
|
||||
expect(v.validate('false')).toEqual({ ok: true, coerced: false });
|
||||
});
|
||||
|
||||
it('coerces numeric-looking strings to numbers (legacy)', () => {
|
||||
expect(v.validate('42')).toEqual({ ok: true, coerced: 42 });
|
||||
expect(v.validate('3.14')).toEqual({ ok: true, coerced: 3.14 });
|
||||
});
|
||||
|
||||
it('passes plain strings through', () => {
|
||||
expect(v.validate('Open Tickets')).toEqual({ ok: true, coerced: 'Open Tickets' });
|
||||
});
|
||||
|
||||
it('passes empty string through unchanged', () => {
|
||||
expect(v.validate('')).toEqual({ ok: true, coerced: '' });
|
||||
});
|
||||
|
||||
it('rejects null', () => {
|
||||
expect(v.validate(null).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('email validator', () => {
|
||||
const v = getValidator('SUPPORT_EMAIL');
|
||||
|
||||
it('accepts valid email', () => {
|
||||
expect(v.validate('support@example.com'))
|
||||
.toEqual({ ok: true, coerced: 'support@example.com' });
|
||||
});
|
||||
|
||||
it('rejects malformed strings', () => {
|
||||
expect(v.validate('not-an-email').ok).toBe(false);
|
||||
expect(v.validate('a@').ok).toBe(false);
|
||||
expect(v.validate('@b').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
234
tests/utils.test.js
Normal file
234
tests/utils.test.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
stripEmailQuotes,
|
||||
stripMobileFooter,
|
||||
extractRawEmail,
|
||||
escapeHtml,
|
||||
sanitizeEmbedText,
|
||||
truncateEmbedDescription,
|
||||
replaceVariables,
|
||||
getPriorityEmoji,
|
||||
safeEqual,
|
||||
isStaff
|
||||
} from '../utils.js';
|
||||
|
||||
describe('stripEmailQuotes', () => {
|
||||
it('strips "On X wrote:" reply quote', () => {
|
||||
const input = 'My reply.\nOn Mon, May 5, 2025 at 1:00 PM Bob <bob@x.com> wrote:\n> previous message';
|
||||
expect(stripEmailQuotes(input)).toBe('My reply.');
|
||||
});
|
||||
|
||||
it('strips "From: …" reply header block', () => {
|
||||
const input = 'New reply text.\nFrom: Bob <bob@x.com>\nSent: Monday\nSubject: Re: foo';
|
||||
expect(stripEmailQuotes(input)).toBe('New reply text.');
|
||||
});
|
||||
|
||||
it('strips "_____" signature underline', () => {
|
||||
const input = 'My message.\n_____\nold thread content';
|
||||
expect(stripEmailQuotes(input)).toBe('My message.');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripEmailQuotes('')).toBe('');
|
||||
});
|
||||
|
||||
it('trims whitespace when no marker is found', () => {
|
||||
expect(stripEmailQuotes(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('keeps body intact when "On" appears mid-text without "wrote:"', () => {
|
||||
expect(stripEmailQuotes('I clicked On the button.')).toBe('I clicked On the button.');
|
||||
});
|
||||
|
||||
it('normalizes CRLF before scanning', () => {
|
||||
const input = 'New reply.\r\nOn Monday Bob <b@x.com> wrote:\r\n> quoted';
|
||||
expect(stripEmailQuotes(input)).toBe('New reply.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripMobileFooter', () => {
|
||||
it('removes "Sent from my iPhone"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my iPhone').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Sent from my Android"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my Android').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Sent from my Galaxy"', () => {
|
||||
expect(stripMobileFooter('Hi\nSent from my Galaxy').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('removes "Get Outlook for iOS"', () => {
|
||||
expect(stripMobileFooter('Hi\nGet Outlook for iOS').trim()).toBe('Hi');
|
||||
});
|
||||
|
||||
it('returns input unchanged when no footer present', () => {
|
||||
expect(stripMobileFooter('Just a normal message')).toBe('Just a normal message');
|
||||
});
|
||||
|
||||
it('returns null/undefined unchanged', () => {
|
||||
expect(stripMobileFooter(null)).toBe(null);
|
||||
expect(stripMobileFooter(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('returns empty string unchanged', () => {
|
||||
expect(stripMobileFooter('')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRawEmail', () => {
|
||||
it('extracts address from "Name <email>" form', () => {
|
||||
expect(extractRawEmail('Bob <bob@example.com>')).toBe('bob@example.com');
|
||||
});
|
||||
|
||||
it('returns trimmed input when angle brackets absent', () => {
|
||||
expect(extractRawEmail(' bob@example.com ')).toBe('bob@example.com');
|
||||
});
|
||||
|
||||
it('handles quoted name', () => {
|
||||
expect(extractRawEmail('"Bob, the Developer" <bob@example.com>')).toBe('bob@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes <, >, &, ", \'', () => {
|
||||
expect(escapeHtml('<script>alert("xss")</script>'))
|
||||
.toBe('<script>alert("xss")</script>');
|
||||
expect(escapeHtml("a & b's <foo>")).toBe('a & b's <foo>');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(escapeHtml(null)).toBe('');
|
||||
expect(escapeHtml(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('passes through plain text unchanged', () => {
|
||||
expect(escapeHtml('plain text')).toBe('plain text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeEmbedText', () => {
|
||||
it('replaces triple-backticks to prevent code-block escape', () => {
|
||||
expect(sanitizeEmbedText('```injected```')).toBe("'''injected'''");
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(sanitizeEmbedText(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(sanitizeEmbedText(null)).toBe('');
|
||||
expect(sanitizeEmbedText(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateEmbedDescription', () => {
|
||||
it('returns short strings unchanged', () => {
|
||||
expect(truncateEmbedDescription('hi')).toBe('hi');
|
||||
});
|
||||
|
||||
it('truncates at default 4096 with ellipsis', () => {
|
||||
const big = 'a'.repeat(5000);
|
||||
const out = truncateEmbedDescription(big);
|
||||
expect(out.length).toBe(4096);
|
||||
expect(out.endsWith('...')).toBe(true);
|
||||
});
|
||||
|
||||
it('respects custom max', () => {
|
||||
expect(truncateEmbedDescription('abcdef', 5)).toBe('ab...');
|
||||
});
|
||||
|
||||
it('returns empty string for null/undefined', () => {
|
||||
expect(truncateEmbedDescription(null)).toBe('');
|
||||
expect(truncateEmbedDescription(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceVariables', () => {
|
||||
it('substitutes ticket fields', () => {
|
||||
const ctx = {
|
||||
ticket: {
|
||||
sender_name: 'Alice',
|
||||
senderEmail: 'alice@x.com',
|
||||
ticketNumber: 42,
|
||||
subject: 'Help'
|
||||
}
|
||||
};
|
||||
const out = replaceVariables('User {ticket.user} ({ticket.email}) #{ticket.number} - {ticket.subject}', ctx);
|
||||
expect(out).toBe('User Alice (alice@x.com) #42 - Help');
|
||||
});
|
||||
|
||||
it('falls back when fields missing', () => {
|
||||
const out = replaceVariables('{ticket.user} {ticket.email} {ticket.subject}', { ticket: {} });
|
||||
expect(out).toBe('Unknown No subject');
|
||||
});
|
||||
|
||||
it('substitutes staff fields', () => {
|
||||
const ctx = {
|
||||
staff: { username: 'bob', displayName: 'Bob the Builder', mention: '<@123>' }
|
||||
};
|
||||
expect(replaceVariables('{staff.user} / {staff.name} / {staff.mention}', ctx))
|
||||
.toBe('bob / Bob the Builder / <@123>');
|
||||
});
|
||||
|
||||
it('returns empty string for empty template', () => {
|
||||
expect(replaceVariables('')).toBe('');
|
||||
expect(replaceVariables(null)).toBe('');
|
||||
});
|
||||
|
||||
it('substitutes hours when provided', () => {
|
||||
expect(replaceVariables('after {hours} hours', { hours: 24 })).toBe('after 24 hours');
|
||||
});
|
||||
|
||||
it('substitutes {date} and {time} from current time', () => {
|
||||
const out = replaceVariables('on {date}', {});
|
||||
expect(out).toMatch(/^on \S+/);
|
||||
expect(out).not.toContain('{date}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriorityEmoji', () => {
|
||||
it('maps high/medium/low/normal to CONFIG values', () => {
|
||||
expect(typeof getPriorityEmoji('high')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('low')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('medium')).toBe('string');
|
||||
expect(typeof getPriorityEmoji('normal')).toBe('string');
|
||||
});
|
||||
|
||||
it('falls back for unknown priority', () => {
|
||||
expect(typeof getPriorityEmoji('weird')).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeEqual', () => {
|
||||
it('returns true for matching strings', () => {
|
||||
expect(safeEqual('hello', 'hello')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for mismatched strings', () => {
|
||||
expect(safeEqual('hello', 'world')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for length mismatch (no throw)', () => {
|
||||
expect(safeEqual('a', 'abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null/undefined inputs', () => {
|
||||
expect(safeEqual(null, 'abc')).toBe(false);
|
||||
expect(safeEqual(undefined, undefined)).toBe(true);
|
||||
expect(safeEqual('', '')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStaff', () => {
|
||||
it('returns false for null/undefined member', () => {
|
||||
expect(isStaff(null)).toBe(false);
|
||||
expect(isStaff(undefined)).toBe(false);
|
||||
expect(isStaff({})).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for member with no roles cache', () => {
|
||||
expect(isStaff({ roles: null })).toBe(false);
|
||||
});
|
||||
});
|
||||
10
vitest.config.mjs
Normal file
10
vitest.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.js'],
|
||||
globals: false,
|
||||
testTimeout: 10000
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user