diff --git a/.github/workflows/http-tests.yml b/.github/workflows/http-tests.yml index 22e9740bd8..adac057374 100644 --- a/.github/workflows/http-tests.yml +++ b/.github/workflows/http-tests.yml @@ -8,11 +8,16 @@ jobs: runs-on: ubuntu-latest env: ASF_ARCHIVE: https://archive.apache.org/dist/ - JENA_VERSION: 4.7.0 + JENA_VERSION: 6.1.0 BASE_URI: https://localhost:4443/ steps: - name: Install Linux packages run: sudo apt-get update && sudo apt-get install -qq raptor2-utils && sudo apt-get install curl + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' - name: Download Jena run: curl -sS --fail "${{ env.ASF_ARCHIVE }}jena/binaries/apache-jena-${{ env.JENA_VERSION }}.tar.gz" -o "${{ runner.temp }}/jena.tar.gz" - name: Unpack Jena @@ -49,6 +54,12 @@ jobs: run: ./run.sh "$PWD/ssl/owner/cert.pem" "${{ secrets.HTTP_TEST_OWNER_CERT_PASSWORD }}" "$PWD/ssl/secretary/cert.pem" "${{ secrets.HTTP_TEST_SECRETARY_CERT_PASSWORD }}" shell: bash working-directory: http-tests + - name: Dump container logs on failure + if: failure() + run: docker compose --env-file ./http-tests/.env logs --no-color + - name: Dump Tomcat logs from linkeddatahub container on failure + if: failure() + run: docker compose --env-file ./http-tests/.env exec -T linkeddatahub sh -c 'for f in /usr/local/tomcat/logs/*; do echo "=== $f ==="; cat "$f"; done' || true - name: Stop Docker containers and remove volumes run: docker compose --env-file ./http-tests/.env down -v - name: Remove Docker containers diff --git a/CHANGELOG.md b/CHANGELOG.md index a829e1c8ef..f15876cfe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## [5.5.0] - 2026-06-07 +### Added +- GraphMode 3D canvas Fullscreen toggle (CSS maximize, Esc exits) +- Esc closes topmost modal +- HTTP test for orphan bnode object skolemization + +### Changed +- Jena upgraded to 6.1.0 (#309) +- Modal- and row-form metadata fetches converted to async load/set pairs; row-form chain extended with property-metadata, constraints, object-metadata; `sd:endpoint()` carried in context (#310) +- `bs2:Form` `$required` lifted to a predicate at `rdf:RDF` level +- CSR-only helpers moved out of `layout.xsl`; `bs2:FormControl` boolean overrides relocated +- Admin XSLT overrides (`bs2:Row`, `bs2:Create`, `bs2:FormControl`, `bs2:NavBarNavList`) and ACL/cert vocab templates moved from the SSR-only `admin/` chain into shared `document.xsl`/`resource.xsl`/`layout.xsl` and new `imports/{acl,cert}.xsl`; gated by `admin.`-subdomain on `lapp:origin()` +- `lapp:Application` form restrictions scoped to the app-settings flow +- `rdf:type` editable on `ldh:View` instance forms +- GraphMode canvas persisted across view re-renders via a `{container-id}-graph-host`; WebGL context and force-simulation state survive search/filter re-runs; dangling `@rdf:nodeID` and anonymous nested `rdf:Description` rendered as bnodes; click handlers skip bnodes +- View mode preserved across re-runs of the same search container +- Container result count short-circuits COUNT when the result set fits one page +- Removed bash trace debug from entrypoint +- Modal-form per-flow `render-fn` stamping unified via `ldh:constructor-form-response` / `ldh:edit-form-response` (parallel to `ldh:settings-form-response`); new `ldh:render-constructor-form#2` routes Container/Item creation violation re-render through `mode="bs2:Form"`, leaving `mode="ldh:DocumentForm"` for the edit flow + +### Fixed +- Drop just-added block on empty-graph submit +- `btn-remove-resource` removes outermost duplicate `.block` wrapper +- Relative `document('translations.rdf')` calls in `imports/{nfo,sioc,sp}.xsl`, `admin/layout.xsl`, `document.xsl` 404'd against the SEF root under SaxonJS 3; switched to absolute `resolve-uri(..., lapp:origin())` +- Admin dropdowns, form-control defaults, and navbar reverted to end-user variants after CSR navigation (overrides only lived in the SSR `admin/` chain) +- Admin `bs2:Row` `foaf:Person`/`foaf:Group` lookup failed under SaxonJS XHR because `ac:document-uri` leaves slash-vocab term URIs intact; override now fetches the namespace doc +- `Skolemizer` covers blank nodes in object position; orphan bnode references (e.g. ` rdf:_1 [ ]`) rewritten to skolem URIs +- Container/Item modal violation re-render preserves co-shipped peer Descriptions (default `ldh:ChildrenView` content block was lost) +- EDIT and violation re-render were missing the SPIN-constructor merge that CREATE performs; `bs2:FormControl`'s SHACL branch silently dropped SPIN-defined property templates for classes with both (e.g. `skos:Concept`); merge extracted into shared `ldh:build-merged-constructor` and wired into both flows via the `constructor` tunnel + ## [5.4.0] - 2026-06-04 ### Added - Multi-tab document navigation (`Document tabs`) with per-pane modal scoping and cached tab switching (#294, #302) diff --git a/Dockerfile b/Dockerfile index 3d8066e6e8..a5fdd51e49 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM maven:3.8.4-openjdk-17 AS maven +FROM maven:3.9-eclipse-temurin-21 AS maven # download and extract Jena -ARG JENA_VERSION=4.7.0 +ARG JENA_VERSION=6.1.0 ARG JENA_TAR_URL="https://archive.apache.org/dist/jena/binaries/apache-jena-${JENA_VERSION}.tar.gz" @@ -22,7 +22,7 @@ RUN mvn -Pstandalone clean install # ============================== -FROM atomgraph/letsencrypt-tomcat:10.1.46 +FROM atomgraph/letsencrypt-tomcat:10.1.52 LABEL maintainer="martynas@atomgraph.com" diff --git a/docker-compose.yml b/docker-compose.yml index c95560dbd4..02b5f52033 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ volumes: varnish_end_user_cache: services: nginx: - image: nginx:1.23.3 + image: nginx:1.31.1 mem_limit: 128m configs: - source: nginx_conf @@ -105,7 +105,7 @@ services: - ./config/dataspaces.trig:/var/linkeddatahub/datasets/dataspaces.trig - ./config/system.trig:/var/linkeddatahub/datasets/system.trig fuseki-admin: - image: atomgraph/fuseki:4.7.0 + image: atomgraph/fuseki:6.1.0 user: root # otherwise fuseki user does not have permissions to the mounted folder which is owner by root expose: - 3030 @@ -114,7 +114,7 @@ services: - ./fuseki/admin:/fuseki/databases command: [ "--config", "/fuseki/config.ttl" ] fuseki-end-user: - image: atomgraph/fuseki:4.7.0 + image: atomgraph/fuseki:6.1.0 user: root # otherwise the fuseki user does not have permissions to the mounted folder which is owner by root expose: - 3030 @@ -123,7 +123,7 @@ services: - ./fuseki/end-user:/fuseki/databases command: [ "--config", "/fuseki/config.ttl" ] varnish-frontend: - image: varnish:7.3.0 + image: varnish:7.7.3 user: root # otherwise varnish user does not have permissions to the mounted folder which is owner by root configs: - source: varnish-frontend_vcl @@ -136,7 +136,7 @@ services: entrypoint: varnishd command: [ "-F", "-f", "/etc/varnish/default.vcl", "-a", "http=:6060,HTTP", "-a", "proxy=:8443,PROXY", "-p", "feature=+http2", "-s", "file,/var/lib/varnish/storage.bin,3G", "-t", "86400" ] # -F: foreground, -f: config, -a: listeners, -p: http2, -s: storage, -t: TTL varnish-admin: - image: varnish:7.3.0 + image: varnish:7.7.3 user: root # otherwise the varnish user does not have permissions to the mounted folder which is owner by root configs: - source: varnish-admin_vcl @@ -147,7 +147,7 @@ services: entrypoint: varnishd command: [ "-F", "-f", "/etc/varnish/default.vcl", "-a", "http=:80,HTTP", "-a", "proxy=:8443,PROXY", "-p", "feature=+http2", "-s", "malloc,1G", "-t", "86400", "-p", "timeout_idle=60s" ] # -F: foreground, -f: config, -a: listeners, -p: http2 + idle timeout, -s: storage, -t: TTL varnish-end-user: - image: varnish:7.3.0 + image: varnish:7.7.3 user: root # otherwise varnish user does not have permissions to the mounted folder which is owner by root configs: - source: varnish-end-user_vcl diff --git a/http-tests/document-hierarchy/POST-html-jsonld.sh b/http-tests/document-hierarchy/POST-html-jsonld.sh new file mode 100755 index 0000000000..ab16fe546f --- /dev/null +++ b/http-tests/document-hierarchy/POST-html-jsonld.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# add agent to the writers group + +add-agent-to-group.sh \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + --agent "$AGENT_URI" \ + "${ADMIN_BASE_URL}acl/groups/writers/" + +# POST an HTML document with an embedded JSON-LD +EOF +) \ +| grep -q "$STATUS_NO_CONTENT" + +# check that the triple from the embedded JSON-LD is queryable + +curl -k -f -s -G \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H "Accept: application/n-triples" \ +"$END_USER_BASE_URL" \ +| tr -d '\n' \ +| grep '"named object HTML/JSON-LD POST"' > /dev/null diff --git a/http-tests/document-hierarchy/PUT-orphan-bnode-object-skolemized.sh b/http-tests/document-hierarchy/PUT-orphan-bnode-object-skolemized.sh new file mode 100755 index 0000000000..a8c58ee43d --- /dev/null +++ b/http-tests/document-hierarchy/PUT-orphan-bnode-object-skolemized.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# add agent to the writers group + +add-agent-to-group.sh \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + --agent "$AGENT_URI" \ + "${ADMIN_BASE_URL}acl/groups/writers/" + +# create a container under root; it becomes the PUT target + +slug=$(uuidgen | tr '[:upper:]' '[:lower:]') + +container=$(create-container.sh \ + -f "$AGENT_CERT_FILE" \ + -p "$AGENT_CERT_PWD" \ + -b "$END_USER_BASE_URL" \ + --title "Test container" \ + --slug "$slug" \ + --parent "$END_USER_BASE_URL") + +# PUT a body where rdf:_1 references a bnode that has NO further triples +# (an orphan blank node in object position). The body is otherwise valid +# (title is present, parent is present), so SPIN constraints pass. +# Expected: 200 OK. + +curl -k -w "%{http_code}\n" -o /dev/null -f -s \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -X PUT \ + -H "Accept: application/n-triples" \ + -H "Content-Type: application/n-triples" \ + --data-binary @- \ + "$container" < . +<${container}> "Test container" . +<${container}> <${END_USER_BASE_URL}> . +<${container}> _:orphan . +EOF + +# fetch the persisted representation and assert: the object of rdf:_1 is a URI, +# never a blank node label. The Skolemizer must have rewritten _:orphan to a +# skolem URI before the graph reached the store. + +response=$(curl -k -f -s -G \ + -E "$AGENT_CERT_FILE":"$AGENT_CERT_PWD" \ + -H "Accept: application/n-triples" \ + "$container") + +rdf_1_line=$(echo "$response" | grep -E "^<${container}> " || true) + +[ -n "$rdf_1_line" ] || exit 1 + +# object of rdf:_1 must be a URI (<...>), not a blank node label (_:...) +! echo "$rdf_1_line" | grep -qE '_:[A-Za-z0-9]+ \.$' +echo "$rdf_1_line" | grep -qE '<\S+> \.$' diff --git a/http-tests/proxy/GET-proxied-html-jsonld.sh b/http-tests/proxy/GET-proxied-html-jsonld.sh new file mode 100755 index 0000000000..28e4fd9722 --- /dev/null +++ b/http-tests/proxy/GET-proxied-html-jsonld.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +initialize_dataset "$END_USER_BASE_URL" "$TMP_END_USER_DATASET" "$END_USER_ENDPOINT_URL" +initialize_dataset "$ADMIN_BASE_URL" "$TMP_ADMIN_DATASET" "$ADMIN_ENDPOINT_URL" +purge_cache "$END_USER_VARNISH_SERVICE" +purge_cache "$ADMIN_VARNISH_SERVICE" +purge_cache "$FRONTEND_VARNISH_SERVICE" + +# add agent to the readers group to be able to read documents + +add-agent-to-group.sh \ + -f "$OWNER_CERT_FILE" \ + -p "$OWNER_CERT_PWD" \ + --agent "$AGENT_URI" \ + "${ADMIN_BASE_URL}acl/groups/readers/" + +# Regression: when an upstream proxied URI returns text/html (e.g. a schema.org term page) +# but embeds JSON-LD via + + """.formatted(EX); + + Model model = parse(html); + + assertTrue(model.contains( + ResourceFactory.createResource(EX + "alice"), + ResourceFactory.createProperty(EX, "name"), + "Alice")); + assertTrue(model.contains( + ResourceFactory.createResource(EX + "alice"), + org.apache.jena.vocabulary.RDF.type, + ResourceFactory.createResource(EX + "Person"))); + } + + @Test + public void testMultipleScriptsAreMerged() + { + String html = """ + + + + + """.formatted(EX, EX); + + Model model = parse(html); + + assertTrue(model.contains( + ResourceFactory.createResource(EX + "alice"), + ResourceFactory.createProperty(EX, "name"), + "Alice")); + assertTrue(model.contains( + ResourceFactory.createResource(EX + "bob"), + ResourceFactory.createProperty(EX, "name"), + "Bob")); + } + + @Test + public void testMissingScriptThrows() + { + String html = "no jsonld

