Initial hop. implementation and UGREEN NAS deployment guide

This commit is contained in:
2026-10-12 13:37:50 +09:00
commit 439c2ae61b
93 changed files with 42434 additions and 0 deletions

149
apps/server/test/helpers.ts Normal file
View File

@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { io, type Socket } from 'socket.io-client';
import { eq } from 'drizzle-orm';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import * as schema from '../src/db/schema.js';
import { clientIp } from '../src/security.js';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export function urlFor(value: string) {
return /^https?:\/\//.test(value)
? value
.split('\n')
.map((url) => new URL(url).href)
.join('\n')
: `https://example.test/${encodeURIComponent(value)}`;
}
const origin = 'http://hop.test';
type Client = { cookie: string; csrf: string; deviceId: string; socket?: Socket; ip: string };
export async function fixture(discovery = false, mailer?: import('../src/auth.js').Mailer) {
const folder = await mkdtemp(join(tmpdir(), 'hop-api-'));
const cfg = {
...loadConfig({
APP_URL: origin,
DATABASE_PATH: ':memory:',
SESSION_SECRET: 's'.repeat(40),
DISCOVERY_SECRET: 'd'.repeat(40),
}),
discoveryEnabled: discovery,
uploadDir: join(folder, 'uploads'),
minFreeBytes: 0,
trustedProxies: ['127.0.0.1', '::1'],
offlineGraceMs: 20,
};
const runtime = await createApp(cfg, { mailer });
await runtime.app.listen({ port: 0, host: '127.0.0.1' });
const address = runtime.app.server.address();
assert(address && typeof address !== 'string');
const url = `http://127.0.0.1:${address.port}`;
const sockets: Socket[] = [];
const call = async (
c: Client | null,
path: string,
method = 'GET',
payload?: unknown,
key?: string,
headers: Record<string, string> = {},
) =>
runtime.app.inject({
method: method as any,
url: `/api${path}`,
headers: {
origin,
...(c ? { cookie: c.cookie, 'x-csrf-token': c.csrf, 'x-forwarded-for': c.ip } : {}),
...(key ? { 'idempotency-key': key } : {}),
...headers,
},
payload,
});
const create = async (ip = '203.0.113.1', online = true) => {
const response = await call(null, '/guest/session', 'POST', {});
assert.equal(response.statusCode, 200);
const result = response.json();
const c: Client = {
cookie: String(response.headers['set-cookie']).split(';')[0]!,
csrf: result.csrfToken,
deviceId: result.device.id,
ip,
};
if (online) await connect(c);
return c;
};
const connect = async (c: Client) => {
const socket = io(url, {
transports: ['websocket'],
reconnection: false,
auth: { csrfToken: c.csrf },
extraHeaders: { Origin: origin, Cookie: c.cookie, 'X-Forwarded-For': c.ip },
});
sockets.push(socket);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(Error('socket connect timeout')), 3000);
socket.once('connect', () => {
clearTimeout(timer);
resolve();
});
socket.once('connect_error', (err) => {
clearTimeout(timer);
reject(err);
});
});
c.socket = socket;
return socket;
};
const invitations = new Map<string, any>();
const space = async (a: Client, b: Client, approvalRequired = false) => {
const current = (await call(a, '/spaces')).json().spaces;
let result = invitations.get(a.deviceId);
if (!result || !current.some((room: any) => room.id === result.space.id)) {
const response = await call(a, '/spaces', 'POST', { approvalRequired });
assert.equal(response.statusCode, 200, response.body);
result = response.json();
invitations.set(a.deviceId, result);
}
const join = await call(b, '/spaces/join', 'POST', { code: result.code });
assert.equal(join.statusCode, 200);
return result;
};
const request = async (
a: Client,
b: Client,
text = '안녕하세요',
kind: 'url' = 'url',
count = 1,
key = randomUUID(),
) => {
const r = await call(
a,
'/transfer-requests',
'POST',
{ targetDeviceId: b.deviceId, kind, byteSize: Buffer.byteLength(urlFor(text)), count },
key,
);
return r;
};
const complete = async (a: Client, b: Client, text = '안녕하세요') => {
const r = await request(a, b, text);
assert.equal(r.statusCode, 200);
const id = r.json().requestId;
assert.equal(
(await call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
assert.equal(
(await call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, randomUUID()))
.statusCode,
200,
);
return id;
};
const close = async () => {
for (const socket of sockets) socket.disconnect();
await runtime.app.close();
await rm(folder, { recursive: true, force: true });
};
return { ...runtime, cfg, url, call, create, connect, space, request, complete, close };
}

View File

@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createResendMailer } from '../src/mail.js';
import { ApiError } from '../src/security.js';
test('Resend 키가 없으면 발송 모듈은 비활성', () => {
assert.equal(createResendMailer({ mailFrom: 'hop. <hello@noname.company>' }), undefined);
});
test('Resend 발송 계약과 고정 중복 키: 키는 서버 Authorization에만 전달', async () => {
const calls: { url: unknown; options: RequestInit | undefined }[] = [];
const fakeFetch = (async (url: unknown, options: RequestInit) => {
calls.push({ url, options });
return new Response(JSON.stringify({ id: 'test-message' }), { status: 200 });
}) as typeof fetch;
const mailer = createResendMailer(
{ resendApiKey: 're_test_fixture', mailFrom: 'hop. <hello@noname.company>' },
fakeFetch,
)!;
await mailer({
to: 'recipient@example.com',
subject: '인증',
text: 'test body',
idempotencyKey: 'auth-verify-fixture',
});
assert.equal(calls.length, 1);
assert.equal(calls[0]!.url, 'https://api.resend.com/emails');
const options = calls[0]!.options!,
headers = new Headers(options.headers);
assert.equal(headers.get('Authorization'), 'Bearer re_test_fixture');
assert.equal(headers.get('Idempotency-Key'), 'auth-verify-fixture');
assert.equal(options.redirect, 'error');
assert(options.signal instanceof AbortSignal);
assert.deepEqual(JSON.parse(String(options.body)), {
from: 'hop. <hello@noname.company>',
to: ['recipient@example.com'],
subject: '인증',
text: 'test body',
});
assert(!String(options.body).includes('re_test_fixture'));
});
test('Resend 거절·타임아웃·잘못된 응답은 민감한 원문 없이 오류를 반환', async () => {
for (const fakeFetch of [
async () =>
new Response(JSON.stringify({ message: 'private provider response re_test_fixture' }), {
status: 401,
}),
async () => {
throw Error('private credential re_test_fixture');
},
async () => new Response('{}', { status: 200 }),
]) {
const mailer = createResendMailer(
{ resendApiKey: 're_test_fixture', mailFrom: 'hop. <hello@noname.company>' },
fakeFetch as typeof fetch,
)!;
await assert.rejects(
mailer({ to: 'recipient@example.com', subject: '인증', text: 'test body' }),
(error) =>
error instanceof ApiError &&
error.code === 'MAIL_DELIVERY_FAILED' &&
error.status === 503 &&
!error.message.includes('re_test_fixture') &&
!error.message.includes('private'),
);
}
});

