Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user