Fix mobile username matching and expose VOD subtitles

This commit is contained in:
Technomancer702
2026-10-13 20:29:08 -07:00
parent 735b6299c9
commit b5e9086b24
13 changed files with 235 additions and 12 deletions

View File

@ -172,3 +172,48 @@ test('real authenticated media requests deliver remux and owned HLS output witho
assert.equal((await fixture.request('/api/transcode/lease', { ...post({ playbackLeaseId: lease }), method: 'DELETE', headers })).status, 200);
assert.equal((await fetch(`${fixture.base}${session.body.playlistUrl}`, { headers })).status, 404);
});
test('local login matches case variants and preserves account IDs, roles, passwords and display spelling', async t => {
const { request } = await serverFixture(t);
const admin = await request('/api/auth/setup', post({ username: 'Owner', password: 'owner-password' }));
assert.equal(admin.status, 201);
const headers = { Cookie: admin.cookie.split(';')[0] };
const viewer = await request('/api/auth/users', { ...post({ username: 'Viewer', password: 'viewer-password', role: 'viewer' }), headers });
assert.equal(viewer.status, 201);
for (const username of ['viewer', 'VIEWER', 'vIeWeR']) {
const login = await request('/api/auth/login', post({ username, password: 'viewer-password' }));
assert.equal(login.status, 200);
assert.deepEqual(login.body.user, { id: viewer.body.id, username: 'Viewer', role: 'viewer' });
}
assert.equal((await request('/api/auth/login', post({ username: 'viewer', password: 'VIEWER-PASSWORD' }))).status, 401);
assert.equal((await request('/api/auth/login', post({ username: 'owner', password: 'viewer-password' }))).status, 401);
assert.equal((await request('/api/auth/users', { ...post({ username: 'VIEWER', password: 'another-password', role: 'admin' }), headers })).status, 500);
assert.equal((await request(`/api/auth/users/${viewer.body.id}`, { method: 'PUT', body: JSON.stringify({ username: 'OWNER' }), headers })).status, 500);
assert.equal((await request(`/api/auth/users/${viewer.body.id}`, { method: 'PUT', body: JSON.stringify({ username: 'VIEWER' }), headers })).status, 200);
const renamed = await request('/api/auth/login', post({ username: 'viewer', password: 'viewer-password' }));
assert.equal(renamed.body.user.id, viewer.body.id); assert.equal(renamed.body.user.username, 'VIEWER');
});
test('legacy case-only accounts retain exact logins and reject ambiguous case variants until renamed', async t => {
const fixture = await serverFixture(t);
await fixture.request('/api/auth/setup', post({ username: 'Admin', password: 'admin-password' }));
await fixture.stop();
const filename = path.join(fixture.dir, 'db.json'), data = JSON.parse(fs.readFileSync(filename, 'utf8'));
const bcrypt = require('bcryptjs');
data.users.push({ id: data.nextId++, username: 'Twin', role: 'admin', passwordHash: await bcrypt.hash('first-password', 4) },
{ id: data.nextId++, username: 'twin', role: 'viewer', passwordHash: await bcrypt.hash('second-password', 4) });
fs.writeFileSync(filename, JSON.stringify(data)); await fixture.start();
for (const [username, password, role] of [['Twin', 'first-password', 'admin'], ['twin', 'second-password', 'viewer']]) {
const login = await fixture.request('/api/auth/login', post({ username, password }));
assert.equal(login.status, 200); assert.equal(login.body.user.role, role);
}
assert.equal((await fixture.request('/api/auth/login', post({ username: 'TWIN', password: 'first-password' }))).status, 401);
assert.equal((await fixture.request('/api/auth/login', post({ username: 'Twin', password: 'second-password' }))).status, 401);
const owner = await fixture.request('/api/auth/login', post({ username: 'admin', password: 'admin-password' }));
const headers = { Cookie: owner.cookie.split(';')[0] }, lower = data.users.find(user => user.username === 'twin');
assert.equal((await fixture.request(`/api/auth/users/${lower.id}`, { method: 'PUT', body: JSON.stringify({ username: 'twin', password: 'changed-password' }), headers })).status, 200);
assert.equal((await fixture.request(`/api/auth/users/${lower.id}`, { method: 'PUT', body: JSON.stringify({ username: 'TWIN' }), headers })).status, 500);
assert.equal((await fixture.request(`/api/auth/users/${lower.id}`, { method: 'PUT', body: JSON.stringify({ username: 'Other' }), headers })).status, 200);
const resolved = await fixture.request('/api/auth/login', post({ username: 'TWIN', password: 'first-password' }));
assert.equal(resolved.status, 200); assert.equal(resolved.body.user.username, 'Twin');
});

