Harden same-name hoster recovery
Serialize recovery windows per hoster account and normalized filename, reserve remote codes across the batch, and keep cancellation responsive while waiting. Reject semantic and malformed file-list baselines instead of treating them as empty accounts, with focused regression coverage and release allowlist updates.
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
const { after, before, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const hosters = require('../lib/hosters');
|
||||
const originalUploadFile = hosters.uploadFile;
|
||||
const originalPrefetchBaseline = hosters.prefetchBaseline;
|
||||
let tempRoot;
|
||||
let firstPath;
|
||||
let secondPath;
|
||||
let UploadManager;
|
||||
|
||||
before(() => {
|
||||
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-manager-recovery-'));
|
||||
const firstDir = path.join(tempRoot, 'first');
|
||||
const secondDir = path.join(tempRoot, 'second');
|
||||
fs.mkdirSync(firstDir);
|
||||
fs.mkdirSync(secondDir);
|
||||
firstPath = path.join(firstDir, 'Shared Episode.mkv');
|
||||
secondPath = path.join(secondDir, 'shared-episode.mp4');
|
||||
fs.writeFileSync(firstPath, Buffer.alloc(1024, 1));
|
||||
fs.writeFileSync(secondPath, Buffer.alloc(1024, 2));
|
||||
});
|
||||
|
||||
after(() => {
|
||||
hosters.uploadFile = originalUploadFile;
|
||||
hosters.prefetchBaseline = originalPrefetchBaseline;
|
||||
delete require.cache[require.resolve('../lib/upload-manager')];
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function loadManager(uploadFile) {
|
||||
hosters.uploadFile = uploadFile;
|
||||
hosters.prefetchBaseline = async () => new Set();
|
||||
delete require.cache[require.resolve('../lib/upload-manager')];
|
||||
UploadManager = require('../lib/upload-manager');
|
||||
}
|
||||
|
||||
function settings(parallelCount) {
|
||||
return {
|
||||
'byse.sx': {
|
||||
retries: 0,
|
||||
parallelCount,
|
||||
maxSpeedKbs: 0,
|
||||
restartBelowKbs: 0,
|
||||
timeIntervalSec: 0,
|
||||
maxSizeMb: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function runBatch(manager, tasks) {
|
||||
let summary;
|
||||
manager.once('batch-done', value => {
|
||||
summary = value;
|
||||
});
|
||||
await manager.startBatch(tasks);
|
||||
return summary;
|
||||
}
|
||||
|
||||
test('a batch shares recovery claims across normalized same-name jobs', async () => {
|
||||
let unsafeCalls = 0;
|
||||
loadManager(async (hoster, file, apiKey, onProgress, signal, throttle, options) => {
|
||||
const claim = options && options.recoveryClaim;
|
||||
if (claim && claim.reserve('SHARED_REMOTE_CODE')) {
|
||||
return {
|
||||
file_code: 'SHARED_REMOTE_CODE',
|
||||
download_url: 'https://byse.sx/d/SHARED_REMOTE_CODE'
|
||||
};
|
||||
}
|
||||
if (!claim) {
|
||||
unsafeCalls++;
|
||||
return {
|
||||
file_code: `UNSAFE_${unsafeCalls}`,
|
||||
download_url: `https://byse.sx/d/UNSAFE_${unsafeCalls}`
|
||||
};
|
||||
}
|
||||
const error = new Error('Remote recovery candidate already claimed');
|
||||
error.hosterTransient = true;
|
||||
throw error;
|
||||
});
|
||||
const manager = new UploadManager(settings(2));
|
||||
|
||||
const summary = await runBatch(manager, [
|
||||
{ jobId: 'same-name-a', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' },
|
||||
{ jobId: 'same-name-b', file: secondPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' }
|
||||
]);
|
||||
|
||||
assert.equal(summary.succeeded, 1);
|
||||
assert.equal(summary.failed, 1);
|
||||
});
|
||||
|
||||
test('recovery claims stay isolated between accounts', async () => {
|
||||
loadManager(async (hoster, file, apiKey, onProgress, signal, throttle, options) => {
|
||||
if (!options || !options.recoveryClaim) {
|
||||
throw new Error('Missing recovery claim');
|
||||
}
|
||||
if (!options.recoveryClaim.reserve('SHARED_REMOTE_CODE')) {
|
||||
const error = new Error('Remote recovery candidate already claimed');
|
||||
error.hosterTransient = true;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
file_code: 'SHARED_REMOTE_CODE',
|
||||
download_url: 'https://byse.sx/d/SHARED_REMOTE_CODE'
|
||||
};
|
||||
});
|
||||
const manager = new UploadManager(settings(2));
|
||||
|
||||
const summary = await runBatch(manager, [
|
||||
{ jobId: 'account-a', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_A' },
|
||||
{ jobId: 'account-b', file: secondPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_B' }
|
||||
]);
|
||||
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(summary.failed, 0);
|
||||
});
|
||||
|
||||
test('normalized same-name recovery sections never overlap', async () => {
|
||||
let active = 0;
|
||||
let maximumActive = 0;
|
||||
let sequence = 0;
|
||||
loadManager(async () => {
|
||||
active++;
|
||||
maximumActive = Math.max(maximumActive, active);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
active--;
|
||||
sequence++;
|
||||
return {
|
||||
file_code: `SERIAL_${sequence}`,
|
||||
download_url: `https://byse.sx/d/SERIAL_${sequence}`
|
||||
};
|
||||
});
|
||||
const manager = new UploadManager(settings(2));
|
||||
|
||||
const summary = await runBatch(manager, [
|
||||
{ jobId: 'serialized-a', file: firstPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' },
|
||||
{ jobId: 'serialized-b', file: secondPath, hoster: 'byse.sx', apiKey: 'ACCOUNT_KEY' }
|
||||
]);
|
||||
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(maximumActive, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user