DefaultExceptionHandlerTest.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */
package org.apache.maven.exception;

import java.io.File;
import java.io.IOException;
import java.net.ConnectException;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.maven.model.Plugin;
import org.apache.maven.model.building.DefaultModelProblem;
import org.apache.maven.model.building.ModelProblem;
import org.apache.maven.plugin.MojoExecution;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.PluginContainerException;
import org.apache.maven.plugin.PluginExecutionException;
import org.apache.maven.plugin.descriptor.MojoDescriptor;
import org.apache.maven.plugin.descriptor.PluginDescriptor;
import org.apache.maven.project.ProjectBuildingException;
import org.apache.maven.project.ProjectBuildingResult;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.resolution.ArtifactResolutionException;
import org.eclipse.aether.resolution.ArtifactResult;
import org.eclipse.aether.transfer.ArtifactFilteredOutException;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
 */
class DefaultExceptionHandlerTest {
    /**
     * Running Maven under JDK7 may cause connection issues because IPv6 is used by default.
     * <p>
     * e.g running mvn site:run will cause Jetty to fail.
     * </p>
     * <p>
     * The resolution is to add -Djava.net.preferIPv4Stack=true to the command line as documented in
     * http://cwiki.apache.org/confluence/display/MAVEN/ConnectException
     * </p>
     */
    @Test
    void testJdk7ipv6() {
        ConnectException connEx = new ConnectException("Connection refused: connect");
        IOException ioEx = new IOException("Unable to establish loopback connection", connEx);
        MojoExecutionException mojoEx =
                new MojoExecutionException("Error executing Jetty: Unable to establish loopback connection", ioEx);

        ExceptionHandler exceptionHandler = new DefaultExceptionHandler();
        ExceptionSummary exceptionSummary = exceptionHandler.handleException(mojoEx);

        String expectedReference = "http://cwiki.apache.org/confluence/display/MAVEN/ConnectException";
        assertEquals(expectedReference, exceptionSummary.getReference());
    }

    @Test
    void testHandleExceptionAetherClassNotFound() {
        Throwable cause2 = new NoClassDefFoundError("org/sonatype/aether/RepositorySystem");
        Plugin plugin = new Plugin();
        Exception cause = new PluginContainerException(plugin, null, null, cause2);
        PluginDescriptor pluginDescriptor = new PluginDescriptor();
        MojoDescriptor mojoDescriptor = new MojoDescriptor();
        mojoDescriptor.setPluginDescriptor(pluginDescriptor);
        MojoExecution mojoExecution = new MojoExecution(mojoDescriptor);
        Throwable exception = new PluginExecutionException(mojoExecution, null, cause);

        DefaultExceptionHandler handler = new DefaultExceptionHandler();
        ExceptionSummary summary = handler.handleException(exception);

        String expectedReference = "http://cwiki.apache.org/confluence/display/MAVEN/AetherClassNotFound";
        assertEquals(expectedReference, summary.getReference());
    }

    @Test
    void testHandleExceptionNoClassDefFoundErrorNull() {
        Throwable cause2 = new NoClassDefFoundError();
        Plugin plugin = new Plugin();
        Exception cause = new PluginContainerException(plugin, null, null, cause2);
        PluginDescriptor pluginDescriptor = new PluginDescriptor();
        MojoDescriptor mojoDescriptor = new MojoDescriptor();
        mojoDescriptor.setPluginDescriptor(pluginDescriptor);
        MojoExecution mojoExecution = new MojoExecution(mojoDescriptor);
        Throwable exception = new PluginExecutionException(mojoExecution, null, cause);

        DefaultExceptionHandler handler = new DefaultExceptionHandler();
        ExceptionSummary summary = handler.handleException(exception);

        String expectedReference = "http://cwiki.apache.org/confluence/display/MAVEN/PluginContainerException";
        assertEquals(expectedReference, summary.getReference());
    }

    @Test
    void testHandleExceptionLoopInCause() {
        // Some broken exception that does return "this" as getCause
        AtomicReference<Throwable> causeRef = new AtomicReference<>(null);
        Exception cause2 = new RuntimeException("loop") {
            @Override
            public synchronized Throwable getCause() {
                return causeRef.get();
            }
        };
        causeRef.set(cause2);

        Plugin plugin = new Plugin();
        Exception cause = new PluginContainerException(plugin, null, null, cause2);
        cause2.initCause(cause);
        PluginDescriptor pluginDescriptor = new PluginDescriptor();
        MojoDescriptor mojoDescriptor = new MojoDescriptor();
        mojoDescriptor.setPluginDescriptor(pluginDescriptor);
        MojoExecution mojoExecution = new MojoExecution(mojoDescriptor);
        Throwable exception = new PluginExecutionException(mojoExecution, null, cause);

        DefaultExceptionHandler handler = new DefaultExceptionHandler();
        ExceptionSummary summary = handler.handleException(exception);

        String expectedReference = "http://cwiki.apache.org/confluence/display/MAVEN/PluginContainerException";
        assertEquals(expectedReference, summary.getReference());
    }

