// Device registration, holdings reports and range challenges (plan 013). import { test, expect, beforeAll } from 'bun:test'; import { mkdtempSync, writeFileSync } from 'node:fs'; 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-p2p-routes-')); 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 { sha256Range } = await import('./hash.js'); const file = join(root, 'copy.mp4'); const bytes = Buffer.from(Array.from({ length: 400000 }, (_, i) => (i * 13) % 256)); writeFileSync(file, bytes); const CID = createHash('sha256').update(bytes).digest('hex'); const OTHER = 'c'.repeat(64); // verified but the server has no file const UNKNOWN = 'd'.repeat(64); // not in p2p_content const quiet = { warn() {}, info() {} }; let app; let enabled = true; const req = (path, { method = 'GET', body, dev } = {}) => app.request(path, { method, headers: { 'Content-Type': 'application/json', ...(dev ? { 'X-Device': dev.deviceId + '.' + dev.secret } : {}) }, body: body ? JSON.stringify(body) : undefined, }); beforeAll(async () => { await dbmod.initDb(); await p2pDb.initP2pSchema(); await p2pDb.upsertContent({ cid: CID, videoId: 'dQw4w9WgXcQ', size: bytes.length, origin: 'server', now: 1 }); await p2pDb.upsertContent({ cid: OTHER, videoId: 'dQw4w9WgXcQ', size: 10, origin: 'server', now: 1 }); app = new Hono(); registerP2pRoutes(app, { cfg: { get enabled() { return enabled; }, staleDays: 7 }, p2pDb, fileForCid: async (cid) => (cid === CID ? { path: file, size: bytes.length } : null), sha256Range, log: quiet, }); }); async function newDevice() { const r = await req('/api/p2p/device', { method: 'POST', body: { fingerprint: 'fp1' } }); expect(r.status).toBe(200); const j = await r.json(); expect(j.deviceId).toMatch(/^dev_[0-9a-f]{16}$/); expect(j.secret).toMatch(/^[0-9a-f]{64}$/); return j; } test('config is public and says enabled + staleDays', async () => { expect(await (await req('/api/p2p/config')).json()).toEqual({ ok: true, enabled: true, staleDays: 7 }); }); test('holdings need a valid device secret', async () => { const dev = await newDevice(); expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] } })).status).toBe(401); expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401); }); test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => { const dev = await newDevice(); const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [ { cid: CID, size: bytes.length, videoId: 'dQw4w9WgXcQ' }, { cid: OTHER, size: 10 }, { cid: UNKNOWN, size: 5 }, { cid: 'nothex' }, ] } })).json(); expect(r.accepted.sort()).toEqual([CID, OTHER].sort()); expect(r.unknown).toEqual([UNKNOWN]); expect(r.challenges.length).toBe(1); const ch = r.challenges[0]; expect(ch.cid).toBe(CID); const good = createHash('sha256').update(bytes.subarray(ch.offset, ch.offset + ch.length)).digest('hex'); const a = await (await req('/api/p2p/challenge', { method: 'POST', dev, body: { answers: [{ ...ch, sha256: good }] } })).json(); expect(a).toEqual({ ok: true, passed: [CID], failed: [] }); const hs = await p2pDb.listHolders(CID); expect(hs.find((h) => h.device_id === dev.deviceId).trust).toBe('challenged'); }); test('a wrong challenge answer removes the holder; a replayed answer is ignored', async () => { const dev = await newDevice(); const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { items: [{ cid: CID, size: bytes.length }] } })).json(); const ch = r.challenges[0]; const a = await (await req('/api/p2p/challenge', { method: 'POST', dev, body: { answers: [{ ...ch, sha256: '0'.repeat(64) }] } })).json(); expect(a.failed).toEqual([CID]); expect((await p2pDb.listHolders(CID)).some((h) => h.device_id === dev.deviceId)).toBe(false); const again = await (await req('/api/p2p/challenge', { method: 'POST', dev, body: { answers: [{ ...ch, sha256: '0'.repeat(64) }] } })).json(); expect(again).toEqual({ ok: true, passed: [], failed: [] }); }); test('a full report without an item removes it; share=false withdraws everything', async () => { const dev = await newDevice(); await req('/api/p2p/holdings', { method: 'POST', dev, body: { items: [{ cid: CID }, { cid: OTHER }] } }); expect((await p2pDb.activeHoldingsOf(dev.deviceId)).sort()).toEqual([CID, OTHER].sort()); await req('/api/p2p/holdings', { method: 'POST', dev, body: { items: [{ cid: OTHER }] } }); expect(await p2pDb.activeHoldingsOf(dev.deviceId)).toEqual([OTHER]); await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: false, items: [{ cid: OTHER }] } }); expect(await p2pDb.activeHoldingsOf(dev.deviceId)).toEqual([]); expect((await p2pDb.getDevice(dev.deviceId)).share).toBe(0); }); test('P2P_ENABLED=0 → routes answer 404 (config still says disabled)', async () => { enabled = false; try { expect((await req('/api/p2p/device', { method: 'POST', body: {} })).status).toBe(404); expect((await (await req('/api/p2p/config')).json()).enabled).toBe(false); } finally { enabled = true; } });