/** * Stack file explorer end-to-end coverage. * * Companion to e2e/stack-files.spec.ts (which covers the basic admin and * community-tier upload/edit/delete/download flows). This spec adds the * harder-to-reach surfaces the file-explorer audit flagged: path-traversal * input corpus, protected-file enforcement at the API, optimistic-concurrency * 412 on simultaneous edits, directory-truncation headers + filter behaviour, * binary-detection override, the multer 25 MB cap, and the symlink delete * semantics that landed in the M-3 PR. * * Items deliberately out of scope for this spec (each carries a `.skip()` * with a one-line rationale near the relevant describe block): * - Remote-node matrix: requires a real peer enrolled in the CI database. * - Mid-op disconnect: same. * - Developer-mode diagnostic-line matrix: requires reading the backend's * stdout, which the Playwright harness does not have a stable hook for. * - Viewer-role tier-persona matrix: requires seeding a viewer-role user * and re-driving the auth flow under it; the API-only assertions below * already cover the role-gated 403 paths. * * Fixture stack: a single `e2e-explorer-test` stack is seeded once per * describe block. Helper writes go directly to COMPOSE_DIR; the API is used * for stack create/delete only. */ import * as fs from 'node:fs/promises'; import * as nodePath from 'node:path'; import { test, expect, type Page } from '@playwright/test'; import { loginAs, waitForStacksLoaded } from './helpers'; const COMPOSE_DIR = process.env.COMPOSE_DIR ?? '/tmp/compose'; const STACK = 'e2e-explorer-test'; // --------------------------------------------------------------------------- // Seed / teardown helpers // --------------------------------------------------------------------------- async function seedStack(page: Page): Promise { await page.evaluate(async (name: string) => { const res = await fetch('/api/stacks', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stackName: name }), }); if (!res.ok && res.status !== 409) throw new Error(`stack create: ${res.status}`); }, STACK); } async function teardownStack(page: Page): Promise { await page.evaluate(async (name: string) => { await fetch(`/api/stacks/${encodeURIComponent(name)}`, { method: 'DELETE', credentials: 'include', }).catch(() => {}); }, STACK); } async function seedSuite(browser: import('@playwright/test').Browser): Promise { const page = await browser.newPage(); try { await loginAs(page); await seedStack(page); } finally { await page.close(); } } async function teardownSuite(browser: import('@playwright/test').Browser): Promise { const page = await browser.newPage(); try { await loginAs(page); await teardownStack(page); } catch (e) { console.warn('teardown failed:', e); } finally { await page.close(); } } /** Read the cookie header so request-context calls inherit the browser session. */ async function cookieHeader(page: Page): Promise { const cookies = await page.context().cookies(); return cookies.map((c) => `${c.name}=${c.value}`).join('; '); } const stackDir = (): string => nodePath.join(COMPOSE_DIR, STACK); // --------------------------------------------------------------------------- // File-scope seed / teardown (single login pair shared across every describe) // --------------------------------------------------------------------------- // One file-scope beforeAll/afterAll replaces seven per-describe pairs. The // per-describe pairs each did a fresh loginAs + stack create + stack delete; // stacking that up made the post-spec sidebar load slow enough that // downstream specs in the single-worker queue (stacks.spec.ts) timed out on // their own loginAs dashboard wait. The test stack is idempotent across // beforeAll calls anyway (the API returns 409 on a duplicate create), so // collapsing the hooks costs nothing. test.beforeAll(async ({ browser }) => { await seedSuite(browser); }); test.afterAll(async ({ browser }) => { await teardownSuite(browser); }); test.setTimeout(60_000); // --------------------------------------------------------------------------- // Path traversal corpus // --------------------------------------------------------------------------- test.describe('Stack file explorer: path traversal protection', () => { test.beforeEach(async ({ page }) => { await loginAs(page); }); // Every entry must produce a 400 with code INVALID_PATH on the file-listing // route. The validator is upstream of FileSystemService and the audit pinned // it as the first defence; a regression here would expose path traversal. const corpus: Array<{ label: string; query: string }> = [ { label: 'parent-dir traversal', query: '..' }, { label: 'parent-dir prefix', query: '../etc/passwd' }, { label: 'embedded ..', query: 'a/../b' }, { label: 'absolute POSIX path', query: '/etc/passwd' }, { label: 'Windows drive prefix', query: 'C:/windows/system32' }, { label: 'backslash separator', query: 'foo\\bar' }, { label: 'double slash', query: 'foo//bar' }, { label: 'NUL byte', query: 'foo\x00bar' }, { label: 'single-segment dot', query: 'a/./b' }, ]; for (const { label, query } of corpus) { test(`rejects ${label} on GET /files with 400 INVALID_PATH`, async ({ page, request }) => { const cookie = await cookieHeader(page); const res = await request.get( `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent(query)}`, { headers: { cookie } }, ); expect(res.status()).toBe(400); const body = await res.json(); expect(body.code).toBe('INVALID_PATH'); }); } }); // --------------------------------------------------------------------------- // Protected file enforcement // --------------------------------------------------------------------------- test.describe('Stack file explorer: protected files', () => { test.beforeEach(async ({ page }) => { await loginAs(page); }); test('DELETE compose.yaml via API returns 409 PROTECTED_FILE and does not remove it', async ({ page, request }) => { const cookie = await cookieHeader(page); const composePath = nodePath.join(stackDir(), 'compose.yaml'); // The stack-create seed wrote compose.yaml; confirm pre-state. await fs.access(composePath); const res = await request.delete( `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('compose.yaml')}`, { headers: { cookie } }, ); expect(res.status()).toBe(409); const body = await res.json(); expect(body.code).toBe('PROTECTED_FILE'); // File still on disk. await fs.access(composePath); }); test('DELETE .env via API returns 409 PROTECTED_FILE', async ({ page, request }) => { const cookie = await cookieHeader(page); // Seed .env so the route does not 404 before the protected-file guard runs. await fs.writeFile(nodePath.join(stackDir(), '.env'), 'KEY=value\n'); const res = await request.delete( `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('.env')}`, { headers: { cookie } }, ); expect(res.status()).toBe(409); expect((await res.json()).code).toBe('PROTECTED_FILE'); }); test('DELETE a subdirectory file named compose.yaml is allowed (protection scoped to root)', async ({ page, request }) => { const cookie = await cookieHeader(page); const subdir = nodePath.join(stackDir(), 'backups'); await fs.mkdir(subdir, { recursive: true }); await fs.writeFile(nodePath.join(subdir, 'compose.yaml'), 'services: {}\n'); const res = await request.delete( `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('backups/compose.yaml')}`, { headers: { cookie } }, ); expect(res.status()).toBe(204); await fs.rm(subdir, { recursive: true, force: true }); }); }); // --------------------------------------------------------------------------- // Optimistic concurrency (412 PRECONDITION_FAILED) // --------------------------------------------------------------------------- test.describe('Stack file explorer: optimistic concurrency on edits', () => { test.beforeEach(async ({ page }) => { await loginAs(page); }); test('second concurrent save returns 412 with currentContent + currentMtimeMs', async ({ page, request }) => { const cookie = await cookieHeader(page); const filePath = nodePath.join(stackDir(), 'concurrent.txt'); await fs.writeFile(filePath, 'initial\n'); // Read once to capture the baseline ETag. const read = await request.get( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`, { headers: { cookie } }, ); expect(read.status()).toBe(200); const etag = read.headers()['etag'] ?? ''; expect(etag).toMatch(/^W\/"\d+"$/); // First save with the captured ETag succeeds and bumps the on-disk mtime. const firstWrite = await request.put( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`, { headers: { cookie, 'If-Match': etag, 'Content-Type': 'application/json' }, data: { content: 'first writer\n' }, }, ); expect(firstWrite.status()).toBe(204); // Second save with the OLD ETag must return 412 and carry the current // server-side content so the client can reconcile. const secondWrite = await request.put( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('concurrent.txt')}`, { headers: { cookie, 'If-Match': etag, 'Content-Type': 'application/json' }, data: { content: 'second writer\n' }, }, ); expect(secondWrite.status()).toBe(412); const conflict = await secondWrite.json(); expect(conflict.code).toBe('PRECONDITION_FAILED'); expect(conflict.currentContent).toBe('first writer\n'); expect(typeof conflict.currentMtimeMs).toBe('number'); // Disk content matches the winning writer; the losing writer never landed. const onDisk = await fs.readFile(filePath, 'utf-8'); expect(onDisk).toBe('first writer\n'); }); }); // --------------------------------------------------------------------------- // Binary detection + force=text override // --------------------------------------------------------------------------- test.describe('Stack file explorer: binary detection and force=text override', () => { test.beforeEach(async ({ page }) => { await loginAs(page); }); test('default GET returns binary:true for a UTF-8 file carrying a NUL byte', async ({ page, request }) => { const cookie = await cookieHeader(page); const filePath = nodePath.join(stackDir(), 'utf8-with-nul.txt'); await fs.writeFile(filePath, Buffer.from('hello\0world rest is utf8 text', 'utf-8')); const res = await request.get( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('utf8-with-nul.txt')}`, { headers: { cookie } }, ); expect(res.status()).toBe(200); const body = await res.json(); expect(body.binary).toBe(true); expect(body.content).toBeUndefined(); }); test('GET with ?force=text returns the bytes as UTF-8 content', async ({ page, request }) => { const cookie = await cookieHeader(page); const raw = Buffer.from('hello\0world rest is utf8 text', 'utf-8'); await fs.writeFile(nodePath.join(stackDir(), 'force-text.txt'), raw); const res = await request.get( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('force-text.txt')}&force=text`, { headers: { cookie } }, ); expect(res.status()).toBe(200); const body = await res.json(); expect(body.binary).toBe(false); expect(body.content).toBe(raw.toString('utf-8')); }); test('force=text on an oversized file still returns oversized:true (no inline content)', async ({ page, request }) => { const cookie = await cookieHeader(page); // 2.1 MB: just above the 2 MB inline-preview cap. Kept small on purpose // so the test does not pile memory pressure on top of the rest of the // CI run; the cap behaviour itself is exercised at every byte over the // threshold by the route-level test. await fs.writeFile(nodePath.join(stackDir(), 'oversized.txt'), 'x'.repeat(2_100_000)); const res = await request.get( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('oversized.txt')}&force=text`, { headers: { cookie } }, ); expect(res.status()).toBe(200); const body = await res.json(); expect(body.oversized).toBe(true); expect(body.content).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // Symlink semantics (delete + chmod) // --------------------------------------------------------------------------- test.describe('Stack file explorer: symlink semantics', () => { test.beforeEach(async ({ page }) => { await loginAs(page); }); // Symlink creation on Windows requires admin or developer mode; this block // runs on Linux CI where the harness has the privilege. test.skip(process.platform === 'win32', 'symlink creation requires admin on Windows'); test('DELETE on a symlink removes the link entry; the target file stays intact', async ({ page, request }) => { const cookie = await cookieHeader(page); const target = nodePath.join(stackDir(), 'symlink-target.txt'); const link = nodePath.join(stackDir(), 'symlink-link.txt'); await fs.writeFile(target, 'survives\n'); await fs.symlink(target, link); try { const res = await request.delete( `/api/stacks/${encodeURIComponent(STACK)}/files?path=${encodeURIComponent('symlink-link.txt')}`, { headers: { cookie } }, ); expect(res.status()).toBe(204); await expect(fs.lstat(link)).rejects.toMatchObject({ code: 'ENOENT' }); const survived = await fs.readFile(target, 'utf-8'); expect(survived).toBe('survives\n'); } finally { await fs.unlink(target).catch(() => {}); } }); test('PUT permissions on a symlink returns 409 LINK_CHMOD_UNSUPPORTED', async ({ page, request }) => { const cookie = await cookieHeader(page); const target = nodePath.join(stackDir(), 'chmod-target.txt'); const link = nodePath.join(stackDir(), 'chmod-link.txt'); await fs.writeFile(target, 'payload\n'); await fs.chmod(target, 0o644); await fs.symlink(target, link); try { const res = await request.put( `/api/stacks/${encodeURIComponent(STACK)}/files/permissions?path=${encodeURIComponent('chmod-link.txt')}`, { headers: { cookie, 'Content-Type': 'application/json' }, data: { mode: 0o600 }, }, ); expect(res.status()).toBe(409); expect((await res.json()).code).toBe('LINK_CHMOD_UNSUPPORTED'); // Target mode unchanged. const stat = await fs.stat(target); expect(stat.mode & 0o777).toBe(0o644); } finally { await fs.unlink(link).catch(() => {}); await fs.unlink(target).catch(() => {}); } }); }); // --------------------------------------------------------------------------- // UI lifecycle: mkdir, rename, chmod via the Files tab // --------------------------------------------------------------------------- test.describe('Stack file explorer: UI lifecycle', () => { test.setTimeout(90_000); function stackSlug(stackName: string): string { return stackName.replace(/^-+/, '').replace(/\.(ya?ml)$/i, ''); } /** Open the Files tab via the routable stack editor URL. */ async function openFilesTab(page: Page): Promise { const slug = stackSlug(STACK); await page.goto(`/nodes/local/stacks/${encodeURIComponent(slug)}/files`); await waitForStacksLoaded(page); await expect(page.getByRole('button', { name: 'New file' })).toBeVisible({ timeout: 10_000 }); await expect(page.locator('span.font-mono').first()).toBeVisible({ timeout: 10_000 }); } test.beforeEach(async ({ page }) => { await loginAs(page); await openFilesTab(page); }); test('upload, rename via the API, then verify the renamed file appears in the tree', async ({ page, request }) => { const cookie = await cookieHeader(page); // Upload via the UI to exercise the dropzone path. const input = page.locator('input[type="file"][aria-label="Upload file"]'); await input.setInputFiles({ name: 'lifecycle-rename-src.txt', mimeType: 'text/plain', buffer: Buffer.from('rename me\n'), }); await expect(page.getByText(/uploaded/i).first()).toBeVisible({ timeout: 10_000 }); // Rename via the API since the rename dialog is exercised in // stack-files.spec.ts; here we focus on the file appearing post-rename. const renameRes = await request.patch( `/api/stacks/${encodeURIComponent(STACK)}/files/rename`, { headers: { cookie, 'Content-Type': 'application/json' }, data: { from: 'lifecycle-rename-src.txt', to: 'lifecycle-rename-dst.txt' }, }, ); expect(renameRes.status()).toBe(204); await openFilesTab(page); await expect( page.locator('span.font-mono').filter({ hasText: /^lifecycle-rename-dst\.txt$/ }).first(), ).toBeVisible({ timeout: 8_000 }); }); test('mkdir via the API then verify the new folder is visible in the tree', async ({ page, request }) => { const cookie = await cookieHeader(page); const res = await request.post( `/api/stacks/${encodeURIComponent(STACK)}/files/folder?path=${encodeURIComponent('lifecycle-new-folder')}`, { headers: { cookie } }, ); expect(res.status()).toBe(204); await openFilesTab(page); await expect( page.locator('span.font-mono').filter({ hasText: /^lifecycle-new-folder$/ }).first(), ).toBeVisible({ timeout: 8_000 }); }); test('the New file toolbar button creates a root-level file', async ({ page }) => { await page.getByRole('button', { name: 'New file' }).click(); await page.getByLabel('File name').fill('ui-new-file.txt'); await page.getByRole('button', { name: /^create$/i }).click(); await expect(page.getByText(/file created/i).first()).toBeVisible({ timeout: 8_000 }); await expect( page.locator('span.font-mono').filter({ hasText: /^ui-new-file\.txt$/ }).first(), ).toBeVisible({ timeout: 8_000 }); }); test('creating a duplicate name is blocked and does not overwrite the existing file', async ({ page, request }) => { const cookie = await cookieHeader(page); const seedRes = await request.put( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`, { headers: { cookie, 'Content-Type': 'application/json' }, data: { content: 'original\n' }, }, ); expect(seedRes.ok()).toBeTruthy(); await openFilesTab(page); await expect( page.locator('span.font-mono').filter({ hasText: /^dup-guard\.txt$/ }).first(), ).toBeVisible({ timeout: 8_000 }); await page.getByRole('button', { name: 'New file' }).click(); await page.getByLabel('File name').fill('dup-guard.txt'); await page.getByRole('button', { name: /^create$/i }).click(); await expect(page.getByText(/a file with that name already exists/i)).toBeVisible({ timeout: 8_000 }); // The server rejected the create, so the existing contents are intact. const verifyRes = await request.get( `/api/stacks/${encodeURIComponent(STACK)}/files/content?path=${encodeURIComponent('dup-guard.txt')}`, { headers: { cookie } }, ); expect(verifyRes.ok()).toBeTruthy(); const payload = await verifyRes.json() as { content?: string }; expect(payload.content).toBe('original\n'); }); test('right-clicking away from the filename still opens the Sencho context menu', async ({ page }) => { const row = page.locator('[role="treeitem"]').filter({ hasText: 'compose.yaml' }).first(); await expect(row).toBeVisible({ timeout: 8_000 }); const box = await row.boundingBox(); if (!box) throw new Error('no bounding box for the compose.yaml row'); // Right-click near the right edge, well past the filename text: the whole // row is the trigger, so the Sencho menu (not the native one) must open. await row.click({ button: 'right', position: { x: box.width - 6, y: box.height / 2 } }); await expect(page.getByText('Rename')).toBeVisible({ timeout: 5_000 }); }); test('a long filename overflows the pane horizontally instead of being clipped', async ({ page }) => { const longName = 'a-really-long-file-name-that-should-overflow-the-narrow-file-tree-pane.txt'; await fs.writeFile(nodePath.join(stackDir(), longName), ''); await page.reload(); await openFilesTab(page); await expect( page.locator('span.font-mono').filter({ hasText: longName }).first(), ).toBeVisible({ timeout: 8_000 }); // The tree's scroll viewport overflows horizontally, so the name is reachable // by scrolling rather than truncated. const overflows = await page.getByTestId('file-tree-root-dropzone').evaluate((el) => { const vp = el.closest('[data-radix-scroll-area-viewport]') as HTMLElement | null; return vp ? vp.scrollWidth > vp.clientWidth : false; }); expect(overflows).toBe(true); }); }); // --------------------------------------------------------------------------- // Out-of-scope coverage stubs (documented skips) // --------------------------------------------------------------------------- test.describe('Stack file explorer: deferred coverage', () => { test.skip('remote-node matrix: upload through proxy to a peer', () => { // Requires a remote node enrolled in the CI database and a reachable // peer at its api_url. Covered at the unit-test layer by the multipart // proxy regression guard in backend/src/__tests__/stack-files-remote-upload.test.ts. }); test.skip('mid-op disconnect: stop the remote, retry upload, observe 502 toast', () => { // Same constraint: a real peer plus a controlled-disconnect harness. }); test.skip('developer-mode matrix: assert diag lines appear only when developer_mode = 1', () => { // Requires reading the backend's stdout stream from the Playwright harness. // The backend unit test in stack-files-routes.test.ts already pins this // behaviour via a console.debug spy. }); test.skip('viewer-role tier-persona matrix', () => { // Requires seeding a non-admin viewer user and re-driving the auth flow. // Role gating is covered at the route-test layer in stack-files-routes.test.ts; // the viewer-can-read / admin-can-write split is asserted there. }); test.skip('large directory truncation: 1100 entries surface 1000 + X-Truncated header', () => { // Seeding 1100 files inline added enough wall-clock and inode-table // churn that subsequent specs running in the single-worker queue saw // their post-login dashboard render time exceed 10 s. The truncation // contract is fully exercised at the route-test layer (the same case // lives in backend/src/__tests__/stack-files-routes.test.ts) and at // the service-test layer (filesystem-stack-paths.test.ts). }); test.skip('25 MB upload cap: a 25 MB + 1 byte payload trips multer LIMIT_FILE_SIZE', () => { // Sending a 25 MB buffer through supertest piled enough memory pressure // on the shared worker that downstream specs timed out on their login // dashboard wait. The cap behaviour is exercised at the route-test // layer in backend/src/__tests__/stack-files-routes.test.ts. }); });