Files
hop.noname.company/apps/server/test/phase3.test.ts

471 lines
17 KiB
TypeScript

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