View File

@ -106,3 +106,23 @@ test('source route does not reapply fields from a stale read', async t => {
assert.equal(res.value.url, 'https://example.invalid/current');
assert.equal(res.value.name, 'Renamed');
});
test('case-folded username creation and renames are atomic without merging legacy accounts', async t => {
const { db, dir } = await fixture(t);
const created = await Promise.allSettled(['Alice', 'ALICE', 'alice'].map(username => db.users.create({ username })));
assert.equal(created.filter(result => result.status === 'fulfilled').length, 1);
const alice = (await db.users.getAll())[0]; assert.equal((await db.users.getByUsername('aLiCe')).id, alice.id);
for (const username of [null, [], {}, '', ' ', 'a'.repeat(65), 'bad\nname']) await assert.rejects(db.users.create({ username }));
const bob = await db.users.create({ username: 'Bob' });
await assert.rejects(db.users.update(bob.id, { username: 'ALICE' }), /already exists/);
await db.users.update(alice.id, { username: 'ALICE' }); assert.equal((await db.users.getByUsername('alice')).id, alice.id);
const renames = await Promise.allSettled([db.users.update(alice.id, { username: 'Shared' }), db.users.update(bob.id, { username: 'SHARED' })]);
assert.equal(renames.filter(result => result.status === 'fulfilled').length, 1);
assert.equal((await db.users.getAll()).length, 2);
const legacy = { users: [{ id: 1, username: 'Old', role: 'admin' }, { id: 2, username: 'old', role: 'viewer' }], nextId: 3 };
await fs.writeFile(path.join(dir, 'db.json'), JSON.stringify(legacy));
assert.equal((await db.users.getByUsername('Old')).id, 1); assert.equal((await db.users.getByUsername('old')).id, 2);
assert.equal(await db.users.getByUsername('OLD'), undefined);
await db.users.update(2, { username: 'old', passwordHash: 'changed' });
const saved = await db.loadDb(); assert.equal(saved.users.length, 2); assert.equal(saved.users[1].authVersion, 1);
});

View File

