mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
d8b6f8cf3b
* feat(stack-files): force-text override for misidentified binary files
The binary-detection heuristic (30% non-printable / NUL in the first
8 KB) sometimes flags UTF-8 files that happen to carry an embedded NUL
or a high non-printable ratio, locking the user out of inline editing
with only a Download fallback.
readStackFile now accepts an optional { forceText: true } that bypasses
isBinaryBuffer on the small-file path and returns the bytes as UTF-8
content. The route exposes this as ?force=text on GET /files/content.
The oversized branch deliberately stays untouched: returning a multi-MB
file as JSON-encoded text is wasteful regardless of the heuristic.
SpecialFilePanel grows an optional extraAction slot. The viewer's
binary branch wires Open as text anyway, which refetches with the new
flag, clears isBinary, and routes the content through the existing
Monaco editor path. A failed override surfaces both an inline error
panel and a toast so the user knows why the click did nothing.
Backend tests pin the heuristic-vs-override behaviour pair on a file
with a literal NUL byte. The frontend test asserts that the second
readStackFile call carries forceText: true and that Monaco mounts.
Troubleshooting accordion entry updated to mention the new affordance.
* fix(stack-files): guard the binary-override path against oversized files
The override on the binary panel could open Monaco against an empty
content buffer if the backend's oversized branch ran (files past the
2 MB inline-preview cap intentionally carry no content even when
force=text is set). Saving that empty buffer would wipe the file on
disk.
Two reinforcing changes:
- Initial load now checks result.oversized before result.binary, so a
file that is both oversized and has binary bytes in the 8 KB probe
shows the Download panel rather than the binary panel. The size
signal stays in front of the operator and the override button never
surfaces for a file that cannot be safely opened inline.
- The handleForceText handler now respects result.oversized on the
refetch and transitions to the Download panel instead of clearing
isBinary and copying result.content ?? '' into Monaco.
Same handler also gains a stale-request guard via a selectedPathRef:
a slow override for file A no longer stomps on file B's state if the
user navigated away while the request was in flight.
Two regression tests pin the new behaviour: oversized+binary surfaces
the Download panel on initial load, and an oversized refetch from the
binary panel routes to the Download panel rather than Monaco.
1030 lines
41 KiB
TypeScript
1030 lines
41 KiB
TypeScript
/**
|
|
* Route-level tests for the stack file explorer endpoints:
|
|
* GET /:stackName/files
|
|
* GET /:stackName/files/content
|
|
* GET /:stackName/files/download
|
|
* POST /:stackName/files/upload
|
|
* PUT /:stackName/files/content
|
|
* DELETE /:stackName/files
|
|
* POST /:stackName/files/folder
|
|
* PATCH /:stackName/files/rename
|
|
* PUT /:stackName/files/permissions
|
|
*
|
|
* The full file explorer is available on every tier; writes still require the
|
|
* `stack:edit` permission (admin role). Tests cover: auth gating, RBAC gating,
|
|
* Community-tier success, 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('emits diagnostic logs only when developer_mode is enabled', async () => {
|
|
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined);
|
|
|
|
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
|
|
await request(app)
|
|
.get(`/api/stacks/${STACK}/files`)
|
|
.set('Cookie', adminCookie);
|
|
expect(debugSpy).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('[Files:diag]'),
|
|
expect.anything(),
|
|
);
|
|
|
|
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
|
|
await request(app)
|
|
.get(`/api/stacks/${STACK}/files`)
|
|
.set('Cookie', adminCookie);
|
|
expect(debugSpy).toHaveBeenCalledWith(
|
|
expect.stringContaining('[Files:diag]'),
|
|
expect.anything(),
|
|
);
|
|
|
|
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '0');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('truncates a directory with more than 1000 entries and advertises totals in headers', async () => {
|
|
const subdir = path.join(stacksDir, STACK, 'huge');
|
|
await fs.mkdir(subdir, { recursive: true });
|
|
// Seed 1100 small files. Names sort lexicographically so we can pin the
|
|
// truncation boundary by inspecting the last returned entry.
|
|
const targetCount = 1100;
|
|
for (let i = 0; i < targetCount; i++) {
|
|
const name = `f${String(i).padStart(5, '0')}.txt`;
|
|
await fs.writeFile(path.join(subdir, name), '');
|
|
}
|
|
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files`)
|
|
.query({ path: 'huge' })
|
|
.set('Cookie', adminCookie);
|
|
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
expect(res.body.length).toBe(1000);
|
|
expect(res.headers['x-total-count']).toBe(String(targetCount));
|
|
expect(res.headers['x-returned-count']).toBe('1000');
|
|
expect(res.headers['x-truncated']).toBe('true');
|
|
|
|
await fs.rm(subdir, { recursive: true, force: true });
|
|
}, 30000);
|
|
|
|
it('does not set X-Truncated when the directory fits under the limit', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files`)
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['x-truncated']).toBeUndefined();
|
|
expect(res.headers['x-total-count']).toBe(String(res.body.length));
|
|
});
|
|
});
|
|
|
|
// ── 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');
|
|
expect(typeof res.body.mtimeMs).toBe('number');
|
|
expect(res.body.mtimeMs).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('sets a quoted ETag header derived from mtimeMs', 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.headers['etag']).toMatch(/^W\/"\d+"$/);
|
|
});
|
|
|
|
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);
|
|
|
|
it('returns binary:true by default for a file with a NUL byte in the probe window', async () => {
|
|
const oddPath = path.join(stacksDir, STACK, 'force-text-default.txt');
|
|
await fs.writeFile(oddPath, Buffer.from('hello\0world rest is utf8 text', 'utf-8'));
|
|
try {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'force-text-default.txt' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.binary).toBe(true);
|
|
expect(res.body.content).toBeUndefined();
|
|
} finally {
|
|
await fs.unlink(oddPath);
|
|
}
|
|
});
|
|
|
|
it('returns the content as UTF-8 when ?force=text is set, even for files the binary heuristic rejects', async () => {
|
|
const oddPath = path.join(stacksDir, STACK, 'force-text-on.txt');
|
|
const raw = Buffer.from('hello\0world rest is utf8 text', 'utf-8');
|
|
await fs.writeFile(oddPath, raw);
|
|
try {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'force-text-on.txt', force: 'text' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.body.binary).toBe(false);
|
|
expect(typeof res.body.content).toBe('string');
|
|
expect(res.body.content).toBe(raw.toString('utf-8'));
|
|
} finally {
|
|
await fs.unlink(oddPath);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── 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('streams the file for a Community-tier admin', 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(200);
|
|
expect(res.headers['content-disposition']).toMatch(/attachment/);
|
|
});
|
|
|
|
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files/download`)
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('INVALID_PATH');
|
|
});
|
|
|
|
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('uploads successfully for a Community-tier admin', 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('community-upload'), 'community-upload.txt');
|
|
expect(res.status).toBe(204);
|
|
|
|
const content = await fs.readFile(path.join(stacksDir, STACK, 'community-upload.txt'), 'utf-8');
|
|
expect(content).toBe('community-upload');
|
|
});
|
|
|
|
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('rejects upload filenames with path separators', async () => {
|
|
const boundary = '----sencho-test-boundary';
|
|
const body = [
|
|
`--${boundary}`,
|
|
'Content-Disposition: form-data; name="file"; filename="../evil.txt"',
|
|
'Content-Type: text/plain',
|
|
'',
|
|
'data',
|
|
`--${boundary}--`,
|
|
'',
|
|
].join('\r\n');
|
|
|
|
const res = await request(app)
|
|
.post(`/api/stacks/${STACK}/files/upload`)
|
|
.set('Cookie', adminCookie)
|
|
.set('Content-Type', `multipart/form-data; boundary=${boundary}`)
|
|
.send(body);
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.error).toBe('Invalid filename');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('returns 409 FILE_EXISTS when the target name already exists and overwrite is not set', async () => {
|
|
const target = path.join(stacksDir, STACK, 'existing.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'), 'existing.txt');
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('FILE_EXISTS');
|
|
// Original content must be preserved when the upload is rejected.
|
|
const after = await fs.readFile(target, 'utf-8');
|
|
expect(after).toBe('original');
|
|
await fs.unlink(target);
|
|
});
|
|
|
|
it('overwrites when ?overwrite=1 is set', async () => {
|
|
const target = path.join(stacksDir, STACK, 'replaceme.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'), 'replaceme.txt');
|
|
expect(res.status).toBe(204);
|
|
const after = await fs.readFile(target, 'utf-8');
|
|
expect(after).toBe('after');
|
|
await fs.unlink(target);
|
|
});
|
|
|
|
it('returns 409 DIR_EXISTS when a directory occupies the upload target name', async () => {
|
|
const dir = path.join(stacksDir, STACK, 'collide-dir');
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const res = await request(app)
|
|
.post(`/api/stacks/${STACK}/files/upload`)
|
|
.set('Cookie', adminCookie)
|
|
.attach('file', Buffer.from('whatever'), 'collide-dir');
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('DIR_EXISTS');
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('still returns 409 DIR_EXISTS even when ?overwrite=1 is set (directories are never replaced)', async () => {
|
|
const dir = path.join(stacksDir, STACK, 'collide-dir-2');
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const res = await request(app)
|
|
.post(`/api/stacks/${STACK}/files/upload`)
|
|
.query({ overwrite: '1' })
|
|
.set('Cookie', adminCookie)
|
|
.attach('file', Buffer.from('whatever'), 'collide-dir-2');
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('DIR_EXISTS');
|
|
// The directory must still exist after the rejected upload.
|
|
const stat = await fs.stat(dir);
|
|
expect(stat.isDirectory()).toBe(true);
|
|
await fs.rm(dir, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
// ── 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('writes the file for a Community-tier admin', async () => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'community-write.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ content: 'community-write' });
|
|
expect(res.status).toBe(204);
|
|
|
|
const content = await fs.readFile(path.join(stacksDir, STACK, 'community-write.txt'), 'utf-8');
|
|
expect(content).toBe('community-write');
|
|
});
|
|
|
|
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 400 INVALID_PATH when path query parameter is missing', async () => {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.set('Cookie', adminCookie)
|
|
.send({ content: 'hello' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('INVALID_PATH');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
it('echoes a fresh ETag header on successful write', async () => {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'etag-write.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ content: 'etag write' });
|
|
expect(res.status).toBe(204);
|
|
expect(res.headers['etag']).toMatch(/^W\/"\d+"$/);
|
|
});
|
|
|
|
it('returns 412 when If-Match disagrees with the current mtimeMs and surfaces the live content', async () => {
|
|
// Seed the target so a known mtime exists.
|
|
const target = path.join(stacksDir, STACK, 'mtime-target.txt');
|
|
await fs.writeFile(target, 'ORIGINAL');
|
|
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'mtime-target.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.set('If-Match', '"1"') // deliberately stale
|
|
.send({ content: 'overwrite attempt' });
|
|
|
|
expect(res.status).toBe(412);
|
|
expect(res.body.code).toBe('PRECONDITION_FAILED');
|
|
expect(res.body.currentContent).toBe('ORIGINAL');
|
|
expect(typeof res.body.currentMtimeMs).toBe('number');
|
|
|
|
// Disk content is unchanged.
|
|
const after = await fs.readFile(target, 'utf-8');
|
|
expect(after).toBe('ORIGINAL');
|
|
});
|
|
|
|
it('succeeds when If-Match matches the current mtimeMs', async () => {
|
|
const target = path.join(stacksDir, STACK, 'mtime-match.txt');
|
|
await fs.writeFile(target, 'first');
|
|
const getRes = await request(app)
|
|
.get(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'mtime-match.txt' })
|
|
.set('Cookie', adminCookie);
|
|
expect(getRes.status).toBe(200);
|
|
const etag = getRes.headers['etag'];
|
|
expect(etag).toBeDefined();
|
|
|
|
const putRes = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'mtime-match.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.set('If-Match', etag)
|
|
.send({ content: 'second' });
|
|
expect(putRes.status).toBe(204);
|
|
expect(putRes.headers['etag']).toBeDefined();
|
|
expect(putRes.headers['etag']).not.toBe(etag);
|
|
|
|
const after = await fs.readFile(target, 'utf-8');
|
|
expect(after).toBe('second');
|
|
});
|
|
|
|
it('still writes when no If-Match header is sent (backward compat)', async () => {
|
|
const target = path.join(stacksDir, STACK, 'no-ifmatch.txt');
|
|
await fs.writeFile(target, 'before');
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'no-ifmatch.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ content: 'after' });
|
|
expect(res.status).toBe(204);
|
|
const after = await fs.readFile(target, 'utf-8');
|
|
expect(after).toBe('after');
|
|
});
|
|
|
|
it('returns 412 with an empty snapshot when If-Match was set but the target has been deleted', async () => {
|
|
// No file at this path; the caller's If-Match implies an existing file.
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'vanished.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.set('If-Match', '"42"')
|
|
.send({ content: 'i was editing this' });
|
|
expect(res.status).toBe(412);
|
|
expect(res.body.code).toBe('PRECONDITION_FAILED');
|
|
expect(res.body.currentContent).toBe('');
|
|
expect(res.body.currentMtimeMs).toBe(0);
|
|
// Nothing was written.
|
|
await expect(fs.access(path.join(stacksDir, STACK, 'vanished.txt'))).rejects.toThrow();
|
|
});
|
|
|
|
it('still writes a fresh file when no If-Match is sent (target does not exist)', async () => {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'brand-new.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ content: 'first content' });
|
|
expect(res.status).toBe(204);
|
|
const content = await fs.readFile(path.join(stacksDir, STACK, 'brand-new.txt'), 'utf-8');
|
|
expect(content).toBe('first content');
|
|
});
|
|
});
|
|
|
|
// ── PATCH /:stackName/files/rename ───────────────────────────────────────────
|
|
|
|
describe('PATCH /api/stacks/:stackName/files/rename', () => {
|
|
it('returns 409 ALREADY_EXISTS when destination exists', async () => {
|
|
await fs.writeFile(path.join(stacksDir, STACK, 'rename-source.txt'), 'source');
|
|
await fs.writeFile(path.join(stacksDir, STACK, 'rename-target.txt'), 'target');
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/stacks/${STACK}/files/rename`)
|
|
.set('Cookie', adminCookie)
|
|
.send({ from: 'rename-source.txt', to: 'rename-target.txt' });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('ALREADY_EXISTS');
|
|
});
|
|
|
|
it('renames successfully for a Community-tier admin', async () => {
|
|
await fs.writeFile(path.join(stacksDir, STACK, 'community-rename-from.txt'), 'src');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
|
const res = await request(app)
|
|
.patch(`/api/stacks/${STACK}/files/rename`)
|
|
.set('Cookie', adminCookie)
|
|
.send({ from: 'community-rename-from.txt', to: 'community-rename-to.txt' });
|
|
expect(res.status).toBe(204);
|
|
|
|
await expect(fs.access(path.join(stacksDir, STACK, 'community-rename-from.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
const moved = await fs.readFile(path.join(stacksDir, STACK, 'community-rename-to.txt'), 'utf-8');
|
|
expect(moved).toBe('src');
|
|
});
|
|
});
|
|
|
|
// ── PUT /:stackName/files/permissions ────────────────────────────────────────
|
|
|
|
describe('PUT /api/stacks/:stackName/files/permissions', () => {
|
|
it('returns 400 INVALID_PATH for invalid chmod modes', async () => {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/permissions`)
|
|
.query({ path: 'compose.yaml' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ mode: 0o1000 });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.code).toBe('INVALID_PATH');
|
|
});
|
|
|
|
it('sets permissions successfully for a Community-tier admin', async () => {
|
|
await fs.writeFile(path.join(stacksDir, STACK, 'community-perms.txt'), 'data');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/permissions`)
|
|
.query({ path: 'community-perms.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ mode: 0o600 });
|
|
expect(res.status).toBe(204);
|
|
});
|
|
});
|
|
|
|
// ── 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('deletes successfully for a Community-tier admin', async () => {
|
|
await fs.writeFile(path.join(stacksDir, STACK, 'community-delete.txt'), 'bye');
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
|
const res = await request(app)
|
|
.delete(`/api/stacks/${STACK}/files`)
|
|
.query({ path: 'community-delete.txt' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(204);
|
|
|
|
await expect(fs.access(path.join(stacksDir, STACK, 'community-delete.txt'))).rejects.toMatchObject({ code: 'ENOENT' });
|
|
});
|
|
|
|
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('creates the folder for a Community-tier admin', async () => {
|
|
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValueOnce('community');
|
|
const res = await request(app)
|
|
.post(`/api/stacks/${STACK}/files/folder`)
|
|
.query({ path: 'community-folder' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(204);
|
|
|
|
const stat = await fs.stat(path.join(stacksDir, STACK, 'community-folder'));
|
|
expect(stat.isDirectory()).toBe(true);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('viewer receives 403 from PATCH /files/rename', async () => {
|
|
const res = await request(app)
|
|
.patch(`/api/stacks/${STACK}/files/rename`)
|
|
.set('Cookie', viewerCookie)
|
|
.send({ from: 'compose.yaml', to: 'compose-renamed.yaml' });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('viewer receives 403 from PUT /files/permissions', async () => {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/permissions`)
|
|
.query({ path: 'compose.yaml' })
|
|
.set('Cookie', viewerCookie)
|
|
.send({ mode: 0o644 });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('viewer with global stack:read can GET /files', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files`)
|
|
.set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(Array.isArray(res.body)).toBe(true);
|
|
});
|
|
|
|
it('viewer with global stack:read can GET /files/content', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'compose.yaml' })
|
|
.set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.content).toBe('string');
|
|
});
|
|
|
|
it('viewer with global stack:read can GET /files/download', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files/download`)
|
|
.query({ path: 'compose.yaml' })
|
|
.set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-disposition']).toMatch(/attachment/);
|
|
});
|
|
|
|
it('viewer with global stack:read can GET /files/permissions', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/stacks/${STACK}/files/permissions`)
|
|
.query({ path: 'compose.yaml' })
|
|
.set('Cookie', viewerCookie);
|
|
expect(res.status).toBe(200);
|
|
expect(typeof res.body.octal).toBe('string');
|
|
});
|
|
});
|
|
|
|
// ── protected stack files (compose.yaml, .env) ────────────────────────────────
|
|
|
|
describe('protected stack files', () => {
|
|
it('DELETE /files refuses compose.yaml with 409 PROTECTED_FILE', async () => {
|
|
const res = await request(app)
|
|
.delete(`/api/stacks/${STACK}/files`)
|
|
.query({ path: 'compose.yaml' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PROTECTED_FILE');
|
|
});
|
|
|
|
it('DELETE /files refuses .env with 409 PROTECTED_FILE', async () => {
|
|
const res = await request(app)
|
|
.delete(`/api/stacks/${STACK}/files`)
|
|
.query({ path: '.env' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PROTECTED_FILE');
|
|
});
|
|
|
|
it('PATCH /files/rename refuses compose.yaml as source with 409 PROTECTED_FILE', async () => {
|
|
const res = await request(app)
|
|
.patch(`/api/stacks/${STACK}/files/rename`)
|
|
.set('Cookie', adminCookie)
|
|
.send({ from: 'compose.yaml', to: 'renamed-compose.yaml' });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PROTECTED_FILE');
|
|
});
|
|
|
|
it('PATCH /files/rename refuses compose.yaml as destination with 409 PROTECTED_FILE', async () => {
|
|
await fs.writeFile(path.join(stacksDir, STACK, 'pretend.yaml'), 'services: {}\n');
|
|
const res = await request(app)
|
|
.patch(`/api/stacks/${STACK}/files/rename`)
|
|
.set('Cookie', adminCookie)
|
|
.send({ from: 'pretend.yaml', to: 'compose.yaml' });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PROTECTED_FILE');
|
|
await fs.unlink(path.join(stacksDir, STACK, 'pretend.yaml'));
|
|
});
|
|
|
|
it('PUT /files/permissions refuses .env with 409 PROTECTED_FILE', async () => {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/permissions`)
|
|
.query({ path: '.env' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ mode: 0o644 });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('PROTECTED_FILE');
|
|
});
|
|
|
|
it('DELETE /files still succeeds on a non-protected file', async () => {
|
|
const target = path.join(stacksDir, STACK, 'disposable.txt');
|
|
await fs.writeFile(target, 'temporary');
|
|
const res = await request(app)
|
|
.delete(`/api/stacks/${STACK}/files`)
|
|
.query({ path: 'disposable.txt' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(204);
|
|
});
|
|
|
|
it('PUT /files/content still succeeds on compose.yaml (compose editor path)', async () => {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/content`)
|
|
.query({ path: 'compose.yaml' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ content: 'services:\n echo:\n image: busybox\n' });
|
|
expect(res.status).toBe(204);
|
|
});
|
|
|
|
it('POST /files/upload still succeeds when overwriting compose.yaml with overwrite=1 (legitimate replace)', async () => {
|
|
// Combined semantics: same-name uploads need ?overwrite=1 to pass the
|
|
// upload-confirm gate. The protected-file enforcement deliberately does
|
|
// NOT block this path because replacing compose.yaml via upload is a
|
|
// legitimate user-driven action.
|
|
const replacement = 'services:\n uploaded:\n image: busybox\n';
|
|
const res = await request(app)
|
|
.post(`/api/stacks/${STACK}/files/upload`)
|
|
.query({ overwrite: '1' })
|
|
.set('Cookie', adminCookie)
|
|
.attach('file', Buffer.from(replacement), 'compose.yaml');
|
|
expect(res.status).toBe(204);
|
|
const written = await fs.readFile(path.join(stacksDir, STACK, 'compose.yaml'), 'utf-8');
|
|
expect(written).toContain('uploaded');
|
|
});
|
|
|
|
it('DELETE /files succeeds on a subdirectory file named compose.yaml (not the active compose file)', async () => {
|
|
const subdir = path.join(stacksDir, STACK, 'backups');
|
|
await fs.mkdir(subdir, { recursive: true });
|
|
await fs.writeFile(path.join(subdir, 'compose.yaml'), 'services: {}\n');
|
|
const res = await request(app)
|
|
.delete(`/api/stacks/${STACK}/files`)
|
|
.query({ path: 'backups/compose.yaml' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(204);
|
|
await fs.rm(subdir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('DELETE /files refuses compose.yaml even with a trailing slash', async () => {
|
|
const res = await request(app)
|
|
.delete(`/api/stacks/${STACK}/files`)
|
|
.query({ path: 'compose.yaml/' })
|
|
.set('Cookie', adminCookie);
|
|
// Either the validator rejects the trailing slash (400) or the protected-file
|
|
// guard catches the normalized basename (409). Both are acceptable; what is NOT
|
|
// acceptable is a 204 success that silently deletes the protected file.
|
|
expect([400, 409]).toContain(res.status);
|
|
if (res.status === 409) expect(res.body.code).toBe('PROTECTED_FILE');
|
|
});
|
|
});
|
|
|
|
// ── symlink semantics ────────────────────────────────────────────────────────
|
|
// Symlink creation requires admin/developer-mode on Windows; skip on that
|
|
// platform so the suite stays green where the OS denies the setup itself.
|
|
|
|
describe.skipIf(isWindows)('symlink semantics (Linux/macOS only)', () => {
|
|
it('PUT /files/permissions returns 409 LINK_CHMOD_UNSUPPORTED on a symlink', async () => {
|
|
const targetPath = path.join(stacksDir, STACK, 'symlink-target.txt');
|
|
const linkPath = path.join(stacksDir, STACK, 'symlink-link.txt');
|
|
await fs.writeFile(targetPath, 'payload');
|
|
await fs.chmod(targetPath, 0o644);
|
|
await fs.symlink(targetPath, linkPath);
|
|
|
|
try {
|
|
const res = await request(app)
|
|
.put(`/api/stacks/${STACK}/files/permissions`)
|
|
.query({ path: 'symlink-link.txt' })
|
|
.set('Cookie', adminCookie)
|
|
.send({ mode: 0o600 });
|
|
expect(res.status).toBe(409);
|
|
expect(res.body.code).toBe('LINK_CHMOD_UNSUPPORTED');
|
|
|
|
const stat = await fs.stat(targetPath);
|
|
expect(stat.mode & 0o777).toBe(0o644);
|
|
} finally {
|
|
await fs.unlink(linkPath).catch(() => {});
|
|
await fs.unlink(targetPath).catch(() => {});
|
|
}
|
|
});
|
|
|
|
it('DELETE /files removes a symlink and leaves the target intact', async () => {
|
|
const targetPath = path.join(stacksDir, STACK, 'sym-delete-target.txt');
|
|
const linkPath = path.join(stacksDir, STACK, 'sym-delete-link.txt');
|
|
await fs.writeFile(targetPath, 'survives');
|
|
await fs.symlink(targetPath, linkPath);
|
|
|
|
try {
|
|
const res = await request(app)
|
|
.delete(`/api/stacks/${STACK}/files`)
|
|
.query({ path: 'sym-delete-link.txt' })
|
|
.set('Cookie', adminCookie);
|
|
expect(res.status).toBe(204);
|
|
|
|
await expect(fs.lstat(linkPath)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
const targetContent = await fs.readFile(targetPath, 'utf-8');
|
|
expect(targetContent).toBe('survives');
|
|
} finally {
|
|
await fs.unlink(targetPath).catch(() => {});
|
|
}
|
|
});
|
|
});
|