From 3bc68a57131e996f5f081215e11668052947a948 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Tue, 16 Jun 2026 13:15:25 +0200 Subject: [PATCH] test: add unit tests for rate-limiter and episode-utils - test-rate-limiter.mjs: 12 test groups covering all rate-limit functions, Maps, cleanup, and double-start guard - test-episode-utils.mjs: 30+ assertions covering SxxExx patterns, 6 separator types (dash/dot/slash/colon/comma/space), German/English, edge cases (null/undefined/empty), and sameEpisode matching logic - verify-release.mjs: added 2 test suites + 1 syntax check to pipeline --- scripts/test-episode-utils.mjs | 76 +++++++++++++++++++++++ scripts/test-rate-limiter.mjs | 110 +++++++++++++++++++++++++++++++++ scripts/verify-release.mjs | 3 + 3 files changed, 189 insertions(+) create mode 100644 scripts/test-episode-utils.mjs create mode 100644 scripts/test-rate-limiter.mjs diff --git a/scripts/test-episode-utils.mjs b/scripts/test-episode-utils.mjs new file mode 100644 index 0000000..b65cb7c --- /dev/null +++ b/scripts/test-episode-utils.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { extractEpisodeId, sameEpisode } from '../extension/episode-utils.js'; + +// --- extractEpisodeId --- + +// Standard SxxExx patterns +assert.equal(extractEpisodeId('S01E01'), 'S01E01'); +assert.equal(extractEpisodeId('S1E1'), 'S01E01'); +assert.equal(extractEpisodeId('s01e01'), 'S01E01', 'case insensitive'); +assert.equal(extractEpisodeId('Season 1 Episode 2'), 'S01E02'); +assert.equal(extractEpisodeId('season 01 episode 02'), 'S01E02'); + +// Separators: dash, dot, slash, colon, space, comma +assert.equal(extractEpisodeId('S01 - E01'), 'S01E01', 'dash separator'); +assert.equal(extractEpisodeId('S01.E01'), 'S01E01', 'dot separator'); +assert.equal(extractEpisodeId('S01/E01'), 'S01E01', 'slash separator (Crunchyroll)'); +assert.equal(extractEpisodeId('S01:E01'), 'S01E01', 'colon separator'); +assert.equal(extractEpisodeId('S01,E01'), 'S01E01', 'comma separator'); +assert.equal(extractEpisodeId('S01 E01'), 'S01E01', 'space separator'); + +// German / multi-language +assert.equal(extractEpisodeId('Folge 5'), 'EP005'); +assert.equal(extractEpisodeId('Episode 12'), 'EP012'); +assert.equal(extractEpisodeId('Ep. 3'), 'EP003'); +assert.equal(extractEpisodeId('#42'), 'EP042'); + +// Edge cases +assert.equal(extractEpisodeId(null), null); +assert.equal(extractEpisodeId(undefined), null); +assert.equal(extractEpisodeId(''), null); +assert.equal(extractEpisodeId(123), null); +assert.equal(extractEpisodeId('Some Movie Title'), null); +assert.equal(extractEpisodeId('Breaking Bad'), null); + +// Leading zeros preserved +assert.equal(extractEpisodeId('S01E001'), 'S01E001'); + +// --- sameEpisode --- + +// Identical episodes +assert.equal(sameEpisode('S01E01', 'S01E01'), true); +assert.equal(sameEpisode('S01E01 - Pilot', 'S01E01'), true, 'extra text ignored'); +assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German vs English'); + +// Different episodes +assert.equal(sameEpisode('S01E01', 'S01E02'), false); +assert.equal(sameEpisode('Folge 1', 'Folge 2'), false); +assert.equal(sameEpisode('S01E01', 'S02E01'), false); + +// Both unknown → assume same (backward compat) +assert.equal(sameEpisode(null, null), true); +assert.equal(sameEpisode(undefined, undefined), true); +assert.equal(sameEpisode('', ''), true); +assert.equal(sameEpisode('Some Movie', 'Some Movie'), true); +assert.equal(sameEpisode('Some Movie', 'Other Movie'), false, 'different unknowns differ'); + +// One unknown, one known → different +assert.equal(sameEpisode('S01E01', null), false); +assert.equal(sameEpisode(null, 'Episode 5'), false); +assert.equal(sameEpisode(undefined, 'S01E01'), false); + +// Mixed formats — only match when the same episode +assert.equal(sameEpisode('S01E05', 'S01E05'), true, 'same SxxExx'); +assert.equal(sameEpisode('Folge 5', 'Episode 5'), true, 'German Folge vs English Episode'); +assert.equal(sameEpisode('Episode 12', 'Ep. 12'), true, 'Episode X vs Ep. X'); +assert.equal(sameEpisode('#42', 'Folge 42'), true, '#X vs Folge X'); + +// Different format IDs → different (season-tagged vs seasonless) +assert.equal(sameEpisode('S01E05', 'Episode 5'), false, 'SxxExx vs Episode X: different IDs'); +assert.equal(sameEpisode('S01E01', 'EP001'), false, 'SxxExx vs EPxxx: different IDs'); + +// parseable but truly different +assert.equal(sameEpisode('S01E01', 'S01E02'), false, 'different episodes'); +assert.equal(sameEpisode('S01E01', 'S02E01'), false, 'different seasons'); + +console.log('episode-utils tests passed'); diff --git a/scripts/test-rate-limiter.mjs b/scripts/test-rate-limiter.mjs new file mode 100644 index 0000000..d37dab1 --- /dev/null +++ b/scripts/test-rate-limiter.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { + checkConnectionRate, + checkEventRate, + checkHealthRate, + checkAdminMetricsAuthRate, + checkAuthRate, + recordAuthFailure, + clearRateLimitMaps, + connectionCounts, + failedAuthAttempts, + eventCounts, + healthCounts, + adminMetricsAuthCounts, + roomListCooldowns, + rateLimitDenied, + startRateLimitCleanup, + stopRateLimitCleanup +} from '../server/rate-limiter.js'; + +// Helper: mock io for cleanup +const mockIo = { sockets: { sockets: new Map() } }; + +// Reset state before each test group +function reset() { + clearRateLimitMaps(); + Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }); + stopRateLimitCleanup(); +} + +// --- checkConnectionRate --- +reset(); +assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed'); +for (let i = 0; i < 9; i++) checkConnectionRate('1.1.1.1'); +assert.equal(checkConnectionRate('1.1.1.1'), false, '11th connection blocked'); +assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented'); + +reset(); +assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent'); + +// --- checkEventRate --- +reset(); +assert.equal(checkEventRate('sock1'), true, 'first event allowed'); +for (let i = 0; i < 29; i++) checkEventRate('sock1'); +assert.equal(checkEventRate('sock1'), false, '31st event blocked'); +assert.equal(rateLimitDenied.events, 1); + +reset(); +assert.equal(checkEventRate('sock2'), true, 'separate socket independent'); + +// --- checkHealthRate --- +reset(); +assert.equal(checkHealthRate('1.2.3.4'), true, 'first health check allowed'); +for (let i = 0; i < 9; i++) checkHealthRate('1.2.3.4'); +assert.equal(checkHealthRate('1.2.3.4'), false, '11th health check blocked'); +assert.equal(rateLimitDenied.health, 1); + +// --- checkAdminMetricsAuthRate --- +reset(); +assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), true, 'first admin auth allowed'); +for (let i = 0; i < 4; i++) checkAdminMetricsAuthRate('5.6.7.8'); +assert.equal(checkAdminMetricsAuthRate('5.6.7.8'), false, '6th admin auth blocked'); +assert.equal(rateLimitDenied.adminMetricsAuth, 1); + +// --- checkAuthRate --- +reset(); +assert.equal(checkAuthRate('10.0.0.1', 'room-a'), true, 'first auth attempt allowed'); +for (let i = 0; i < 5; i++) recordAuthFailure('10.0.0.1', 'room-a'); +assert.equal(checkAuthRate('10.0.0.1', 'room-a'), false, '6th auth attempt blocked'); +assert.equal(checkAuthRate('10.0.0.1', 'room-b'), true, 'different room not blocked'); + +// --- recordAuthFailure --- +reset(); +recordAuthFailure('10.0.0.2', 'room-x'); +assert.equal(failedAuthAttempts.size, 1, 'failure recorded'); +const record = failedAuthAttempts.get('10.0.0.2:room-x'); +assert.equal(record.count, 1, 'count incremented'); +assert.ok(record.lastAttempt <= Date.now(), 'timestamp set'); + +recordAuthFailure('10.0.0.2', 'room-x'); +assert.equal(failedAuthAttempts.get('10.0.0.2:room-x').count, 2, 'count increments on repeat'); + +// --- clearRateLimitMaps --- +reset(); +connectionCounts.set('ip1', { count: 1, resetTime: Date.now() + 60000 }); +eventCounts.set('sock1', { count: 1, resetTime: Date.now() + 10000 }); +healthCounts.set('ip2', { count: 1, resetTime: Date.now() + 60000 }); +adminMetricsAuthCounts.set('ip3', { count: 1, resetTime: Date.now() + 60000 }); +roomListCooldowns.set('sock2', Date.now()); +clearRateLimitMaps(); +assert.equal(connectionCounts.size, 0, 'connectionCounts cleared'); +assert.equal(eventCounts.size, 0, 'eventCounts cleared'); +assert.equal(healthCounts.size, 0, 'healthCounts cleared'); +assert.equal(adminMetricsAuthCounts.size, 0, 'adminMetricsAuthCounts cleared'); +assert.equal(roomListCooldowns.size, 0, 'roomListCooldowns cleared'); + +// --- startRateLimitCleanup / stopRateLimitCleanup --- +reset(); +startRateLimitCleanup(mockIo); +startRateLimitCleanup(mockIo); // double-start guard +stopRateLimitCleanup(); +assert.ok(true, 'cleanup start/stop does not throw'); + +// --- rateLimitDenied reset --- +reset(); +rateLimitDenied.connections = 5; +Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0 }); +assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable'); + +console.log('rate-limiter tests passed'); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index 4a1e716..1549a91 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -11,11 +11,14 @@ const checks = [ ['server routes', 'node', ['scripts/test-server-routes.mjs'], { env: { ADMIN_METRICS_TOKEN: 'verify-admin-token-with-more-than-32-chars' } }], + ['rate-limiter unit tests', 'node', ['scripts/test-rate-limiter.mjs']], + ['episode-utils unit tests', 'node', ['scripts/test-episode-utils.mjs']], ['content video finder', 'node', ['scripts/test-content-video-finder.js']], ['audio settings', 'node', ['scripts/test-audio-settings.mjs']], ['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']], ['server syntax index', 'node', ['-c', 'server/index.js']], ['server syntax ops', 'node', ['-c', 'server/ops.js']], + ['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']], ['content syntax', 'node', ['-c', 'extension/content.js']], ['popup syntax', 'node', ['-c', 'extension/popup.js']], ['background syntax', 'node', ['-c', 'extension/background.js']],