cli/internal/cmd/php/dockerfile_test.go
Snider f47e8211fb feat(mcp): add workspace root validation to prevent path traversal (#100)
* feat(mcp): add workspace root validation to prevent path traversal

- Add workspaceRoot field to Service for restricting file operations
- Add WithWorkspaceRoot() option for configuring the workspace directory
- Add validatePath() helper to check paths are within workspace
- Apply validation to all file operation handlers
- Default to current working directory for security
- Add comprehensive tests for path validation

Closes #82

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: move CLI commands from pkg/ to internal/cmd/

- Move 18 CLI command packages to internal/cmd/ (not externally importable)
- Keep 16 library packages in pkg/ (externally importable)
- Update all import paths throughout codebase
- Cleaner separation between CLI logic and reusable libraries

CLI commands moved: ai, ci, dev, docs, doctor, gitcmd, go, monitor,
php, pkgcmd, qa, sdk, security, setup, test, updater, vm, workspace

Libraries remaining: agentic, build, cache, cli, container, devops,
errors, framework, git, i18n, io, log, mcp, process, release, repos

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor(mcp): use pkg/io Medium for sandboxed file operations

Replace manual path validation with pkg/io.Medium for all file operations.
This delegates security (path traversal, symlink bypass) to the sandboxed
local.Medium implementation.

Changes:
- Add io.NewSandboxed() for creating sandboxed Medium instances
- Refactor MCP Service to use io.Medium instead of direct os.* calls
- Remove validatePath and resolvePathWithSymlinks functions
- Update tests to verify Medium-based behaviour

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct import path and workflow references

- Fix pkg/io/io.go import from core-gui to core
- Update CI workflows to use internal/cmd/updater path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(security): address CodeRabbit review issues for path validation

- pkg/io/local: add symlink resolution and boundary-aware containment
  - Reject absolute paths in sandboxed Medium
  - Use filepath.EvalSymlinks to prevent symlink bypass attacks
  - Fix prefix check to prevent /tmp/root matching /tmp/root2

- pkg/mcp: fix resolvePath to validate and return errors
  - Changed resolvePath from (string) to (string, error)
  - Update deleteFile, renameFile, listDirectory, fileExists to handle errors
  - Changed New() to return (*Service, error) instead of *Service
  - Properly propagate option errors instead of silently discarding

- pkg/io: wrap errors with E() helper for consistent context
  - Copy() and MockMedium.Read() now use coreerr.E()

- tests: rename to use _Good/_Bad/_Ugly suffixes per coding guidelines
  - Fix hardcoded /tmp in TestPath to use t.TempDir()
  - Add TestResolvePath_Bad_SymlinkTraversal test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix gofmt formatting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: fix gofmt formatting across all files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:59:34 +00:00

634 lines
17 KiB
Go

package php
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateDockerfile_Good(t *testing.T) {
t.Run("basic Laravel project", func(t *testing.T) {
dir := t.TempDir()
// Create composer.json
composerJSON := `{
"name": "test/laravel-project",
"require": {
"php": "^8.2",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
// Create composer.lock
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
// Check content
assert.Contains(t, content, "FROM dunglas/frankenphp")
assert.Contains(t, content, "php8.2")
assert.Contains(t, content, "COPY composer.json composer.lock")
assert.Contains(t, content, "composer install")
assert.Contains(t, content, "EXPOSE 80 443")
})
t.Run("Laravel project with Octane", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-octane",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "php8.3")
assert.Contains(t, content, "octane:start")
})
t.Run("project with frontend assets", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-vite",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{
"name": "test-app",
"scripts": {
"dev": "vite",
"build": "vite build"
}
}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
// Should have multi-stage build
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
assert.Contains(t, content, "npm ci")
assert.Contains(t, content, "npm run build")
assert.Contains(t, content, "COPY --from=frontend")
})
t.Run("project with pnpm", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-pnpm",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{
"name": "test-app",
"scripts": {
"build": "vite build"
}
}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
// Create pnpm-lock.yaml
err = os.WriteFile(filepath.Join(dir, "pnpm-lock.yaml"), []byte("lockfileVersion: 6.0"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "pnpm install")
assert.Contains(t, content, "pnpm run build")
})
t.Run("project with Redis dependency", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/laravel-redis",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"predis/predis": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "install-php-extensions")
assert.Contains(t, content, "redis")
})
t.Run("project with explicit ext- requirements", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/with-extensions",
"require": {
"php": "^8.3",
"ext-gd": "*",
"ext-imagick": "*",
"ext-intl": "*"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
assert.Contains(t, content, "install-php-extensions")
assert.Contains(t, content, "gd")
assert.Contains(t, content, "imagick")
assert.Contains(t, content, "intl")
})
}
func TestGenerateDockerfile_Bad(t *testing.T) {
t.Run("missing composer.json", func(t *testing.T) {
dir := t.TempDir()
_, err := GenerateDockerfile(dir)
assert.Error(t, err)
assert.Contains(t, err.Error(), "composer.json")
})
t.Run("invalid composer.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("not json{"), 0644)
require.NoError(t, err)
_, err = GenerateDockerfile(dir)
assert.Error(t, err)
})
}
func TestDetectDockerfileConfig_Good(t *testing.T) {
t.Run("full Laravel project", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/full-laravel",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0",
"predis/predis": "^2.0",
"intervention/image": "^3.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
packageJSON := `{"scripts": {"build": "vite build"}}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "yarn.lock"), []byte(""), 0644)
require.NoError(t, err)
config, err := DetectDockerfileConfig(dir)
require.NoError(t, err)
assert.Equal(t, "8.3", config.PHPVersion)
assert.True(t, config.IsLaravel)
assert.True(t, config.HasOctane)
assert.True(t, config.HasAssets)
assert.Equal(t, "yarn", config.PackageManager)
assert.Contains(t, config.PHPExtensions, "redis")
assert.Contains(t, config.PHPExtensions, "gd")
})
}
func TestDetectDockerfileConfig_Bad(t *testing.T) {
t.Run("non-existent directory", func(t *testing.T) {
_, err := DetectDockerfileConfig("/non/existent/path")
assert.Error(t, err)
})
}
func TestExtractPHPVersion_Good(t *testing.T) {
tests := []struct {
constraint string
expected string
}{
{"^8.2", "8.2"},
{"^8.3", "8.3"},
{">=8.2", "8.2"},
{"~8.2", "8.2"},
{"8.2.*", "8.2"},
{"8.2.0", "8.2"},
{"8", "8.0"},
}
for _, tt := range tests {
t.Run(tt.constraint, func(t *testing.T) {
result := extractPHPVersion(tt.constraint)
assert.Equal(t, tt.expected, result)
})
}
}
func TestDetectPHPExtensions_Good(t *testing.T) {
t.Run("detects Redis from predis", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"predis/predis": "^2.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "redis")
})
t.Run("detects GD from intervention/image", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"intervention/image": "^3.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "gd")
})
t.Run("detects multiple extensions from Laravel", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"laravel/framework": "^11.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "pdo_mysql")
assert.Contains(t, extensions, "bcmath")
})
t.Run("detects explicit ext- requirements", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-gd": "*",
"ext-imagick": "*",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "gd")
assert.Contains(t, extensions, "imagick")
})
t.Run("skips built-in extensions", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-json": "*",
"ext-session": "*",
"ext-pdo": "*",
},
}
extensions := detectPHPExtensions(composer)
assert.NotContains(t, extensions, "json")
assert.NotContains(t, extensions, "session")
assert.NotContains(t, extensions, "pdo")
})
t.Run("sorts extensions alphabetically", func(t *testing.T) {
composer := ComposerJSON{
Require: map[string]string{
"ext-zip": "*",
"ext-gd": "*",
"ext-intl": "*",
},
}
extensions := detectPHPExtensions(composer)
// Check they are sorted
for i := 1; i < len(extensions); i++ {
assert.True(t, extensions[i-1] < extensions[i],
"extensions should be sorted: %v", extensions)
}
})
}
func TestHasNodeAssets_Good(t *testing.T) {
t.Run("with build script", func(t *testing.T) {
dir := t.TempDir()
packageJSON := `{
"name": "test",
"scripts": {
"dev": "vite",
"build": "vite build"
}
}`
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
assert.True(t, hasNodeAssets(dir))
})
}
func TestHasNodeAssets_Bad(t *testing.T) {
t.Run("no package.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, hasNodeAssets(dir))
})
t.Run("no build script", func(t *testing.T) {
dir := t.TempDir()
packageJSON := `{
"name": "test",
"scripts": {
"dev": "vite"
}
}`
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
assert.False(t, hasNodeAssets(dir))
})
t.Run("invalid package.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("invalid{"), 0644)
require.NoError(t, err)
assert.False(t, hasNodeAssets(dir))
})
}
func TestGenerateDockerignore_Good(t *testing.T) {
t.Run("generates complete dockerignore", func(t *testing.T) {
dir := t.TempDir()
content := GenerateDockerignore(dir)
// Check key entries
assert.Contains(t, content, ".git")
assert.Contains(t, content, "node_modules")
assert.Contains(t, content, ".env")
assert.Contains(t, content, "vendor")
assert.Contains(t, content, "storage/logs/*")
assert.Contains(t, content, ".idea")
assert.Contains(t, content, ".vscode")
})
}
func TestGenerateDockerfileFromConfig_Good(t *testing.T) {
t.Run("minimal config", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3-alpine")
assert.Contains(t, content, "WORKDIR /app")
assert.Contains(t, content, "COPY composer.json composer.lock")
assert.Contains(t, content, "EXPOSE 80 443")
})
t.Run("with extensions", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
PHPExtensions: []string{"redis", "gd", "intl"},
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "install-php-extensions redis gd intl")
})
t.Run("Laravel with Octane", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
IsLaravel: true,
HasOctane: true,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "php artisan config:cache")
assert.Contains(t, content, "php artisan route:cache")
assert.Contains(t, content, "php artisan view:cache")
assert.Contains(t, content, "chown -R www-data:www-data storage")
assert.Contains(t, content, "octane:start")
})
t.Run("with frontend assets", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "npm",
}
content := GenerateDockerfileFromConfig(config)
// Multi-stage build
assert.Contains(t, content, "FROM node:20-alpine AS frontend")
assert.Contains(t, content, "COPY package.json package-lock.json")
assert.Contains(t, content, "RUN npm ci")
assert.Contains(t, content, "RUN npm run build")
assert.Contains(t, content, "COPY --from=frontend /app/public/build public/build")
})
t.Run("with yarn", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "yarn",
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "COPY package.json yarn.lock")
assert.Contains(t, content, "yarn install --frozen-lockfile")
assert.Contains(t, content, "yarn build")
})
t.Run("with bun", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: true,
HasAssets: true,
PackageManager: "bun",
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "npm install -g bun")
assert.Contains(t, content, "COPY package.json bun.lockb")
assert.Contains(t, content, "bun install --frozen-lockfile")
assert.Contains(t, content, "bun run build")
})
t.Run("non-alpine image", func(t *testing.T) {
config := &DockerfileConfig{
PHPVersion: "8.3",
BaseImage: "dunglas/frankenphp",
UseAlpine: false,
}
content := GenerateDockerfileFromConfig(config)
assert.Contains(t, content, "FROM dunglas/frankenphp:latest-php8.3 AS app")
assert.NotContains(t, content, "alpine")
})
}
func TestIsPHPProject_Good(t *testing.T) {
t.Run("project with composer.json", func(t *testing.T) {
dir := t.TempDir()
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte("{}"), 0644)
require.NoError(t, err)
assert.True(t, IsPHPProject(dir))
})
}
func TestIsPHPProject_Bad(t *testing.T) {
t.Run("project without composer.json", func(t *testing.T) {
dir := t.TempDir()
assert.False(t, IsPHPProject(dir))
})
t.Run("non-existent directory", func(t *testing.T) {
assert.False(t, IsPHPProject("/non/existent/path"))
})
}
func TestExtractPHPVersion_Edge(t *testing.T) {
t.Run("handles single major version", func(t *testing.T) {
result := extractPHPVersion("8")
assert.Equal(t, "8.0", result)
})
}
func TestDetectPHPExtensions_RequireDev(t *testing.T) {
t.Run("detects extensions from require-dev", func(t *testing.T) {
composer := ComposerJSON{
RequireDev: map[string]string{
"predis/predis": "^2.0",
},
}
extensions := detectPHPExtensions(composer)
assert.Contains(t, extensions, "redis")
})
}
func TestDockerfileStructure_Good(t *testing.T) {
t.Run("Dockerfile has proper structure", func(t *testing.T) {
dir := t.TempDir()
composerJSON := `{
"name": "test/app",
"require": {
"php": "^8.3",
"laravel/framework": "^11.0",
"laravel/octane": "^2.0",
"predis/predis": "^2.0"
}
}`
err := os.WriteFile(filepath.Join(dir, "composer.json"), []byte(composerJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "composer.lock"), []byte("{}"), 0644)
require.NoError(t, err)
packageJSON := `{"scripts": {"build": "vite build"}}`
err = os.WriteFile(filepath.Join(dir, "package.json"), []byte(packageJSON), 0644)
require.NoError(t, err)
err = os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0644)
require.NoError(t, err)
content, err := GenerateDockerfile(dir)
require.NoError(t, err)
lines := strings.Split(content, "\n")
var fromCount, workdirCount, copyCount, runCount, exposeCount, cmdCount int
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "FROM "):
fromCount++
case strings.HasPrefix(trimmed, "WORKDIR "):
workdirCount++
case strings.HasPrefix(trimmed, "COPY "):
copyCount++
case strings.HasPrefix(trimmed, "RUN "):
runCount++
case strings.HasPrefix(trimmed, "EXPOSE "):
exposeCount++
case strings.HasPrefix(trimmed, "CMD ["):
// Only count actual CMD instructions, not HEALTHCHECK CMD
cmdCount++
}
}
// Multi-stage build should have 2 FROM statements
assert.Equal(t, 2, fromCount, "should have 2 FROM statements for multi-stage build")
// Should have proper structure
assert.GreaterOrEqual(t, workdirCount, 1, "should have WORKDIR")
assert.GreaterOrEqual(t, copyCount, 3, "should have multiple COPY statements")
assert.GreaterOrEqual(t, runCount, 2, "should have multiple RUN statements")
assert.Equal(t, 1, exposeCount, "should have exactly one EXPOSE")
assert.Equal(t, 1, cmdCount, "should have exactly one CMD")
})
}