Initial U-Navigator prototype
This commit is contained in:
+125
@@ -0,0 +1,125 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export class JobStore {
|
||||
constructor() {
|
||||
this.jobs = new Map();
|
||||
this.nextId = 1;
|
||||
}
|
||||
|
||||
create(type, task) {
|
||||
const id = String(this.nextId++);
|
||||
const job = {
|
||||
id,
|
||||
type,
|
||||
status: 'queued',
|
||||
progress: 0,
|
||||
message: '',
|
||||
error: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
cancelRequested: false
|
||||
};
|
||||
|
||||
this.jobs.set(id, job);
|
||||
queueMicrotask(async () => {
|
||||
await this.run(job, task);
|
||||
});
|
||||
return this.publicJob(job);
|
||||
}
|
||||
|
||||
get(id) {
|
||||
const job = this.jobs.get(String(id));
|
||||
return job ? this.publicJob(job) : null;
|
||||
}
|
||||
|
||||
cancel(id) {
|
||||
const job = this.jobs.get(String(id));
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
job.cancelRequested = true;
|
||||
this.touch(job, 'cancel_requested', job.progress, 'Cancel requested');
|
||||
return this.publicJob(job);
|
||||
}
|
||||
|
||||
async run(job, task) {
|
||||
try {
|
||||
this.touch(job, 'running', 1, 'Running');
|
||||
await task({
|
||||
isCanceled: () => job.cancelRequested,
|
||||
progress: (progress, message = job.message) => this.touch(job, 'running', progress, message)
|
||||
});
|
||||
if (job.cancelRequested) {
|
||||
this.touch(job, 'canceled', job.progress, 'Canceled');
|
||||
} else {
|
||||
this.touch(job, 'completed', 100, 'Completed');
|
||||
}
|
||||
} catch (error) {
|
||||
this.touch(job, job.cancelRequested ? 'canceled' : 'failed', job.progress, error.message);
|
||||
job.error = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
touch(job, status, progress, message) {
|
||||
job.status = status;
|
||||
job.progress = Math.max(0, Math.min(100, Math.round(progress)));
|
||||
job.message = message;
|
||||
job.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
publicJob(job) {
|
||||
const { cancelRequested, ...publicFields } = job;
|
||||
return publicFields;
|
||||
}
|
||||
}
|
||||
|
||||
export async function movePath(source, destination, ctx) {
|
||||
if (ctx.isCanceled()) {
|
||||
throw new Error('Canceled');
|
||||
}
|
||||
ctx.progress(10, 'Moving');
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
await fs.rename(source, destination);
|
||||
ctx.progress(95, 'Move finished');
|
||||
}
|
||||
|
||||
export async function copyPath(source, destination, ctx) {
|
||||
if (ctx.isCanceled()) {
|
||||
throw new Error('Canceled');
|
||||
}
|
||||
ctx.progress(5, 'Preparing copy');
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true });
|
||||
await copyRecursive(source, destination, ctx);
|
||||
ctx.progress(95, 'Copy finished');
|
||||
}
|
||||
|
||||
async function copyRecursive(source, destination, ctx) {
|
||||
if (ctx.isCanceled()) {
|
||||
throw new Error('Canceled');
|
||||
}
|
||||
|
||||
const stat = await fs.lstat(source);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error('Copying symlinks is not allowed');
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await fs.mkdir(destination, { recursive: true, mode: stat.mode });
|
||||
const entries = await fs.readdir(source);
|
||||
let done = 0;
|
||||
for (const entry of entries) {
|
||||
await copyRecursive(path.join(source, entry), path.join(destination, entry), ctx);
|
||||
done += 1;
|
||||
ctx.progress(10 + (done / Math.max(entries.length, 1)) * 80, 'Copying directory');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stat.isFile()) {
|
||||
throw new Error('Only files and directories can be copied');
|
||||
}
|
||||
|
||||
await fs.copyFile(source, destination);
|
||||
ctx.progress(85, 'Copying file');
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export async function readRequestBody(req, limitBytes) {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
|
||||
for await (const chunk of req) {
|
||||
size += chunk.length;
|
||||
if (size > limitBytes) {
|
||||
const error = new Error('Request body is too large');
|
||||
error.status = 413;
|
||||
throw error;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export function parseMultipart(buffer, contentType) {
|
||||
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType || '');
|
||||
if (!match) {
|
||||
const error = new Error('Missing multipart boundary');
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const boundary = `--${match[1] || match[2]}`;
|
||||
const binary = buffer.toString('binary');
|
||||
const parts = [];
|
||||
|
||||
for (const raw of binary.split(boundary)) {
|
||||
if (!raw || raw === '--\r\n' || raw === '--') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = raw.replace(/^\r\n/, '').replace(/\r\n--$/, '');
|
||||
const separator = trimmed.indexOf('\r\n\r\n');
|
||||
if (separator === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const headerText = trimmed.slice(0, separator);
|
||||
let bodyText = trimmed.slice(separator + 4);
|
||||
if (bodyText.endsWith('\r\n')) {
|
||||
bodyText = bodyText.slice(0, -2);
|
||||
}
|
||||
|
||||
const disposition = /content-disposition:\s*form-data;\s*([^\r\n]+)/i.exec(headerText)?.[1] || '';
|
||||
const name = /name="([^"]+)"/i.exec(disposition)?.[1] || '';
|
||||
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1] || '';
|
||||
|
||||
parts.push({
|
||||
name,
|
||||
filename,
|
||||
data: Buffer.from(bodyText, 'binary')
|
||||
});
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export class Sandbox {
|
||||
constructor(roots, options = {}) {
|
||||
if (!roots.length) {
|
||||
throw new Error('At least one allowed root is required');
|
||||
}
|
||||
|
||||
this.readOnly = Boolean(options.readOnly);
|
||||
this.roots = roots.map((root, index) => ({
|
||||
id: `root${index + 1}`,
|
||||
label: root,
|
||||
path: path.resolve(root)
|
||||
}));
|
||||
}
|
||||
|
||||
async init() {
|
||||
const usable = [];
|
||||
|
||||
for (const root of this.roots) {
|
||||
const real = await fs.realpath(root.path);
|
||||
const stat = await fs.stat(real);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Allowed root is not a directory: ${root.path}`);
|
||||
}
|
||||
usable.push({ ...root, real });
|
||||
}
|
||||
|
||||
this.roots = usable;
|
||||
return this;
|
||||
}
|
||||
|
||||
publicConfig() {
|
||||
return {
|
||||
readOnly: this.readOnly,
|
||||
roots: this.roots.map((root) => ({
|
||||
id: root.id,
|
||||
label: root.label,
|
||||
path: root.real
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
assertWritable() {
|
||||
if (this.readOnly) {
|
||||
const error = new Error('Read-only mode is enabled');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
rootFor(inputPath = '/') {
|
||||
const normalized = String(inputPath || '/');
|
||||
|
||||
if (normalized === '/' || normalized === '') {
|
||||
return this.roots[0];
|
||||
}
|
||||
|
||||
for (const root of this.roots) {
|
||||
if (normalized === root.real || normalized.startsWith(`${root.real}${path.sep}`)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error('Path is outside allowed roots');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
rootForReal(realPath) {
|
||||
for (const root of this.roots) {
|
||||
if (this.isInside(realPath, root.real)) {
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
||||
const error = new Error('Path is outside allowed roots');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
async resolveExisting(inputPath = '/') {
|
||||
const candidate = path.resolve(String(inputPath || this.roots[0].real));
|
||||
const real = await fs.realpath(candidate);
|
||||
const root = this.rootForReal(real);
|
||||
|
||||
return { root, path: candidate, real };
|
||||
}
|
||||
|
||||
async resolveDestination(inputPath) {
|
||||
const candidate = path.resolve(String(inputPath || ''));
|
||||
const parent = path.dirname(candidate);
|
||||
const parentReal = await fs.realpath(parent);
|
||||
const root = this.rootForReal(parentReal);
|
||||
|
||||
return { root, path: candidate, parentReal };
|
||||
}
|
||||
|
||||
isInside(candidate, root) {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
}
|
||||
}
|
||||
|
||||
export function rootsFromEnv(env = process.env) {
|
||||
if (env.U_NAV_ROOTS) {
|
||||
return env.U_NAV_ROOTS.split(',').map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
if (env.U_NAV_ROOT) {
|
||||
return [env.U_NAV_ROOT];
|
||||
}
|
||||
return ['/mnt/user'];
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { JobStore, copyPath, movePath } from './jobs.js';
|
||||
import { parseMultipart, readRequestBody } from './multipart.js';
|
||||
import { Sandbox, rootsFromEnv } from './sandbox.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const publicDir = path.join(projectRoot, 'public');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.png': 'image/png'
|
||||
};
|
||||
|
||||
export async function createApp(options = {}) {
|
||||
const sandbox = await new Sandbox(options.roots || rootsFromEnv(options.env), {
|
||||
readOnly: options.readOnly ?? options.env?.U_NAV_READ_ONLY === '1'
|
||||
}).init();
|
||||
const jobs = new JobStore();
|
||||
const uploadLimit = Number(options.uploadLimit || options.env?.U_NAV_UPLOAD_LIMIT || 1024 * 1024 * 100);
|
||||
|
||||
return async function app(req, res) {
|
||||
try {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
await routeApi(req, res, url, sandbox, jobs, uploadLimit);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(req, res, url);
|
||||
} catch (error) {
|
||||
sendJson(res, error.status || 500, {
|
||||
error: error.message || 'Internal server error'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function routeApi(req, res, url, sandbox, jobs, uploadLimit) {
|
||||
if (req.method === 'GET' && url.pathname === '/api/config') {
|
||||
sendJson(res, 200, sandbox.publicConfig());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/list') {
|
||||
const resolved = await sandbox.resolveExisting(url.searchParams.get('path') || sandbox.roots[0].real);
|
||||
const entries = await listDirectory(resolved.real);
|
||||
sendJson(res, 200, { path: resolved.real, entries });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/api/upload') {
|
||||
sandbox.assertWritable();
|
||||
const target = url.searchParams.get('path');
|
||||
const resolved = await sandbox.resolveExisting(target);
|
||||
const stat = await fs.stat(resolved.real);
|
||||
if (!stat.isDirectory()) {
|
||||
throw statusError(400, 'Upload target must be a directory');
|
||||
}
|
||||
const body = await readRequestBody(req, uploadLimit);
|
||||
const parts = parseMultipart(body, req.headers['content-type']);
|
||||
const saved = [];
|
||||
for (const part of parts.filter((item) => item.name === 'files' && item.filename)) {
|
||||
const safeName = path.basename(part.filename);
|
||||
const destination = path.join(resolved.real, safeName);
|
||||
await sandbox.resolveDestination(destination);
|
||||
await fs.writeFile(destination, part.data, { flag: 'wx' });
|
||||
saved.push({ name: safeName, size: part.data.length });
|
||||
}
|
||||
sendJson(res, 201, { uploaded: saved });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && (url.pathname === '/api/jobs/move' || url.pathname === '/api/jobs/copy')) {
|
||||
sandbox.assertWritable();
|
||||
const body = await jsonBody(req);
|
||||
const source = await sandbox.resolveExisting(body.source);
|
||||
const destination = await sandbox.resolveDestination(body.destination);
|
||||
const type = url.pathname.endsWith('/copy') ? 'copy' : 'move';
|
||||
await ensureDestinationAvailable(destination.path);
|
||||
ensureNotNested(source.real, destination.path);
|
||||
|
||||
const job = jobs.create(type, async (ctx) => {
|
||||
if (type === 'copy') {
|
||||
await copyPath(source.real, destination.path, ctx);
|
||||
} else {
|
||||
await movePath(source.real, destination.path, ctx);
|
||||
}
|
||||
});
|
||||
sendJson(res, 202, job);
|
||||
return;
|
||||
}
|
||||
|
||||
const jobMatch = /^\/api\/jobs\/([^/]+)$/.exec(url.pathname);
|
||||
if (req.method === 'GET' && jobMatch) {
|
||||
const job = jobs.get(jobMatch[1]);
|
||||
if (!job) {
|
||||
throw statusError(404, 'Job not found');
|
||||
}
|
||||
sendJson(res, 200, job);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && /^\/api\/jobs\/([^/]+)\/cancel$/.test(url.pathname)) {
|
||||
const id = /^\/api\/jobs\/([^/]+)\/cancel$/.exec(url.pathname)[1];
|
||||
const job = jobs.cancel(id);
|
||||
if (!job) {
|
||||
throw statusError(404, 'Job not found');
|
||||
}
|
||||
sendJson(res, 200, job);
|
||||
return;
|
||||
}
|
||||
|
||||
throw statusError(404, 'API route not found');
|
||||
}
|
||||
|
||||
async function listDirectory(directory) {
|
||||
const entries = await fs.readdir(directory, { withFileTypes: true });
|
||||
const result = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(directory, entry.name);
|
||||
const stat = await fs.lstat(fullPath);
|
||||
result.push({
|
||||
name: entry.name,
|
||||
path: fullPath,
|
||||
type: entry.isDirectory() ? 'directory' : entry.isFile() ? 'file' : entry.isSymbolicLink() ? 'symlink' : 'other',
|
||||
size: stat.size,
|
||||
modifiedAt: stat.mtime.toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1;
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, url) {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
throw statusError(405, 'Method not allowed');
|
||||
}
|
||||
|
||||
const pathname = url.pathname === '/' ? '/index.html' : url.pathname;
|
||||
const requested = path.resolve(publicDir, `.${decodeURIComponent(pathname)}`);
|
||||
const relative = path.relative(publicDir, requested);
|
||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
throw statusError(403, 'Static path outside public directory');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(requested);
|
||||
if (!stat.isFile()) {
|
||||
throw statusError(404, 'Not found');
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(requested)] || 'application/octet-stream',
|
||||
'Content-Length': stat.size
|
||||
});
|
||||
if (req.method === 'HEAD') {
|
||||
res.end();
|
||||
} else {
|
||||
createReadStream(requested).pipe(res);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw statusError(404, 'Not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDestinationAvailable(destination) {
|
||||
try {
|
||||
await fs.lstat(destination);
|
||||
throw statusError(409, 'Destination already exists');
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNotNested(source, destination) {
|
||||
const relative = path.relative(source, destination);
|
||||
if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) {
|
||||
throw statusError(400, 'Destination cannot be inside source');
|
||||
}
|
||||
}
|
||||
|
||||
async function jsonBody(req) {
|
||||
const body = await readRequestBody(req, 1024 * 1024);
|
||||
try {
|
||||
return JSON.parse(body.toString('utf8') || '{}');
|
||||
} catch {
|
||||
throw statusError(400, 'Invalid JSON body');
|
||||
}
|
||||
}
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
const json = JSON.stringify(payload);
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(json)
|
||||
});
|
||||
res.end(json);
|
||||
}
|
||||
|
||||
function statusError(status, message) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const port = Number(process.env.PORT || 8088);
|
||||
createApp({ env: process.env }).then((app) => {
|
||||
http.createServer(app).listen(port, '127.0.0.1', () => {
|
||||
console.log(`U-Navigator listening on http://127.0.0.1:${port}`);
|
||||
});
|
||||
}).catch((error) => {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApp } from './server.js';
|
||||
|
||||
let root;
|
||||
let outside;
|
||||
let baseUrl;
|
||||
let server;
|
||||
|
||||
describe('U-Navigator API', () => {
|
||||
before(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'u-nav-root-'));
|
||||
outside = await fs.mkdtemp(path.join(os.tmpdir(), 'u-nav-outside-'));
|
||||
await fs.mkdir(path.join(root, 'share'));
|
||||
await fs.writeFile(path.join(root, 'share', 'alpha.txt'), 'alpha');
|
||||
await fs.writeFile(path.join(outside, 'secret.txt'), 'secret');
|
||||
await fs.symlink(outside, path.join(root, 'share', 'escape'));
|
||||
|
||||
const app = await createApp({ roots: [root] });
|
||||
server = http.createServer(app);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('lists files inside the configured root', async () => {
|
||||
const result = await json(`/api/list?path=${encodeURIComponent(path.join(root, 'share'))}`);
|
||||
assert.equal(result.entries.some((entry) => entry.name === 'alpha.txt'), true);
|
||||
});
|
||||
|
||||
it('blocks path traversal outside the root', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/list?path=${encodeURIComponent(outside)}`);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
it('blocks symlink escapes outside the root', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/list?path=${encodeURIComponent(path.join(root, 'share', 'escape'))}`);
|
||||
assert.equal(response.status, 403);
|
||||
});
|
||||
|
||||
it('uploads multipart files into an allowed directory', async () => {
|
||||
const form = new FormData();
|
||||
form.append('files', new Blob(['hello']), 'upload.txt');
|
||||
const response = await fetch(`${baseUrl}/api/upload?path=${encodeURIComponent(path.join(root, 'share'))}`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(await fs.readFile(path.join(root, 'share', 'upload.txt'), 'utf8'), 'hello');
|
||||
});
|
||||
|
||||
it('creates move jobs for internal drag and drop operations', async () => {
|
||||
await fs.writeFile(path.join(root, 'share', 'move-me.txt'), 'move');
|
||||
await fs.mkdir(path.join(root, 'target'));
|
||||
const response = await fetch(`${baseUrl}/api/jobs/move`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: path.join(root, 'share', 'move-me.txt'),
|
||||
destination: path.join(root, 'target', 'move-me.txt')
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 202);
|
||||
const job = await response.json();
|
||||
await waitForJob(job.id);
|
||||
assert.equal(await fs.readFile(path.join(root, 'target', 'move-me.txt'), 'utf8'), 'move');
|
||||
});
|
||||
|
||||
it('rejects destinations that already exist', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/jobs/copy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
source: path.join(root, 'share', 'alpha.txt'),
|
||||
destination: path.join(root, 'share', 'upload.txt')
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 409);
|
||||
});
|
||||
});
|
||||
|
||||
async function json(pathname) {
|
||||
const response = await fetch(`${baseUrl}${pathname}`);
|
||||
assert.equal(response.ok, true);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function waitForJob(id) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const job = await json(`/api/jobs/${id}`);
|
||||
if (job.status === 'completed') return job;
|
||||
if (job.status === 'failed') throw new Error(job.error);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw new Error('Timed out waiting for job');
|
||||
}
|
||||
Reference in New Issue
Block a user