ExceptionUtilsTest.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.tika.utils;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;

import org.junit.jupiter.api.Test;

import org.apache.tika.config.ExceptionReporting;
import org.apache.tika.config.ExceptionReporting.Level;
import org.apache.tika.exception.TikaException;
import org.apache.tika.parser.ParseContext;

public class ExceptionUtilsTest {

    private static final String[] MESSAGES = {"outer secret", "middle secret", "inner secret",
            "suppressed secret"};

    private static Throwable chain() {
        IllegalStateException inner = new IllegalStateException(MESSAGES[2]);
        IOException middle = new IOException(MESSAGES[1], inner);
        TikaException outer = new TikaException(MESSAGES[0], middle);
        outer.addSuppressed(new RuntimeException(MESSAGES[3]));
        return outer;
    }

    private static String format(Throwable t, Level level) {
        return ExceptionUtils.format(t, new ExceptionReporting(level, ExceptionReporting.UNLIMITED));
    }

    private static void assertNoMessages(String s) {
        for (String m : MESSAGES) {
            assertFalse(s.contains(m), "must not contain '" + m + "':\n" + s);
        }
    }

    @Test
    public void fullMatchesPrintStackTrace() {
        Throwable t = chain();
        String full = format(t, Level.FULL);
        StringWriter expected = new StringWriter();
        try (PrintWriter writer = new PrintWriter(expected)) {
            t.printStackTrace(writer);
        }
        assertEquals(expected.toString(), full);
        for (String m : MESSAGES) {
            assertTrue(full.contains(m));
        }
        assertTrue(full.contains("Caused by: java.io.IOException: " + MESSAGES[1]));
    }

    @Test
    public void messageRedactedEqualsFullMinusMessages() {
        Throwable t = chain();
        String full = format(t, Level.FULL);
        String redacted = format(t, Level.MESSAGE_REDACTED);
        assertNoMessages(redacted);
        assertTrue(redacted.contains("\tat "));
        assertTrue(redacted.contains("Caused by: java.io.IOException\n"));
        assertTrue(redacted.contains("\tSuppressed: java.lang.RuntimeException\n"));
        assertTrue(redacted.contains(" more\n"), "common-frame elision kept");
        // Strip ": message" from every header line of FULL (frame lines start with a tab,
        // so they cannot match); the rest must be identical.
        String expected = full.replaceAll("(?m)^((?:\\t*Suppressed: |Caused by: )?[\\w.$]+): .*$", "$1");
        expected = expected.replace("\r", ""); // windows
        assertEquals(expected, redacted);
    }

    @Test
    public void redactedIsClassChainOnly() {
        String s = format(chain(), Level.REDACTED);
        assertNoMessages(s);
        assertFalse(s.contains("\tat "));
        assertEquals("org.apache.tika.exception.TikaException\n"
                + "\tSuppressed: java.lang.RuntimeException\n"
                + "Caused by: java.io.IOException\n"
                + "Caused by: java.lang.IllegalStateException\n", s);
    }

    @Test
    public void cyclicCauseTerminates() throws Exception {
        RuntimeException a = new RuntimeException("a");
        RuntimeException b = new RuntimeException("b", a);
        a.initCause(b);
        for (Level level : Level.values()) {
            String s = format(a, level);
            assertTrue(s.contains("CIRCULAR REFERENCE"), level + ":\n" + s);
        }
    }

    @Test
    public void deepChainCapped() {
        Throwable t = new RuntimeException("leaf");
        for (int i = 0; i < 200; i++) {
            t = new RuntimeException("level " + i, t);
        }
        String s = format(t, Level.REDACTED);
        assertTrue(s.contains("cause chain truncated"));
        long causes = s.lines().filter(l -> l.startsWith("Caused by: ")).count();
        assertEquals(64, causes, "cause chain must stop at MAX_CAUSE_DEPTH");
    }

    @Test
    public void maxLengthTruncates() {
        Throwable t = chain();
        String s = ExceptionUtils.format(t, new ExceptionReporting(Level.FULL, 50));
        assertTrue(s.startsWith(format(t, Level.FULL).substring(0, 50)));
        assertTrue(s.endsWith("...[truncated]"));
        assertEquals(50 + "...[truncated]".length(), s.length());
    }

    @Test
    public void maxLengthDoesNotSplitSurrogatePair() {
        // Cut lands between the two UTF-16 units of the astral char; must back off by one.
        Throwable t = new RuntimeException("x����yyyyyyyy");
        int cut = "java.lang.RuntimeException: x".length() + 1;
        String s = ExceptionUtils.format(t, new ExceptionReporting(Level.FULL, cut));
        assertTrue(s.endsWith("x...[truncated]"), s);
    }

    @Test
    public void nullContextIsFull() {
        Throwable t = chain();
        String full = format(t, Level.FULL);
        assertEquals(full, ExceptionUtils.format(t, (ParseContext) null));
        assertEquals(full, ExceptionUtils.format(t, new ParseContext()));
    }

    @Test
    public void nullReportingIsFull() {
        // formatting someone else's exception must never NPE
        Throwable t = chain();
        assertEquals(format(t, Level.FULL),
                ExceptionUtils.format(t, (ExceptionReporting) null));
    }

    @Test
    public void getFilteredStackTraceStillUnwrapsBareTikaException() {
        Throwable t = chain();
        assertEquals(ExceptionUtils.format(t.getCause(), (ParseContext) null),
                ExceptionUtils.getFilteredStackTrace(t));
    }

    @Test
    public void contextPolicyApplies() {
        ParseContext context = new ParseContext();
        context.set(ExceptionReporting.class, new ExceptionReporting(Level.REDACTED, -1));
        assertNoMessages(ExceptionUtils.format(chain(), context));
    }

}