PresetRegistryTest.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.config.loader;

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

import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import org.apache.tika.config.ExceptionReporting;
import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.parser.ParseContext;

public class PresetRegistryTest {

    @TempDir
    Path tmp;

    private TikaJsonConfig config(String json) throws Exception {
        Path p = tmp.resolve("config-" + json.hashCode() + ".json");
        Files.writeString(p, json);
        return TikaJsonConfig.load(p);
    }

    private PresetRegistry load(String json) throws Exception {
        return PresetRegistry.load(config(json), getClass().getClassLoader());
    }

    @Test
    public void testCatalogPresetInertUntilActivated() throws Exception {
        // src/test/resources/META-INF/tika/preset-catalog.properties contributes builtin-sample,
        // but a catalog jar must never activate itself
        PresetRegistry registry = PresetRegistry.load(null, getClass().getClassLoader());
        assertTrue(registry.names().isEmpty());
        assertNull(registry.parseContextJson("builtin-sample"));

        assertNull(load("{}").parseContextJson("builtin-sample"));
    }

    @Test
    public void testTrueActivatesCatalogDefinition() throws Exception {
        PresetRegistry registry = load("""
                {"presets": {"builtin-sample": true}}
                """);
        JsonNode content =
                new ObjectMapper().readTree(registry.parseContextJson("builtin-sample"));
        assertEquals("TEXT", content.get("basic-content-handler-factory").get("type").asText());
    }

    @Test
    public void testTrueWithoutCatalogDefinitionFailsStartup() {
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": {"no-such-catalog-entry": true}}
                """));
    }

    @Test
    public void testConfigDefinesPreset() throws Exception {
        PresetRegistry registry = load("""
                {"presets": {"my-preset": {"basic-content-handler-factory": {"type": "XML"}}}}
                """);
        JsonNode content = new ObjectMapper().readTree(registry.parseContextJson("my-preset"));
        assertEquals("XML", content.get("basic-content-handler-factory").get("type").asText());
    }

    @Test
    public void testConfigOverridesCatalogDefinitionWholesale() throws Exception {
        PresetRegistry registry = load("""
                {"presets": {"builtin-sample": {"embedded-limits": {"maxDepth": 2}}}}
                """);
        JsonNode content =
                new ObjectMapper().readTree(registry.parseContextJson("builtin-sample"));
        assertNull(content.get("basic-content-handler-factory"),
                "an override replaces the whole preset, it does not merge");
        assertEquals(2, content.get("embedded-limits").get("maxDepth").asInt());
    }

    @Test
    public void testFalseAndNullAreExplicitNoOps() throws Exception {
        PresetRegistry registry = load("""
                {"presets": {"builtin-sample": false, "other": null}}
                """);
        assertFalse(registry.names().contains("builtin-sample"));
        assertNull(registry.parseContextJson("builtin-sample"));
        assertNull(registry.parseContextJson("other"));
    }

    @Test
    public void testUnknownPresetIsNull() throws Exception {
        assertNull(load("{}").parseContextJson("nope"));
        assertNull(load("{}").parseContextJson(null));
    }

    @Test
    public void testInvalidNameRejected() {
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": {"bad/name": {}}}
                """));
    }

    @Test
    public void testConfigPrefixedNameRejected() {
        // tika-server gates /config endpoints on the path fragment, so such a
        // preset would be unreachable there; refuse it at definition time
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": {"config-fast": {}}}
                """));
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": {"CONFIGX": {}}}
                """));
        assertTrue(PresetRegistry.isValidName("fast-config"));
    }

    @Test
    public void testUnresolvablePresetFailsLoad() {
        // a known component with malformed content must fail at load, not first use
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": {"bad": {"basic-content-handler-factory": {"type": "NO_SUCH_TYPE"}}}}
                """));
    }

    @Test
    public void testNewParseContextIsTrustedAndFresh() throws Exception {
        // exception-reporting is wire-blocked for caller-supplied contexts; a preset is
        // operator config and must be able to bind it
        PresetRegistry registry = load("""
                {"presets": {"reporting": {"exception-reporting": {"maxLength": 512}}}}
                """);
        ParseContext first = registry.newParseContext("reporting");
        assertTrue(first.get(ExceptionReporting.class) != null);
        // fresh per call: callers mutate the result per request
        assertTrue(first != registry.newParseContext("reporting"));
        assertNull(registry.newParseContext("nope"));
        assertNull(registry.newParseContext(null));
    }

    @Test
    public void testSuppliesContentHandlerFactory() throws Exception {
        PresetRegistry registry = load("""
                {"presets": {
                  "with-chf": {"basic-content-handler-factory": {"type": "XML"}},
                  "without-chf": {"embedded-limits": {"maxDepth": 2}}}}
                """);
        assertTrue(registry.suppliesContentHandlerFactory("with-chf"));
        assertFalse(registry.suppliesContentHandlerFactory("without-chf"));
        assertFalse(registry.suppliesContentHandlerFactory("nope"));
        assertFalse(registry.suppliesContentHandlerFactory(null));
    }

    @Test
    public void testCatalogNameCollisionAcrossJarsFails() throws Exception {
        Path dirA = catalogDir("a", "colliding", "{\"embedded-limits\": {\"maxDepth\": 1}}");
        Path dirB = catalogDir("b", "colliding", "{\"embedded-limits\": {\"maxDepth\": 2}}");
        // parent is the test loader, so component classes still resolve; its own
        // catalog contributes only the distinct builtin-sample name
        try (URLClassLoader loader = new URLClassLoader(
                new URL[]{dirA.toUri().toURL(), dirB.toUri().toURL()},
                getClass().getClassLoader())) {
            assertThrows(TikaConfigException.class, () -> PresetRegistry.load(
                    config("{\"presets\": {\"colliding\": true}}"), loader));
        }
    }

    @Test
    public void testCatalogIdenticalDuplicateTolerated() throws Exception {
        // the same jar visible twice on a classpath is noise, not a conflict
        Path dirA = catalogDir("a2", "dup", "{\"embedded-limits\": {\"maxDepth\": 3}}");
        Path dirB = catalogDir("b2", "dup", "{\"embedded-limits\": {\"maxDepth\": 3}}");
        try (URLClassLoader loader = new URLClassLoader(
                new URL[]{dirA.toUri().toURL(), dirB.toUri().toURL()},
                getClass().getClassLoader())) {
            PresetRegistry registry = PresetRegistry.load(
                    config("{\"presets\": {\"dup\": true}}"), loader);
            assertTrue(registry.hasPreset("dup"));
        }
    }

    private Path catalogDir(String dirName, String presetName, String json) throws Exception {
        Path dir = tmp.resolve(dirName);
        Files.createDirectories(dir.resolve("META-INF/tika"));
        // resource path unique per dir: identical paths would shadow on the classpath
        Files.writeString(dir.resolve("META-INF/tika/preset-catalog.properties"),
                presetName + "=/presets-" + dirName + "/" + presetName + ".json\n");
        Files.createDirectories(dir.resolve("presets-" + dirName));
        Files.writeString(dir.resolve("presets-" + dirName + "/" + presetName + ".json"), json);
        return dir;
    }

    @Test
    public void testNonObjectPresetRejected() {
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": {"bad": "a string"}}
                """));
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": {"bad": 42}}
                """));
    }

    @Test
    public void testNonObjectPresetsBlockRejected() {
        assertThrows(TikaConfigException.class, () -> load("""
                {"presets": ["not", "an", "object"]}
                """));
    }
}