Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
149
apps/server/test/helpers.ts
Normal file
149
apps/server/test/helpers.ts
Normal 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 };
|
||||
}
|
||||
66
apps/server/test/mail.test.ts
Normal file
66
apps/server/test/mail.test.ts
Normal 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'),
|
||||
);
|
||||
}
|
||||
});
|
||||
74
apps/server/test/migrations.test.ts
Normal file
74
apps/server/test/migrations.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
557
apps/server/test/phase1.test.ts
Normal file
557
apps/server/test/phase1.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
411
apps/server/test/phase2.test.ts
Normal file
411
apps/server/test/phase2.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
470
apps/server/test/phase3.test.ts
Normal file
470
apps/server/test/phase3.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
40
apps/server/test/refresh-queue.test.ts
Normal file
40
apps/server/test/refresh-queue.test.ts
Normal 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);
|
||||
});
|
||||
66
apps/server/test/retention-migration.test.js
Normal file
66
apps/server/test/retention-migration.test.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
90
apps/server/test/rooms.test.js
Normal file
90
apps/server/test/rooms.test.js
Normal 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();
|
||||
}
|
||||
});
|
||||
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