mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
This commit consolidates directory security from two environment variables (WORKSPACE_DIR, ALLOWED_PROJECT_DIRS) into a single ALLOWED_ROOT_DIRECTORY variable while maintaining backward compatibility. Changes: - Re-enabled path validation in security.ts (was previously disabled) - Implemented isPathAllowed() to check ALLOWED_ROOT_DIRECTORY with DATA_DIR exception - Added backward compatibility for legacy ALLOWED_PROJECT_DIRS and WORKSPACE_DIR - Implemented path traversal protection via isPathWithinDirectory() helper - Added PathNotAllowedError custom exception for security violations - Updated all FS route endpoints to validate paths and return 403 on violation - Updated template clone endpoint to validate project paths - Updated workspace config endpoints to use ALLOWED_ROOT_DIRECTORY - Fixed stat() response property access bug in project-init.ts - Updated security tests to expect actual validation behavior Security improvements: - Path validation now enforced at all layers (routes, project init, agent services) - appData directory (DATA_DIR) always allowed for settings/credentials - Backward compatible with existing ALLOWED_PROJECT_DIRS/WORKSPACE_DIR configurations - Protection against path traversal attacks Backend test results: 654/654 passing ✅ 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { test, expect } from "@playwright/test";
|
|
import {
|
|
resetFixtureSpec,
|
|
setupProjectWithFixture,
|
|
getFixturePath,
|
|
navigateToSpecEditor,
|
|
getEditorContent,
|
|
setEditorContent,
|
|
clickSaveButton,
|
|
getByTestId,
|
|
clickElement,
|
|
fillInput,
|
|
waitForNetworkIdle,
|
|
waitForElement,
|
|
} from "./utils";
|
|
|
|
test.describe("Spec Editor Persistence", () => {
|
|
test.beforeEach(async () => {
|
|
// Reset the fixture spec file to original content before each test
|
|
resetFixtureSpec();
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
// Clean up - reset the spec file after each test
|
|
resetFixtureSpec();
|
|
});
|
|
|
|
test("should open project, edit spec, save, and persist changes after refresh", async ({
|
|
page,
|
|
}) => {
|
|
// Use the resolved fixture path
|
|
const fixturePath = getFixturePath();
|
|
|
|
// Step 1: Set up the project in localStorage pointing to our fixture
|
|
await setupProjectWithFixture(page, fixturePath);
|
|
|
|
// Step 2: Navigate to the app
|
|
await page.goto("/");
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Step 3: Verify we're on the dashboard with the project loaded
|
|
// The sidebar should show the project selector
|
|
const sidebar = await getByTestId(page, "sidebar");
|
|
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
|
|
|
// Step 4: Click on the Spec Editor in the sidebar
|
|
await navigateToSpecEditor(page);
|
|
|
|
// Step 5: Wait for the spec view to load (not empty state)
|
|
await waitForElement(page, "spec-view", { timeout: 10000 });
|
|
|
|
// Step 6: Wait for the spec editor to load
|
|
const specEditor = await getByTestId(page, "spec-editor");
|
|
await specEditor.waitFor({ state: "visible", timeout: 10000 });
|
|
|
|
// Step 7: Wait for CodeMirror to initialize (it has a .cm-content element)
|
|
await specEditor.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
|
|
|
// Step 8: Modify the editor content to "hello world"
|
|
await setEditorContent(page, "hello world");
|
|
|
|
// Verify content was set before saving
|
|
const contentBeforeSave = await getEditorContent(page);
|
|
expect(contentBeforeSave.trim()).toBe("hello world");
|
|
|
|
// Step 9: Click the save button and wait for save to complete
|
|
await clickSaveButton(page);
|
|
|
|
// Step 10: Refresh the page
|
|
await page.reload();
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Step 11: Navigate back to the spec editor
|
|
// After reload, we need to wait for the app to initialize
|
|
await waitForElement(page, "sidebar", { timeout: 10000 });
|
|
|
|
// Navigate to spec editor again
|
|
await navigateToSpecEditor(page);
|
|
|
|
// Wait for CodeMirror to be ready
|
|
const specEditorAfterReload = await getByTestId(page, "spec-editor");
|
|
await specEditorAfterReload.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
|
|
|
// Wait for CodeMirror content to update with the loaded spec
|
|
// The spec might need time to load into the editor after page reload
|
|
let contentMatches = false;
|
|
let attempts = 0;
|
|
const maxAttempts = 30; // Try for up to 30 seconds with 1-second intervals
|
|
|
|
while (!contentMatches && attempts < maxAttempts) {
|
|
try {
|
|
const contentElement = page.locator('[data-testid="spec-editor"] .cm-content');
|
|
const text = await contentElement.textContent();
|
|
if (text && text.trim() === "hello world") {
|
|
contentMatches = true;
|
|
break;
|
|
}
|
|
} catch (e) {
|
|
// Element might not be ready yet, continue
|
|
}
|
|
|
|
if (!contentMatches) {
|
|
await page.waitForTimeout(1000);
|
|
attempts++;
|
|
}
|
|
}
|
|
|
|
// If we didn't get the right content with our polling, use the fallback
|
|
if (!contentMatches) {
|
|
await page.waitForFunction(
|
|
(expectedContent) => {
|
|
const contentElement = document.querySelector('[data-testid="spec-editor"] .cm-content');
|
|
if (!contentElement) return false;
|
|
const text = (contentElement.textContent || "").trim();
|
|
return text === expectedContent;
|
|
},
|
|
"hello world",
|
|
{ timeout: 10000 }
|
|
);
|
|
}
|
|
|
|
// Step 12: Verify the content was persisted
|
|
const persistedContent = await getEditorContent(page);
|
|
expect(persistedContent.trim()).toBe("hello world");
|
|
});
|
|
|
|
test("should handle opening project via Open Project button and file browser", async ({
|
|
page,
|
|
}) => {
|
|
// This test covers the flow of:
|
|
// 1. Clicking Open Project button
|
|
// 2. Using the file browser to navigate to the fixture directory
|
|
// 3. Opening the project
|
|
// 4. Editing the spec
|
|
|
|
// Set up without a current project to test the open project flow
|
|
await page.addInitScript(() => {
|
|
const mockState = {
|
|
state: {
|
|
projects: [],
|
|
currentProject: null,
|
|
currentView: "welcome",
|
|
theme: "dark",
|
|
sidebarOpen: true,
|
|
apiKeys: { anthropic: "", google: "" },
|
|
chatSessions: [],
|
|
chatHistoryOpen: false,
|
|
maxConcurrency: 3,
|
|
},
|
|
version: 0,
|
|
};
|
|
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
|
|
|
// Mark setup as complete
|
|
const setupState = {
|
|
state: {
|
|
isFirstRun: false,
|
|
setupComplete: true,
|
|
currentStep: "complete",
|
|
skipClaudeSetup: false,
|
|
},
|
|
version: 0,
|
|
};
|
|
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
|
});
|
|
|
|
// Navigate to the app
|
|
await page.goto("/");
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Wait for the sidebar to be visible
|
|
const sidebar = await getByTestId(page, "sidebar");
|
|
await sidebar.waitFor({ state: "visible", timeout: 10000 });
|
|
|
|
// Click the Open Project button
|
|
const openProjectButton = await getByTestId(page, "open-project-button");
|
|
|
|
// Check if the button is visible (it might not be in collapsed sidebar)
|
|
const isButtonVisible = await openProjectButton
|
|
.isVisible()
|
|
.catch(() => false);
|
|
|
|
if (isButtonVisible) {
|
|
await clickElement(page, "open-project-button");
|
|
|
|
// The file browser dialog should open
|
|
// Note: In web mode, this might use the FileBrowserDialog component
|
|
// which makes requests to the backend server at /api/fs/browse
|
|
|
|
// Wait a bit to see if a dialog appears
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Check if a dialog is visible
|
|
const dialog = page.locator('[role="dialog"]');
|
|
const dialogVisible = await dialog.isVisible().catch(() => false);
|
|
|
|
if (dialogVisible) {
|
|
// If file browser dialog is open, we need to navigate to the fixture path
|
|
// This depends on the current directory structure
|
|
|
|
// For now, let's verify the dialog appeared and close it
|
|
// A full test would navigate through directories
|
|
console.log("File browser dialog opened successfully");
|
|
|
|
// Press Escape to close the dialog
|
|
await page.keyboard.press("Escape");
|
|
}
|
|
}
|
|
|
|
// For a complete e2e test with file browsing, we'd need to:
|
|
// 1. Navigate through the directory tree
|
|
// 2. Select the projectA directory
|
|
// 3. Click "Select Current Folder"
|
|
|
|
// Since this involves actual file system navigation,
|
|
// and depends on the backend server being properly configured,
|
|
// we'll verify the basic UI elements are present
|
|
|
|
expect(sidebar).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
test.describe("Spec Editor - Full Open Project Flow", () => {
|
|
test.beforeEach(async () => {
|
|
// Reset the fixture spec file to original content before each test
|
|
resetFixtureSpec();
|
|
});
|
|
|
|
test.afterEach(async () => {
|
|
// Clean up - reset the spec file after each test
|
|
resetFixtureSpec();
|
|
});
|
|
|
|
// Skip in CI - file browser navigation is flaky in headless environments
|
|
test.skip("should open project via file browser, edit spec, and persist", async ({
|
|
page,
|
|
}) => {
|
|
// Navigate to app first
|
|
await page.goto("/");
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Set up localStorage state (without a current project, but mark setup complete)
|
|
// Using evaluate instead of addInitScript so it only runs once
|
|
// Note: In CI, setup wizard is also skipped via NEXT_PUBLIC_SKIP_SETUP env var
|
|
await page.evaluate(() => {
|
|
const mockState = {
|
|
state: {
|
|
projects: [],
|
|
currentProject: null,
|
|
currentView: "welcome",
|
|
theme: "dark",
|
|
sidebarOpen: true,
|
|
apiKeys: { anthropic: "", google: "" },
|
|
chatSessions: [],
|
|
chatHistoryOpen: false,
|
|
maxConcurrency: 3,
|
|
},
|
|
version: 0,
|
|
};
|
|
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
|
|
|
// Mark setup as complete (fallback for when NEXT_PUBLIC_SKIP_SETUP isn't set)
|
|
const setupState = {
|
|
state: {
|
|
isFirstRun: false,
|
|
setupComplete: true,
|
|
currentStep: "complete",
|
|
skipClaudeSetup: false,
|
|
},
|
|
version: 0,
|
|
};
|
|
localStorage.setItem("automaker-setup", JSON.stringify(setupState));
|
|
});
|
|
|
|
// Reload to apply the localStorage state
|
|
await page.reload();
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Wait for sidebar
|
|
await waitForElement(page, "sidebar", { timeout: 10000 });
|
|
|
|
// Click the Open Project button
|
|
const openProjectButton = await getByTestId(page, "open-project-button");
|
|
await openProjectButton.waitFor({ state: "visible", timeout: 10000 });
|
|
await clickElement(page, "open-project-button");
|
|
|
|
// Wait for the file browser dialog to open
|
|
const dialogTitle = page.locator('text="Select Project Directory"');
|
|
await dialogTitle.waitFor({ state: "visible", timeout: 10000 });
|
|
|
|
// Wait for the dialog to fully load (loading to complete)
|
|
await page.waitForFunction(
|
|
() => !document.body.textContent?.includes("Loading directories..."),
|
|
{ timeout: 10000 }
|
|
);
|
|
|
|
// Use the path input to directly navigate to the fixture directory
|
|
const pathInput = await getByTestId(page, "path-input");
|
|
await pathInput.waitFor({ state: "visible", timeout: 5000 });
|
|
|
|
// Clear the input and type the full path to the fixture
|
|
await fillInput(page, "path-input", getFixturePath());
|
|
|
|
// Click the Go button to navigate to the path
|
|
await clickElement(page, "go-to-path-button");
|
|
|
|
// Wait for loading to complete
|
|
await page.waitForFunction(
|
|
() => !document.body.textContent?.includes("Loading directories..."),
|
|
{ timeout: 10000 }
|
|
);
|
|
|
|
// Verify we're in the right directory by checking the path display
|
|
const pathDisplay = page.locator(".font-mono.text-sm.truncate");
|
|
await expect(pathDisplay).toContainText("projectA");
|
|
|
|
// Click "Select Current Folder" button
|
|
const selectFolderButton = page.locator(
|
|
'button:has-text("Select Current Folder")'
|
|
);
|
|
await selectFolderButton.click();
|
|
|
|
// Wait for dialog to close and project to load
|
|
await page.waitForFunction(
|
|
() => !document.querySelector('[role="dialog"]'),
|
|
{ timeout: 10000 }
|
|
);
|
|
await page.waitForTimeout(500);
|
|
|
|
// Navigate to spec editor
|
|
const specNav = await getByTestId(page, "nav-spec");
|
|
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
|
await clickElement(page, "nav-spec");
|
|
|
|
// Wait for spec view with the editor (not the empty state)
|
|
await waitForElement(page, "spec-view", { timeout: 10000 });
|
|
const specEditorForOpenFlow = await getByTestId(page, "spec-editor");
|
|
await specEditorForOpenFlow.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
// Edit the content
|
|
await setEditorContent(page, "hello world");
|
|
|
|
// Click save button
|
|
await clickSaveButton(page);
|
|
|
|
// Refresh and verify persistence
|
|
await page.reload();
|
|
await waitForNetworkIdle(page);
|
|
|
|
// Navigate back to spec editor
|
|
await specNav.waitFor({ state: "visible", timeout: 10000 });
|
|
await clickElement(page, "nav-spec");
|
|
|
|
const specEditorAfterRefresh = await getByTestId(page, "spec-editor");
|
|
await specEditorAfterRefresh.locator(".cm-content").waitFor({ state: "visible", timeout: 10000 });
|
|
await page.waitForTimeout(500);
|
|
|
|
// Verify the content persisted
|
|
const persistedContent = await getEditorContent(page);
|
|
expect(persistedContent.trim()).toBe("hello world");
|
|
});
|
|
});
|