From 230e7f5932735b6bc9eadb93120415ba67d96e63 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:10:35 +0200 Subject: [PATCH 1/5] test: harden coverage and browser release gates --- .github/workflows/ci.yml | 6 +- .github/workflows/race-tests.yml | 51 +++++++ .github/workflows/release.yml | 5 + extension/README.md | 3 +- extension/episode-utils.test.mjs | 56 +++++++ extension/host-access.test.mjs | 155 +++++++++++++++++++ extension/title-privacy.test.mjs | 58 ++++++++ package.json | 6 +- scripts/README.md | 35 ++++- scripts/release-artifact-checks.mjs | 123 ++++++++++++++++ scripts/release-artifact-checks.test.mjs | 153 +++++++++++++++++++ scripts/test-blacklist-settings.mjs | 145 ------------------ scripts/test-episode-utils.mjs | 76 ---------- scripts/test-host-access.mjs | 180 ----------------------- scripts/test-names.mjs | 56 ------- scripts/test-rate-limiter.mjs | 131 ----------------- scripts/test-server-ops.mjs | 89 ----------- scripts/test-title-privacy.mjs | 87 ----------- scripts/verify-published-release.mjs | 151 +++++++++++++++++++ scripts/verify-release.mjs | 9 +- server/README.md | 4 +- server/ops.test.mjs | 87 +++++++++++ server/rate-limiter.test.mjs | 77 ++++++++-- shared/blacklist.test.mjs | 115 +++++++++++++++ shared/names.test.mjs | 41 ++++++ tests/e2e/README.md | 10 ++ tests/e2e/extension.spec.mjs | 2 +- tests/e2e/playwright.config.mjs | 21 +++ vitest.config.mjs | 50 ++++++- 29 files changed, 1176 insertions(+), 806 deletions(-) create mode 100644 .github/workflows/race-tests.yml create mode 100644 extension/episode-utils.test.mjs create mode 100644 extension/host-access.test.mjs create mode 100644 extension/title-privacy.test.mjs create mode 100644 scripts/release-artifact-checks.mjs create mode 100644 scripts/release-artifact-checks.test.mjs delete mode 100644 scripts/test-blacklist-settings.mjs delete mode 100644 scripts/test-episode-utils.mjs delete mode 100644 scripts/test-host-access.mjs delete mode 100644 scripts/test-names.mjs delete mode 100644 scripts/test-rate-limiter.mjs delete mode 100644 scripts/test-server-ops.mjs delete mode 100644 scripts/test-title-privacy.mjs create mode 100644 scripts/verify-published-release.mjs create mode 100644 server/ops.test.mjs create mode 100644 shared/blacklist.test.mjs create mode 100644 shared/names.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8383631..5094e27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/race-tests.yml b/.github/workflows/race-tests.yml new file mode 100644 index 0000000..7529a3b --- /dev/null +++ b/.github/workflows/race-tests.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7014be..f8f43a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/extension/README.md b/extension/README.md index 1e0412d..c790333 100644 --- a/extension/README.md +++ b/extension/README.md @@ -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 ``` diff --git a/extension/episode-utils.test.mjs b/extension/episode-utils.test.mjs new file mode 100644 index 0000000..32edd24 --- /dev/null +++ b/extension/episode-utils.test.mjs @@ -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); + }); +}); diff --git a/extension/host-access.test.mjs b/extension/host-access.test.mjs new file mode 100644 index 0000000..82d5e2c --- /dev/null +++ b/extension/host-access.test.mjs @@ -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"/); + }); +}); diff --git a/extension/title-privacy.test.mjs b/extension/title-privacy.test.mjs new file mode 100644 index 0000000..d020e23 --- /dev/null +++ b/extension/title-privacy.test.mjs @@ -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' + }); + }); +}); diff --git a/package.json b/package.json index 9eaa1b0..0b737e4 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/README.md b/scripts/README.md index eee47bc..09c9062 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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. diff --git a/scripts/release-artifact-checks.mjs b/scripts/release-artifact-checks.mjs new file mode 100644 index 0000000..ab8f4a4 --- /dev/null +++ b/scripts/release-artifact-checks.mjs @@ -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 || ''}`); + 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 || ''} 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(', ') || ''}; Firefox only: ${firefoxOnly.join(', ') || ''}`); + } +} diff --git a/scripts/release-artifact-checks.test.mjs b/scripts/release-artifact-checks.test.mjs new file mode 100644 index 0000000..8b5b3e7 --- /dev/null +++ b/scripts/release-artifact-checks.test.mjs @@ -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' + ); + }); +}); diff --git a/scripts/test-blacklist-settings.mjs b/scripts/test-blacklist-settings.mjs deleted file mode 100644 index 1ba7015..0000000 --- a/scripts/test-blacklist-settings.mjs +++ /dev/null @@ -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'); diff --git a/scripts/test-episode-utils.mjs b/scripts/test-episode-utils.mjs deleted file mode 100644 index b65cb7c..0000000 --- a/scripts/test-episode-utils.mjs +++ /dev/null @@ -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'); diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs deleted file mode 100644 index 69308e5..0000000 --- a/scripts/test-host-access.mjs +++ /dev/null @@ -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'); diff --git a/scripts/test-names.mjs b/scripts/test-names.mjs deleted file mode 100644 index 5611baf..0000000 --- a/scripts/test-names.mjs +++ /dev/null @@ -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'); diff --git a/scripts/test-rate-limiter.mjs b/scripts/test-rate-limiter.mjs deleted file mode 100644 index 4ee5cb6..0000000 --- a/scripts/test-rate-limiter.mjs +++ /dev/null @@ -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'); diff --git a/scripts/test-server-ops.mjs b/scripts/test-server-ops.mjs deleted file mode 100644 index 65cec5b..0000000 --- a/scripts/test-server-ops.mjs +++ /dev/null @@ -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'); diff --git a/scripts/test-title-privacy.mjs b/scripts/test-title-privacy.mjs deleted file mode 100644 index 33dd0dc..0000000 --- a/scripts/test-title-privacy.mjs +++ /dev/null @@ -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'); diff --git a/scripts/verify-published-release.mjs b/scripts/verify-published-release.mjs new file mode 100644 index 0000000..6b69354 --- /dev/null +++ b/scripts/verify-published-release.mjs @@ -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 [--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; +}); diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs index db3d515..334988a 100644 --- a/scripts/verify-release.mjs +++ b/scripts/verify-release.mjs @@ -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']], diff --git a/server/README.md b/server/README.md index 320db91..d515677 100644 --- a/server/README.md +++ b/server/README.md @@ -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. diff --git a/server/ops.test.mjs b/server/ops.test.mjs new file mode 100644 index 0000000..b5d294b --- /dev/null +++ b/server/ops.test.mjs @@ -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 + } + } + }); + }); +}); diff --git a/server/rate-limiter.test.mjs b/server/rate-limiter.test.mjs index d5ac4bd..49571bc 100644 --- a/server/rate-limiter.test.mjs +++ b/server/rate-limiter.test.mjs @@ -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); diff --git a/shared/blacklist.test.mjs b/shared/blacklist.test.mjs new file mode 100644 index 0000000..9fec0fb --- /dev/null +++ b/shared/blacklist.test.mjs @@ -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"/); + }); +}); diff --git a/shared/names.test.mjs b/shared/names.test.mjs new file mode 100644 index 0000000..0da8708 --- /dev/null +++ b/shared/names.test.mjs @@ -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('šŸ‘¤'); + }); +}); diff --git a/tests/e2e/README.md b/tests/e2e/README.md index a8dc951..19481f3 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -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 | diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 62e719e..3892e95 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -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 diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs index a324214..2c91a36 100644 --- a/tests/e2e/playwright.config.mjs +++ b/tests/e2e/playwright.config.mjs @@ -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`, diff --git a/vitest.config.mjs b/vitest.config.mjs index 6d58fdf..b2e16be 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -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 + } + } } } }); From 7286a6db3dc326b39bcdffc1708cf1d605ca3e23 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Fri, 21 Aug 2026 15:49:51 +0200 Subject: [PATCH 2/5] test: harden release and browser gates --- .github/workflows/ci.yml | 45 ++++ .github/workflows/race-tests.yml | 6 +- .github/workflows/release.yml | 272 ++++++++++++++---------- CONTRIBUTING.md | 4 +- docs/AI_INIT.md | 16 +- docs/SYNC_GUIDE.md | 5 +- docs/devops.md | 59 +++-- extension/host-access.test.mjs | 43 +++- extension/popup.html | 22 +- extension/popup.js | 28 ++- extension/title-privacy.test.mjs | 7 + package-lock.json | 2 +- package.json | 3 +- scripts/README.md | 11 +- scripts/check-coverage-inventory.mjs | 62 ++++++ scripts/coverage-plan.mjs | 68 ++++++ scripts/coverage-plan.test.mjs | 21 ++ scripts/prepare-release.mjs | 80 +++++++ scripts/prepare-release.test.mjs | 13 ++ scripts/release-preflight.mjs | 111 ++++++++++ scripts/release-preflight.test.mjs | 59 +++++ scripts/verify-published-release.mjs | 12 +- scripts/verify-release.mjs | 1 + server/rate-limiter.test.mjs | 57 ++++- tests/e2e/README.md | 2 + tests/e2e/extension.spec.mjs | 20 +- tests/e2e/helpers/extension-fixture.mjs | 104 +++++++-- tests/e2e/helpers/relay-process.mjs | 62 ++++++ tests/e2e/playwright.config.mjs | 2 +- tests/e2e/popup-accessibility.spec.mjs | 29 +++ tests/e2e/room-sync.spec.mjs | 117 ++++++++++ vitest.config.mjs | 8 +- 32 files changed, 1150 insertions(+), 201 deletions(-) create mode 100644 scripts/check-coverage-inventory.mjs create mode 100644 scripts/coverage-plan.mjs create mode 100644 scripts/coverage-plan.test.mjs create mode 100644 scripts/prepare-release.mjs create mode 100644 scripts/prepare-release.test.mjs create mode 100644 scripts/release-preflight.mjs create mode 100644 scripts/release-preflight.test.mjs create mode 100644 tests/e2e/helpers/relay-process.mjs create mode 100644 tests/e2e/popup-accessibility.spec.mjs create mode 100644 tests/e2e/room-sync.spec.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5094e27..c2d53ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,36 @@ jobs: - name: Run verification suite run: npm run verify + node20: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up lowest supported Node.js + uses: actions/setup-node@v6 + with: + node-version: '20.19.0' + cache: 'npm' + cache-dependency-path: | + package-lock.json + server/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Run unit and relay integration gates + run: | + npm run test:coverage + node scripts/test-server-routes.mjs + node scripts/test-server-ws.mjs + env: + ADMIN_METRICS_TOKEN: verify-admin-token-with-more-than-32-chars + e2e: # Kept separate from `verify`: this job needs a downloaded browser, so a # failure here should read as "the browser flow broke", not as a broken @@ -66,6 +96,10 @@ jobs: - name: Install root dependencies run: npm ci + - name: Install server dependencies + run: npm ci + working-directory: server + - name: Install Playwright browsers run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit @@ -75,3 +109,14 @@ jobs: - name: Run cross-browser detection and extension E2E tests run: npm run test:e2e + + - name: Upload browser failure diagnostics + if: failure() + uses: actions/upload-artifact@v7 + with: + name: e2e-failure-diagnostics + path: | + test-results/ + playwright-report/ + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/race-tests.yml b/.github/workflows/race-tests.yml index 7529a3b..8f14849 100644 --- a/.github/workflows/race-tests.yml +++ b/.github/workflows/race-tests.yml @@ -15,7 +15,7 @@ concurrency: jobs: extension-races: runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Checkout code uses: actions/checkout@v7 @@ -30,6 +30,10 @@ jobs: - name: Install dependencies run: npm ci + - name: Install server dependencies + run: npm ci + working-directory: server + - name: Install Playwright Chromium run: npx playwright install --with-deps chromium chromium-headless-shell diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8f43a6..b627534 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,14 +5,139 @@ on: tags: - 'v*' -# A release run must never be interrupted (it commits back to main and publishes -# artifacts). Only dedupe accidental re-pushes of the same tag. concurrency: group: release-${{ github.ref_name }} cancel-in-progress: false jobs: + preflight: + runs-on: ubuntu-latest + permissions: + contents: read + checks: read + outputs: + version: ${{ steps.release-ref.outputs.version }} + tag-commit: ${{ steps.release-ref.outputs.tag_commit }} + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: | + package-lock.json + server/package-lock.json + + - name: Validate annotated tag, main commit, and required checks + id: release-ref + run: node scripts/release-preflight.mjs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Run complete release verification + run: npm run verify + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium chromium-headless-shell firefox webkit + + - name: Run browser E2E suite + run: npm run test:e2e + + - name: Build relay container without publishing + run: docker build --file server/Dockerfile --tag koalasync-release-preflight . + + - name: Smoke-test relay container + run: | + CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-preflight-salt-with-more-than-thirty-two-chars koalasync-release-preflight) + trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT + HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://') + for attempt in $(seq 1 30); do + if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then + exit 0 + fi + sleep 1 + done + docker logs "$CONTAINER_ID" + exit 1 + + release-extension-draft: + needs: preflight + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Checkout release tag + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies and build release artifacts + run: | + npm ci + npm run build:extension + node website/build.cjs + + - name: Validate Firefox package + run: npx addons-linter --warnings-as-errors dist/koalasync-firefox.zip + + - name: Generate extension checksums + working-directory: dist + run: sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS + + - name: Validate release assets before publication + run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --asset-dir dist --skip-attestation + + - name: Attest extension archives + uses: actions/attest@v4 + with: + subject-path: dist/koalasync-*.zip + + - name: Create draft GitHub release + uses: softprops/action-gh-release@v3 + with: + files: | + dist/koalasync-chrome.zip + dist/koalasync-firefox.zip + dist/SHA256SUMS + name: Release ${{ github.ref_name }} + generate_release_notes: true + draft: true + prerelease: false + + - name: Verify draft extension release + run: node scripts/verify-published-release.mjs "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload website artifacts + uses: actions/upload-artifact@v7 + with: + name: website-www + path: website/www/ + if-no-files-found: error + release-server: + needs: [preflight, release-extension-draft] runs-on: ubuntu-latest permissions: contents: read @@ -20,7 +145,7 @@ jobs: id-token: write attestations: write steps: - - name: Checkout code + - name: Checkout release tag uses: actions/checkout@v7 - name: Set up Docker Buildx @@ -52,131 +177,46 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # Reuse layers across releases to speed up the multi-arch build. cache-from: type=gha cache-to: type=gha,mode=max - - name: Generate artifact attestation + - name: Attest relay image uses: actions/attest@v4 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true - release-extension: + - name: Verify manifest, provenance, and running image + env: + DIGEST: ${{ steps.build.outputs.digest }} + IMAGE: ghcr.io/${{ github.repository }} + SOURCE_DIGEST: ${{ needs.preflight.outputs.tag-commit }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + docker buildx imagetools inspect "$IMAGE@$DIGEST" --raw > /tmp/koalasync-manifest.json + node -e 'const m=require("/tmp/koalasync-manifest.json"); const p=new Set(m.manifests.map(x=>`${x.platform.os}/${x.platform.architecture}`)); for (const x of ["linux/amd64","linux/arm64"]) if(!p.has(x)) throw new Error(`missing platform ${x}`)' + gh attestation verify "oci://$IMAGE@$DIGEST" --repo "$GITHUB_REPOSITORY" --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" --source-ref "$GITHUB_REF" --source-digest "$SOURCE_DIGEST" --deny-self-hosted-runners + docker pull --platform linux/amd64 "$IMAGE@$DIGEST" + CONTAINER_ID=$(docker run --detach --publish 127.0.0.1::3000 --env SERVER_SALT=release-smoke-salt-with-more-than-thirty-two-chars "$IMAGE@$DIGEST") + trap 'docker rm --force "$CONTAINER_ID" >/dev/null 2>&1 || true' EXIT + HOST_PORT=$(docker port "$CONTAINER_ID" 3000/tcp | sed 's/.*://') + for attempt in $(seq 1 30); do + if curl --fail --silent "http://127.0.0.1:$HOST_PORT/health" >/dev/null; then + exit 0 + fi + sleep 1 + done + docker logs "$CONTAINER_ID" + exit 1 + + finalize-release: + needs: [preflight, release-extension-draft, release-server] runs-on: ubuntu-latest permissions: contents: write - id-token: write - attestations: write steps: - - name: Checkout code - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '24' - cache: 'npm' - - - name: Extract version from tag - id: version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - - - name: Inject version into source files - run: | - VERSION=${{ steps.version.outputs.VERSION }} - DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - echo "Injecting version $VERSION from tag $GITHUB_REF_NAME..." - - # 1. extension/manifest.base.json - jq --arg v "$VERSION" '.version = $v' extension/manifest.base.json > tmp.json && mv tmp.json extension/manifest.base.json - echo " āœ“ manifest.base.json -> $VERSION" - - # 2. shared/constants.js — APP_VERSION - sed -i "s/export const APP_VERSION = [\"'].*[\"']/export const APP_VERSION = \"$VERSION\"/" shared/constants.js - echo " āœ“ shared/constants.js -> $VERSION" - - # 3. package.json - jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json - echo " āœ“ package.json -> $VERSION" - - # 4. package-lock.json root package metadata - jq --arg v "$VERSION" '.version = $v | .packages[""].version = $v' package-lock.json > tmp.json && mv tmp.json package-lock.json - echo " āœ“ package-lock.json -> $VERSION" - - # 5. website/version.json - jq -n --arg v "$VERSION" --arg d "$DATE" '{version: $v, date: $d}' > website/version.json - echo " āœ“ website/version.json -> version $VERSION, date $DATE" - - # 6. website/template.html — SoftwareApplication schema - sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html - echo " āœ“ website/template.html -> softwareVersion $VERSION" - - # 7. website/llms.txt — machine-readable release metadata - sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt - echo " āœ“ website/llms.txt -> $VERSION" - - # 8. README.md — version badge & banner - sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md - sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md - echo " āœ“ README.md -> v$VERSION" - - echo "Version injection complete." - - - name: Commit and push version updates back to main - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add extension/manifest.base.json shared/constants.js package.json package-lock.json website/version.json website/template.html website/llms.txt README.md - git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit" - git push origin HEAD:main - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Build Extensions - run: | - npm ci - npm run build:extension - - - name: Generate extension checksums - run: | - cd dist - sha256sum koalasync-chrome.zip koalasync-firefox.zip > SHA256SUMS - cat SHA256SUMS - - - name: Generate artifact attestation for extensions - uses: actions/attest@v4 - with: - subject-path: dist/koalasync-*.zip - - - name: Build Website - run: node website/build.cjs - - - name: Upload Website Artifacts - uses: actions/upload-artifact@v7 - with: - name: website-www - path: website/www/ - if-no-files-found: error - - - name: Create GitHub Release - uses: softprops/action-gh-release@v3 - with: - files: | - dist/koalasync-chrome.zip - dist/koalasync-firefox.zip - dist/SHA256SUMS - name: Release ${{ github.ref_name }} - generate_release_notes: true - draft: false - 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" + - name: Publish verified GitHub release + run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false --verify-tag env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index abba016..4fce658 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,9 @@ KoalaSync uses a **single source of truth** for all protocol constants in `share ## Version Numbers > [!CAUTION] -> **Never manually bump version numbers.** The CI pipeline injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Manual bumps cause conflicts. +> **Never edit release versions independently.** Release maintainers run +> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch and merge the +> resulting version changes through a CI-green pull request before tagging. --- diff --git a/docs/AI_INIT.md b/docs/AI_INIT.md index 7fddb36..b1a0ae2 100644 --- a/docs/AI_INIT.md +++ b/docs/AI_INIT.md @@ -118,17 +118,23 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r > [!CAUTION] > **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.** > -> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures. +> **🚫 NO INDEPENDENT VERSION EDITS**: Run `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch. Never edit only one version source, and never expect the tag workflow to modify `main`. > - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output. 1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file: - **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check. - **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check. -2. Commit all verified code changes and push to `main`. -3. Create and push a new tag. **MANDATORY**: Tags MUST start with a `v` (e.g., `v1.4.0`). The GitHub Actions release workflow is strictly configured to ignore any tags without the `v` prefix. +2. Commit the prepared version and release-note changes on a branch, push it, + open a pull request, and wait for required `verify`, `node20`, and `e2e` + checks. Direct pushes to `main` are not part of the release process. +3. After the PR is merged, update local `main` and create an annotated exact + SemVer tag (`git tag -a v1.4.0 -m "Release v1.4.0"`) on the same commit as + `origin/main`. - **🚫 TAG IMMUTABILITY**: Once a tag is pushed to `origin`, it is **PERMANENT**. You MUST **NEVER** reuse, move, or force-push an existing tag — not even to "fix" a mistake. If a release is missing a fix, increment the version and create a **new** tag (e.g., `v1.7.0` → `v1.7.1`). Tags are immutable identifiers; moving them breaks CI pipelines, corrupts the release history, and causes unreproducible builds. - **🚫 WHEN NOT TO TAG**: Do NOT create a release tag for changes that do NOT affect the shipped extension or server artifacts. Website text changes, documentation updates (`.md` files), and landing page content do NOT require a version tag. Tags trigger the full CI pipeline (Docker build, extension packaging, GitHub Release) — running this for a typo fix wastes CI resources and creates meaningless releases. Only tag when extension code (`extension/`), server code (`server/`), or shared protocol constants (`shared/`) have changed. -4. The CI will extract the version from the tag (e.g., `v1.4.0` → `1.4.0`), inject it into all source files, build the extension artifacts, publish the Docker image, and create a GitHub Release. -5. Verify the release builds on GitHub Actions. +4. The release workflow validates the unchanged tagged source, creates a draft + release, publishes and verifies the relay image, and makes the release public + only after every gate succeeds. +5. Verify GitHub assets, attestations, GHCR platforms/digest, and health smoke. ### 🚫 Force Push Policy > [!CAUTION] diff --git a/docs/SYNC_GUIDE.md b/docs/SYNC_GUIDE.md index df0f69b..6fec6ac 100644 --- a/docs/SYNC_GUIDE.md +++ b/docs/SYNC_GUIDE.md @@ -35,7 +35,10 @@ The build script performs the following actions: The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake. - The version is defined in `shared/constants.js`. - If the extension and server versions mismatch, the server will reject the connection with an `Incompatible protocol version` error. -- **Never manually bump version numbers**. The CI pipeline automatically injects the version from the git tag into `manifest.base.json`, `shared/constants.js`, and `package.json` during release builds. Run the build script to synchronize other constant updates. +- Never edit release versions independently. Run + `npm run prepare:release -- MAJOR.MINOR.PATCH` on a release-preparation branch; + the release tag is accepted only after that change reaches `main` with all CI + checks passing. > [!CAUTION] > **NEVER** edit the files inside `extension/shared/` directly. They will be overwritten the next time the build script is run. Always edit the files in the root `shared/` directory and then run the build script. diff --git a/docs/devops.md b/docs/devops.md index a9d08e0..6cf702b 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -4,30 +4,32 @@ This document describes the deployment and release process for KoalaSync. ## Tag-Based Releases -KoalaSync uses a fully automated release pipeline triggered by Git tags. +KoalaSync uses a gated release pipeline triggered by immutable Git tags. > [!IMPORTANT] -> **DO NOT** manually bump the version numbers in any files (such as `package.json`, `manifest.base.json`, `shared/constants.js`, etc.) before creating a release. -> Bumping versions manually is redundant, leads to conflicts, and is completely handled by the CI/CD pipeline. +> **DO NOT** edit individual version files or tag an unmerged branch. Run +> `npm run prepare:release -- MAJOR.MINOR.PATCH` on a branch, review all generated +> source changes, and merge them through a pull request with successful CI. ### How it Works -When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions release workflow (`.github/workflows/release.yml`) is triggered. The workflow performs the following actions: +When an annotated tag matching exact `vMAJOR.MINOR.PATCH` is pushed, the GitHub +Actions workflow performs these ordered gates: -1. **Extracts the version** from the tag (e.g., `2.5.1` from `v2.5.1`). -2. **Injects the version** automatically into the following files: - - `extension/manifest.base.json` - - `shared/constants.js` (updates `APP_VERSION`) - - `package.json` - - `package-lock.json` (root package metadata) - - `website/version.json` - - `website/template.html` (updates `softwareVersion` schema) - - `README.md` (updates badge and announcement banner) - - `website/sitemap.xml` (updates `lastmod` dates) -3. **Commits and pushes** these version updates back to the `main` branch automatically with the commit message `chore(release): update versions to vX.X.X [skip ci]`. -4. **Builds the extension** for both Chrome and Firefox and publishes the zipped archives with a `SHA256SUMS` checksum file and signed provenance attestations. -5. **Builds the website** and uploads website artifacts. -6. **Builds and publishes** the Docker image for the relay server to the GitHub Container Registry (`ghcr.io`). +1. Confirms that the tag is annotated, points exactly at current `origin/main`, + and matches every committed version source. +2. Requires successful `verify`, `node20`, and `e2e` checks for that commit. +3. Re-runs release verification, cross-browser E2E, and an unpublished relay + container smoke test. +4. Builds and locally validates Chrome/Firefox archives, checksums, AMO output, + website output, archive parity, and manifests. +5. Creates an attested **draft** GitHub Release. +6. Publishes the multi-architecture relay image, verifies both platforms, + attestation identity, digest, tag source, and a running health check. +7. Makes the GitHub Release public only after every preceding gate succeeds. + +The release workflow never writes to `main` and never derives shell code from a +tag. Version changes must pass normal branch protection first. --- @@ -35,18 +37,29 @@ When you push a Git tag matching `v*` (e.g., `v2.5.1`), the GitHub Actions relea To release a new version (e.g., `v2.5.1`), follow these steps: -1. Make sure your local repository is synced on `main`: +1. Create a release-preparation branch from current `main` and update every + version source atomically: ```bash git checkout main git pull origin main + git checkout -b release/v2.5.1 + npm run prepare:release -- 2.5.1 + npm run verify ``` -2. Create a local Git tag: +2. Commit the release notes and prepared version changes, open a pull request, + and wait for required `verify`, `node20`, and `e2e` checks. +3. After the PR is merged, fast-forward local `main` and create an **annotated** + tag on that exact commit: ```bash - git tag v2.5.1 + git checkout main + git pull --ff-only origin main + git tag -a v2.5.1 -m "Release v2.5.1" ``` -3. Push the tag to GitHub: +4. Verify the tag target, then push it once: ```bash + test "$(git rev-parse v2.5.1^{commit})" = "$(git rev-parse origin/main)" git push origin v2.5.1 ``` -The release pipeline will take care of the rest! You can monitor the progress under the **Actions** tab of the GitHub repository. +Never reuse or move a published tag. Monitor every release job and verify both +the public GitHub assets and GHCR digest before calling the release complete. diff --git a/extension/host-access.test.mjs b/extension/host-access.test.mjs index 82d5e2c..f3767b0 100644 --- a/extension/host-access.test.mjs +++ b/extension/host-access.test.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { HOST_ACCESS_REQUIRED_STATUS, addTabHostAccessRequest, @@ -16,6 +16,8 @@ import { const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); describe('host access helpers', () => { + afterEach(() => vi.useRealTimers()); + 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]) { @@ -38,6 +40,11 @@ describe('host access helpers', () => { }); expect(describeTabUrl('chrome://extensions/')).toBeNull(); expect(describeTabUrl('not a url')).toBeNull(); + expect(describeTabUrl('file:///Users/koala/movie.mp4')).toEqual({ + url: 'file:///Users/koala/movie.mp4', + host: 'local file', + originPattern: 'file:///*' + }); }); it('checks the selected tab origin and preserves an unknown callback result', async () => { @@ -112,9 +119,43 @@ describe('host access helpers', () => { }; await expect(requestOriginPermission(callbackChrome, 'https://video.example/*')).resolves.toBe(true); await expect(requestOriginPermission({ permissions: {} }, 'https://video.example/*')).resolves.toBeNull(); + await expect(requestOriginPermission(callbackChrome, '')).resolves.toBeNull(); + await expect(requestOriginPermission({ + permissions: { request: async () => { throw new Error('denied'); } } + }, 'https://video.example/*')).resolves.toBe(false); + await expect(addTabHostAccessRequest({ + permissions: { addHostAccessRequest: async () => { throw new Error('denied'); } } + }, 42)).resolves.toBe(false); + await expect(removeTabHostAccessRequest({ + permissions: { removeHostAccessRequest: async () => { throw new Error('denied'); } } + }, 42)).resolves.toBe(false); expect(isHostAccessError(new Error('Missing host permission for the tab'))).toBe(true); expect(isHostAccessError(new Error('No tab with id: 42'))).toBe(false); }); + + it('treats permission inspection errors and timeouts as advisory unknowns', async () => { + const base = { + tabs: { get: async () => ({ url: 'https://video.example/watch' }) } + }; + await expect(inspectTabHostAccess({ + ...base, + permissions: { contains: async () => { throw new Error('permission API failed'); } } + }, 42)).resolves.toMatchObject({ granted: null }); + + await expect(inspectTabHostAccess({ + ...base, + runtime: { lastError: { message: 'permission callback failed' } }, + permissions: { contains: (_request, callback) => callback(false) } + }, 42)).resolves.toMatchObject({ granted: null }); + + vi.useFakeTimers(); + const pending = inspectTabHostAccess({ + ...base, + permissions: { contains: () => undefined } + }, 42); + await vi.advanceTimersByTimeAsync(1000); + await expect(pending).resolves.toMatchObject({ granted: null }); + }); }); describe('host access recovery contracts', () => { diff --git a/extension/popup.html b/extension/popup.html index 8a8035e..05af8db 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1413,16 +1413,16 @@ -
- - - - - +
+ + + + +
-
+
@@ -1510,7 +1510,7 @@
-
+