mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
801a098a5b
* feat(files): backend foundation for stack file explorer
Install multer for multipart file upload handling. Add
isValidRelativeStackPath to validation.ts to guard client-supplied
relative paths against traversal, absolute paths, NUL bytes, backslash
injection, and double-slash segments. Add isBinaryBuffer to a new
binaryDetect.ts utility for heuristic text/binary detection via
NUL-byte fast exit and non-printable byte ratio sampling.
* fix(files): reject bare dot segments in isValidRelativeStackPath
* feat(files): add safe stack-scoped file I/O methods to FileSystemService
Adds FileEntry interface and seven new public methods to FileSystemService
for stack-scoped file operations: listStackDirectory, readStackFile,
streamStackFile, writeStackFile, deleteStackPath, mkdirStackPath, and
statStackEntry.
Each method routes through a private resolveSafeStackPath helper that
enforces two-phase path containment: a pre-realpath lexical check plus a
post-realpath symlink-escape check. ENOENT targets are handled by walking
up to the deepest existing ancestor, realpaths that ancestor, and
reattaching the remaining suffix.
Binary detection delegates to isBinaryBuffer; path safety delegates to
isPathWithinBase. Protected file names and the MIME map are module-level
constants to avoid repeated allocation.
* feat(files): frontend API wrappers and Monaco language helper
* fix(files): tighten stackFilesApi error handling and localOnly support
* fix(files): FileSystemService safety and correctness fixes
* feat(files): add file explorer API endpoints to stacks router
* feat(files): FileTree and FileTreeNode components
* fix(files): route security hardening and stream cleanup
* fix(files): FileTree accessibility, icon stroke, stale fetch guard
Add strokeWidth={1.5} to all Lucide icons in FileTreeNode to match the
design system. Add aria-expanded to directory rows for accessibility.
Guard handleDirClick .then() callbacks against stale stack name
references when the component re-renders with a new stack. Add
toast.info fallbacks when compose.yaml or .env is clicked without a
navigation callback registered.
* feat(files): FileViewer, FileUploadDropzone, NewFolderDialog, DeleteFileConfirm
* fix(files): resolve code quality findings in file explorer components
- Move editorOptions useMemo above conditional returns in FileViewer (Rules of Hooks fix)
- Fix blob download: append anchor to DOM before click, defer URL revoke 100ms
- Keep protected-file confirm input visible during NOT_EMPTY recursive retry in DeleteFileConfirm
- Remove non-functional cursor-pointer/onClick from Community upgrade pill in FileUploadDropzone
- Add success toast on folder creation in NewFolderDialog
- Switch all (e as Error).message casts to instanceof Error narrowing
* test(files): unit tests for binary detection, stack path safety, and file explorer routes
- binary-detection.test.ts: covers isBinaryBuffer edge cases (empty, NUL,
PNG header, threshold boundary, sampleBytes parameter)
- filesystem-stack-paths.test.ts: covers isValidRelativeStackPath (accepts/
rejects matrix) and FileSystemService stack methods against a real temp dir
(listStackDirectory sort and protection flags, readStackFile text/binary/
oversized paths, writeStackFile/Buffer, deleteStackPath, mkdirStackPath,
traversal guard); platform-specific empty-dir/NOT_EMPTY cases skip on Windows
- stack-files-routes.test.ts: route-level integration tests for all seven
file explorer endpoints; covers auth gating, Community-tier 403 gates,
input validation, 413 TOO_LARGE upload limit, and 204/200 happy paths
* feat(files): StackFileExplorer container with lazy tree, viewer, and action bar
* fix(files): add Download button to explorer toolbar, fix Community upgrade pill, reset state on stack change
* test(files): add missing test coverage for file explorer routes and service
* feat(files): add Files tab to EditorLayout with StackFileExplorer integration
* fix(files): add defensive activeTab guard to saveFile and discardChanges
* test(files): unit tests for FileTree expand/collapse and FileViewer render modes
Covers the three FileViewer content modes (text/Monaco, binary panel,
oversized panel) and the FileTree expand/collapse/cache cycle: first
expand fetches the subdirectory, second click collapses without a fetch,
third click re-expands from the in-memory cache without a second fetch.
* test(e2e): file explorer community and skipper+ flows
Covers the full file-explorer feature surface in two describe blocks:
Community (read-only): intercepts /api/license to simulate community
tier, confirms the upgrade pill is visible in the left pane, and
asserts that the Save button is absent after opening a text file.
Skipper+ (full CRUD): uploads a text file and confirms it appears in
the tree; edits config/app.conf and saves via Monaco; deletes an
uploaded file and asserts the tree entry is gone; issues a raw HTTP
request to the download endpoint and checks for status 200 and the
content-disposition: attachment header.
Also adds data-testid="file-action-delete" to the action bar Delete
button in StackFileExplorer for stable targeting, and exports
waitForStacksLoaded from e2e/helpers.ts to eliminate the three
identical local copies in stacks, deploy-log-panel, and stack-files
spec files.
* fix(e2e): improve test isolation and selector stability in stack-files spec
Move beforeEach seed to beforeAll/afterAll so fixtures are created once per
suite, not before every test. Extract shared seedSuite/teardownSuite helpers
to eliminate the duplicate beforeAll/afterAll blocks. Wrap teardown in
try/catch so failures log a warning rather than masking test results.
Replace waitForTimeout(500) with a deterministic expect on the file tree
sentinel. Add data-testid="anatomy-files-btn" and data-testid="delete-confirm-btn"
to replace the fragile button text/positional selectors. Assert Save button
starts disabled before editing.
* docs(files): add stack file explorer documentation
Add user-facing guide for the stack file explorer feature covering
tier access (Community read-only, Skipper+ read-write), viewing
limits, upload/download caps, protected file routing, and
troubleshooting. Update the editor page to reference the new guide
and register the page in the navigation.
* fix(docs): use canonical Skipper tier name in file explorer overview card
* fix(files): resolve lint errors blocking CI
Remove unnecessary backslash escape before double-quote in the
Content-Disposition regex (no-useless-escape). Replace five synchronous
setState resets at the top of the FileTree mount effect with a React key
prop on the FileTree element in StackFileExplorer so remounting resets
state automatically, eliminating the react-hooks/set-state-in-effect
violation.
* test(files): fix e2e seeding to work on community-tier CI
Replace the browser-side paid upload/mkdir API calls in seedTestStack with
direct Node fs writes. The upload and folder endpoints require Skipper+ so
they returned 403 on CI, which runs with no license set. Stack creation
via POST /api/stacks stays as an API call since it is community-allowed and
keeps the backend registry in sync.
Add a per-test tier check in the Skipper+ beforeEach that skips gracefully
when the instance is community, matching the pattern in auto-heal-policies.
333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
/**
|
|
* Tests for isValidRelativeStackPath (pure function) and the stack-scoped
|
|
* file methods on FileSystemService (listStackDirectory, readStackFile,
|
|
* writeStackFile, writeStackFileBuffer, deleteStackPath, mkdirStackPath).
|
|
*
|
|
* FileSystemService stack methods are tested against a real temp directory so
|
|
* that realpath, stat, and fs I/O all run with actual OS semantics.
|
|
* NodeRegistry is mocked to redirect the composeDir to our temp location.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { promises as fs } from 'fs';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { isValidRelativeStackPath } from '../utils/validation';
|
|
|
|
// On Windows, fs.unlink on a directory returns EPERM rather than EISDIR.
|
|
// The deleteStackPath empty-dir and NOT_EMPTY paths rely on EISDIR (Linux/macOS).
|
|
// Skip those specific cases on Windows.
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
// Mutable state the mocked NodeRegistry reads. Each beforeEach updates it
|
|
// before any FileSystemService method runs.
|
|
const mockState = { composeDir: '' };
|
|
|
|
vi.mock('../services/NodeRegistry', () => ({
|
|
NodeRegistry: {
|
|
getInstance: () => ({
|
|
getComposeDir: () => mockState.composeDir,
|
|
getDefaultNodeId: () => 1,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../utils/debug', () => ({ isDebugEnabled: () => false }));
|
|
|
|
import { FileSystemService } from '../services/FileSystemService';
|
|
|
|
// ── isValidRelativeStackPath ──────────────────────────────────────────────────
|
|
|
|
describe('isValidRelativeStackPath', () => {
|
|
// Accepted inputs
|
|
it('accepts empty string (stack root)', () => expect(isValidRelativeStackPath('')).toBe(true));
|
|
it('accepts simple filename', () => expect(isValidRelativeStackPath('compose.yaml')).toBe(true));
|
|
it('accepts dotfile', () => expect(isValidRelativeStackPath('.env')).toBe(true));
|
|
it('accepts nested path', () => expect(isValidRelativeStackPath('config/app.conf')).toBe(true));
|
|
it('accepts deeply nested path', () => expect(isValidRelativeStackPath('a/b/c/d.txt')).toBe(true));
|
|
|
|
// Rejected inputs
|
|
it('rejects ..', () => expect(isValidRelativeStackPath('..')).toBe(false));
|
|
it('rejects ../etc/passwd traversal', () => expect(isValidRelativeStackPath('../etc/passwd')).toBe(false));
|
|
it('rejects a/../b', () => expect(isValidRelativeStackPath('a/../b')).toBe(false));
|
|
it('rejects absolute path', () => expect(isValidRelativeStackPath('/etc/passwd')).toBe(false));
|
|
it('rejects Windows drive path', () => expect(isValidRelativeStackPath('C:/windows')).toBe(false));
|
|
it('rejects NUL byte', () => expect(isValidRelativeStackPath('file\x00name')).toBe(false));
|
|
it('rejects backslash', () => expect(isValidRelativeStackPath('path\\file')).toBe(false));
|
|
it('rejects double-slash', () => expect(isValidRelativeStackPath('a//b')).toBe(false));
|
|
it('rejects bare dot segment', () => expect(isValidRelativeStackPath('a/./b')).toBe(false));
|
|
});
|
|
|
|
// ── FileSystemService stack methods ──────────────────────────────────────────
|
|
|
|
describe('FileSystemService stack methods', () => {
|
|
const STACK = 'mystack';
|
|
let tmpBase: string;
|
|
let stackDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-fsp-'));
|
|
stackDir = path.join(tmpBase, STACK);
|
|
mockState.composeDir = tmpBase;
|
|
await fs.mkdir(stackDir, { recursive: true });
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpBase, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── listStackDirectory ──────────────────────────────────────────────────
|
|
|
|
describe('listStackDirectory', () => {
|
|
it('returns entries with directories sorted before files', async () => {
|
|
await fs.mkdir(path.join(stackDir, 'config'));
|
|
await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
|
|
await fs.writeFile(path.join(stackDir, '.env'), 'KEY=val\n');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
const entries = await service.listStackDirectory(STACK, '');
|
|
|
|
// Directories come first
|
|
expect(entries[0].type).toBe('directory');
|
|
expect(entries[0].name).toBe('config');
|
|
|
|
// Files follow, sorted alphabetically (case-insensitive)
|
|
const fileNames = entries.filter(e => e.type === 'file').map(e => e.name);
|
|
expect(fileNames).toEqual(['.env', 'compose.yaml']);
|
|
});
|
|
|
|
it('marks compose.yaml and .env as protected', async () => {
|
|
await fs.writeFile(path.join(stackDir, 'compose.yaml'), '');
|
|
await fs.writeFile(path.join(stackDir, '.env'), '');
|
|
await fs.writeFile(path.join(stackDir, 'custom.conf'), '');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
const entries = await service.listStackDirectory(STACK, '');
|
|
|
|
const byName = Object.fromEntries(entries.map(e => [e.name, e]));
|
|
expect(byName['compose.yaml'].isProtected).toBe(true);
|
|
expect(byName['.env'].isProtected).toBe(true);
|
|
expect(byName['custom.conf'].isProtected).toBe(false);
|
|
});
|
|
|
|
it('includes size and mtime for files', async () => {
|
|
await fs.writeFile(path.join(stackDir, 'test.txt'), 'hello');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
const entries = await service.listStackDirectory(STACK, '');
|
|
|
|
const file = entries.find(e => e.name === 'test.txt');
|
|
expect(file).toBeDefined();
|
|
expect(file!.size).toBe(5);
|
|
expect(file!.mtime).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('returns empty array for an empty stack directory', async () => {
|
|
const service = FileSystemService.getInstance();
|
|
const entries = await service.listStackDirectory(STACK, '');
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
it('lists a subdirectory when relPath is provided', async () => {
|
|
await fs.mkdir(path.join(stackDir, 'sub'));
|
|
await fs.writeFile(path.join(stackDir, 'sub', 'child.txt'), 'data');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
const entries = await service.listStackDirectory(STACK, 'sub');
|
|
expect(entries.length).toBe(1);
|
|
expect(entries[0].name).toBe('child.txt');
|
|
});
|
|
});
|
|
|
|
// ── readStackFile ───────────────────────────────────────────────────────
|
|
|
|
describe('readStackFile', () => {
|
|
it('returns text content for a UTF-8 file', async () => {
|
|
await fs.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
const result = await service.readStackFile(STACK, 'compose.yaml');
|
|
expect(result.binary).toBe(false);
|
|
expect(result.oversized).toBe(false);
|
|
expect(result.content).toBe('services: {}\n');
|
|
});
|
|
|
|
it('returns binary:true and no content for a binary file', async () => {
|
|
// PNG magic bytes followed by non-printable data
|
|
const pngMagic = Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
Buffer.alloc(50, 0xff),
|
|
]);
|
|
await fs.writeFile(path.join(stackDir, 'icon.png'), pngMagic);
|
|
|
|
const service = FileSystemService.getInstance();
|
|
const result = await service.readStackFile(STACK, 'icon.png');
|
|
expect(result.binary).toBe(true);
|
|
expect(result.oversized).toBe(false);
|
|
expect(result.content).toBeUndefined();
|
|
});
|
|
|
|
it('returns oversized:true for files exceeding maxBytes', async () => {
|
|
// Write a text file larger than our small maxBytes limit
|
|
await fs.writeFile(path.join(stackDir, 'big.txt'), 'a'.repeat(200));
|
|
|
|
const service = FileSystemService.getInstance();
|
|
// maxBytes=100 forces the oversized path
|
|
const result = await service.readStackFile(STACK, 'big.txt', 100);
|
|
expect(result.oversized).toBe(true);
|
|
expect(result.size).toBe(200);
|
|
});
|
|
|
|
it('throws IS_DIRECTORY when path points to a directory', async () => {
|
|
await fs.mkdir(path.join(stackDir, 'subdir'));
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await expect(service.readStackFile(STACK, 'subdir')).rejects.toMatchObject({ code: 'IS_DIRECTORY' });
|
|
});
|
|
});
|
|
|
|
// ── writeStackFile ──────────────────────────────────────────────────────
|
|
|
|
describe('writeStackFile', () => {
|
|
it('creates a new file with the given content', async () => {
|
|
const service = FileSystemService.getInstance();
|
|
await service.writeStackFile(STACK, 'new.txt', 'hello world');
|
|
|
|
const content = await fs.readFile(path.join(stackDir, 'new.txt'), 'utf-8');
|
|
expect(content).toBe('hello world');
|
|
});
|
|
|
|
it('overwrites an existing file', async () => {
|
|
await fs.writeFile(path.join(stackDir, 'data.txt'), 'original');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await service.writeStackFile(STACK, 'data.txt', 'updated');
|
|
|
|
const content = await fs.readFile(path.join(stackDir, 'data.txt'), 'utf-8');
|
|
expect(content).toBe('updated');
|
|
});
|
|
|
|
it('creates parent directories if they do not exist', async () => {
|
|
const service = FileSystemService.getInstance();
|
|
await service.writeStackFile(STACK, 'deep/nested/file.txt', 'content');
|
|
|
|
const content = await fs.readFile(path.join(stackDir, 'deep', 'nested', 'file.txt'), 'utf-8');
|
|
expect(content).toBe('content');
|
|
});
|
|
});
|
|
|
|
// ── writeStackFileBuffer ────────────────────────────────────────────────
|
|
|
|
describe('writeStackFileBuffer', () => {
|
|
it('writes raw bytes correctly', async () => {
|
|
const data = Buffer.from([0x01, 0x02, 0x03, 0xff]);
|
|
const service = FileSystemService.getInstance();
|
|
await service.writeStackFileBuffer(STACK, 'binary.bin', data);
|
|
|
|
const read = await fs.readFile(path.join(stackDir, 'binary.bin'));
|
|
expect(read).toEqual(data);
|
|
});
|
|
|
|
it('creates parent directories when needed', async () => {
|
|
const payload = Buffer.from([0xde, 0xad]);
|
|
const service = FileSystemService.getInstance();
|
|
await service.writeStackFileBuffer(STACK, 'sub/img.bin', payload);
|
|
|
|
const read = await fs.readFile(path.join(stackDir, 'sub', 'img.bin'));
|
|
expect(read).toEqual(payload);
|
|
});
|
|
});
|
|
|
|
// ── deleteStackPath ─────────────────────────────────────────────────────
|
|
|
|
describe('deleteStackPath', () => {
|
|
it('deletes a file', async () => {
|
|
await fs.writeFile(path.join(stackDir, 'todelete.txt'), '');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await service.deleteStackPath(STACK, 'todelete.txt');
|
|
|
|
await expect(fs.access(path.join(stackDir, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it.skipIf(isWindows)('deletes an empty directory (Linux/macOS only: Windows unlink returns EPERM)', async () => {
|
|
await fs.mkdir(path.join(stackDir, 'emptydir'));
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await service.deleteStackPath(STACK, 'emptydir');
|
|
|
|
await expect(fs.access(path.join(stackDir, 'emptydir'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
it.skipIf(isWindows)('throws NOT_EMPTY for non-empty directory without recursive flag (Linux/macOS only)', async () => {
|
|
await fs.mkdir(path.join(stackDir, 'nonempty'));
|
|
await fs.writeFile(path.join(stackDir, 'nonempty', 'child.txt'), '');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await expect(service.deleteStackPath(STACK, 'nonempty', false)).rejects.toMatchObject({ code: 'NOT_EMPTY' });
|
|
});
|
|
|
|
it('recursively deletes a non-empty directory when recursive=true', async () => {
|
|
await fs.mkdir(path.join(stackDir, 'tree'));
|
|
await fs.writeFile(path.join(stackDir, 'tree', 'child.txt'), '');
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await service.deleteStackPath(STACK, 'tree', true);
|
|
|
|
await expect(fs.access(path.join(stackDir, 'tree'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
});
|
|
|
|
// ── mkdirStackPath ──────────────────────────────────────────────────────
|
|
|
|
describe('mkdirStackPath', () => {
|
|
it('creates a new directory', async () => {
|
|
const service = FileSystemService.getInstance();
|
|
await service.mkdirStackPath(STACK, 'newdir');
|
|
|
|
const stat = await fs.stat(path.join(stackDir, 'newdir'));
|
|
expect(stat.isDirectory()).toBe(true);
|
|
});
|
|
|
|
it('creates nested directories', async () => {
|
|
const service = FileSystemService.getInstance();
|
|
await service.mkdirStackPath(STACK, 'a/b/c');
|
|
|
|
const stat = await fs.stat(path.join(stackDir, 'a', 'b', 'c'));
|
|
expect(stat.isDirectory()).toBe(true);
|
|
});
|
|
|
|
it('does not throw when directory already exists', async () => {
|
|
await fs.mkdir(path.join(stackDir, 'existing'));
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await expect(service.mkdirStackPath(STACK, 'existing')).resolves.toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ── path traversal ──────────────────────────────────────────────────────
|
|
|
|
describe('path traversal protection', () => {
|
|
it('throws INVALID_PATH when relPath escapes stack directory via ..', async () => {
|
|
// isValidRelativeStackPath rejects ".." before it reaches the service,
|
|
// but we also test the service-level guard with a stack name that would
|
|
// escape the compose dir (isPathWithinBase check in resolveSafeStackPath).
|
|
const service = FileSystemService.getInstance();
|
|
await expect(service.listStackDirectory('..', '')).rejects.toMatchObject({ code: 'INVALID_PATH' });
|
|
});
|
|
|
|
it('throws INVALID_PATH for a stack name with path separator', async () => {
|
|
const service = FileSystemService.getInstance();
|
|
await expect(service.readStackFile('../other', 'file.txt')).rejects.toMatchObject({ code: 'INVALID_PATH' });
|
|
});
|
|
|
|
it.skipIf(isWindows)('throws SYMLINK_ESCAPE when a symlink inside the stack points outside it (Linux/macOS only)', async () => {
|
|
const externalFile = path.join(tmpBase, 'secret.txt');
|
|
await fs.writeFile(externalFile, 'secret');
|
|
await fs.symlink(externalFile, path.join(stackDir, 'escape-link'));
|
|
|
|
const service = FileSystemService.getInstance();
|
|
await expect(service.readStackFile(STACK, 'escape-link')).rejects.toMatchObject({ code: 'SYMLINK_ESCAPE' });
|
|
});
|
|
});
|
|
});
|