Rdf4jServerWorkbenchApplicationTest.java

/*******************************************************************************
 * Copyright (c) 2025 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
 *******************************************************************************/
// Some portions generated by Codex
package org.eclipse.rdf4j.tools.serverboot;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.io.StringReader;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.eclipse.rdf4j.http.client.shacl.RemoteShaclValidationException;
import org.eclipse.rdf4j.model.IRI;
import org.eclipse.rdf4j.model.ValueFactory;
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
import org.eclipse.rdf4j.model.vocabulary.RDF;
import org.eclipse.rdf4j.model.vocabulary.RDF4J;
import org.eclipse.rdf4j.model.vocabulary.RDFS;
import org.eclipse.rdf4j.query.TupleQuery;
import org.eclipse.rdf4j.query.TupleQueryResult;
import org.eclipse.rdf4j.repository.Repository;
import org.eclipse.rdf4j.repository.RepositoryConnection;
import org.eclipse.rdf4j.repository.RepositoryException;
import org.eclipse.rdf4j.repository.config.RepositoryConfig;
import org.eclipse.rdf4j.repository.config.RepositoryConfigException;
import org.eclipse.rdf4j.repository.manager.RemoteRepositoryManager;
import org.eclipse.rdf4j.repository.sail.config.SailRepositoryConfig;
import org.eclipse.rdf4j.rio.RDFFormat;
import org.eclipse.rdf4j.sail.config.SailImplConfig;
import org.eclipse.rdf4j.sail.inferencer.fc.config.SchemaCachingRDFSInferencerConfig;
import org.eclipse.rdf4j.sail.memory.config.MemoryStoreConfig;
import org.eclipse.rdf4j.sail.shacl.ShaclSailValidationException;
import org.eclipse.rdf4j.sail.shacl.config.ShaclSailConfig;
import org.eclipse.rdf4j.workbench.proxy.WorkbenchGateway;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.boot.web.servlet.ServletRegistrationBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.Logger;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.read.ListAppender;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class Rdf4jServerWorkbenchApplicationTest {

	@LocalServerPort
	private int port;

	@Autowired
	private TestRestTemplate restTemplate;

	@Autowired
	private ServletRegistrationBean<WorkbenchGateway> rdf4jWorkbenchServlet;

	private ListAppender<ILoggingEvent> loggingAppender;
	private Logger loggingFilterLogger;
	private RemoteRepositoryManager repositoryManager;
	private final List<String> createdRepositories = new ArrayList<>();
	private final ValueFactory valueFactory = SimpleValueFactory.getInstance();

	@BeforeEach
	void attachLoggingAppender() throws RepositoryException {
		loggingFilterLogger = (Logger) LoggerFactory.getLogger(ErrorLoggingFilter.class);
		loggingAppender = new ListAppender<>();
		loggingAppender.start();
		loggingFilterLogger.addAppender(loggingAppender);
		repositoryManager = RemoteRepositoryManager.getInstance(serverUrl());
	}

	@AfterEach
	void detachLoggingAppender() {
		if (loggingFilterLogger != null && loggingAppender != null) {
			loggingFilterLogger.detachAppender(loggingAppender);
			loggingAppender.stop();
		}
		cleanupRepositories();
	}

	@Test
	void serverRepositoriesEndpointResponds() {
		ResponseEntity<String> response = restTemplate.getForEntity(
				"http://localhost:" + port + "/rdf4j-server/repositories", String.class);

		assertThat(response.getStatusCode()).as("HTTP status for /rdf4j-server/repositories")
				.isEqualTo(HttpStatus.OK);
	}

	@Test
	void serverRootReturnsDummyHomePage() {
		ResponseEntity<String> response = restTemplate.getForEntity(
				"http://localhost:" + port + "/rdf4j-server/", String.class);

		assertThat(response.getStatusCode()).as("HTTP status for /rdf4j-server/")
				.isEqualTo(HttpStatus.OK);
		assertThat(response.getHeaders().getContentType()).as("Server root content type")
				.isNotNull()
				.satisfies(mediaType -> assertThat(mediaType.toString())
						.contains("text/html"));
		assertThat(response.getBody()).as("Server root HTML body")
				.contains("<title>RDF4J Server - Home</title>");
	}

	@Test
	void rootLandingPageHasLinks() {
		ResponseEntity<String> response = restTemplate.getForEntity(
				"http://localhost:" + port + "/", String.class);

		assertThat(response.getStatusCode()).as("HTTP status for /")
				.isEqualTo(HttpStatus.OK);
		assertThat(response.getHeaders().getContentType()).as("Root content type")
				.isNotNull()
				.satisfies(mediaType -> assertThat(mediaType.toString())
						.contains("text/html"));
		assertThat(response.getBody()).as("Root landing page body")
				.contains("RDF4J")
				.contains("href=\"/rdf4j-workbench/\"")
				.contains("href=\"/rdf4j-server/\"")
				.contains("href=\"https://rdf4j.org/documentation/\"")
				.contains("href=\"https://rdf4j.org/documentation/tools/server-workbench/\"")
				.contains("href=\"https://rdf4j.org/documentation/reference/rest-api/\"");
	}

	@Test
	void workbenchServletHasMultipartConfig() {
		assertThat(rdf4jWorkbenchServlet.getMultipartConfig())
				.as("Workbench servlet must be configured for multipart requests")
				.isNotNull();
	}

	@Test
	void workbenchRootReturnsHtml() {
		ResponseEntity<String> redirect = restTemplate.getForEntity(
				"http://localhost:" + port + "/rdf4j-workbench/", String.class);

		assertThat(redirect.getStatusCode()).as("Redirect status for /rdf4j-workbench/")
				.isEqualTo(HttpStatus.FOUND);
		assertThat(redirect.getHeaders().getLocation()).as("Workbench redirect location")
				.isNotNull()
				.hasToString("http://localhost:" + port + "/rdf4j-workbench/repositories");

		ResponseEntity<String> response = followRedirects(redirect.getHeaders().getLocation());

		assertThat(response.getStatusCode()).as("HTTP status for /rdf4j-workbench/repositories")
				.isEqualTo(HttpStatus.OK);
		assertThat(response.getHeaders().getContentType()).as("Workbench content type")
				.isNotNull()
				.satisfies(mediaType -> assertThat(mediaType.toString())
						.contains("application/sparql-results+xml"));
		assertThat(response.getBody()).as("Workbench XML body")
				.contains("<?xml")
				.contains("<sparql");
	}

	@Test
	void workbenchStylesheetReferenceUsesWorkbenchContext() {
		ResponseEntity<String> workbenchResponse = followRedirects(
				URI.create("http://localhost:" + port + "/rdf4j-workbench/"));

		assertThat(workbenchResponse.getBody()).as("Workbench XML references stylesheet under /rdf4j-workbench")
				.contains("href='/rdf4j-workbench/transformations/repositories.xsl'");

		ResponseEntity<String> stylesheet = restTemplate.getForEntity(
				"http://localhost:" + port + "/rdf4j-workbench/transformations/repositories.xsl", String.class);

		assertThat(stylesheet.getStatusCode()).as("HTTP status for repositories.xsl")
				.isEqualTo(HttpStatus.OK);
		assertThat(stylesheet.getHeaders().getContentType()).as("XSL content type")
				.isNotNull()
				.satisfies(mediaType -> assertThat(mediaType.toString())
						.contains("application/xml"));
		assertThat(stylesheet.getBody()).as("repositories.xsl body")
				.contains("<xsl:stylesheet");
	}

	@Test
	void workbenchCssServedWithoutServerError() {
		ResponseEntity<String> css = restTemplate.getForEntity(
				"http://localhost:" + port + "/rdf4j-workbench/styles/default/screen.css", String.class);

		assertThat(css.getStatusCode()).as("HTTP status for screen.css")
				.isEqualTo(HttpStatus.OK);
		assertThat(css.getHeaders().getContentType()).as("CSS content type")
				.isNotNull()
				.satisfies(mediaType -> assertThat(mediaType.toString())
						.contains("text/css"));
		assertThat(css.getBody()).as("screen.css body")
				.contains("@import url(../w3-html40-recommended.css);");
	}

	@Test
	void workbenchRootRedirectsToRepositories() {
		ResponseEntity<String> response = restTemplate.getForEntity("http://localhost:" + port + "/rdf4j-workbench/",
				String.class);
		assertThat(response.getStatusCode().value()).isEqualTo(302);
		URI location = response.getHeaders().getLocation();
		assertThat(location).isNotNull();
		assertThat(location.getPath()).isEqualTo("/rdf4j-workbench/repositories");
	}

	@Test
	void missingResourceIsLogged() {
		ResponseEntity<String> response = restTemplate.getForEntity(
				"http://localhost:" + port + "/rdf4j-workbench/not-a-real-endpoint", String.class);

		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
		assertThat(loggingAppender.list).anySatisfy(event -> {
			assertThat(event.getLevel()).isEqualTo(Level.WARN);
			assertThat(event.getFormattedMessage()).contains("404")
					.contains("not-a-real-endpoint");
		});
	}

	@Test
	void workbenchRepositoriesPageLoads() {
		ResponseEntity<String> response = restTemplate.getForEntity(
				"http://localhost:" + port + "/rdf4j-workbench/repositories/NONE/repositories", String.class);
		assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
		assertThat(response.getBody()).isNotNull();
		assertThat(response.getBody()).contains("<sparql");
	}

	@Test
	void systemOverviewPageLoads() {
		ResponseEntity<String> response = restTemplate.getForEntity(
				"http://localhost:" + port + "/system/overview.view", String.class);
		assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
		assertThat(response.getBody()).contains("Application Information");
	}

	@Test
	void memoryRepositorySupportsDataLifecycle() throws Exception {
		String repoId = registerRepository("mem", new MemoryStoreConfig());
		withRepositoryConnection(repoId, connection -> {
			IRI subject = valueFactory.createIRI("urn:example:alice");
			IRI predicate = valueFactory.createIRI("urn:example:name");
			connection.add(subject, predicate, valueFactory.createLiteral("Alice"));

			TupleQuery query = connection.prepareTupleQuery(
					"SELECT ?name WHERE { <" + subject + "> <" + predicate + "> ?name }");
			try (TupleQueryResult result = query.evaluate()) {
				assertThat(result.hasNext()).isTrue();
				assertThat(result.next().getValue("name").stringValue()).isEqualTo("Alice");
				assertThat(result.hasNext()).isFalse();
			}
		});
	}

	@Test
	void rdfsRepositoryProvidesSubclassInference() throws Exception {
		String repoId = registerRepository("rdfs", new SchemaCachingRDFSInferencerConfig(new MemoryStoreConfig()));
		withRepositoryConnection(repoId, connection -> {
			IRI child = valueFactory.createIRI("urn:example:Child");
			IRI parent = valueFactory.createIRI("urn:example:Parent");
			IRI instance = valueFactory.createIRI("urn:example:bob");

			connection.add(child, RDFS.SUBCLASSOF, parent);
			connection.add(instance, RDF.TYPE, child);

			assertThat(connection.hasStatement(instance, RDF.TYPE, parent, true))
					.as("RDFS inferencer exposes subclass types")
					.isTrue();
		});
	}

	@Test
	void shaclRepositoryRejectsInvalidData() throws Exception {
		String repoId = registerRepository("shacl", new ShaclSailConfig(new MemoryStoreConfig()));
		withRepositoryConnection(repoId, connection -> {
			String shapes = String.join("\n",
					"@prefix sh: <http://www.w3.org/ns/shacl#> .",
					"@prefix ex: <urn:example:> .",
					"ex:PersonShape a sh:NodeShape ;",
					"  sh:targetClass ex:Person ;",
					"  sh:property [",
					"    sh:path ex:name ;",
					"    sh:minCount 1",
					"  ] .");
			connection.add(new StringReader(shapes), "", RDFFormat.TURTLE, RDF4J.SHACL_SHAPE_GRAPH);

			String invalidInstance = String.join("\n",
					"@prefix ex: <urn:example:> .",
					"ex:InvalidPerson a ex:Person .");

			assertThatThrownBy(() -> connection.add(new StringReader(invalidInstance), "", RDFFormat.TURTLE))
					.isInstanceOf(RepositoryException.class)
					.satisfies(ex -> assertThat(hasRootCause(ex, ShaclSailValidationException.class)
							|| hasRootCause(ex, RemoteShaclValidationException.class))
							.as("SHACL validation exception propagated to caller")
							.isTrue());

			String validInstance = String.join("\n",
					"@prefix ex: <urn:example:> .",
					"ex:ValidPerson a ex:Person ;",
					"  ex:name \"Example\" .");
			connection.add(new StringReader(validInstance), "", RDFFormat.TURTLE);
		});
	}

	private void cleanupRepositories() {
		if (repositoryManager == null) {
			return;
		}
		for (String repoId : createdRepositories) {
			try {
				repositoryManager.removeRepository(repoId);
			} catch (RepositoryException ignored) {
				// best-effort cleanup
			}
		}
		createdRepositories.clear();
		repositoryManager.shutDown();
		repositoryManager = null;
	}

	private String registerRepository(String prefix, SailImplConfig sailImplConfig)
			throws RepositoryException, RepositoryConfigException {
		String repoId = prefix + "-" + UUID.randomUUID();
		RepositoryConfig config = new RepositoryConfig(repoId, new SailRepositoryConfig(sailImplConfig));
		repositoryManager.addRepositoryConfig(config);
		createdRepositories.add(repoId);
		return repoId;
	}

	private void withRepositoryConnection(String repoId, ConnectionConsumer consumer) throws Exception {
		Repository repository = repositoryManager.getRepository(repoId);
		repository.init();
		try (RepositoryConnection connection = repository.getConnection()) {
			consumer.accept(connection);
		} finally {
			repository.shutDown();
		}
	}

	@FunctionalInterface
	private interface ConnectionConsumer {
		void accept(RepositoryConnection connection) throws Exception;
	}

	private String serverUrl() {
		return "http://localhost:" + port + "/rdf4j-server";
	}

	private ResponseEntity<String> followRedirects(URI initialLocation) {
		assertThat(initialLocation).as("Initial redirect location").isNotNull();

		URI next = ensureAbsolute(initialLocation);
		ResponseEntity<String> current = restTemplate.getForEntity(next, String.class);
		int redirectAttempts = 0;
		while (current.getStatusCode().is3xxRedirection() && redirectAttempts < 5) {
			URI target = current.getHeaders().getLocation();
			assertThat(target).as("Redirect hop " + redirectAttempts).isNotNull();
			next = ensureAbsolute(target);
			current = restTemplate.getForEntity(next, String.class);
			redirectAttempts++;
		}
		return current;
	}

	private URI ensureAbsolute(URI uri) {
		if (uri.isAbsolute()) {
			return uri;
		}
		return URI.create("http://localhost:" + port).resolve(uri);
	}

	private boolean hasRootCause(Throwable throwable, Class<? extends Throwable> type) {
		Throwable cursor = throwable;
		while (cursor != null) {
			if (type.isInstance(cursor)) {
				return true;
			}
			Throwable next = cursor.getCause();
			if (next == null || next == cursor) {
				break;
			}
			cursor = next;
		}
		return false;
	}

}