OperatorOnlyGuardTest.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.serialization;

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

import java.io.IOException;

import com.fasterxml.jackson.annotation.JsonAlias;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import org.apache.tika.config.OperatorOnly;
import org.apache.tika.config.ParseContextConfig;
import org.apache.tika.parser.ParseContext;

/**
 * Per-request JSON may not name an {@link OperatorOnly} property; operator JSON may. The
 * refusal is by JSON key before any setter runs, through aliases, into nested objects, and
 * survives a subclass override that drops the annotation.
 */
public class OperatorOnlyGuardTest {

    public static class Inner {
        private String path = "default-inner";
        private int depth = 1;

        public String getPath() {
            return path;
        }

        @OperatorOnly
        public void setPath(String path) {
            this.path = path;
        }

        public int getDepth() {
            return depth;
        }

        public void setDepth(int depth) {
            this.depth = depth;
        }
    }

    public static class Config {
        static volatile boolean executableSetterRan;

        private String executable = "/usr/bin/default";
        private String language = "eng";
        private Inner inner = new Inner();

        public String getExecutable() {
            return executable;
        }

        @OperatorOnly
        @JsonAlias("binary")
        public void setExecutable(String executable) {
            executableSetterRan = true;
            this.executable = executable;
        }

        public String getLanguage() {
            return language;
        }

        public void setLanguage(String language) {
            this.language = language;
        }

        public Inner getInner() {
            return inner;
        }

        public void setInner(Inner inner) {
            this.inner = inner;
        }
    }

    /** An override without the annotation. */
    public static class SubConfig extends Config {
        @Override
        public void setExecutable(String executable) {
            super.setExecutable(executable);
        }
    }

    private static final String KEY = "my-parser";

    private static <T> T resolve(String json, boolean trusted, Class<T> clazz, T defaults)
            throws Exception {
        ParseContext context = new ParseContext();
        context.setJsonConfig(KEY, json, trusted);
        return ParseContextConfig.getConfig(context, KEY, clazz, defaults);
    }

    @ParameterizedTest
    @ValueSource(strings = {"{\"executable\": \"/tmp/evil\"}", "{\"executable\": null}",
            "{\"binary\": \"/tmp/evil\"}", "{\"inner\": {\"depth\": 3, \"path\": \"/tmp/evil\"}}"})
    public void testPerRequestIsRefusedBeforeAnySetterRuns(String json) {
        Config.executableSetterRan = false;
        IOException e = assertThrows(IOException.class, () -> resolve(json, false, Config.class, new Config()));
        assertTrue(e.getMessage().contains("at runtime: per-request config for '" + KEY + "'"), e.getMessage());
        assertFalse(Config.executableSetterRan);
    }

    @Test
    public void testUnrelatedAndNestedPlainFieldsMerge() throws Exception {
        Config merged = resolve("{\"language\": \"fra\", \"inner\": {\"depth\": 3}}", false,
                Config.class, new Config());
        assertEquals("fra", merged.getLanguage());
        assertEquals(3, merged.getInner().getDepth());
        assertEquals("/usr/bin/default", merged.getExecutable());
    }

    @Test
    public void testOperatorConfigMaySetIt() throws Exception {
        Config merged = resolve("{\"executable\": \"/opt/bin/x\", \"inner\": {\"path\": \"p\"}}", true,
                Config.class, new Config());
        assertEquals("/opt/bin/x", merged.getExecutable());
        assertEquals("p", merged.getInner().getPath());
    }

    @Test
    public void testOverrideWithoutAnnotationStaysLocked() {
        assertThrows(IOException.class,
                () -> resolve("{\"executable\": \"/tmp/evil\"}", false, SubConfig.class, new SubConfig()));
    }

    @Test
    public void testUnknownKeyIsLeftToJackson() {
        IOException e = assertThrows(IOException.class, () -> resolve("{\"nope\": 1}", false, Config.class, new Config()));
        assertFalse(e.getMessage().contains("at runtime"), e.getMessage());
    }
}