440 lines
16 KiB
JavaScript
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();
|
|
}
|
|
});
|