Files
Cleanuparr/e2e/tests/malware-blocker.spec.ts

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);
});
});
}