import { test } from 'node:test'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; import { once } from 'node:events'; import { get } from 'node:http'; import { Readable } from 'node:stream'; import { existsSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs'; import { resolve } from 'node:path'; import { and, eq, isNotNull } from 'drizzle-orm'; import { fixture } from './helpers.js'; import * as s from '../src/db/schema.js'; async function login(f, c) { const id = randomUUID(), email = `${id}@hop.test`; f.service.db .insert(s.users) .values({ id, email, displayName: email, passwordHash: await f.service.auth.password('12345678'), status: 'active', verifiedAt: Date.now(), createdAt: Date.now(), }) .run(); const r = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' }); assert.equal(r.statusCode, 200); c.cookie += '; ' + String(r.headers['set-cookie']).split(';')[0]; return id; } async function file(f, a, b, name = '사진.JPG') { const bytes = Buffer.from('unchanged file bytes'); await f.space(a, b); const r = await f.call( a, '/transfer-requests', 'POST', { targetDeviceId: b.deviceId, kind: 'file', count: 1, byteSize: bytes.length, files: [{ name, size: bytes.length, type: 'application/octet-stream' }], }, randomUUID(), ); assert.equal(r.statusCode, 200, r.body); const id = r.json().requestId; assert.equal( (await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode, 200, ); const boundary = 'layout-upload', body = Buffer.concat([ Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/octet-stream\r\n\r\n`, ), bytes, Buffer.from(`\r\n--${boundary}--\r\n`), ]); const upload = await f.call( a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', body, randomUUID(), { 'content-type': `multipart/form-data; boundary=${boundary}` }, ); assert.equal(upload.statusCode, 200, upload.body); const row = f.service.storage.fileRows(id)[0]; const item = (await f.call(b, '/device/items')).json().items.find((i) => i.file?.id === row.id); return { row, item, bytes, id }; } function current(f, row) { return f.service.db.select().from(s.files).where(eq(s.files.id, row.id)).get(); } async function archived(f, row) { // The HTTP response can finish before the file descriptor's asynchronous close. for (let i = 0; i < 100 && !current(f, row).storageKey.startsWith('completed/'); i++) await new Promise((r) => setTimeout(r, 5)); assert.equal(current(f, row).storageKey, `completed/${row.storageKey}`); } test('logged-in sender to anonymous recipient: extension kept, completed after download, seven-day expiry and re-download unchanged', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); await login(f, a); const value = await file(f, a, b), row = value.row; assert.match(row.storageKey, /^[a-f0-9-]{36}\.jpg$/); assert.equal((await f.call(a, '/vault')).json().items.length, 0); const expiry = f.service.db .select() .from(s.items) .where(eq(s.items.fileId, row.id)) .get().expiresAt; assert.equal(existsSync(resolve(f.service.storage.originals, row.storageKey)), true); const downloaded = await f.call(b, `/access/${value.item.accessId}/download`); assert.equal(downloaded.statusCode, 200); assert.deepEqual(downloaded.rawPayload, value.bytes); await archived(f, row); const stored = current(f, row); assert.equal(stored.storageKey, `completed/${row.storageKey}`); assert.equal(existsSync(resolve(f.service.storage.originals, row.storageKey)), false); assert.deepEqual(readFileSync(f.service.storage.layout.path(stored)), value.bytes); assert.equal(stored.checksum, row.checksum); assert.equal(stored.updatedAt, row.updatedAt); assert.equal( f.service.db.select().from(s.items).where(eq(s.items.fileId, row.id)).get().expiresAt, expiry, ); assert.deepEqual( (await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload, value.bytes, ); } finally { await f.close(); } }); test('anonymous sender to signed-in recipient: received-account reference keeps the file in originals', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); await login(f, b); const value = await file(f, a, b, 'received.pdf'); const downloaded = await f.call(b, `/access/${value.item.accessId}/download`); assert.equal(downloaded.statusCode, 200); await new Promise((r) => setImmediate(r)); assert.equal(current(f, value.row).storageKey, value.row.storageKey); assert(existsSync(f.service.storage.layout.path(value.row))); assert.equal((await f.call(b, '/inbox')).json().items.length, 1); } finally { await f.close(); } }); test('a sender read or unauthorized read does not classify a transfer as recipient-completed', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(), c = await f.create(); const value = await file(f, a, b); assert.equal((await f.call(c, `/access/${value.item.accessId}/download`)).statusCode, 404); const sent = (await f.call(a, '/device/items')).json().items[0]; assert.equal((await f.call(a, `/access/${sent.accessId}/download`)).statusCode, 200); await new Promise((r) => setImmediate(r)); assert.equal(current(f, value.row).storageKey, value.row.storageKey); } finally { await f.close(); } }); test('relocation waits for all active readers and preserves concurrent download bytes', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const value = await file(f, a, b); const guest = f.service.db .select() .from(s.guests) .where(eq(s.guests.id, f.service.deviceGuest(b.deviceId).guestId)) .get(); const first = await f.service.storage.openDownload(guest, value.item.accessId), second = await f.service.storage.openDownload(guest, value.item.accessId); f.service.storage.layout.downloaded(value.row.id); assert.equal(current(f, value.row).storageKey, value.row.storageKey); const firstClosed = once(first.stream, 'close'); first.stream.resume(); await firstClosed; assert.equal(current(f, value.row).storageKey, value.row.storageKey); const chunks = []; second.stream.on('data', (chunk) => chunks.push(chunk)); await once(second.stream, 'close'); assert.deepEqual(Buffer.concat(chunks), value.bytes); assert.equal(current(f, value.row).storageKey, `completed/${value.row.storageKey}`); } finally { await f.close(); } }); test('legacy extension-less files migrate without changing IDs, checksums, content or expiry', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const value = await file(f, a, b, 'old.png'); const legacy = randomUUID(); renameSync( f.service.storage.layout.path(value.row), resolve(f.service.storage.originals, legacy), ); f.service.db .update(s.files) .set({ storageKey: legacy }) .where(eq(s.files.id, value.row.id)) .run(); f.service.storage.layout.prepare(); const stored = current(f, value.row); assert.equal(stored.storageKey, legacy + '.png'); assert.equal(stored.id, value.row.id); assert.deepEqual(readFileSync(f.service.storage.layout.path(stored)), value.bytes); assert.equal(stored.checksum, value.row.checksum); const before = stored.storageKey; f.service.storage.layout.prepare(); assert.equal(current(f, value.row).storageKey, before); } finally { await f.close(); } }); test('database relocation failure rolls back the filename and preserves the original bytes', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const value = await file(f, a, b); f.sqlite.exec( "CREATE TRIGGER reject_move BEFORE UPDATE OF storageKey ON files BEGIN SELECT RAISE(ABORT, 'test relocation failure'); END", ); assert.throws(() => f.service.storage.layout.move(value.row, `completed/${value.row.storageKey}`), ); assert.equal(current(f, value.row).storageKey, value.row.storageKey); assert.deepEqual(readFileSync(f.service.storage.layout.path(value.row)), value.bytes); assert.equal( existsSync(f.service.storage.layout.keyPath(`completed/${value.row.storageKey}`)), false, ); assert.equal( existsSync(resolve(f.service.storage.layout.journal, value.row.id + '.json')), false, ); f.sqlite.exec('DROP TRIGGER reject_move'); f.service.storage.layout.downloaded(value.row.id); assert.equal(current(f, value.row).storageKey, `completed/${value.row.storageKey}`); } finally { await f.close(); } }); test('account deletion undo remains protected; restoring an account reference returns an archive to originals', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); await login(f, b); const value = await file(f, a, b); const reference = f.service.db .select() .from(s.access) .where(and(eq(s.access.itemId, value.item.id), isNotNull(s.access.userId))) .get(); assert(reference); f.service.db .update(s.access) .set({ deletedAt: Date.now() }) .where(eq(s.access.id, reference.id)) .run(); f.service.storage.layout.downloaded(value.row.id); assert.equal(current(f, value.row).storageKey, value.row.storageKey); f.service.db .update(s.access) .set({ deletedAt: Date.now() - f.cfg.undoSeconds * 1000 - 1 }) .where(eq(s.access.id, reference.id)) .run(); f.service.storage.layout.downloaded(value.row.id); assert.equal(current(f, value.row).storageKey, `completed/${value.row.storageKey}`); f.service.db .update(s.access) .set({ deletedAt: null }) .where(eq(s.access.id, reference.id)) .run(); f.service.storage.layout.reconcile(); assert.equal(current(f, value.row).storageKey, value.row.storageKey); assert.deepEqual(readFileSync(f.service.storage.layout.path(value.row)), value.bytes); } finally { await f.close(); } }); test('aborting an HTTP download before all bytes arrive leaves the file available in originals', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const value = await file(f, a, b); const openDownload = f.service.storage.openDownload.bind(f.service.storage); let slow; f.service.storage.openDownload = async (...args) => { const result = await openDownload(...args); const closed = once(result.stream, 'close'); result.stream.destroy(); await closed; slow = Readable.from( (async function* () { yield value.bytes.subarray(0, 1); await new Promise((r) => setTimeout(r, 100)); yield value.bytes.subarray(1); })(), ); return { ...result, stream: slow }; }; await new Promise((resolve, reject) => { const request = get( `${f.url}/api/access/${value.item.accessId}/download`, { headers: { cookie: b.cookie, origin: 'http://hop.test' } }, (response) => { assert.equal(response.statusCode, 200); response.once('data', () => { response.destroy(); request.destroy(); resolve(); }); }, ); request.once('error', reject); }); if (!slow.closed) await once(slow, 'close'); assert.equal(current(f, value.row).storageKey, value.row.storageKey); f.service.storage.openDownload = openDownload; assert.deepEqual( (await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload, value.bytes, ); await archived(f, value.row); } finally { await f.close(); } }); test('journal recovers a restart between file rename and DB commit; unrelated files are not adopted', async () => { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const value = await file(f, a, b); const key = `completed/${value.row.storageKey}`, target = f.service.storage.layout.keyPath(key); renameSync(f.service.storage.layout.path(value.row), target); writeFileSync( resolve(f.service.storage.layout.journal, value.row.id + '.json'), JSON.stringify({ fileId: value.row.id, checksum: value.row.checksum, size: value.row.size, from: value.row.storageKey, to: key, }), ); f.service.storage.layout.prepare(); assert.equal(current(f, value.row).storageKey, key); assert.deepEqual( (await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload, value.bytes, ); unlinkSync(target); writeFileSync( resolve(f.service.storage.originals, value.row.storageKey), Buffer.alloc(value.row.size, 120), ); const missing = await f.call(b, `/access/${value.item.accessId}/download`); assert.equal(missing.statusCode, 404, 'no journal: do not adopt an unrelated same-size file'); } finally { await f.close(); } }); test('completed manual deletion and natural expiry clear references and remove bytes in either directory', async () => { for (const manual of [true, false]) { const f = await fixture(); try { const a = await f.create(), b = await f.create(); const value = await file(f, a, b); await f.call(b, `/access/${value.item.accessId}/download`); await archived(f, value.row); const path = f.service.storage.layout.path(current(f, value.row)); if (manual) { unlinkSync(path); assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 404); } else f.service.db .update(s.items) .set({ expiresAt: Date.now() - 1 }) .where(eq(s.items.fileId, value.row.id)) .run(); await f.service.storage.cleanup(); assert.equal(existsSync(path), false); assert.equal(current(f, value.row).status, 'failed'); } finally { await f.close(); } } });