Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
409
apps/server/test/vault.test.js
Normal file
409
apps/server/test/vault.test.js
Normal file
@@ -0,0 +1,409 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user