mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +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);
|
||||
|
||||
Reference in New Issue
Block a user