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

208 lines
7.4 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 { classifyClipboard, clipboardCharacterCount } from '@hop/shared';
import * as s from '../src/db/schema.js';
const raw =
' 첫 줄 😀\r\n日本語の文章\n\t URL은 https://example.com 안에 있어요. \n<script>window.__xss = true</script>';
function limits(f) {
f.cfg.sessionRate = 100;
f.cfg.ipRate = 200;
f.cfg.pairRate = 100;
}
async function textRequest(f, a, b, value, bytes = Buffer.byteLength(value)) {
return f.call(
a,
'/transfer-requests',
'POST',
{ targetDeviceId: b.deviceId, kind: 'text', count: 1, byteSize: bytes },
randomUUID(),
);
}
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('12345678'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
const login = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(login.statusCode, 200);
client.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
return id;
}
test('클립보드는 전체 단일 URL만 링크로 판별하고 나머지 원문과 이모지를 보존한다', () => {
for (const [input, url] of [
['https://example.com/article', 'https://example.com/article'],
['http://127.0.0.1/path', 'http://127.0.0.1/path'],
['www.example.com', 'https://www.example.com/'],
['example.com/path', 'https://example.com/path'],
])
assert.deepEqual(classifyClipboard(input), { kind: 'url', url });
for (const value of [
raw,
'',
' ',
'여기 https://example.com 보세요',
'https://one.test/\nhttps://two.test/',
'javascript:alert(1)',
'https://user:pass@example.com',
'https://example.com/\n',
])
assert.deepEqual(classifyClipboard(value), { kind: 'text', text: value });
assert.equal(clipboardCharacterCount('한글日本語😀'), 6);
});
test('텍스트는 수락 전 비공개이며 수락 후 원문·UTF-8·접근권·읽음·멱등성을 유지한다', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const response = await textRequest(f, a, b, raw);
assert.equal(response.statusCode, 200, response.body);
const id = response.json().requestId;
assert.equal(
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID()))
.statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
assert.equal((await f.call(b, '/device/snapshot')).json().requests[0].text, undefined);
assert.notEqual(
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.notEqual(
(await f.call(c, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID()))
.statusCode,
200,
);
const key = randomUUID();
assert.equal(
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, key)).statusCode,
200,
);
assert.equal(
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, key)).statusCode,
200,
);
const item = (await f.call(b, '/device/items')).json().items[0];
assert.equal(item.kind, 'text');
assert.equal(item.text, raw);
assert.equal(item.url, null);
assert.equal((await f.call(c, '/device/items')).json().items.length, 0);
assert.equal(f.service.db.select().from(s.items).all().length, 1);
assert.equal(f.service.db.select().from(s.files).all().length, 0);
await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true);
assert(item.expiresAt - item.createdAt <= 7 * 86400000);
} finally {
await f.close();
}
});
test('텍스트의 UTF-8 256KiB 한도와 본문 타입·빈 내용·승인 크기 변경을 검사한다', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create();
await f.space(a, b);
assert.equal(f.cfg.maxTextBytes, 256 * 1024);
assert.equal((await textRequest(f, a, b, '가'.repeat(87382))).statusCode, 413);
const boundary = 'x'.repeat(256 * 1024);
const id = (await textRequest(f, a, b, boundary)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: boundary }, randomUUID()))
.statusCode,
200,
);
const small = (await textRequest(f, a, b, 'x')).json().requestId;
await f.call(b, `/transfer-requests/${small}/accept`, 'POST', {}, randomUUID());
for (const [body, status] of [
[{ text: boundary + 'x' }, 413],
[{ text: ' ' }, 400],
[{ text: '変更 😀' }, 409],
[{ urls: ['https://example.com/'] }, 400],
[{ text: 'x', urls: [] }, 400],
])
assert.equal(
(await f.call(a, `/transfers/${small}/complete`, 'POST', body, randomUUID())).statusCode,
status,
);
assert.equal(
(await f.call(a, `/transfers/${small}/complete`, 'POST', { text: 'x' }, randomUUID()))
.statusCode,
200,
);
} finally {
await f.close();
}
});
test('로그인 중 받은 텍스트는 7일 기록하고 임시 저장·검색·보관함 재전송도 원문을 유지한다', async () => {
const f = await fixture();
try {
limits(f);
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await signed(f, b, 'text@hop.test');
const id = (await textRequest(f, a, b, raw)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await f.call(a, `/transfers/${id}/complete`, 'POST', { text: raw }, randomUUID());
const receipt = (await f.call(b, '/inbox')).json().items[0];
assert.equal(receipt.text, raw);
const saved = await f.call(
b,
'/vault',
'POST',
{ kind: 'text', text: raw, note: '保存 메모' },
randomUUID(),
);
assert.equal(saved.statusCode, 200, saved.body);
const vault = (await f.call(b, '/vault?search=' + encodeURIComponent('日本語'))).json().items;
assert.equal(vault.length, 1);
assert.equal(vault[0].text, raw);
const forwarded = await f.call(
b,
`/vault/${vault[0].accessId}/send`,
'POST',
{ targetDeviceId: a.deviceId },
randomUUID(),
);
assert.equal(forwarded.statusCode, 200, forwarded.body);
const sentId = forwarded.json().requestId;
await f.call(a, `/transfer-requests/${sentId}/accept`, 'POST', {}, randomUUID());
assert(
(await f.call(a, '/device/items'))
.json()
.items.some((item) => item.role === 'recipient' && item.text === raw),
);
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.where(eq(s.items.id, vault[0].id))
.run();
assert.equal((await f.call(b, '/vault')).json().items.length, 0);
} finally {
await f.close();
}
});