394 lines
14 KiB
JavaScript
394 lines
14 KiB
JavaScript
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();
|
|
}
|
|
}
|
|
});
|