272 lines
9.9 KiB
JavaScript
272 lines
9.9 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { unlink, chmod } from 'node:fs/promises';
|
|
import { unlinkSync } 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 signIn(f, client) {
|
|
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 response = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
|
|
assert.equal(response.statusCode, 200, response.body);
|
|
client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
|
|
client.socket.disconnect();
|
|
await f.connect(client);
|
|
}
|
|
async function save(f, client, bytes = Buffer.from('test')) {
|
|
const request = await f.call(
|
|
client,
|
|
'/vault',
|
|
'POST',
|
|
{
|
|
kind: 'file',
|
|
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
|
|
},
|
|
randomUUID(),
|
|
);
|
|
assert.equal(request.statusCode, 200, request.body);
|
|
const boundary = 'missing-file-test';
|
|
const body = Buffer.concat([
|
|
Buffer.from(
|
|
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="saved.txt"\r\nContent-Type: text/plain\r\n\r\n`,
|
|
),
|
|
bytes,
|
|
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
]);
|
|
const response = await f.call(
|
|
client,
|
|
`/uploads?requestId=${request.json().requestId}&fileIndex=0`,
|
|
'POST',
|
|
body,
|
|
randomUUID(),
|
|
{ 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
);
|
|
assert.equal(response.statusCode, 200, response.body);
|
|
const row = f.service.storage.fileRows(request.json().requestId)[0];
|
|
const item = (await f.call(client, '/vault'))
|
|
.json()
|
|
.items.find((item) => item.file?.id === row.id);
|
|
return { file: row, item, path: resolve(f.service.storage.originals, row.storageKey) };
|
|
}
|
|
async function forward(f, sender, receiver, item, accept = true) {
|
|
const response = await f.call(
|
|
sender,
|
|
`/vault/${item.accessId}/send`,
|
|
'POST',
|
|
{ targetDeviceId: receiver.deviceId },
|
|
randomUUID(),
|
|
);
|
|
assert.equal(response.statusCode, 200, response.body);
|
|
const id = response.json().requestId;
|
|
if (accept)
|
|
assert.equal(
|
|
(await f.call(receiver, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID()))
|
|
.statusCode,
|
|
200,
|
|
);
|
|
return id;
|
|
}
|
|
function limits(f) {
|
|
f.cfg.sessionRate = 60;
|
|
f.cfg.pairRate = 60;
|
|
f.cfg.ipRate = 100;
|
|
}
|
|
|
|
for (const trigger of ['download', 'list', 'quota', 'cleanup']) {
|
|
test(`수동 삭제(${trigger}): 공유 원본의 모든 기록·개인 용량 정리와 정상 파일/링크·페이지 유지`, async () => {
|
|
const f = await fixture();
|
|
try {
|
|
limits(f);
|
|
const a = await f.create(),
|
|
b = await f.create(),
|
|
outsider = await f.create();
|
|
await f.space(a, b);
|
|
await signIn(f, a);
|
|
await signIn(f, b);
|
|
const healthy = await save(f, a, Buffer.from('healthy'));
|
|
const link = await f.call(
|
|
a,
|
|
'/vault',
|
|
'POST',
|
|
{ kind: 'url', urls: ['https://example.com/healthy'] },
|
|
randomUUID(),
|
|
);
|
|
assert.equal(link.statusCode, 200, link.body);
|
|
const missing = await save(f, a);
|
|
await forward(f, a, b, missing.item);
|
|
const received = (await f.call(b, '/inbox')).json().items[0];
|
|
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 11);
|
|
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 4);
|
|
await unlink(missing.path);
|
|
const foreign = await f.call(outsider, `/access/${missing.item.accessId}/download`);
|
|
assert(foreign.statusCode === 401 || foreign.statusCode === 404);
|
|
assert.equal(
|
|
f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status,
|
|
'stored',
|
|
'무권한 접근으로 정리/존재 확인을 유발하지 않음',
|
|
);
|
|
if (trigger === 'download') {
|
|
const response = await f.call(b, `/access/${received.accessId}/download`);
|
|
assert.equal(response.statusCode, 404, response.body);
|
|
assert.match(response.json().message, /다시 전송/);
|
|
} else if (trigger === 'list') {
|
|
await f.call(a, '/vault');
|
|
} else if (trigger === 'quota') {
|
|
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
|
} else {
|
|
const update = new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(Error('missing notification')), 2000);
|
|
b.socket.once('inbox:updated', () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
await f.service.storage.cleanup();
|
|
await update;
|
|
}
|
|
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
|
|
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
|
assert.equal(
|
|
(await f.call(a, '/vault/contents'))
|
|
.json()
|
|
.items.some((i) => i.file?.id === missing.file.id),
|
|
false,
|
|
);
|
|
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 7);
|
|
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
|
|
const page = (await f.call(a, '/vault?limit=1')).json();
|
|
assert.equal(page.items.length, 1);
|
|
assert(page.nextCursor);
|
|
const next = (
|
|
await f.call(a, `/vault?limit=1&cursor=${encodeURIComponent(page.nextCursor)}`)
|
|
).json();
|
|
assert.equal(next.items.length, 1);
|
|
assert.equal(next.nextCursor, null);
|
|
const remaining = [...page.items, ...next.items];
|
|
assert(remaining.some((i) => i.file?.id === healthy.file.id));
|
|
assert(remaining.some((i) => i.url === 'https://example.com/healthy'));
|
|
assert.deepEqual(
|
|
(await f.call(a, `/access/${healthy.item.accessId}/download`)).rawPayload,
|
|
Buffer.from('healthy'),
|
|
);
|
|
assert.equal((await f.call(a, `/access/${missing.item.accessId}/preview`)).statusCode, 404);
|
|
f.service.cleanup();
|
|
await f.service.storage.cleanup();
|
|
assert.equal((await f.call(a, '/vault')).json().items.length, 2);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const action of ['send', 'accept']) {
|
|
test(`수동 삭제 뒤 ${action} 실패: 외부 멱등 트랜잭션 롤백 후에도 삭제 감지와 대기 전송 취소 유지`, async () => {
|
|
const f = await fixture();
|
|
try {
|
|
limits(f);
|
|
const a = await f.create(),
|
|
b = await f.create();
|
|
await f.space(a, b);
|
|
await signIn(f, a);
|
|
const missing = await save(f, a);
|
|
const pending = action === 'accept' ? await forward(f, a, b, missing.item, false) : null;
|
|
await unlink(missing.path);
|
|
const result =
|
|
action === 'accept'
|
|
? await f.call(b, `/transfer-requests/${pending}/accept`, 'POST', {}, randomUUID())
|
|
: await f.call(
|
|
a,
|
|
`/vault/${missing.item.accessId}/send`,
|
|
'POST',
|
|
{ targetDeviceId: b.deviceId },
|
|
randomUUID(),
|
|
);
|
|
assert.equal(result.statusCode, 404, result.body);
|
|
assert.equal(
|
|
f.service.db.select().from(s.files).where(eq(s.files.id, missing.file.id)).get().status,
|
|
'failed',
|
|
);
|
|
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
|
|
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
|
|
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
|
|
if (pending) {
|
|
assert.equal(
|
|
f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status,
|
|
'cancelled',
|
|
);
|
|
assert.equal(
|
|
f.service.db.select().from(s.batches).where(eq(s.batches.requestId, pending)).get()
|
|
.status,
|
|
'failed',
|
|
);
|
|
}
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
test('권한 오류는 삭제로 오인하지 않고 503 안내 후 권한 복구 시 원본·DB·용량 유지', async (t) => {
|
|
if (process.getuid?.() === 0) return t.skip('root bypasses filesystem permissions');
|
|
const f = await fixture();
|
|
try {
|
|
const a = await f.create();
|
|
await signIn(f, a);
|
|
const saved = await save(f, a);
|
|
await chmod(f.service.storage.originals, 0);
|
|
const response = await f.call(a, `/access/${saved.item.accessId}/download`);
|
|
assert.equal(response.statusCode, 503, response.body);
|
|
assert.equal(response.json().code, 'STORAGE_UNAVAILABLE');
|
|
assert.equal(
|
|
f.service.db.select().from(s.files).where(eq(s.files.id, saved.file.id)).get().status,
|
|
'stored',
|
|
);
|
|
await chmod(f.service.storage.originals, 0o700);
|
|
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 4);
|
|
assert.deepEqual(
|
|
(await f.call(a, `/access/${saved.item.accessId}/download`)).rawPayload,
|
|
Buffer.from('test'),
|
|
);
|
|
} finally {
|
|
await chmod(f.service.storage.originals, 0o700);
|
|
await f.close();
|
|
}
|
|
});
|
|
|
|
test('메타데이터 확인 직후 원본이 사라져도 다운로드는 500 대신 명확한 404 반환', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const a = await f.create();
|
|
await signIn(f, a);
|
|
const saved = await save(f, a);
|
|
const download = f.service.storage.download.bind(f.service.storage);
|
|
f.service.storage.download = (...args) => {
|
|
const result = download(...args);
|
|
unlinkSync(saved.path);
|
|
return result;
|
|
};
|
|
const response = await f.call(a, `/access/${saved.item.accessId}/download`);
|
|
assert.equal(response.statusCode, 404, response.body);
|
|
assert.equal(response.json().code, 'FILE_NOT_FOUND');
|
|
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, 0);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|