mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-02 13:48:03 +00:00
feat(files): copy & duplicate, bulk actions, disk-backed uploads, and an accessible file tree (#1409)
* perf(files): spool uploads to disk instead of buffering in memory
Switch the stack file-explorer upload from multer memoryStorage to
diskStorage and stream the spooled temp file through the file-root
gateway, so an upload is never held fully in RAM. Authorization and
root resolution now run before multer spools, so an unauthorized or
read-only-root request is rejected without writing a temp file, and the
spool is removed on every exit path. The named-volume helper write
verifies the written byte count, since cat cannot report a short write.
* feat(files): copy and duplicate files in the explorer
Add a copy capability to the stack file explorer: a same-folder
Duplicate (auto-suffixed name) and a "Copy to..." destination picker,
on both filesystem and named-volume roots. Copying is within-root,
symlink-leaf-safe, blocks a directory copy into its own subtree, and
refuses to create a protected name (compose/.env) at the stack root
while still allowing a protected file to be duplicated under a new name.
* feat(files): make the file tree keyboard accessible
Bring the stack file explorer tree to the WCAG tree pattern: rows are
treeitems carrying aria-level, aria-selected, and aria-expanded, with a
single roving tabindex and full keyboard navigation (arrow keys,
Home/End, Enter/Space) over a flattened visible-node list that stays in
lockstep with the rendered rows. A polite live region announces the
selected file. No visual change to the tree.
* feat(files): bulk select, delete, move, and download files
Add multi-select to the stack file explorer (checkboxes plus Shift and
Ctrl/Cmd click over the visible order) driving three bulk actions:
delete, move, and download as a streamed .tar.gz. All run within the
active root on both filesystem and named-volume backends, report
per-item results so partial failures surface (with the failed items
kept selected for retry), normalize ancestor/descendant selections
server-side, and cap the archive entry and byte counts before any
bytes are streamed. Protected compose/.env files are excluded from
bulk delete and move but may still be downloaded.
* docs(files): document copy, bulk actions, and keyboard navigation
Add the copy/duplicate and multi-select bulk delete/move/download
sections to the Files & Volumes page, a keyboard-navigation note for the
tree, an updated context-menu reference, and bulk troubleshooting entries.
* fix(files): inline path-injection barriers at the new file-op sinks
CodeQL js/path-injection does not credit the wrapped isPathWithinBase
containment check, so the new copy/bulk/disk-upload flows tripped the
gate. Inline the canonical path.resolve + startsWith barrier at the
realpath sink in resolveSafePathWithin (covers every user-relPath flow)
and confirm the multer spool path resolves within UPLOAD_TMP_DIR before
unlinking it or streaming it onward. Behavior is unchanged; the paths
were already validated.
* fix(files): guard the ancestor-walk realpath sink too
The first barrier covered realpath(target), but the ENOENT ancestor
walk re-derives the path via path.dirname, which static analysis treats
as a fresh tainted value. Add the same inline containment barrier before
that realpath and resolve the root case via the untainted base, so the
only tainted realpath input is one the startsWith check has cleared.
Behavior is unchanged.
* fix(files): resolve the root case off the taint path in the ancestor walk
The compound guard on existing (the same variable as the startsWith
subject) was not credited as a sanitizer. Handle the root case before
the barrier by resolving the untainted base directly, leaving a plain
canonical startsWith guard on the strictly-within ancestor. Behavior is
unchanged.
* fix(files): harden helper-backend bulk download and uploads
Address three issues found in the named-volume (helper) backend:
- Bulk download could send 200 headers before discovering a file the
helper download path refuses, tearing the archive mid-stream. The
prewalk now rejects symlinks, non-regular ("other") entries, and
files over the per-file download cap before any header (400/413).
FileEntry gains an 'other' type so non-regular entries stay distinct
from regular files as they pass through the gateway.
- The helper directory listing was fully buffered before the archive
entry cap could fire. listDir now accepts a limit; the list script
stops after limit+1 rows and the gateway reports truncation.
- A stdin pipeline error during a helper upload masked the container's
real nonzero exit code (and its 4xx mapping) as a generic 500. The
nonzero exit now wins; the masked stream error is logged.
* feat(files): add a New file toolbar button with server-enforced create-only
The stack file explorer could create a folder from a toolbar button but a
new file only from a folder's right-click menu, so a file could not be
created at the stack root at all. Add a New file toolbar button beside New
folder, targeting the current directory.
Creating a file now routes through a new createEmptyStackFile helper that
posts a zero-byte file through the existing upload endpoint with overwrite
off, so the server's exclusive-create path rejects an existing name instead
of clobbering it. A file collision surfaces inline in the dialog; a folder
collision and other failures surface as a toast.
* fix(files): widen the tree row hit area and add horizontal scroll for long names
Right-clicking a file tree row only opened the Sencho context menu when the
click landed on the filename; the rest of the row fell through to the native
browser menu, and long names were truncated with no way to read them.
Make each row span the full pane width (and grow with its content) so the
whole row is the context-menu trigger, and let the tree scroll horizontally
so a long name is reachable instead of clipped. A new opt-in horizontal prop
on ScrollArea adds the styled horizontal scrollbar without clamping content
width.
* docs(files): document the New file button, full-row right-click, and long-name scrolling
* test(files): cover createEmptyStackFile targeting the stack root
Add an API-layer case for the empty-directory (stack root) create path, the
primary reason the New file toolbar button exists, so a regression in the
root-level URL would be caught at unit speed rather than only in e2e.
This commit is contained in:
@@ -20,8 +20,41 @@ import request from 'supertest';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import zlib from 'zlib';
|
||||
import { Readable } from 'stream';
|
||||
import * as tar from 'tar-stream';
|
||||
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
|
||||
import type { StackFileRoot } from '../services/StackFileRootsService';
|
||||
|
||||
/** Gunzip + untar a bulk-download response body into a { entryName: contents } map. */
|
||||
async function extractTarGz(buf: Buffer): Promise<Record<string, string>> {
|
||||
const out: Record<string, string> = {};
|
||||
const extract = tar.extract();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
extract.on('entry', (header, stream, next) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on('data', (c: Buffer) => chunks.push(c));
|
||||
stream.on('end', () => { out[header.name] = Buffer.concat(chunks).toString('utf-8'); next(); });
|
||||
stream.on('error', reject);
|
||||
});
|
||||
extract.on('finish', () => resolve());
|
||||
extract.on('error', reject);
|
||||
extract.end(zlib.gunzipSync(buf));
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* supertest parser that buffers a binary response body. supertest types the
|
||||
* parser's first arg as its Response while passing the raw readable stream at
|
||||
* runtime, so the function is cast to supertest's own parser parameter type.
|
||||
*/
|
||||
const binaryParser = ((res: NodeJS.ReadableStream, cb: (err: Error | null, body: Buffer) => void): void => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on('data', (c: Buffer) => chunks.push(Buffer.from(c)));
|
||||
res.on('end', () => cb(null, Buffer.concat(chunks)));
|
||||
res.on('error', (err: Error) => cb(err, Buffer.alloc(0)));
|
||||
}) as unknown as Parameters<ReturnType<typeof request>['parse']>[0];
|
||||
|
||||
// On Windows, fs.unlink on a directory returns EPERM instead of EISDIR so the
|
||||
// NOT_EMPTY code path in deleteStackPath is never reached. Skip that test case
|
||||
@@ -35,8 +68,19 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
let stacksDir: string;
|
||||
let uploadTmpDir: string;
|
||||
const STACK = 'teststack';
|
||||
|
||||
/** Count files left in the upload spool dir (ENOENT = dir never created = 0). */
|
||||
async function uploadTempCount(): Promise<number> {
|
||||
try {
|
||||
return (await fs.readdir(uploadTmpDir)).length;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return 0;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
type FileExplorerMetricEntry = {
|
||||
op: string;
|
||||
count: number;
|
||||
@@ -90,6 +134,10 @@ async function expectDownloadMetricCounts(count: number, successCount: number, e
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
stacksDir = process.env.COMPOSE_DIR!;
|
||||
// Isolate the upload spool dir per worker so temp-leak assertions are
|
||||
// deterministic (the default is a shared os.tmpdir() subdir).
|
||||
uploadTmpDir = path.join(tmpDir, 'uploads');
|
||||
process.env.SENCHO_UPLOAD_DIR = uploadTmpDir;
|
||||
|
||||
// Create stack directory so file operations have something to work with
|
||||
await fs.mkdir(path.join(stacksDir, STACK), { recursive: true });
|
||||
@@ -113,6 +161,7 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.SENCHO_UPLOAD_DIR;
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
@@ -947,6 +996,100 @@ describe('POST /api/stacks/:stackName/files/upload', () => {
|
||||
expect(stat.isDirectory()).toBe(true);
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// The upload spools to a disk temp file (diskStorage); that temp must never be
|
||||
// left behind, on success or on any rejection path.
|
||||
it('leaves no spooled temp file after a successful upload', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.set('Cookie', adminCookie)
|
||||
.attach('file', Buffer.from('spool-success'), 'spool-success.txt');
|
||||
expect(res.status).toBe(204);
|
||||
expect(await uploadTempCount()).toBe(0);
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'spool-success.txt'));
|
||||
});
|
||||
|
||||
it('leaves no spooled temp file after a 409 conflict', async () => {
|
||||
const target = path.join(stacksDir, STACK, 'spool-conflict.txt');
|
||||
await fs.writeFile(target, 'original');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.set('Cookie', adminCookie)
|
||||
.attach('file', Buffer.from('replacement'), 'spool-conflict.txt');
|
||||
expect(res.status).toBe(409);
|
||||
expect(await uploadTempCount()).toBe(0);
|
||||
await fs.unlink(target);
|
||||
});
|
||||
|
||||
it('leaves no spooled temp file after exceeding the size limit (413)', async () => {
|
||||
const bigFile = Buffer.alloc(26 * 1024 * 1024, 0x61);
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.set('Cookie', adminCookie)
|
||||
.attach('file', bigFile, 'spool-toobig.txt');
|
||||
expect(res.status).toBe(413);
|
||||
expect(await uploadTempCount()).toBe(0);
|
||||
}, 20000);
|
||||
|
||||
it('rejects a viewer before any file is spooled (pre-multer auth gate)', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.set('Cookie', viewerCookie)
|
||||
.attach('file', Buffer.from('viewer-data'), 'viewer-spool.txt');
|
||||
expect(res.status).toBe(403);
|
||||
expect(await uploadTempCount()).toBe(0);
|
||||
// And nothing was written into the stack dir.
|
||||
await expect(fs.access(path.join(stacksDir, STACK, 'viewer-spool.txt'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an upload to a read-only root before spooling (pre-multer root gate)', async () => {
|
||||
const readonlyRoot: StackFileRoot = {
|
||||
id: 'bind:readonlybind', kind: 'bind', label: '/ro', hostPathOrName: path.join(stacksDir, 'ro-bind'),
|
||||
mounts: [{ service: 'app', containerPath: '/ro', readOnly: true }],
|
||||
readonly: true, accessible: true, browsable: true, writable: false,
|
||||
chmodable: false, dangerous: false, managedSourceOverlap: false,
|
||||
warning: 'This location is read-only.', backend: 'fs',
|
||||
};
|
||||
const { StackFileRootsService } = await import('../services/StackFileRootsService');
|
||||
const spy = vi.spyOn(StackFileRootsService.prototype, 'resolveRoot').mockResolvedValue(readonlyRoot);
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.query({ rootId: 'bind:readonlybind' })
|
||||
.set('Cookie', adminCookie)
|
||||
.attach('file', Buffer.from('blocked'), 'ro.txt');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('READONLY_ROOT');
|
||||
expect(await uploadTempCount()).toBe(0);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('cleans up the spooled temp file when the write throws after spooling', async () => {
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
const spy = vi.spyOn(FileSystemService.prototype, 'writeScopedFileFromTemp')
|
||||
.mockRejectedValue(Object.assign(new Error('disk gone'), { code: 'EIO' }));
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.set('Cookie', adminCookie)
|
||||
.attach('file', Buffer.from('boom'), 'wfail-upload.txt');
|
||||
expect(res.status).toBe(500);
|
||||
expect(await uploadTempCount()).toBe(0);
|
||||
await expect(fs.access(path.join(stacksDir, STACK, 'wfail-upload.txt'))).rejects.toThrow();
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves no spooled temp file after an overwrite', async () => {
|
||||
const target = path.join(stacksDir, STACK, 'spool-overwrite.txt');
|
||||
await fs.writeFile(target, 'before');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/upload`)
|
||||
.query({ overwrite: '1' })
|
||||
.set('Cookie', adminCookie)
|
||||
.attach('file', Buffer.from('after'), 'spool-overwrite.txt');
|
||||
expect(res.status).toBe(204);
|
||||
expect(await fs.readFile(target, 'utf-8')).toBe('after');
|
||||
expect(await uploadTempCount()).toBe(0);
|
||||
await fs.unlink(target);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PUT /:stackName/files/content ─────────────────────────────────────────────
|
||||
@@ -1184,6 +1327,364 @@ describe('PATCH /api/stacks/:stackName/files/rename', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── POST /:stackName/files/copy ──────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/stacks/:stackName/files/copy', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.send({ from: 'a.txt', to: 'b.txt' });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('viewer receives 403', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ from: 'a.txt', to: 'b.txt' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('copies a file, preserving the original', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'copy-src.txt'), 'payload');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'copy-src.txt', to: 'copy-dst.txt' });
|
||||
expect(res.status).toBe(204);
|
||||
expect(await fs.readFile(path.join(stacksDir, STACK, 'copy-dst.txt'), 'utf-8')).toBe('payload');
|
||||
expect(await fs.readFile(path.join(stacksDir, STACK, 'copy-src.txt'), 'utf-8')).toBe('payload');
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'copy-src.txt'));
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'copy-dst.txt'));
|
||||
});
|
||||
|
||||
it('allows duplicating a protected file under a new name', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'compose.yaml', to: 'compose.yaml.bak' });
|
||||
expect(res.status).toBe(204);
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'compose.yaml.bak'));
|
||||
});
|
||||
|
||||
it('blocks copying onto a protected root name with 409 PROTECTED_FILE', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'rogue.yaml'), 'services: {}\n');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'rogue.yaml', to: 'docker-compose.yml' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('PROTECTED_FILE');
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'rogue.yaml'));
|
||||
});
|
||||
|
||||
it('returns 409 ALREADY_EXISTS when the destination exists', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'cexist-src.txt'), 'a');
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'cexist-dst.txt'), 'b');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'cexist-src.txt', to: 'cexist-dst.txt' });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('ALREADY_EXISTS');
|
||||
// The existing destination is untouched.
|
||||
expect(await fs.readFile(path.join(stacksDir, STACK, 'cexist-dst.txt'), 'utf-8')).toBe('b');
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'cexist-src.txt'));
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'cexist-dst.txt'));
|
||||
});
|
||||
|
||||
it('recursively copies a directory', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'cdir/nested'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'cdir/nested/deep.txt'), 'deep');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'cdir', to: 'cdir-copy' });
|
||||
expect(res.status).toBe(204);
|
||||
expect(await fs.readFile(path.join(stacksDir, STACK, 'cdir-copy/nested/deep.txt'), 'utf-8')).toBe('deep');
|
||||
await fs.rm(path.join(stacksDir, STACK, 'cdir'), { recursive: true, force: true });
|
||||
await fs.rm(path.join(stacksDir, STACK, 'cdir-copy'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects copying a directory into its own descendant with 400', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'selfcopy/sub'), { recursive: true });
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: 'selfcopy', to: 'selfcopy/sub/selfcopy' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
await fs.rm(path.join(stacksDir, STACK, 'selfcopy'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects path traversal in from/to with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/copy`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: '../escape.txt', to: 'x.txt' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Bulk operations: delete / move / download ────────────────────────────────
|
||||
|
||||
describe('POST /api/stacks/:stackName/files/bulk-delete', () => {
|
||||
it('rejects unauthenticated requests with 401', async () => {
|
||||
const res = await request(app).post(`/api/stacks/${STACK}/files/bulk-delete`).send({ paths: ['a.txt'] });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('viewer receives 403', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ paths: ['a.txt'] });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects an empty or oversized selection with 400', async () => {
|
||||
const empty = await request(app).post(`/api/stacks/${STACK}/files/bulk-delete`).set('Cookie', adminCookie).send({ paths: [] });
|
||||
expect(empty.status).toBe(400);
|
||||
const tooMany = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ paths: Array.from({ length: 101 }, (_, i) => `f${i}.txt`) });
|
||||
expect(tooMany.status).toBe(400);
|
||||
expect(tooMany.body.code).toBe('TOO_MANY');
|
||||
});
|
||||
|
||||
it('rejects a selection containing an invalid path with 400', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ paths: ['ok.txt', '../escape'] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
});
|
||||
|
||||
it('deletes multiple files and reports per-item results', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'bd1.txt'), '1');
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'bd2.txt'), '2');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ paths: ['bd1.txt', 'bd2.txt', 'missing.txt'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deleted).toEqual(expect.arrayContaining(['bd1.txt', 'bd2.txt']));
|
||||
expect(res.body.failed).toHaveLength(1);
|
||||
expect(res.body.failed[0].path).toBe('missing.txt');
|
||||
await expect(fs.access(path.join(stacksDir, STACK, 'bd1.txt'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('deletes a non-empty folder recursively', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'bdir/sub'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'bdir/sub/x.txt'), 'x');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ paths: ['bdir'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deleted).toEqual(['bdir']);
|
||||
await expect(fs.access(path.join(stacksDir, STACK, 'bdir'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('reports a protected file as a per-item failure, not a thrown request', async () => {
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ paths: ['compose.yaml'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.deleted).toEqual([]);
|
||||
expect(res.body.failed[0].path).toBe('compose.yaml');
|
||||
// compose.yaml must survive.
|
||||
expect(await fs.readFile(path.join(stacksDir, STACK, 'compose.yaml'), 'utf-8')).toContain('services');
|
||||
});
|
||||
|
||||
it('normalizes a directly-submitted ancestor+descendant selection (no double-process)', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'ndir'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'ndir/child.txt'), 'c');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ paths: ['ndir', 'ndir/child.txt'] });
|
||||
expect(res.status).toBe(200);
|
||||
// Only the ancestor is acted on; the descendant is dropped, so no spurious failure.
|
||||
expect(res.body.deleted).toEqual(['ndir']);
|
||||
expect(res.body.failed).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns 403 for a read-only root before deleting', async () => {
|
||||
const readonlyRoot: StackFileRoot = {
|
||||
id: 'bind:robd', kind: 'bind', label: '/ro', hostPathOrName: path.join(stacksDir, 'ro'),
|
||||
mounts: [{ service: 'app', containerPath: '/ro', readOnly: true }],
|
||||
readonly: true, accessible: true, browsable: true, writable: false,
|
||||
chmodable: false, dangerous: false, managedSourceOverlap: false,
|
||||
warning: 'This location is read-only.', backend: 'fs',
|
||||
};
|
||||
const { StackFileRootsService } = await import('../services/StackFileRootsService');
|
||||
const spy = vi.spyOn(StackFileRootsService.prototype, 'resolveRoot').mockResolvedValue(readonlyRoot);
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-delete`)
|
||||
.query({ rootId: 'bind:robd' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ paths: ['x.txt'] });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('READONLY_ROOT');
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/stacks/:stackName/files/bulk-move', () => {
|
||||
it('moves multiple entries into a destination folder', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'bmdest'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'bm1.txt'), '1');
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'bm2.txt'), '2');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-move`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: ['bm1.txt', 'bm2.txt'], toDir: 'bmdest' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.moved).toEqual(expect.arrayContaining(['bm1.txt', 'bm2.txt']));
|
||||
expect(await fs.readFile(path.join(stacksDir, STACK, 'bmdest/bm1.txt'), 'utf-8')).toBe('1');
|
||||
await fs.rm(path.join(stacksDir, STACK, 'bmdest'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports a same-name collision at the destination as a per-item failure', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'bmd2'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'bmcol.txt'), 'src');
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'bmd2/bmcol.txt'), 'existing');
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-move`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: ['bmcol.txt'], toDir: 'bmd2' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.moved).toEqual([]);
|
||||
expect(res.body.failed[0].path).toBe('bmcol.txt');
|
||||
await fs.rm(path.join(stacksDir, STACK, 'bmd2'), { recursive: true, force: true });
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'bmcol.txt'));
|
||||
});
|
||||
|
||||
it('rejects the whole request when the destination is inside a selected folder (400)', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'bmself/sub'), { recursive: true });
|
||||
const res = await request(app)
|
||||
.post(`/api/stacks/${STACK}/files/bulk-move`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ from: ['bmself'], toDir: 'bmself/sub' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('INVALID_PATH');
|
||||
await fs.rm(path.join(stacksDir, STACK, 'bmself'), { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/stacks/:stackName/files/bulk-download', () => {
|
||||
it('streams a .tar.gz of the selection (viewer/read access), de-duplicating nested paths', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'dl/sub'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'dl/sub/deep.txt'), 'deep');
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'dl-top.txt'), 'top');
|
||||
// Select the dir, a file inside it (redundant), and a top-level file.
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/bulk-download`)
|
||||
.query({ path: ['dl', 'dl/sub/deep.txt', 'dl-top.txt'] })
|
||||
.set('Cookie', viewerCookie) // a read-only viewer can download
|
||||
.buffer(true)
|
||||
.parse(binaryParser);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toContain('.tar.gz');
|
||||
const entries = await extractTarGz(res.body as Buffer);
|
||||
// Nested selection de-duplicated: deep.txt appears once, under its dir path.
|
||||
expect(entries['dl/sub/deep.txt']).toBe('deep');
|
||||
expect(entries['dl-top.txt']).toBe('top');
|
||||
expect(Object.keys(entries)).toHaveLength(2);
|
||||
// Tar entry names are relative POSIX paths (no leading slash, no '..').
|
||||
for (const name of Object.keys(entries)) {
|
||||
expect(name.startsWith('/')).toBe(false);
|
||||
expect(name.includes('..')).toBe(false);
|
||||
}
|
||||
await fs.rm(path.join(stacksDir, STACK, 'dl'), { recursive: true, force: true });
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'dl-top.txt'));
|
||||
});
|
||||
|
||||
it('lets a viewer download but not bulk-delete or bulk-move', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'v.txt'), 'v');
|
||||
const dl = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/bulk-download`)
|
||||
.query({ path: ['v.txt'] })
|
||||
.set('Cookie', viewerCookie)
|
||||
.buffer(true)
|
||||
.parse(binaryParser);
|
||||
expect(dl.status).toBe(200);
|
||||
const del = await request(app).post(`/api/stacks/${STACK}/files/bulk-delete`).set('Cookie', viewerCookie).send({ paths: ['v.txt'] });
|
||||
expect(del.status).toBe(403);
|
||||
const mv = await request(app).post(`/api/stacks/${STACK}/files/bulk-move`).set('Cookie', viewerCookie).send({ from: ['v.txt'], toDir: '' });
|
||||
expect(mv.status).toBe(403);
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'v.txt'));
|
||||
});
|
||||
|
||||
// The helper (named-volume) bulk path (rootCaseSensitive helper branch,
|
||||
// FileRootGateway.stat/download helper branch) runs an Alpine container per
|
||||
// file and is validated on Linux / CI, not on this workstation.
|
||||
|
||||
it('returns 413 when the total byte cap is exceeded', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'big.bin'), 'x');
|
||||
const { FileRootGateway } = await import('../services/FileRootGateway');
|
||||
const spy = vi.spyOn(FileRootGateway.prototype, 'stat').mockResolvedValue({
|
||||
name: 'big.bin', type: 'file', size: 2 * 1024 * 1024 * 1024, mtime: 0, isProtected: false,
|
||||
});
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/bulk-download`)
|
||||
.query({ path: ['big.bin'] })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body.code).toBe('TOO_LARGE');
|
||||
spy.mockRestore();
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'big.bin'));
|
||||
});
|
||||
|
||||
it('returns 400 UNSUPPORTED when an entry is not archivable (e.g. a helper symlink/non-regular file)', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'odd.bin'), 'x');
|
||||
// assertArchivable only rejects on helper roots; force the rejection so the
|
||||
// route's mapping (which fires for any backend) is covered without a volume.
|
||||
const { FileRootGateway } = await import('../services/FileRootGateway');
|
||||
const spy = vi.spyOn(FileRootGateway.prototype, 'assertArchivable').mockImplementation(() => {
|
||||
throw Object.assign(new Error('"odd.bin" cannot be downloaded from this volume'), { code: 'ARCHIVE_UNSUPPORTED' });
|
||||
});
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/bulk-download`)
|
||||
.query({ path: ['odd.bin'] })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('UNSUPPORTED');
|
||||
spy.mockRestore();
|
||||
await fs.unlink(path.join(stacksDir, STACK, 'odd.bin'));
|
||||
});
|
||||
|
||||
it('returns 413 (before any archive byte) when the entry cap is exceeded', async () => {
|
||||
await fs.mkdir(path.join(stacksDir, STACK, 'huge'), { recursive: true });
|
||||
await fs.writeFile(path.join(stacksDir, STACK, 'huge/a.txt'), 'a');
|
||||
// A truncated directory listing means more entries than the archive cap.
|
||||
const { FileRootGateway } = await import('../services/FileRootGateway');
|
||||
const spy = vi.spyOn(FileRootGateway.prototype, 'listDir').mockResolvedValue({
|
||||
entries: [{ name: 'a.txt', type: 'file', size: 1, mtime: 0, isProtected: false }],
|
||||
total: 999999,
|
||||
truncated: true,
|
||||
});
|
||||
const res = await request(app)
|
||||
.get(`/api/stacks/${STACK}/files/bulk-download`)
|
||||
.query({ path: ['huge'] })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(413);
|
||||
expect(res.body.code).toBe('TOO_LARGE');
|
||||
spy.mockRestore();
|
||||
await fs.rm(path.join(stacksDir, STACK, 'huge'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects an empty selection with 400', async () => {
|
||||
const res = await request(app).get(`/api/stacks/${STACK}/files/bulk-download`).set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ── PUT /:stackName/files/permissions ────────────────────────────────────────
|
||||
|
||||
describe('PUT /api/stacks/:stackName/files/permissions', () => {
|
||||
|
||||
Reference in New Issue
Block a user