Files
uniare.sori.studio/api/test/account-deletion.test.js

313 lines
12 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import bcrypt from 'bcryptjs';
import express from 'express';
import { initDb } from '../src/db.js';
import { createAdminRouter } from '../src/routes/admin.js';
import { createAuthRouter } from '../src/routes/auth.js';
import { deleteUserAccount } from '../src/services/accountDeletion.js';
test('삭제한 계정의 사용자명을 새 계정이 사용해도 기존 정보가 연결되지 않는다', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-account-delete-'));
const dbPath = path.join(tempDir, 'test.db');
process.env.AVATARS_PATH = path.join(tempDir, 'avatars');
const db = initDb(dbPath);
try {
const oldUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('old@example.com', 'test', 'old-password-hash', 'test').lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('OLDDECK1', 'user', ?, 'ua01', '테스트', '기존 덱', '[]', 1)
`).run(oldUserId);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(oldUserId);
const referenceUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'reference@example.com',
'reference',
'reference-password-hash',
'reference',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO deck_favorites (user_id, share_code)
VALUES (?, 'OLDDECK1')
`).run(referenceUserId);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'old-token', datetime('now', '+1 day'))
`).run(oldUserId);
db.prepare(`
INSERT INTO email_send_logs (user_id, type, month_key, status)
VALUES (?, 'verify_email', '2026-06', 'sent')
`).run(oldUserId);
const deletionResult = deleteUserAccount(db, oldUserId);
const newUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run('new@example.com', 'test', 'new-password-hash', 'test').lastInsertRowid,
);
const oldDeck = db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'OLDDECK1'
`).get();
assert.equal(deletionResult.deleted, true);
assert.equal(deletionResult.retainedDeckCount, 1);
assert.ok(newUserId > oldUserId);
assert.equal(oldDeck.userId, null);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM deck_favorites WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM deck_favorites
WHERE user_id = ? AND share_code = 'OLDDECK1'
`).get(referenceUserId).count,
1,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM auth_tokens WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM email_send_logs WHERE user_id = ?').get(oldUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count FROM decks
WHERE share_code = 'OLDDECK1' AND user_id = ?
`).get(newUserId).count,
0,
);
} finally {
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('이메일 승인·본인 탈퇴·관리자 탈퇴·덱 일괄 삭제 API가 동작한다', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uniare-admin-verify-'));
const dbPath = path.join(tempDir, 'test.db');
const db = initDb(dbPath);
process.env.JWT_SECRET = 'test-secret-for-admin-email-verification';
const adminPassword = 'admin-password';
const adminUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, role, email_verified_at
)
VALUES (?, ?, ?, ?, 'admin', datetime('now'))
`).run(
'admin@example.com',
'admin',
bcrypt.hashSync(adminPassword, 4),
'admin',
).lastInsertRowid,
);
const targetUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, NULL)
`).run(
'pending@example.com',
'pending',
bcrypt.hashSync('pending-password', 4),
'pending',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO auth_tokens (user_id, type, token_hash, expires_at)
VALUES (?, 'verify_email', 'pending-token', datetime('now', '+1 day'))
`).run(targetUserId);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES ('PENDING1', 'user', ?, 'ua01', '테스트', '탈퇴 후 보존 덱', '[]', 1)
`).run(targetUserId);
const forcedUserId = Number(
db.prepare(`
INSERT INTO users (
email, username, password_hash, display_name, email_verified_at
)
VALUES (?, ?, ?, ?, datetime('now'))
`).run(
'forced@example.com',
'forced',
bcrypt.hashSync('forced-password', 4),
'forced',
).lastInsertRowid,
);
db.prepare(`
INSERT INTO decks (
share_code, share_type, user_id, series, series_name, name, cards_json, is_public
)
VALUES
('BULKDEL1', 'user', ?, 'ua01', '테스트', '일괄 삭제 1', '[]', 1),
('BULKDEL2', 'user', ?, 'ua01', '테스트', '일괄 삭제 2', '[]', 1)
`).run(adminUserId, adminUserId);
const app = express();
app.use(express.json());
app.use('/api/v1/auth', createAuthRouter(db));
app.use('/api/v1/admin', createAdminRouter(db));
const server = app.listen(0);
try {
await new Promise((resolve) => server.once('listening', resolve));
const address = server.address();
const baseUrl = `http://127.0.0.1:${address.port}/api/v1`;
const loginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'admin@example.com',
password: adminPassword,
}),
});
assert.equal(loginResponse.status, 200);
const { token } = await loginResponse.json();
const adminSelfDeleteResponse = await fetch(`${baseUrl}/admin/users/${adminUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminSelfDeleteResponse.status, 400);
const approveResponse = await fetch(
`${baseUrl}/admin/users/${targetUserId}/verify-email`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
},
);
assert.equal(approveResponse.status, 200);
const target = db.prepare(`
SELECT email_verified_at AS emailVerifiedAt
FROM users WHERE id = ?
`).get(targetUserId);
const pendingToken = db.prepare(`
SELECT used_at AS usedAt
FROM auth_tokens WHERE user_id = ? AND type = 'verify_email'
`).get(targetUserId);
assert.ok(target.emailVerifiedAt);
assert.ok(pendingToken.usedAt);
const pendingLoginResponse = await fetch(`${baseUrl}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'pending@example.com',
password: 'pending-password',
}),
});
assert.equal(pendingLoginResponse.status, 200);
const { token: pendingUserToken } = await pendingLoginResponse.json();
const invalidSelfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'delete' }),
});
assert.equal(invalidSelfDeleteResponse.status, 400);
const selfDeleteResponse = await fetch(`${baseUrl}/auth/me`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${pendingUserToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(selfDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(targetUserId).count,
0,
);
assert.equal(
db.prepare(`
SELECT user_id AS userId FROM decks WHERE share_code = 'PENDING1'
`).get().userId,
null,
);
const adminDeleteResponse = await fetch(`${baseUrl}/admin/users/${forcedUserId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ confirmation: 'DELETE' }),
});
assert.equal(adminDeleteResponse.status, 200);
assert.equal(
db.prepare('SELECT COUNT(*) AS count FROM users WHERE id = ?').get(forcedUserId).count,
0,
);
const bulkDeleteResponse = await fetch(`${baseUrl}/admin/decks/bulk-delete`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ shareCodes: ['BULKDEL1', 'BULKDEL2'] }),
});
assert.equal(bulkDeleteResponse.status, 200);
const bulkDeleteResult = await bulkDeleteResponse.json();
assert.equal(bulkDeleteResult.deletedCount, 2);
assert.equal(
db.prepare(`
SELECT COUNT(*) AS count
FROM decks WHERE share_code IN ('BULKDEL1', 'BULKDEL2')
`).get().count,
0,
);
} finally {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
db.close();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});