Files
ytplayer/server/p2p-intake.test.js

125 lines
7.4 KiB
JavaScript

// Device → server intake: hash, validate, (scan), admit (plan 016). Needs ffmpeg.
import { test, expect, beforeAll } from 'bun:test';
import { mkdtempSync, readFileSync, readdirSync, unlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-intake-'));
process.env.DB_PATH = join(root, 'test.db');
const dbmod = await import('./db.js');
const p2pDb = await import('./p2p-db.js');
const { registerP2pRoutes } = await import('./p2p-routes.js');
const { registerIntakeRoutes } = await import('./p2p-intake.js');
const { admitFile } = await import('./p2p-admit.js');
const { validateMedia } = await import('./media-cache.js');
const quiet = { warn() {}, info() {} };
const intakeDir = join(root, 'intake');
const cfg = { enabled: true, malwareScan: false, scanCmd: 'true', staleDays: 7, intakeDir, intakeMaxBytes: 50 * 1024 * 1024 };
let app;
let dev;
let good;
let goodCid;
const adopted = [];
const req = (path, { method = 'GET', body, raw } = {}) => app.request(path, {
method,
headers: { ...(raw ? {} : { 'Content-Type': 'application/json' }), 'X-Device': dev.deviceId + '.' + dev.secret },
body: raw || (body ? JSON.stringify(body) : undefined),
});
beforeAll(async () => {
await dbmod.initDb();
await p2pDb.initP2pSchema();
const f = join(root, 'good.mp4');
const r = spawnSync('ffmpeg', ['-v', 'error', '-y', '-f', 'lavfi', '-i', 'testsrc=size=320x180:rate=25:duration=4', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=4',
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', '-movflags', '+faststart', f]);
if (r.status !== 0) throw new Error('ffmpeg fixture failed: ' + r.stderr);
good = readFileSync(f);
goodCid = createHash('sha256').update(good).digest('hex');
app = new Hono();
const p2p = registerP2pRoutes(app, { cfg, p2pDb, fileForCid: async () => null, sha256Range: async () => '', log: quiet });
registerIntakeRoutes(app, {
cfg, p2pDb, gate: p2p.gate, requireDevice: p2p.requireDevice, validateMedia, admitFile, log: quiet,
adopt: async (videoId, path, info) => { adopted.push({ videoId, sha256: info.sha256, meta: info.meta }); return { adopted: false }; },
});
dev = await (await app.request('/api/p2p/device', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json();
});
test('a valid file is hashed by the server, validated, admitted and its uploader becomes a holder', async () => {
const t = await (await req('/api/p2p/intake', { method: 'POST', body: { videoId: 'upAAAAAAAA1', cid: goodCid, size: good.length, title: 'Song <b>x</b>', channel: 'Choir' } })).json();
expect(t.ok).toBe(true);
const r = await req(t.url, { method: 'PUT', raw: good });
expect(r.status).toBe(200);
expect(await r.json()).toEqual({ ok: true, cid: goodCid, adopted: false });
expect((await p2pDb.getContent(goodCid))).toMatchObject({ origin: 'intake', status: 'verified', scan: 'skipped', video_id: 'upAAAAAAAA1' });
expect((await p2pDb.listHolders(goodCid))[0]).toMatchObject({ device_id: dev.deviceId, trust: 'challenged' });
expect(adopted).toEqual([{ videoId: 'upAAAAAAAA1', sha256: goodCid, meta: { title: 'Song b x /b', channel: 'Choir' } }]);
expect(JSON.parse((await p2pDb.getContent(goodCid)).meta).title).toBe('Song b x /b');
expect(readdirSync(intakeDir)).toEqual([]); // not adopted → deleted
// Already verified → nothing to upload next time.
expect(await (await req('/api/p2p/intake', { method: 'POST', body: { videoId: 'upAAAAAAAA1', cid: goodCid, size: good.length } })).json()).toEqual({ ok: true, known: true });
});
test('restore: a known cid is uploaded again only when the server lost its copy', async () => {
let serverHas = true;
const app3 = new Hono();
const p2p = registerP2pRoutes(app3, { cfg, p2pDb, fileForCid: async () => null, sha256Range: async () => '', log: quiet });
const got = [];
registerIntakeRoutes(app3, {
cfg, p2pDb, gate: p2p.gate, requireDevice: p2p.requireDevice, validateMedia, admitFile, log: quiet,
serverHasCid: async () => serverHas,
adopt: async (videoId, path, info) => { got.push(info.sha256); unlinkSync(path); return { adopted: true }; }, // a real adopt moves the file
});
const h = { 'X-Device': dev.deviceId + '.' + dev.secret, 'Content-Type': 'application/json' };
const open = async () => (await app3.request('/api/p2p/intake', { method: 'POST', headers: h, body: JSON.stringify({ videoId: 'upAAAAAAAA1', cid: goodCid, size: good.length, restore: true }) })).json();
expect(await open()).toEqual({ ok: true, known: true }); // server still has it
serverHas = false;
const t = await open();
expect(t.ticket).toMatch(/^[0-9a-f]{32}$/);
const r = await (await app3.request(t.url, { method: 'PUT', headers: { 'X-Device': h['X-Device'] }, body: good })).json();
expect(r).toEqual({ ok: true, cid: goodCid, adopted: true });
expect(got).toEqual([goodCid]);
});
test('claimed cid mismatch, truncated media, and non-media are rejected and deleted', async () => {
const cases = [
[{ cid: 'f'.repeat(64), bytes: good }, 400, /hash mismatch/],
[{ bytes: good.subarray(0, Math.floor(good.length * 0.6)) }, 422, /validation/],
[{ bytes: Buffer.alloc(200 * 1024, 7) }, 422, /validation/],
];
for (const [c, status, msg] of cases) {
const t = await (await req('/api/p2p/intake', { method: 'POST', body: { videoId: 'upAAAAAAAA2', cid: c.cid, size: c.bytes.length } })).json();
const r = await req(t.url, { method: 'PUT', raw: c.bytes });
expect(r.status).toBe(status);
expect((await r.json()).error).toMatch(msg);
}
expect(readdirSync(intakeDir)).toEqual([]);
expect((await p2pDb.listContentForVideo('upAAAAAAAA2')).length).toBe(0);
});
test('size limits and tickets are enforced', async () => {
expect((await req('/api/p2p/intake', { method: 'POST', body: { videoId: 'upAAAAAAAA3', size: cfg.intakeMaxBytes + 1 } })).status).toBe(413);
const t = await (await req('/api/p2p/intake', { method: 'POST', body: { videoId: 'upAAAAAAAA3', size: 10 } })).json();
expect((await req('/api/p2p/intake', { method: 'POST', body: { videoId: 'upAAAAAAAA4', size: 10 } })).status).toBe(429); // one open per device
const r = await req(t.url, { method: 'PUT', raw: Buffer.alloc(11) });
expect(r.status).toBe(413);
expect((await req(t.url, { method: 'PUT', raw: Buffer.alloc(10) })).status).toBe(404); // ticket consumed
});
test('malware scan on: an infected verdict is never admitted', async () => {
const cfg2 = { ...cfg, malwareScan: true, scanCmd: 'false' }; // exit 1 = infected
const app2 = new Hono();
const p2p = registerP2pRoutes(app2, { cfg: cfg2, p2pDb, fileForCid: async () => null, sha256Range: async () => '', log: quiet });
registerIntakeRoutes(app2, { cfg: cfg2, p2pDb, gate: p2p.gate, requireDevice: p2p.requireDevice, validateMedia, admitFile, log: quiet });
const h = { 'X-Device': dev.deviceId + '.' + dev.secret };
const t = await (await app2.request('/api/p2p/intake', { method: 'POST', headers: { ...h, 'Content-Type': 'application/json' }, body: JSON.stringify({ videoId: 'upAAAAAAAA5', size: good.length }) })).json();
await p2pDb.revokeContent(goodCid); // make sure it is not simply "already known"
const r = await app2.request(t.url, { method: 'PUT', headers: h, body: good });
expect(r.status).toBe(422);
expect((await r.json()).error).toMatch(/scan infected/);
});