diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b1a4bb4..69e944f 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,16 +1,10 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. name: "CodeQL" on: push: - branches: [master] + branches: [main] pull_request: - # The branches below must be a subset of the branches above - branches: [master] + branches: [main] schedule: - cron: '0 11 * * 6' @@ -18,54 +12,27 @@ jobs: analyze: name: Analyze runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read strategy: fail-fast: false matrix: - # Override automatic language detection by changing the below list - # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] language: ['javascript'] - # Learn more... - # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - name: Checkout repository - uses: actions/checkout@v2 - with: - # We must fetch at least the immediate parents so that if this is - # a pull request then we can checkout the head. - fetch-depth: 2 - - # If this run was triggered by a pull request event, then checkout - # the head of the pull request instead of the merge commit. - - run: git checkout HEAD^2 - if: ${{ github.event_name == 'pull_request' }} + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 diff --git a/tests/unit/cache.test.mjs b/tests/unit/cache.test.mjs new file mode 100644 index 0000000..5a73882 --- /dev/null +++ b/tests/unit/cache.test.mjs @@ -0,0 +1,253 @@ +import { describe, it, before, beforeEach, afterEach, after } from 'node:test'; +import assert from 'node:assert/strict'; + +// HttpCache is exported as a singleton, so we import the module and access +// the class constructor through it for static method testing. +import cache from '../../proxy/cache.mjs'; + +const HttpCache = cache.constructor; + +// Tear down the singleton's cleanup interval so Node can exit cleanly +after(() => { + cache.destroy(); +}); + +describe('HttpCache.parseCacheControl', () => { + it('returns 0 for null/undefined input', () => { + assert.equal(HttpCache.parseCacheControl(null), 0); + assert.equal(HttpCache.parseCacheControl(undefined), 0); + assert.equal(HttpCache.parseCacheControl(''), 0); + }); + + it('extracts max-age value', () => { + assert.equal(HttpCache.parseCacheControl('max-age=300'), 300); + assert.equal(HttpCache.parseCacheControl('public, max-age=600'), 600); + }); + + it('prefers s-maxage over max-age', () => { + assert.equal(HttpCache.parseCacheControl('s-maxage=120, max-age=600'), 120); + }); + + it('extracts s-maxage when present alone', () => { + assert.equal(HttpCache.parseCacheControl('s-maxage=3600'), 3600); + }); + + it('returns 0 for no-cache directives', () => { + assert.equal(HttpCache.parseCacheControl('no-cache'), 0); + assert.equal(HttpCache.parseCacheControl('no-store'), 0); + }); + + it('is case-insensitive', () => { + assert.equal(HttpCache.parseCacheControl('Max-Age=120'), 120); + assert.equal(HttpCache.parseCacheControl('S-MAXAGE=60'), 60); + }); +}); + +describe('HttpCache.generateKey', () => { + it('generates key from path when no query string', () => { + const req = { path: '/api/points/42,-90', url: '/api/points/42,-90' }; + const key = HttpCache.generateKey(req); + assert.equal(key, '/api/points/42,-90'); + }); + + it('includes query string in key', () => { + const req = { path: '/api/points/42,-90', url: '/api/points/42,-90?units=us' }; + const key = HttpCache.generateKey(req); + assert.equal(key, '/api/points/42,-90?units=us'); + }); + + it('uses path as fallback when url is missing', () => { + const req = { path: '/api/forecast' }; + const key = HttpCache.generateKey(req); + assert.equal(key, '/api/forecast'); + }); + + it('uses url as fallback when path is missing', () => { + // When path is missing, url is used for both path and url, + // so query string gets appended again + const req = { url: '/api/forecast?a=1' }; + const key = HttpCache.generateKey(req); + assert.equal(key, '/api/forecast?a=1?a=1'); + }); + + it('defaults to / when both are missing', () => { + const req = {}; + const key = HttpCache.generateKey(req); + assert.equal(key, '/'); + }); +}); + +describe('HttpCache.setFilteredHeaders', () => { + it('strips cache-related headers and sets proxy cache policy', () => { + const headersSet = {}; + const res = { + header(name, value) { headersSet[name] = value; }, + }; + + HttpCache.setFilteredHeaders(res, { + 'content-type': 'application/json', + 'cache-control': 'max-age=600', + etag: '"abc123"', + 'last-modified': 'Mon, 01 Jan 2024 00:00:00 GMT', + expires: 'Thu, 01 Jan 2099 00:00:00 GMT', + 'x-custom': 'value', + }); + + assert.equal(headersSet['content-type'], 'application/json'); + assert.equal(headersSet['x-custom'], 'value'); + assert.equal(headersSet['cache-control'], 'public, max-age=30'); + assert.equal(headersSet.etag, undefined); + assert.equal(headersSet['last-modified'], undefined); + assert.equal(headersSet.expires, undefined); + }); + + it('handles null/undefined headers gracefully', () => { + const headersSet = {}; + const res = { header(name, value) { headersSet[name] = value; } }; + + HttpCache.setFilteredHeaders(res, null); + assert.equal(headersSet['cache-control'], 'public, max-age=30'); + }); +}); + +describe('HttpCache.calculateHeuristicMaxAge', () => { + it('returns 0 for future dates', () => { + const future = new Date(Date.now() + 3600 * 1000).toUTCString(); + assert.equal(HttpCache.calculateHeuristicMaxAge(future), 0); + }); + + it('clamps to minimum of 1 hour for recent resources', () => { + const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000).toUTCString(); + assert.equal(HttpCache.calculateHeuristicMaxAge(tenMinutesAgo), 3600); + }); + + it('uses 10% of age for mid-range resources', () => { + const twentyHoursAgo = new Date(Date.now() - 20 * 3600 * 1000).toUTCString(); + const result = HttpCache.calculateHeuristicMaxAge(twentyHoursAgo); + // 20 hours = 72000s, 10% = 7200s = 2 hours, within [1h, 4h] + assert.equal(result, 7200); + }); + + it('clamps to maximum of 4 hours for old resources', () => { + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000).toUTCString(); + assert.equal(HttpCache.calculateHeuristicMaxAge(oneWeekAgo), 4 * 3600); + }); + + it('returns NaN for invalid date strings (Date constructor does not throw)', () => { + const result = HttpCache.calculateHeuristicMaxAge('not-a-date'); + assert.ok(Number.isNaN(result)); + }); +}); + +describe('HttpCache instance - cache state transitions', () => { + let testCache; + + beforeEach(() => { + testCache = new HttpCache(); + // Stop the cleanup interval to avoid interference + if (testCache.cleanupInterval) { + clearInterval(testCache.cleanupInterval); + testCache.cleanupInterval = null; + } + }); + + afterEach(() => { + if (testCache.cleanupInterval) { + clearInterval(testCache.cleanupInterval); + } + }); + + it('returns miss for unknown keys', () => { + const req = { path: '/api/test', url: '/api/test' }; + const result = testCache.getCachedRequest(req); + assert.equal(result.status, 'miss'); + assert.equal(result.data, null); + }); + + it('returns fresh for non-expired entries', () => { + const req = { path: '/api/test', url: '/api/test' }; + const key = HttpCache.generateKey(req); + + testCache.cache.set(key, { + statusCode: 200, + headers: { 'content-type': 'application/json' }, + data: '{"ok":true}', + expiry: Date.now() + 60000, + timestamp: Date.now(), + url: 'https://api.weather.gov/api/test', + }); + + const result = testCache.getCachedRequest(req); + assert.equal(result.status, 'fresh'); + assert.equal(result.data.statusCode, 200); + }); + + it('returns stale for expired entries', () => { + const req = { path: '/api/test', url: '/api/test' }; + const key = HttpCache.generateKey(req); + + testCache.cache.set(key, { + statusCode: 200, + headers: { 'content-type': 'application/json' }, + data: '{"ok":true}', + expiry: Date.now() - 1000, + timestamp: Date.now() - 61000, + url: 'https://api.weather.gov/api/test', + }); + + const result = testCache.getCachedRequest(req); + assert.equal(result.status, 'stale'); + assert.equal(result.data.statusCode, 200); + }); + + it('storeCachedResponse stores entry with explicit TTL', () => { + const req = { path: '/api/test', url: '/api/test' }; + const response = { statusCode: 200, headers: {}, data: '{"ok":true}' }; + const originalHeaders = { 'cache-control': 'max-age=300' }; + + testCache.storeCachedResponse(req, response, 'https://api.weather.gov/api/test', originalHeaders); + + const key = HttpCache.generateKey(req); + const cached = testCache.cache.get(key); + assert.ok(cached); + assert.equal(cached.statusCode, 200); + assert.ok(cached.expiry > Date.now()); + assert.ok(cached.expiry <= Date.now() + 300 * 1000 + 100); + }); + + it('storeCachedResponse does not cache when no cache directives', () => { + const req = { path: '/api/nocache', url: '/api/nocache' }; + const response = { statusCode: 200, headers: {}, data: '{}' }; + + testCache.storeCachedResponse(req, response, 'https://api.weather.gov/api/nocache', {}); + + const key = HttpCache.generateKey(req); + assert.equal(testCache.cache.has(key), false); + }); + + it('getStats returns correct counts', () => { + const now = Date.now(); + testCache.cache.set('valid', { expiry: now + 60000 }); + testCache.cache.set('expired', { expiry: now - 1000 }); + + const stats = testCache.getStats(); + assert.equal(stats.total, 2); + assert.equal(stats.valid, 1); + assert.equal(stats.expired, 1); + assert.equal(stats.inFlight, 0); + }); + + it('clearEntry removes a specific entry', () => { + testCache.cache.set('/api/test', { data: 'test' }); + assert.equal(testCache.cache.size, 1); + + const result = testCache.clearEntry('/api/test'); + assert.equal(result, true); + assert.equal(testCache.cache.size, 0); + }); + + it('clearEntry returns false for missing entry', () => { + const result = testCache.clearEntry('/api/nonexistent'); + assert.equal(result, false); + }); +}); diff --git a/tests/unit/playlist.test.mjs b/tests/unit/playlist.test.mjs new file mode 100644 index 0000000..2f5a85a --- /dev/null +++ b/tests/unit/playlist.test.mjs @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +describe('mp3 filter regex', () => { + const mp3Filter = (file) => file.match(/\.mp3$/); + + it('matches .mp3 files', () => { + assert.ok(mp3Filter('song.mp3')); + assert.ok(mp3Filter('Trammell Starks - After Midnight.mp3')); + }); + + it('rejects non-mp3 files', () => { + assert.equal(mp3Filter('readme.txt'), null); + assert.equal(mp3Filter('image.png'), null); + assert.equal(mp3Filter('.gitkeep'), null); + }); + + it('rejects files with mp3 in the name but wrong extension', () => { + assert.equal(mp3Filter('mp3-notes.txt'), null); + assert.equal(mp3Filter('song.mp3.bak'), null); + }); + + it('rejects directory names', () => { + assert.equal(mp3Filter('default'), null); + }); +}); + +describe('playlist-reader integration', () => { + it('returns mp3 files from server/music directory', async () => { + const reader = (await import('../../src/playlist-reader.mjs')).default; + const files = await reader(); + + assert.ok(Array.isArray(files)); + assert.ok(files.length > 0, 'Expected at least one mp3 file'); + + for (const file of files) { + assert.match(file, /\.mp3$/, `Expected mp3 file, got: ${file}`); + } + }); + + it('does not include non-mp3 files', async () => { + const reader = (await import('../../src/playlist-reader.mjs')).default; + const files = await reader(); + + const nonMp3 = files.filter((f) => !f.match(/\.mp3$/)); + assert.equal(nonMp3.length, 0, `Found non-mp3 files: ${nonMp3.join(', ')}`); + }); +}); diff --git a/tests/unit/radar-utils.test.mjs b/tests/unit/radar-utils.test.mjs new file mode 100644 index 0000000..3cdf51f --- /dev/null +++ b/tests/unit/radar-utils.test.mjs @@ -0,0 +1,225 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + getXYFromLatitudeLongitudeMap, + getXYFromLatitudeLongitudeDoppler, + removeDopplerRadarImageNoise, +} from '../../server/scripts/modules/radar-utils.mjs'; + +// Helper: create a mock canvas context with pixel data +const createMockContext = (width, height, pixels) => { + const data = new Uint8ClampedArray(pixels); + const imageData = { data, width, height }; + let storedImageData = null; + + return { + canvas: { width, height }, + getImageData: () => ({ ...imageData, data: new Uint8ClampedArray(data) }), + putImageData: (imgData) => { storedImageData = imgData; }, + getStoredImageData: () => storedImageData, + }; +}; + +// Helper: pack RGBA values into an array +const rgba = (r, g, b, a = 255) => [r, g, b, a]; + +describe('getXYFromLatitudeLongitudeMap', () => { + it('returns coordinates within tile bounds for continental US center', () => { + const result = getXYFromLatitudeLongitudeMap({ latitude: 39.8, longitude: -98.5 }); + assert.ok(result.x >= 0, `x should be >= 0, got ${result.x}`); + assert.ok(result.y >= 0, `y should be >= 0, got ${result.y}`); + }); + + it('clamps to 0 for far northwest positions', () => { + const result = getXYFromLatitudeLongitudeMap({ latitude: 70, longitude: -170 }); + assert.equal(result.x, 0); + assert.equal(result.y, 0); + }); + + it('returns different coordinates for different positions', () => { + const seattle = getXYFromLatitudeLongitudeMap({ latitude: 47.6, longitude: -122.3 }); + const miami = getXYFromLatitudeLongitudeMap({ latitude: 25.8, longitude: -80.2 }); + + assert.ok(miami.x > seattle.x, 'Miami should be to the right of Seattle'); + assert.ok(miami.y > seattle.y, 'Miami should be below Seattle'); + }); +}); + +describe('getXYFromLatitudeLongitudeDoppler', () => { + it('returns coordinates for a given position with offsets', () => { + const result = getXYFromLatitudeLongitudeDoppler( + { latitude: 39.8, longitude: -98.5 }, + 0, + 0, + ); + assert.ok(result.x >= 0, `x should be >= 0, got ${result.x}`); + assert.ok(result.y >= 0, `y should be >= 0, got ${result.y}`); + }); + + it('offsets shift the result', () => { + const base = getXYFromLatitudeLongitudeDoppler( + { latitude: 39.8, longitude: -98.5 }, + 0, + 0, + ); + const shifted = getXYFromLatitudeLongitudeDoppler( + { latitude: 39.8, longitude: -98.5 }, + 100, + 50, + ); + + assert.ok(shifted.x < base.x || shifted.x === 0, 'x offset should decrease x'); + assert.ok(shifted.y < base.y || shifted.y === 0, 'y offset should decrease y'); + }); + + it('returns values consistent with 2x scale factor', () => { + // The function computes raw values then multiplies by 2. + // Verify by checking two nearby points produce a proportional difference. + const pos1 = { latitude: 40, longitude: -100 }; + const pos2 = { latitude: 40, longitude: -99 }; + const r1 = getXYFromLatitudeLongitudeDoppler(pos1, 0, 0); + const r2 = getXYFromLatitudeLongitudeDoppler(pos2, 0, 0); + + // 1 degree of longitude at this scale ~= 42.1768 * 2 ~= 84.35 pixels + const xDiff = Math.abs(r2.x - r1.x); + assert.ok(xDiff > 80 && xDiff < 90, `Expected ~84px difference, got ${xDiff}`); + }); +}); + +describe('removeDopplerRadarImageNoise', () => { + it('makes black pixels transparent', () => { + const ctx = createMockContext(1, 1, [...rgba(0, 0, 0, 255)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(0, 0, 0, 0)]); + }); + + it('makes cyan (0,236,236) transparent', () => { + const ctx = createMockContext(1, 1, [...rgba(0, 236, 236, 255)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(0, 0, 0, 0)]); + }); + + it('makes light blue (1,160,246) transparent', () => { + const ctx = createMockContext(1, 1, [...rgba(1, 160, 246, 255)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(0, 0, 0, 0)]); + }); + + it('makes blue (0,0,246) transparent', () => { + const ctx = createMockContext(1, 1, [...rgba(0, 0, 246, 255)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(0, 0, 0, 0)]); + }); + + it('remaps bright green (0,255,0) to muted green', () => { + const ctx = createMockContext(1, 1, [...rgba(0, 255, 0)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(49, 210, 22, 255)]); + }); + + it('remaps medium green (0,200,0) to darker green', () => { + const ctx = createMockContext(1, 1, [...rgba(0, 200, 0)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(0, 142, 0, 255)]); + }); + + it('remaps dark green (0,144,0) to very dark green', () => { + const ctx = createMockContext(1, 1, [...rgba(0, 144, 0)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(20, 90, 15, 255)]); + }); + + it('remaps yellow (255,255,0) to near-black green', () => { + const ctx = createMockContext(1, 1, [...rgba(255, 255, 0)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(10, 40, 10, 255)]); + }); + + it('remaps warm yellow (231,192,0) to muted yellow', () => { + const ctx = createMockContext(1, 1, [...rgba(231, 192, 0)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(196, 179, 70, 255)]); + }); + + it('remaps orange (255,144,0) to dark orange', () => { + const ctx = createMockContext(1, 1, [...rgba(255, 144, 0)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(190, 72, 19, 255)]); + }); + + it('remaps red shades (214,0,0) and (255,0,0) to dark red', () => { + const ctx1 = createMockContext(1, 1, [...rgba(214, 0, 0)]); + removeDopplerRadarImageNoise(ctx1); + assert.deepEqual([...ctx1.getStoredImageData().data], [...rgba(171, 14, 14, 255)]); + + const ctx2 = createMockContext(1, 1, [...rgba(255, 0, 0)]); + removeDopplerRadarImageNoise(ctx2); + assert.deepEqual([...ctx2.getStoredImageData().data], [...rgba(171, 14, 14, 255)]); + }); + + it('remaps brown shades (192,0,0) and (255,0,255) to brown', () => { + const ctx1 = createMockContext(1, 1, [...rgba(192, 0, 0)]); + removeDopplerRadarImageNoise(ctx1); + assert.deepEqual([...ctx1.getStoredImageData().data], [...rgba(115, 31, 4, 255)]); + + const ctx2 = createMockContext(1, 1, [...rgba(255, 0, 255)]); + removeDopplerRadarImageNoise(ctx2); + assert.deepEqual([...ctx2.getStoredImageData().data], [...rgba(115, 31, 4, 255)]); + }); + + it('leaves unrecognized colors unchanged', () => { + const ctx = createMockContext(1, 1, [...rgba(128, 128, 128, 200)]); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data], [...rgba(128, 128, 128, 200)]); + }); + + it('handles multiple pixels in sequence', () => { + const pixels = [ + ...rgba(0, 0, 0, 255), // -> transparent + ...rgba(0, 255, 0, 255), // -> muted green + ...rgba(128, 128, 128, 200), // -> unchanged + ]; + const ctx = createMockContext(3, 1, pixels); + removeDopplerRadarImageNoise(ctx); + + const result = ctx.getStoredImageData(); + assert.deepEqual([...result.data.slice(0, 4)], [...rgba(0, 0, 0, 0)]); + assert.deepEqual([...result.data.slice(4, 8)], [...rgba(49, 210, 22, 255)]); + assert.deepEqual([...result.data.slice(8, 12)], [...rgba(128, 128, 128, 200)]); + }); + + it('handles null context gracefully', () => { + assert.doesNotThrow(() => removeDopplerRadarImageNoise(null)); + }); + + it('handles context with missing canvas gracefully', () => { + assert.doesNotThrow(() => removeDopplerRadarImageNoise({})); + }); + + it('handles zero-dimension canvas gracefully', () => { + const ctx = { canvas: { width: 0, height: 0 } }; + assert.doesNotThrow(() => removeDopplerRadarImageNoise(ctx)); + }); +});