Skip to content
This repository was archived by the owner on Feb 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 10 additions & 43 deletions .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
@@ -1,71 +1,38 @@
# 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'

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
253 changes: 253 additions & 0 deletions tests/unit/cache.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
});
48 changes: 48 additions & 0 deletions tests/unit/playlist.test.mjs
Original file line number Diff line number Diff line change
@@ -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(', ')}`);
});
});
Loading
Loading