diff --git a/backend/src/__tests__/stack-files-routes.test.ts b/backend/src/__tests__/stack-files-routes.test.ts index 7fdfbee1..291752c5 100644 --- a/backend/src/__tests__/stack-files-routes.test.ts +++ b/backend/src/__tests__/stack-files-routes.test.ts @@ -7,6 +7,8 @@ * PUT /:stackName/files/content (Skipper+) * DELETE /:stackName/files (Skipper+) * POST /:stackName/files/folder (Skipper+) + * PATCH /:stackName/files/rename (Skipper+) + * PUT /:stackName/files/permissions (Skipper+) * * Covers: auth gating, tier gating (Community vs paid), input validation, * upload size limit, and happy-path 204/200 responses. @@ -98,6 +100,30 @@ describe('GET /api/stacks/:stackName/files', () => { 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') @@ -188,6 +214,14 @@ describe('GET /api/stacks/:stackName/files/download', () => { expect(res.status).toBe(403); }); + 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`) @@ -225,6 +259,27 @@ describe('POST /api/stacks/:stackName/files/upload', () => { 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); @@ -291,6 +346,15 @@ describe('PUT /api/stacks/:stackName/files/content', () => { 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`) @@ -304,6 +368,36 @@ describe('PUT /api/stacks/:stackName/files/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'); + }); +}); + +// ── 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'); + }); +}); + // ── DELETE /:stackName/files ────────────────────────────────────────────────── describe('DELETE /api/stacks/:stackName/files', () => { @@ -444,4 +538,21 @@ describe('permission gating', () => { .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); + }); }); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 346b6ce3..ae33bfed 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -111,6 +111,7 @@ export async function resolveAllEnvFilePaths(nodeId: number, stackName: string): const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024, files: 1 }, + preservePath: true, }); function getRelPath(req: Request): string { @@ -821,7 +822,14 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => { // ── File explorer endpoints ── -type FsErrorCode = 'INVALID_PATH' | 'SYMLINK_ESCAPE' | 'IS_DIRECTORY' | 'NOT_EMPTY' | 'NOT_FOUND' | 'TOO_LARGE'; +type FsErrorCode = + | 'INVALID_PATH' + | 'SYMLINK_ESCAPE' + | 'IS_DIRECTORY' + | 'NOT_EMPTY' + | 'NOT_FOUND' + | 'TOO_LARGE' + | 'ALREADY_EXISTS'; function sendFsError( res: Response, @@ -839,23 +847,61 @@ function sendFsError( if (e.code === 'NOT_EMPTY') { return res.status(409).json({ error: e.message, code: e.code as FsErrorCode }); } + if (e.code === 'EEXIST') { + return res.status(409).json({ error: e.message, code: 'ALREADY_EXISTS' satisfies FsErrorCode }); + } + if (e.code === 'ENOTDIR') { + return res.status(400).json({ error: 'Target path is not a directory', code: 'INVALID_PATH' satisfies FsErrorCode }); + } if (e.code === 'ENOENT') { return res.status(404).json({ error: opts.notFoundMessage ?? 'File not found', code: 'NOT_FOUND' }); } - console.error(`[files] ${fallback}:`, e.message); + console.error(`[files] ${fallback}:`, sanitizeForLog(e.message)); return res.status(500).json({ error: fallback }); } +function logFileOperation(level: 'info' | 'warn', message: string, details: Record): void { + const cleaned = Object.fromEntries( + Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]), + ); + const log = level === 'warn' ? console.warn : console.log; + log(`[Files] ${message}`, cleaned); +} + +function fsErrorCode(err: unknown): string { + const code = (err as NodeJS.ErrnoException & { code?: unknown }).code; + return typeof code === 'string' ? code : 'UNKNOWN'; +} + +function logFileDiag(message: string, details: Record): void { + if (DatabaseService.getInstance().getGlobalSettings().developer_mode !== '1') return; + const cleaned = Object.fromEntries( + Object.entries(details).map(([key, value]) => [key, sanitizeForLog(value)]), + ); + console.debug(`[Files:diag] ${message}`, cleaned); +} + +function isSafeUploadFilename(rawName: string): boolean { + if (!rawName || rawName === '.' || rawName === '..') return false; + if (rawName.includes('\0') || rawName.includes('/') || rawName.includes('\\')) return false; + if (/^[a-zA-Z]:/.test(rawName) || path.isAbsolute(rawName)) return false; + return path.basename(rawName) === rawName; +} + stacksRouter.get('/:stackName/files', async (req: Request, res: Response) => { const stackName = req.params.stackName as string; const relPath = getRelPath(req); if (relPath !== '' && !isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } + const startedAt = Date.now(); + logFileDiag('list start', { stackName, relPath, nodeId: req.nodeId }); try { const entries = await FileSystemService.getInstance(req.nodeId).listStackDirectory(stackName, relPath); + logFileDiag('list complete', { stackName, relPath, nodeId: req.nodeId, entries: entries.length, elapsedMs: Date.now() - startedAt }); return res.json(entries); } catch (err: unknown) { + logFileOperation('warn', 'list failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to list directory'); } }); @@ -867,10 +913,22 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response if (!isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } + const startedAt = Date.now(); + logFileDiag('read start', { stackName, relPath, nodeId: req.nodeId }); try { const result = await FileSystemService.getInstance(req.nodeId).readStackFile(stackName, relPath); + logFileDiag('read complete', { + stackName, + relPath, + nodeId: req.nodeId, + binary: result.binary, + oversized: result.oversized, + size: result.size, + elapsedMs: Date.now() - startedAt, + }); return res.json(result); } catch (err: unknown) { + logFileOperation('warn', 'read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to read file'); } }); @@ -879,9 +937,12 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons if (!requirePaid(req, res)) return; const stackName = req.params.stackName as string; const relPath = getRelPath(req); - if (relPath !== '' && !isValidRelativeStackPath(relPath)) { + if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); + if (!isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } + const startedAt = Date.now(); + logFileDiag('download start', { stackName, relPath, nodeId: req.nodeId }); try { const result = await FileSystemService.getInstance(req.nodeId).streamStackFile(stackName, relPath); res.setHeader('Content-Type', result.mime); @@ -890,14 +951,16 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons const safeFilename = result.filename.replace(/[\\"]/g, ''); res.setHeader('Content-Disposition', `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodedFilename}`); result.stream.on('error', (streamErr) => { - console.error('[files] stream error:', streamErr); + console.error('[files] stream error:', sanitizeForLog(getErrorMessage(streamErr, 'unknown'))); if (!res.headersSent) res.status(500).end(); else res.destroy(); }); req.on('close', () => result.stream.destroy()); + logFileDiag('download stream opened', { stackName, relPath, nodeId: req.nodeId, size: result.size, elapsedMs: Date.now() - startedAt }); result.stream.pipe(res); return; } catch (err: unknown) { + logFileOperation('warn', 'download failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to download file'); } }); @@ -924,15 +987,20 @@ stacksRouter.post( if (relPath !== '' && !isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } - const safeName = path.basename(req.file.originalname); - if (!safeName || safeName === '.' || safeName === '..') { + const originalName = req.file.originalname; + if (!isSafeUploadFilename(originalName)) { return res.status(400).json({ error: 'Invalid filename' }); } - const targetRelPath = relPath ? `${relPath}/${safeName}` : safeName; + const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName; + const startedAt = Date.now(); + logFileDiag('upload start', { stackName, relPath: targetRelPath, nodeId: req.nodeId, size: req.file.size }); try { await FileSystemService.getInstance(req.nodeId).writeStackFileBuffer(stackName, targetRelPath, req.file.buffer); + logFileOperation('info', 'upload complete', { nodeId: req.nodeId, size: req.file.size }); + logFileDiag('upload timing', { stackName, relPath: targetRelPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); return res.status(204).send(); } catch (err: unknown) { + logFileOperation('warn', 'upload failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to upload file', { notFoundMessage: 'Target directory not found' }); } }, @@ -943,17 +1011,23 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; const relPath = getRelPath(req); - if (relPath !== '' && !isValidRelativeStackPath(relPath)) { + if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' }); + if (!isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } const { content } = req.body as { content?: unknown }; if (typeof content !== 'string') { return res.status(400).json({ error: '"content" must be a string' }); } + const startedAt = Date.now(); + logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8') }); try { await FileSystemService.getInstance(req.nodeId).writeStackFile(stackName, relPath, content); + logFileOperation('info', 'write complete', { nodeId: req.nodeId }); + logFileDiag('write timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); return res.status(204).send(); } catch (err: unknown) { + logFileOperation('warn', 'write failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to write file'); } }); @@ -968,10 +1042,15 @@ stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } const recursive = req.query.recursive === '1'; + const startedAt = Date.now(); + logFileDiag('delete start', { stackName, relPath, recursive, nodeId: req.nodeId }); try { await FileSystemService.getInstance(req.nodeId).deleteStackPath(stackName, relPath, recursive); + logFileOperation('info', 'delete complete', { nodeId: req.nodeId, recursive }); + logFileDiag('delete timing', { stackName, relPath, recursive, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); return res.status(204).send(); } catch (err: unknown) { + logFileOperation('warn', 'delete failed', { nodeId: req.nodeId, recursive, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to delete path'); } }); @@ -985,10 +1064,15 @@ stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response if (!isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } + const startedAt = Date.now(); + logFileDiag('mkdir start', { stackName, relPath, nodeId: req.nodeId }); try { await FileSystemService.getInstance(req.nodeId).mkdirStackPath(stackName, relPath); + logFileOperation('info', 'mkdir complete', { nodeId: req.nodeId }); + logFileDiag('mkdir timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); return res.status(204).send(); } catch (err: unknown) { + logFileOperation('warn', 'mkdir failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to create folder'); } }); @@ -1010,10 +1094,15 @@ stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Respons if (!isValidRelativeStackPath(to)) { return res.status(400).json({ error: 'Invalid destination path', code: 'INVALID_PATH' }); } + const startedAt = Date.now(); + logFileDiag('rename start', { stackName, from, to, nodeId: req.nodeId }); try { await FileSystemService.getInstance(req.nodeId).renameStackPath(stackName, from, to); + logFileOperation('info', 'rename complete', { nodeId: req.nodeId }); + logFileDiag('rename timing', { stackName, from, to, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); return res.status(204).send(); } catch (err: unknown) { + logFileOperation('warn', 'rename failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to rename'); } }); @@ -1025,10 +1114,14 @@ stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Resp if (!isValidRelativeStackPath(relPath)) { return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' }); } + const startedAt = Date.now(); + logFileDiag('permissions read start', { stackName, relPath, nodeId: req.nodeId }); try { const result = await FileSystemService.getInstance(req.nodeId).getStackEntryMode(stackName, relPath); + logFileDiag('permissions read complete', { stackName, relPath, nodeId: req.nodeId, mode: result.octal, elapsedMs: Date.now() - startedAt }); return res.json(result); } catch (err: unknown) { + logFileOperation('warn', 'permissions read failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to read permissions'); } }); @@ -1046,10 +1139,15 @@ stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Resp if (typeof mode !== 'number') { return res.status(400).json({ error: '"mode" must be a number' }); } + const startedAt = Date.now(); + logFileDiag('chmod start', { stackName, relPath, nodeId: req.nodeId, mode }); try { await FileSystemService.getInstance(req.nodeId).chmodStackPath(stackName, relPath, mode); + logFileOperation('info', 'chmod complete', { nodeId: req.nodeId, mode }); + logFileDiag('chmod timing', { stackName, relPath, nodeId: req.nodeId, elapsedMs: Date.now() - startedAt }); return res.status(204).send(); } catch (err: unknown) { + logFileOperation('warn', 'chmod failed', { nodeId: req.nodeId, errorCode: fsErrorCode(err) }); return sendFsError(res, err, 'Failed to set permissions'); } }); diff --git a/docs/features/stack-file-explorer.mdx b/docs/features/stack-file-explorer.mdx index 96a76562..4601f340 100644 --- a/docs/features/stack-file-explorer.mdx +++ b/docs/features/stack-file-explorer.mdx @@ -93,7 +93,7 @@ Click **Save** to write the file to disk. Navigating away from the file before s The toolbar **New folder** button at the top of the tree creates a folder in the currently selected directory (the parent of the file you have open, or the stack root if nothing is open). The button is hidden on Community. -Right-click any folder for **New File** and **New Folder** entries that scope to the right-clicked folder. +Right-click any folder for **New File** and **New Folder** entries that scope to the right-clicked folder. These write controls appear only when your account has stack edit permission and the active tier is Skipper or Admiral. New file modal scoped to the nginx folder, with the file name field populated and a Create button @@ -111,7 +111,7 @@ The dashed **Upload file** affordance at the top of the tree opens a file picker | Target directory | The currently selected directory, or the stack root if no file is open. | | Same-name files | Overwritten without prompt. | -On Community the upload affordance is hidden entirely. +On Community, and for users without stack edit permission, the upload affordance is hidden entirely. For bulk transfers or files above 25 MB, use `scp` or `rsync` from your workstation directly to the stack directory on the host. @@ -125,7 +125,7 @@ When a file is selected on Skipper+, the right pane action bar shows **Download* Right-click any file or folder and choose **Rename**. The dialog accepts a new name following the same rules as creation. -The rename is in-place; cross-directory moves are not supported. To move an entry between directories, copy it via the host shell or upload to the new location and delete the original. +Rename appears only when your account has stack edit permission and the active tier is Skipper or Admiral. The rename is in-place; cross-directory moves are not supported. To move an entry between directories, copy it via the host shell or upload to the new location and delete the original. ## Permissions (chmod) @@ -143,7 +143,7 @@ On Community the dialog opens read-only: the toggles render the current state an ## Deleting (Skipper+) -There are three delete entry points. All three open the same confirmation modal. +There are three delete entry points. All three require stack edit permission and a Skipper or Admiral tier, and all three open the same confirmation modal. - **Toolbar delete.** With a file open in the viewer, click **Delete** in the right-pane action bar. - **Context-menu delete.** Right-click any file or folder in the tree and choose **Delete**. @@ -171,10 +171,10 @@ When the entry is one of the five protected names, the modal asks you to type th | Right-click target | Skipper+ entries | Community admin entries | |---|---|---| -| Folder | New File, New Folder, Rename, Delete | Rename, Delete | -| File | Rename, Permissions, Delete | Rename, Permissions, Delete | +| Folder | New File, New Folder, Rename, Delete | No write entries | +| File | Rename, Permissions, Delete | Permissions | -On Community, the New File and New Folder entries on a folder are hidden. Rename and Delete are gated by the `stack:edit` permission, so admins still see them, but the underlying API rejects the call with a 403 toast on a non-paid tier. Use Skipper+ for any write action; the Permissions dialog opens for everyone but only Skipper+ can save changes. +On Community, write actions are hidden in the file explorer. The Permissions dialog opens for everyone, but only Skipper and Admiral users can save changes. ## Troubleshooting @@ -197,7 +197,7 @@ On Community, the New File and New Folder entries on a folder are hidden. Rename Each directory render is capped at 500 entries to keep the tree responsive. The first 500 entries alphabetically are shown. To work with the entries past the cap, drop into a host shell with `cd` into the stack directory. - - Rename and Delete in the file context menu are write operations that require Skipper+. The menu items appear for any user with the `stack:edit` permission so a Community admin can see them, but the API rejects the call. Upgrade to Skipper+ to enable these actions, or perform the rename or delete from a host shell. + + Upload, create, rename, chmod save, and delete require stack edit permission and a Skipper or Admiral tier. Community users can browse, preview text files, and inspect permissions in read-only mode. diff --git a/frontend/src/components/files/FilePermissionsDialog.tsx b/frontend/src/components/files/FilePermissionsDialog.tsx index 918bc6ee..bb3c50dd 100644 --- a/frontend/src/components/files/FilePermissionsDialog.tsx +++ b/frontend/src/components/files/FilePermissionsDialog.tsx @@ -50,6 +50,7 @@ interface FilePermissionsDialogProps { relPath: string; entryName: string; isPaid: boolean; + canEdit: boolean; } export function FilePermissionsDialog({ @@ -59,6 +60,7 @@ export function FilePermissionsDialog({ relPath, entryName, isPaid, + canEdit, }: FilePermissionsDialogProps) { const [mode, setMode] = useState(0o644); const [loading, setLoading] = useState(false); @@ -101,6 +103,7 @@ export function FilePermissionsDialog({ }; const octal = mode.toString(8).padStart(3, '0'); + const canModify = isPaid && canEdit; return ( @@ -138,15 +141,15 @@ export function FilePermissionsDialog({ } primary={ - isPaid ? ( + canModify ? ( + {canEdit && ( + + )} )}
@@ -297,6 +300,7 @@ export function StackFileExplorer({ relPath={permissionsRelPath} entryName={permissionsEntryName} isPaid={isPaid} + canEdit={canEdit} />
); diff --git a/frontend/src/components/files/__tests__/FileUploadDropzone.test.tsx b/frontend/src/components/files/__tests__/FileUploadDropzone.test.tsx new file mode 100644 index 00000000..db22e667 --- /dev/null +++ b/frontend/src/components/files/__tests__/FileUploadDropzone.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { FileUploadDropzone } from '../FileUploadDropzone'; + +const licenseState = { isPaid: true }; + +vi.mock('@/context/LicenseContext', () => ({ + useLicense: () => licenseState, +})); + +vi.mock('@/lib/stackFilesApi', () => ({ + uploadStackFile: vi.fn(), +})); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + loading: vi.fn(() => 'loading-id'), + dismiss: vi.fn(), + }, +})); + +describe('FileUploadDropzone', () => { + beforeEach(() => { + licenseState.isPaid = true; + }); + + it('renders upload control for paid users with stack edit permission', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument(); + }); + + it('hides upload control when the user cannot edit the stack', () => { + render( + , + ); + + expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument(); + }); + + it('hides upload control on Community tier', () => { + licenseState.isPaid = false; + + render( + , + ); + + expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument(); + }); +});