Files
hop.noname.company/apps/server/test/admin-management.test.js

350 lines
12 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { auditChangeLines, formatCountry } from '../../web/src/displayFormat.js';
async function account(f, email) {
const c = await f.create();
f.service.db
.insert(s.users)
.values({
id: randomUUID(),
email,
displayName: email,
passwordHash: await f.service.auth.password('12345678'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
const response = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(response.statusCode, 200, response.body);
c.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
return { ...c, userId: response.json().account.id, email };
}
const h = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR', 'cf-ipcity': 'Daejeon' };
test(
'audit shows only differences, skips no-op saves, compares compatible old entries and supports individual/all deletion with authorization',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const admin = await account(f, 'hello@noname.company'),
user = await account(f, 'user@hop.test');
let response = await f.call(admin, '/admin/settings', 'PATCH', {
revision: 0,
maxConcurrentUploads: 5,
});
assert.equal(response.statusCode, 200);
response = await f.call(admin, '/admin/settings', 'PATCH', {
revision: 1,
maxConcurrentUploads: 10,
blockedCountries: ['CN'],
});
assert.equal(response.statusCode, 200);
const events = (await f.call(admin, '/admin/audit')).json().events;
const latest = events.find((e) => e.details.changes?.some((c) => c.after === 10));
assert.deepEqual(latest.details.changes, [
{ field: 'maxConcurrentUploads', before: 5, after: 10 },
{ field: 'blockedCountries', added: ['CN'], removed: [] },
]);
assert.deepEqual(latest.details.changes.flatMap(auditChangeLines), [
'동시에 처리할 업로드 수 5 > 10',
'블랙리스트 추가: 중국(CN)',
]);
const count = events.length;
await f.call(admin, '/admin/settings', 'PATCH', { revision: 2, maxConcurrentUploads: 10 });
assert.equal((await f.call(admin, '/admin/audit')).json().events.length, count);
assert.equal(
(await f.call(user, '/admin/audit/remove', 'POST', { all: true })).statusCode,
403,
);
assert.equal(
(await f.call(admin, '/admin/audit/remove', 'POST', { ids: [latest.id] })).statusCode,
200,
);
assert.equal(
(await f.call(admin, '/admin/audit')).json().events.some((e) => e.id === latest.id),
false,
);
const before = {
uploadsPaused: false,
maxConcurrentUploads: 5,
blockedCountries: [],
blockedIps: [],
totalStorageLimitMb: 20480,
};
for (const [offset, body] of [
[2000, before],
[1000, { ...before, maxConcurrentUploads: 6 }],
])
f.service.db
.insert(s.adminAudit)
.values({
id: randomUUID(),
actorUserId: admin.userId,
action: 'settings-change',
details: JSON.stringify(body),
createdAt: Date.now() - offset,
})
.run();
const old = (await f.call(admin, '/admin/audit'))
.json()
.events.find((e) => e.details.legacy && e.details.changes.length);
assert.deepEqual(old.details.changes, [
{ field: 'maxConcurrentUploads', before: 5, after: 6 },
]);
assert.equal(
(await f.call(admin, '/admin/audit/remove', 'POST', { all: true })).statusCode,
200,
);
assert.deepEqual((await f.call(admin, '/admin/audit')).json().events, []);
assert.equal(
f.service.admin.state.maxConcurrentUploads,
10,
'deleting audit entries never changes settings',
);
assert.equal(formatCountry('JP'), '일본(JP)');
} finally {
await f.close();
}
},
);
test(
'user list keeps basic last-seen country/IP with throttled updates, expires location data and never exposes credentials',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const admin = await account(f, 'hello@noname.company'),
user = await account(f, 'user@hop.test'),
guest = await f.create();
f.cfg.clientIpSource = 'cloudflare';
await f.call(user, '/auth/me', 'GET', undefined, undefined, h);
const first = f.service.db
.select()
.from(s.userActivity)
.where(eq(s.userActivity.userId, user.userId))
.get();
await f.call(user, '/auth/me', 'GET', undefined, undefined, h);
assert.equal(
f.service.db
.select()
.from(s.userActivity)
.where(eq(s.userActivity.userId, user.userId))
.get().lastSeenAt,
first.lastSeenAt,
);
const response = await f.call(
admin,
'/admin/users?q=user@hop.test',
'GET',
undefined,
undefined,
h,
),
list = response.json().users;
assert.equal(list.length, 1);
assert.equal(list[0].email, user.email);
assert.equal(list[0].countryCode, 'KR');
assert.equal(list[0].city, 'Daejeon');
assert.equal(list[0].ip, '8.8.4.4');
assert.equal(response.body.includes('passwordHash'), false);
assert.equal(response.body.includes('tokenHash'), false);
assert.equal((await f.call(user, '/admin/users')).statusCode, 403);
assert.equal((await f.call(guest, '/admin/users')).statusCode, 401);
f.service.db
.update(s.userActivity)
.set({ lastSeenAt: Date.now() - 31 * 86400000 })
.where(eq(s.userActivity.userId, user.userId))
.run();
const expired = (
await f.call(admin, '/admin/users?q=user@hop.test', 'GET', undefined, undefined, h)
).json().users[0];
assert.equal(expired.ip, null);
assert.equal(expired.countryCode, null);
assert.ok(expired.lastSeenAt);
f.service.admin.cleanup();
assert.equal(
f.service.db
.select()
.from(s.userActivity)
.where(eq(s.userActivity.userId, user.userId))
.get().city,
null,
);
} finally {
await f.close();
}
},
);
test(
'admin reset sends a one-use email link; password and sessions change only when the user completes it',
{ timeout: 20000 },
async () => {
const mail = [];
const f = await fixture(false, async (message) => mail.push(message));
try {
const admin = await account(f, 'hello@noname.company'),
user = await account(f, 'user@hop.test');
assert.equal(
(await f.call(user, `/admin/users/${admin.userId}/password-reset`, 'POST', {})).statusCode,
403,
);
assert.equal(
(await f.call(admin, `/admin/users/${user.userId}/password-reset`, 'POST', {})).statusCode,
200,
);
assert.equal(mail.length, 1);
assert.equal(mail[0].to, user.email);
assert.ok((await f.call(user, '/auth/me')).json().account);
const token = /#reset-password=([^\s]+)/.exec(mail[0].text)[1];
const reset = await f.call(user, '/auth/reset-password', 'POST', {
token,
password: 'new-password',
passwordConfirmation: 'new-password',
});
assert.equal(reset.statusCode, 200, reset.body);
assert.equal((await f.call(user, '/auth/me')).json().account, null);
const guest = await f.create();
assert.equal(
(await f.call(guest, '/auth/login', 'POST', { email: user.email, password: '12345678' }))
.statusCode,
401,
);
assert.equal(
(
await f.call(guest, '/auth/login', 'POST', {
email: user.email,
password: 'new-password',
})
).statusCode,
200,
);
assert.equal(
(
await f.call(guest, '/auth/reset-password', 'POST', {
token,
password: 'again-password',
passwordConfirmation: 'again-password',
})
).statusCode,
400,
);
} finally {
await f.close();
}
},
);
test(
'admin deletion confirms email, protects the administrator, revokes credentials and preserves shared recipient files',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const admin = await account(f, 'hello@noname.company'),
user = await account(f, 'user@hop.test'),
recipient = await f.create();
const created = await f.call(
user,
'/vault',
'POST',
{ kind: 'file', files: [{ name: 'shared.txt', size: 4, type: 'text/plain' }] },
randomUUID(),
);
assert.equal(created.statusCode, 200, created.body);
const id = created.json().requestId,
payload = Buffer.from(
'--delete\r\nContent-Disposition: form-data; name="file"; filename="shared.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--delete--\r\n',
);
const uploaded = await f.call(
user,
`/uploads?requestId=${id}&fileIndex=0`,
'POST',
payload,
randomUUID(),
{ 'content-type': 'multipart/form-data; boundary=delete' },
);
assert.equal(uploaded.statusCode, 200, uploaded.body);
const file = f.service.storage.fileRows(id)[0],
item = f.service.db.select().from(s.items).where(eq(s.items.fileId, file.id)).get(),
accessId = randomUUID();
f.service.db
.insert(s.access)
.values({
id: accessId,
itemId: item.id,
guestId: f.service.deviceGuest(recipient.deviceId).guestId,
role: 'recipient',
read: false,
})
.run();
assert.equal(
(await f.call(admin, `/admin/users/${admin.userId}/delete`, 'POST', { email: admin.email }))
.statusCode,
403,
);
assert.equal(
(
await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', {
email: 'wrong@hop.test',
})
).statusCode,
409,
);
assert.equal(
(await f.call(user, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email }))
.statusCode,
403,
);
assert.equal(
(await f.call(admin, `/admin/users/${user.userId}/delete`, 'POST', { email: user.email }))
.statusCode,
200,
);
const deleted = f.service.auth.user(user.userId);
assert.equal(deleted.status, 'deleted');
assert.equal(deleted.email, null);
assert.equal(deleted.passwordHash, null);
assert.equal((await f.call(user, '/auth/me')).json().account, null);
assert.equal(
(await f.call(admin, '/admin/users')).json().users.some((u) => u.id === user.userId),
false,
);
const download = await f.call(recipient, `/access/${accessId}/download`);
assert.equal(download.statusCode, 200, download.body);
assert.equal(download.body, 'test');
assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'account-delete');
} finally {
await f.close();
}
},
);
test(
'account deletion during password verification cannot issue a usable new login session',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const user = await account(f, 'race@hop.test');
const login = f.service.auth.login(user.email, '12345678', '8.8.8.8');
f.service.db
.update(s.users)
.set({ status: 'deleted', email: null, passwordHash: null })
.where(eq(s.users.id, user.userId))
.run();
await assert.rejects(login, (error) => error.code === 'LOGIN_FAILED');
} finally {
await f.close();
}
},
);