410 lines
15 KiB
JavaScript
410 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
const 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 modulePathCache = new Map();
|
|
let activeTransferState = null;
|
|
|
|
function mockModule(resolvedPath, exportsValue) {
|
|
const current = require.cache[resolvedPath];
|
|
modulePathCache.set(resolvedPath, current);
|
|
require.cache[resolvedPath] = {
|
|
id: resolvedPath,
|
|
filename: resolvedPath,
|
|
loaded: true,
|
|
exports: exportsValue,
|
|
};
|
|
}
|
|
|
|
function restoreModules() {
|
|
for (const [resolvedPath, original] of modulePathCache) {
|
|
if (original === undefined) delete require.cache[resolvedPath];
|
|
else require.cache[resolvedPath] = original;
|
|
}
|
|
modulePathCache.clear();
|
|
}
|
|
|
|
function installRegistryMocks(rootDir) {
|
|
const createOperationResult = () => ({
|
|
status: 'ok',
|
|
services: [],
|
|
metadata: {},
|
|
scans: [],
|
|
health: {},
|
|
});
|
|
|
|
mockModule(path.resolve(rootDir, 'src/platform/data-paths.js'), {
|
|
mountedRoots: () => [],
|
|
normalizeAllowedRoots: (roots, fallback) => (Array.isArray(roots) && roots.length ? roots : [fallback]),
|
|
resolveAllowedPath: (candidate, _roots, options = {}) => {
|
|
if (!candidate) return options.fallbackRoot || '';
|
|
return path.isAbsolute(candidate) ? candidate : path.join(options.fallbackRoot || '', candidate);
|
|
},
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/hashing.js'), {
|
|
hashFile: async () => Buffer.from('fake-hash'),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/elf-arsenal-client.js'), {
|
|
createElfArsenalClient: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/local-library-scanner.js'), {
|
|
scanLocalLibraries: async () => ({ games: [], added: 0, total: 0 }),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/transfer-service.js'), {
|
|
createTransferService: ({ broadcast, getCredentials }) => {
|
|
const executors = new Map();
|
|
const state = {
|
|
jobs: [],
|
|
jobsById: new Map(),
|
|
registerExecutor: (kind, factory) => {
|
|
executors.set(kind, factory);
|
|
},
|
|
enqueue: (type, options = {}, runner) => {
|
|
const jobId = `job-${state.jobs.length + 1}`;
|
|
let status = 'queued';
|
|
const progress = {};
|
|
const throwIfCancelled = () => {
|
|
if (status === 'cancelled') {
|
|
throw new Error('Taak geannuleerd');
|
|
}
|
|
};
|
|
const context = {
|
|
progress: () => {},
|
|
throwIfCancelled,
|
|
client: {
|
|
uploadFile: async () => ({ success: true }),
|
|
uploadDirectory: async () => ({ success: true }),
|
|
downloadFile: async () => ({ success: true }),
|
|
},
|
|
};
|
|
const job = {
|
|
jobId,
|
|
id: jobId,
|
|
type,
|
|
progress,
|
|
status,
|
|
promise: Promise.resolve(),
|
|
deduplicated: false,
|
|
};
|
|
const execute = async () => {
|
|
status = 'active';
|
|
if (runner) {
|
|
await runner(context);
|
|
} else {
|
|
const executor = executors.get(String((options.descriptor || {}).kind || type));
|
|
if (!executor) throw new Error(`No executor for ${options.descriptor?.kind || type}`);
|
|
await executor(options.descriptor || {})(context);
|
|
}
|
|
return { success: true, remotePath: options.remotePath };
|
|
};
|
|
const credentials = getCredentials?.();
|
|
if (credentials) {
|
|
context.credentials = credentials;
|
|
}
|
|
const resolved = execute()
|
|
.then(result => { status = 'done'; broadcast?.('transfer:update', state.state()); return result; })
|
|
.catch(error => {
|
|
status = 'failed';
|
|
throw error;
|
|
});
|
|
job.promise = resolved;
|
|
job.status = status;
|
|
state.jobs.push(job);
|
|
state.jobsById.set(jobId, job);
|
|
broadcast?.('transfer:update', state.state());
|
|
return job;
|
|
},
|
|
pause: () => {},
|
|
resume: () => {},
|
|
clearDone: () => {
|
|
state.jobs = [];
|
|
state.jobsById = new Map();
|
|
},
|
|
setConcurrent: () => 4,
|
|
setPolicy: options => options,
|
|
retry: () => false,
|
|
cancel: () => false,
|
|
state: () => ({ jobs: state.jobs.slice(), policy: { bandwidthLimitSupported: false } }),
|
|
close: () => {},
|
|
};
|
|
activeTransferState = state;
|
|
return state;
|
|
},
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/conversion-service.js'), {
|
|
createConversionService: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/operations-service.js'), {
|
|
createOperationsService: () => new Proxy({}, {
|
|
get: (_target, prop) => {
|
|
if (prop === 'start' || prop === 'stop') return () => {};
|
|
return () => createOperationResult();
|
|
},
|
|
}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/ecosystem-service.js'), {
|
|
createEcosystemService: () => ({
|
|
stop: () => {},
|
|
payloads: {},
|
|
listRemoteSources: () => [],
|
|
classification: {},
|
|
downloads: () => [],
|
|
}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/ecosystem-intelligence-service.js'), {
|
|
createEcosystemIntelligenceService: () => ({ latestHealth: () => ({}) }),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/game-update-service.js'), {
|
|
createGameUpdateService: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/ps5debug-readonly-client.js'), {
|
|
createPs5DebugReadonlyClient: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/remote-play-service.js'), {
|
|
createRemotePlayService: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/backup-replication-service.js'), {
|
|
createBackupReplicationService: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/payload-runtime-service.js'), {
|
|
createPayloadRuntimeService: () => ({
|
|
watchdog: () => ({}),
|
|
updatePlan: () => ({}),
|
|
refreshRepositories: () => ({}),
|
|
installUpdate: () => ({}),
|
|
}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/chimera-beacon-client.js'), {
|
|
createChimeraBeaconClient: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/console-profile-service.js'), {
|
|
createConsoleProfileService: () => {
|
|
const secrets = new Map();
|
|
return {
|
|
activeId: () => 'console-1',
|
|
getSecret: key => secrets.get(String(key)),
|
|
setSecret: (key, value) => { secrets.set(String(key), value); },
|
|
syncActiveFromLegacy: () => {},
|
|
list: () => [],
|
|
comparison: () => ({ local: [], remote: [] }),
|
|
save: () => ({}),
|
|
activate: () => ({}),
|
|
remove: () => ({}),
|
|
storageTopology: () => ({ roots: [], games: [] }),
|
|
};
|
|
},
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/companion-worker-service.js'), {
|
|
createCompanionWorkerService: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/audit-log-service.js'), {
|
|
createAuditLogService: () => ({
|
|
query: () => [],
|
|
health: () => ({ ok: true }),
|
|
exportAudit: () => Buffer.from('[]'),
|
|
}),
|
|
normalizeEntry: () => ({}),
|
|
LEVELS: {},
|
|
CLASSIFICATIONS: {},
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/ps5-klog-service.js'), {
|
|
createPs5KlogService: () => ({ start: () => {}, stop: () => {} }),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/security/totp.js'), {
|
|
createTotpService: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/notification-service.js'), {
|
|
createNotificationService: () => ({ emit: async () => ({}) }),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/source-classification-service.js'), {
|
|
createSourceClassificationService: () => ({}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/game-metadata-view.js'), {
|
|
sanitizeOverride: value => value,
|
|
annotateRegionalVariants: value => value,
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/security/read-only-sql.js'), {
|
|
assertReadOnlySql: value => value,
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/server/register-functional-modules.js'), {
|
|
registerFunctionalModules: () => ({
|
|
metadata: {
|
|
queue: () => {},
|
|
queueDue: () => [],
|
|
status: () => ({}),
|
|
sync: () => [],
|
|
snapshot: () => ({}),
|
|
},
|
|
payloadCatalog: {
|
|
sync: async () => ({ count: 0 }),
|
|
seed: () => ({ count: 0 }),
|
|
snapshot: () => ({}),
|
|
},
|
|
payloadManager: {},
|
|
cheatsSync: async () => ({}),
|
|
}),
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/ezremote-server-client.js'), {
|
|
createEzRemoteServerClient: () => ({
|
|
probe: () => ({ status: 'mock' }),
|
|
downloads: () => [],
|
|
waitFor: () => ({}),
|
|
ensureDownload: async () => ({ completed: true, download: { transferred: 123, size: 123, path: '' } }),
|
|
}),
|
|
buildDownloadRequest: input => input,
|
|
DEFAULT_DESTINATION_ROOTS: ['/games/ps4', '/games/ps5'],
|
|
});
|
|
mockModule(path.resolve(rootDir, 'src/services/console-scan-reconciler.js'), {
|
|
reconcileConsoleScan: () => ({ applied: true, upserted: 0, cleared: 0 }),
|
|
});
|
|
}
|
|
|
|
function withMockedRegistry(callback) {
|
|
const rootDir = path.resolve(__dirname, '../..');
|
|
let originalCreateRegistry;
|
|
const registryPath = path.resolve(rootDir, 'src/server/create-registry.js');
|
|
let active = true;
|
|
const cleanup = () => {
|
|
if (!active) return;
|
|
active = false;
|
|
restoreModules();
|
|
delete require.cache[registryPath];
|
|
};
|
|
|
|
try {
|
|
installRegistryMocks(rootDir);
|
|
delete require.cache[registryPath];
|
|
originalCreateRegistry = require(registryPath);
|
|
const result = callback(originalCreateRegistry.createRegistry);
|
|
if (result && typeof result.then === 'function') {
|
|
return result.finally(cleanup);
|
|
}
|
|
cleanup();
|
|
return result;
|
|
} finally {
|
|
if (active === false) return;
|
|
cleanup();
|
|
}
|
|
}
|
|
|
|
function createDbWithTracking() {
|
|
const updates = [];
|
|
const games = new Map();
|
|
const db = new Proxy({
|
|
__updates: updates,
|
|
updateGame: (gameId, fields) => {
|
|
updates.push({ gameId, fields });
|
|
const current = games.get(String(gameId)) || { game_id: String(gameId), ftp_path: '' };
|
|
games.set(String(gameId), { ...current, ...fields });
|
|
},
|
|
getGame: gameId => games.get(String(gameId)) || null,
|
|
upsertGame: game => { if (game?.game_id) games.set(String(game.game_id), { ...(games.get(String(game.game_id)) || {}), ...game }); },
|
|
setGamePorked: () => {},
|
|
listBackporkFolders: () => [],
|
|
listBackups: () => [],
|
|
upsertGameMinimal: () => {},
|
|
setGameFirmwareLabel: () => {},
|
|
upsertBackup: () => {},
|
|
pruneGameFirmwareForFolder: () => {},
|
|
pruneOrphanScanGames: () => {},
|
|
syncBackedUpFlags: () => {},
|
|
beginBulkUpdate: () => {},
|
|
endBulkUpdate: () => {},
|
|
}, {
|
|
get(target, property) {
|
|
return Reflect.get(target, property) || (() => {});
|
|
},
|
|
});
|
|
return { db, updates };
|
|
}
|
|
|
|
function waitForJob(transferState, jobId) {
|
|
const job = transferState.jobsById.get(jobId);
|
|
if (!job) throw new Error(`Missing job ${jobId}`);
|
|
return job.promise;
|
|
}
|
|
|
|
test('FTP upload queue completion does not set installed', async () => {
|
|
await withMockedRegistry(async (createRegistry) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0003-upload-'));
|
|
try {
|
|
const localFile = path.join(root, 'game.pkg');
|
|
fs.writeFileSync(localFile, 'payload');
|
|
const { db, updates } = createDbWithTracking();
|
|
db.upsertGame({ game_id: 'PPSA12345', ftp_path: '/existing/path', installed: 1 });
|
|
const settings = { get: () => '127.0.0.1', set: () => {} };
|
|
const secrets = { get: () => '', has: () => false };
|
|
const ftp = { isConnected: () => true, hasCredentials: () => true };
|
|
const paths = { root, cache: root, backups: root, tmp: root, config: root };
|
|
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
|
const registry = createRegistry({
|
|
db,
|
|
ftp,
|
|
settings,
|
|
secrets,
|
|
paths,
|
|
logger,
|
|
capabilities: { features: {} },
|
|
broadcast: () => {},
|
|
appRoot: root,
|
|
});
|
|
|
|
const upload = registry.commands.get('ftp:upload');
|
|
assert.ok(upload);
|
|
const result = upload.handler({ localPath: localFile, remotePath: '/mnt/sandbox' });
|
|
await waitForJob(activeTransferState, result.jobId);
|
|
assert.equal(result.success, true);
|
|
assert.equal(updates.length, 0, 'no db.updateGame calls expected after upload completion');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
activeTransferState = null;
|
|
}
|
|
});
|
|
});
|
|
|
|
test('Backpork queue completion writes ftp_path but never flips installed', async () => {
|
|
await withMockedRegistry(async (createRegistry) => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-ch0003-pork-'));
|
|
try {
|
|
const backporkRoot = path.join(root, 'backporks');
|
|
fs.mkdirSync(backporkRoot, { recursive: true });
|
|
const gameFolder = path.join(backporkRoot, 'PPSA54321-pack');
|
|
fs.mkdirSync(gameFolder, { recursive: true });
|
|
const { db, updates } = createDbWithTracking();
|
|
db.upsertGame({ game_id: 'PPSA54321', ftp_path: '/mnt/sandbox/pfsmnt/PPSA54321', installed: 1 });
|
|
const settings = { get: () => '127.0.0.1', set: () => {} };
|
|
const secrets = { get: () => '', has: () => false };
|
|
const ftp = { isConnected: () => true, hasCredentials: () => true };
|
|
const paths = { root, cache: root, backups: root, tmp: root, config: root };
|
|
const logger = { info() {}, warn() {}, error() {}, audit() {} };
|
|
const registry = createRegistry({
|
|
db,
|
|
ftp,
|
|
settings,
|
|
secrets,
|
|
paths,
|
|
logger,
|
|
capabilities: { features: {} },
|
|
broadcast: () => {},
|
|
appRoot: root,
|
|
});
|
|
|
|
const pork = registry.commands.get('backporks:pork');
|
|
assert.ok(pork);
|
|
const queued = pork.handler({ game_id: 'PPSA54321', folder_name: '11.50.0', folder_path: backporkRoot });
|
|
await waitForJob(activeTransferState, queued.jobId);
|
|
assert.equal(updates.length >= 1, true);
|
|
assert.equal(updates.some(item => item.fields && Object.prototype.hasOwnProperty.call(item.fields, 'ftp_path')), true);
|
|
const hasInstalled = updates.some(item => item.fields && Object.prototype.hasOwnProperty.call(item.fields, 'installed'));
|
|
assert.equal(hasInstalled, false, 'backpork completion must not set installed');
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
activeTransferState = null;
|
|
}
|
|
});
|
|
});
|