74 lines
5.0 KiB
JavaScript
74 lines
5.0 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const { Readable } = require('stream');
|
|
const test = require('node:test');
|
|
const { createImportUploadService, sanitizeRelativePath } = require('../../src/services/import-upload-service');
|
|
|
|
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
|
|
test('folder upload sanitizes paths, resumes exact offsets and survives service restart', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-upload-'));
|
|
try {
|
|
const service = createImportUploadService({ root, limits: { maxChunkBytes: 8 } });
|
|
const created = service.create({ owner: 'alice', files: [{ relativePath: 'Game/base.pkg', size: 6, sha256: digest('abcdef') }] });
|
|
assert.equal(Object.hasOwn(created, 'owner'), false);
|
|
assert.equal(JSON.stringify(created).includes(root), false);
|
|
const file = created.files[0];
|
|
const first = await service.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 0, length: 3, chunkSha256: digest('abc'), stream: Readable.from('abc') });
|
|
assert.equal(first.files[0].offset, 3);
|
|
|
|
const restarted = createImportUploadService({ root, limits: { maxChunkBytes: 8 } });
|
|
assert.equal(restarted.get(created.id, 'alice').files[0].offset, 3);
|
|
await assert.rejects(
|
|
restarted.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 2, length: 3, chunkSha256: digest('def'), stream: Readable.from('def') }),
|
|
error => error.code === 'UPLOAD_OFFSET' && error.expectedOffset === 3,
|
|
);
|
|
const completed = await restarted.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 3, length: 3, chunkSha256: digest('def'), stream: Readable.from('def') });
|
|
assert.equal(completed.state, 'uploaded');
|
|
assert.equal(completed.bytesReceived, 6);
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('hash failures roll back, quotas are enforced and hostile paths are rejected', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-upload-policy-'));
|
|
try {
|
|
const service = createImportUploadService({ root, limits: { maxFiles: 2, maxFileBytes: 8, maxSessionBytes: 10, maxUserBytes: 12, maxGlobalBytes: 14, maxChunkBytes: 8 } });
|
|
for (const value of ['../escape.pkg', '/absolute.pkg', 'C:\\escape.pkg', 'folder//file.pkg', 'folder/CON']) assert.throws(() => sanitizeRelativePath(value));
|
|
const created = service.create({ owner: 'alice', files: [{ relativePath: 'safe/file.pkg', size: 4 }] });
|
|
const file = created.files[0];
|
|
await assert.rejects(
|
|
service.writeChunk({ id: created.id, fileId: file.id, owner: 'alice', offset: 0, length: 4, chunkSha256: digest('nope'), stream: Readable.from('data') }),
|
|
error => error.code === 'UPLOAD_HASH',
|
|
);
|
|
assert.equal(service.get(created.id, 'alice').files[0].offset, 0);
|
|
assert.throws(() => service.create({ owner: 'alice', files: [{ relativePath: 'too-large.pkg', size: 9 }] }), error => error.code === 'UPLOAD_QUOTA');
|
|
assert.throws(() => service.create({ owner: 'alice', files: [{ relativePath: 'a.pkg', size: 5 }, { relativePath: 'A.pkg', size: 5 }] }), error => error.code === 'UPLOAD_COLLISION');
|
|
service.create({ owner: 'alice', files: [{ relativePath: 'second.pkg', size: 8 }] });
|
|
assert.throws(() => service.create({ owner: 'alice', files: [{ relativePath: 'over-user.pkg', size: 1 }] }), error => error.code === 'UPLOAD_QUOTA');
|
|
assert.throws(() => service.get(created.id, 'bob'), error => error.code === 'UPLOAD_NOT_FOUND');
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('cancel and expiry remove partial content without exposing or mutating a library', async () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'chimera-upload-lifecycle-'));
|
|
let clock = 1000;
|
|
const events = [];
|
|
try {
|
|
const service = createImportUploadService({ root, now: () => clock, emit: (channel, payload) => events.push({ channel, payload }), limits: { ttlMs: 10, maxChunkBytes: 8 } });
|
|
const cancelled = service.create({ owner: 'alice', files: [{ relativePath: 'cancel.pkg', size: 4 }] });
|
|
await service.writeChunk({ id: cancelled.id, fileId: cancelled.files[0].id, owner: 'alice', offset: 0, length: 2, chunkSha256: digest('ab'), stream: Readable.from('ab') });
|
|
assert.equal(service.cancel(cancelled.id, 'alice').state, 'cancelled');
|
|
const expiring = service.create({ owner: 'alice', files: [{ relativePath: 'expire.pkg', size: 2 }] });
|
|
clock = 1011;
|
|
assert.equal(service.expire(), 1);
|
|
assert.equal(service.get(expiring.id, 'alice').state, 'expired');
|
|
assert.equal(fs.readdirSync(root).some(name => name === 'library'), false);
|
|
assert.deepEqual(events.map(event => event.channel), ['import:upload:created', 'import:upload:progress', 'import:upload:cancelled', 'import:upload:created', 'import:upload:expired']);
|
|
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
|
});
|