FailOnUnexpectedView6077Test.java

package tools.jackson.databind.views;

import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.*;

import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.InjectableValues;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectReader;
import tools.jackson.databind.annotation.JsonDeserialize;
import tools.jackson.databind.annotation.JsonPOJOBuilder;
import tools.jackson.databind.exc.MismatchedInputException;
import tools.jackson.databind.testutil.DatabindTestUtil;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.fail;

/**
 * Coverage for [databind#6077]: {@code DeserializationFeature.FAIL_ON_UNEXPECTED_VIEW_PROPERTIES}
 * used to be honored only on the plain field/setter path
 * ({@code BeanDeserializer.deserializeWithView()}). A view-hidden property fed through any
 * other binding path was silently dropped instead of triggering the documented failure.
 *<p>
 * The fix routes every such skip site through
 * {@code BeanDeserializerBase.handleUnexpectedView(...)}. This suite exercises <b>each modified
 * skip site</b> at least once. The nested-class Javadoc names the exact method + condition it
 * pins so a future refactor that moves the site can be re-mapped mechanically:
 *<ul>
 *  <li>{@link Records} -- {@code BeanDeserializer._deserializeUsingPropertyBased} creator arm;</li>
 *  <li>{@link PropertiesCreator} -- same method, regular-property buffering arm (before + after
 *      the creator arg);</li>
 *  <li>{@link RecordUpdate} -- {@code BeanDeserializer._deserializeRecordForUpdate} (the split
 *      view/injection-only condition);</li>
 *  <li>{@link BuilderCreator} -- {@code BuilderBasedDeserializer._deserializeUsingPropertyBased};</li>
 *  <li>{@link SetterUnwrapped} / {@link BuilderUnwrapped} -- the setter/mutator buffering loops
 *      entered when a bean carries an {@code @JsonUnwrapped} member
 *      ({@code deserializeWithUnwrapped} / {@code deserializeUsingPropertyBasedWithUnwrapped});</li>
 *  <li>{@link PropertiesCreatorUnwrapped} -- regular-property arm of
 *      {@code BeanDeserializer.deserializeUsingPropertyBasedWithUnwrapped};</li>
 *  <li>{@link UnwrappedHiddenCreatorParam} -- <i>creator</i> arm of the same method;</li>
 *  <li>{@link ExternalTypeIdSetter} -- {@code BeanDeserializer._deserializeWithExternalTypeId};</li>
 *  <li>{@link ExternalTypeIdCreator} -- both arms of
 *      {@code BeanDeserializer.deserializeUsingPropertyBasedWithExternalTypeId};</li>
 *  <li>{@link BuilderExternalTypeId} -- {@code BuilderBasedDeserializer.deserializeWithExternalTypeId};</li>
 *  <li>{@link BeanAsArraySetter} / {@link BeanAsArrayCreator} / {@link BeanAsArrayUpdate} -- the
 *      positional loops of {@code BeanAsArrayDeserializer} ({@code @JsonFormat(shape = ARRAY)}),
 *      on the create, property-based-creator, and update-existing-value paths respectively;</li>
 *  <li>{@link BeanAsArrayBuilderSetter} / {@link BeanAsArrayBuilderCreator} -- ditto for
 *      {@code BeanAsArrayBuilderDeserializer}.</li>
 *</ul>
 *
 * Each fixture asserts the three-way contract, which is also what proves the property really
 * travelled through a {@code handleUnexpectedView} site (that message and the feature fork exist
 * nowhere else):
 *<ol>
 *  <li>the wider view still binds the restricted property (guards against a vacuous pass);</li>
 *  <li>feature ON + hidden property present in input -> {@link MismatchedInputException};</li>
 *  <li>feature OFF -> the property is silently skipped (unchanged legacy behavior).</li>
 *</ol>
 *
 * See also {@link InjectionOnlyCreatorParam}, which pins the precedence between the view check
 * and the {@code @JacksonInject(useInput = FALSE)} skip that used to share one fused condition.
 *<p>
 * Deliberately <b>out of scope</b> (see {@link NotCovered}): properties resolved through the
 * {@code ExternalTypeHandler} / {@code UnwrappedPropertyHandler} helper classes, i.e. the
 * {@code @JsonTypeInfo(include = EXTERNAL_PROPERTY)} <i>value</i> itself and {@code @JsonUnwrapped}
 * <i>creator parameters</i>. Those handlers walk buffered declared properties rather than incoming
 * JSON names, so honoring the feature there is a larger change left for a follow-up; the
 * {@link NotCovered} tests pin the current (still-silent) behavior so a future change is a visible,
 * intentional edit rather than a silent regression.
 */
