mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-07-01 00:36:01 -04:00
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import {
|
|
loginAndGetToken,
|
|
createDownloadClient,
|
|
listDownloadClients,
|
|
deleteDownloadClient,
|
|
createArrInstance,
|
|
deleteArrInstance,
|
|
getMalwareBlockerConfig,
|
|
updateMalwareBlockerConfig,
|
|
triggerJob,
|
|
} from './helpers/app-api';
|
|
import { ALL_CLIENTS, TorrentClientFixture } from './helpers/torrent-clients';
|
|
import { buildMultiFileTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures';
|
|
import { ArrStubServer } from './helpers/arr-stub';
|
|
|
|
/**
|
|
* End-to-end coverage for the `DeleteIfAnyFileBlocked` setting on the
|
|
* Malware Blocker.
|
|
*
|
|
* Each download client gets a pair of torrents containing both a benign
|
|
* `movie.mkv` and a `installer.exe` that matches the test blocklist
|
|
* pattern `*.exe`. With the toggle off, today's behavior must keep the
|
|
* torrent in the client (only some files unwanted → not removed). With
|
|
* the toggle on, the entire torrent must be removed and the corresponding
|
|
* Sonarr queue record must be deleted via DELETE /api/v3/queue/{id}.
|
|
*
|
|
* Sonarr is stubbed in-process via {@link ArrStubServer}; the cleanuparr
|
|
* app container reaches the stub through `network_mode: host`.
|
|
*
|
|
* Distinct torrent names per scenario (`off-*` vs `on-*`) give each test
|
|
* its own hash and therefore independent client state — without that,
|
|
* the OFF test marks `installer.exe` as Skip in the client and the
|
|
* subsequent ON test would short-circuit on the "file already skipped"
|
|
* branch before reaching the new code path.
|
|
*/
|
|
|
|
const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads');
|
|
const CLIENT_DOWNLOADS = '/downloads';
|
|
const APP_DOWNLOADS = '/e2e-downloads';
|
|
const BLOCKLIST_REL_PATH = 'malware-blocker/blocklist.txt';
|
|
const APP_BLOCKLIST_PATH = `${APP_DOWNLOADS}/${BLOCKLIST_REL_PATH}`;
|
|
const STUB_PORT = 9100;
|
|
const POLL_TIMEOUT_MS = 30_000;
|
|
|
|
function farFutureCron(): string {
|
|
const minutes = (new Date().getUTCMinutes() + 30) % 60;
|
|
return `0 ${minutes} * * * ?`;
|
|
}
|
|
|
|
const TRIGGER_SETTLE_MS = 4_000;
|
|
|
|
const SLUG_BY_TYPE: Record<string, string> = {
|
|
qBittorrent: 'qbittorrent',
|
|
Transmission: 'transmission',
|
|
Deluge: 'deluge',
|
|
uTorrent: 'utorrent',
|
|
rTorrent: 'rtorrent',
|
|
};
|
|
|
|
interface TorrentMeta {
|
|
name: string;
|
|
infoHash: string;
|
|
}
|
|
|
|
async function waitForTorrents(
|
|
driver: { listTorrents(): Promise<Array<{ hash: string }>> },
|
|
expectedHashes: string[],
|
|
timeoutMs = 15_000,
|
|
): Promise<void> {
|
|
const want = new Set(expectedHashes.map((h) => h.toLowerCase()));
|
|
const start = Date.now();
|
|
let last: Set<string> = new Set();
|
|
while (Date.now() - start < timeoutMs) {
|
|
const list = await driver.listTorrents();
|
|
last = new Set(list.map((t) => t.hash.toLowerCase()));
|
|
if ([...want].every((h) => last.has(h))) {
|
|
return;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
const missing = [...want].filter((h) => !last.has(h));
|
|
throw new Error(`Torrents missing after ${timeoutMs}ms: ${missing.join(', ')}`);
|
|
}
|
|
|
|
async function torrentPresent(
|
|
driver: { listTorrents(): Promise<Array<{ hash: string }>> },
|
|
hash: string,
|
|
): Promise<boolean> {
|
|
const target = hash.toLowerCase();
|
|
const list = await driver.listTorrents();
|
|
return list.some((t) => t.hash.toLowerCase() === target);
|
|
}
|
|
|
|
async function setMalwareBlocker(
|
|
token: string,
|
|
overrides: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const currentRes = await getMalwareBlockerConfig(token);
|
|
expect(currentRes.status).toBe(200);
|
|
const current = await currentRes.json();
|
|
const merged = {
|
|
...current,
|
|
...overrides,
|
|
sonarr: { ...(current.sonarr ?? {}), ...(overrides.sonarr as Record<string, unknown> ?? {}) },
|
|
};
|
|
const updateRes = await updateMalwareBlockerConfig(token, merged);
|
|
if (updateRes.status >= 300) {
|
|
throw new Error(`update malware blocker config: ${updateRes.status} ${await updateRes.text()}`);
|
|
}
|
|
}
|
|
|
|
test.describe.serial('Malware blocker — DeleteIfAnyFileBlocked', () => {
|
|
let token: string;
|
|
const stub = new ArrStubServer();
|
|
let sonarrId: string | undefined;
|
|
|
|
test.beforeAll(async () => {
|
|
token = await loginAndGetToken();
|
|
|
|
for (const client of await listDownloadClients(token)) {
|
|
await deleteDownloadClient(token, client.id);
|
|
}
|
|
|
|
mkdirSync(join(HOST_DOWNLOADS, 'malware-blocker'), { recursive: true });
|
|
chmodIgnoringEPERM(join(HOST_DOWNLOADS, 'malware-blocker'), 0o777);
|
|
writeFileSync(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n');
|
|
|
|
await stub.start(STUB_PORT);
|
|
|
|
const sonarrRes = await createArrInstance(token, 'sonarr', {
|
|
name: 'Malware Blocker Stub',
|
|
url: stub.containerUrl,
|
|
apiKey: 'malware-blocker-e2e',
|
|
version: 4,
|
|
});
|
|
expect(sonarrRes.status).toBe(201);
|
|
const sonarrBody = await sonarrRes.json();
|
|
sonarrId = sonarrBody.id;
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
if (sonarrId) {
|
|
await deleteArrInstance(token, 'sonarr', sonarrId);
|
|
}
|
|
await stub.stop();
|
|
});
|
|
|
|
for (const fixture of ALL_CLIENTS) {
|
|
runClientScenarios(fixture, () => ({ token, stub }));
|
|
}
|
|
});
|
|
|
|
function runClientScenarios(
|
|
fixture: TorrentClientFixture,
|
|
getCtx: () => { token: string; stub: ArrStubServer },
|
|
): void {
|
|
const { driver } = fixture;
|
|
const slug = SLUG_BY_TYPE[driver.typeName];
|
|
const describeFn = fixture.enabled ? test.describe.serial : test.describe.skip;
|
|
|
|
describeFn(`${driver.typeName}`, () => {
|
|
let clientId: string;
|
|
let offTorrent: TorrentMeta;
|
|
let onTorrent: TorrentMeta;
|
|
const hostScanDir = join(HOST_DOWNLOADS, slug);
|
|
|
|
test.beforeAll(async () => {
|
|
const { token } = getCtx();
|
|
|
|
resetDirectory(hostScanDir);
|
|
await driver.ready();
|
|
await driver.clearAllTorrents();
|
|
|
|
const runId = Date.now().toString(36);
|
|
const offFx = buildMultiFileTorrent(hostScanDir, `off-mixed-${slug}-${runId}`, [
|
|
{ filename: 'movie.mkv', sizeBytes: 32_768 },
|
|
{ filename: 'installer.exe', sizeBytes: 1024 },
|
|
]);
|
|
const onFx = buildMultiFileTorrent(hostScanDir, `on-mixed-${slug}-${runId}`, [
|
|
{ filename: 'movie.mkv', sizeBytes: 32_768 },
|
|
{ filename: 'installer.exe', sizeBytes: 1024 },
|
|
]);
|
|
offTorrent = { name: offFx.name, infoHash: offFx.infoHash };
|
|
onTorrent = { name: onFx.name, infoHash: onFx.infoHash };
|
|
|
|
const createRes = await createDownloadClient(token, {
|
|
enabled: true,
|
|
name: `${driver.typeName} malware-blocker e2e`,
|
|
typeName: driver.typeName,
|
|
type: 'Torrent',
|
|
host: driver.cleanuparrHost,
|
|
username: driver.username ?? '',
|
|
password: driver.password ?? '',
|
|
});
|
|
expect(createRes.status, `createDownloadClient: ${createRes.status}`).toBeGreaterThanOrEqual(200);
|
|
expect(createRes.status).toBeLessThan(300);
|
|
clientId = (await createRes.json()).id;
|
|
|
|
await driver.addTorrent({
|
|
metainfo: offFx.metainfo,
|
|
savePath: CLIENT_DOWNLOADS,
|
|
name: offFx.name,
|
|
infoHash: offFx.infoHash,
|
|
});
|
|
await driver.addTorrent({
|
|
metainfo: onFx.metainfo,
|
|
savePath: CLIENT_DOWNLOADS,
|
|
name: onFx.name,
|
|
infoHash: onFx.infoHash,
|
|
});
|
|
await waitForTorrents(driver, [offTorrent.infoHash, onTorrent.infoHash]);
|
|
});
|
|
|
|
test.afterAll(async () => {
|
|
const { token } = getCtx();
|
|
if (clientId) {
|
|
await deleteDownloadClient(token, clientId);
|
|
}
|
|
});
|
|
|
|
test('toggle OFF keeps the torrent in the client', async () => {
|
|
test.setTimeout(120_000);
|
|
const { token, stub } = getCtx();
|
|
|
|
await setMalwareBlocker(token, {
|
|
enabled: true,
|
|
useAdvancedScheduling: true,
|
|
cronExpression: farFutureCron(),
|
|
deleteIfAnyFileBlocked: false,
|
|
ignorePrivate: false,
|
|
deletePrivate: true,
|
|
processNoContentId: false,
|
|
sonarr: {
|
|
enabled: true,
|
|
blocklistPath: APP_BLOCKLIST_PATH,
|
|
blocklistType: 'Blacklist',
|
|
},
|
|
});
|
|
|
|
stub.setQueue([
|
|
{
|
|
id: 1001,
|
|
downloadId: offTorrent.infoHash,
|
|
title: offTorrent.name,
|
|
protocol: 'torrent',
|
|
seriesId: 1,
|
|
episodeId: 1,
|
|
},
|
|
]);
|
|
stub.resetCounters();
|
|
|
|
const trig = await triggerJob(token, 'MalwareBlocker');
|
|
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
|
|
|
|
const ran = await stub.waitForQueueRequest(POLL_TIMEOUT_MS);
|
|
expect(ran, 'malware blocker should have called the sonarr stub /api/v3/queue').toBe(true);
|
|
|
|
await new Promise((r) => setTimeout(r, TRIGGER_SETTLE_MS));
|
|
|
|
expect(stub.getDeletes(), 'no sonarr queue items should be deleted when toggle is off').toEqual([]);
|
|
expect(await torrentPresent(driver, offTorrent.infoHash), `expected ${offTorrent.infoHash} to remain in ${driver.typeName} (toggle off)`).toBe(true);
|
|
});
|
|
|
|
test('toggle ON removes the torrent and notifies sonarr', async () => {
|
|
test.setTimeout(120_000);
|
|
const { token, stub } = getCtx();
|
|
|
|
await setMalwareBlocker(token, {
|
|
enabled: true,
|
|
useAdvancedScheduling: true,
|
|
cronExpression: farFutureCron(),
|
|
deleteIfAnyFileBlocked: true,
|
|
ignorePrivate: false,
|
|
deletePrivate: true,
|
|
processNoContentId: false,
|
|
sonarr: {
|
|
enabled: true,
|
|
blocklistPath: APP_BLOCKLIST_PATH,
|
|
blocklistType: 'Blacklist',
|
|
},
|
|
});
|
|
|
|
stub.setQueue([
|
|
{
|
|
id: 1002,
|
|
downloadId: onTorrent.infoHash,
|
|
title: onTorrent.name,
|
|
protocol: 'torrent',
|
|
seriesId: 2,
|
|
episodeId: 2,
|
|
},
|
|
]);
|
|
stub.resetCounters();
|
|
|
|
const trig = await triggerJob(token, 'MalwareBlocker');
|
|
expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true);
|
|
|
|
const start = Date.now();
|
|
while (Date.now() - start < POLL_TIMEOUT_MS) {
|
|
if (stub.getDeletes().some((d) => d.id === 1002)) {
|
|
break;
|
|
}
|
|
await new Promise((r) => setTimeout(r, 500));
|
|
}
|
|
|
|
const deletes = stub.getDeletes();
|
|
const onDelete = deletes.find((d) => d.id === 1002);
|
|
expect(onDelete, `sonarr queue item 1002 should have been deleted (saw ${JSON.stringify(deletes)})`).toBeDefined();
|
|
expect(onDelete!.removeFromClient, 'malware blocker should ask sonarr to remove the torrent from the client').toBe(true);
|
|
expect(onDelete!.blocklist, 'malware blocker should ask sonarr to blocklist the release').toBe(true);
|
|
});
|
|
});
|
|
}
|