    @Test
    void testArtifactFilteredOutException() {
        RemoteRepository repo =
                new RemoteRepository.Builder("my-repo", "default", "https://repo.example.com/maven").build();
        ArtifactFilteredOutException filterEx = new ArtifactFilteredOutException(
                new DefaultArtifact("com.example:my-lib:jar:1.0"),
                repo,
                "Prefix com/example/my-lib/1.0/my-lib-1.0.jar NOT allowed from my-repo"
                        + " (https://repo.example.com/maven, default, releases)");
        ArtifactResult artifactResult = new ArtifactResult(new org.eclipse.aether.resolution.ArtifactRequest(
                new DefaultArtifact("com.example:my-lib:jar:1.0"), java.util.List.of(repo), null));
        artifactResult.addException(filterEx);
        ArtifactResolutionException resolutionEx =
                new ArtifactResolutionException(java.util.List.of(artifactResult), "Could not resolve artifact");
        MojoExecutionException mojoEx = new MojoExecutionException("Resolution failed", resolutionEx);

        DefaultExceptionHandler handler = new DefaultExceptionHandler();
        ExceptionSummary summary = handler.handleException(mojoEx);

        assertTrue(
                summary.getMessage().contains("-Daether.remoteRepositoryFilter.prefixes=false"),
                "Message should contain the prefixes workaround property");
        assertTrue(
                summary.getMessage().contains("-Daether.remoteRepositoryFilter.groupId=false"),
                "Message should contain the groupId workaround property");
        assertTrue(
                summary.getMessage().contains("remote repository filter"),
                "Message should explain the filtering cause");
        assertEquals(
                "https://maven.apache.org/resolver/remote-repository-filtering.html",
                summary.getReference(),
                "Reference should point to the RRF documentation");
    }

    @Test
    void testHandleExceptionSelfReferencing() {
        RuntimeException boom3 = new RuntimeException("BOOM3");
        RuntimeException boom2 = new RuntimeException("BOOM2", boom3);
        RuntimeException boom1 = new RuntimeException("BOOM1", boom2);
        boom3.initCause(boom1);

        DefaultExceptionHandler handler = new DefaultExceptionHandler();
        ExceptionSummary summary = handler.handleException(boom1);

        assertEquals("BOOM1: BOOM2: BOOM3: [CIRCULAR REFERENCE]", summary.getMessage());
        assertEquals("", summary.getReference());
        assertEquals(0, summary.getChildren().size());
        assertEquals(boom1, summary.getException());
    }

    @Test
    void testProjectBuildingExceptionNotRenderedTwice() {
        ModelProblem problem1 = new DefaultModelProblem(
                "Malformed POM test.xml: unexpected element",
                ModelProblem.Severity.FATAL,
                null,
                "test.xml",
                8,
                3,
                null,
                null);

        ModelProblem problem2 = new DefaultModelProblem(
                "Malformed POM test.xml: missing artifactId",
                ModelProblem.Severity.ERROR,
                null,
                "test.xml",
                12,
                5,
                null,
                null);

        ProjectBuildingResult result = mock(ProjectBuildingResult.class);
        when(result.getProjectId()).thenReturn("test:fail-build:0.1-SNAPSHOT");
        when(result.getPomFile()).thenReturn(new File("test.xml"));
        when(result.getProblems()).thenReturn(List.of(problem1, problem2));

        ProjectBuildingException exception = new ProjectBuildingException(List.of(result));

        assertEquals(
                "Encountered 2 problem(s) while processing the POMs",
                exception.getMessage(),
                "exception message must report the total number of problems");

        ExceptionSummary summary = new DefaultExceptionHandler().handleException(exception);

        ExceptionSummary projectSummary = summary.getChildren().get(0);
        ExceptionSummary problemSummary = projectSummary.getChildren().get(0);

        assertTrue(
                problemSummary.getMessage().contains("Malformed POM test.xml"),
                "the handler's own tree should still render the problem detail exactly once");

        assertEquals(1, countOccurrences(flatten(summary), "Malformed POM test.xml: unexpected element"));
    }

    private static String flatten(ExceptionSummary summary) {
        if (summary == null) {
            return "";
        }

        StringBuilder sb = new StringBuilder();
        if (summary.getMessage() != null) {
            sb.append(summary.getMessage());
        }

        for (ExceptionSummary child : summary.getChildren()) {
            if (!sb.isEmpty()) {
                sb.append('\n');
            }
            sb.append(flatten(child));
        }

        return sb.toString();
    }

    private static int countOccurrences(String text, String substring) {
        int count = 0;
        int index = 0;

        while ((index = text.indexOf(substring, index)) != -1) {
            count++;
            index += substring.length();
        }

        return count;
    }
}