JSONLDParserCustomTest.java

/*******************************************************************************
 * Copyright (c) 2018 Eclipse RDF4J contributors.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *******************************************************************************/
package org.eclipse.rdf4j.rio.jsonld;

import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
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.File;
import java.io.StringReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

import org.apache.commons.io.FileUtils;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.Literal;
import org.eclipse.rdf4j.model.Model;
import org.eclipse.rdf4j.model.Resource;
import org.eclipse.rdf4j.model.Value;
import org.eclipse.rdf4j.model.impl.LinkedHashModel;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.model.vocabulary.FOAF;
import org.eclipse.rdf4j.model.vocabulary.XSD;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.eclipse.rdf4j.rio.RDFParseException;
import org.eclipse.rdf4j.rio.RDFParser;
import org.eclipse.rdf4j.rio.Rio;
import org.eclipse.rdf4j.rio.helpers.ContextStatementCollector;
import org.eclipse.rdf4j.rio.helpers.ParseErrorCollector;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;

import jakarta.json.spi.JsonProvider;
import no.hasmac.jsonld.JsonLdError;
import no.hasmac.jsonld.document.Document;
import no.hasmac.jsonld.document.JsonDocument;
import no.hasmac.jsonld.loader.DocumentLoader;
import no.hasmac.jsonld.loader.DocumentLoaderOptions;
import no.hasmac.jsonld.loader.SchemeRouter;

/**
 * Custom (non-manifest) tests for JSON-LD parser.
 *
 * @author Peter Ansell
 */
public class JSONLDParserCustomTest {

	/**
	 * Backslash escaped "h" in "http"
	 */
	private static final String BACKSLASH_ESCAPED_TEST_STRING = "[{\"@id\": \"\\http://example.com/Subj1\",\"http://example.com/prop1\": [{\"@id\": \"http://example.com/Obj1\"}]}]";

	/**
	 * Java/C++ style comments
	 */
	private static final String COMMENTS_TEST_STRING = "[{/*This is a non-standard java/c++ style comment\n*/\"@id\": \"http://example.com/Subj1\",\"http://example.com/prop1\": [{\"@id\": \"http://example.com/Obj1\"}]}]";

	/**
	 * Tests for NaN
	 */
	private static final String NON_NUMERIC_NUMBERS_TEST_STRING = "[{\"@id\": \"http://example.com/Subj1\",\"http://example.com/prop1\": NaN}]";

	/**
	 * Tests for numeric leading zeroes
	 */
	private static final String NUMERIC_LEADING_ZEROES_TEST_STRING = "[{\"@id\": \"http://example.com/Subj1\",\"http://example.com/prop1\": 000042}]";

	/**
	 * Tests for single-quotes
	 */
	private static final String SINGLE_QUOTES_TEST_STRING = "[{\'@id\': \"http://example.com/Subj1\",\'http://example.com/prop1\': 42}]";

	/**
	 * Tests for unquoted control char
	 */
	private static final String UNQUOTED_CONTROL_CHARS_TEST_STRING = "[{\"@id\": \"http://example.com/Subj1\",\"http://example.com/prop1\": \"42\u0009\"}]";

	/**
	 * Tests for unquoted field names
	 */
	private static final String UNQUOTED_FIELD_NAMES_TEST_STRING = "[{@id: \"http://example.com/Subj1\",\"http://example.com/prop1\": 42}]";

	/**
	 * YAML style comments
	 */
	private static final String YAML_COMMENTS_TEST_STRING = "[\n{#This is a non-standard yaml style comment/*\n\"@id\": \"http://example.com/Subj1\",\"http://example.com/prop1\": [{\"@id\": \"http://example.com/Obj1\"}]}]";

	/**
	 * Trailing comma
	 */
	private static final String TRAILING_COMMA_TEST_STRING = "[{\"@id\": \"http://example.com/Subj1\",\"http://example.com/prop1\": [{\"@id\": \"http://example.com/Obj1\"},]}]";

	/**
	 * Strict duplicate detection
	 */
	private static final String STRICT_DUPLICATE_DETECTION_TEST_STRING = "[{\"@context\": {}, \"@context\": {}, \"@id\": \"http://example.com/Subj1\",\"http://example.com/prop1\": [{\"@id\": \"http://example.com/Obj1\"}]}]";

	/**
	 * Used for custom document loader
	 */
	private static final String LOADER_CONTEXT = "{ \"@context\": {\"prop\": \"http://example.com/prop1\"} }";
	private static final String LOADER_JSONLD = "{ \"@context\": \"http://example.com/context.jsonld\", \"@id\": \"http://example.com/Subj1\", \"prop\": \"Property\" }";

	private RDFParser parser;

	private ParseErrorCollector errors;

	private Model model;

