283 lines
11 KiB
JavaScript
283 lines
11 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import argon2 from 'argon2';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { eq } from 'drizzle-orm';
|
|
import { fixture } from './helpers.js';
|
|
import * as s from '../src/db/schema.js';
|
|
import { hash } from '../src/security.js';
|
|
|
|
const password = '12345678';
|
|
async function setup() {
|
|
const mails = [];
|
|
const f = await fixture(false, async (mail) => mails.push(mail));
|
|
f.cfg.registrationMode = 'open';
|
|
f.cfg.sessionRate = 60;
|
|
return { ...f, mails };
|
|
}
|
|
function useCookie(client, response) {
|
|
const cookie = String(response.headers['set-cookie']).split(';')[0];
|
|
assert(cookie.startsWith('hop_account='));
|
|
client.cookie = client.cookie.replace(/;\s*hop_account=[^;]*/g, '') + '; ' + cookie;
|
|
return cookie.slice('hop_account='.length);
|
|
}
|
|
async function register(f, email) {
|
|
const client = await f.create();
|
|
const response = await f.call(client, '/auth/register', 'POST', {
|
|
email,
|
|
password,
|
|
passwordConfirmation: password,
|
|
displayName: email.split('@')[0],
|
|
});
|
|
assert.equal(response.statusCode, 200, response.body);
|
|
const token = f.mails.find((mail) => mail.to === email).text.match(/#verify=([A-Za-z0-9_-]+)/)[1];
|
|
return { ...client, token, email };
|
|
}
|
|
async function verify(f, client, token = client.token) {
|
|
const response = await f.call(client, '/auth/verify-email', 'POST', { token });
|
|
assert.equal(response.statusCode, 200, response.body);
|
|
useCookie(client, response);
|
|
client.socket.disconnect();
|
|
await f.connect(client);
|
|
return response;
|
|
}
|
|
|
|
test('가입 7자 거절·8자 허용, 기존 짧은 비밀번호 로그인은 유지', async () => {
|
|
const f = await setup();
|
|
try {
|
|
const client = await f.create();
|
|
const input = {
|
|
email: 'length@hop.test',
|
|
password: '1234567',
|
|
passwordConfirmation: '1234567',
|
|
displayName: 'length',
|
|
};
|
|
const rejected = await f.call(client, '/auth/register', 'POST', input);
|
|
assert.equal(rejected.statusCode, 400, rejected.body);
|
|
assert.equal(rejected.json().code, 'PASSWORD_INVALID');
|
|
assert.equal(f.mails.length, 0);
|
|
assert.equal(f.service.db.select().from(s.users).all().length, 0);
|
|
const accepted = await f.call(client, '/auth/register', 'POST', {
|
|
...input,
|
|
password,
|
|
passwordConfirmation: password,
|
|
});
|
|
assert.equal(accepted.statusCode, 200, accepted.body);
|
|
assert.equal(f.mails.length, 1);
|
|
const id = randomUUID();
|
|
f.service.db
|
|
.insert(s.users)
|
|
.values({
|
|
id,
|
|
email: 'legacy@hop.test',
|
|
displayName: 'legacy',
|
|
passwordHash: await argon2.hash('1234'),
|
|
status: 'active',
|
|
verifiedAt: Date.now(),
|
|
createdAt: Date.now(),
|
|
})
|
|
.run();
|
|
assert.equal(
|
|
(await f.call(client, '/auth/login', 'POST', { email: 'legacy@hop.test', password: '1234' }))
|
|
.statusCode,
|
|
200,
|
|
);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
|
|
for (const secure of [false, true]) {
|
|
test(`인증 완료 후 비밀번호 재입력 없이 로그인·쿠키 보호·기기 유지 (Secure=${secure})`, async () => {
|
|
const f = await setup();
|
|
try {
|
|
const a = await register(f, 'verify@hop.test');
|
|
const device = (await f.call(a, '/device/me')).json().device;
|
|
assert.equal((await f.call(a, '/auth/me')).json().account, null);
|
|
f.cfg.production = secure;
|
|
const response = await verify(f, a);
|
|
assert.equal(response.json().account.email, a.email);
|
|
assert.equal(response.json().account.verified, true);
|
|
assert.equal(response.json().token, undefined);
|
|
const header = String(response.headers['set-cookie']);
|
|
assert.match(header, /HttpOnly/);
|
|
assert.match(header, /SameSite=Lax/);
|
|
assert.equal(/; Secure(?:;|$)/.test(header), secure);
|
|
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
|
|
assert.equal((await f.call(a, '/inbox')).statusCode, 200);
|
|
assert.equal((await f.call(a, '/device/me')).json().device.id, device.id);
|
|
const sessions = f.service.db.select().from(s.accountSessions).all();
|
|
assert.equal(sessions.length, 1);
|
|
assert.equal(sessions[0].tokenHash === a.cookie.split('hop_account=')[1], false);
|
|
assert(sessions[0].expiresAt - sessions[0].createdAt <= 30 * 86400000);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
test('다른 계정 인증 시 현재 세션·업로드·소켓 종료, 다른 기기 세션과 기기 ID 유지·보관함 격리', async () => {
|
|
const f = await setup();
|
|
try {
|
|
const a = await register(f, 'previous@hop.test');
|
|
await verify(f, a);
|
|
const previousToken = a.cookie.split('hop_account=')[1];
|
|
const previous = f.service.auth.session(previousToken);
|
|
const oldClient = { ...a };
|
|
const own = await f.call(
|
|
a,
|
|
'/vault',
|
|
'POST',
|
|
{ kind: 'url', urls: ['https://example.com/previous-private'] },
|
|
randomUUID(),
|
|
);
|
|
assert.equal(own.statusCode, 200, own.body);
|
|
const second = await f.create();
|
|
const anotherLogin = await f.call(second, '/auth/login', 'POST', { email: a.email, password });
|
|
useCookie(second, anotherLogin);
|
|
const b = await register(f, 'next@hop.test');
|
|
let aborted;
|
|
const abort = f.service.storage.abortSession.bind(f.service.storage);
|
|
f.service.storage.abortSession = (id) => {
|
|
aborted = id;
|
|
abort(id);
|
|
};
|
|
const revoked = new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(Error('session notification missing')), 2000);
|
|
a.socket.once('account:revoked', () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
});
|
|
const switched = await f.call(a, '/auth/verify-email', 'POST', { token: b.token });
|
|
assert.equal(switched.statusCode, 200, switched.body);
|
|
await revoked;
|
|
const serverSocket = f.service.io.sockets.sockets.get(a.socket.id);
|
|
assert.equal(serverSocket.data.accountSessionId, undefined);
|
|
assert.equal(serverSocket.rooms.has(`account:${previous.user.id}`), false);
|
|
assert.equal(aborted, previous.session.id);
|
|
useCookie(a, switched);
|
|
assert.equal((await f.call(a, '/auth/me')).json().account.email, b.email);
|
|
assert.equal((await f.call(oldClient, '/auth/me')).json().account, null);
|
|
assert.equal((await f.call(oldClient, '/vault')).statusCode, 401);
|
|
assert.equal((await f.call(a, '/vault')).json().items.length, 0);
|
|
assert.equal((await f.call(second, '/auth/me')).json().account.email, 'previous@hop.test');
|
|
assert.equal(
|
|
(await f.call(second, '/vault')).json().items[0].url,
|
|
'https://example.com/previous-private',
|
|
);
|
|
assert.equal((await f.call(a, '/device/me')).json().device.id, a.deviceId);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
|
|
for (const invalid of ['expired', 'suspended', 'deleted', 'unknown']) {
|
|
test(`유효하지 않은 인증(${invalid})은 기존 로그인과 인증 토큰 상태를 변경하지 않음`, async () => {
|
|
const f = await setup();
|
|
try {
|
|
const a = await register(f, 'old@hop.test');
|
|
await verify(f, a);
|
|
const b = await register(f, 'invalid@hop.test');
|
|
const user = f.service.db.select().from(s.users).where(eq(s.users.email, b.email)).get();
|
|
const tokenRow = f.service.db
|
|
.select()
|
|
.from(s.accountTokens)
|
|
.where(eq(s.accountTokens.tokenHash, hash(b.token)))
|
|
.get();
|
|
if (invalid === 'expired')
|
|
f.service.db
|
|
.update(s.accountTokens)
|
|
.set({ expiresAt: Date.now() - 1 })
|
|
.where(eq(s.accountTokens.id, tokenRow.id))
|
|
.run();
|
|
if (['suspended', 'deleted'].includes(invalid))
|
|
f.service.db.update(s.users).set({ status: invalid }).where(eq(s.users.id, user.id)).run();
|
|
const response = await f.call(a, '/auth/verify-email', 'POST', {
|
|
token: invalid === 'unknown' ? 'A'.repeat(43) : b.token,
|
|
});
|
|
assert.equal(response.statusCode, 400, response.body);
|
|
assert.equal(response.headers['set-cookie'], undefined);
|
|
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
|
|
assert.equal(
|
|
f.service.db.select().from(s.accountTokens).where(eq(s.accountTokens.id, tokenRow.id)).get()
|
|
.usedAt,
|
|
null,
|
|
);
|
|
assert.equal(
|
|
f.service.db
|
|
.select()
|
|
.from(s.accountSessions)
|
|
.where(eq(s.accountSessions.userId, user.id))
|
|
.all().length,
|
|
0,
|
|
);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
test('동시·재사용 인증은 한 번만 로그인하고 기존 로그인 쿠키를 덮어쓰지 않음', async () => {
|
|
const f = await setup();
|
|
try {
|
|
const a = await register(f, 'once@hop.test');
|
|
const responses = await Promise.all([
|
|
f.call(a, '/auth/verify-email', 'POST', { token: a.token }),
|
|
f.call(a, '/auth/verify-email', 'POST', { token: a.token }),
|
|
]);
|
|
assert.deepEqual(responses.map((r) => r.statusCode).sort(), [200, 400]);
|
|
const successful = responses.find((r) => r.statusCode === 200);
|
|
useCookie(a, successful);
|
|
assert.equal(f.service.db.select().from(s.accountSessions).all().length, 1);
|
|
const replay = await f.call(a, '/auth/verify-email', 'POST', { token: a.token });
|
|
assert.equal(replay.statusCode, 400);
|
|
assert.equal(replay.headers['set-cookie'], undefined);
|
|
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|
|
|
|
test('세션 발급 실패 시 인증·이전 로그아웃·토큰 소비 모두 롤백하고 같은 링크로 재시도 가능', async () => {
|
|
const f = await setup();
|
|
try {
|
|
const a = await register(f, 'keep@hop.test');
|
|
await verify(f, a);
|
|
const b = await register(f, 'retry@hop.test');
|
|
const original = f.service.auth.createSession.bind(f.service.auth);
|
|
f.service.auth.createSession = (...args) => {
|
|
original(...args);
|
|
throw Error('test issuance failure');
|
|
};
|
|
const failed = await f.call(a, '/auth/verify-email', 'POST', { token: b.token });
|
|
assert.equal(failed.statusCode, 500);
|
|
assert.equal(failed.headers['set-cookie'], undefined);
|
|
assert.equal((await f.call(a, '/auth/me')).json().account.email, a.email);
|
|
const pending = f.service.db.select().from(s.users).where(eq(s.users.email, b.email)).get();
|
|
assert.equal(pending.status, 'pending');
|
|
assert.equal(pending.verifiedAt, null);
|
|
assert.equal(
|
|
f.service.db
|
|
.select()
|
|
.from(s.accountSessions)
|
|
.where(eq(s.accountSessions.userId, pending.id))
|
|
.all().length,
|
|
0,
|
|
);
|
|
assert.equal(
|
|
f.service.db
|
|
.select()
|
|
.from(s.accountTokens)
|
|
.where(eq(s.accountTokens.tokenHash, hash(b.token)))
|
|
.get().usedAt,
|
|
null,
|
|
);
|
|
f.service.auth.createSession = original;
|
|
await verify(f, a, b.token);
|
|
assert.equal((await f.call(a, '/auth/me')).json().account.email, b.email);
|
|
} finally {
|
|
await f.close();
|
|
}
|
|
});
|