Files
hop.noname.company/apps/server/test/vault.test.js

410 lines
13 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
async function signed(f, client, email) {
const id = randomUUID();
f.service.db
.insert(s.users)
.values({
id,
email,
displayName: email,
passwordHash: await f.service.auth.password('1234'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
const login = await f.call(client, '/auth/login', 'POST', { email, password: '1234' });
assert.equal(login.statusCode, 200, login.body);
client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
return id;
}
async function upload(f, client, id, bytes) {
const boundary = 'hop-vault-test';
const payload = 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`),
]);
return f.call(client, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
function limits(f) {
f.cfg.sessionRate = 30;
f.cfg.pairRate = 30;
f.cfg.ipRate = 60;
}
test('기기 수신은 로그인 계정의 날짜 기록에만 자동 저장, 익명 과거 자료와 로그아웃 후 도착은 가져오지 않음', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b, 'https://example.com/before');
await signed(f, b, 'receiver@hop.test');
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
await f.complete(a, b, 'https://example.com/after');
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(item.url, 'https://example.com/after');
assert.equal(item.role, 'recipient');
assert.equal((await f.call(b, '/vault')).json().items.length, 0);
assert(item.expiresAt - item.createdAt <= 7 * 86400000);
const guestItem = (await f.call(b, '/device/items'))
.json()
.items.find((i) => i.url === item.url);
await f.call(b, `/access/${guestItem.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(b, '/inbox')).json().items[0].read, true);
const request = (await f.request(a, b, 'https://example.com/logged-out')).json().requestId;
await f.call(b, `/transfer-requests/${request}/accept`, 'POST', {}, randomUUID());
await f.call(b, '/auth/logout', 'POST', {});
await f.call(
a,
`/transfers/${request}/complete`,
'POST',
{ urls: ['https://example.com/logged-out'] },
randomUUID(),
);
const login = await f.call(b, '/auth/login', 'POST', {
email: 'receiver@hop.test',
password: '1234',
});
b.cookie =
b.cookie.split('; hop_account=')[0] +
'; ' +
String(login.headers['set-cookie']).split(';')[0];
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
} finally {
await f.close();
}
});
test('임시 링크 보관: 메모, 7일, 중복 방지, 타인 접근 차단과 이메일 전송 제거', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'other@hop.test');
const key = randomUUID(),
input = { kind: 'url', urls: ['https://example.com/saved'], note: '나중에 읽을 글' };
const result = await f.call(a, '/vault', 'POST', input, key);
assert.equal(result.statusCode, 200, result.body);
assert.equal(
(await f.call(a, '/vault', 'POST', input, key)).json().requestId,
result.json().requestId,
);
const list = (await f.call(a, '/vault')).json().items;
assert.equal(list.length, 1);
const item = list[0];
assert.equal(item.note, input.note);
assert.equal(item.expiresAt - item.createdAt, 7 * 86400000);
assert.equal((await f.call(a, '/inbox')).json().items.length, 0);
assert.equal(
(await f.call(b, `/vault/${item.accessId}/note`, 'PATCH', { note: 'stolen' })).statusCode,
404,
);
assert.equal(
(await f.call(a, `/vault/${item.accessId}/note`, 'PATCH', { note: '새 메모' })).statusCode,
200,
);
assert.equal((await f.call(a, '/vault?search=새')).json().items.length, 1);
assert.equal(
(await f.call(a, '/vault', 'POST', { ...input, note: '줄\n바꿈' }, randomUUID())).statusCode,
400,
);
assert.equal(
(await f.call(a, '/vault', 'POST', { kind: 'file', files: [] }, randomUUID())).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['javascript:alert(1)'] },
randomUUID(),
)
).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
'/transfer-requests',
'POST',
{ mode: 'email', email: 'other@hop.test', kind: 'url', count: 1, byteSize: 1 },
randomUUID(),
)
).statusCode,
410,
);
} finally {
await f.close();
}
});
test('보관함 링크 전송은 수락 전 비공개이며 수락 후 서버에서 전달, 삭제된 원본과 계정 세션 변경은 차단', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create(),
c = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'receiver@hop.test');
await signed(f, c, 'other@hop.test');
await f.space(a, b);
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/private'] },
randomUUID(),
);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(
(
await f.call(
c,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
)
).statusCode,
404,
);
const sent = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(sent.statusCode, 200, sent.body);
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
const requests = (await f.call(b, '/transfer-requests')).json().requests;
assert(!JSON.stringify(requests).includes(item.url));
assert.equal(
(
await f.call(
b,
`/transfer-requests/${sent.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
assert.equal((await f.call(b, '/inbox')).json().items[0].url, item.url);
const next = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
await f.call(a, '/access/delete-batch', 'POST', { ids: [item.accessId] });
assert.equal(
(
await f.call(
b,
`/transfer-requests/${next.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
404,
);
f.service.cleanup();
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
await f.call(a, `/transfer-requests/${next.json().requestId}/cancel`, 'POST', {});
await f.call(
a,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/session'] },
randomUUID(),
);
const another = (await f.call(a, '/vault')).json().items[0];
const revokedSource = await f.call(
a,
`/vault/${another.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
await f.call(a, '/auth/logout', 'POST', {});
assert.equal(
(
await f.call(
b,
`/transfer-requests/${revokedSource.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
401,
);
assert.equal((await f.call(b, '/inbox')).json().items.length, 1);
} finally {
await f.close();
}
});
test('보관함 파일은 저장된 바이트를 재사용, 수신 계정 기록·다운로드·원본 만료 후 상대 권한 유지와 마지막 참조 정리', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await signed(f, a, 'owner@hop.test');
await signed(f, b, 'receiver@hop.test');
await f.space(a, b);
const bytes = Buffer.from('stored vault file');
const saved = await f.call(
a,
'/vault',
'POST',
{
kind: 'file',
files: [{ name: 'saved.txt', size: bytes.length, type: 'text/plain' }],
note: '업무 자료',
},
randomUUID(),
);
assert.equal(saved.statusCode, 200, saved.body);
assert.equal((await upload(f, a, saved.json().requestId, bytes)).statusCode, 200);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(item.note, '업무 자료');
const sent = await f.call(
a,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(sent.statusCode, 200, sent.body);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${sent.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
const received = (await f.call(b, '/inbox')).json().items[0];
assert.equal(received.file.id, item.file.id);
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length);
const file = f.service.db.select().from(s.files).where(eq(s.files.id, item.file.id)).get();
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.where(eq(s.items.id, item.id))
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.equal((await f.call(b, `/access/${received.accessId}/download`)).statusCode, 404);
assert.equal(existsSync(resolve(f.service.storage.originals, file.storageKey)), false);
assert.equal(f.service.db.select().from(s.items).all().length, 0);
f.service.db
.update(s.requests)
.set({ createdAt: Date.now() - 8 * 86400000, note: '만료된 메모', manifest: '[]' })
.where(eq(s.requests.id, saved.json().requestId))
.run();
f.service.cleanup();
const expired = f.service.db
.select()
.from(s.requests)
.where(eq(s.requests.id, saved.json().requestId))
.get();
assert.equal(expired.note, null);
assert.equal(expired.manifest, null);
} finally {
await f.close();
}
});
test('보관함 콘텐츠에는 직접 저장과 받은 항목이 포함되며 자기 수신 기록도 같은 방 기기에 전달 가능', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create(),
c = await f.create();
await signed(f, a, 'a@hop.test');
await signed(f, b, 'b@hop.test');
await signed(f, c, 'c@hop.test');
await f.space(a, b);
await f.space(a, c);
await f.complete(a, b, 'https://example.com/received-source');
await f.call(
b,
'/vault',
'POST',
{ kind: 'url', urls: ['https://example.com/saved-source'] },
randomUUID(),
);
const sources = (await f.call(b, '/vault/contents')).json().items;
assert.equal(sources.length, 2);
assert(sources.some((item) => item.role === 'recipient'));
assert(sources.some((item) => item.role === 'self'));
const received = sources.find((item) => item.role === 'recipient');
const request = await f.call(
b,
`/vault/${received.accessId}/send`,
'POST',
{ targetDeviceId: c.deviceId },
randomUUID(),
);
assert.equal(request.statusCode, 200, request.body);
assert.equal(
(
await f.call(
c,
`/transfer-requests/${request.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).statusCode,
200,
);
assert.equal(
(await f.call(c, '/inbox')).json().items[0].url,
'https://example.com/received-source',
);
assert.equal((await f.call(a, '/vault/contents')).json().items.length, 0);
} finally {
await f.close();
}
});