@ -101,6 +101,9 @@ test('login prevents native GET credential leaks after missing or failed scripts
page.on('pageerror', () => {});
await page.route('**/*', route => route.request().url().startsWith(base) ? route.continue() : route.abort());
await page.goto(base + '/login.html');
assert.equal(await page.locator('#username').getAttribute('autocapitalize'), 'none');
assert.equal(await page.locator('#username').getAttribute('autocorrect'), 'off');
assert.equal(await page.locator('#username').getAttribute('spellcheck'), 'false');
await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
assert.equal(await page.locator('#submit-btn').isDisabled(), true);
await page.locator('#password').press('Enter');
@ -128,6 +131,9 @@ test('login prevents native GET credential leaks after missing or failed scripts
await page.goto(base + '/login.html');
await page.waitForFunction(() => !document.querySelector('#submit-btn').disabled);
assert.equal(await page.locator('#submit-btn').textContent(), endpoint.endsWith('setup') ? 'Create Account' : 'Sign In');
assert.equal(await page.locator('#username').getAttribute('autocapitalize'), 'none');
assert.equal(await page.locator('#username').getAttribute('autocorrect'), 'off');
assert.equal(await page.locator('#username').getAttribute('spellcheck'), 'false');
await page.fill('#username', 'fixture-user'); await page.fill('#password', 'fixture-password');
const sent = page.waitForRequest(request => request.url() === base + endpoint && request.method() === 'POST');
await page.click('#submit-btn');

101
test/vod-subtitles.test.js Normal file
View File

@ -0,0 +1,101 @@
process.env.ALLOW_LOCAL_MEDIA_URLS = 'true';
const test = require('node:test'), assert = require('node:assert/strict');
const fs = require('node:fs'), os = require('node:os'), path = require('node:path'), http = require('node:http'), vm = require('node:vm');
const { spawnSync } = require('node:child_process'), { createRequire } = require('node:module');
const express = require('express');
function route(file) {
const filename = path.join(__dirname, '../server/routes', file), realRequire = createRequire(filename), module = { exports: {} };
vm.runInNewContext(fs.readFileSync(filename, 'utf8'), { module, URL, setTimeout, clearTimeout, console: { log() {}, error() {} },
require: id => id === '../db' ? { settings: { get: async () => ({}) }, getUserAgent: () => 'Fixture' } : realRequire(id) }, { filename });
return module.exports;
}
test('Watch VOD probes expose real embedded subtitles for HLS and remux, align seeks, and reset on content changes',
{ skip: process.env.NODECAST_BROWSER_TESTS === '1' ? false : 'Browser tests enabled in CI', timeout: 60000 }, async t => {
const { chromium } = require('playwright');
const binary = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0 ? 'ffmpeg' : require('ffmpeg-static');
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nodecast-vod-subs-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
for (const language of ['English', 'French']) fs.writeFileSync(path.join(dir, language + '.srt'),
`1\n00:00:01,000 --> 00:00:04,000\n${language} early\n\n2\n00:00:08,000 --> 00:00:11,000\n${language} late\n`);
const run = args => { const result = spawnSync(binary, ['-hide_banner', '-loglevel', 'error', '-y', ...args], { encoding: 'utf8', timeout: 15000 }); assert.equal(result.status, 0, result.stderr); };
const movie = path.join(dir, 'movie.mkv');
run(['-f', 'lavfi', '-i', 'color=c=black:s=160x90:r=10', '-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
'-i', path.join(dir, 'English.srt'), '-i', path.join(dir, 'French.srt'), '-map', '0:v', '-map', '1:a', '-map', '2:s', '-map', '3:s',
'-t', '12', '-c:v', 'libx264', '-threads', '1', '-preset', 'ultrafast', '-g', '10', '-c:a', 'aac', '-c:s', 'srt',
'-metadata:s:s:0', 'language=eng', '-metadata:s:s:0', 'title=English', '-metadata:s:s:1', 'language=fra', '-metadata:s:s:1', 'title=French', movie]);
const direct = path.join(dir, 'direct.mp4');
run(['-i', movie, '-map', '0', '-c:v', 'copy', '-c:a', 'copy', '-c:s', 'mov_text', direct]);
for (const [name, offset] of [['video', '0'], ['seek', '6']]) run(['-ss', offset, '-i', movie, '-map', '0:v', '-map', '0:a', '-c', 'copy',
'-f', 'hls', '-hls_time', '1', '-hls_list_size', '0', '-hls_segment_filename', path.join(dir, name + '%d.ts'), path.join(dir, name + '.m3u8')]);
const scripts = ['/js/components/CaptionTracks.js', '/js/components/HlsAudioRecovery.js', '/js/components/PlaybackProbe.js',
'/js/components/VodDuration.js', '/js/pages/WatchPage.js'], root = path.join(__dirname, '../public');
const app = express(), requests = [];
app.locals.ffmpegPath = binary;
app.locals.ffprobePath = spawnSync('ffprobe', ['-version'], { stdio: 'ignore' }).status === 0 ? 'ffprobe' : require('@ffprobe-installer/ffprobe').path;
app.use((req, res, next) => { requests.push(req.url); next(); });
app.get('/', (req, res) => res.type('html').send(`<video muted></video><div id="list"></div><div id="menu"></div><div id="ui"></div><script src="/hls.js"></script>${scripts.map(src => `<script src="${src}"></script>`).join('')}`));
app.get('/hls.js', (req, res) => res.type('js').send(fs.readFileSync(require.resolve('hls.js/dist/hls.min.js'))));
for (const file of scripts) app.get(file, (req, res) => res.type('js').send(fs.readFileSync(path.join(root, file))));
for (const name of ['/movie.mkv', '/movie.ts']) app.get(name, (req, res) => res.type('video/x-matroska').send(fs.readFileSync(movie)));
app.get('/direct.mp4', (req, res) => res.sendFile(direct));
app.use('/api/probe', route('probe.js')); app.use('/api/subtitle', route('subtitle.js')); app.use('/api/remux', route('remux.js'));
app.use(express.static(dir));
const server = http.createServer(app); await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => { server.close(resolve); server.closeAllConnections(); }));
const browser = await chromium.launch({ headless: true, executablePath: process.env.NODECAST_BROWSER_EXECUTABLE || undefined });
t.after(() => browser.close());
const base = `http://127.0.0.1:${server.address().port}`, page = await browser.newPage(), errors = [];
page.on('pageerror', error => errors.push(error.message));
if (process.env.NODECAST_BROWSER_DEBUG) page.on('console', message => console.log('VOD browser:', message.text()));
await page.goto(base);
await page.evaluate(() => {
window.API = { settings: { get: async () => window.settings } };
window.settings = { autoTranscode: true };
const ui = document.querySelector('#ui'), watch = window.watch = Object.create(WatchPage.prototype);
Object.assign(watch, { video: document.querySelector('video'), captionsList: document.querySelector('#list'), captionsMenu: document.querySelector('#menu'),
overlay: ui, nowPlaying: ui, content: { type: 'movie' }, resumeTime: 0, stopHistoryTracking() {}, saveProgress() {}, hideNowPlaying() {},
showLoading() {}, updateTranscodeStatus() {}, updateQualityBadge() {}, setVolumeFromStorage() {},
getHlsConfig: () => ({ enableWorker: false }),
stopTranscodeSession() { this.currentSessionId = null; this.playbackTimeOffset = 0; },
async startTranscodeSession(url, options) {
this.currentSessionId = 'fixture'; this.currentTranscodeOptions = options; this.playbackTimeOffset = options.seekOffset || 0;
return this.playbackTimeOffset ? '/seek.m3u8' : '/video.m3u8';
} });
});
await page.evaluate(base => window.watch.loadVideo(base + '/movie.mkv'), base);
await page.waitForFunction(() => document.querySelectorAll('#list button').length === 3, null, { timeout: 10000 }).catch(async error => {
console.error('VOD state:', await page.evaluate(() => ({ src: window.watch.video.src, probe: window.watch.getCaptionTracks().probe, hls: !!window.watch.hls, list: document.querySelector('#list').textContent })), errors, requests); throw error;
});
assert.deepEqual(await page.locator('#list button').allTextContents(), ['Off', 'English', 'French']);
assert.equal(requests.some(url => url.startsWith('/api/subtitle')), false, 'Unselected subtitles must not open provider connections');
await page.locator('#list button').nth(2).click();
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && track.cues?.length === 2));
await page.evaluate(() => { window.watch.video.pause(); window.watch.video.currentTime = 2; });
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French early')));
await page.evaluate(() => window.watch.seekToContentTime(6));
await page.waitForFunction(() => window.watch.hls?.url?.endsWith('/seek.m3u8') && document.querySelectorAll('#list button').length === 3);
await page.locator('#list button').nth(2).click();
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.cues || []).some(cue => cue.text === 'French late' && cue.startTime > 1.9 && cue.startTime < 2.1)));
await page.evaluate(() => { window.watch.video.pause(); window.watch.video.currentTime = 3; });
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French late')));
// Reversing an offset restores original timings, including cues clipped by a forward seek.
await page.evaluate(() => { window.watch.getCaptionTracks().setTimeOffset(0); window.watch.video.currentTime = 2; });
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.activeCues || []).some(cue => cue.text === 'French early')));
for (const settings of [{ autoTranscode: false, forceTranscode: true }, { autoTranscode: false, forceRemux: true }, { autoTranscode: true }, { autoTranscode: true, direct: true }]) {
await page.evaluate(async ({ base, settings }) => {
window.settings = settings;
const original = window.watch.playbackProbe.read.bind(window.watch.playbackProbe);
if (settings.autoTranscode && !settings.direct) window.watch.playbackProbe.read = async (...args) => ({ ...await original(...args), needsTranscode: false, needsRemux: true });
await window.watch.loadVideo(base + (settings.direct ? '/direct.mp4' : '/movie.ts')); window.watch.playbackProbe.read = original;
}, { base, settings });
await page.waitForFunction(() => document.querySelectorAll('#list button').length === 3 && window.watch.video.readyState >= 2);
assert.equal(await page.locator('video > track').count(), 2);
await page.locator('#list button').nth(2).click();
await page.waitForFunction(() => Array.from(window.watch.video.textTracks).some(track => track.mode === 'showing' && Array.from(track.cues || []).some(cue => cue.text === 'French early' && cue.startTime > 0.9 && cue.startTime < 1.1)), null, { timeout: 10000 }).catch(async error => { console.error('Caption mode:', settings, await page.evaluate(() => ({ offset: window.watch.getCaptionTracks().timeOffset, probe: window.watch.getCaptionTracks().probe, tracks: Array.from(window.watch.video.textTracks, track => ({ label: track.label, mode: track.mode, cues: Array.from(track.cues || [], cue => ({ text: cue.text, start: cue.startTime, end: cue.endTime })) })) }))); throw error; });
}
await page.evaluate(async base => { window.settings = {}; await window.watch.loadVideo(base + '/video.m3u8'); }, base);
await page.waitForFunction(() => window.watch.getCaptionTracks().manifestReady && document.querySelectorAll('#list button').length === 1);
assert.equal(await page.locator('video > track').count(), 0); assert.deepEqual(errors, []);
await page.evaluate(() => window.watch.stop()); await page.close();
});