public class FailOnUnexpectedView6077Test extends DatabindTestUtil
{
    static class Public {}
    static class Admin extends Public {}

    // Shared payload hierarchy for the EXTERNAL_PROPERTY fixtures.
    interface Payload {}

    @JsonTypeName("text")
    static class TextPayload implements Payload {
        public String body;
    }

    // DEFAULT_VIEW_INCLUSION differs between 2.x and 3.x defaults; enable it so that
    // fixture properties left without a @JsonView (plain data, not access-controlled) stay
    // visible in every view and don't muddy the assertions.
    private final ObjectMapper FAIL_ON = jsonMapperBuilder()
            .enable(MapperFeature.DEFAULT_VIEW_INCLUSION)
            .enable(DeserializationFeature.FAIL_ON_UNEXPECTED_VIEW_PROPERTIES)
            .build();

    private final ObjectMapper FAIL_OFF = jsonMapperBuilder()
            .enable(MapperFeature.DEFAULT_VIEW_INCLUSION)
            .disable(DeserializationFeature.FAIL_ON_UNEXPECTED_VIEW_PROPERTIES)
            .build();

    private void expectRejected(Class<?> view, Class<?> type, String json) throws Exception {
        expectRejected(FAIL_ON.readerWithView(view).forType(type), json);
    }

    private void expectRejected(ObjectReader reader, String json) throws Exception {
        try {
            reader.readValue(json);
            fail("Expected MismatchedInputException for view-hidden property, but parse succeeded");
        } catch (MismatchedInputException e) {
            verifyException(e, "Input mismatch while deserializing");
            verifyException(e, "is not part of current active view");
        }
    }

    private <T> T readWithView(ObjectMapper mapper, Class<?> view, Class<T> type, String json)
        throws Exception
    {
        return mapper.readerWithView(view).forType(type).readValue(json);
    }

    /*
    /**********************************************************************
    /* Property-based creators: records and @JsonCreator(PROPERTIES)
    /**********************************************************************
     */

    /** {@code BeanDeserializer._deserializeUsingPropertyBased} -- creator arm (records bind
     *  every component through the canonical constructor). This is the exact #6077 repro. */
    @Nested
    class Records
    {
        record User(
                @JsonView(Public.class) String name,
                @JsonView(Admin.class) String role) {
        }

        static final String JSON = """
                {"name":"alice","role":"admin"}
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            User u = readWithView(FAIL_ON, Admin.class, User.class, JSON);
            assertEquals("alice", u.name());
            assertEquals("admin", u.role());
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            User u = readWithView(FAIL_OFF, Public.class, User.class, JSON);
            assertEquals("alice", u.name());
            assertNull(u.role());
        }
    }

    /** {@code BeanDeserializer._deserializeUsingPropertyBased} -- regular-property arm.
     *  {@code role} is a non-creator property buffered alongside the creator arg; feeding it
     *  both before and after {@code name} exercises the buffering branch in either ordering. */
    @Nested
    class PropertiesCreator
    {
        static class User {
            final String name;
            @JsonView(Admin.class)
            String role;

            @JsonCreator
            User(@JsonProperty("name") @JsonView(Public.class) String name) {
                this.name = name;
            }
        }

        static final String JSON = """
                {"name":"alice","role":"admin"}
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            User u = readWithView(FAIL_ON, Admin.class, User.class, JSON);
            assertEquals("admin", u.role);
        }

