ThrowableViewDeserializationBypassTest.java

package tools.jackson.databind.views;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonView;

import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectReader;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.exc.MismatchedInputException;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.*;

/**
 * {@code @JsonView} filtering is applied by {@code BeanDeserializer} (and the array/builder
 * variants), but {@link tools.jackson.databind.deser.jdk.ThrowableDeserializer} overrides
 * {@code deserializeFromObject} wholesale and never consulted the active view. For an
 * exception type that reaches that loop (default-constructor, no property-based creator),
 * a view-hidden property was populated from input even when the active view excluded it.
 */
public class ThrowableViewDeserializationBypassTest extends DatabindTestUtil
{
    static class Public {}
    static class Internal {}

    @SuppressWarnings("serial")
    static class ViewException extends RuntimeException {
        @JsonView(Public.class) public String pub;
        @JsonView(Internal.class) public String sec; // internal-only
        public ViewException() { super(); }
    }

    // Same, but with a single-String constructor so that "message" is actually settable
    // (with only a default constructor it is skipped; see [databind#4071])
    @SuppressWarnings("serial")
    static class StdPropsException extends RuntimeException {
        @JsonView(Public.class) public String pub;
        @JsonView(Internal.class) public String sec; // internal-only
        public StdPropsException() { super(); }
        public StdPropsException(String msg) { super(msg); }
    }

    // [databind#6174]: the exemption must cover ALL standard `Throwable` properties,
    // including "message" and "suppressed" -- which a sub-class may make settable, in
    // which case they are bound as regular properties (and not handled separately)
    @SuppressWarnings("serial")
    static class SettableStdPropsException extends RuntimeException {
        protected String _msg;
        public Throwable[] supp;

        public SettableStdPropsException() { super(); }

        @Override public String getMessage() { return _msg; }
        public void setMessage(String msg) { _msg = msg; }
        public void setSuppressed(Throwable[] s) { supp = s; }
    }

    private final ObjectMapper MAPPER = newJsonMapper();

    private final ObjectMapper FAIL_ON_UNEXPECTED_MAPPER = jsonMapperBuilder()
            .enable(DeserializationFeature.FAIL_ON_UNEXPECTED_VIEW_PROPERTIES).build();

    // Under the Public view the Internal-only property must not be set from input
    @Test
    public void throwableHonorsViewOnDeserialize() throws Exception {
        ViewException ex = MAPPER.readerWithView(Public.class)
                .forType(ViewException.class)
                .readValue("{\"pub\":\"visible\",\"sec\":\"leaked\"}");

        assertEquals("visible", ex.pub);
        assertNull(ex.sec,
                "view-hidden 'sec' should stay null under the Public view but was: " + ex.sec);
    }

    // Control: with no active view every property is set as before
    @Test
    public void throwableWithoutViewSetsAll() throws Exception {
        ViewException ex = MAPPER.readerFor(ViewException.class)
                .readValue("{\"pub\":\"visible\",\"sec\":\"kept\"}");

        assertEquals("visible", ex.pub);
        assertEquals("kept", ex.sec);
    }

    // [databind#6174]: view filtering must not affect the standard `Throwable` properties;
    // "message", "cause", "stackTrace", "suppressed" and "localizedMessage" have no
    // `@JsonView` of their own and must default to inclusion under any active view.
    @Test
    public void standardThrowablePropsIncludedUnderView() throws Exception {
        final String json = """
{
  "message" : "the message",
  "cause" : { "message" : "root cause" },
  "stackTrace" : [ {
    "className" : "some.Class", "methodName" : "someMethod",
    "fileName" : "Class.java", "lineNumber" : 42
  } ],
  "suppressed" : [ { "message" : "suppressed one" } ],
  "localizedMessage" : "the message",
  "pub" : "visible",
  "sec" : "leaked"
}
""";
        StdPropsException ex = MAPPER.readerWithView(Public.class)
                .forType(StdPropsException.class)
                .readValue(json);

        // First: view filtering still applies to view-annotated properties
        assertEquals("visible", ex.pub);
        assertNull(ex.sec);

        // But none of the standard `Throwable` properties may be dropped:
        assertEquals("the message", ex.getMessage());
        assertEquals("the message", ex.getLocalizedMessage());

        assertNotNull(ex.getCause(), "'cause' should be set under active view");
        assertEquals("root cause", ex.getCause().getMessage());

        // NOTE: only checking that the property itself was applied from input (a
        // single frame), and not left as the multi-frame fill-in trace. Contents of
        // the nested `StackTraceElement` follow the regular bean/View rules -- with
        // `DEFAULT_VIEW_INCLUSION` disabled its un-annotated properties are not part
        // of any view -- which is out of scope here.
        StackTraceElement[] trace = ex.getStackTrace();
        assertEquals(1, trace.length,
                "'stackTrace' should be set from input under active view");

        Throwable[] suppressed = ex.getSuppressed();
        assertEquals(1, suppressed.length,
                "'suppressed' should be set under active view");
        assertEquals("suppressed one", suppressed[0].getMessage());
    }