View File

@@ -0,0 +1,74 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { openDatabase } from '../src/db/index.js';
test('사용 중 적용된 초기 계정 DB 버전을 후속 마이그레이션으로 보정하고 기록 보존', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-legacy-'));
try {
const path = join(folder, 'legacy.sqlite'),
old = new Database(path),
migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)',
);
for (const [index, migration] of migrations.slice(0, 5).entries()) {
for (let statement of migration.sql) {
if (index === 4 && statement.includes('CREATE TABLE `__new_deletion_operations`'))
statement = statement.replace('`guestId` text,', '`guestId` text NOT NULL,');
old.exec(statement);
}
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(
index === 4
? 'fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068'
: migration.hash,
index === 4 ? 1791448938074 : migration.folderMillis,
);
}
const now = Date.now();
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run('guest', 'hash', now + 86400000, now);
old
.prepare('INSERT INTO deletion_operations(id,guestId,createdAt,undoUntil) VALUES (?,?,?,?)')
.run('operation', 'guest', now, now + 5000);
old.close();
const { sqlite } = openDatabase(path);
const info = sqlite.pragma('table_info(deletion_operations)') as {
name: string;
notnull: number;
}[];
assert.equal(info.find((c) => c.name === 'guestId')!.notnull, 0);
assert.equal((sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n, 1);
assert.equal(
(sqlite.prepare('SELECT guestId FROM deletion_operations WHERE id=?').get('operation') as any)
.guestId,
'guest',
);
assert.equal((sqlite.pragma('foreign_key_check') as any[]).length, 0);
assert.equal(
(
sqlite
.prepare('SELECT count(*) AS n FROM __drizzle_migrations WHERE hash=?')
.get('fb1af44cf19e4f77c50f07608ba046819dce40cae3925e0b632d1d9afa045068') as any
).n,
1,
);
sqlite.close();
const again = openDatabase(path);
assert.equal(
(again.sqlite.prepare('SELECT count(*) AS n FROM guest_sessions').get() as any).n,
1,
);
again.sqlite.close();
} finally {
await rm(folder, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,557 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { io, type Socket } from 'socket.io-client';
import { eq } from 'drizzle-orm';
import { createApp } from '../src/app.js';
import { loadConfig } from '../src/config.js';
import * as schema from '../src/db/schema.js';
import { clientIp } from '../src/security.js';
import { fixture, urlFor } from './helpers.js';
test('익명 이름과 비밀 쿠키가 유지되고 공개 ID는 인증이 아니다', async () => {
const f = await fixture();
try {
const a = await f.create();
const changed = await f.call(a, '/device/me', 'PATCH', { name: '나의 Mac' });
assert.equal(changed.statusCode, 200);
const again = await f.call(a, '/guest/session', 'POST', {});
assert.equal(again.json().device.name, '나의 Mac');
assert.equal(again.json().device.id, a.deviceId);
assert.equal((await f.call(null, '/device/me')).statusCode, 401);
assert.equal(
(await f.call({ ...a, cookie: `hop_guest=${a.deviceId}` }, '/device/me')).statusCode,
401,
);
assert.match(String(again.headers['set-cookie']), /HttpOnly/);
assert.match(String(again.headers['set-cookie']), /SameSite=Lax/);
} finally {
await f.close();
}
});
test('Origin, CSRF, HTML 이름 검증', async () => {
const f = await fixture();
try {
const a = await f.create();
assert.equal((await f.call(a, '/device/me', 'PATCH', { name: '<script>' })).statusCode, 400);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
origin: 'https://evil.test',
})
).statusCode,
403,
);
assert.equal(
(
await f.call(a, '/device/me', 'PATCH', { name: '테스트' }, undefined, {
'x-csrf-token': 'bad',
})
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('검증 전 네트워크 탐색은 꺼지고 QR 코드로 다른 IP 연결', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create('198.51.100.2'),
c = await f.create('198.51.100.3');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
await f.space(a, b);
const peers = (await f.call(a, '/discovery/devices')).json().devices;
assert.deepEqual(
peers.map((d: any) => d.id),
[b.deviceId],
);
assert.deepEqual(peers[0].sources, ['space']);
assert.equal((await f.request(a, c)).statusCode, 403);
} finally {
await f.close();
}
});
test('같은 공인 IP만 발견하고 다른 IP 기기에는 전송 금지', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('1.1.1.1');
assert.deepEqual(
(await f.call(a, '/discovery/devices')).json().devices.map((d: any) => d.id),
[b.deviceId],
);
assert.equal((await f.request(a, c)).statusCode, 403);
} finally {
await f.close();
}
});
test('발견 끄기는 즉시 목록과 새 요청에서 제외', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.call(b, '/device/me', 'PATCH', { discoverable: false });
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
} finally {
await f.close();
}
});
test('승인 전 참여자는 발견되지 않으며 생성자만 승인 가능', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
const invite = await f.space(a, b, true);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(c, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
404,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}/approve`, 'POST', {}))
.statusCode,
200,
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
} finally {
await f.close();
}
});
test('공간 퇴장, 재가입 거절, 폐쇄, QR 토큰 참여', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = (await f.call(a, '/spaces', 'POST', {})).json();
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
200,
);
assert.equal(
(await f.call(a, `/spaces/${invite.space.id}/members/${b.deviceId}`, 'DELETE')).statusCode,
200,
);
assert.equal((await f.request(a, b)).statusCode, 403);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 403);
assert.equal((await f.call(a, `/spaces/${invite.space.id}`, 'DELETE')).statusCode, 200);
assert.equal(
(await f.call(b, '/spaces/join', 'POST', { token: invite.token })).statusCode,
404,
);
} finally {
await f.close();
}
});
test('메타데이터만 수락 전 공개하고 전송 확정 금지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const response = await f.request(a, b, '비밀 본문');
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal(
JSON.stringify((await f.call(b, '/transfer-requests')).json()).includes('비밀 본문'),
false,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비밀 본문')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
} finally {
await f.close();
}
});
test('수락 이후 실제 URL 저장, 수신 접근권 격리, 읽음 공유', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.complete(a, b, '안녕\n<script>alert(1)</script>');
const received = (await f.call(b, '/device/items')).json().items[0];
assert.equal(received.url, urlFor('안녕\n<script>alert(1)</script>'));
assert.equal(received.read, false);
assert.equal(
(await f.call(c, `/access/${received.accessId}/read`, 'PATCH', { read: true })).statusCode,
404,
);
await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, true);
await f.call(b, `/access/${received.accessId}/read`, 'PATCH', { read: false });
assert.equal((await f.call(a, '/device/items')).json().items[0].recipientRead, false);
} finally {
await f.close();
}
});
test('다중 URL을 개별 항목으로 저장하고 내부 URL 전달을 허용', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const urls = ['https://example.com/', 'http://192.168.1.10/'];
const response = await f.request(a, b, urls.join('\n'), 'url', 2);
const id = response.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const result = await f.call(a, `/transfers/${id}/complete`, 'POST', { urls }, randomUUID());
assert.equal(result.statusCode, 200);
assert.equal((await f.call(b, '/device/items')).json().items.length, 2);
} finally {
await f.close();
}
});
test('URL 스크립트 프로토콜 및 승인 메타데이터 변경 차단', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const r = await f.request(a, b, 'https://example.com/', 'url', 1);
const id = r.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['javascript:alert(1)'] },
randomUUID(),
)
).statusCode,
400,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['https://example.com/longer'] },
randomUUID(),
)
).statusCode,
409,
);
} finally {
await f.close();
}
});
test('동일 키 요청과 확정은 한 번만 처리하고 다른 내용은 충돌', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const key = randomUUID(),
text = 'repeat';
const one = await f.request(a, b, text, 'url', 1, key),
two = await f.request(a, b, text, 'url', 1, key);
assert.equal(one.json().requestId, two.json().requestId);
assert.equal((await f.request(a, b, 'different', 'url', 1, key)).statusCode, 409);
const id = one.json().requestId,
acceptKey = randomUUID(),
completeKey = randomUUID();
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode,
200,
);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, acceptKey)).statusCode,
200,
);
await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey);
await f.call(a, `/transfers/${id}/complete`, 'POST', { urls: [urlFor(text)] }, completeKey);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('수신자만 수락하고 송신자만 확정 가능', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
assert.equal(
(await f.call(a, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
403,
);
assert.equal(
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
404,
);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(
await f.call(
b,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
403,
);
} finally {
await f.close();
}
});
test('거절, 만료, 취소 후 확정 금지와 거절 재요청 제한', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/reject`, 'POST', {});
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.request(a, b)).statusCode, 429);
const c = await f.create();
await f.space(a, c);
const second = (await f.request(a, c)).json().requestId;
f.service.db
.update(schema.requests)
.set({ expiresAt: Date.now() - 1 })
.where(eq(schema.requests.id, second))
.run();
assert.equal(
(await f.call(c, `/transfer-requests/${second}/accept`, 'POST', {}, randomUUID())).statusCode,
409,
);
f.service.cleanup();
assert.equal(
f.service.db.select().from(schema.requests).where(eq(schema.requests.id, second)).get()!
.status,
'expired',
);
} finally {
await f.close();
}
});
test('수락 직전 오프라인 검사 및 여러 탭 합산', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const first = b.socket!;
const second = await f.connect(b);
first.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
const id = (await f.request(a, b)).json().requestId;
second.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
409,
);
} finally {
await f.close();
}
});
test('방 종료 시 진행 중 수락 전송도 취소되며 재연결로 확정할 수 없음', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await f.call(a, `/spaces/${invite.space.id}`, 'DELETE');
b.socket!.disconnect();
await new Promise((r) => setTimeout(r, 35));
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
await f.connect(b);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
} finally {
await f.close();
}
});
test('익명 차단은 새로운 요청과 수락한 요청의 확정을 금지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = (await f.request(a, b)).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await f.call(b, `/transfer-requests/${id}/block`, 'POST', {})).statusCode, 200);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('안녕하세요')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
} finally {
await f.close();
}
});
test('익명 초기화는 이전 접근권만 폐기하고 상대 자료는 유지', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
await f.complete(a, b);
const response = await f.call(b, '/device/reset', 'POST', {});
assert.equal(response.statusCode, 200);
assert.equal((await f.call(b, '/device/items')).statusCode, 401);
const next = response.json();
const fresh = {
...b,
cookie: String(response.headers['set-cookie']).split(';')[0]!,
csrf: next.csrfToken,
deviceId: next.device.id,
};
assert.equal((await f.call(fresh, '/device/items')).json().items.length, 0);
assert.equal((await f.call(a, '/device/items')).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 f.space(a, b);
await f.complete(a, b);
f.service.db
.update(schema.items)
.set({ expiresAt: Date.now() - 1 })
.run();
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
f.service.cleanup();
assert.equal(f.service.db.select().from(schema.items).all().length, 0);
} finally {
await f.close();
}
});
test('코드 대입 횟수 제한과 공간 만료 적용', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
const invite = (await f.call(a, '/spaces', 'POST', {})).json();
f.service.db
.update(schema.spaces)
.set({ expiresAt: Date.now() - 1 })
.run();
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: invite.code })).statusCode, 404);
for (let i = 0; i < 10; i++) await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' });
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: 'ABCDEFGH' })).statusCode, 429);
} finally {
await f.close();
}
});
test('임의 X-Forwarded-For는 신뢰하지 않고 명시된 프록시 체인만 해석', () => {
assert.equal(clientIp('8.8.8.8', { 'x-forwarded-for': '1.1.1.1' }, []), '8.8.8.8');
assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': '1.1.1.1' }, ['127.0.0.1']), '1.1.1.1');
assert.equal(clientIp('127.0.0.1', {}, ['127.0.0.1']), null);
assert.equal(clientIp('127.0.0.1', { 'x-forwarded-for': 'bogus' }, ['127.0.0.1']), null);
assert.equal(
clientIp('127.0.0.1', { 'x-forwarded-for': '8.8.8.8, 1.1.1.1' }, ['127.0.0.1']),
'1.1.1.1',
);
});
test('신규 일반 텍스트 요청과 본문 확정을 거절하고 URL·파일만 지원', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
assert.equal(
(
await f.call(
a,
'/transfer-requests',
'POST',
{ targetDeviceId: b.deviceId, kind: 'text', byteSize: 5, count: 1 },
randomUUID(),
)
).statusCode,
400,
);
const id = (await f.request(a, b, 'https://example.com/')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal(
(await f.call(a, `/transfers/${id}/complete`, 'POST', { text: 'hello' }, randomUUID()))
.statusCode,
400,
);
assert.equal((await f.call(b, '/device/snapshot')).json().items.length, 0);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: ['https://example.com/'] },
randomUUID(),
)
).statusCode,
200,
);
const snapshot = (await f.call(b, '/device/snapshot')).json();
assert.equal(snapshot.items[0].kind, 'url');
assert.equal(snapshot.items[0].url, 'https://example.com/');
assert.equal('text' in snapshot.items[0], false);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,411 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { readFile, mkdtemp, rm, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import Database from 'better-sqlite3';
import sharp from 'sharp';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { openDatabase } from '../src/db/index.js';
async function fileRequest(
f: Awaited<ReturnType<typeof fixture>>,
a: any,
b: any,
files: { name: string; size: number; type: string }[],
) {
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: files.reduce((sum, v) => sum + v.size, 0),
count: files.length,
files,
},
randomUUID(),
);
assert.equal(r.statusCode, 200, r.body);
return r.json().requestId as string;
}
async function upload(
f: Awaited<ReturnType<typeof fixture>>,
a: any,
id: string,
name: string,
bytes: Buffer,
index = 0,
key = randomUUID(),
) {
const boundary = `hop${randomUUID()}`;
const body = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${name}"\r\nContent-Type: application/octet-stream\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return f.call(a, `/uploads?requestId=${id}&fileIndex=${index}`, 'POST', body, key, {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
test('파일은 수락 전 업로드되지 않으며 실제 스트리밍 저장과 인증 다운로드', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
const bytes = Buffer.from('실제 파일 내용'),
id = await fileRequest(f, a, b, [
{ name: '파일.txt', size: bytes.length, type: 'text/plain' },
]);
assert.equal((await upload(f, a, id, '파일.txt', bytes)).statusCode, 409);
assert.equal((await readdir(f.service.storage.originals)).length, 0);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const uploaded = await upload(f, a, id, '파일.txt', bytes);
assert.equal(uploaded.statusCode, 200, uploaded.body);
const item = (await f.call(b, '/device/items')).json().items[0];
assert.equal(item.file.name, '파일.txt');
assert.equal((await f.call(c, `/access/${item.accessId}/download`)).statusCode, 404);
const download = await f.call(b, `/access/${item.accessId}/download`);
assert.equal(download.statusCode, 200);
assert.deepEqual(download.rawPayload, bytes);
assert.match(String(download.headers['content-disposition']), /attachment/);
assert.equal(download.headers['x-content-type-options'], 'nosniff');
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
} finally {
await f.close();
}
});
test('선언 크기와 다른 파일, 요청 밖 파일, 이름 변경을 거절하고 임시 파일 회수', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'a.txt', size: 4, type: 'text/plain' }]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('excess'))).statusCode, 413);
assert.equal((await readdir(f.service.storage.temp)).length, 0);
assert.equal((await upload(f, a, id, 'changed.txt', Buffer.from('test'))).statusCode, 409);
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'), 1)).statusCode, 400);
assert.equal((await upload(f, a, id, 'a.txt', Buffer.from('test'))).statusCode, 200);
} finally {
await f.close();
}
});
test('부분 성공은 유지하고 실패 파일만 재시도', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'one.txt', size: 3, type: 'text/plain' },
{ name: 'two.txt', size: 3, type: 'text/plain' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await upload(f, a, id, 'one.txt', Buffer.from('one'))).statusCode, 200);
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('badsize'), 1)).statusCode, 413);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
assert.equal((await upload(f, a, id, 'two.txt', Buffer.from('two'), 1)).statusCode, 200);
assert.equal((await f.call(b, '/device/items')).json().items.length, 2);
assert.equal(
f.service.storage.fileRows(id).every((v) => v.status === 'stored'),
true,
);
} finally {
await f.close();
}
});
test('예약을 원자적으로 적용해 전체 익명 상한을 우회하지 못한다', async () => {
const f = await fixture();
try {
f.cfg.totalGuestQuota = 4;
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
const one = await fileRequest(f, a, b, [
{ name: 'one', size: 4, type: 'application/octet-stream' },
]),
two = await fileRequest(f, c, b, [
{ name: 'two', size: 4, type: 'application/octet-stream' },
]);
const results = await Promise.all([
f.call(b, `/transfer-requests/${one}/accept`, 'POST', {}, randomUUID()),
f.call(b, `/transfer-requests/${two}/accept`, 'POST', {}, randomUUID()),
]);
assert.deepEqual(results.map((r) => r.statusCode).sort(), [200, 507]);
assert.equal(
f.service.db
.select()
.from(s.reservations)
.all()
.reduce((sum, r) => sum + r.bytes, 0),
4,
);
} finally {
await f.close();
}
});
test('개별 익명 용량, 파일당 제한과 취소 후 예약 회수', async () => {
const f = await fixture();
try {
f.cfg.guestQuota = 3;
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'large', size: 4, type: 'application/octet-stream' },
]);
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.guestQuota = 4;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
await f.call(a, `/transfer-requests/${id}/cancel`, 'POST', {});
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
assert.equal((await upload(f, a, id, 'large', Buffer.from('test'))).statusCode, 409);
} finally {
await f.close();
}
});
test('인증 래스터 미리보기는 읽음이 아니며 SVG와 실행 콘텐츠는 거절', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const png = await sharp({
create: { width: 16, height: 16, channels: 3, background: '#365e40' },
})
.png()
.toBuffer();
const id = await fileRequest(f, a, b, [
{ name: 'test.png', size: png.length, type: 'image/png' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await upload(f, a, id, 'test.png', png);
const item = (await f.call(b, '/device/items')).json().items[0];
const preview = await f.call(b, `/access/${item.accessId}/preview`);
assert.equal(preview.statusCode, 200);
assert.equal(preview.headers['content-type'], 'image/webp');
assert.equal((await f.call(b, '/device/items')).json().items[0].read, false);
const c = await f.create();
await f.space(a, c);
const svg = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
);
const svgId = await fileRequest(f, c, b, [
{ name: 'evil.svg', size: svg.length, type: 'image/svg+xml' },
]);
await f.call(b, `/transfer-requests/${svgId}/accept`, 'POST', {}, randomUUID());
await upload(f, c, svgId, 'evil.svg', svg);
const svgItem = (await f.call(b, '/device/items'))
.json()
.items.find((v: any) => v.file.name === 'evil.svg');
assert.equal((await f.call(b, `/access/${svgItem.accessId}/preview`)).statusCode, 415);
} finally {
await f.close();
}
});
test('내 목록 삭제는 상대 접근권을 유지하고 서버 복원 ID로 실행 취소', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.complete(a, b);
const item = (await f.call(b, '/device/items')).json().items[0];
const deletion = (
await f.call(b, '/access/delete-batch', 'POST', { ids: [item.accessId] })
).json();
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
assert.equal((await f.call(a, '/device/items')).json().items.length, 1);
assert.equal(
(await f.call(c, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
404,
);
assert.equal(
(await f.call(b, `/deletion-operations/${deletion.operationId}/undo`, 'POST', {})).statusCode,
200,
);
assert.equal((await f.call(b, '/device/items')).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 f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'delete.txt', size: 4, type: 'text/plain' }]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await upload(f, a, id, 'delete.txt', Buffer.from('test'));
const sender = (await f.call(a, '/device/items')).json().items[0],
receiver = (await f.call(b, '/device/items')).json().items[0];
await f.call(a, '/access/delete-batch', 'POST', { ids: [sender.accessId] });
const d = (
await f.call(b, '/access/delete-batch', 'POST', { ids: [receiver.accessId] })
).json();
await f.service.storage.cleanup();
assert.equal((await readdir(f.service.storage.originals)).length, 1);
f.service.db
.update(s.deletions)
.set({ undoUntil: Date.now() - 1 })
.where(eq(s.deletions.id, d.operationId))
.run();
assert.equal(
(await f.call(b, `/deletion-operations/${d.operationId}/undo`, 'POST', {})).statusCode,
409,
);
f.service.db
.update(s.access)
.set({ deletedAt: Date.now() - 6000 })
.run();
f.service.cleanup();
await f.service.storage.cleanup();
assert.equal((await readdir(f.service.storage.originals)).length, 0);
} finally {
await f.close();
}
});
test('Phase 1 자료가 있는 DB를 Phase 2로 마이그레이션해도 본문과 접근권 유지', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-migrate-'));
try {
const path = join(folder, 'old.sqlite'),
old = new Database(path);
const migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT,hash TEXT NOT NULL,created_at INTEGER)',
);
for (const migration of migrations.slice(0, 2)) {
for (const sql of migration.sql) old.exec(sql);
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
const now = Date.now();
for (const id of ['a', 'b'])
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run(id, id, now + 86400000, now);
old
.prepare(
'INSERT INTO transfer_requests(id,mode,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?,?)',
)
.run('request', 'local', 'a', 'b', 'text', 4, 1, 'accepted', now, now + 60000);
old
.prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)')
.run('batch', 'request', 'stored');
old
.prepare('INSERT INTO items(id,batchId,kind,text,createdAt,expiresAt) VALUES (?,?,?,?,?,?)')
.run('item', 'batch', 'text', 'keep', now, now + 86400000);
old
.prepare('INSERT INTO item_access(id,itemId,guestId,role,read) VALUES (?,?,?,?,?)')
.run('access', 'item', 'b', 'recipient', 0);
old.close();
const { sqlite } = openDatabase(path);
assert.equal(
(sqlite.prepare('SELECT text FROM items WHERE id=?').get('item') as any).text,
'keep',
);
assert.equal((sqlite.prepare('SELECT count(*) AS n FROM item_access').get() as any).n, 1);
assert.equal(sqlite.pragma('foreign_key_check').length, 0);
assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1);
sqlite.close();
} finally {
await rm(folder, { recursive: true, force: true });
}
});
test('업로드 확정 재시도는 같은 키와 바이트에만 동일 결과를 반환', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [{ name: 'retry.txt', size: 4, type: 'text/plain' }]),
key = randomUUID();
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const first = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key),
second = await upload(f, a, id, 'retry.txt', Buffer.from('same'), 0, key);
assert.equal(first.statusCode, 200);
assert.deepEqual(first.json(), second.json());
assert.equal(
(await upload(f, a, id, 'retry.txt', Buffer.from('diff'), 0, key)).statusCode,
409,
);
assert.equal((await f.call(b, '/device/items')).json().items.length, 1);
} finally {
await f.close();
}
});
test('실제 HTTP 업로드 중단 시 임시 파일과 해당 파일 예약을 회수', async () => {
const f = await fixture();
try {
const a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b, [
{ name: 'abort.bin', size: 1048576, type: 'application/octet-stream' },
]);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const { Readable } = await import('node:stream');
const controller = new AbortController(),
boundary = 'hop-abort';
async function* bytes() {
yield Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="abort.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`,
);
yield Buffer.alloc(65536);
while (!controller.signal.aborted) await new Promise((r) => setTimeout(r, 10));
}
const pending = fetch(f.url + `/api/uploads?requestId=${id}&fileIndex=0`, {
method: 'POST',
headers: {
Origin: 'http://hop.test',
Cookie: a.cookie,
'X-CSRF-Token': a.csrf,
'Idempotency-Key': randomUUID(),
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body: Readable.from(bytes()),
duplex: 'half',
signal: controller.signal,
} as any).catch(() => null);
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'uploading'; i++)
await new Promise((r) => setTimeout(r, 10));
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'uploading');
controller.abort();
await pending;
for (let i = 0; i < 100 && f.service.storage.fileRows(id)[0]?.status !== 'failed'; i++)
await new Promise((r) => setTimeout(r, 10));
assert.equal(f.service.storage.fileRows(id)[0]!.status, 'failed');
assert.equal((await readdir(f.service.storage.temp)).length, 0);
assert.equal(f.service.db.select().from(s.reservations).all().length, 0);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,470 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture, urlFor } from './helpers.js';
import type { Mail } from '../src/auth.js';
import { normalizeEmail } from '../src/auth.js';
import * as s from '../src/db/schema.js';
const password = 'correct horse battery staple';
test('개발은 1자 비밀번호를 허용하고 운영은 12자 규칙을 유지', async () => {
const f = await fixture();
try {
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 1);
assert.match(await f.service.auth.password('1'), /^\$argon2id\$/);
await assert.rejects(() => f.service.auth.password(''));
f.cfg.production = true;
assert.equal((await f.call(null, '/auth/me')).json().passwordMinLength, 12);
await assert.rejects(() => f.service.auth.password('1234'), /12~1024/);
assert.match(await f.service.auth.password('123412341234'), /^\$argon2id\$/);
} finally {
await f.close();
}
});
async function accountFixture() {
const mails: Mail[] = [];
const f = await fixture(false, async (mail) => {
mails.push(mail);
});
f.cfg.registrationMode = 'open';
return { ...f, mails };
}
async function register(
f: Awaited<ReturnType<typeof accountFixture>>,
email: string,
verify = true,
) {
const c = await f.create();
const result = await f.call(c, '/auth/register', 'POST', {
email,
password,
passwordConfirmation: password,
displayName: email.split('@')[0],
});
assert.equal(result.statusCode, 200, result.body);
const mail = f.mails.find((m) => m.to === email)!;
const token = mail.text.match(/#verify=([A-Za-z0-9_-]+)/)![1]!;
if (verify)
assert.equal((await f.call(c, '/auth/verify-email', 'POST', { token })).statusCode, 200);
const login = await f.call(c, '/auth/login', 'POST', { email, password });
assert.equal(login.statusCode, 200, login.body);
c.cookie += '; ' + String(login.headers['set-cookie']).split(';')[0];
c.socket?.disconnect();
await f.connect(c);
return { ...c, userId: login.json().account.id, token };
}
async function request(
f: Awaited<ReturnType<typeof accountFixture>>,
a: any,
email: string,
text = 'remote text',
) {
return legacyRequest(f, a, {
email,
kind: 'url',
byteSize: Buffer.byteLength(urlFor(text)),
count: 1,
});
}
// Reproduce requests already stored by the previous email system; this is not a public creation API.
async function legacyRequest(f: any, a: any, input: any) {
try {
const result = f.service.accounts.create(a.userId, input);
f.service.notifyTransfer(result.requestId);
return { statusCode: 200, body: JSON.stringify(result), json: () => result };
} catch (error: any) {
return {
statusCode: error.status,
body: error.message,
json: () => ({ message: error.message }),
};
}
}
test('가입은 메일 게이트웨이로 인증 토큰을 보내고 해시만 저장, 인증과 로그인 회전', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com', false);
assert.equal(f.mails.length, 1);
const user = f.service.db.select().from(s.users).get()!;
assert.match(user.passwordHash!, /^\$argon2id\$/);
assert.equal(f.service.db.select().from(s.accountTokens).get()!.tokenHash === a.token, false);
assert.equal((await request(f, a, 'alice@example.com')).statusCode, 403);
assert.equal(
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode,
200,
);
assert.equal(
(await f.call(a, '/auth/verify-email', 'POST', { token: a.token })).statusCode,
400,
);
const oldCookie = a.cookie;
const again = await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password });
assert.equal(again.statusCode, 200);
assert.equal((await f.call({ ...a, cookie: oldCookie }, '/auth/me')).json().account, null);
assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId);
} finally {
await f.close();
}
});
test('메일 미설정 상태에서 공개 가입을 열 수 없고 폐쇄 가입은 거절', async () => {
const f = await fixture();
try {
const a = await f.create();
const input = {
email: 'alice@example.com',
password,
passwordConfirmation: password,
displayName: 'Alice',
};
assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 403);
f.cfg.registrationMode = 'open';
assert.equal((await f.call(a, '/auth/register', 'POST', input)).statusCode, 503);
} finally {
await f.close();
}
});
test('기존 이메일 자료: 낯선 계정은 메타데이터 요청만 만들고 수락 후 실제 전달 및 허용 관계', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com'),
c = await register(f, 'charlie@example.com');
const response = await request(f, a, 'bob@example.com', '비공개 원격 본문');
assert.equal(response.statusCode, 200, response.body);
assert.equal(response.json().requiresAcceptance, true);
const id = response.json().requestId;
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
assert.equal(
JSON.stringify((await f.call(b, '/transfer-requests?mode=email')).json()).includes(
'비공개 원격 본문',
),
false,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비공개 원격 본문')] },
randomUUID(),
)
).statusCode,
409,
);
assert.equal(
(await f.call(c, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
404,
);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${id}/accept`,
'POST',
{ allowSender: true },
randomUUID(),
)
).statusCode,
200,
);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('비공개 원격 본문')] },
randomUUID(),
)
).statusCode,
200,
);
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(item.url, urlFor('비공개 원격 본문'));
assert.equal((await f.call(c, `/access/${item.accessId}`)).statusCode, 404);
assert.equal((await f.call(b, '/device/items')).json().items.length, 0);
await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true });
assert.equal((await f.call(a, '/sent')).json().items[0].recipientRead, true);
b.socket?.disconnect();
const second = await request(f, a, 'bob@example.com');
assert.equal(second.statusCode, 200);
assert.equal(second.json().requiresAcceptance, false);
assert.equal(
(
await f.call(
a,
`/transfers/${second.json().requestId}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
)
).statusCode,
200,
);
assert.equal((await f.call(b, '/inbox')).json().items.length, 2);
} finally {
await f.close();
}
});
test('자신에게 저장은 요청 수락 없이 내 보관함이며 계정 간 상태 격리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const r = await request(f, a, 'alice@example.com');
assert.equal(r.statusCode, 200);
assert.equal(r.json().requiresAcceptance, false);
await f.call(
a,
`/transfers/${r.json().requestId}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
);
const item = (await f.call(a, '/vault')).json().items[0];
assert.equal(item.role, 'self');
assert.equal((await f.call(a, '/inbox')).json().items.length, 0);
assert.equal(
(await f.call(b, `/access/${item.accessId}/read`, 'PATCH', { read: true })).statusCode,
404,
);
} finally {
await f.close();
}
});
test('기존 이메일 자료: 차단이 허용보다 우선하며 주소 오류는 일반화', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const id = (await request(f, a, 'bob@example.com')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', { allowSender: true }, randomUUID());
await f.call(b, `/account/rules/${a.userId}`, 'PUT', { action: 'block' });
const blocked = await request(f, a, 'bob@example.com'),
unknown = await request(f, a, 'nobody@example.com');
assert.equal(blocked.statusCode, 400);
assert.equal(unknown.statusCode, 400);
assert.equal(blocked.json().message, unknown.json().message);
assert.equal(
(
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
)
).statusCode,
409,
);
} finally {
await f.close();
}
});
test('비밀번호 재설정 토큰은 일회용이며 모든 기존 세션을 무효화', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com');
const another = await f.call(a, '/auth/login', 'POST', {
email: 'alice@example.com',
password,
});
a.cookie =
a.cookie.split('; hop_account=')[0] +
'; ' +
String(another.headers['set-cookie']).split(';')[0];
await f.call(a, '/auth/forgot-password', 'POST', { email: 'alice@example.com' });
const mail = f.mails.at(-1)!,
token = mail.text.match(/#reset-password=([A-Za-z0-9_-]+)/)![1]!,
next = 'a different long password';
const response = await f.call(a, '/auth/reset-password', 'POST', {
token,
password: next,
passwordConfirmation: next,
});
assert.equal(response.statusCode, 200);
assert.equal((await f.call(a, '/auth/me')).json().account, null);
assert.equal(
(
await f.call(a, '/auth/reset-password', 'POST', {
token,
password: next,
passwordConfirmation: next,
})
).statusCode,
400,
);
assert.equal(
(await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password })).statusCode,
401,
);
assert.equal(
(await f.call(a, '/auth/login', 'POST', { email: 'alice@example.com', password: next }))
.statusCode,
200,
);
} finally {
await f.close();
}
});
test('로그인 중 받은 자료는 받은 항목에 자동 기록하며 익명 가져오기는 제거', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await f.create();
await f.space(a, b);
await f.complete(b, a, '자동 기록');
const item = (await f.call(a, '/device/items')).json().items[0];
assert.equal(
(await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode,
410,
);
const saved = (await f.call(a, '/inbox')).json().items[0];
assert.equal(saved.url, item.url);
assert.equal(saved.expiresAt, item.expiresAt);
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
await f.call(a, '/auth/logout', 'POST', {});
assert.equal((await f.call(a, '/inbox')).statusCode, 401);
} finally {
await f.close();
}
});
test('즐겨찾기 영구 보관 제거와 만료된 자료의 실제 정리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
const id = (await request(f, a, 'bob@example.com')).json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
await f.call(
a,
`/transfers/${id}/complete`,
'POST',
{ urls: [urlFor('remote text')] },
randomUUID(),
);
const item = (await f.call(b, '/inbox')).json().items[0];
assert.equal(
(await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
.statusCode,
410,
);
f.service.db
.update(s.items)
.set({ expiresAt: Date.now() - 1 })
.run();
f.service.cleanup();
assert.equal((await f.call(b, '/favorites')).json().items.length, 0);
assert.equal((await f.call(a, '/sent')).json().items.length, 0);
await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: false });
assert.equal((await f.call(b, '/inbox')).json().items.length, 0);
} finally {
await f.close();
}
});
test('이메일 정규화는 도메인만 소문자화하고 제공자 점·별칭을 보존', () => {
assert.equal(normalizeEmail(' Alice.Smith+tag@EXAMPLE.COM '), 'Alice.Smith+tag@example.com');
assert.notEqual(normalizeEmail('Alice@example.com'), normalizeEmail('alice@example.com'));
});
async function fileUpload(
f: Awaited<ReturnType<typeof accountFixture>>,
a: any,
id: string,
bytes: Buffer,
) {
const boundary = 'hop-account-file';
const payload = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="account.txt"\r\nContent-Type: text/plain\r\n\r\n`,
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
return f.call(a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': `multipart/form-data; boundary=${boundary}`,
});
}
test('기존 이메일 자료: 계정 파일은 수락 후 저장되고 익명 상한과 계정 용량을 분리', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
f.cfg.totalGuestQuota = 1;
const bytes = Buffer.from('account file');
const response = await legacyRequest(f, a, {
email: 'bob@example.com',
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }],
});
assert.equal(response.statusCode, 200);
const id = response.json().requestId;
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 409);
f.cfg.userQuota = bytes.length - 1;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
413,
);
f.cfg.userQuota = bytes.length;
assert.equal(
(await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const upload = await fileUpload(f, a, id, bytes);
assert.equal(upload.statusCode, 200, upload.body);
const item = (await f.call(b, '/inbox')).json().items[0],
download = await f.call(b, `/access/${item.accessId}/download`);
assert.deepEqual(download.rawPayload, bytes);
assert.equal((await f.call(a, '/account/storage')).json().usedBytes, bytes.length);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
assert.equal(
(await f.call(b, `/access/${item.accessId}/favorite`, 'PATCH', { favorite: true }))
.statusCode,
410,
);
assert.equal((await f.call(b, '/account/storage')).json().usedBytes, 0);
} finally {
await f.close();
}
});
test('로그인 수신 파일은 계정에 기록하되 보내는 익명 파일 소유권을 옮기지 않음', async () => {
const f = await accountFixture();
try {
const a = await register(f, 'alice@example.com'),
b = await register(f, 'bob@example.com');
await f.space(a, b);
const bytes = Buffer.from('move my file');
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: bytes.length,
count: 1,
files: [{ name: 'account.txt', size: bytes.length, type: 'text/plain' }],
},
randomUUID(),
);
const id = r.json().requestId;
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
assert.equal((await fileUpload(f, a, id, bytes)).statusCode, 200);
const item = (await f.call(a, '/device/items')).json().items[0];
assert.equal(
(await f.call(a, `/access/${item.accessId}/save-to-account`, 'POST', {})).statusCode,
410,
);
assert.equal(
f.service.storage.fileRows(id)[0]!.ownerGuestId,
f.service.deviceGuest(a.deviceId)!.guestId,
);
const received = (await f.call(b, '/inbox')).json().items[0];
assert.deepEqual((await f.call(b, `/access/${received.accessId}/download`)).rawPayload, bytes);
} finally {
await f.close();
}
});

