Files
hop.noname.company/apps/server/test/file-layout.test.js

334 lines
12 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { once } from 'node:events';
import {
existsSync,
readFileSync,
renameSync,
writeFileSync,
unlinkSync,
mkdirSync,
utimesSync,
} from 'node:fs';
import { resolve } from 'node:path';
import { eq } 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();
}
test('signed-in uploads use the account folder; download retains the key, original filename and seven-day expiry', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
userId = await login(f, a);
const value = await file(f, a, b);
assert.match(value.row.storageKey, new RegExp(`^${userId}/[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, value.row.id))
.get().expiresAt;
for (let i = 0; i < 2; i++) {
const response = await f.call(b, `/access/${value.item.accessId}/download`);
assert.equal(response.statusCode, 200);
assert.deepEqual(response.rawPayload, value.bytes);
assert.match(response.headers['content-disposition'], /filename\*=UTF-8/);
}
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
assert.equal(
f.service.db.select().from(s.items).where(eq(s.items.fileId, value.row.id)).get().expiresAt,
expiry,
);
assert.equal(existsSync(f.service.storage.layout.completed), false);
} finally {
await f.close();
}
});
test('anonymous upload uses its guest folder even when a signed-in recipient adds a received reference', 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.mp4');
assert.equal(value.row.storageKey.split('/')[0], value.row.ownerGuestId);
assert.match(value.row.storageKey, /\.mp4$/);
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 200);
assert.equal(current(f, value.row).storageKey, value.row.storageKey);
assert.equal(
(await f.call(await f.create(), `/access/${value.item.accessId}/download`)).statusCode,
404,
);
} finally {
await f.close();
}
});
test('the same account on different devices shares one upload folder with unique physical filenames', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create(),
d = await f.create();
const id = await login(f, a),
user = f.service.db.select().from(s.users).where(eq(s.users.id, id)).get();
const loginResponse = await f.call(c, '/auth/login', 'POST', {
email: user.email,
password: '12345678',
});
assert.equal(loginResponse.statusCode, 200);
c.cookie += '; ' + String(loginResponse.headers['set-cookie']).split(';')[0];
const one = await file(f, a, b),
two = await file(f, c, d);
assert.equal(one.row.storageKey.split('/')[0], id);
assert.equal(two.row.storageKey.split('/')[0], id);
assert.notEqual(one.row.storageKey, two.row.storageKey);
assert.deepEqual(readFileSync(f.service.storage.layout.path(one.row)), one.bytes);
assert.deepEqual(readFileSync(f.service.storage.layout.path(two.row)), two.bytes);
} finally {
await f.close();
}
});
test('old flat originals and completed files migrate to their recorded upload owner without changing IDs or expiry', async () => {
for (const completed of [false, true]) {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
value = await file(f, a, b, 'old.png'),
legacy = randomUUID();
if (completed) mkdirSync(f.service.storage.layout.completed, { recursive: true });
const key = completed ? `completed/${legacy}` : legacy;
renameSync(f.service.storage.layout.path(value.row), f.service.storage.layout.keyPath(key));
f.service.db
.update(s.files)
.set({ storageKey: key })
.where(eq(s.files.id, value.row.id))
.run();
f.service.storage.layout.prepare();
const stored = current(f, value.row);
assert.equal(stored.storageKey, `${value.row.ownerGuestId}/${legacy}.png`);
assert.equal(stored.id, value.row.id);
assert.equal(stored.checksum, value.row.checksum);
assert.equal(stored.updatedAt, value.row.updatedAt);
assert.deepEqual(readFileSync(f.service.storage.layout.path(stored)), value.bytes);
assert.equal(existsSync(f.service.storage.layout.completed), false);
f.service.storage.layout.prepare();
assert.equal(current(f, value.row).storageKey, stored.storageKey);
assert.deepEqual(
(await f.call(b, `/access/${value.item.accessId}/download`)).rawPayload,
value.bytes,
);
} finally {
await f.close();
}
}
});
test('migration waits for all active readers before moving a legacy file', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
value = await file(f, a, b),
key = randomUUID();
renameSync(f.service.storage.layout.path(value.row), f.service.storage.layout.keyPath(key));
f.service.db.update(s.files).set({ storageKey: key }).where(eq(s.files.id, value.row.id)).run();
const guest = f.service.guest(a.cookie.split('=')[1]);
const sent = (await f.call(a, '/device/items')).json().items[0];
const first = await f.service.storage.openDownload(guest, sent.accessId),
second = await f.service.storage.openDownload(guest, sent.accessId);
f.service.storage.layout.prepare();
assert.equal(current(f, value.row).storageKey, key);
const closed = once(first.stream, 'close');
first.stream.resume();
await closed;
f.service.storage.layout.prepare();
assert.equal(current(f, value.row).storageKey, key);
const chunks = [];
second.stream.on('data', (chunk) => chunks.push(chunk));
await once(second.stream, 'close');
assert.deepEqual(Buffer.concat(chunks), value.bytes);
f.service.storage.layout.prepare();
assert.equal(current(f, value.row).storageKey, `${value.row.ownerGuestId}/${key}.jpg`);
} finally {
await f.close();
}
});
test('database move failure rolls back the filename and leaves bytes and old key available', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
value = await file(f, a, b);
const destination = value.row.storageKey.split('/')[0] + '/' + randomUUID() + '.jpg';
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, destination));
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(destination)), false);
assert.equal(
existsSync(resolve(f.service.storage.layout.journal, value.row.id + '.json')),
false,
);
} finally {
await f.close();
}
});
test('move journal restores a rename interrupted before DB commit and never adopts unrelated replacement files', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
value = await file(f, a, b);
const key = value.row.storageKey.split('/')[0] + '/' + randomUUID() + '.jpg',
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(
f.service.storage.layout.keyPath(value.row.storageKey),
Buffer.alloc(value.row.size, 120),
);
assert.equal((await f.call(b, `/access/${value.item.accessId}/download`)).statusCode, 404);
} finally {
await f.close();
}
});
test('manual deletion and seven-day expiry remove personal and guest folder files and reconcile references', async () => {
for (const signedIn of [false, true])
for (const manual of [false, true]) {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
if (signedIn) await login(f, a);
const value = await file(f, a, b),
path = f.service.storage.layout.path(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();
}
}
});
test('orphan cleanup scans nested owner folders and preserves valid old files', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
value = await file(f, a, b);
const valid = f.service.storage.layout.path(value.row),
orphan = resolve(valid, '..', randomUUID() + '.txt'),
old = new Date(Date.now() - 25 * 3600000);
writeFileSync(orphan, 'orphan');
utimesSync(orphan, old, old);
utimesSync(valid, old, old);
await f.service.storage.cleanup();
assert.equal(existsSync(orphan), false);
assert.equal(existsSync(valid), true);
assert.throws(() => f.service.storage.layout.keyPath('../outside'));
assert.throws(() => f.service.storage.layout.keyPath('user-x/../../outside'));
} finally {
await f.close();
}
});