feat(files): per-stack file explorer (#780)

* 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.
This commit is contained in:
Anso
2026-04-26 13:05:19 -04:00
committed by GitHub
parent dd9d33813b
commit 801a098a5b
29 changed files with 3645 additions and 71 deletions
@@ -0,0 +1,64 @@
/**
* Unit tests for isBinaryBuffer from utils/binaryDetect.ts.
*
* The function uses byte-range sampling to classify buffers:
* - Empty buffers are text (returns false)
* - Any NUL byte triggers immediate binary detection
* - More than 30% non-printable bytes in the sample triggers binary detection
* - The sampleBytes parameter limits how many bytes are examined
*/
import { describe, it, expect } from 'vitest';
import { isBinaryBuffer } from '../utils/binaryDetect';
describe('isBinaryBuffer', () => {
it('returns false for empty buffer', () => {
expect(isBinaryBuffer(Buffer.alloc(0))).toBe(false);
});
it('returns false for plain ASCII text', () => {
const buf = Buffer.from('hello world\nfoo: bar\n');
expect(isBinaryBuffer(buf)).toBe(false);
});
it('returns false for YAML content', () => {
const yaml = Buffer.from('services:\n app:\n image: nginx:latest\n');
expect(isBinaryBuffer(yaml)).toBe(false);
});
it('returns true when NUL byte present', () => {
const buf = Buffer.from([0x68, 0x65, 0x6c, 0x00, 0x6c, 0x6f]);
expect(isBinaryBuffer(buf)).toBe(true);
});
it('returns true for PNG header (binary)', () => {
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(100, 0xff)]);
expect(isBinaryBuffer(png)).toBe(true);
});
it('returns true when >30% bytes are non-printable', () => {
// 101 bytes: 70 printable 'a' (0x61) + 31 non-printable (0x01)
// ratio = 31/101 > 0.30
const buf = Buffer.concat([Buffer.alloc(31, 0x01), Buffer.alloc(70, 0x61)]);
expect(isBinaryBuffer(buf)).toBe(true);
});
it('returns false when exactly 30% are non-printable', () => {
// 100 bytes: 70 printable + 30 non-printable (0x01, not NUL)
// ratio = 30/100 = 0.30, which is NOT > 0.30, so should return false
const buf = Buffer.concat([Buffer.alloc(30, 0x01), Buffer.alloc(70, 0x61)]);
expect(isBinaryBuffer(buf)).toBe(false);
});
it('respects sampleBytes=0 (empty sample returns false)', () => {
// sampleBytes=0 means subarray(0,0) which is empty, so no bytes to check
const buf = Buffer.from([0x00, 0x61, 0x61]);
expect(isBinaryBuffer(buf, 0)).toBe(false);
});
it('only samples the first N bytes', () => {
// First byte is printable 'a', second is NUL — only sample 1 byte, so no NUL detected
const buf = Buffer.from([0x61, 0x00, 0x61]);
expect(isBinaryBuffer(buf, 1)).toBe(false);
});
});
@@ -0,0 +1,332 @@
/**
* 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' });
});
});
});
@@ -0,0 +1,447 @@
/**
* Route-level tests for the stack file explorer endpoints:
* GET /:stackName/files
* GET /:stackName/files/content
* GET /:stackName/files/download (Skipper+)
* POST /:stackName/files/upload (Skipper+)
* PUT /:stackName/files/content (Skipper+)
* DELETE /:stackName/files (Skipper+)
* POST /:stackName/files/folder (Skipper+)
*
* Covers: auth gating, tier gating (Community vs paid), input validation,
* upload size limit, and happy-path 204/200 responses.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import bcrypt from 'bcrypt';
import { promises as fs } from 'fs';
import path from 'path';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
// 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
// on Windows; it is covered on Linux (CI).
const isWindows = process.platform === 'win32';
let tmpDir: string;
let app: import('express').Express;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
let viewerCookie: string;
let stacksDir: string;
const STACK = 'teststack';
beforeAll(async () => {
tmpDir = await setupTestDb();
stacksDir = process.env.COMPOSE_DIR!;
// Create stack directory so file operations have something to work with
await fs.mkdir(path.join(stacksDir, STACK), { recursive: true });
await fs.writeFile(path.join(stacksDir, STACK, 'compose.yaml'), 'services: {}\n');
await fs.writeFile(path.join(stacksDir, STACK, '.env'), 'KEY=val\n');
({ LicenseService } = await import('../services/LicenseService'));
({ DatabaseService } = await import('../services/DatabaseService'));
// Default: paid tier so most tests pass the tier gate
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'files-viewer', password_hash: viewerHash, role: 'viewer' });
const viewerRes = await request(app).post('/api/auth/login').send({ username: 'files-viewer', password: 'viewerpass' });
const viewerCookies = viewerRes.headers['set-cookie'] as string | string[];
viewerCookie = Array.isArray(viewerCookies) ? viewerCookies[0] : viewerCookies;
});
afterAll(async () => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
// Restore all spies then re-establish the paid-tier default so per-test
// overrides via mockReturnValueOnce don't accumulate across tests.
vi.restoreAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
});
// ── GET /:stackName/files ─────────────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app).get(`/api/stacks/${STACK}/files`);
expect(res.status).toBe(401);
});
it('returns 200 with entries array for authenticated admin', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
});
it('includes compose.yaml and .env in the listing', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
const names = res.body.map((e: { name: string }) => e.name);
expect(names).toContain('compose.yaml');
expect(names).toContain('.env');
});
it('returns 400 for an invalid stack name containing path traversal', async () => {
const res = await request(app)
.get('/api/stacks/../evil/files')
.set('Cookie', adminCookie);
// Express may normalise the URL before it reaches the handler;
// the important thing is we never get 200
expect([400, 404]).toContain(res.status);
});
it('returns 400 for a stack name with special characters', async () => {
const res = await request(app)
.get('/api/stacks/my%20stack/files')
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
});
// ── GET /:stackName/files/content ─────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files/content', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
expect(res.body.code).toBe('INVALID_PATH');
});
it('returns 200 with file content for an existing text file', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.binary).toBe(false);
expect(res.body.oversized).toBe(false);
expect(typeof res.body.content).toBe('string');
});
it('returns 404 for a non-existent file', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'nonexistent.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(404);
});
it('returns oversized:true for a file larger than the 2 MB read limit', async () => {
const bigPath = path.join(stacksDir, STACK, 'oversized.txt');
await fs.writeFile(bigPath, 'x'.repeat(3 * 1024 * 1024));
const res = await request(app)
.get(`/api/stacks/${STACK}/files/content`)
.query({ path: 'oversized.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.oversized).toBe(true);
expect(res.body.binary).toBe(false);
expect(res.body.content).toBeUndefined();
await fs.unlink(bigPath);
}, 15000);
});
// ── GET /:stackName/files/download ────────────────────────────────────────────
describe('GET /api/stacks/:stackName/files/download', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('streams the file for a paid tier user', async () => {
const res = await request(app)
.get(`/api/stacks/${STACK}/files/download`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toMatch(/attachment/);
expect(res.text).toContain('services');
});
});
// ── POST /:stackName/files/upload ─────────────────────────────────────────────
describe('POST /api/stacks/:stackName/files/upload', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(403);
});
it('returns 400 when no file is attached', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 413 TOO_LARGE when file exceeds 25 MB', async () => {
// 26 MB buffer
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, 'toobig.txt');
expect(res.status).toBe(413);
expect(res.body.code).toBe('TOO_LARGE');
}, 20000);
it('returns 204 for a valid file upload (paid tier)', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', adminCookie)
.attach('file', Buffer.from('uploaded content'), 'uploaded.txt');
expect(res.status).toBe(204);
// Verify the file was written
const content = await fs.readFile(path.join(stacksDir, STACK, 'uploaded.txt'), 'utf-8');
expect(content).toBe('uploaded content');
});
it('returns 204 and writes into a subdirectory when ?path= is provided', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.query({ path: 'subdir' })
.set('Cookie', adminCookie)
.attach('file', Buffer.from('subdir content'), 'sub.txt');
expect(res.status).toBe(204);
const content = await fs.readFile(path.join(stacksDir, STACK, 'subdir', 'sub.txt'), 'utf-8');
expect(content).toBe('subdir content');
});
});
// ── PUT /:stackName/files/content ─────────────────────────────────────────────
describe('PUT /api/stacks/:stackName/files/content', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.send({ content: 'hello' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', adminCookie)
.send({ content: 'hello' });
expect(res.status).toBe(403);
});
it('returns 400 when content is not a string', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', adminCookie)
.send({ content: 42 });
expect(res.status).toBe(400);
});
it('returns 204 and writes the file for a paid tier admin', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'written.txt' })
.set('Cookie', adminCookie)
.send({ content: 'written via PUT' });
expect(res.status).toBe(204);
const content = await fs.readFile(path.join(stacksDir, STACK, 'written.txt'), 'utf-8');
expect(content).toBe('written via PUT');
});
});
// ── DELETE /:stackName/files ──────────────────────────────────────────────────
describe('DELETE /api/stacks/:stackName/files', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('returns 400 when path is missing', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 204 on successful file deletion', async () => {
// Create a disposable file first
await fs.writeFile(path.join(stacksDir, STACK, 'todelete.txt'), 'bye');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'todelete.txt' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
await expect(fs.access(path.join(stacksDir, STACK, 'todelete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it.skipIf(isWindows)('returns 409 NOT_EMPTY when deleting a non-empty directory without recursive flag (Linux/macOS only)', async () => {
const dirPath = path.join(stacksDir, STACK, 'nonemptydir');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'child.txt'), '');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'nonemptydir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(409);
expect(res.body.code).toBe('NOT_EMPTY');
});
it.skipIf(isWindows)('returns 204 and removes a non-empty directory when recursive=1 (Linux/macOS only)', async () => {
const dirPath = path.join(stacksDir, STACK, 'recursivedir');
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(dirPath, 'child.txt'), 'data');
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'recursivedir', recursive: '1' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
await expect(fs.access(dirPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
});
// ── POST /:stackName/files/folder ─────────────────────────────────────────────
describe('POST /api/stacks/:stackName/files/folder', () => {
it('rejects unauthenticated requests with 401', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'newdir' });
expect(res.status).toBe(401);
});
it('returns 403 for Community tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'newdir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(403);
});
it('returns 400 when path is missing', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.set('Cookie', adminCookie);
expect(res.status).toBe(400);
});
it('returns 204 and creates the directory for a paid tier admin', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'mynewdir' })
.set('Cookie', adminCookie);
expect(res.status).toBe(204);
const stat = await fs.stat(path.join(stacksDir, STACK, 'mynewdir'));
expect(stat.isDirectory()).toBe(true);
});
});
// ── permission gating ─────────────────────────────────────────────────────────
describe('permission gating', () => {
it('viewer receives 403 from PUT /files/content', async () => {
const res = await request(app)
.put(`/api/stacks/${STACK}/files/content`)
.query({ path: 'new.txt' })
.set('Cookie', viewerCookie)
.send({ content: 'hello' });
expect(res.status).toBe(403);
});
it('viewer receives 403 from POST /files/upload', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/upload`)
.set('Cookie', viewerCookie)
.attach('file', Buffer.from('data'), 'test.txt');
expect(res.status).toBe(403);
});
it('viewer receives 403 from DELETE /files', async () => {
const res = await request(app)
.delete(`/api/stacks/${STACK}/files`)
.query({ path: 'compose.yaml' })
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
it('viewer receives 403 from POST /files/folder', async () => {
const res = await request(app)
.post(`/api/stacks/${STACK}/files/folder`)
.query({ path: 'somedir' })
.set('Cookie', viewerCookie);
expect(res.status).toBe(403);
});
});