View File

@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRefreshQueue } from '../../web/src/refreshQueue.js';
test('조회 중 들어온 수락 이벤트를 버리지 않고 최신 상태를 다시 읽는다', async () => {
let status = 'pending',
seen: string[] = [];
let release!: () => void, started!: () => void;
const firstStarted = new Promise<void>((resolve) => {
started = resolve;
});
const pause = new Promise<void>((resolve) => {
release = resolve;
});
const refresh = createRefreshQueue(async () => {
const snapshot = status;
if (!seen.length) {
started();
await pause;
}
seen.push(snapshot);
});
const initial = refresh();
await firstStarted;
status = 'accepted';
const update = refresh();
refresh();
release();
await Promise.all([initial, update]);
assert.deepEqual(seen, ['pending', 'accepted']);
});
test('실패한 조회 다음 요청은 정상 재시도', async () => {
let calls = 0;
const refresh = createRefreshQueue(async () => {
if (++calls === 1) throw Error('network');
});
await assert.rejects(refresh(), /network/);
await refresh();
assert.equal(calls, 2);
});

View File

@@ -0,0 +1,66 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import Database from 'better-sqlite3';
import { readMigrationFiles } from 'drizzle-orm/migrator';
import { openDatabase } from '../src/db/index.js';
test('기존 30일·즐겨찾기 자료는 ID·본문·권한을 유지하며 변경 시점부터 최대 7일로 제한', async () => {
const folder = await mkdtemp(join(tmpdir(), 'hop-retention-'));
try {
const path = join(folder, 'existing.sqlite'),
old = new Database(path);
const migrations = readMigrationFiles({
migrationsFolder: join(import.meta.dirname, '../drizzle'),
});
old.exec(
'CREATE TABLE __drizzle_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, created_at INTEGER)',
);
for (const migration of migrations.slice(0, 8)) {
for (const statement of migration.sql) old.exec(statement);
old
.prepare('INSERT INTO __drizzle_migrations(hash,created_at) VALUES (?,?)')
.run(migration.hash, migration.folderMillis);
}
const now = Date.now();
for (const id of ['a', 'b'])
old
.prepare('INSERT INTO guest_sessions(id,tokenHash,expiresAt,createdAt) VALUES (?,?,?,?)')
.run(id, id, now + 30 * 86400000, now);
old
.prepare(
'INSERT INTO transfer_requests(id,senderGuestId,recipientGuestId,kind,byteSize,count,status,createdAt,expiresAt) VALUES (?,?,?,?,?,?,?,?,?)',
)
.run('request', 'a', 'b', 'url', 1, 1, 'accepted', now, now + 60000);
old
.prepare('INSERT INTO transfer_batches(id,requestId,status) VALUES (?,?,?)')
.run('batch', 'request', 'stored');
old
.prepare('INSERT INTO items(id,batchId,kind,url,createdAt,expiresAt) VALUES (?,?,?,?,?,?)')
.run('item', 'batch', 'url', 'https://example.com/existing', now, now + 30 * 86400000);
old
.prepare('INSERT INTO item_access(id,itemId,guestId,role,favorite) VALUES (?,?,?,?,?)')
.run('access', 'item', 'b', 'recipient', 1);
old.close();
const migrated = openDatabase(path);
try {
const item = migrated.sqlite.prepare('SELECT * FROM items WHERE id=?').get('item');
assert.equal(item.url, 'https://example.com/existing');
assert(
item.expiresAt <= Date.now() + 7 * 86400000 && item.expiresAt >= now + 7 * 86400000 - 1500,
);
const access = migrated.sqlite.prepare('SELECT * FROM item_access WHERE id=?').get('access');
assert.equal(access.guestId, 'b');
assert.equal(access.favorite, 0);
assert.equal(access.note, null);
assert.equal(migrated.sqlite.pragma('foreign_key_check').length, 0);
assert.equal(migrated.sqlite.pragma('integrity_check', { simple: true }), 'ok');
} finally {
migrated.sqlite.close();
}
} finally {
await rm(folder, { recursive: true, force: true });
}
});