	private final SimpleValueFactory F = SimpleValueFactory.getInstance();

	private final IRI testSubjectIRI = F.createIRI("http://example.com/Subj1");
	private final IRI testPredicate = F.createIRI("http://example.com/prop1");
	private final IRI testObjectIRI = F.createIRI("http://example.com/Obj1");

	private final Literal testObjectLiteralNotANumber = F.createLiteral("NaN", XSD.DOUBLE);
	private final Literal testObjectLiteralNumber = F.createLiteral("42", XSD.INTEGER);
	private final Literal testObjectLiteralUnquotedControlChar = F.createLiteral("42\u0009", XSD.STRING);

	@BeforeEach
	public void setUp() {
		parser = Rio.createParser(RDFFormat.JSONLD);
		errors = new ParseErrorCollector();
		model = new LinkedHashModel();
		parser.setParseErrorListener(errors);
		parser.setRDFHandler(new ContextStatementCollector(model, F));
	}

	private void verifyParseResults(Resource nextSubject, IRI nextPredicate, Value nextObject) {
		assertEquals(0, errors.getWarnings().size());
		assertEquals(0, errors.getErrors().size());
		assertEquals(0, errors.getFatalErrors().size());

		assertEquals(1, model.size());
		assertTrue(model.contains(nextSubject, nextPredicate, nextObject),
				"model was not as expected: " + model.toString());
	}

	@Test
	public void testSupportedSettings() {
		assertEquals(19, parser.getSupportedSettings().size());
	}

	@Test
	public void testAllowBackslashEscapingAnyCharacterDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(BACKSLASH_ESCAPED_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowBackslashEscapingAnyCharacterDisabled() {
		assertThatThrownBy(() -> parser.parse(new StringReader(BACKSLASH_ESCAPED_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");

	}

	@Test
	public void testAllowCommentsDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(COMMENTS_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowNonNumericNumbersDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(NON_NUMERIC_NUMBERS_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowNumericLeadingZeroesDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(NUMERIC_LEADING_ZEROES_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowSingleQuotesDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(SINGLE_QUOTES_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowUnquotedControlCharactersDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(UNQUOTED_CONTROL_CHARS_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowUnquotedFieldNamesDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(UNQUOTED_FIELD_NAMES_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowYamlCommentsDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(YAML_COMMENTS_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testAllowTrailingCommaDefault() {
		assertThatThrownBy(() -> parser.parse(new StringReader(TRAILING_COMMA_TEST_STRING), ""))
				.isInstanceOf(RDFParseException.class)
				.hasMessageContaining("Could not parse JSONLD");
	}

	@Test
	public void testStrictDuplicateDetectionDefault() throws Exception {
		parser.parse(new StringReader(STRICT_DUPLICATE_DETECTION_TEST_STRING), "");
		verifyParseResults(testSubjectIRI, testPredicate, testObjectIRI);
	}

	@Test
	public void testContext() throws Exception {

		Document jsonDocument = JsonDocument.of(new StringReader(LOADER_CONTEXT));
		jsonDocument.setDocumentUrl(URI.create("http://example.com/context.jsonld"));

		parser.getParserConfig().set(JSONLDSettings.EXPAND_CONTEXT, jsonDocument);
		parser.parse(new StringReader(LOADER_JSONLD), "");
		assertTrue(model.predicates().contains(testPredicate));
	}

	@Test
	public void testLocalFileSecurity() throws Exception {

		String contextUri = JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/localFileContext/context.jsonld")
				.toString();

		String jsonld = FileUtils
				.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
						.getResource("testcases/jsonld/localFileContext/data.jsonld")
						.getFile()), StandardCharsets.UTF_8)
				.replace("file:./context.jsonld", contextUri);

		// expect exception
		RDFParseException rdfParseException = Assertions.assertThrowsExactly(RDFParseException.class, () -> {
			parser.parse(new StringReader(jsonld), "");
		});

		Assertions.assertEquals("Could not load document from " + contextUri
				+ " because it is not whitelisted. See: JSONLDSettings.WHITELIST and JSONLDSettings.SECURE_MODE which can also be set as system properties.",
				rdfParseException.getMessage());
	}

	@Test
	public void testLocalFileSecurityWhiteList() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/localFileContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);
		String contextUri = JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/localFileContext/context.jsonld")
				.toString();
		jsonld = jsonld.replace("file:./context.jsonld", contextUri);

		parser.getParserConfig().set(JSONLDSettings.WHITELIST, Set.of(contextUri));

		parser.parse(new StringReader(jsonld), "");
		assertTrue(model.objects().contains(FOAF.PERSON));
	}

	@Test
	public void testLocalFileSecurityDisableSecurity() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/localFileContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);
		jsonld = jsonld.replace("file:./context.jsonld",
				JSONLDParserCustomTest.class.getClassLoader()
						.getResource("testcases/jsonld/localFileContext/context.jsonld")
						.toString());

		parser.getParserConfig().set(JSONLDSettings.SECURE_MODE, false);

		parser.parse(new StringReader(jsonld), "");
		assertTrue(model.objects().contains(FOAF.PERSON));
	}

	@Test
	public void testLocalFileSecurityCustomDocumentLoader() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/localFileContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);
		jsonld = jsonld.replace("file:./context.jsonld",
				JSONLDParserCustomTest.class.getClassLoader()
						.getResource("testcases/jsonld/localFileContext/context.jsonld")
						.toString());

		AtomicBoolean called = new AtomicBoolean(false);
		parser.getParserConfig().set(JSONLDSettings.DOCUMENT_LOADER, (url, options) -> {
			called.set(true);
			return new CachingDocumentLoader(false, Set.of(), true).loadDocument(url, options);
		});

		parser.parse(new StringReader(jsonld), "");
		assertTrue(model.objects().contains(FOAF.PERSON));
		assertTrue(called.get());
	}

	@Test
	public void testLocalFileSecurityCustomDocumentLoader2() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/localFileContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);
		jsonld = jsonld.replace("file:./context.jsonld",
				JSONLDParserCustomTest.class.getClassLoader()
						.getResource("testcases/jsonld/localFileContext/context.jsonld")
						.toString());

		AtomicBoolean called = new AtomicBoolean(false);
		parser.getParserConfig().set(JSONLDSettings.DOCUMENT_LOADER, (url, options) -> {
			called.set(true);
			return new CachingDocumentLoader(false, Set.of(), true).loadDocument(url, options);
		});

		parser.getParserConfig().set(JSONLDSettings.SECURE_MODE, false);

		parser.parse(new StringReader(jsonld), "");
		assertTrue(model.objects().contains(FOAF.PERSON));
		assertTrue(called.get());
	}

	@Test
	public void testLocalFileSecurityDisableSecuritySystemProperty() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/localFileContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);
		jsonld = jsonld.replace("file:./context.jsonld",
				JSONLDParserCustomTest.class.getClassLoader()
						.getResource("testcases/jsonld/localFileContext/context.jsonld")
						.toString());

