410 lines
13 KiB
JavaScript
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();
|
|
}
|
|
});
|