350 lines
12 KiB
JavaScript
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();
|
|
}
|
|
},
|
|
);
|