		try {
			System.setProperty(JSONLDSettings.SECURE_MODE.getKey(), "false");
			parser.parse(new StringReader(jsonld), "");
			assertTrue(model.objects().contains(FOAF.PERSON));
		} finally {
			System.clearProperty(JSONLDSettings.SECURE_MODE.getKey());
		}

	}

	@RepeatedTest(100)
	public void testRemoteContextDefaultWhitelist() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/remoteContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);

		parser.parse(new StringReader(jsonld), "");
		assertEquals(59, model.size());
	}

	@RepeatedTest(100)
	public void testRemoteContext() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/remoteContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);

		parser.getParserConfig().set(JSONLDSettings.WHITELIST, Set.of("https://schema.org"));
		parser.parse(new StringReader(jsonld), "");
		assertEquals(59, model.size());
	}

	@Test
	public void testRemoteContextSystemProperty() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/remoteContext/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);

		try {
			System.setProperty(JSONLDSettings.WHITELIST.getKey(),
					"[\"https://schema.org\",\"https://example.org/context.jsonld\"]");
			parser.parse(new StringReader(jsonld), "");
			assertEquals(59, model.size());
		} finally {
			System.clearProperty(JSONLDSettings.WHITELIST.getKey());
		}

	}

	@Test
	public void testRemoteContextException() throws Exception {
		String jsonld = FileUtils.readFileToString(new File(JSONLDParserCustomTest.class.getClassLoader()
				.getResource("testcases/jsonld/remoteContextException/data.jsonld")
				.getFile()), StandardCharsets.UTF_8);

		parser.getParserConfig().set(JSONLDSettings.WHITELIST, Set.of("https://example.org/context.jsonld"));
		RDFParseException rdfParseException = Assertions.assertThrowsExactly(RDFParseException.class, () -> {
			parser.parse(new StringReader(jsonld), "");
		});

		assertEquals("Could not load document from https://example.org/context.jsonld", rdfParseException.getMessage());
	}

	@Test
	public void testSPI() {
		ServiceLoader<JsonProvider> load = ServiceLoader.load(JsonProvider.class);
		List<String> collect = load.stream()
				.map(ServiceLoader.Provider::get)
				.map(t -> t.getClass().getName())
				.collect(Collectors.toList());
		assertFalse(collect.isEmpty());
		assertEquals("org.glassfish.json.JsonProviderImpl", collect.stream().findFirst().orElse(""));
	}

}