mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
feat(files): open stack file explorer to every tier (#1144)
* feat(files): open stack file explorer to every tier
Drop the `requirePaid` guard from the seven stack-file write routes
(download, upload, write-content, delete, mkdir, rename, chmod) and
remove every matching `isPaid` check from the file-explorer frontend.
Stack edit permission (RBAC) continues to gate every write end-to-end.
The file explorer is the primary way a user touches a stack's on-disk
surface; gating it behind a paid tier conflicted with the principle
that Community covers single user-initiated actions while paid tiers
add automation and governance.
* docs(files): treat download as a read action, not a write
Download has no `requirePermission('stack:edit')` on the route and no
`canEdit` gate in the UI, so viewer accounts can download. Update the
top paragraph to list download under reads, and rewrite the
troubleshooting accordion to describe the actual gating (a file must be
selected) instead of asserting a role gate that does not exist.
* test(e2e): align stack-files spec with the new tier rule
The community-tier describe block asserted that the Upload control is
absent and the editor shows a `Read-only` chip; the admin-tier block
skipped on Community via `test.skip(tier !== 'paid')`. Both rules
reflected the previous gate, where writes required a paid tier.
Writes are now gated on the `stack:edit` role, not on the license tier.
Repurpose the community describe to assert that a Community admin
under a mocked community license still sees the Upload control and an
editable Save button. Drop the obsolete tier-skip in the admin describe
so upload, edit, delete, and download exercise on every tier. Update
stale comments to reference the role gate.
This commit is contained in:
@@ -2,16 +2,18 @@
|
||||
* 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+)
|
||||
* PATCH /:stackName/files/rename (Skipper+)
|
||||
* PUT /:stackName/files/permissions (Skipper+)
|
||||
* 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
|
||||
*
|
||||
* Covers: auth gating, tier gating (Community vs paid), input validation,
|
||||
* upload size limit, and happy-path 204/200 responses.
|
||||
* 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';
|
||||
@@ -205,13 +207,14 @@ describe('GET /api/stacks/:stackName/files/download', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for Community tier', async () => {
|
||||
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(403);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-disposition']).toMatch(/attachment/);
|
||||
});
|
||||
|
||||
it('returns 400 INVALID_PATH when path query parameter is missing', async () => {
|
||||
@@ -243,13 +246,16 @@ describe('POST /api/stacks/:stackName/files/upload', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for Community tier', async () => {
|
||||
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('data'), 'test.txt');
|
||||
expect(res.status).toBe(403);
|
||||
.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 () => {
|
||||
@@ -327,14 +333,17 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for Community tier', async () => {
|
||||
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: 'new.txt' })
|
||||
.query({ path: 'community-write.txt' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'hello' });
|
||||
expect(res.status).toBe(403);
|
||||
.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 () => {
|
||||
@@ -382,6 +391,20 @@ describe('PATCH /api/stacks/:stackName/files/rename', () => {
|
||||
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 ────────────────────────────────────────
|
||||
@@ -396,6 +419,17 @@ describe('PUT /api/stacks/:stackName/files/permissions', () => {
|
||||
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 ──────────────────────────────────────────────────
|
||||
@@ -408,13 +442,16 @@ describe('DELETE /api/stacks/:stackName/files', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for Community tier', async () => {
|
||||
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: 'compose.yaml' })
|
||||
.query({ path: 'community-delete.txt' })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(403);
|
||||
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 () => {
|
||||
@@ -475,13 +512,16 @@ describe('POST /api/stacks/:stackName/files/folder', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 403 for Community tier', async () => {
|
||||
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: 'newdir' })
|
||||
.query({ path: 'community-folder' })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(403);
|
||||
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 () => {
|
||||
|
||||
@@ -1016,7 +1016,6 @@ stacksRouter.get('/:stackName/files/content', async (req: Request, res: Response
|
||||
});
|
||||
|
||||
stacksRouter.get('/:stackName/files/download', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
const relPath = getRelPath(req);
|
||||
if (!relPath) return res.status(400).json({ error: 'path query parameter is required', code: 'INVALID_PATH' });
|
||||
@@ -1050,7 +1049,6 @@ stacksRouter.get('/:stackName/files/download', async (req: Request, res: Respons
|
||||
stacksRouter.post(
|
||||
'/:stackName/files/upload',
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err && (err as multer.MulterError).code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: 'File exceeds 25 MB limit', code: 'TOO_LARGE' });
|
||||
@@ -1089,7 +1087,6 @@ stacksRouter.post(
|
||||
);
|
||||
|
||||
stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
@@ -1115,7 +1112,6 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
|
||||
});
|
||||
|
||||
stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
@@ -1138,7 +1134,6 @@ stacksRouter.delete('/:stackName/files', async (req: Request, res: Response) =>
|
||||
});
|
||||
|
||||
stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
@@ -1160,7 +1155,6 @@ stacksRouter.post('/:stackName/files/folder', async (req: Request, res: Response
|
||||
});
|
||||
|
||||
stacksRouter.patch('/:stackName/files/rename', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const { from, to } = req.body as { from?: unknown; to?: unknown };
|
||||
@@ -1209,7 +1203,6 @@ stacksRouter.get('/:stackName/files/permissions', async (req: Request, res: Resp
|
||||
});
|
||||
|
||||
stacksRouter.put('/:stackName/files/permissions', async (req: Request, res: Response) => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
const stackName = req.params.stackName as string;
|
||||
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||
const relPath = getRelPath(req);
|
||||
|
||||
@@ -25,7 +25,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing,
|
||||
|
||||
**Community** includes:
|
||||
|
||||
- Unlimited nodes, the Monaco compose editor, and the App Store with 199+ one-click templates
|
||||
- Unlimited nodes, the Monaco compose editor, the full stack file explorer (browse, view, edit, upload, download, rename, chmod, delete; admin role for writes), and the App Store with 199+ one-click templates
|
||||
- Real-time container stats, global logs, the interactive network topology graph, and stack labels
|
||||
- Git sources for compose stacks
|
||||
- Multi-node management in both Proxy and Pilot Agent modes
|
||||
|
||||
@@ -21,16 +21,7 @@ The file explorer gives you direct access to everything inside a stack's directo
|
||||
|
||||
The **files** shortcut button next to **edit** in the Anatomy header opens the editor and selects the Files tab in one click.
|
||||
|
||||
## Tiers at a glance
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Community" icon="eye">
|
||||
Browse the directory tree, view text files in read-only mode, and inspect file permissions.
|
||||
</Card>
|
||||
<Card title="Skipper" icon="pencil">
|
||||
Full read and write: upload, download, edit and save, create files and folders, rename, change permissions, and delete. Admiral inherits the same access.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
The file explorer is available on every Sencho tier. Read actions (browse, preview, download, inspect permissions) are available to every authenticated account. Write actions (upload, edit, create, rename, change permissions, delete) require **stack edit** permission on your account.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -68,14 +59,12 @@ When you click a text file, its contents appear in the editor on the right. The
|
||||
| File type | Behaviour |
|
||||
|-----------|-----------|
|
||||
| Text file up to 2 MB | Rendered inline with syntax highlighting. |
|
||||
| Text file over 2 MB | Panel with the filename and size, plus a **Download** button on Skipper+. |
|
||||
| Text file over 2 MB | Panel with the filename, size, and a **Download** button. |
|
||||
| Binary file | Same panel layout, label is `Binary file`. |
|
||||
|
||||
On Community, the panel for over-2-MB and binary files omits the Download button. To pull oversized or binary files from Community, use a host shell or `docker cp`.
|
||||
## Editing and saving
|
||||
|
||||
## Editing and saving (Skipper+)
|
||||
|
||||
When you have stack edit permission and a paid tier, the editor opens in write mode. The toolbar shows the filename and a **Save** button that activates as soon as there are unsaved changes.
|
||||
When you have stack edit permission, the editor opens in write mode. The toolbar shows the filename and a **Save** button that activates as soon as there are unsaved changes.
|
||||
|
||||
When you do not, the toolbar shows a `Read-only` chip and the editor refuses input.
|
||||
|
||||
@@ -89,11 +78,11 @@ Click **Save** to write the file to disk. Navigating away from the file before s
|
||||
Editing a file does not restart any containers. If your stack reads the file at runtime (for example, a config file mounted as a volume), restart the relevant service after saving so the container picks up the new content.
|
||||
</Warning>
|
||||
|
||||
## Creating files and folders (Skipper+)
|
||||
## Creating files and folders
|
||||
|
||||
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.
|
||||
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).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/stack-file-explorer/new-file-dialog.png" alt="New file modal scoped to the nginx folder, with the file name field populated and a Create button" />
|
||||
@@ -101,7 +90,7 @@ Right-click any folder for **New File** and **New Folder** entries that scope to
|
||||
|
||||
Filenames cannot be empty, cannot contain `/` or `\`, and cannot be `.` or `..`. The Create button stays disabled until the input passes validation.
|
||||
|
||||
## Uploading files (Skipper+)
|
||||
## Uploading files
|
||||
|
||||
The dashed **Upload file** affordance at the top of the tree opens a file picker. Uploads are one file at a time.
|
||||
|
||||
@@ -111,21 +100,21 @@ 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, and for users without stack edit permission, the upload affordance is hidden entirely.
|
||||
The upload affordance is hidden for users without stack edit permission.
|
||||
|
||||
<Tip>
|
||||
For bulk transfers or files above 25 MB, use `scp` or `rsync` from your workstation directly to the stack directory on the host.
|
||||
</Tip>
|
||||
|
||||
## Downloading files (Skipper+)
|
||||
## Downloading files
|
||||
|
||||
When a file is selected on Skipper+, the right pane action bar shows **Download**. Files stream straight to your browser. Files that exceed the inline preview limit also expose a Download button inside the oversized-file panel itself.
|
||||
When a file is selected, the right pane action bar shows **Download**. Files stream straight to your browser. Files that exceed the inline preview limit also expose a Download button inside the oversized-file panel itself.
|
||||
|
||||
## Renaming (Skipper+)
|
||||
## Renaming
|
||||
|
||||
Right-click any file or folder and choose **Rename**. The dialog accepts a new name following the same rules as creation.
|
||||
|
||||
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.
|
||||
Rename appears only when your account has stack edit permission. 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)
|
||||
|
||||
@@ -135,15 +124,15 @@ Right-click any file and choose **Permissions** to inspect or edit its Unix mode
|
||||
<img src="/images/stack-file-explorer/permissions-dialog.png" alt="Permissions modal for a file showing the rwx grid for Owner, Group, and Other plus the octal value 644" />
|
||||
</Frame>
|
||||
|
||||
On Community the dialog opens read-only: the toggles render the current state and the footer shows only **Close**. On Skipper+ the toggles are interactive and the footer adds **Save**.
|
||||
When your account has stack edit permission, the toggles are interactive and the footer adds **Save**. For viewer accounts the dialog opens read-only: the toggles render the current state and the footer shows only **Close**.
|
||||
|
||||
<Note>
|
||||
Permissions are applied with `chmod`. Symlinks may not honour the change depending on the host kernel.
|
||||
</Note>
|
||||
|
||||
## Deleting (Skipper+)
|
||||
## Deleting
|
||||
|
||||
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.
|
||||
There are three delete entry points. All three require stack edit permission, 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**.
|
||||
@@ -169,12 +158,12 @@ When the entry is one of the five protected names, the modal asks you to type th
|
||||
<img src="/images/stack-file-explorer/context-menu-file.png" alt="Right-click menu on a file showing Rename, Permissions, and Delete entries" />
|
||||
</Frame>
|
||||
|
||||
| Right-click target | Skipper+ entries | Community admin entries |
|
||||
| Right-click target | Admin entries (with stack edit) | Viewer entries |
|
||||
|---|---|---|
|
||||
| Folder | New File, New Folder, Rename, Delete | No write entries |
|
||||
| File | Rename, Permissions, Delete | Permissions |
|
||||
| File | Rename, Permissions, Delete | Permissions (read-only) |
|
||||
|
||||
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.
|
||||
The Permissions dialog opens for everyone; only users with stack edit permission can save changes.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -192,12 +181,12 @@ On Community, write actions are hidden in the file explorer. The Permissions dia
|
||||
The container caches the file at startup, or the mount is configured read-only inside the container. Restart the service after saving so the container picks up the new file.
|
||||
</Accordion>
|
||||
<Accordion title="Download button is missing">
|
||||
You are on the Community tier. Downloads are a Skipper+ feature. To pull large or binary files from Community, use a host terminal or `docker cp`.
|
||||
The Download button appears in the right-pane action bar only when a file is selected in the tree. Click any text or binary file to open it, and the button activates.
|
||||
</Accordion>
|
||||
<Accordion title="The tree shows 'Showing 500 of N - refine in shell'">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="Write controls are missing">
|
||||
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.
|
||||
Upload, create, rename, chmod save, and delete require **stack edit** permission. Viewer accounts can browse, preview text files, and inspect permissions in read-only mode.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
+29
-35
@@ -1,20 +1,23 @@
|
||||
/**
|
||||
* File explorer E2E tests.
|
||||
*
|
||||
* Two scenarios are covered:
|
||||
* The file explorer is available on every Sencho tier; writes are gated on
|
||||
* `stack:edit` permission (admin role), not on the license tier. Two scenarios
|
||||
* are covered:
|
||||
*
|
||||
* 1. Community flow (read-only): the license endpoint is intercepted so the
|
||||
* frontend believes the instance is community tier. Only viewing files is
|
||||
* allowed; the upgrade pill is visible in the left pane and the Save button
|
||||
* is absent from the editor toolbar.
|
||||
* 1. Community admin (full read+write under a mocked community license): the
|
||||
* license endpoint is intercepted to force `tier: 'community'` on the
|
||||
* frontend, then the suite asserts that an admin still sees the Upload
|
||||
* affordance and the editor opens in write mode. This guards against
|
||||
* regressing to a tier-based gate.
|
||||
*
|
||||
* 2. Skipper+ flow (full CRUD): the real license state (paid) is used.
|
||||
* Upload, edit-and-save, delete, and download are exercised end-to-end.
|
||||
* These tests skip gracefully when the instance is community tier (CI).
|
||||
* 2. Admin full CRUD (real license state): upload, edit-and-save, delete, and
|
||||
* download are exercised end-to-end. Runs on every tier because writes are
|
||||
* role-based, not tier-based.
|
||||
*
|
||||
* Fixture files (config/app.conf and assets/logo.png) are seeded once via
|
||||
* direct filesystem writes in a beforeAll hook so seeding works on any tier.
|
||||
* The entire test stack is torn down in afterAll.
|
||||
* direct filesystem writes in a beforeAll hook. The entire test stack is torn
|
||||
* down in afterAll.
|
||||
*/
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as nodePath from 'node:path';
|
||||
@@ -47,9 +50,9 @@ async function dismissUpgradeOverlays(page: Page): Promise<void> {
|
||||
|
||||
/**
|
||||
* Click the test stack in the sidebar, then click the "files" button in the
|
||||
* anatomy panel header to enter the Files tab. This works regardless of the
|
||||
* current license tier because the Files panel itself is always rendered
|
||||
* (isPaid only gates edit/upload/delete within the panel).
|
||||
* anatomy panel header to enter the Files tab. Writes inside the panel are
|
||||
* gated on `canEdit` (the `stack:edit` RBAC permission); the panel itself
|
||||
* always renders for any authenticated session.
|
||||
*/
|
||||
async function openFilesTab(page: Page): Promise<void> {
|
||||
await waitForStacksLoaded(page);
|
||||
@@ -169,10 +172,10 @@ async function mockCommunityLicense(context: BrowserContext): Promise<void> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Community flow (read-only)
|
||||
// Community admin (full read+write under a mocked community license)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('File explorer - community (read-only)', () => {
|
||||
test.describe('File explorer - community admin (full read+write)', () => {
|
||||
// Increase timeout: seeding + navigation add overhead
|
||||
test.setTimeout(60_000);
|
||||
|
||||
@@ -196,11 +199,11 @@ test.describe('File explorer - community (read-only)', () => {
|
||||
await context.unroute('/api/license');
|
||||
});
|
||||
|
||||
test('upload control is absent in community tier', async ({ page }) => {
|
||||
await expect(page.getByLabel('Upload file')).toHaveCount(0, { timeout: 1_000 });
|
||||
test('upload control is visible for a community admin', async ({ page }) => {
|
||||
await expect(page.getByLabel('Upload file').first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test('can expand config/ and click config/app.conf - Save button is absent', async ({ page }) => {
|
||||
test('opening config/app.conf as a community admin shows Save (write mode)', async ({ page }) => {
|
||||
// The config/ directory should be visible in the tree
|
||||
const configNode = page.locator('span.font-mono').filter({ hasText: /^config$/ }).first();
|
||||
await expect(configNode).toBeVisible({ timeout: 8_000 });
|
||||
@@ -213,39 +216,30 @@ test.describe('File explorer - community (read-only)', () => {
|
||||
await expect(appConfNode).toBeVisible({ timeout: 8_000 });
|
||||
await appConfNode.click();
|
||||
|
||||
// The editor header should show "Read-only" badge (isPaid is false)
|
||||
await expect(page.getByText('Read-only')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The Save button must NOT be present in community mode
|
||||
await expect(page.getByRole('button', { name: /^save$/i })).not.toBeVisible();
|
||||
// Editor opens in write mode for any admin (stack:edit); the `Read-only`
|
||||
// chip is reserved for viewer accounts and must not appear here.
|
||||
await expect(page.getByRole('button', { name: /^save$/i })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('Read-only')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skipper+ flow (full CRUD)
|
||||
// Admin full CRUD (real license state, runs on every tier)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('File explorer - skipper+ (full CRUD)', () => {
|
||||
test.describe('File explorer - admin (full CRUD)', () => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test.beforeAll(async ({ browser }) => { await seedSuite(browser); });
|
||||
test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'skipper'); });
|
||||
test.afterAll(async ({ browser }) => { await teardownSuite(browser, 'admin'); });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page);
|
||||
// Skip when the instance is community tier: upload/edit/delete/download
|
||||
// all require Skipper+ and would 403. Same pattern as auto-heal-policies.
|
||||
const tier = await page.evaluate(async () => {
|
||||
const res = await fetch('/api/license', { credentials: 'include' });
|
||||
const json = await res.json() as { tier?: string };
|
||||
return json.tier;
|
||||
});
|
||||
test.skip(tier !== 'paid', 'Skipper+ tier required; instance is community.');
|
||||
await openFilesTab(page);
|
||||
});
|
||||
|
||||
test('upload a text file and verify it appears in the tree', async ({ page }) => {
|
||||
// Guard: paid users see the upload dropzone, not the upgrade pill.
|
||||
// Admins with stack:edit see the upload dropzone on every tier.
|
||||
await expect(
|
||||
page.locator('[role="button"]').filter({ hasText: /upload file/i }).first()
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
@@ -49,7 +49,6 @@ interface FilePermissionsDialogProps {
|
||||
stackName: string;
|
||||
relPath: string;
|
||||
entryName: string;
|
||||
isPaid: boolean;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
@@ -59,7 +58,6 @@ export function FilePermissionsDialog({
|
||||
stackName,
|
||||
relPath,
|
||||
entryName,
|
||||
isPaid,
|
||||
canEdit,
|
||||
}: FilePermissionsDialogProps) {
|
||||
const [mode, setMode] = useState<number>(0o644);
|
||||
@@ -103,7 +101,7 @@ export function FilePermissionsDialog({
|
||||
};
|
||||
|
||||
const octal = mode.toString(8).padStart(3, '0');
|
||||
const canModify = isPaid && canEdit;
|
||||
const canModify = canEdit;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={handleClose} size="sm">
|
||||
|
||||
@@ -18,7 +18,6 @@ interface FileTreeProps {
|
||||
onNavigateToEnv?: () => void;
|
||||
// Context menu wiring
|
||||
canEdit?: boolean;
|
||||
isPaid?: boolean;
|
||||
onContextMenuRename?: (relPath: string) => void;
|
||||
onContextMenuNewFile?: (dirRelPath: string) => void;
|
||||
onContextMenuNewFolder?: (dirRelPath: string) => void;
|
||||
@@ -39,7 +38,6 @@ export function FileTree({
|
||||
onNavigateToCompose,
|
||||
onNavigateToEnv,
|
||||
canEdit = false,
|
||||
isPaid = false,
|
||||
onContextMenuRename = () => undefined,
|
||||
onContextMenuNewFile = () => undefined,
|
||||
onContextMenuNewFolder = () => undefined,
|
||||
@@ -173,7 +171,6 @@ export function FileTree({
|
||||
}
|
||||
}}
|
||||
canEdit={canEdit}
|
||||
isPaid={isPaid}
|
||||
onContextMenuRename={onContextMenuRename}
|
||||
onContextMenuNewFile={onContextMenuNewFile}
|
||||
onContextMenuNewFolder={onContextMenuNewFolder}
|
||||
|
||||
@@ -13,7 +13,6 @@ interface FileTreeContextMenuProps {
|
||||
entry: FileEntry;
|
||||
relPath: string;
|
||||
canEdit: boolean;
|
||||
isPaid: boolean;
|
||||
onRequestRename: (relPath: string) => void;
|
||||
onRequestNewFile: (dirRelPath: string) => void;
|
||||
onRequestNewFolder: (dirRelPath: string) => void;
|
||||
@@ -26,7 +25,6 @@ export function FileTreeContextMenu({
|
||||
entry,
|
||||
relPath,
|
||||
canEdit,
|
||||
isPaid,
|
||||
onRequestRename,
|
||||
onRequestNewFile,
|
||||
onRequestNewFolder,
|
||||
@@ -35,7 +33,7 @@ export function FileTreeContextMenu({
|
||||
children,
|
||||
}: FileTreeContextMenuProps) {
|
||||
const isDir = entry.type === 'directory';
|
||||
const canWrite = canEdit && isPaid;
|
||||
const canWrite = canEdit;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
|
||||
@@ -13,7 +13,6 @@ interface FileTreeNodeProps {
|
||||
onClick: () => void;
|
||||
// Context menu wiring
|
||||
canEdit: boolean;
|
||||
isPaid: boolean;
|
||||
onContextMenuRename: (relPath: string) => void;
|
||||
onContextMenuNewFile: (dirRelPath: string) => void;
|
||||
onContextMenuNewFolder: (dirRelPath: string) => void;
|
||||
@@ -30,7 +29,6 @@ export function FileTreeNode({
|
||||
isLoading,
|
||||
onClick,
|
||||
canEdit,
|
||||
isPaid,
|
||||
onContextMenuRename,
|
||||
onContextMenuNewFile,
|
||||
onContextMenuNewFolder,
|
||||
@@ -44,7 +42,6 @@ export function FileTreeNode({
|
||||
entry={entry}
|
||||
relPath={relPath}
|
||||
canEdit={canEdit}
|
||||
isPaid={isPaid}
|
||||
onRequestRename={onContextMenuRename}
|
||||
onRequestNewFile={onContextMenuNewFile}
|
||||
onRequestNewFolder={onContextMenuNewFolder}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRef } from 'react';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { uploadStackFile } from '@/lib/stackFilesApi';
|
||||
|
||||
const MAX_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
@@ -19,10 +18,9 @@ export function FileUploadDropzone({
|
||||
canEdit,
|
||||
onUploaded,
|
||||
}: FileUploadDropzoneProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (!isPaid || !canEdit) return null;
|
||||
if (!canEdit) return null;
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
if (file.size > MAX_BYTES) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { readStackFile, writeStackFile, downloadStackFile } from '@/lib/stackFilesApi';
|
||||
import { extensionToLanguage } from '@/lib/monacoLanguages';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
@@ -27,7 +26,6 @@ interface SpecialFilePanelProps {
|
||||
label: string;
|
||||
stackName: string;
|
||||
relPath: string;
|
||||
canDownload: boolean;
|
||||
}
|
||||
|
||||
function SpecialFilePanel({
|
||||
@@ -36,12 +34,10 @@ function SpecialFilePanel({
|
||||
label,
|
||||
stackName,
|
||||
relPath,
|
||||
canDownload,
|
||||
}: SpecialFilePanelProps) {
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!canDownload) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
const res = await downloadStackFile(stackName, relPath);
|
||||
@@ -72,21 +68,19 @@ function SpecialFilePanel({
|
||||
<p className="font-mono text-sm text-stat-title">{filename}</p>
|
||||
<p className="text-xs text-stat-subtitle">{label} · {formatBytes(size)}</p>
|
||||
</div>
|
||||
{canDownload && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleDownload()}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Download
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,8 +92,6 @@ export function FileViewer({
|
||||
isDarkMode,
|
||||
onSaved,
|
||||
}: FileViewerProps) {
|
||||
const { isPaid } = useLicense();
|
||||
|
||||
const [content, setContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -109,7 +101,7 @@ export function FileViewer({
|
||||
const [isOversized, setIsOversized] = useState(false);
|
||||
const [size, setSize] = useState(0);
|
||||
|
||||
const readOnly = !canEdit || !isPaid;
|
||||
const readOnly = !canEdit;
|
||||
|
||||
const editorOptions = useMemo(
|
||||
() => ({
|
||||
@@ -220,7 +212,6 @@ export function FileViewer({
|
||||
label="Binary file"
|
||||
stackName={stackName}
|
||||
relPath={selectedPath}
|
||||
canDownload={isPaid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -233,7 +224,6 @@ export function FileViewer({
|
||||
label="File too large to preview"
|
||||
stackName={stackName}
|
||||
relPath={selectedPath}
|
||||
canDownload={isPaid}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Trash2, FolderPlus, Download, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { downloadStackFile, listStackDirectory } from '@/lib/stackFilesApi';
|
||||
import { FileTree } from './FileTree';
|
||||
import { FileViewer } from './FileViewer';
|
||||
@@ -29,7 +28,6 @@ export function StackFileExplorer({
|
||||
onNavigateToCompose,
|
||||
onNavigateToEnv,
|
||||
}: StackFileExplorerProps) {
|
||||
const { isPaid } = useLicense();
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [selectedEntry, setSelectedEntry] = useState<FileEntry | null>(null);
|
||||
const [currentDir, setCurrentDir] = useState('');
|
||||
@@ -154,7 +152,7 @@ export function StackFileExplorer({
|
||||
onUploaded={refresh}
|
||||
/>
|
||||
</div>
|
||||
{isPaid && canEdit && (
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -180,7 +178,6 @@ export function StackFileExplorer({
|
||||
onNavigateToCompose={onNavigateToCompose}
|
||||
onNavigateToEnv={onNavigateToEnv}
|
||||
canEdit={canEdit}
|
||||
isPaid={isPaid}
|
||||
onContextMenuRename={handleContextMenuRename}
|
||||
onContextMenuNewFile={handleContextMenuNewFile}
|
||||
onContextMenuNewFolder={handleContextMenuNewFolder}
|
||||
@@ -192,7 +189,7 @@ export function StackFileExplorer({
|
||||
|
||||
{/* Right pane: action bar + viewer */}
|
||||
<div className="flex flex-col flex-1 min-h-0 min-w-0">
|
||||
{selectedPath !== null && isPaid && (
|
||||
{selectedPath !== null && (
|
||||
<div className="flex items-center justify-end gap-1 px-2 py-1 border-b border-glass-border shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -299,7 +296,6 @@ export function StackFileExplorer({
|
||||
stackName={stackName}
|
||||
relPath={permissionsRelPath}
|
||||
entryName={permissionsEntryName}
|
||||
isPaid={isPaid}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi } 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(),
|
||||
}));
|
||||
@@ -22,11 +16,7 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
}));
|
||||
|
||||
describe('FileUploadDropzone', () => {
|
||||
beforeEach(() => {
|
||||
licenseState.isPaid = true;
|
||||
});
|
||||
|
||||
it('renders upload control for paid users with stack edit permission', () => {
|
||||
it('renders upload control for users with stack edit permission', () => {
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
@@ -51,19 +41,4 @@ describe('FileUploadDropzone', () => {
|
||||
|
||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides upload control on Community tier', () => {
|
||||
licenseState.isPaid = false;
|
||||
|
||||
render(
|
||||
<FileUploadDropzone
|
||||
stackName="app"
|
||||
currentDir=""
|
||||
canEdit
|
||||
onUploaded={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /upload file/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,11 +63,6 @@ vi.mock('@/lib/monacoLanguages', () => ({
|
||||
extensionToLanguage: () => 'plaintext',
|
||||
}));
|
||||
|
||||
const licenseState = { isPaid: true };
|
||||
vi.mock('@/context/LicenseContext', () => ({
|
||||
useLicense: () => licenseState,
|
||||
}));
|
||||
|
||||
import { readStackFile } from '@/lib/stackFilesApi';
|
||||
import { FileViewer } from '../FileViewer';
|
||||
|
||||
@@ -93,7 +88,6 @@ const defaultProps = {
|
||||
|
||||
beforeEach(() => {
|
||||
mockReadFile.mockReset();
|
||||
licenseState.isPaid = true;
|
||||
});
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
@@ -150,7 +144,7 @@ describe('FileViewer', () => {
|
||||
expect(screen.queryByTestId('monaco-editor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Download button when user has a paid tier', async () => {
|
||||
it('shows the Download button for binary files', async () => {
|
||||
mockReadFile.mockResolvedValue(binaryResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
|
||||
@@ -160,16 +154,6 @@ describe('FileViewer', () => {
|
||||
expect(downloadBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides Download button for community tier', async () => {
|
||||
licenseState.isPaid = false;
|
||||
mockReadFile.mockResolvedValue(binaryResult());
|
||||
|
||||
render(<FileViewer {...defaultProps} selectedPath="data.bin" />);
|
||||
|
||||
await screen.findByText(/binary file/i);
|
||||
expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-fetches when selectedPath changes', async () => {
|
||||
mockReadFile.mockResolvedValue(textResult());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user