471 lines
17 KiB
TypeScript
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();
|
|
}
|
|
});
|