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

483 lines
16 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash, randomUUID } from 'node:crypto';
import { statfsSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import Database from 'better-sqlite3';
import { fixture } from './helpers.js';
import * as s from '../src/db/schema.js';
import { Administration } from '../src/administration.js';
import { CHUNK_BYTES } from '../src/resumable.js';
async function signed(f, email) {
const client = await f.create(),
id = randomUUID();
f.service.db
.insert(s.users)
.values({
id,
email,
displayName: email,
passwordHash: await f.service.auth.password('12345678'),
status: 'active',
verifiedAt: Date.now(),
createdAt: Date.now(),
})
.run();
const response = await f.call(client, '/auth/login', 'POST', { email, password: '12345678' });
assert.equal(response.statusCode, 200, response.body);
client.cookie += '; ' + String(response.headers['set-cookie']).split(';')[0];
return client;
}
async function patch(f, admin, values) {
const response = await f.call(admin, '/admin/settings', 'PATCH', {
revision: f.service.admin.state.revision,
...values,
});
assert.equal(response.statusCode, 200, response.body);
return response.json();
}
async function request(f, sender, receiver, bytes = 8) {
return f.call(
sender,
'/transfer-requests',
'POST',
{
targetDeviceId: receiver.deviceId,
kind: 'file',
count: 1,
byteSize: bytes,
files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }],
},
randomUUID(),
);
}
async function accept(f, receiver, response) {
assert.equal(response.statusCode, 200, response.body);
const id = response.json().requestId;
const result = await f.call(
receiver,
`/transfer-requests/${id}/accept`,
'POST',
{},
randomUUID(),
);
assert.equal(result.statusCode, 200, result.body);
return id;
}
const own = (f, sender, bytes = 8) =>
f.call(
sender,
'/vault',
'POST',
{ kind: 'file', files: [{ name: 'quota.txt', size: bytes, type: 'text/plain' }] },
randomUUID(),
);
async function upload(f, sender, id, bytes) {
return f.call(
sender,
`/uploads?requestId=${id}&fileIndex=0`,
'POST',
Buffer.concat([
Buffer.from(
'--quota\r\nContent-Disposition: form-data; name="file"; filename="quota.txt"\r\nContent-Type: text/plain\r\n\r\n',
),
bytes,
Buffer.from('\r\n--quota--\r\n'),
]),
randomUUID(),
{ 'content-type': 'multipart/form-data; boundary=quota' },
);
}
test('administrator limits persist, report to clients, reject invalid updates and enforce single-file/account quotas immediately', async () => {
const f = await fixture(false, undefined, { maxFileBytes: 4 });
try {
const admin = await signed(f, 'hello@noname.company'),
user = await signed(f, 'quota@hop.test'),
a = await f.create(),
b = await f.create();
await f.space(a, b);
assert.equal((await request(f, a, b)).json().code, 'TRANSFER_LIMIT');
assert.equal((await own(f, user)).json().code, 'TRANSFER_LIMIT');
const updated = await patch(f, admin, {
perFileBytes: 16,
accountBytes: 12,
deviceBytes: 24,
devicesTotalBytes: 32,
minDiskFreeBytes: 0,
});
const restored = new Administration(f.service);
for (const key of [
'perFileBytes',
'accountBytes',
'deviceBytes',
'devicesTotalBytes',
'minDiskFreeBytes',
])
assert.equal(restored.state[key], updated[key]);
assert.equal((await f.call(user, '/account/storage')).json().limitBytes, 12);
const id = await accept(f, b, await request(f, a, b));
// Runtime increase must also override the original multipart plugin's 4-byte limit.
assert.equal((await upload(f, a, id, Buffer.from('12345678'))).statusCode, 200);
const saved = await own(f, user);
assert.equal(saved.statusCode, 200, saved.body);
assert.equal(
(await own(f, user)).json().code,
'ACCOUNT_QUOTA',
'pending reservations count toward the account limit',
);
await patch(f, admin, { accountBytes: 24 });
assert.equal((await own(f, user)).statusCode, 200);
assert.equal(
(await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode,
200,
);
const list = (await f.call(admin, '/admin/files')).json().items;
const accountFile = list.find((file) => file.uploader?.email === 'quota@hop.test');
assert.ok(accountFile);
assert.equal(accountFile.path.split('/')[0], accountFile.uploader.id);
assert.equal(accountFile.uploader.kind, 'user');
assert.equal('name' in accountFile.uploader, false);
const guestFile = list.find((file) => file.uploader?.kind === 'guest');
assert.equal(guestFile.uploader.email, null);
assert.equal(guestFile.path.split('/')[0], guestFile.uploader.id);
const before = f.service.admin.state.revision;
for (const invalid of [
{ perFileBytes: 0 },
{ accountBytes: -1 },
{ deviceBytes: 1.2 },
{ devicesTotalBytes: 1e12 + 1 },
{ minDiskFreeBytes: -1 },
]) {
assert.equal(
(await f.call(admin, '/admin/settings', 'PATCH', { revision: before, ...invalid }))
.statusCode,
400,
);
assert.equal(f.service.admin.state.revision, before);
}
const audits = (await f.call(admin, '/admin/audit')).json().events;
assert.ok(
audits.some((event) =>
event.details.changes?.some(
(change) => change.field === 'perFileBytes' && change.before === 4 && change.after === 16,
),
),
);
} finally {
await f.close();
}
});
test('administrator device, combined device and minimum disk limits govern new reservations without changing existing files', async () => {
const f = await fixture();
try {
const admin = await signed(f, 'hello@noname.company'),
a = await f.create(),
b = await f.create();
await f.space(a, b);
await patch(f, admin, { deviceBytes: 4 });
let pending = await request(f, a, b);
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'GUEST_QUOTA',
);
await patch(f, admin, { deviceBytes: 16, devicesTotalBytes: 4 });
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'TOTAL_QUOTA',
);
await patch(f, admin, { devicesTotalBytes: 24 });
const disk = statfsSync(f.cfg.uploadDir),
free = disk.bavail * disk.bsize;
if (free + 1 <= 1e12) {
await patch(f, admin, { minDiskFreeBytes: free + 1 });
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'DISK_FULL',
);
}
await patch(f, admin, { minDiskFreeBytes: 0 });
const id = await accept(f, b, pending);
await patch(f, admin, { deviceBytes: 1, devicesTotalBytes: 1, perFileBytes: 1 });
assert.equal(
(await upload(f, a, id, Buffer.from('12345678'))).statusCode,
200,
'accepted reservation survives reduced limits',
);
const item = (await f.call(b, '/device/items'))
.json()
.items.find((item) => item.file?.name === 'quota.txt');
assert.deepEqual(
(await f.call(b, `/access/${item.accessId}/download`)).rawPayload,
Buffer.from('12345678'),
);
assert.equal(f.service.admin.summary().storedFiles, 1);
} finally {
await f.close();
}
});
test('resumable offsets and completion honor the accepted manifest after reducing the file and account limits', async () => {
const f = await fixture(false, undefined, { maxFileBytes: 4 });
try {
const admin = await signed(f, 'hello@noname.company'),
user = await signed(f, 'resume@hop.test');
await patch(f, admin, { perFileBytes: CHUNK_BYTES + 7, accountBytes: CHUNK_BYTES + 7 });
const response = await own(f, user, CHUNK_BYTES + 7),
bytes = Buffer.alloc(CHUNK_BYTES + 7, 120);
assert.equal(response.statusCode, 200, response.body);
const requestId = response.json().requestId,
hash = (value) => createHash('sha256').update(value).digest(),
fingerprint = hash(
Buffer.concat([hash(bytes.subarray(0, CHUNK_BYTES)), hash(bytes.subarray(CHUNK_BYTES))]),
).toString('hex');
const started = await f.call(user, '/uploads/resumable', 'POST', {
requestId,
fileIndex: 0,
fingerprint,
});
assert.equal(started.statusCode, 200, started.body);
const id = started.json().uploadId;
const put = (offset, body) =>
f.call(user, `/uploads/resumable/${id}`, 'PUT', body, undefined, {
'content-type': 'application/octet-stream',
'upload-offset': String(offset),
});
assert.equal((await put(0, bytes.subarray(0, CHUNK_BYTES))).statusCode, 200);
await patch(f, admin, { perFileBytes: 1, accountBytes: 1, uploadsPaused: true });
assert.equal(
(await put(CHUNK_BYTES, bytes.subarray(CHUNK_BYTES))).statusCode,
200,
'offset is checked against manifest, not a stale startup limit',
);
const completed = await f.call(user, `/uploads/resumable/${id}/complete`, 'POST', {});
assert.equal(completed.statusCode, 200, completed.body);
assert.equal((await f.call(user, '/account/storage')).json().usedBytes, bytes.length);
assert.equal((await own(f, user)).json().code, 'UPLOADS_PAUSED');
} finally {
await f.close();
}
});
test('offline transfer reset removes only transfer/guest data and keeps accounts, authentication, settings and user information', async () => {
const f = await fixture(),
folder = await mkdtemp(resolve(tmpdir(), 'hop-reset-test-'));
let database;
try {
const admin = await signed(f, 'hello@noname.company'),
user = await signed(f, 'keep@hop.test');
const saved = await own(f, user);
assert.equal(
(await upload(f, user, saved.json().requestId, Buffer.from('12345678'))).statusCode,
200,
);
await patch(f, admin, { accountBytes: 3e9 });
const path = resolve(folder, 'snapshot.sqlite');
await f.sqlite.backup(path);
database = new Database(path);
const kept = [
'users',
'account_sessions',
'account_tokens',
'user_rules',
'user_activity',
'admin_settings',
'admin_audit',
'__drizzle_migrations',
];
const before = kept.map((table) =>
JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()),
);
const script = fileURLToPath(
new URL('../../../scripts/reset-transfer-records.mjs', import.meta.url),
);
const denied = spawnSync(process.execPath, [script], {
env: { ...process.env, DATABASE_PATH: path },
encoding: 'utf8',
});
assert.notEqual(denied.status, 0);
assert.equal(database.prepare('SELECT count(*) AS count FROM files').get().count, 1);
const cleared = spawnSync(process.execPath, [script, '--confirm-reset-transfer-records'], {
env: { ...process.env, DATABASE_PATH: path },
encoding: 'utf8',
});
assert.equal(cleared.status, 0, cleared.stderr);
for (const table of [
'files',
'items',
'item_access',
'transfer_requests',
'transfer_batches',
'quota_reservations',
'file_upload_contexts',
'guest_sessions',
'browser_devices',
'idempotency_records',
])
assert.equal(
database.prepare(`SELECT count(*) AS count FROM "${table}"`).get().count,
0,
table,
);
kept.forEach((table, index) =>
assert.equal(
JSON.stringify(database.prepare(`SELECT * FROM "${table}"`).all()),
before[index],
table,
),
);
assert.deepEqual(database.pragma('foreign_key_check'), []);
assert.equal(f.service.admin.summary().storedFiles, 1, 'only isolated DB copy is reset');
} finally {
database?.close();
await f.close();
await rm(folder, { recursive: true, force: true });
}
});
test(
'file-only ban publishes a minimal live policy, blocks new uploads, stored-file forwarding and pending acceptance while allowing clipboard and accepted uploads',
{ timeout: 20000 },
async () => {
const f = await fixture();
try {
const admin = await signed(f, 'hello@noname.company'),
a = await f.create(),
b = await f.create(),
c = await f.create(),
d = await f.create();
await f.space(a, b);
await f.space(a, admin);
await f.space(c, d);
const original = await own(f, admin);
assert.equal(
(await upload(f, admin, original.json().requestId, Buffer.from('12345678'))).statusCode,
200,
);
const stored = (await f.call(admin, '/vault')).json().items[0];
const pending = await request(f, a, b),
accepted = await accept(f, d, await request(f, c, d));
assert.equal(pending.statusCode, 200, pending.body);
const event = new Promise((resolve) => a.socket.once('service:policy', resolve));
const settings = await patch(f, admin, { uploadsPaused: true });
assert.deepEqual(await event, { fileTransfersAllowed: false, revision: settings.revision });
const snapshot = (await f.call(a, '/device/snapshot')).json();
assert.deepEqual(snapshot.servicePolicy, {
fileTransfersAllowed: false,
revision: settings.revision,
});
assert.equal('blockedIps' in snapshot.servicePolicy, false);
assert.equal((await request(f, a, b)).json().code, 'UPLOADS_PAUSED');
assert.equal((await own(f, admin)).json().code, 'UPLOADS_PAUSED');
assert.equal(
(
await f.call(
b,
`/transfer-requests/${pending.json().requestId}/accept`,
'POST',
{},
randomUUID(),
)
).json().code,
'UPLOADS_PAUSED',
);
assert.equal(
(
await f.call(
admin,
`/vault/${stored.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
)
).json().code,
'UPLOADS_PAUSED',
);
assert.deepEqual(
(await f.call(admin, `/access/${stored.accessId}/download`)).rawPayload,
Buffer.from('12345678'),
);
assert.equal((await upload(f, c, accepted, Buffer.from('12345678'))).statusCode, 200);
await f.call(
a,
`/transfer-requests/${pending.json().requestId}/cancel`,
'POST',
{},
randomUUID(),
);
await f.complete(a, b, 'https://example.test/allowed');
for (const body of [
{ kind: 'url', urls: ['https://example.test/clipboard'] },
{ kind: 'text', text: '한글 日本語 😀\n 공백 유지' },
]) {
const saved = await f.call(admin, '/vault', 'POST', body, randomUUID());
assert.equal(saved.statusCode, 200, saved.body);
const item = (await f.call(admin, '/vault'))
.json()
.items.find((item) => item.kind === body.kind);
const forwarded = await f.call(
admin,
`/vault/${item.accessId}/send`,
'POST',
{ targetDeviceId: b.deviceId },
randomUUID(),
);
assert.equal(forwarded.statusCode, 200, forwarded.body);
const received = await f.call(
b,
`/transfer-requests/${forwarded.json().requestId}/accept`,
'POST',
{},
randomUUID(),
);
assert.equal(received.statusCode, 200, received.body);
}
const reconnected = new Promise((resolve) => a.socket.once('service:policy', resolve));
a.socket.disconnect();
a.socket.connect();
assert.deepEqual(await reconnected, {
fileTransfersAllowed: false,
revision: settings.revision,
});
await patch(f, admin, { uploadsPaused: false });
assert.equal((await request(f, a, b)).statusCode, 200);
assert.equal(
(await f.call(a, '/device/snapshot')).json().servicePolicy.fileTransfersAllowed,
true,
);
} finally {
await f.close();
}
},
);