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

440 lines
16 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import { existsSync } from 'node:fs';
import { io } from 'socket.io-client';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { eq } from 'drizzle-orm';
import { Administration, adminPatch, matchesIp } from '../src/administration.js';
async function login(f, email = 'hello@noname.company', verified = true) {
const c = await f.create(),
id = randomUUID();
f.service.db
.insert(s.users)
.values({
id,
email,
displayName: email,
passwordHash: await f.service.auth.password('12345678'),
role: 'user',
status: verified ? 'active' : 'pending',
verifiedAt: verified ? Date.now() : null,
createdAt: Date.now(),
})
.run();
const response = await f.call(c, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(response.statusCode, 200);
c.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
return c;
}
async function patch(f, c, changes, headers = {}) {
return f.call(
c,
'/admin/settings',
'PATCH',
{ revision: f.service.admin.state.revision, ...changes },
undefined,
headers,
);
}
async function fileRequest(f, a, b, size = 4) {
const r = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: size,
count: 1,
files: [{ name: 'private.txt', size, type: 'text/plain' }],
},
randomUUID(),
);
assert.equal(r.statusCode, 200, r.body);
return r.json().requestId;
}
test('only the verified active hello account can view and mutate admin APIs; guest CSRF still applies', async () => {
const f = await fixture();
try {
const guest = await f.create(),
other = await login(f, 'other@hop.test'),
admin = await login(f),
caseAlias = await login(f, 'Hello@noname.company'),
pending = await login(f, 'pending@hop.test', false);
for (const path of ['/admin/summary', '/admin/files', '/admin/audit']) {
assert.equal((await f.call(null, path)).statusCode, 401);
assert.equal((await f.call(guest, path)).statusCode, 401);
assert.equal((await f.call(other, path)).statusCode, 403);
assert.equal((await f.call(caseAlias, path)).statusCode, 403);
assert.equal((await f.call(pending, path)).statusCode, 403);
assert.equal((await f.call(admin, path)).statusCode, 200);
}
assert.equal((await f.call(admin, '/auth/me')).json().account.administrator, true);
assert.equal((await f.call(other, '/auth/me')).json().account.administrator, false);
assert.equal((await f.call(caseAlias, '/auth/me')).json().account.administrator, false);
assert.equal((await patch(f, other, { uploadsPaused: true })).statusCode, 403);
assert.equal(
(await patch(f, admin, { uploadsPaused: true }, { 'x-csrf-token': 'bad' })).statusCode,
403,
);
assert.equal((await f.call(other, `/admin/files/${randomUUID()}`, 'DELETE')).statusCode, 403);
const state = f.service.admin.state;
assert.equal(state.uploadsPaused, false);
f.service.db
.update(s.users)
.set({ status: 'suspended' })
.where(eq(s.users.email, 'hello@noname.company'))
.run();
assert.equal((await f.call(admin, '/admin/summary')).statusCode, 401);
} finally {
await f.close();
}
});
test('unverified hello is not an administrator even with an admin role', async () => {
const f = await fixture();
try {
const c = await login(f, 'hello@noname.company', false);
f.service.db
.update(s.users)
.set({ role: 'admin' })
.where(eq(s.users.email, 'hello@noname.company'))
.run();
assert.equal((await f.call(c, '/auth/me')).json().account.administrator, false);
assert.equal((await f.call(c, '/admin/summary')).statusCode, 403);
} finally {
await f.close();
}
});
test('pause blocks new guest and personal file requests but keeps links and accepted uploads; settings and audit persist', async () => {
const f = await fixture();
try {
const admin = await login(f),
a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const r = await patch(f, admin, { uploadsPaused: true, maxConcurrentUploads: 3 });
assert.equal(r.statusCode, 200);
const body = Buffer.from(
'--boundary\r\nContent-Disposition: form-data; name="file"; filename="private.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--boundary--\r\n',
);
const uploaded = await f.call(
a,
`/uploads?requestId=${id}&fileIndex=0`,
'POST',
body,
randomUUID(),
{ 'content-type': 'multipart/form-data; boundary=boundary' },
);
assert.equal(uploaded.statusCode, 200, uploaded.body);
const denied = await f.call(
a,
'/transfer-requests',
'POST',
{
targetDeviceId: b.deviceId,
kind: 'file',
byteSize: 4,
count: 1,
files: [{ name: 'new.txt', size: 4, type: 'text/plain' }],
},
randomUUID(),
);
assert.equal(denied.json().code, 'UPLOADS_PAUSED');
const own = await f.call(
admin,
'/vault',
'POST',
{ kind: 'file', files: [{ name: 'own.txt', size: 4, type: 'text/plain' }] },
randomUUID(),
);
assert.equal(own.json().code, 'UPLOADS_PAUSED');
assert.equal((await f.request(a, b)).statusCode, 200);
assert.equal(new Administration(f.service).state.uploadsPaused, true);
assert.equal(new Administration(f.service).state.maxConcurrentUploads, 3);
assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'settings-change');
const stale = await f.call(admin, '/admin/settings', 'PATCH', {
revision: 0,
uploadsPaused: false,
});
assert.equal(stale.statusCode, 409);
} finally {
await f.close();
}
});
test('country and CIDR rules cover HTTP and WebSocket; untrusted location headers are ignored and self-block is rejected', async () => {
const f = await fixture();
let socket;
try {
const admin = await login(f),
guest = await f.create();
f.cfg.clientIpSource = 'cloudflare';
const actor = { 'cf-connecting-ip': '8.8.4.4', 'cf-ipcountry': 'KR' };
assert.equal(
(
await patch(
f,
admin,
{ blockedCountries: ['CN'], blockedIps: ['1.1.1.0/24', '2001:db8::/32'] },
actor,
)
).statusCode,
200,
);
assert.equal(matchesIp('2001:db8::123', f.service.admin.ipRules), true);
assert.equal(matchesIp('2001:db9::123', f.service.admin.ipRules), false);
assert.equal(matchesIp('::ffff:1.1.1.12', f.service.admin.ipRules), true);
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '8.8.8.8',
'cf-ipcountry': 'CN',
})
).json().code,
'ACCESS_BLOCKED',
);
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '1.1.1.99',
'cf-ipcountry': 'US',
})
).statusCode,
403,
);
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '8.8.8.8',
})
).statusCode,
200,
'unknown country is not guessed',
);
assert.equal(
(await patch(f, admin, { blockedCountries: ['KR'] }, actor)).json().code,
'ADMIN_SELF_BLOCK',
);
assert.equal(
(await patch(f, admin, { blockedIps: ['8.8.0.0/16'] }, actor)).json().code,
'ADMIN_SELF_BLOCK',
);
assert.equal((await patch(f, admin, { blockedIps: ['bad'] }, actor)).statusCode, 400);
assert.equal((await patch(f, admin, { maxConcurrentUploads: 100 }, actor)).statusCode, 400);
socket = io(f.url, {
transports: ['websocket'],
reconnection: false,
auth: { csrfToken: guest.csrf },
extraHeaders: {
Origin: f.cfg.appUrl,
Cookie: guest.cookie,
'CF-Connecting-IP': '8.8.8.8',
'CF-IPCountry': 'CN',
},
});
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(Error('blocked socket unexpectedly connected')), 2000);
socket.once('connect_error', () => {
clearTimeout(timer);
resolve();
});
socket.once('connect', () => reject(Error('blocked socket connected')));
});
f.cfg.trustedProxies = [];
assert.equal(
(
await f.call(guest, '/device/me', 'GET', undefined, undefined, {
'cf-connecting-ip': '1.1.1.99',
'cf-ipcountry': 'CN',
})
).statusCode,
200,
);
} finally {
socket?.disconnect();
await f.close();
}
});
test('a newly blocked IP aborts its admitted request while keeping its raw HTTP objects private', async () => {
const f = await fixture();
try {
const admin = await login(f);
const raw = new EventEmitter(),
response = new EventEmitter();
raw.socket = { remoteAddress: '127.0.0.1' };
let destroyed = 0;
raw.destroy = () => destroyed++;
response.destroy = () => destroyed++;
f.service.admin.admit(
{ raw, headers: { 'x-forwarded-for': '8.8.8.8' } },
{ raw: response },
'attacker',
);
const summary = f.service.admin.summary();
assert.equal(summary.requests[0].ip, '8.8.8.8');
assert.equal('raw' in summary.requests[0], false);
assert.equal((await patch(f, admin, { blockedIps: ['8.8.8.8'] })).statusCode, 200);
assert.equal(destroyed, 2);
assert.equal(f.service.admin.inflight.size, 0);
} finally {
await f.close();
}
});
test('a failed admin settings commit does not change the effective policy or audit ledger', async () => {
const f = await fixture();
try {
const admin = await login(f);
assert.equal((await patch(f, admin, { maxConcurrentUploads: 3 })).statusCode, 200);
f.sqlite.exec(
"CREATE TRIGGER reject_admin BEFORE UPDATE ON admin_settings BEGIN SELECT RAISE(ABORT, 'test settings failure'); END",
);
assert.equal((await patch(f, admin, { uploadsPaused: true })).statusCode, 500);
assert.equal(f.service.admin.state.uploadsPaused, false);
assert.equal(new Administration(f.service).state.uploadsPaused, false);
assert.equal(f.service.admin.events().length, 1);
} finally {
await f.close();
}
});
test('global quota includes existing reservations across different upload owners', async () => {
const f = await fixture();
try {
const admin = await login(f),
a = await f.create(),
b = await f.create(),
c = await f.create();
await f.space(a, b);
await f.space(a, c);
assert.equal((await patch(f, admin, { totalStorageLimitMb: 1024 })).statusCode, 200);
const one = await fileRequest(f, a, b, 700 * 1048576),
two = await fileRequest(f, c, b, 700 * 1048576);
assert.equal(
(await f.call(b, `/transfer-requests/${one}/accept`, 'POST', {}, randomUUID())).statusCode,
200,
);
const denied = await f.call(b, `/transfer-requests/${two}/accept`, 'POST', {}, randomUUID());
assert.equal(denied.statusCode, 507);
assert.equal(denied.json().code, 'STORAGE_QUOTA');
assert.equal((await f.call(admin, '/admin/summary')).json().reservedBytes, 700 * 1048576);
} finally {
await f.close();
}
});
test('body admission rejects extra uploads before parsing, releases slots on abort/response and enforces per-owner concurrency', async () => {
const f = await fixture();
try {
const c = await f.create();
f.service.admin.state.maxConcurrentUploads = 1;
const req = { raw: new EventEmitter(), headers: {} };
req.raw.socket = { remoteAddress: '127.0.0.1' };
const reply = { raw: new EventEmitter() };
const release = f.service.admin.admit(req, reply, 'owner');
const busy = await f.call(
c,
`/uploads/resumable/${randomUUID()}`,
'PUT',
Buffer.alloc(10),
undefined,
{ 'content-type': 'application/octet-stream', 'upload-offset': '0' },
);
assert.equal(busy.statusCode, 429);
assert.equal(busy.json().code, 'UPLOAD_BUSY');
assert.equal(Number(busy.headers['retry-after']), 1);
req.raw.emit('aborted');
release();
assert.equal(f.service.admin.inflight.size, 0);
const next = await f.call(
c,
`/uploads/resumable/${randomUUID()}`,
'PUT',
Buffer.alloc(10),
undefined,
{ 'content-type': 'application/octet-stream', 'upload-offset': '0' },
);
assert.equal(next.statusCode, 404);
assert.equal(f.service.admin.inflight.size, 0);
f.service.admin.state.maxConcurrentUploads = 4;
const releases = [
f.service.admin.admit(req, reply, 'owner'),
f.service.admin.admit(req, reply, 'owner'),
];
assert.throws(
() => f.service.admin.admit(req, reply, 'owner'),
(error) => error.code === 'UPLOAD_BUSY',
);
releases.forEach((stop) => stop());
const oversized = await f.call(null, '/guest/session', 'POST', { value: 'a'.repeat(2000) });
assert.equal(oversized.statusCode, 413);
} finally {
await f.close();
}
});
test('admin deletion removes physical bytes and all shared references; listing is paginated and audit entries expire', async () => {
const f = await fixture();
try {
const admin = await login(f),
a = await f.create(),
b = await f.create();
await f.space(a, b);
const id = await fileRequest(f, a, b);
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
const payload = Buffer.from(
'--delete\r\nContent-Disposition: form-data; name="file"; filename="private.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--delete--\r\n',
);
await f.call(a, `/uploads?requestId=${id}&fileIndex=0`, 'POST', payload, randomUUID(), {
'content-type': 'multipart/form-data; boundary=delete',
});
const row = f.service.storage.fileRows(id)[0],
path = f.service.storage.layout.path(row),
access = (await f.call(b, '/device/items')).json().items[0];
const secondId = await fileRequest(f, a, b);
await f.call(b, `/transfer-requests/${secondId}/accept`, 'POST', {}, randomUUID());
const secondUpload = await f.call(
a,
`/uploads?requestId=${secondId}&fileIndex=0`,
'POST',
payload,
randomUUID(),
{ 'content-type': 'multipart/form-data; boundary=delete' },
);
assert.equal(secondUpload.statusCode, 200, secondUpload.body);
const secondRow = f.service.storage.fileRows(secondId)[0];
const list = (await f.call(admin, '/admin/files?limit=1')).json();
assert.equal(list.items[0].name, 'private.txt');
assert.equal(list.items[0].uploader.id, row.ownerGuestId);
assert.ok(list.nextCursor);
const nextPage = (await f.call(admin, '/admin/files?limit=1&cursor=' + list.nextCursor)).json();
assert.equal(nextPage.items.length, 1);
assert.notEqual(list.items[0].id, nextPage.items[0].id);
assert.deepEqual(
new Set([list.items[0].id, nextPage.items[0].id]),
new Set([row.id, secondRow.id]),
);
assert.equal(nextPage.nextCursor, null);
assert.equal((await f.call(admin, '/admin/files?cursor=bad')).statusCode, 400);
assert.equal((await f.call(admin, `/admin/files/${row.id}`, 'DELETE')).statusCode, 200);
assert.equal(existsSync(path), false);
assert.equal((await f.call(b, `/access/${access.accessId}/download`)).statusCode, 404);
assert.equal((await f.call(admin, '/admin/summary')).json().storedBytes, 4);
assert.equal((await f.call(admin, `/admin/files/${secondRow.id}`, 'DELETE')).statusCode, 200);
assert.equal((await f.call(admin, '/admin/summary')).json().storedBytes, 0);
assert.equal((await f.call(admin, '/admin/audit')).json().events[0].action, 'file-delete');
f.service.db
.update(s.adminAudit)
.set({ createdAt: Date.now() - 31 * 86400000 })
.run();
f.service.admin.cleanup();
assert.equal(f.service.admin.events().length, 0);
} finally {
await f.close();
}
});