    // [databind#6174]: ...and that holds for "message"/"suppressed" bound as regular
    // properties, too: without an explicit `@JsonView` they must not be view-filtered
    @Test
    public void settableStandardThrowablePropsIncludedUnderView() throws Exception {
        final String json = """
{
  "message" : "the message",
  "suppressed" : [ { "message" : "suppressed one" } ]
}
""";
        SettableStdPropsException ex = MAPPER.readerWithView(Public.class)
                .forType(SettableStdPropsException.class)
                .readValue(json);
        assertEquals("the message", ex.getMessage(),
                "settable 'message' should be set from input under active view");
        assertNotNull(ex.supp, "settable 'suppressed' should be set from input under active view");
        assertEquals(1, ex.supp.length);

        // ... and must not trigger `FAIL_ON_UNEXPECTED_VIEW_PROPERTIES` either
        SettableStdPropsException ex2 = FAIL_ON_UNEXPECTED_MAPPER.readerWithView(Public.class)
                .forType(SettableStdPropsException.class)
                .readValue(json);
        assertEquals("the message", ex2.getMessage());
    }

    // [databind#437]: with `FAIL_ON_UNEXPECTED_VIEW_PROPERTIES` enabled, a property
    // outside the active view is reported as an unexpected property instead of skipped
    @Test
    public void throwableFailsOnUnexpectedViewProperty() throws Exception {
        ObjectReader r = FAIL_ON_UNEXPECTED_MAPPER.readerWithView(Public.class)
                .forType(ViewException.class);
        try {
            r.readValue("{\"pub\":\"visible\",\"sec\":\"leaked\"}");
            fail("should not pass, but fail with exception with unexpected view");
        } catch (MismatchedInputException e) {
            verifyException(e, "Input mismatch while deserializing");
            verifyException(e, "Property 'sec' is not part of current active view");
        }
    }

    // ...but the standard `Throwable` properties are exempt from view filtering, so
    // they must not trigger the failure either
    @Test
    public void throwableStandardPropsDoNotFailOnUnexpectedView() throws Exception {
        final String json = """
{
  "message" : "the message",
  "cause" : { "message" : "root cause" },
  "stackTrace" : [ ],
  "suppressed" : [ ],
  "localizedMessage" : "the message",
  "pub" : "visible"
}
""";
        StdPropsException ex = FAIL_ON_UNEXPECTED_MAPPER.readerWithView(Public.class)
                .forType(StdPropsException.class)
                .readValue(json);

        assertEquals("visible", ex.pub);
        assertEquals("the message", ex.getMessage());
        assertNotNull(ex.getCause());
        assertEquals(0, ex.getStackTrace().length);
    }

    // [databind#3497]: ...and the exemption must survive a `PropertyNamingStrategy`.
    // With SNAKE_CASE the property is externally named "stack_trace", which no
    // case-insensitive comparison against "stackTrace" can ever match
    @Test
    public void standardThrowablePropsIncludedUnderViewWithNamingStrategy() throws Exception {
        ObjectMapper mapper = jsonMapperBuilder()
                .propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
                .build();
        final String json = """
{
  "message" : "the message",
  "cause" : { "message" : "root cause" },
  "stack_trace" : [ {
    "class_name" : "some.Class", "method_name" : "someMethod",
    "file_name" : "Class.java", "line_number" : 42
  } ],
  "pub" : "visible",
  "sec" : "leaked"
}
""";
        StdPropsException ex = mapper.readerWithView(Public.class)
                .forType(StdPropsException.class)
                .readValue(json);

        assertEquals("visible", ex.pub);
        assertNull(ex.sec);
        assertEquals("the message", ex.getMessage());
        assertNotNull(ex.getCause(), "'cause' should be set under active view");
        assertEquals(1, ex.getStackTrace().length,
                "'stack_trace' should be set from input under active view");
    }
}