nothing

"; + + assertThrows(RiotParseException.class, () -> parse(html)); + } + + @Test + public void testOtherScriptTypesIgnored() + { + // a non-ld+json + + + """.formatted(EX); + + Model model = parse(html); + + assertTrue(model.contains( + ResourceFactory.createResource(EX + "alice"), + ResourceFactory.createProperty(EX, "name"), + "Alice")); + assertFalse(model.contains( + ResourceFactory.createResource(EX + "js"), + ResourceFactory.createProperty(EX, "name"), + "JS")); + } + + @Test + public void testSameOutputAsDirectJsonLdParse() + { + // the HTML reader must be a transparent wrapper around Jena's JSON-LD11 reader: + // wrapping the same payload in HTML must yield exactly the same model as parsing the payload directly + String jsonLd = """ + { + "@context": {"ex": "%s", "name": {"@id": "ex:name"}}, + "@id": "ex:alice", + "@type": "ex:Person", + "name": "Alice" + } + """.formatted(EX); + String html = ""; + + Model direct = ModelFactory.createDefaultModel(); + RDFParser.create(). + source(new ByteArrayInputStream(jsonLd.getBytes(StandardCharsets.UTF_8))). + lang(Lang.JSONLD11). + base(BASE_URI). + parse(StreamRDFLib.graph(direct.getGraph())); + + Model viaHtml = parse(html); + + assertEquals(direct.size(), viaHtml.size()); + assertTrue(direct.isIsomorphicWith(viaHtml)); + } + +} diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilterTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilterTest.java index 745ecd94dd..2321ecf817 100644 --- a/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilterTest.java +++ b/src/test/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilterTest.java @@ -24,11 +24,11 @@ import java.io.IOException; import java.net.URI; import java.util.List; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.*; @@ -37,7 +37,7 @@ * * @author Martynas Jusevičius {@literal } */ -@RunWith(MockitoJUnitRunner.class) +@ExtendWith(MockitoExtension.class) public class ProxyRequestFilterTest { @@ -47,7 +47,7 @@ public class ProxyRequestFilterTest private ProxyRequestFilter filter; - @Before + @BeforeEach public void setUp() { filter = new ProxyRequestFilter(); diff --git a/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java b/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java index fd22dae189..b72bc4b653 100644 --- a/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java +++ b/src/test/java/com/atomgraph/linkeddatahub/server/util/URLValidatorTest.java @@ -16,9 +16,10 @@ package com.atomgraph.linkeddatahub.server.util; import com.atomgraph.linkeddatahub.server.exception.InternalURLException; -import org.junit.Test; +import org.junit.jupiter.api.Test; import java.net.URI; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * Unit tests for URLValidator SSRF protection. @@ -32,34 +33,34 @@ public class URLValidatorTest { - @Test(expected = IllegalArgumentException.class) + @Test public void testNullURI() { - new URLValidator(false).validate(null); + assertThrows(IllegalArgumentException.class, () -> new URLValidator(false).validate(null)); } - @Test(expected = InternalURLException.class) + @Test public void testLinkLocalIPv4Blocked() { - new URLValidator(false).validate(URI.create("http://169.254.1.1:8080/test")); + assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://169.254.1.1:8080/test"))); } - @Test(expected = InternalURLException.class) + @Test public void testPrivateClass10Blocked() { - new URLValidator(false).validate(URI.create("http://10.0.0.1:8080/test")); + assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://10.0.0.1:8080/test"))); } - @Test(expected = InternalURLException.class) + @Test public void testPrivateClass172Blocked() { - new URLValidator(false).validate(URI.create("http://172.16.0.0:8080/test")); + assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://172.16.0.0:8080/test"))); } - @Test(expected = InternalURLException.class) + @Test public void testPrivateClass192Blocked() { - new URLValidator(false).validate(URI.create("http://192.168.1.1:8080/test")); + assertThrows(InternalURLException.class, () -> new URLValidator(false).validate(URI.create("http://192.168.1.1:8080/test"))); } @Test