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;
}
}