        @Test
        void hiddenRoleBeforeCreatorRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, """
                    {"role":"admin","name":"alice"}
                    """);
        }

        @Test
        void hiddenRoleAfterCreatorRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            User u = readWithView(FAIL_OFF, Public.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertNull(u.role);
        }
    }

    /** {@code BeanDeserializer._deserializeRecordForUpdate} -- the condition that was split so a
     *  view-hidden creator property fails while a (view-visible) injection-only one still skips
     *  silently. See {@link InjectionOnlyCreatorParam} for the precedence between the two. */
    @Nested
    class RecordUpdate
    {
        public record User(
                @JsonView(Public.class) String name,
                @JsonView(Admin.class) String role) {
        }

        @Test
        void adminViewOverwritesRole() throws Exception {
            User updated = FAIL_ON.readerWithView(Admin.class)
                    .forType(User.class)
                    .withValueToUpdate(new User("alice", "user"))
                    .readValue("""
                            {"role":"admin"}
                            """);
            assertEquals("admin", updated.role());
        }

        @Test
        void hiddenRoleUpdateRejectedWhenEnabled() throws Exception {
            expectRejected(FAIL_ON.readerWithView(Public.class)
                            .forType(User.class)
                            .withValueToUpdate(new User("alice", "user")),
                    """
                    {"role":"HACKED"}
                    """);
        }

        @Test
        void hiddenRoleUpdateSkippedWhenDisabled() throws Exception {
            User updated = FAIL_OFF.readerWithView(Public.class)
                    .forType(User.class)
                    .withValueToUpdate(new User("alice", "user"))
                    .readValue("""
                            {"role":"HACKED"}
                            """);
            assertEquals("user", updated.role());
        }
    }

    /*
    /**********************************************************************
    /* Builder-based property creators
    /**********************************************************************
     */

    /** {@code BuilderBasedDeserializer._deserializeUsingPropertyBased}. */
    @Nested
    class BuilderCreator
    {
        @JsonDeserialize(builder = User.Builder.class)
        static class User {
            final String name;
            final String role;

            User(String name, String role) {
                this.name = name;
                this.role = role;
            }

            @JsonPOJOBuilder(withPrefix = "")
            static class Builder {
                String name;
                String role;

                @JsonCreator
                Builder(@JsonProperty("name") @JsonView(Public.class) String name) {
                    this.name = name;
                }

                @JsonView(Public.class) Builder name(String n) { this.name = n; return this; }
                @JsonView(Admin.class)  Builder role(String r) { this.role = r; return this; }

                User build() { return new User(name, role); }
            }
        }

        static final String JSON = """
                {"name":"alice","role":"admin"}
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            User u = readWithView(FAIL_ON, Admin.class, User.class, JSON);
            assertEquals("admin", u.role);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            User u = readWithView(FAIL_OFF, Public.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertNull(u.role);
        }
    }

    /*
    /**********************************************************************
    /* Buffering paths entered via @JsonUnwrapped
    /*
    /* The @JsonUnwrapped block itself is resolved by UnwrappedPropertyHandler (out of
    /* scope, see NotCovered). What these fixtures verify is that a *co-located* ordinary
    /* property -- one bound by the surrounding buffering loop -- now honors the feature.
    /**********************************************************************
     */

    /** {@code BeanDeserializer.deserializeWithUnwrapped} (setter buffering loop). */
    @Nested
    class SetterUnwrapped
    {
        static class Flags {
            public String tier;
        }

        static class Account {
            String email;
            @JsonView(Admin.class)
            String role;
            Flags flags;

            @JsonView(Public.class) public void setEmail(String e) { this.email = e; }
            @JsonView(Admin.class)  public void setRole(String r)  { this.role = r; }
            @JsonUnwrapped          public void setFlags(Flags f)  { this.flags = f; }
        }

        static final String JSON = """
                {"email":"e","role":"admin","tier":"gold"}
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            Account a = readWithView(FAIL_ON, Admin.class, Account.class, JSON);
            assertEquals("admin", a.role);
            assertNotNull(a.flags);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Account.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            Account a = readWithView(FAIL_OFF, Public.class, Account.class, JSON);
            assertEquals("e", a.email);
            assertNull(a.role);
        }
    }

    /** {@code BuilderBasedDeserializer.deserializeWithUnwrapped} (builder buffering loop). */
    @Nested
    class BuilderUnwrapped
    {
        static class Flags {
            public String tier;
        }

        @JsonDeserialize(builder = Account.Builder.class)
        static class Account {
            final String email;
            final String role;
            final Flags flags;

            Account(String email, String role, Flags flags) {
                this.email = email;
                this.role = role;
                this.flags = flags;
            }

            @JsonPOJOBuilder(withPrefix = "")
            static class Builder {
                String email;
                String role;
                Flags flags;

                @JsonView(Public.class) Builder email(String e) { this.email = e; return this; }
                @JsonView(Admin.class)  Builder role(String r)  { this.role = r; return this; }
                @JsonUnwrapped          Builder flags(Flags f)  { this.flags = f; return this; }

                Account build() { return new Account(email, role, flags); }
            }
        }

        static final String JSON = """
                {"email":"e","role":"admin","tier":"gold"}
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            Account a = readWithView(FAIL_ON, Admin.class, Account.class, JSON);
            assertEquals("admin", a.role);
            assertNotNull(a.flags);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Account.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            Account a = readWithView(FAIL_OFF, Public.class, Account.class, JSON);
            assertEquals("e", a.email);
            assertNull(a.role);
        }
    }

    /** {@code BeanDeserializer.deserializeUsingPropertyBasedWithUnwrapped} -- regular-property
     *  arm ({@code role} is buffered while the property-based creator is being assembled). */
    @Nested
    class PropertiesCreatorUnwrapped
    {
        static class Flags {
            public String tier;
        }

        static class Account {
            final String name;
            @JsonView(Admin.class)
            String role;
            @JsonUnwrapped
            Flags flags;

            @JsonCreator
            Account(@JsonProperty("name") @JsonView(Public.class) String name) {
                this.name = name;
            }
        }

        static final String JSON = """
                {"name":"alice","role":"admin","tier":"gold"}
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            Account a = readWithView(FAIL_ON, Admin.class, Account.class, JSON);
            assertEquals("admin", a.role);
            assertNotNull(a.flags);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Account.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            Account a = readWithView(FAIL_OFF, Public.class, Account.class, JSON);
            assertEquals("alice", a.name);
            assertNull(a.role);
        }
    }

    /** {@code BeanDeserializer.deserializeUsingPropertyBasedWithUnwrapped} -- <i>creator</i> arm.
     *  {@code secret} is a hidden creator parameter (not the unwrapped block), so it flows through
     *  the creator branch of the unwrapped property-based loop rather than the buffering branch. */
    @Nested
    class UnwrappedHiddenCreatorParam
    {
        static class Flags {
            public String tier;
        }

        static class Account {
            final String name;
            final String secret;
            @JsonUnwrapped
            Flags flags;

            @JsonCreator
            Account(@JsonProperty("name") @JsonView(Public.class) String name,
                    @JsonProperty("secret") @JsonView(Admin.class) String secret) {
                this.name = name;
                this.secret = secret;
            }
        }

        static final String JSON = """
                {"name":"alice","secret":"s","tier":"gold"}
                """;

        @Test
        void adminViewBindsSecret() throws Exception {
            Account a = readWithView(FAIL_ON, Admin.class, Account.class, JSON);
            assertEquals("alice", a.name);
            assertEquals("s", a.secret);
            assertNotNull(a.flags);
        }

        @Test
        void hiddenSecretRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Account.class, JSON);
        }

        @Test
        void hiddenSecretSkippedWhenDisabled() throws Exception {
            Account a = readWithView(FAIL_OFF, Public.class, Account.class, JSON);
            assertEquals("alice", a.name);
            assertNull(a.secret);
        }
    }

    /*
    /**********************************************************************
    /* External type id: co-located regular/creator properties
    /*
    /* The EXTERNAL_PROPERTY *value* stays silent (resolved by ExternalTypeHandler, see
    /* NotCovered). These fixtures verify a *co-located* property bound by the surrounding
    /* name loop now honors the feature.
    /**********************************************************************
     */

    /** {@code BeanDeserializer._deserializeWithExternalTypeId} -- non-creator (default ctor)
     *  bean; the hidden {@code secret} is bound by the name loop, the {@code payload} value by
     *  the external-type handler. */
    @Nested
    class ExternalTypeIdSetter
    {
        static class Envelope {
            @JsonView(Public.class)
            public String label;
            @JsonView(Admin.class)
            public String secret;

            @JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
                    include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "kind")
            @JsonSubTypes({ @JsonSubTypes.Type(TextPayload.class) })
            public Payload payload;
        }

        static final String JSON = """
                {"label":"hi","secret":"s","kind":"text","payload":{"body":"b"}}
                """;

        @Test
        void adminViewBindsSecret() throws Exception {
            Envelope e = readWithView(FAIL_ON, Admin.class, Envelope.class, JSON);
            assertEquals("s", e.secret);
            assertNotNull(e.payload);
        }

        @Test
        void hiddenSecretRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Envelope.class, JSON);
        }

        @Test
        void hiddenSecretSkippedWhenDisabled() throws Exception {
            Envelope e = readWithView(FAIL_OFF, Public.class, Envelope.class, JSON);
            assertEquals("hi", e.label);
            assertNull(e.secret);
            assertNotNull(e.payload);
        }
    }

    /** {@code BeanDeserializer.deserializeUsingPropertyBasedWithExternalTypeId} -- both arms:
     *  a hidden creator parameter ({@code secret}) and a hidden regular property ({@code note}). */
    @Nested
    class ExternalTypeIdCreator
    {
        static class Envelope {
            final String label;
            final String secret;
            @JsonView(Admin.class)
            public String note;

            @JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
                    include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "kind")
            @JsonSubTypes({ @JsonSubTypes.Type(TextPayload.class) })
            public Payload payload;

            @JsonCreator
            Envelope(@JsonProperty("label") @JsonView(Public.class) String label,
                    @JsonProperty("secret") @JsonView(Admin.class) String secret) {
                this.label = label;
                this.secret = secret;
            }
        }

        static final String ALL_PROPS_JSON = """
                {"label":"hi","secret":"s","note":"n","kind":"text","payload":{"body":"b"}}
                """;

        @Test
        void adminViewBindsHiddenProps() throws Exception {
            Envelope e = readWithView(FAIL_ON, Admin.class, Envelope.class, ALL_PROPS_JSON);
            assertEquals("s", e.secret);
            assertEquals("n", e.note);
            assertNotNull(e.payload);
        }

        // Hidden creator parameter -> creator arm.
        @Test
        void hiddenCreatorParamRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Envelope.class, """
                    {"label":"hi","secret":"s","kind":"text","payload":{"body":"b"}}
                    """);
        }

        // Hidden regular property -> buffering arm.
        @Test
        void hiddenRegularPropRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Envelope.class, """
                    {"label":"hi","note":"n","kind":"text","payload":{"body":"b"}}
                    """);
        }

        @Test
        void hiddenPropsSkippedWhenDisabled() throws Exception {
            Envelope e = readWithView(FAIL_OFF, Public.class, Envelope.class, ALL_PROPS_JSON);
            assertEquals("hi", e.label);
            assertNull(e.secret);
            assertNull(e.note);
            assertNotNull(e.payload);
        }
    }

    /** {@code BuilderBasedDeserializer.deserializeWithExternalTypeId} (default-ctor builder). */
    @Nested
    class BuilderExternalTypeId
    {
        @JsonDeserialize(builder = Envelope.Builder.class)
        static class Envelope {
            final String label;
            final String secret;
            final Payload payload;

            Envelope(String label, String secret, Payload payload) {
                this.label = label;
                this.secret = secret;
                this.payload = payload;
            }

            @JsonPOJOBuilder(withPrefix = "")
            static class Builder {
                String label;
                String secret;
                Payload payload;

                @JsonView(Public.class) Builder label(String l) { this.label = l; return this; }
                @JsonView(Admin.class)  Builder secret(String s) { this.secret = s; return this; }

                @JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
                        include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "kind")
                @JsonSubTypes({ @JsonSubTypes.Type(TextPayload.class) })
                Builder payload(Payload p) { this.payload = p; return this; }

                Envelope build() { return new Envelope(label, secret, payload); }
            }
        }

        static final String JSON = """
                {"label":"hi","secret":"s","kind":"text","payload":{"body":"b"}}
                """;

        @Test
        void adminViewBindsSecret() throws Exception {
            Envelope e = readWithView(FAIL_ON, Admin.class, Envelope.class, JSON);
            assertEquals("s", e.secret);
            assertNotNull(e.payload);
        }

        @Test
        void hiddenSecretRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, Envelope.class, JSON);
        }

        @Test
        void hiddenSecretSkippedWhenDisabled() throws Exception {
            Envelope e = readWithView(FAIL_OFF, Public.class, Envelope.class, JSON);
            assertEquals("hi", e.label);
            assertNull(e.secret);
            assertNotNull(e.payload);
        }
    }

    /*
    /**********************************************************************
    /* Array-shaped beans (@JsonFormat(shape = ARRAY))
    /*
    /* Positional binding, so a view-hidden property is an *element* rather than a named
    /* field; it must still be rejected when the feature is on rather than silently dropped.
    /**********************************************************************
     */

    /** {@code BeanAsArrayDeserializer.deserializeFromArray} -- non-creator positional loop. */
    @Nested
    class BeanAsArraySetter
    {
        @JsonFormat(shape = JsonFormat.Shape.ARRAY)
        @JsonPropertyOrder({ "name", "role" })
        static class User {
            @JsonView(Public.class)
            public String name;
            @JsonView(Admin.class)
            public String role;
        }

        static final String JSON = """
                ["alice","admin"]
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            User u = readWithView(FAIL_ON, Admin.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertEquals("admin", u.role);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            User u = readWithView(FAIL_OFF, Public.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertNull(u.role);
        }
    }

    /** {@code BeanAsArrayDeserializer._deserializeUsingPropertyBased} -- creator positional loop. */
    @Nested
    class BeanAsArrayCreator
    {
        @JsonFormat(shape = JsonFormat.Shape.ARRAY)
        @JsonPropertyOrder({ "name", "role" })
        static class User {
            final String name;
            final String role;

            @JsonCreator
            User(@JsonProperty("name") @JsonView(Public.class) String name,
                    @JsonProperty("role") @JsonView(Admin.class) String role) {
                this.name = name;
                this.role = role;
            }
        }

        static final String JSON = """
                ["alice","admin"]
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            User u = readWithView(FAIL_ON, Admin.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertEquals("admin", u.role);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            User u = readWithView(FAIL_OFF, Public.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertNull(u.role);
        }
    }

    /** {@code BeanAsArrayDeserializer.deserialize(JsonParser, DeserializationContext, Object)} --
     *  the "update existing value" positional loop ({@code readerForUpdating} /
     *  {@code withValueToUpdate}). This path bound every element unconditionally, so a view-hidden
     *  position overwrote its original value even under a restricting view. */
    @Nested
    class BeanAsArrayUpdate
    {
        @JsonFormat(shape = JsonFormat.Shape.ARRAY)
        @JsonPropertyOrder({ "name", "role" })
        static class User {
            @JsonView(Public.class)
            public String name;
            @JsonView(Admin.class)
            public String role;

            public User() { }
            public User(String name, String role) {
                this.name = name;
                this.role = role;
            }
        }

        static final String JSON = """
                ["mallory","admin"]
                """;

        @Test
        void adminViewOverwritesRole() throws Exception {
            User updated = FAIL_ON.readerWithView(Admin.class)
                    .forType(User.class)
                    .withValueToUpdate(new User("alice", "user"))
                    .readValue(JSON);
            assertEquals("mallory", updated.name);
            assertEquals("admin", updated.role);
        }

        @Test
        void hiddenRoleUpdateRejectedWhenEnabled() throws Exception {
            expectRejected(FAIL_ON.readerWithView(Public.class)
                            .forType(User.class)
                            .withValueToUpdate(new User("alice", "user")),
                    JSON);
        }

        @Test
        void hiddenRoleUpdateSkippedWhenDisabled() throws Exception {
            User updated = FAIL_OFF.readerWithView(Public.class)
                    .forType(User.class)
                    .withValueToUpdate(new User("alice", "user"))
                    .readValue(JSON);
            // name is visible under Public view -> updated
            assertEquals("mallory", updated.name);
            // role is Admin-only -> left untouched under the Public view
            assertEquals("user", updated.role);
        }
    }

    /** {@code BeanAsArrayBuilderDeserializer.deserializeFromArray} -- non-creator builder loop. */
    @Nested
    class BeanAsArrayBuilderSetter
    {
        @JsonDeserialize(builder = User.Builder.class)
        static class User {
            final String name;
            final String role;

            User(String name, String role) {
                this.name = name;
                this.role = role;
            }

            // ARRAY shape must be declared on the *builder*: that is the type the
            // BuilderBasedDeserializer contextualizes, so that is where the shape is read from.
            @JsonPOJOBuilder(withPrefix = "")
            @JsonFormat(shape = JsonFormat.Shape.ARRAY)
            @JsonPropertyOrder({ "name", "role" })
            static class Builder {
                String name;
                String role;

                @JsonView(Public.class) Builder name(String n) { this.name = n; return this; }
                @JsonView(Admin.class)  Builder role(String r) { this.role = r; return this; }

                User build() { return new User(name, role); }
            }
        }

        static final String JSON = """
                ["alice","admin"]
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            User u = readWithView(FAIL_ON, Admin.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertEquals("admin", u.role);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            User u = readWithView(FAIL_OFF, Public.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertNull(u.role);
        }
    }

    /** {@code BeanAsArrayBuilderDeserializer._deserializeUsingPropertyBased} -- creator builder loop. */
    @Nested
    class BeanAsArrayBuilderCreator
    {
        @JsonDeserialize(builder = User.Builder.class)
        static class User {
            final String name;
            final String role;

            User(String name, String role) {
                this.name = name;
                this.role = role;
            }

            // ARRAY shape must be declared on the *builder*: that is the type the
            // BuilderBasedDeserializer contextualizes, so that is where the shape is read from.
            @JsonPOJOBuilder(withPrefix = "")
            @JsonFormat(shape = JsonFormat.Shape.ARRAY)
            @JsonPropertyOrder({ "name", "role" })
            static class Builder {
                String name;
                String role;

                @JsonCreator
                Builder(@JsonProperty("name") @JsonView(Public.class) String name) {
                    this.name = name;
                }

                @JsonView(Admin.class) Builder role(String r) { this.role = r; return this; }

                User build() { return new User(name, role); }
            }
        }

        static final String JSON = """
                ["alice","admin"]
                """;

        @Test
        void adminViewBindsRole() throws Exception {
            User u = readWithView(FAIL_ON, Admin.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertEquals("admin", u.role);
        }

        @Test
        void hiddenRoleRejectedWhenEnabled() throws Exception {
            expectRejected(Public.class, User.class, JSON);
        }

        @Test
        void hiddenRoleSkippedWhenDisabled() throws Exception {
            User u = readWithView(FAIL_OFF, Public.class, User.class, JSON);
            assertEquals("alice", u.name);
            assertNull(u.role);
        }
    }

    /**
     * Pins the precedence between the two conditions that #6077 split apart in
     * {@code BeanDeserializer._deserializeRecordForUpdate}: it used to be a single fused
     * {@code (view-hidden || injection-only)} test that always skipped silently. The view check
     * now runs first, so a view-<i>visible</i> injection-only component still skips its input
     * silently, while one that is <i>also</i> view-hidden is reported when the feature is on.
     */
    @Nested
    class InjectionOnlyCreatorParam
    {
        public record VisibleInject(
                @JsonView(Public.class) String name,
                @JacksonInject(value = "role", useInput = OptBoolean.FALSE)
                @JsonView(Public.class) String role) {
        }

        public record HiddenInject(
                @JsonView(Public.class) String name,
                @JacksonInject(value = "role", useInput = OptBoolean.FALSE)
                @JsonView(Admin.class) String role) {
        }

        final InjectableValues INJECTED = new InjectableValues.Std().addValue("role", "injected");

        static final String JSON = """
                {"role":"HACKED"}
                """;

        // Injection-only but visible in the active view: input is discarded (never an error),
        // even with the feature on.
        @Test
        void visibleInjectionOnlySkipsSilentlyWhenEnabled() throws Exception {
            VisibleInject updated = FAIL_ON.readerWithView(Public.class)
                    .forType(VisibleInject.class)
                    .with(INJECTED)
                    .withValueToUpdate(new VisibleInject("alice", "user"))
                    .readValue(JSON);
            assertEquals("alice", updated.name());
            assertEquals("injected", updated.role());
        }

        // Injection-only AND hidden by the active view: the view check wins and reports.
        @Test
        void hiddenInjectionOnlyRejectedWhenEnabled() throws Exception {
            expectRejected(FAIL_ON.readerWithView(Public.class)
                            .forType(HiddenInject.class)
                            .with(INJECTED)
                            .withValueToUpdate(new HiddenInject("alice", "user")),
                    JSON);
        }

        @Test
        void hiddenInjectionOnlySkippedWhenDisabled() throws Exception {
            HiddenInject updated = FAIL_OFF.readerWithView(Public.class)
                    .forType(HiddenInject.class)
                    .with(INJECTED)
                    .withValueToUpdate(new HiddenInject("alice", "user"))
                    .readValue(JSON);
            assertEquals("alice", updated.name());
            assertEquals("injected", updated.role());
        }
    }

    /*
    /**********************************************************************
    /* Known limitations: paths NOT covered by the fix
    /*
    /* These pin the current behavior (silent skip even when the feature is enabled) so
    /* that a later fix of the handler-class paths shows up as an intentional change here.
    /**********************************************************************
     */

    @Nested
    class NotCovered
    {
        // (a) External type id VALUE itself: resolved by ExternalTypeHandler, never reaches the
        // name-loop view check.
        static class Envelope {
            @JsonView(Public.class)
            public String label;

            @JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
                    include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "kind")
            @JsonSubTypes({ @JsonSubTypes.Type(TextPayload.class) })
            @JsonView(Admin.class)
            public Payload payload;
        }

        static final String ENVELOPE_JSON = """
                {"label":"hi","kind":"text","payload":{"body":"x"}}
                """;

        @Test
        void externalTypeIdValueStillSilentlySkippedWhenEnabled() throws Exception {
            // Admin-only "payload" is hidden under Public view; feature is ON, yet it is
            // dropped rather than rejected -- this path does not (yet) honor the feature.
            Envelope e = readWithView(FAIL_ON, Public.class, Envelope.class, ENVELOPE_JSON);
            assertEquals("hi", e.label);
            assertNull(e.payload);
        }

        // (b) @JsonUnwrapped creator parameter: resolved by UnwrappedPropertyHandler.
        static class Address {
            @JsonView(Admin.class)
            public String street;
        }

        static class Person {
            final String name;
            final Address address;

            @JsonCreator
            Person(@JsonProperty("name") @JsonView(Public.class) String name,
                    @JsonView(Admin.class) @JsonUnwrapped Address address) {
                this.name = name;
                this.address = address;
            }
        }

        static final String PERSON_JSON = """
                {"name":"alice","street":"1 Main"}
                """;

        @Test
        void unwrappedCreatorParamStillSilentlySkippedWhenEnabled() throws Exception {
            Person p = readWithView(FAIL_ON, Public.class, Person.class, PERSON_JSON);
            assertEquals("alice", p.name);
            // Address is admin-only; under Public the whole unwrapped parameter is left
            // unbound (null) even with the feature ON, rather than rejected.
            assertNull(p.address);
        }
    }
}