mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-29 03:57:09 +00:00
test: harden coverage and browser release gates
This commit is contained in:
@@ -66,12 +66,12 @@ jobs:
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium chromium-headless-shell
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit
|
||||
|
||||
# The extension specs load dist/chrome, so the artifact has to exist.
|
||||
- name: Build the extension
|
||||
run: npm run build:extension
|
||||
|
||||
- name: Run extension E2E smoke tests
|
||||
- name: Run cross-browser detection and extension E2E tests
|
||||
run: npm run test:e2e
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Repeated Race Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '17 3 * * *'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: race-tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
extension-races:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium chromium-headless-shell
|
||||
|
||||
- name: Build the extension
|
||||
run: npm run build:extension
|
||||
|
||||
- name: Repeat race-sensitive extension tests
|
||||
run: npm run test:e2e:race
|
||||
|
||||
- name: Upload failure diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: race-test-diagnostics
|
||||
path: |
|
||||
test-results/
|
||||
playwright-report/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
@@ -175,3 +175,8 @@ jobs:
|
||||
prerelease: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify published extension release
|
||||
run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+1
-2
@@ -67,8 +67,7 @@ Useful focused checks from the repository root:
|
||||
node -c extension/background.js
|
||||
node -c extension/content.js
|
||||
node -c extension/popup.js
|
||||
node scripts/test-episode-utils.mjs
|
||||
node scripts/test-title-privacy.mjs
|
||||
npx vitest run extension/episode-utils.test.mjs extension/title-privacy.test.mjs
|
||||
node scripts/test-audio-settings.mjs
|
||||
node scripts/test-locales.cjs
|
||||
```
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractEpisodeId, sameEpisode } from './episode-utils.js';
|
||||
|
||||
describe('episode title matching', () => {
|
||||
it.each([
|
||||
['S01E01', 'S01E01'],
|
||||
['S1E1', 'S01E01'],
|
||||
['s01e01', 'S01E01'],
|
||||
['Season 1 Episode 2', 'S01E02'],
|
||||
['season 01 episode 02', 'S01E02'],
|
||||
['S01 - E01', 'S01E01'],
|
||||
['S01.E01', 'S01E01'],
|
||||
['S01/E01', 'S01E01'],
|
||||
['S01:E01', 'S01E01'],
|
||||
['S01,E01', 'S01E01'],
|
||||
['S01 E01', 'S01E01'],
|
||||
['Folge 5', 'EP005'],
|
||||
['Episode 12', 'EP012'],
|
||||
['Ep. 3', 'EP003'],
|
||||
['#42', 'EP042'],
|
||||
['S01E001', 'S01E001']
|
||||
])('extracts %s as %s', (title, expected) => {
|
||||
expect(extractEpisodeId(title)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([null, undefined, '', 123, 'Some Movie Title', 'Breaking Bad'])(
|
||||
'returns null for non-episode input %j',
|
||||
input => expect(extractEpisodeId(input)).toBeNull()
|
||||
);
|
||||
|
||||
it.each([
|
||||
['S01E01', 'S01E01'],
|
||||
['S01E01 - Pilot', 'S01E01'],
|
||||
['Folge 5', 'Episode 5'],
|
||||
['Episode 12', 'Ep. 12'],
|
||||
['#42', 'Folge 42'],
|
||||
[null, null],
|
||||
['', ''],
|
||||
['Some Movie', 'Some Movie']
|
||||
])('matches equivalent titles %j and %j', (left, right) => {
|
||||
expect(sameEpisode(left, right)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['S01E01', 'S01E02'],
|
||||
['S01E01', 'S02E01'],
|
||||
['Folge 1', 'Folge 2'],
|
||||
['Some Movie', 'Other Movie'],
|
||||
['S01E01', null],
|
||||
[null, 'Episode 5'],
|
||||
['S01E05', 'Episode 5'],
|
||||
['S01E01', 'EP001']
|
||||
])('rejects different titles %j and %j', (left, right) => {
|
||||
expect(sameEpisode(left, right)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HOST_ACCESS_REQUIRED_STATUS,
|
||||
addTabHostAccessRequest,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
normalizeTabId,
|
||||
removeTabHostAccessRequest,
|
||||
requestOriginPermission
|
||||
} from './host-access.js';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
describe('host access helpers', () => {
|
||||
it('normalizes only positive safe tab IDs', () => {
|
||||
expect(HOST_ACCESS_REQUIRED_STATUS).toBe('host_permission_required');
|
||||
for (const invalid of [null, undefined, '', 0, true, [42], '42.5', Number.MAX_SAFE_INTEGER + 1]) {
|
||||
expect(normalizeTabId(invalid)).toBeNull();
|
||||
}
|
||||
expect(normalizeTabId('42')).toBe(42);
|
||||
expect(normalizeTabId(' 42 ')).toBe(42);
|
||||
});
|
||||
|
||||
it('describes supported origins with Firefox-compatible localhost permissions', () => {
|
||||
expect(describeTabUrl('https://emby.example:8443/web/index.html')).toEqual({
|
||||
url: 'https://emby.example:8443/web/index.html',
|
||||
host: 'emby.example:8443',
|
||||
originPattern: 'https://emby.example:8443/*'
|
||||
});
|
||||
expect(describeTabUrl('http://localhost:8096/web/', { includePort: false })).toEqual({
|
||||
url: 'http://localhost:8096/web/',
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost/*'
|
||||
});
|
||||
expect(describeTabUrl('chrome://extensions/')).toBeNull();
|
||||
expect(describeTabUrl('not a url')).toBeNull();
|
||||
});
|
||||
|
||||
it('checks the selected tab origin and preserves an unknown callback result', async () => {
|
||||
let containsRequest;
|
||||
const deniedChrome = {
|
||||
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
await expect(inspectTabHostAccess(deniedChrome, 42)).resolves.toMatchObject({
|
||||
granted: false,
|
||||
host: 'video.example',
|
||||
originPattern: 'https://video.example/*'
|
||||
});
|
||||
expect(containsRequest).toEqual({ origins: ['https://video.example/*'] });
|
||||
|
||||
const unknownChrome = {
|
||||
runtime: {},
|
||||
tabs: { get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) },
|
||||
permissions: { contains: (_request, callback) => callback(undefined) }
|
||||
};
|
||||
await expect(inspectTabHostAccess(unknownChrome, 42)).resolves.toMatchObject({ granted: null });
|
||||
});
|
||||
|
||||
it('uses Firefox host patterns without ports', async () => {
|
||||
let containsRequest;
|
||||
const chromeApi = {
|
||||
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
|
||||
tabs: {
|
||||
get: async tabId => ({
|
||||
id: tabId,
|
||||
url: 'http://localhost:8096/web/',
|
||||
pendingUrl: 'https://different.example/loading'
|
||||
})
|
||||
},
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
await expect(inspectTabHostAccess(chromeApi, 42)).resolves.toMatchObject({
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost/*'
|
||||
});
|
||||
expect(containsRequest).toEqual({ origins: ['http://localhost/*'] });
|
||||
});
|
||||
|
||||
it('adds, removes, and requests permissions through promise and callback APIs', async () => {
|
||||
let added;
|
||||
expect(await addTabHostAccessRequest({
|
||||
permissions: { addHostAccessRequest: async request => { added = request; } }
|
||||
}, 42, 'https://video.example/*')).toBe(true);
|
||||
expect(added).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
|
||||
expect(await addTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
|
||||
|
||||
let removed;
|
||||
expect(await removeTabHostAccessRequest({
|
||||
permissions: { removeHostAccessRequest: async request => { removed = request; } }
|
||||
}, 42, 'https://video.example/*')).toBe(true);
|
||||
expect(removed).toEqual({ tabId: 42, pattern: 'https://video.example/*' });
|
||||
expect(await removeTabHostAccessRequest({ permissions: {} }, 42)).toBe(false);
|
||||
|
||||
const callbackChrome = {
|
||||
runtime: {},
|
||||
permissions: { request: (_request, callback) => callback(true) }
|
||||
};
|
||||
await expect(requestOriginPermission(callbackChrome, 'https://video.example/*')).resolves.toBe(true);
|
||||
await expect(requestOriginPermission({ permissions: {} }, 'https://video.example/*')).resolves.toBeNull();
|
||||
expect(isHostAccessError(new Error('Missing host permission for the tab'))).toBe(true);
|
||||
expect(isHostAccessError(new Error('No tab with id: 42'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('host access recovery contracts', () => {
|
||||
it('keeps activation, permission recovery, and target identity guarded', () => {
|
||||
const background = fs.readFileSync(path.join(repoRoot, 'extension/background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
|
||||
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
|
||||
const tabManager = fs.readFileSync(path.join(repoRoot, 'extension/modules/tab-manager.js'), 'utf8');
|
||||
|
||||
expect(background).toMatch(/await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/);
|
||||
expect(background).toMatch(/addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/);
|
||||
expect(background).toMatch(/retryPendingTarget\(\)/);
|
||||
expect(background).toMatch(/activationGeneration !== targetActivationGeneration/);
|
||||
expect(background).toMatch(/pendingTargetRequestId/);
|
||||
expect(background).toMatch(/addedOrigins\.includes\(pending\.originPattern\)/);
|
||||
expect(background).toMatch(/isCurrentTargetIdentity\(tabId, targetGeneration\)/);
|
||||
expect(background).toMatch(/message\.expectedTabId/);
|
||||
expect(background).toMatch(/completeForceSyncBeforeTargetChange\(selectedTabId\)/);
|
||||
expect(background).toMatch(/FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/);
|
||||
expect(background).toMatch(/removeTabHostAccessRequest\([\s\S]*pendingTabId/);
|
||||
|
||||
const activationBody = background.slice(
|
||||
background.indexOf('async function activateTargetTab'),
|
||||
background.indexOf('async function retryPendingTarget')
|
||||
);
|
||||
expect(activationBody.indexOf('await injectContentScript')).toBeLessThan(
|
||||
activationBody.indexOf('currentTabId = selectedTabId')
|
||||
);
|
||||
expect(popup).toMatch(/response\?\.status === 'host_permission_required'/);
|
||||
expect(popup).toMatch(/requestOriginPermission\(chrome, requestedOriginPattern\)/);
|
||||
expect(popup).toMatch(/expectedCurrentTabId: tabId/);
|
||||
expect(popup).toMatch(/expectedTabId: tabId/);
|
||||
expect(tabManager).not.toMatch(/injectContentScript/);
|
||||
expect((background.match(/tabs\.onRemoved\.addListener/g) || []).length
|
||||
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length).toBe(1);
|
||||
expect(popupHtml).toMatch(/id="siteAccessNotice"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
TITLE_PRIVACY_MODES,
|
||||
applyTitlePrivacyToPayload,
|
||||
normalizeSendTabTitle,
|
||||
normalizeTabTitle,
|
||||
normalizeTitlePrivacyMode,
|
||||
sanitizeSharedTitle,
|
||||
sanitizeTabTitle
|
||||
} from './title-privacy.js';
|
||||
|
||||
describe('title privacy', () => {
|
||||
it('normalizes settings and tab notification prefixes', () => {
|
||||
expect(normalizeTitlePrivacyMode(undefined)).toBe(TITLE_PRIVACY_MODES.FULL);
|
||||
expect(normalizeTitlePrivacyMode('unknown')).toBe(TITLE_PRIVACY_MODES.FULL);
|
||||
expect(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN)).toBe(TITLE_PRIVACY_MODES.HIDDEN);
|
||||
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL)).toBe(true);
|
||||
expect(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE)).toBe(false);
|
||||
expect(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN)).toBe(true);
|
||||
expect(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL)).toBe(false);
|
||||
expect(normalizeTabTitle('(12) Testvideo - YouTube')).toBe('Testvideo - YouTube');
|
||||
expect(normalizeTabTitle('[999+] Testvideo - YouTube')).toBe('Testvideo - YouTube');
|
||||
expect(normalizeTabTitle('(500) Days of Summer')).toBe('Days of Summer');
|
||||
expect(normalizeTabTitle(' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps tab-title and media-title privacy independent', () => {
|
||||
expect(sanitizeTabTitle('(12) Private Tab', true)).toBe('Private Tab');
|
||||
expect(sanitizeTabTitle('Private Tab', false)).toBeNull();
|
||||
expect(sanitizeSharedTitle('Example Movie', 'full')).toBe('Example Movie');
|
||||
expect(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode')).toBe('S01E04');
|
||||
expect(sanitizeSharedTitle('Folge 7 - Private Server', 'episode')).toBe('EP007');
|
||||
expect(sanitizeSharedTitle('Example Movie', 'episode')).toBeNull();
|
||||
expect(sanitizeSharedTitle('Show Name - S01E04', 'hidden')).toBeNull();
|
||||
});
|
||||
|
||||
it('rewrites only present media keys without mutating the input', () => {
|
||||
const input = {
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: 'Private Media',
|
||||
expectedTitle: 'S01E04',
|
||||
title: 'S01E04',
|
||||
currentTime: 42
|
||||
};
|
||||
expect(applyTitlePrivacyToPayload(input, 'hidden')).toEqual({
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: null,
|
||||
expectedTitle: null,
|
||||
title: null,
|
||||
currentTime: 42
|
||||
});
|
||||
expect(input.mediaTitle).toBe('Private Media');
|
||||
expect(applyTitlePrivacyToPayload({ tabTitle: 'Private Tab', status: 'heartbeat' }, 'episode')).toEqual({
|
||||
tabTitle: 'Private Tab',
|
||||
status: 'heartbeat'
|
||||
});
|
||||
});
|
||||
});
|
||||
+5
-1
@@ -15,7 +15,11 @@
|
||||
"subset-flags": "node website/tools/subset-flag-font.mjs",
|
||||
"test": "npm run verify",
|
||||
"test:e2e": "playwright test --config tests/e2e/playwright.config.mjs",
|
||||
"test:e2e:install": "playwright install chromium chromium-headless-shell",
|
||||
"test:e2e:detection": "playwright test --config tests/e2e/playwright.config.mjs --project=detection-chromium --project=detection-firefox --project=detection-webkit",
|
||||
"test:e2e:extension": "playwright test --config tests/e2e/playwright.config.mjs --project=extension-chromium",
|
||||
"test:e2e:install": "playwright install chromium chromium-headless-shell firefox webkit",
|
||||
"test:e2e:race": "playwright test --config tests/e2e/playwright.config.mjs --project=extension-chromium --grep @race --repeat-each=20",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:unit": "vitest run",
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
|
||||
+27
-8
@@ -9,12 +9,14 @@ npm run build:extension
|
||||
npm run verify
|
||||
npm run lint
|
||||
npm run test:unit
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
- `npm run build:extension` runs `scripts/build-extension.cjs`.
|
||||
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
|
||||
- `npm run lint` runs ESLint across the repository.
|
||||
- `npm run test:unit` runs Vitest tests.
|
||||
- `npm run test:coverage` runs the same tests with the enforced coverage floor.
|
||||
|
||||
## build-extension.cjs
|
||||
|
||||
@@ -59,9 +61,9 @@ npm run verify
|
||||
|
||||
It currently runs:
|
||||
|
||||
- Vitest unit tests.
|
||||
- Server ops, route, WebSocket, and rate-limiter checks.
|
||||
- Episode parser, title privacy, audio settings, popup cooldown, names, and content-video-finder checks.
|
||||
- Vitest unit tests with coverage thresholds for importable source modules.
|
||||
- Server route and WebSocket integration checks.
|
||||
- Episode parser, title privacy, host access, blacklist, names, rate limiting, audio settings, popup cooldown, and content-video-finder checks.
|
||||
- JavaScript syntax checks for server and extension entry points.
|
||||
- Extension and website locale coverage checks.
|
||||
- ESLint.
|
||||
@@ -72,19 +74,36 @@ It currently runs:
|
||||
|
||||
| Script | Purpose |
|
||||
|:---|:---|
|
||||
| `test-server-ops.mjs` | Health payload and admin metrics helpers |
|
||||
| `test-server-routes.mjs` | HTTP health routes, caching, and admin metrics access |
|
||||
| `test-server-ws.mjs` | Socket.IO relay integration, including host-control behavior |
|
||||
| `test-rate-limiter.mjs` | Rate-limiter map and cooldown behavior |
|
||||
| `test-episode-utils.mjs` | Episode-title extraction and comparison |
|
||||
| `test-title-privacy.mjs` | Tab/media title privacy sanitization |
|
||||
| `test-audio-settings.mjs` | Audio settings defaults and normalization |
|
||||
| `test-popup-refresh-cooldown.mjs` | Popup refresh throttling behavior |
|
||||
| `test-names.mjs` | Generated username format and coverage |
|
||||
| `test-content-video-finder.cjs` | Content-script video selection helpers |
|
||||
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
|
||||
| `test-website-locales.mjs` | Website locale coverage |
|
||||
|
||||
## Coverage Boundary
|
||||
|
||||
`vitest.config.mjs` covers importable modules executed by Vitest and enforces
|
||||
both global and risk-specific per-module floors. Browser entry points
|
||||
(`background.js`, `content.js`, and `popup.js`) and server process startup are
|
||||
deliberately measured by extension E2E and integration tests instead of being
|
||||
reported as zero-coverage unit code.
|
||||
|
||||
## Published Release Verification
|
||||
|
||||
After a GitHub Release is created, the release workflow runs:
|
||||
|
||||
```bash
|
||||
node scripts/verify-published-release.mjs v3.1.4 --repo Shik3i/KoalaSync
|
||||
```
|
||||
|
||||
The verifier requires the exact three release assets, validates SHA-256 hashes,
|
||||
annotated-tag ancestry, Chrome/Firefox manifest versions and runtime injection,
|
||||
archive parity, unsafe/development-only paths, and GitHub attestations. For a
|
||||
local archive-only diagnosis, pass `--asset-dir PATH`; this deliberately skips
|
||||
GitHub inventory and attestation checks.
|
||||
|
||||
## Do Not Break
|
||||
|
||||
- Keep scripts runnable from the repository root.
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export const RELEASE_ASSET_NAMES = Object.freeze([
|
||||
'koalasync-chrome.zip',
|
||||
'koalasync-firefox.zip',
|
||||
'SHA256SUMS'
|
||||
]);
|
||||
|
||||
const REQUIRED_ARCHIVE_ENTRIES = Object.freeze([
|
||||
'manifest.json',
|
||||
'background.js',
|
||||
'content.js',
|
||||
'popup.html',
|
||||
'shared/constants.js'
|
||||
]);
|
||||
|
||||
export function versionFromTag(tag) {
|
||||
const match = /^v(\d+\.\d+\.\d+)$/u.exec(tag || '');
|
||||
if (!match) throw new Error(`Release tag must match vMAJOR.MINOR.PATCH: ${tag || '<empty>'}`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export function parseChecksumFile(text) {
|
||||
const checksums = new Map();
|
||||
for (const [index, rawLine] of String(text).split(/\r?\n/u).entries()) {
|
||||
if (!rawLine.trim()) continue;
|
||||
const match = /^([a-fA-F0-9]{64}) ([^/\\]+)$/u.exec(rawLine);
|
||||
if (!match) throw new Error(`Invalid SHA256SUMS line ${index + 1}: ${rawLine}`);
|
||||
const [, digest, filename] = match;
|
||||
if (checksums.has(filename)) throw new Error(`Duplicate checksum entry: ${filename}`);
|
||||
checksums.set(filename, digest.toLowerCase());
|
||||
}
|
||||
return checksums;
|
||||
}
|
||||
|
||||
export async function sha256File(filePath) {
|
||||
const hash = crypto.createHash('sha256');
|
||||
for await (const chunk of fs.createReadStream(filePath)) hash.update(chunk);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function validateReleaseAssetNames(assetNames) {
|
||||
const actual = [...new Set(assetNames)].sort();
|
||||
const expected = [...RELEASE_ASSET_NAMES].sort();
|
||||
if (actual.length !== assetNames.length) throw new Error('Release contains duplicate asset names');
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(`Release assets differ: expected ${expected.join(', ')}, got ${actual.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArchiveEntries(browserName, archiveEntries) {
|
||||
if (!Array.isArray(archiveEntries)) throw new Error(`${browserName} archive entries must be an array`);
|
||||
const seen = new Set();
|
||||
const files = new Set();
|
||||
for (const entry of archiveEntries) {
|
||||
if (typeof entry !== 'string' || !entry) throw new Error(`${browserName} archive contains an invalid entry`);
|
||||
if (seen.has(entry)) throw new Error(`${browserName} archive contains duplicate entry: ${entry}`);
|
||||
seen.add(entry);
|
||||
if (entry.startsWith('/')
|
||||
|| /^[A-Za-z]:[\\/]/u.test(entry)
|
||||
|| entry.includes('\\')
|
||||
|| entry.includes('\0')
|
||||
|| entry.split('/').includes('..')) {
|
||||
throw new Error(`${browserName} archive contains unsafe path: ${entry}`);
|
||||
}
|
||||
if (entry.endsWith('/')) continue;
|
||||
files.add(entry);
|
||||
if (/\.test\.[cm]?js$/u.test(entry)
|
||||
|| entry === 'manifest.base.json'
|
||||
|| entry === '.DS_Store'
|
||||
|| entry.endsWith('/.DS_Store')) {
|
||||
throw new Error(`${browserName} archive contains development-only file: ${entry}`);
|
||||
}
|
||||
}
|
||||
for (const required of REQUIRED_ARCHIVE_ENTRIES) {
|
||||
if (!seen.has(required)) throw new Error(`${browserName} archive is missing ${required}`);
|
||||
}
|
||||
return [...files].sort();
|
||||
}
|
||||
|
||||
export function validateManifest(browserName, manifest, expectedVersion) {
|
||||
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
|
||||
throw new Error(`${browserName} manifest must be a JSON object`);
|
||||
}
|
||||
if (manifest.version !== expectedVersion) {
|
||||
throw new Error(`${browserName} manifest version ${manifest.version || '<missing>'} does not match ${expectedVersion}`);
|
||||
}
|
||||
if (manifest.manifest_version !== 3) {
|
||||
throw new Error(`${browserName} manifest must use Manifest V3`);
|
||||
}
|
||||
if (browserName === 'chrome') {
|
||||
if (manifest.background?.service_worker !== 'background.js') {
|
||||
throw new Error('Chrome manifest must use background.js as its service worker');
|
||||
}
|
||||
if (manifest.background?.type !== 'module') throw new Error('Chrome background must be an ES module');
|
||||
if (manifest.browser_specific_settings?.gecko) {
|
||||
throw new Error('Chrome manifest must not contain Firefox gecko settings');
|
||||
}
|
||||
} else if (browserName === 'firefox') {
|
||||
if (!Array.isArray(manifest.background?.scripts)
|
||||
|| manifest.background.scripts.length !== 1
|
||||
|| manifest.background.scripts[0] !== 'background.js') {
|
||||
throw new Error('Firefox manifest must use background.js as its background script');
|
||||
}
|
||||
if (manifest.background?.type !== 'module') throw new Error('Firefox background must be an ES module');
|
||||
if (manifest.browser_specific_settings?.gecko?.id !== 'koalasync@koalastuff.net') {
|
||||
throw new Error('Firefox manifest is missing the expected extension ID');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported browser archive: ${browserName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArchiveParity(chromeEntries, firefoxEntries) {
|
||||
const chrome = [...chromeEntries].sort();
|
||||
const firefox = [...firefoxEntries].sort();
|
||||
if (JSON.stringify(chrome) !== JSON.stringify(firefox)) {
|
||||
const chromeOnly = chrome.filter(entry => !firefox.includes(entry));
|
||||
const firefoxOnly = firefox.filter(entry => !chrome.includes(entry));
|
||||
throw new Error(`Archive contents differ; Chrome only: ${chromeOnly.join(', ') || '<none>'}; Firefox only: ${firefoxOnly.join(', ') || '<none>'}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseChecksumFile,
|
||||
sha256File,
|
||||
validateArchiveEntries,
|
||||
validateArchiveParity,
|
||||
validateManifest,
|
||||
validateReleaseAssetNames,
|
||||
versionFromTag
|
||||
} from './release-artifact-checks.mjs';
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const directory of temporaryDirectories.splice(0)) {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('published release artifact checks', () => {
|
||||
it('accepts semantic release tags and rejects ambiguous versions', () => {
|
||||
expect(versionFromTag('v3.1.4')).toBe('3.1.4');
|
||||
for (const invalid of ['3.1.4', 'v3.1', 'v3.1.4-beta', '', null]) {
|
||||
expect(() => versionFromTag(invalid)).toThrow('vMAJOR.MINOR.PATCH');
|
||||
}
|
||||
});
|
||||
|
||||
it('parses strict sha256sum output and rejects duplicate or unsafe names', () => {
|
||||
const digest = 'a'.repeat(64);
|
||||
expect(parseChecksumFile(`${digest} koalasync-chrome.zip\n`).get('koalasync-chrome.zip')).toBe(digest);
|
||||
expect(() => parseChecksumFile(`${digest} *koalasync-chrome.zip`)).toThrow('Invalid SHA256SUMS line');
|
||||
expect(() => parseChecksumFile(`${digest} ../koalasync-chrome.zip`)).toThrow('Invalid SHA256SUMS line');
|
||||
expect(() => parseChecksumFile(`${digest} chrome.zip\n${digest} chrome.zip`)).toThrow('Duplicate checksum');
|
||||
});
|
||||
|
||||
it('computes file digests without platform-specific checksum commands', async () => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-checksum-test-'));
|
||||
temporaryDirectories.push(directory);
|
||||
const filePath = path.join(directory, 'fixture.txt');
|
||||
fs.writeFileSync(filePath, 'koalasync\n');
|
||||
await expect(sha256File(filePath)).resolves.toBe('2ee7e74af89fb4f42d4fa1bcf93c588bf4460a62c8def5c254bba7b5ae6cd544');
|
||||
});
|
||||
|
||||
it('requires the exact public release asset inventory', () => {
|
||||
expect(() => validateReleaseAssetNames([
|
||||
'koalasync-firefox.zip',
|
||||
'SHA256SUMS',
|
||||
'koalasync-chrome.zip'
|
||||
])).not.toThrow();
|
||||
expect(() => validateReleaseAssetNames(['koalasync-chrome.zip'])).toThrow('Release assets differ');
|
||||
expect(() => validateReleaseAssetNames([
|
||||
'koalasync-chrome.zip',
|
||||
'koalasync-firefox.zip',
|
||||
'SHA256SUMS',
|
||||
'debug.log'
|
||||
])).toThrow('Release assets differ');
|
||||
expect(() => validateReleaseAssetNames([
|
||||
'koalasync-chrome.zip',
|
||||
'koalasync-firefox.zip',
|
||||
'SHA256SUMS',
|
||||
'SHA256SUMS'
|
||||
])).toThrow('duplicate asset names');
|
||||
});
|
||||
|
||||
it('rejects missing, duplicate, traversal, and development-only archive entries', () => {
|
||||
const valid = ['manifest.json', 'background.js', 'content.js', 'popup.html', 'shared/constants.js'];
|
||||
expect(validateArchiveEntries('chrome', valid)).toEqual([...valid].sort());
|
||||
expect(validateArchiveEntries('chrome', [...valid, 'assets/'])).toEqual([...valid].sort());
|
||||
expect(() => validateArchiveEntries('chrome', null)).toThrow('entries must be an array');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, ''])).toThrow('invalid entry');
|
||||
expect(() => validateArchiveEntries('chrome', valid.slice(1))).toThrow('missing manifest.json');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, 'content.js'])).toThrow('duplicate entry');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, '../secret'])).toThrow('unsafe path');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, '../'])).toThrow('unsafe path');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, 'C:/secret'])).toThrow('unsafe path');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, '..\\secret'])).toThrow('unsafe path');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, 'content.test.mjs'])).toThrow('development-only');
|
||||
expect(() => validateArchiveEntries('chrome', [...valid, 'assets/.DS_Store'])).toThrow('development-only');
|
||||
});
|
||||
|
||||
it('validates browser-specific manifests and version alignment', () => {
|
||||
expect(() => validateManifest('chrome', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { service_worker: 'background.js', type: 'module' }
|
||||
}, '3.1.4')).not.toThrow();
|
||||
expect(() => validateManifest('firefox', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { scripts: ['background.js'], type: 'module' },
|
||||
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
|
||||
}, '3.1.4')).not.toThrow();
|
||||
expect(() => validateManifest('chrome', {
|
||||
version: '3.1.3',
|
||||
manifest_version: 3,
|
||||
background: { service_worker: 'background.js', type: 'module' }
|
||||
}, '3.1.4')).toThrow('does not match 3.1.4');
|
||||
|
||||
expect(() => validateManifest('chrome', null, '3.1.4')).toThrow('must be a JSON object');
|
||||
expect(() => validateManifest('chrome', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 2,
|
||||
background: { service_worker: 'background.js', type: 'module' }
|
||||
}, '3.1.4')).toThrow('Manifest V3');
|
||||
expect(() => validateManifest('chrome', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { service_worker: 'wrong.js', type: 'module' }
|
||||
}, '3.1.4')).toThrow('service worker');
|
||||
expect(() => validateManifest('chrome', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { service_worker: 'background.js', type: 'classic' }
|
||||
}, '3.1.4')).toThrow('ES module');
|
||||
expect(() => validateManifest('chrome', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { service_worker: 'background.js', type: 'module' },
|
||||
browser_specific_settings: { gecko: { id: 'unexpected@example.test' } }
|
||||
}, '3.1.4')).toThrow('must not contain Firefox');
|
||||
expect(() => validateManifest('firefox', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { scripts: ['wrong.js'], type: 'module' },
|
||||
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
|
||||
}, '3.1.4')).toThrow('background script');
|
||||
expect(() => validateManifest('firefox', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { scripts: ['background.js'], type: 'classic' },
|
||||
browser_specific_settings: { gecko: { id: 'koalasync@koalastuff.net' } }
|
||||
}, '3.1.4')).toThrow('ES module');
|
||||
expect(() => validateManifest('firefox', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3,
|
||||
background: { scripts: ['background.js'], type: 'module' }
|
||||
}, '3.1.4')).toThrow('expected extension ID');
|
||||
expect(() => validateManifest('safari', {
|
||||
version: '3.1.4',
|
||||
manifest_version: 3
|
||||
}, '3.1.4')).toThrow('Unsupported browser');
|
||||
});
|
||||
|
||||
it('requires Chrome and Firefox to ship the same file set', () => {
|
||||
expect(() => validateArchiveParity(['a', 'b'], ['b', 'a'])).not.toThrow();
|
||||
expect(() => validateArchiveParity(['a', 'chrome-only'], ['a', 'firefox-only'])).toThrow(
|
||||
'Chrome only: chrome-only; Firefox only: firefox-only'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
BLACKLIST_DOMAINS,
|
||||
BLACKLIST_OVERRIDES_STORAGE_KEY,
|
||||
BLACKLIST_SOURCE_DEFAULT,
|
||||
BLACKLIST_SOURCE_USER,
|
||||
CUSTOM_BLACKLIST_STORAGE_KEY,
|
||||
createEmptyBlacklistOverrides,
|
||||
deriveBlacklistOverrides,
|
||||
getBlacklistEntries,
|
||||
getEffectiveBlacklistDomains,
|
||||
isUrlBlacklisted,
|
||||
normalizeBlacklistDomain,
|
||||
normalizeBlacklistOverrides,
|
||||
parseBlacklistDomains
|
||||
} from '../shared/blacklist.js';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
assert.equal(CUSTOM_BLACKLIST_STORAGE_KEY, 'customBlacklistDomains');
|
||||
assert.equal(normalizeBlacklistDomain(' Example.COM. '), 'example.com');
|
||||
assert.equal(normalizeBlacklistDomain('https://Video.Example.com/watch/123'), 'video.example.com');
|
||||
assert.equal(normalizeBlacklistDomain('*.example.com'), null, 'wildcards are rejected');
|
||||
assert.equal(normalizeBlacklistDomain('not a domain'), null, 'spaces are rejected');
|
||||
|
||||
const parsed = parseBlacklistDomains('Example.com\nhttps://sub.example.com/path\nexample.com\n');
|
||||
assert.deepEqual(parsed.domains, ['example.com', 'sub.example.com'], 'domains are normalized and deduplicated');
|
||||
assert.deepEqual(parsed.invalid, []);
|
||||
|
||||
const invalid = parseBlacklistDomains('example.com\nnot a domain');
|
||||
assert.deepEqual(invalid.invalid, ['not a domain'], 'invalid entries are reported without partial silent saves');
|
||||
|
||||
assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'missing local setting uses shipped defaults');
|
||||
assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'an explicitly empty local list stays empty');
|
||||
assert.equal(isUrlBlacklisted('https://mail.google.com/inbox', ['google.com']), true, 'subdomains match a parent domain');
|
||||
assert.equal(isUrlBlacklisted('https://notgoogle.com/', ['google.com']), false, 'lookalike domains do not match');
|
||||
assert.equal(isUrlBlacklisted('not a url', ['example.com']), false, 'invalid URLs are ignored');
|
||||
|
||||
// --- Delta storage: shipped defaults keep flowing in after the user edits ---
|
||||
|
||||
assert.equal(BLACKLIST_OVERRIDES_STORAGE_KEY, 'blacklistOverrides');
|
||||
assert.deepEqual(createEmptyBlacklistOverrides(), { removedDefaults: [], addedDomains: [] });
|
||||
|
||||
// A user who removes two defaults and adds one of their own.
|
||||
const edited = BLACKLIST_DOMAINS
|
||||
.filter(domain => domain !== 'reddit.com' && domain !== 'imgur.com')
|
||||
.concat(['videos.example']);
|
||||
const overrides = deriveBlacklistOverrides(edited);
|
||||
assert.deepEqual(overrides.removedDefaults, ['reddit.com', 'imgur.com'], 'only the removed defaults are stored');
|
||||
assert.deepEqual(overrides.addedDomains, ['videos.example'], 'only the added domains are stored');
|
||||
|
||||
const effective = getEffectiveBlacklistDomains(overrides);
|
||||
const effectiveDomains = new Set(effective);
|
||||
assert.equal(effectiveDomains.has('reddit.com'), false, 'a removed default stays removed');
|
||||
assert.equal(effectiveDomains.has('videos.example'), true, 'an added domain stays added');
|
||||
|
||||
// The property that makes newly shipped defaults reach existing users: every
|
||||
// shipped domain the user did not explicitly remove is part of the result, so a
|
||||
// default added in a later version cannot be missing from a stored delta.
|
||||
const removedSet = new Set(overrides.removedDefaults);
|
||||
for (const domain of BLACKLIST_DOMAINS) {
|
||||
assert.equal(
|
||||
effectiveDomains.has(domain) || removedSet.has(domain),
|
||||
true,
|
||||
`shipped default ${domain} must be present unless explicitly removed`
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy full-list snapshots migrate to the delta form.
|
||||
assert.deepEqual(
|
||||
deriveBlacklistOverrides(edited),
|
||||
normalizeBlacklistOverrides(overrides),
|
||||
'a legacy snapshot produces the same delta'
|
||||
);
|
||||
assert.deepEqual(getEffectiveBlacklistDomains(undefined), BLACKLIST_DOMAINS, 'no stored delta uses shipped defaults');
|
||||
assert.deepEqual(getEffectiveBlacklistDomains([]), [], 'a legacy empty snapshot still means no filtering');
|
||||
|
||||
// Re-adding a removed default clears the removal instead of stacking state.
|
||||
const readded = deriveBlacklistOverrides(effective.concat(['reddit.com']), overrides);
|
||||
const readdedRemovedDefaults = new Set(readded.removedDefaults);
|
||||
assert.equal(readdedRemovedDefaults.has('reddit.com'), false, 're-adding a default clears its removal');
|
||||
|
||||
// A domain the user added explicitly stays tagged as theirs even once the same
|
||||
// domain ships as a default, so dropping the default does not drop their entry.
|
||||
const stillUser = deriveBlacklistOverrides(['google.com'], { removedDefaults: [], addedDomains: ['google.com'] });
|
||||
assert.deepEqual(stillUser.addedDomains, ['google.com'], 'an explicit addition survives becoming a default');
|
||||
|
||||
// Contradictory stored state resolves in favour of the addition.
|
||||
assert.deepEqual(
|
||||
normalizeBlacklistOverrides({ removedDefaults: ['example.com'], addedDomains: ['example.com'] }),
|
||||
{ removedDefaults: [], addedDomains: ['example.com'] },
|
||||
'a domain cannot be removed and added at once'
|
||||
);
|
||||
assert.deepEqual(normalizeBlacklistOverrides('nonsense'), createEmptyBlacklistOverrides(), 'garbage storage falls back to defaults');
|
||||
|
||||
// Entries are tagged so the editor can show what came from where.
|
||||
const entries = getBlacklistEntries(overrides);
|
||||
assert.equal(entries.find(e => e.domain === 'videos.example').source, BLACKLIST_SOURCE_USER);
|
||||
assert.equal(entries.find(e => e.domain === 'google.com').source, BLACKLIST_SOURCE_DEFAULT);
|
||||
|
||||
// Comment lines are editor notes, not domains, and never count as invalid.
|
||||
const withComments = parseBlacklistDomains('# your entries\nvideos.example\n\n#shipped defaults\ngoogle.com');
|
||||
assert.deepEqual(withComments.domains, ['videos.example', 'google.com'], 'comment lines are skipped');
|
||||
assert.deepEqual(withComments.invalid, [], 'comment lines are not reported as invalid');
|
||||
|
||||
// Round trip through the grouped editor body: rendering with comment headers
|
||||
// and saving it again must not change the stored delta.
|
||||
const rendered = [
|
||||
'# Your entries',
|
||||
...entries.filter(e => e.source === BLACKLIST_SOURCE_USER).map(e => e.domain),
|
||||
'',
|
||||
'# Shipped defaults',
|
||||
...entries.filter(e => e.source === BLACKLIST_SOURCE_DEFAULT).map(e => e.domain)
|
||||
].join('\n');
|
||||
const roundTripped = parseBlacklistDomains(rendered);
|
||||
assert.deepEqual(roundTripped.invalid, [], 'the rendered editor body contains no invalid entries');
|
||||
assert.deepEqual(
|
||||
deriveBlacklistOverrides(roundTripped.domains, overrides),
|
||||
normalizeBlacklistOverrides(overrides),
|
||||
'render then save leaves the delta unchanged'
|
||||
);
|
||||
|
||||
const popupSource = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
|
||||
assert.match(popupSource, /chrome\.storage\.local\.set\(\{ \[BLACKLIST_OVERRIDES_STORAGE_KEY\]: overrides \}\)/, 'the delta is saved locally');
|
||||
assert.doesNotMatch(popupSource, /chrome\.storage\.sync\.set\(\{ \[(?:BLACKLIST_OVERRIDES|CUSTOM_BLACKLIST)_STORAGE_KEY\]/, 'the list is never synced');
|
||||
assert.match(popupSource, /chrome\.storage\.local\.remove\(CUSTOM_BLACKLIST_STORAGE_KEY\)/, 'the legacy snapshot is cleaned up after migration');
|
||||
assert.match(popupSource, /isUrlBlacklisted\(tab\.url, blacklistDomains\)/, 'tab filtering uses the effective custom list');
|
||||
|
||||
// A broad parent domain must not hide a host with a dedicated player path,
|
||||
// but an exact user entry for that host still filters it.
|
||||
assert.equal(isUrlBlacklisted('https://drive.google.com/file/d/x/view', BLACKLIST_DOMAINS), false);
|
||||
assert.equal(isUrlBlacklisted('https://drive.google.com/file/d/x/view', ['drive.google.com']), true);
|
||||
assert.equal(isUrlBlacklisted('https://docs.google.com/document/d/x', BLACKLIST_DOMAINS), true);
|
||||
assert.equal(isUrlBlacklisted('https://mail.google.com/mail/u/0', BLACKLIST_DOMAINS), true);
|
||||
|
||||
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
|
||||
assert.match(popupHtml, /id="blacklistDomains"/, 'settings UI contains the editable domain list');
|
||||
assert.match(popupHtml, /id="blacklistReset"/, 'settings UI contains a defaults reset');
|
||||
|
||||
console.log('blacklist settings tests passed');
|
||||
@@ -1,76 +0,0 @@
|
||||
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');
|
||||
@@ -1,180 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { cwd } from 'node:process';
|
||||
import {
|
||||
HOST_ACCESS_REQUIRED_STATUS,
|
||||
addTabHostAccessRequest,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
normalizeTabId,
|
||||
removeTabHostAccessRequest,
|
||||
requestOriginPermission
|
||||
} from '../extension/host-access.js';
|
||||
|
||||
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
|
||||
assert.equal(normalizeTabId(null), null);
|
||||
assert.equal(normalizeTabId(undefined), null);
|
||||
assert.equal(normalizeTabId(''), null);
|
||||
assert.equal(normalizeTabId(0), null);
|
||||
assert.equal(normalizeTabId('42'), 42);
|
||||
assert.equal(normalizeTabId(true), null);
|
||||
assert.equal(normalizeTabId([42]), null);
|
||||
assert.equal(normalizeTabId('42.5'), null);
|
||||
assert.equal(normalizeTabId(' 42 '), 42);
|
||||
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
|
||||
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
|
||||
url: 'https://emby.example:8443/web/index.html',
|
||||
host: 'emby.example:8443',
|
||||
originPattern: 'https://emby.example:8443/*'
|
||||
});
|
||||
assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
|
||||
url: 'http://localhost:8096/web/',
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost:8096/*'
|
||||
});
|
||||
assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), {
|
||||
url: 'http://localhost:8096/web/',
|
||||
host: 'localhost:8096',
|
||||
originPattern: 'http://localhost/*'
|
||||
});
|
||||
assert.equal(describeTabUrl('chrome://extensions/'), null);
|
||||
assert.equal(describeTabUrl('not a url'), null);
|
||||
|
||||
let containsRequest = null;
|
||||
const deniedChrome = {
|
||||
tabs: {
|
||||
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
|
||||
},
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const access = await inspectTabHostAccess(deniedChrome, 42);
|
||||
assert.equal(access.granted, false);
|
||||
assert.equal(access.host, 'video.example');
|
||||
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
|
||||
|
||||
let firefoxContainsRequest = null;
|
||||
const firefoxChrome = {
|
||||
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
|
||||
tabs: {
|
||||
get: async tabId => ({
|
||||
id: tabId,
|
||||
url: 'http://localhost:8096/web/',
|
||||
pendingUrl: 'https://different.example/loading'
|
||||
})
|
||||
},
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
firefoxContainsRequest = request;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42);
|
||||
assert.equal(firefoxAccess.host, 'localhost:8096');
|
||||
assert.equal(firefoxAccess.originPattern, 'http://localhost/*');
|
||||
assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] });
|
||||
|
||||
const unknownPermissionChrome = {
|
||||
runtime: {},
|
||||
tabs: {
|
||||
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
|
||||
},
|
||||
permissions: {
|
||||
contains: (_request, callback) => { callback(undefined); }
|
||||
}
|
||||
};
|
||||
assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null);
|
||||
|
||||
let requestedTabId = null;
|
||||
const requestChrome = {
|
||||
permissions: {
|
||||
addHostAccessRequest: async request => { requestedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
let removedTabId = null;
|
||||
const removeRequestChrome = {
|
||||
permissions: {
|
||||
removeHostAccessRequest: async request => { removedTabId = request; }
|
||||
}
|
||||
};
|
||||
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
|
||||
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
|
||||
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
|
||||
|
||||
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
|
||||
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
|
||||
const callbackPermissionChrome = {
|
||||
runtime: {},
|
||||
permissions: {
|
||||
request: (_request, callback) => { callback(true); }
|
||||
}
|
||||
};
|
||||
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
|
||||
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
|
||||
|
||||
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
|
||||
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
|
||||
const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8');
|
||||
|
||||
assert.match(background, /await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/,
|
||||
'SET_TARGET_TAB must await successful activation before acknowledging it');
|
||||
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
|
||||
'failed injection must register Chrome host-access request');
|
||||
assert.match(background, /retryPendingTarget\(\)/,
|
||||
'pending target must resume after the user grants access');
|
||||
assert.match(background, /activationGeneration !== targetActivationGeneration/,
|
||||
'stale concurrent tab activations must not overwrite the newest selection');
|
||||
assert.match(background, /pendingTargetRequestId/,
|
||||
'pending access recovery must use an identity token');
|
||||
assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/,
|
||||
'unrelated permission grants must not activate a pending target');
|
||||
assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/,
|
||||
'stale content-routing retries must not reactivate an old target');
|
||||
assert.match(background, /message\.expectedTabId/,
|
||||
'popup playback events must be rejected after their target changes');
|
||||
assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/,
|
||||
'a target switch must finish an in-flight force sync on the old target');
|
||||
assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/,
|
||||
'stale content scripts must not acknowledge force sync for a new target');
|
||||
const activateTargetBody = background.slice(
|
||||
background.indexOf('async function activateTargetTab'),
|
||||
background.indexOf('async function retryPendingTarget')
|
||||
);
|
||||
assert.ok(
|
||||
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
|
||||
'a tab must not become current until its content script injection succeeds'
|
||||
);
|
||||
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
|
||||
'clearing a pending target must also clear Chrome toolbar access requests');
|
||||
assert.match(popup, /response\?\.status === 'host_permission_required'/,
|
||||
'popup must render the structured host-access failure');
|
||||
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
|
||||
'retry button must request withheld host access directly');
|
||||
assert.match(popup, /expectedCurrentTabId: tabId/,
|
||||
'manual reinjection must be tied to the selected target identity');
|
||||
assert.match(popup, /expectedTabId: tabId/,
|
||||
'force sync must be tied to the tab whose time was sampled');
|
||||
assert.doesNotMatch(tabManager, /injectContentScript/,
|
||||
'tab reload recovery must use the guarded background activation path');
|
||||
assert.equal(
|
||||
(background.match(/tabs\.onRemoved\.addListener/g) || []).length
|
||||
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length,
|
||||
1,
|
||||
'target-tab closure must have exactly one state owner'
|
||||
);
|
||||
assert.match(popupHtml, /id="siteAccessNotice"/,
|
||||
'popup must contain a persistent site-access notice');
|
||||
|
||||
console.log('host access recovery tests passed');
|
||||
@@ -1,56 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from '../shared/names.js';
|
||||
|
||||
// --- getAvatarForName (deterministic) ---
|
||||
|
||||
// Exact matches
|
||||
assert.equal(getAvatarForName('Koala'), '🐨', 'Koala');
|
||||
assert.equal(getAvatarForName('Tiger'), '🐯', 'Tiger');
|
||||
assert.equal(getAvatarForName('Panda'), '🐼', 'Panda');
|
||||
assert.equal(getAvatarForName('Fox'), '🦊', 'Fox');
|
||||
|
||||
// Case insensitive
|
||||
assert.equal(getAvatarForName('koala'), '🐨', 'lowercase');
|
||||
assert.equal(getAvatarForName('MyKoalaUser'), '🐨', 'embedded uppercase');
|
||||
|
||||
// Longest match wins (caterpillar > cat)
|
||||
assert.equal(getAvatarForName('CaterpillarCat'), '🐛', 'caterpillar before cat');
|
||||
assert.equal(getAvatarForName('Cat'), '🐱', 'cat alone');
|
||||
|
||||
// Emoji with ZWJ sequences (multi-codepoint)
|
||||
assert.equal(getAvatarForName('Polar'), '🐻\u200D❄️', 'polar bear ZWJ');
|
||||
assert.equal(getAvatarForName('Crow'), '🐦\u200D⬛', 'crow ZWJ');
|
||||
|
||||
// Human-like characters
|
||||
assert.equal(getAvatarForName('Ninja'), '🥷', 'ninja');
|
||||
assert.equal(getAvatarForName('Wizard'), '🧙', 'wizard');
|
||||
assert.equal(getAvatarForName('Pirate'), '🏴', 'pirate');
|
||||
assert.equal(getAvatarForName('Alien'), '👾', 'alien');
|
||||
assert.equal(getAvatarForName('Robot'), '🤖', 'robot');
|
||||
|
||||
// Fallback
|
||||
assert.equal(getAvatarForName(''), '👤', 'empty string');
|
||||
assert.equal(getAvatarForName('Xyzzy123'), '👤', 'unknown name');
|
||||
assert.equal(getAvatarForName(null), '👤', 'null');
|
||||
assert.equal(getAvatarForName(undefined), '👤', 'undefined');
|
||||
|
||||
// --- generateUsername (format check) ---
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const name = generateUsername();
|
||||
// Format: AdjectiveNoun (e.g. "HappyKoala")
|
||||
assert.ok(/^[A-Z][a-z]+[A-Z][a-z]+$/.test(name), `format: ${name}`);
|
||||
// Adjective from list
|
||||
const adj = USERNAME_ADJECTIVES.some(a => name.startsWith(a));
|
||||
assert.ok(adj, `adjective from list: ${name}`);
|
||||
// Noun from list
|
||||
const noun = USERNAME_NOUNS.some(n => name.endsWith(n));
|
||||
assert.ok(noun, `noun from list: ${name}`);
|
||||
}
|
||||
|
||||
// Every noun has an emoji (no broken usernames)
|
||||
for (const noun of USERNAME_NOUNS) {
|
||||
const avatar = getAvatarForName(noun);
|
||||
assert.notEqual(avatar, '👤', `noun "${noun}" has no emoji — add to ANIMAL_EMOJI_MAP`);
|
||||
}
|
||||
|
||||
console.log('names tests passed');
|
||||
@@ -1,131 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
checkAuthRate,
|
||||
recordAuthFailure,
|
||||
clearRateLimitMaps,
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
leaveRoomCounts,
|
||||
rateLimitDenied,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup,
|
||||
CONNECTION_RATE_LIMIT,
|
||||
EVENT_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_LIMIT
|
||||
} 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, leaveRoom: 0 });
|
||||
stopRateLimitCleanup();
|
||||
}
|
||||
|
||||
// --- checkConnectionRate ---
|
||||
reset();
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed');
|
||||
// Exhaust the rest of the budget (first call above counted as 1).
|
||||
for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1');
|
||||
assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window 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');
|
||||
// Exhaust the rest of the budget (first call above counted as 1).
|
||||
for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1');
|
||||
assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`);
|
||||
assert.equal(rateLimitDenied.events, 1);
|
||||
|
||||
reset();
|
||||
assert.equal(checkEventRate('sock2'), true, 'separate socket independent');
|
||||
|
||||
// --- checkLeaveRoomRate ---
|
||||
reset();
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-1'), true, 'first leave-room event allowed');
|
||||
for (let i = 0; i < LEAVE_ROOM_RATE_LIMIT - 1; i++) checkLeaveRoomRate('sock-leave-1');
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-1'), false, `leave-room beyond ${LEAVE_ROOM_RATE_LIMIT}/window blocked`);
|
||||
assert.equal(rateLimitDenied.leaveRoom, 1);
|
||||
|
||||
reset();
|
||||
assert.equal(checkLeaveRoomRate('sock-leave-2'), true, 'separate leave-room 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());
|
||||
leaveRoomCounts.set('sock3', { count: 1, resetTime: Date.now() + 60000 });
|
||||
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');
|
||||
assert.equal(leaveRoomCounts.size, 0, 'leaveRoomCounts 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;
|
||||
rateLimitDenied.leaveRoom = 5;
|
||||
Object.assign(rateLimitDenied, { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 0 });
|
||||
assert.equal(rateLimitDenied.connections, 0, 'denial counter resettable');
|
||||
assert.equal(rateLimitDenied.leaveRoom, 0, 'leave-room denial counter resettable');
|
||||
|
||||
console.log('rate-limiter tests passed');
|
||||
@@ -1,89 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from '../server/ops.js';
|
||||
|
||||
const missingAuth = isAdminMetricsAuthorized(undefined, 'secret-token');
|
||||
assert.equal(missingAuth, false, 'missing Authorization header must not authorize metrics');
|
||||
|
||||
const wrongAuth = isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token');
|
||||
assert.equal(wrongAuth, false, 'wrong bearer token must not authorize metrics');
|
||||
|
||||
const correctAuth = isAdminMetricsAuthorized('Bearer secret-token', 'secret-token');
|
||||
assert.equal(correctAuth, true, 'correct bearer token should authorize metrics');
|
||||
|
||||
const disabledAuth = isAdminMetricsAuthorized('Bearer secret-token', '');
|
||||
assert.equal(disabledAuth, false, 'empty admin token disables admin metrics');
|
||||
|
||||
assert.equal(isAdminMetricsTokenStrong(''), true, 'empty admin token is allowed because metrics stay disabled');
|
||||
assert.equal(isAdminMetricsTokenStrong('short-token'), false, 'short admin token should be reported as weak');
|
||||
assert.equal(
|
||||
isAdminMetricsTokenStrong('a'.repeat(32)),
|
||||
true,
|
||||
'admin token with at least 32 characters should be considered strong'
|
||||
);
|
||||
|
||||
const cooldowns = new Map();
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000), true, 'first cooldown check passes');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000), false, 'second cooldown check inside window fails');
|
||||
assert.equal(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000), true, 'cooldown check after window passes');
|
||||
|
||||
const cache = new Map();
|
||||
let buildCalls = 0;
|
||||
const firstCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 1_000);
|
||||
const secondCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 30_000);
|
||||
const expiredCached = getCachedPayload(cache, 'basic-health', 60_000, () => ({ value: ++buildCalls }), 61_001);
|
||||
assert.deepEqual(firstCached, { value: 1 }, 'cache should return the builder payload on first request');
|
||||
assert.strictEqual(secondCached, firstCached, 'cache should reuse payloads inside the ttl');
|
||||
assert.deepEqual(expiredCached, { value: 2 }, 'cache should rebuild payloads after ttl expiry');
|
||||
|
||||
const roomA = { peers: new Set(['a', 'b']), activeLobby: null };
|
||||
const roomB = { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } };
|
||||
const rooms = new Map([['room-a', roomA], ['room-b', roomB]]);
|
||||
|
||||
const basicHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: false,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 }
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
Object.keys(basicHealth).sort(),
|
||||
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort(),
|
||||
'basic health should not expose extended metrics'
|
||||
);
|
||||
|
||||
const adminHealth = buildHealthPayload({
|
||||
rooms,
|
||||
connections: 5,
|
||||
includeMetrics: true,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
|
||||
rateLimitDenied: { leaveRoom: 8 }
|
||||
});
|
||||
|
||||
assert.equal(adminHealth.peers, 5, 'admin metrics should include aggregate peer count');
|
||||
assert.equal(adminHealth.roomsWithLobby, 1, 'admin metrics should count active lobbies');
|
||||
assert.equal(adminHealth.avgPeersPerRoom, 2.5, 'admin metrics should include average room size');
|
||||
assert.equal(adminHealth.maxPeersInRoom, 3, 'admin metrics should include max room size');
|
||||
assert.deepEqual(adminHealth.memory, { rss: 10, heapUsed: 5, heapTotal: 8 }, 'admin metrics should expose process memory');
|
||||
assert.deepEqual(
|
||||
adminHealth.rateLimits,
|
||||
{
|
||||
trackedClients: { connections: 1, events: 2, health: 3, adminMetricsAuth: 4, authFailures: 5, roomList: 6, leaveRoom: 7 },
|
||||
denied: { connections: 0, events: 0, health: 0, adminMetricsAuth: 0, roomList: 0, leaveRoom: 8 }
|
||||
},
|
||||
'admin metrics should expose rate-limit tracking and denial counts'
|
||||
);
|
||||
|
||||
console.log('server ops tests passed');
|
||||
@@ -1,87 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TITLE_PRIVACY_MODES,
|
||||
applyTitlePrivacyToPayload,
|
||||
normalizeSendTabTitle,
|
||||
normalizeTabTitle,
|
||||
normalizeTitlePrivacyMode,
|
||||
sanitizeSharedTitle,
|
||||
sanitizeTabTitle
|
||||
} from '../extension/title-privacy.js';
|
||||
|
||||
assert.equal(normalizeTitlePrivacyMode(undefined), TITLE_PRIVACY_MODES.FULL);
|
||||
assert.equal(normalizeTitlePrivacyMode('unknown'), TITLE_PRIVACY_MODES.FULL);
|
||||
assert.equal(normalizeTitlePrivacyMode(TITLE_PRIVACY_MODES.HIDDEN), TITLE_PRIVACY_MODES.HIDDEN);
|
||||
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.FULL), true);
|
||||
assert.equal(normalizeSendTabTitle(undefined, TITLE_PRIVACY_MODES.EPISODE), false);
|
||||
assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
|
||||
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
|
||||
assert.equal(normalizeTabTitle('(12) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('[7] Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(99+) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(999+) Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('[999+] Testvideo - YouTube'), 'Testvideo - YouTube');
|
||||
assert.equal(normalizeTabTitle('(500) Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle('(101) Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle('[101] Days of Summer'), 'Days of Summer');
|
||||
assert.equal(normalizeTabTitle(' '), null);
|
||||
|
||||
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
|
||||
assert.equal(sanitizeTabTitle('(12) Private Tab', true), 'Private Tab');
|
||||
assert.equal(sanitizeTabTitle('Private Tab', false), null);
|
||||
assert.equal(sanitizeTabTitle('', true), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Example Movie', 'full'), 'Example Movie');
|
||||
assert.equal(sanitizeSharedTitle('', 'full'), null);
|
||||
assert.equal(sanitizeSharedTitle(null, 'full'), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Show Name - S01/E04 - Title', 'episode'), 'S01E04');
|
||||
assert.equal(sanitizeSharedTitle('Folge 7 - Private Server', 'episode'), 'EP007');
|
||||
assert.equal(sanitizeSharedTitle('Example Movie', 'episode'), null);
|
||||
|
||||
assert.equal(sanitizeSharedTitle('Show Name - S01E04', 'hidden'), null);
|
||||
assert.equal(sanitizeSharedTitle('Private Tab Title', 'hidden'), null);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
mediaTitle: 'Show Name - S01E04',
|
||||
currentTime: 42
|
||||
}, 'episode'),
|
||||
{
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
mediaTitle: 'S01E04',
|
||||
currentTime: 42
|
||||
},
|
||||
'media privacy must not rewrite tabTitle'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
status: 'heartbeat'
|
||||
}, 'episode'),
|
||||
{
|
||||
tabTitle: 'Private Jellyfin - S01E04',
|
||||
status: 'heartbeat'
|
||||
},
|
||||
'media privacy must not rewrite tabTitle or add absent media keys'
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
applyTitlePrivacyToPayload({
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: 'Private Media',
|
||||
expectedTitle: 'S01E04',
|
||||
title: 'S01E04'
|
||||
}, 'hidden'),
|
||||
{
|
||||
tabTitle: 'Private Tab',
|
||||
mediaTitle: null,
|
||||
expectedTitle: null,
|
||||
title: null
|
||||
},
|
||||
'hidden media privacy must not clear tabTitle'
|
||||
);
|
||||
|
||||
console.log('title-privacy tests passed');
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
parseChecksumFile,
|
||||
RELEASE_ASSET_NAMES,
|
||||
sha256File,
|
||||
validateArchiveEntries,
|
||||
validateArchiveParity,
|
||||
validateManifest,
|
||||
validateReleaseAssetNames,
|
||||
versionFromTag
|
||||
} from './release-artifact-checks.mjs';
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { tag: '', repo: '', assetDir: '', skipAttestation: false };
|
||||
const positional = [];
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--repo' || argument === '--asset-dir') {
|
||||
const value = argv[++index];
|
||||
if (!value) throw new Error(`${argument} requires a value`);
|
||||
if (argument === '--repo') options.repo = value;
|
||||
else options.assetDir = path.resolve(value);
|
||||
} else if (argument === '--skip-attestation') {
|
||||
options.skipAttestation = true;
|
||||
} else if (argument.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${argument}`);
|
||||
} else {
|
||||
positional.push(argument);
|
||||
}
|
||||
}
|
||||
if (positional.length !== 1) {
|
||||
throw new Error('Usage: node scripts/verify-published-release.mjs <tag> [--repo OWNER/REPO] [--asset-dir PATH] [--skip-attestation]');
|
||||
}
|
||||
options.tag = positional[0];
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args, { capture = true } = {}) {
|
||||
return execFileSync(command, args, {
|
||||
cwd: process.cwd(),
|
||||
encoding: capture ? 'utf8' : undefined,
|
||||
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit'
|
||||
});
|
||||
}
|
||||
|
||||
function readArchiveText(archivePath, entry) {
|
||||
return run('unzip', ['-p', archivePath, entry]);
|
||||
}
|
||||
|
||||
function listArchiveEntries(archivePath) {
|
||||
return run('unzip', ['-Z1', archivePath]).split(/\r?\n/u).filter(Boolean);
|
||||
}
|
||||
|
||||
function assertRuntimeBuild(browserName, archivePath, version) {
|
||||
const constants = readArchiveText(archivePath, 'shared/constants.js');
|
||||
const background = readArchiveText(archivePath, 'background.js');
|
||||
const content = readArchiveText(archivePath, 'content.js');
|
||||
const popup = readArchiveText(archivePath, 'popup.html');
|
||||
if (!constants.includes(`export const APP_VERSION = "${version}";`)) {
|
||||
throw new Error(`${browserName} shared/constants.js does not contain APP_VERSION ${version}`);
|
||||
}
|
||||
if (!background.includes(`const BROWSER_TYPE = "${browserName}";`)) {
|
||||
throw new Error(`${browserName} background.js does not contain the injected browser type`);
|
||||
}
|
||||
if (!content.includes('const EVENTS = {')) {
|
||||
throw new Error(`${browserName} content.js does not contain injected protocol events`);
|
||||
}
|
||||
if (popup.includes('__BUILD_TIMESTAMP__')) {
|
||||
throw new Error(`${browserName} popup.html contains an unresolved build timestamp`);
|
||||
}
|
||||
}
|
||||
|
||||
async function verify() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const version = versionFromTag(options.tag);
|
||||
const repo = options.repo || run('gh', ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner']).trim();
|
||||
if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) throw new Error(`Invalid GitHub repository: ${repo}`);
|
||||
|
||||
const tagRef = `refs/tags/${options.tag}`;
|
||||
if (run('git', ['cat-file', '-t', tagRef]).trim() !== 'tag') {
|
||||
throw new Error(`${options.tag} must be an annotated tag`);
|
||||
}
|
||||
run('git', ['merge-base', '--is-ancestor', tagRef, 'HEAD']);
|
||||
|
||||
const temporaryDirectory = options.assetDir
|
||||
? null
|
||||
: fs.mkdtempSync(path.join(os.tmpdir(), 'koalasync-release-verification-'));
|
||||
const assetDirectory = options.assetDir || temporaryDirectory;
|
||||
try {
|
||||
if (!options.assetDir) {
|
||||
const publishedAssets = run('gh', [
|
||||
'release', 'view', options.tag, '--repo', repo,
|
||||
'--json', 'assets', '--jq', '.assets[].name'
|
||||
]).split(/\r?\n/u).filter(Boolean);
|
||||
validateReleaseAssetNames(publishedAssets);
|
||||
run('gh', [
|
||||
'release', 'download', options.tag, '--repo', repo, '--dir', assetDirectory,
|
||||
'--pattern', 'koalasync-*.zip', '--pattern', 'SHA256SUMS'
|
||||
], { capture: false });
|
||||
}
|
||||
|
||||
for (const assetName of RELEASE_ASSET_NAMES) {
|
||||
const assetPath = path.join(assetDirectory, assetName);
|
||||
if (!fs.statSync(assetPath, { throwIfNoEntry: false })?.isFile()) {
|
||||
throw new Error(`Missing release asset: ${assetName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const checksums = parseChecksumFile(fs.readFileSync(path.join(assetDirectory, 'SHA256SUMS'), 'utf8'));
|
||||
validateReleaseAssetNames([...checksums.keys(), 'SHA256SUMS']);
|
||||
for (const assetName of RELEASE_ASSET_NAMES.filter(name => name.endsWith('.zip'))) {
|
||||
const actual = await sha256File(path.join(assetDirectory, assetName));
|
||||
const expected = checksums.get(assetName);
|
||||
if (actual !== expected) throw new Error(`${assetName} checksum mismatch: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
|
||||
const archiveEntries = {};
|
||||
for (const browserName of ['chrome', 'firefox']) {
|
||||
const archivePath = path.join(assetDirectory, `koalasync-${browserName}.zip`);
|
||||
archiveEntries[browserName] = validateArchiveEntries(browserName, listArchiveEntries(archivePath));
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(readArchiveText(archivePath, 'manifest.json'));
|
||||
} catch (error) {
|
||||
throw new Error(`${browserName} manifest.json is invalid: ${error.message}`);
|
||||
}
|
||||
validateManifest(browserName, manifest, version);
|
||||
assertRuntimeBuild(browserName, archivePath, version);
|
||||
if (!options.skipAttestation && !options.assetDir) {
|
||||
run('gh', ['attestation', 'verify', archivePath, '--repo', repo], { capture: false });
|
||||
}
|
||||
}
|
||||
validateArchiveParity(archiveEntries.chrome, archiveEntries.firefox);
|
||||
|
||||
console.log(`Published release ${options.tag} verified for ${repo}`);
|
||||
console.log(`Assets: ${RELEASE_ASSET_NAMES.join(', ')}`);
|
||||
console.log(`Version: ${version}; checksums, manifests, parity${options.skipAttestation || options.assetDir ? '' : ', attestations'} passed`);
|
||||
} finally {
|
||||
if (temporaryDirectory) fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
verify().catch(error => {
|
||||
console.error(`Published release verification failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -7,22 +7,15 @@ import path from 'node:path';
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const checks = [
|
||||
['vitest unit tests', 'npm', ['run', 'test:unit']],
|
||||
['server ops', 'node', ['scripts/test-server-ops.mjs']],
|
||||
['vitest unit tests and coverage', 'npm', ['run', 'test:coverage']],
|
||||
['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']],
|
||||
['title privacy unit tests', 'node', ['scripts/test-title-privacy.mjs']],
|
||||
['server WebSocket integration', 'node', ['scripts/test-server-ws.mjs']],
|
||||
['names generator', 'node', ['scripts/test-names.mjs']],
|
||||
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
|
||||
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
|
||||
['blacklist settings', 'node', ['scripts/test-blacklist-settings.mjs']],
|
||||
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
|
||||
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
|
||||
['host access recovery', 'node', ['scripts/test-host-access.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']],
|
||||
|
||||
+3
-1
@@ -87,7 +87,9 @@ The server is covered by the root verification suite. From the repository root,
|
||||
npm run verify
|
||||
```
|
||||
|
||||
For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-server-routes.mjs`, `scripts/test-server-ws.mjs`, and `scripts/test-rate-limiter.mjs`.
|
||||
For focused server checks, run `npm run test:unit` for `server/ops.test.mjs`
|
||||
and `server/rate-limiter.test.mjs`, or use `scripts/test-server-routes.mjs` and
|
||||
`scripts/test-server-ws.mjs` for process-level integration coverage.
|
||||
|
||||
## Security
|
||||
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
getCachedPayload,
|
||||
isAdminMetricsAuthorized,
|
||||
isAdminMetricsTokenStrong
|
||||
} from './ops.js';
|
||||
|
||||
describe('server operational helpers', () => {
|
||||
it('authorizes only an exact configured bearer token', () => {
|
||||
expect(isAdminMetricsAuthorized(undefined, 'secret-token')).toBe(false);
|
||||
expect(isAdminMetricsAuthorized('Bearer wrong-token', 'secret-token')).toBe(false);
|
||||
expect(isAdminMetricsAuthorized('Bearer secret-token', 'secret-token')).toBe(true);
|
||||
expect(isAdminMetricsAuthorized('Bearer secret-token', '')).toBe(false);
|
||||
});
|
||||
|
||||
it('allows disabled metrics or strong admin tokens', () => {
|
||||
expect(isAdminMetricsTokenStrong('')).toBe(true);
|
||||
expect(isAdminMetricsTokenStrong('short-token')).toBe(false);
|
||||
expect(isAdminMetricsTokenStrong('a'.repeat(32))).toBe(true);
|
||||
});
|
||||
|
||||
it('tracks cooldowns and expires cached payloads deterministically', () => {
|
||||
const cooldowns = new Map();
|
||||
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 100_000)).toBe(true);
|
||||
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 105_000)).toBe(false);
|
||||
expect(checkCooldown(cooldowns, 'socket-1', 10_000, 110_000)).toBe(true);
|
||||
|
||||
const cache = new Map();
|
||||
let buildCalls = 0;
|
||||
const first = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 1_000);
|
||||
const cached = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 30_000);
|
||||
const expired = getCachedPayload(cache, 'health', 60_000, () => ({ value: ++buildCalls }), 61_001);
|
||||
expect(cached).toBe(first);
|
||||
expect(expired).toEqual({ value: 2 });
|
||||
});
|
||||
|
||||
it('keeps public health minimal and exposes aggregate admin metrics', () => {
|
||||
const rooms = new Map([
|
||||
['room-a', { peers: new Set(['a', 'b']), activeLobby: null }],
|
||||
['room-b', { peers: new Set(['c', 'd', 'e']), activeLobby: { expectedTitle: 'Episode 2' } }]
|
||||
]);
|
||||
const input = {
|
||||
rooms,
|
||||
connections: 5,
|
||||
now: 1234,
|
||||
uptime: 99,
|
||||
memoryUsage: () => ({ rss: 10, heapUsed: 5, heapTotal: 8 }),
|
||||
rateLimitSizes: {
|
||||
connections: 1,
|
||||
events: 2,
|
||||
health: 3,
|
||||
adminMetricsAuth: 4,
|
||||
authFailures: 5,
|
||||
roomList: 6,
|
||||
leaveRoom: 7
|
||||
}
|
||||
};
|
||||
|
||||
expect(Object.keys(buildHealthPayload({ ...input, includeMetrics: false })).sort()).toEqual(
|
||||
['connections', 'rooms', 'status', 'timestamp', 'uptime'].sort()
|
||||
);
|
||||
expect(buildHealthPayload({
|
||||
...input,
|
||||
includeMetrics: true,
|
||||
rateLimitDenied: { leaveRoom: 8 }
|
||||
})).toMatchObject({
|
||||
peers: 5,
|
||||
roomsWithLobby: 1,
|
||||
avgPeersPerRoom: 2.5,
|
||||
maxPeersInRoom: 3,
|
||||
memory: { rss: 10, heapUsed: 5, heapTotal: 8 },
|
||||
rateLimits: {
|
||||
trackedClients: input.rateLimitSizes,
|
||||
denied: {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0,
|
||||
leaveRoom: 8
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,50 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
checkAdminMetricsAuthRate,
|
||||
checkAuthRate,
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkHealthRate,
|
||||
checkLeaveRoomRate,
|
||||
checkChatMessageRate,
|
||||
CONNECTION_RATE_LIMIT,
|
||||
EVENT_RATE_LIMIT,
|
||||
CHAT_MESSAGE_RATE_LIMIT,
|
||||
CHAT_MESSAGE_RATE_WINDOW_MS,
|
||||
chatMessageCounts,
|
||||
failedAuthAttempts,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
leaveRoomCounts,
|
||||
clearRateLimitMaps
|
||||
clearRateLimitMaps,
|
||||
recordAuthFailure,
|
||||
startRateLimitCleanup,
|
||||
stopRateLimitCleanup
|
||||
} from './rate-limiter.js';
|
||||
|
||||
function resetRateLimits() {
|
||||
stopRateLimitCleanup();
|
||||
clearRateLimitMaps();
|
||||
Object.assign(rateLimitDenied, {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0,
|
||||
leaveRoom: 0,
|
||||
chatMessages: 0
|
||||
});
|
||||
}
|
||||
|
||||
describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
const testSocketId = 'test-socket-123';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.leaveRoom = 0;
|
||||
resetRateLimits();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRateLimitMaps();
|
||||
});
|
||||
afterEach(resetRateLimits);
|
||||
|
||||
it('should allow LEAVE_ROOM within limit', () => {
|
||||
// Test within the rate limit
|
||||
@@ -98,7 +120,7 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
checkLeaveRoomRate(testSocketId);
|
||||
expect(leaveRoomCounts.size).toBe(1);
|
||||
|
||||
clearRateLimitMaps();
|
||||
resetRateLimits();
|
||||
expect(leaveRoomCounts.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -106,12 +128,9 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||
const socketId = 'chat-socket';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.chatMessages = 0;
|
||||
});
|
||||
beforeEach(resetRateLimits);
|
||||
|
||||
afterEach(() => clearRateLimitMaps());
|
||||
afterEach(resetRateLimits);
|
||||
|
||||
it('allows ten messages per ten-second window and blocks the next', () => {
|
||||
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
|
||||
@@ -129,6 +148,40 @@ describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remaining relay rate limits', () => {
|
||||
beforeEach(resetRateLimits);
|
||||
afterEach(resetRateLimits);
|
||||
|
||||
it.each([
|
||||
['connection', checkConnectionRate, CONNECTION_RATE_LIMIT, 'ip-1', 'connections'],
|
||||
['event', checkEventRate, EVENT_RATE_LIMIT, 'socket-1', 'events'],
|
||||
['health', checkHealthRate, 10, 'ip-2', 'health'],
|
||||
['admin metrics auth', checkAdminMetricsAuthRate, 5, 'ip-3', 'adminMetricsAuth']
|
||||
])('enforces the %s window and increments its denial counter', (_label, check, limit, key, counter) => {
|
||||
for (let attempt = 0; attempt < limit; attempt++) expect(check(key)).toBe(true);
|
||||
expect(check(key)).toBe(false);
|
||||
expect(rateLimitDenied[counter]).toBe(1);
|
||||
expect(check(`${key}-other`)).toBe(true);
|
||||
});
|
||||
|
||||
it('scopes failed authentication attempts to IP and room', () => {
|
||||
for (let attempt = 0; attempt < 5; attempt++) recordAuthFailure('10.0.0.1', 'room-a');
|
||||
expect(checkAuthRate('10.0.0.1', 'room-a')).toBe(false);
|
||||
expect(checkAuthRate('10.0.0.1', 'room-b')).toBe(true);
|
||||
expect(failedAuthAttempts.get('10.0.0.1:room-a')).toMatchObject({ count: 5 });
|
||||
});
|
||||
|
||||
it('starts cleanup only once and can stop safely', () => {
|
||||
const io = { sockets: { sockets: new Map() } };
|
||||
expect(() => {
|
||||
startRateLimitCleanup(io);
|
||||
startRateLimitCleanup(io);
|
||||
stopRateLimitCleanup();
|
||||
stopRateLimitCleanup();
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
BLACKLIST_DOMAINS,
|
||||
BLACKLIST_OVERRIDES_STORAGE_KEY,
|
||||
BLACKLIST_SOURCE_DEFAULT,
|
||||
BLACKLIST_SOURCE_USER,
|
||||
CUSTOM_BLACKLIST_STORAGE_KEY,
|
||||
createEmptyBlacklistOverrides,
|
||||
deriveBlacklistOverrides,
|
||||
getBlacklistEntries,
|
||||
getEffectiveBlacklistDomains,
|
||||
isUrlBlacklisted,
|
||||
normalizeBlacklistDomain,
|
||||
normalizeBlacklistOverrides,
|
||||
parseBlacklistDomains
|
||||
} from './blacklist.js';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
describe('blacklist behavior', () => {
|
||||
it('normalizes, deduplicates, and rejects unsafe entries', () => {
|
||||
expect(CUSTOM_BLACKLIST_STORAGE_KEY).toBe('customBlacklistDomains');
|
||||
expect(BLACKLIST_OVERRIDES_STORAGE_KEY).toBe('blacklistOverrides');
|
||||
expect(normalizeBlacklistDomain(' Example.COM. ')).toBe('example.com');
|
||||
expect(normalizeBlacklistDomain('https://Video.Example.com/watch/123')).toBe('video.example.com');
|
||||
expect(normalizeBlacklistDomain('*.example.com')).toBeNull();
|
||||
expect(normalizeBlacklistDomain('not a domain')).toBeNull();
|
||||
expect(parseBlacklistDomains('Example.com\nhttps://sub.example.com/path\nexample.com\n')).toEqual({
|
||||
domains: ['example.com', 'sub.example.com'],
|
||||
invalid: []
|
||||
});
|
||||
expect(parseBlacklistDomains('example.com\nnot a domain').invalid).toEqual(['not a domain']);
|
||||
expect(parseBlacklistDomains('# note\nvideos.example\n\n# defaults\ngoogle.com')).toEqual({
|
||||
domains: ['videos.example', 'google.com'],
|
||||
invalid: []
|
||||
});
|
||||
});
|
||||
|
||||
it('matches only exact hosts and their subdomains', () => {
|
||||
expect(isUrlBlacklisted('https://mail.google.com/inbox', ['google.com'])).toBe(true);
|
||||
expect(isUrlBlacklisted('https://notgoogle.com/', ['google.com'])).toBe(false);
|
||||
expect(isUrlBlacklisted('not a url', ['example.com'])).toBe(false);
|
||||
expect(isUrlBlacklisted('https://drive.google.com/file/d/x/view', BLACKLIST_DOMAINS)).toBe(false);
|
||||
expect(isUrlBlacklisted('https://drive.google.com/file/d/x/view', ['drive.google.com'])).toBe(true);
|
||||
expect(isUrlBlacklisted('https://docs.google.com/document/d/x', BLACKLIST_DOMAINS)).toBe(true);
|
||||
});
|
||||
|
||||
it('stores user edits as a delta so future defaults continue to flow in', () => {
|
||||
expect(createEmptyBlacklistOverrides()).toEqual({ removedDefaults: [], addedDomains: [] });
|
||||
const edited = BLACKLIST_DOMAINS
|
||||
.filter(domain => domain !== 'reddit.com' && domain !== 'imgur.com')
|
||||
.concat(['videos.example']);
|
||||
const overrides = deriveBlacklistOverrides(edited);
|
||||
expect(overrides).toEqual({
|
||||
removedDefaults: ['reddit.com', 'imgur.com'],
|
||||
addedDomains: ['videos.example']
|
||||
});
|
||||
|
||||
const effective = new Set(getEffectiveBlacklistDomains(overrides));
|
||||
expect(effective.has('reddit.com')).toBe(false);
|
||||
expect(effective.has('videos.example')).toBe(true);
|
||||
const removed = new Set(overrides.removedDefaults);
|
||||
for (const domain of BLACKLIST_DOMAINS) {
|
||||
expect(effective.has(domain) || removed.has(domain)).toBe(true);
|
||||
}
|
||||
|
||||
const readded = deriveBlacklistOverrides([...effective, 'reddit.com'], overrides);
|
||||
expect(new Set(readded.removedDefaults).has('reddit.com')).toBe(false);
|
||||
expect(deriveBlacklistOverrides(['google.com'], {
|
||||
removedDefaults: [],
|
||||
addedDomains: ['google.com']
|
||||
}).addedDomains).toEqual(['google.com']);
|
||||
});
|
||||
|
||||
it('normalizes legacy and contradictory storage without losing intent', () => {
|
||||
expect(getEffectiveBlacklistDomains(undefined)).toEqual(BLACKLIST_DOMAINS);
|
||||
expect(getEffectiveBlacklistDomains([])).toEqual([]);
|
||||
expect(normalizeBlacklistOverrides({
|
||||
removedDefaults: ['example.com'],
|
||||
addedDomains: ['example.com']
|
||||
})).toEqual({ removedDefaults: [], addedDomains: ['example.com'] });
|
||||
expect(normalizeBlacklistOverrides('nonsense')).toEqual(createEmptyBlacklistOverrides());
|
||||
|
||||
const overrides = { removedDefaults: ['reddit.com'], addedDomains: ['videos.example'] };
|
||||
const entries = getBlacklistEntries(overrides);
|
||||
expect(entries.find(entry => entry.domain === 'videos.example')?.source).toBe(BLACKLIST_SOURCE_USER);
|
||||
expect(entries.find(entry => entry.domain === 'google.com')?.source).toBe(BLACKLIST_SOURCE_DEFAULT);
|
||||
const rendered = [
|
||||
'# Your entries',
|
||||
...entries.filter(entry => entry.source === BLACKLIST_SOURCE_USER).map(entry => entry.domain),
|
||||
'',
|
||||
'# Shipped defaults',
|
||||
...entries.filter(entry => entry.source === BLACKLIST_SOURCE_DEFAULT).map(entry => entry.domain)
|
||||
].join('\n');
|
||||
expect(deriveBlacklistOverrides(parseBlacklistDomains(rendered).domains, overrides)).toEqual(
|
||||
normalizeBlacklistOverrides(overrides)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blacklist integration contracts', () => {
|
||||
it('keeps storage local and the editor present', () => {
|
||||
const popupSource = fs.readFileSync(path.join(repoRoot, 'extension/popup.js'), 'utf8');
|
||||
const popupHtml = fs.readFileSync(path.join(repoRoot, 'extension/popup.html'), 'utf8');
|
||||
expect(popupSource).toMatch(/chrome\.storage\.local\.set\(\{ \[BLACKLIST_OVERRIDES_STORAGE_KEY\]: overrides \}\)/);
|
||||
expect(popupSource).not.toMatch(/chrome\.storage\.sync\.set\(\{ \[(?:BLACKLIST_OVERRIDES|CUSTOM_BLACKLIST)_STORAGE_KEY\]/);
|
||||
expect(popupSource).toMatch(/chrome\.storage\.local\.remove\(CUSTOM_BLACKLIST_STORAGE_KEY\)/);
|
||||
expect(popupSource).toMatch(/isUrlBlacklisted\(tab\.url, blacklistDomains\)/);
|
||||
expect(popupHtml).toMatch(/id="blacklistDomains"/);
|
||||
expect(popupHtml).toMatch(/id="blacklistReset"/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateUsername, getAvatarForName, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './names.js';
|
||||
|
||||
describe('generated peer names', () => {
|
||||
it.each([
|
||||
['Koala', '🐨'],
|
||||
['koala', '🐨'],
|
||||
['MyKoalaUser', '🐨'],
|
||||
['Tiger', '🐯'],
|
||||
['Panda', '🐼'],
|
||||
['Fox', '🦊'],
|
||||
['CaterpillarCat', '🐛'],
|
||||
['Cat', '🐱'],
|
||||
['Polar', '🐻\u200D❄️'],
|
||||
['Crow', '🐦\u200D⬛'],
|
||||
['Ninja', '🥷'],
|
||||
['Wizard', '🧙'],
|
||||
['Pirate', '🏴'],
|
||||
['Alien', '👾'],
|
||||
['Robot', '🤖']
|
||||
])('maps %s to %s', (name, avatar) => {
|
||||
expect(getAvatarForName(name)).toBe(avatar);
|
||||
});
|
||||
|
||||
it.each(['', 'Xyzzy123', null, undefined])('uses the fallback for %j', name => {
|
||||
expect(getAvatarForName(name)).toBe('👤');
|
||||
});
|
||||
|
||||
it('generates only adjective-noun combinations', () => {
|
||||
for (let sample = 0; sample < 100; sample++) {
|
||||
const name = generateUsername();
|
||||
expect(name).toMatch(/^[A-Z][a-z]+[A-Z][a-z]+$/);
|
||||
expect(USERNAME_ADJECTIVES.some(adjective => name.startsWith(adjective))).toBe(true);
|
||||
expect(USERNAME_NOUNS.some(noun => name.endsWith(noun))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('defines an avatar for every generated noun', () => {
|
||||
for (const noun of USERNAME_NOUNS) expect(getAvatarForName(noun)).not.toBe('👤');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ enough to control it.
|
||||
npm run test:e2e:install # once, downloads the browsers
|
||||
npm run build:extension # extension.spec.mjs loads dist/chrome
|
||||
npm run test:e2e
|
||||
npm run test:e2e:detection # finder only: Chromium, Firefox, WebKit
|
||||
npm run test:e2e:extension # packed extension only: Chromium MV3
|
||||
npm run test:e2e:race # @race scenarios, repeated 20 times
|
||||
```
|
||||
|
||||
## Layout
|
||||
@@ -21,6 +24,12 @@ npm run test:e2e
|
||||
| `fixtures/media/` | Small generated clips (see below) |
|
||||
| `helpers/content-source.mjs` | Lifts the real finder out of `extension/content.js` |
|
||||
|
||||
The detection fixtures run as three Playwright projects: Chromium, Firefox,
|
||||
and WebKit. Packed-extension tests remain Chromium-only because they exercise
|
||||
Chrome MV3 APIs and a persistent service-worker context. The scheduled
|
||||
`.github/workflows/race-tests.yml` lane repeats tests marked `@race` and uploads
|
||||
traces/results on failure.
|
||||
|
||||
## Two rules worth keeping
|
||||
|
||||
**The specs run the shipped source, not a copy.** `helpers/content-source.mjs`
|
||||
@@ -45,6 +54,7 @@ reads as a broken fixture instead of a scoring regression.
|
||||
| `late-frame.html` | Player frame attached after the page settled |
|
||||
| `shadow-player.html` | Player in a shadow root, tiny teaser in the light DOM |
|
||||
| `muted-player.html` | Mute must not disqualify the only player |
|
||||
| `display-contents-player.html` | A visible player survives a boxless `display: contents` wrapper |
|
||||
| `hidden-preload.html` | A `display:none` preload still reports 1080p; it must lose |
|
||||
| `ad-frame.html` | 1080p asset in a 300x250 ad slot must lose to the real player |
|
||||
| `background-loop.html` | Silent looping hero must lose despite being the largest |
|
||||
|
||||
@@ -737,7 +737,7 @@ test('polling video state on a page with no video does not restart the target',
|
||||
.toBe('true');
|
||||
});
|
||||
|
||||
test('stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
|
||||
test('@race stays ready on a page whose ad frames keep mutating', async ({ context, extensionId, baseURL }) => {
|
||||
test.setTimeout(90000);
|
||||
// Live ad churn wakes the media-frame monitor several times a second. Each
|
||||
// wake used to schedule a trailing refresh that rebuilt the target
|
||||
|
||||
@@ -25,6 +25,27 @@ export default defineConfig({
|
||||
args: ['--autoplay-policy=no-user-gesture-required']
|
||||
}
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'detection-chromium',
|
||||
testMatch: 'detection.spec.mjs',
|
||||
use: { browserName: 'chromium' }
|
||||
},
|
||||
{
|
||||
name: 'detection-firefox',
|
||||
testMatch: 'detection.spec.mjs',
|
||||
use: { browserName: 'firefox' }
|
||||
},
|
||||
{
|
||||
name: 'detection-webkit',
|
||||
testMatch: 'detection.spec.mjs',
|
||||
use: { browserName: 'webkit' }
|
||||
},
|
||||
{
|
||||
name: 'extension-chromium',
|
||||
testIgnore: 'detection.spec.mjs'
|
||||
}
|
||||
],
|
||||
webServer: {
|
||||
command: `node "${fileURLToPath(new URL('./fixture-server.mjs', import.meta.url))}" ${PORT}`,
|
||||
url: `http://localhost:${PORT}/pages/simple-player.html`,
|
||||
|
||||
+44
-6
@@ -10,17 +10,55 @@ export default defineConfig({
|
||||
'shared/**/*.test.js',
|
||||
'shared/**/*.test.mjs',
|
||||
'extension/**/*.test.js',
|
||||
'extension/**/*.test.mjs'
|
||||
'extension/**/*.test.mjs',
|
||||
'scripts/**/*.test.mjs'
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'lcov'],
|
||||
include: ['server/**/*.js', 'shared/**/*.js'],
|
||||
// Coverage is intentionally scoped to importable modules exercised
|
||||
// by Vitest. Browser entry points and subprocess integration tests
|
||||
// have separate E2E/integration gates and must not be reported as
|
||||
// zero-coverage unit-test targets.
|
||||
include: [
|
||||
'server/{chat,ops,rate-limiter}.js',
|
||||
'shared/{blacklist,invite-links,names}.js',
|
||||
'extension/{chat-activity,chat-crypto,chat-format,chat-session,chat-wire,episode-utils,host-access,media-frame-target,title-privacy}.js',
|
||||
'scripts/release-artifact-checks.mjs'
|
||||
],
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/scripts/**',
|
||||
'**/extension/**'
|
||||
]
|
||||
'**/node_modules/**'
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 68,
|
||||
functions: 85,
|
||||
lines: 83,
|
||||
'extension/media-frame-target.js': {
|
||||
statements: 65,
|
||||
branches: 50,
|
||||
functions: 75,
|
||||
lines: 67
|
||||
},
|
||||
'extension/host-access.js': {
|
||||
statements: 77,
|
||||
branches: 66,
|
||||
functions: 81,
|
||||
lines: 79
|
||||
},
|
||||
'server/rate-limiter.js': {
|
||||
statements: 70,
|
||||
branches: 58,
|
||||
functions: 83,
|
||||
lines: 74
|
||||
},
|
||||
'scripts/release-artifact-checks.mjs': {
|
||||
statements: 100,
|
||||
branches: 95,
|
||||
functions: 100,
|
||||
lines: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user