package stackutils import ( "os" "path/filepath" "testing" portainer "github.com/portainer/portainer/api" "github.com/stretchr/testify/require" ) func TestIsValidStackFile_DefaultPortEnvSubstitution(t *testing.T) { yamlContent := []byte(` version: "3" services: webservice: image: nginx container_name: hello-world networks: - "mynet1" ports: - "${PORT:-8080}:80" networks: mynet1: driver: bridge ipam: config: - subnet: 172.16.0.0/24 `) securitySettings := &portainer.EndpointSecuritySettings{} err := IsValidStackFile(StackFileValidationConfig{ Content: yamlContent, SecuritySettings: securitySettings, }) require.NoError(t, err) } // TestIsValidStackFile_MissingEnvVarBehavior documents how port variable position affects // validation when the env var is not provided. Docker accepts an empty host port (left side) // but requires a valid container port (right side). func TestIsValidStackFile_MissingEnvVarBehavior(t *testing.T) { securitySettings := &portainer.EndpointSecuritySettings{} t.Run("var on left side only passes (docker allows :9090)", func(t *testing.T) { err := IsValidStackFile(StackFileValidationConfig{ Content: []byte(` version: "3" services: api: image: nginx ports: - "${API_PORT}:9090" `), SecuritySettings: securitySettings, }) require.NoError(t, err) }) t.Run("var on right side fails", func(t *testing.T) { err := IsValidStackFile(StackFileValidationConfig{ Content: []byte(` version: "3" services: api: image: nginx ports: - "9090:${API_PORT}" `), SecuritySettings: securitySettings, }) require.Error(t, err) }) t.Run("var on both sides fails", func(t *testing.T) { err := IsValidStackFile(StackFileValidationConfig{ Content: []byte(` version: "3" services: api: image: nginx ports: - "${API_PORT}:${API_PORT}" `), SecuritySettings: securitySettings, }) require.Error(t, err) }) } func TestIsValidStackFile_EnvVarInBothPortFields(t *testing.T) { securitySettings := &portainer.EndpointSecuritySettings{} err := IsValidStackFile(StackFileValidationConfig{ Content: []byte(` version: "3" services: api: image: nginx ports: - "${API_PORT}:${API_PORT}" `), SecuritySettings: securitySettings, Env: map[string]string{"API_PORT": "3000"}, }) require.NoError(t, err) } type mockFileService struct { portainer.FileService fileContent []byte projectVersionPath string } func (m mockFileService) GetFileContent(trustedRootPath, filePath string) ([]byte, error) { return m.fileContent, nil } func (m mockFileService) FormProjectPathByVersion(projectPath string, version int, commitHash string) string { return m.projectVersionPath } func TestValidateStackFiles_EnvVars(t *testing.T) { fileContent := []byte(` version: "3" services: api: image: nginx ports: - "${API_PORT}:${API_PORT}" `) stack := &portainer.Stack{ ProjectPath: "/tmp/stack/1", EntryPoint: "docker-compose.yml", Env: []portainer.Pair{{Name: "API_PORT", Value: "3000"}}, } fileService := mockFileService{ fileContent: fileContent, projectVersionPath: "/tmp/stack/1", } securitySettings := &portainer.EndpointSecuritySettings{} err := ValidateStackFiles(stack, securitySettings, fileService) require.NoError(t, err) } func TestValidateStackFiles_OSEnvVar(t *testing.T) { t.Setenv("HOST_PORT", "3000") fileContent := []byte(` version: "3" services: api: image: nginx ports: - "80:${HOST_PORT}" `) stack := &portainer.Stack{ ProjectPath: "/tmp/stack/1", EntryPoint: "docker-compose.yml", } fileService := mockFileService{ fileContent: fileContent, projectVersionPath: "/tmp/stack/1", } securitySettings := &portainer.EndpointSecuritySettings{} err := ValidateStackFiles(stack, securitySettings, fileService) require.NoError(t, err) } func TestValidateStackFiles_DotEnvFile(t *testing.T) { tmpDir := t.TempDir() err := os.WriteFile(filepath.Join(tmpDir, ".env"), []byte("HOST_PORT=3000\n"), 0600) require.NoError(t, err) fileContent := []byte(` version: "3" services: api: image: nginx ports: - "80:${HOST_PORT}" `) stack := &portainer.Stack{ ProjectPath: tmpDir, EntryPoint: "docker-compose.yml", } fileService := mockFileService{ fileContent: fileContent, projectVersionPath: tmpDir, } securitySettings := &portainer.EndpointSecuritySettings{} err = ValidateStackFiles(stack, securitySettings, fileService) require.NoError(t, err) } func TestValidateStackFiles_EnvFileAttribute(t *testing.T) { tmpDir := t.TempDir() err := os.WriteFile(filepath.Join(tmpDir, "web.env"), []byte("HOST_PORT=3000\n"), 0600) require.NoError(t, err) fileContent := []byte(` version: "3" services: api: image: nginx env_file: - ./web.env `) stack := &portainer.Stack{ ProjectPath: tmpDir, EntryPoint: "docker-compose.yml", } fileService := mockFileService{ fileContent: fileContent, projectVersionPath: tmpDir, } securitySettings := &portainer.EndpointSecuritySettings{} err = ValidateStackFiles(stack, securitySettings, fileService) require.NoError(t, err) } func TestValidateStackFiles_BindMountBlockedForNonAdmin(t *testing.T) { fileContent := []byte(` version: "3" services: api: image: nginx volumes: - /host/path:/container/path `) stack := &portainer.Stack{ ProjectPath: "/tmp/stack/1", EntryPoint: "docker-compose.yml", } fileService := mockFileService{ fileContent: fileContent, projectVersionPath: "/tmp/stack/1", } securitySettings := &portainer.EndpointSecuritySettings{ AllowBindMountsForRegularUsers: false, } err := ValidateStackFiles(stack, securitySettings, fileService) require.ErrorContains(t, err, "bind-mount disabled for non administrator users") }