View File

@@ -0,0 +1,90 @@
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';
test('주변 발견은 방 참여자를 제외하고 한 기기는 한 방만 참여, 방 종료 후 모두 주변으로 복귀', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('8.8.8.8');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2);
const pending = (await f.request(a, b)).json().requestId;
const room = (await f.call(a, '/spaces', 'POST', {})).json();
assert.equal(
f.service.db.select().from(s.requests).where(eq(s.requests.id, pending)).get().status,
'cancelled',
);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 0);
assert.equal(
(await f.call(b, '/discovery/devices'))
.json()
.devices.some((device) => device.id === a.deviceId),
false,
);
assert.equal((await f.request(b, a)).statusCode, 403);
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: room.code })).statusCode, 200);
assert.equal((await f.call(b, '/discovery/devices')).json().devices.length, 1);
assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 409);
const other = (await f.call(c, '/spaces', 'POST', {})).json();
assert.equal((await f.call(b, '/spaces/join', 'POST', { code: other.code })).statusCode, 409);
assert.throws(
() =>
f.service.db
.insert(s.members)
.values({
id: randomUUID(),
spaceId: other.space.id,
deviceId: b.deviceId,
status: 'approved',
joinedAt: Date.now(),
})
.run(),
/UNIQUE/,
);
assert.equal((await f.call(a, `/spaces/${room.space.id}`, 'DELETE')).statusCode, 200);
assert.equal((await f.call(b, '/spaces')).json().spaces.length, 0);
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 1);
await f.call(c, `/spaces/${other.space.id}`, 'DELETE');
assert.equal((await f.call(a, '/discovery/devices')).json().devices.length, 2);
assert.equal((await f.call(b, '/spaces', 'POST', {})).statusCode, 200);
} finally {
await f.close();
}
});
test('승인 대기 참여자도 주변에서 숨고 방장이 승인한 뒤에만 같은 방 전송 가능', async () => {
const f = await fixture(true);
try {
const a = await f.create('8.8.8.8'),
b = await f.create('8.8.8.8'),
c = await f.create('8.8.8.8');
const room = await f.space(a, b, true);
assert.equal((await f.call(c, '/discovery/devices')).json().devices.length, 0);
assert.equal((await f.request(a, b)).statusCode, 403);
await f.call(a, `/spaces/${room.space.id}/members/${b.deviceId}/approve`, 'POST', {});
const request = await f.request(a, b);
assert.equal(request.statusCode, 200);
await f.call(
b,
`/transfer-requests/${request.json().requestId}/accept`,
'POST',
{},
randomUUID(),
);
await f.call(b, `/spaces/${room.space.id}/members/${b.deviceId}`, 'DELETE');
assert.equal(
f.service.db
.select()
.from(s.requests)
.where(eq(s.requests.id, request.json().requestId))
.get().status,
'cancelled',
);
} finally {
await f.close();
}
});

View 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();
}
});