125 lines
7.4 KiB
JavaScript
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/);
|
|
});
|