Skip to content

Enable and repair the system/ http-tests (instrument for diagnosis, fix /access ?this) - #378

Merged
namedgraph merged 9 commits into
developfrom
fix-system-http-tests
Sep 10, 2026
Merged

Enable and repair the system/ http-tests (instrument for diagnosis, fix /access ?this)#378
namedgraph merged 9 commits into
developfrom
fix-system-http-tests

Conversation

@namedgraph

Copy link
Copy Markdown
Member

The 19 http-tests/system/ scripts had never executed until run.sh began invoking them (they cover exactly the auth-boundary surface the rest of the suite doesn't: /settings, /sparql, /ns, /access, /clear across owner / writer / reader / unauthenticated). Their first CI run failed 9 of 19 — all with empty diagnostic messages, the silent curl | grep -q "$STATUS" anti-pattern CLAUDE.md forbids: the http_code goes straight into grep's stdin and is never printed, so a failure names only the file.

What this PR does

Instruments all 19 to the mandated convention — capture the code into $actual, echo DEBUG: Expected/Got, then assert with an anchored grep -qE "^(${expected})$". This also closes the latent footgun the codebase review flagged: the alternation status vars (STATUS_*_SUCCESS='200|204') were matched with a plain BRE grep -q, where | is a literal and the pattern can never match. No expected values are changed by the instrumentation — its purpose is to make the next CI run print the real codes.

Fixes one genuinely-wrong expectation (config-independent, confirmed from source): GET-access.sh requested /access with no query param, but Access.java:115 throws BadRequestException (400) unless ?this names the resource whose access is described. It now passes ?this via -G --data-urlencode, matching the idiom the passing access/ suite already uses. Verified 200 against a live stack.

Deliberately left for a follow-up commit on this branch

The remaining eight failures are the 401 and cross-agent 403 negatives. Their correct codes need the CI fixtures as the oracle — a public demo instance is not faithful for access-control assertions. My read of namespace-ontology.trig.template suggests some current expectations are wrong (the read authorization grants readers/writers acl:Read via accessToClass dh:Item, dh:Container, def:Root), but per the repo's debug-first rule I'm not baking guessed codes in. Once this PR's CI prints the DEBUG: Got: codes, I'll push the corrected expectations here.

So: expect this PR's first CI to still show ~8 system failures — now with visible actual-vs-expected codes. That output is the point.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e

namedgraph and others added 9 commits September 10, 2026 21:28
The 19 system/ scripts had never executed until run.sh started invoking them, and CI's first run failed 9 of them with empty diagnostic messages - the exact silent 'curl | grep -q "$STATUS"' anti-pattern the CLAUDE.md debugging convention forbids: the http_code went straight into grep's stdin and was never printed, so a failure told you only the filename. Every script now captures the code into $actual, echoes DEBUG Expected/Got, and asserts with an anchored 'grep -qE "^(${expected})$"' - which also closes the latent footgun the codebase review flagged, where the alternation status vars (STATUS_*_SUCCESS='200|204') were matched with plain BRE grep -q and could never match. No expected values changed here; the point is to make CI print the actual codes so the genuinely-wrong expectations can be corrected from ground truth rather than guessed.

One expectation is fixed because it is wrong independent of any ACL config: GET-access.sh requested /access with no query param, but Access.java throws BadRequestException (400) unless ?this names the resource whose access is described - the same 400 the endpoint returns everywhere. It now passes ?this via -G --data-urlencode, matching the idiom the passing access/ suite already uses.

The remaining eight failures (the 401 and cross-agent 403 negatives) are left for a follow-up once CI's DEBUG output shows the real codes: the demo config is public-read and not a faithful oracle for access-control assertions, and the default namespace-ontology.trig.template authorizations make several of the current expectations suspect (the read authorization grants readers/writers acl:Read via accessToClass), but that must be confirmed against the actual test fixtures, not reasoned into the scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
The four /sparql tests (owner GET/POST query success, and the certless GET/POST
negatives) sat under system/end-user/ but belong with the rest of the /sparql
protocol coverage: sparql-protocol/query/ already holds the POST-query and
default-graph-uri cases, and GET-ns.sh is the direct analog (owner query on the
endpoint succeeds). git mv preserves history; run.sh picks them up recursively
under the existing sparql-protocol suite instead of system, so no wiring change.
Expected-value state is carried over unchanged - the certless negatives remain as
instrumented pending the ground-truth reconciliation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
…2-login accessToClass)

The system/ http-tests caught unauthenticated and cross-agent requests succeeding where the
ACL forbids them: certless GET /settings and /sparql returned 200, a reader/writer GET /settings
returned 200, and a *writer* PATCH /settings returned 204. Root cause is in the ACL query
(web.xml aclQuery/ownerAclQuery): the resource branch matches on '?auth acl:accessToClass $Type',
where $Type is the requested resource's rdf:type, resolved by a separate document-type query and
injected as a VALUES block (AuthorizationFilter). Platform endpoints like /settings and /sparql are
not stored graphs, so that query returns nothing, the VALUES injection is skipped, and $Type is
left UNBOUND. An unbound $Type turns 'acl:accessToClass $Type' into a wildcard that matches every
authorization carrying any acl:accessToClass - so a typeless resource inherits every class-based
grant. It is not the lapp:allowRead flag (off on the test stack); the filter's read short-circuit
never fires there.

The intended query-side guard FILTER(bound(?Type)) does NOT work and was rejected after testing it
against Jena arq: the 'acl:accessToClass ?Type' triple itself binds ?Type, so bound(?Type) is true
in exactly the wildcard rows we want to drop. The fix instead mirrors the pattern already in
AuthorizationParams, which binds an RDFS.Resource sentinel to $agent/$AuthenticatedAgentClass to
disable those UNION branches: AuthorizationFilter now binds $Type to RDFS.Resource when the resource
has no type, so acl:accessToClass matches nothing (verified with arq: sentinel -> zero matches;
typed $Type and acl:accessTo grants still match). One else-branch covers both aclQuery and
ownerAclQuery; no query edit needed; typed resources are unaffected.

That exposed a second, compounding bug: oauth2-login (admin.trig) granted foaf:Agent Read via
acl:accessToClass <oauth2/login/google>, <oauth2/login/orcid> - but those are endpoint documents,
not classes (its sibling oauth2-authorize correctly uses acl:accessTo). It is the only accessToClass
misuse, and the sole foaf:Agent+accessToClass authorization - i.e. the very grant the unbound-$Type
wildcard turned into anonymous read of every typeless resource. It is also the only thing making the
(typeless) OAuth login endpoints publicly reachable, so the sentinel fix alone would have broken
public OAuth login. Both changes therefore ship together: oauth2-login moves to acl:accessTo, so the
login endpoints stay reachable via their exact-URI grant while the wildcard leak is closed.

Net authorization after the fix matches the templates' explicit acl:accessTo grants: /settings
owner-only, /sparql AuthenticatedAgent-only, /ns and /access public, OAuth login public.

Tests: the five unauthenticated negatives now expect 403 (LDH issues no 401 challenge -
AuthorizationException extends ForbiddenException, WWW-Authenticate is commented out); the three
cross-agent -403 settings tests already expected 403 and start passing. Adds
GET-oauth2-login-public.sh asserting unauthenticated GET /oauth2/login/{google,orcid} stays
reachable (400 = reached the resource on a missing OAuth 'state' param; a 403 would mean the ACL
denied it) - the regression guard for the two-part interaction.

Deployment note: a fresh instance re-seeds admin.trig, but existing deployments carry the old
oauth2-login authorization in their triplestore and need a data migration or re-seed, not just this
file change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
lapp:allowRead had zero test coverage despite doing something drastic: when true,
AuthorizationFilter (isReadAllowed, lines 116-123) skips authorization for ALL GET/HEAD requests.
It is off by default and set in no fixture, so the behaviour was never exercised.

The test toggles it at runtime via owner PATCH /settings (persisted through Application.updateApp,
re-read per request by matchApp) and asserts the three load-bearing properties: a certless GET of the
owner-only /settings flips 403 -> 200 once allowRead is set (authorization skipped), while a certless
PATCH stays 403 (the short-circuit is GET/HEAD only). An EXIT trap always removes the property again so
the read-open state cannot leak into later tests.

Note: lapp:public, set true on every app in the fixtures and shipped config, has NO reader in the Java
model (no LAPP.public reference, no isPublic()) - it is inert and has no behaviour to test. Left as-is
pending a decision on whether it should enforce anything or be removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
Follow-up to closing the unbound-$Type hole: with the wildcard gone, the OAuth login endpoints lost
the accidental grant they were surviving on, so GET /oauth2/login/{google,orcid} started returning 403
(caught by the new GET-oauth2-login-public.sh regression test).

The admin.trig oauth2-login/oauth2-authorize authorizations were a fossil from the path-based era
(pre-v5.1.0, when admin was https://host/admin/ and a relative <oauth2/login> resolved to the login
endpoint). git history: acl:accessTo <oauth2/login> -> acl:accessToClass ldh:OAuthLogin (a real class,
when the endpoints carried that type) -> acl:accessToClass <oauth2/login/google>,<orcid> in 9269294,
which also stripped the endpoints' type - leaving accessToClass pointing at typeless document URIs,
matchable only through the wildcard. Two things moved underneath it since: dataspaces got their own
origins (admin is now a subdomain), and LoginBase gained its EndUserApplication-only guard, so the login
endpoint now runs on the end-user app (localhost/oauth2/login/google reachable; admin.localhost/... 403).

So the grant belongs on the end-user app. Added an oauth2 authorization to
namespace-ontology.trig.template granting foaf:Agent Read to <${end_user_origin}/oauth2/login/{google,
orcid}> and /oauth2/authorize/{google,orcid}> - the same shape as the sibling sparql-endpoint/access
grants there - and removed the two vestigial admin.trig authorizations, which governed admin-base login
endpoints that LoginBase rejects and that the ownerAclQuery whitelist never included anyway. Updated that
whitelist's stale comment to drop OAuth2 login.

Signup stays on the admin app (SignUp requires AdminApplication); moving it to the end-user app to mirror
OAuth login is a sensible but separate refactor, left for later.

Deployment note: like the oauth2-login change, existing instances carry the old admin authorizations in
their triplestore and need a re-seed or data migration, not just this file change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
The $Type sentinel from 9e909cb closed the typeless-resource read hole but broke document creation:
51 http-tests failed, dominated by PUT-create (document-hierarchy, imports, versioning, add, access).
Root cause: the unbound-$Type wildcard was load-bearing for more than the /settings leak. When a PUT
creates a document at a new URI, the document is still typeless, so authorization falls back to the
parent container - but the code only used the parent to check it is a Root/Container and to redirect
accessTo; it never injected the parent's *types* into the ACL query. The parent's acl:accessToClass
write grants (dh:Container/dh:Item/def:Root) matched only through the wildcard. The sentinel disabled
that, so every create was denied.

Fix: on the PUT fallback, use the parent's document-type result to drive the ACL query's
acl:accessToClass matching (aclTypesResult), instead of the empty child result. The parent ResultSet is
rewound after the Root/Container check and injected as the VALUES block, same mechanism as a normal
typed resource; the outer finally owns its lifecycle. Typeless resources with no typed parent (system
endpoints) still hit the RDFS.Resource sentinel and stay owner/authenticated-only.

Known remaining: GET/PATCH/DELETE to a *nonexistent* document (the -404 tests) also relied on the
wildcard to authorize a writer/reader before the resource returns 404; those are not addressed here and
need a design decision (their 404 was itself produced by the wildcard).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
The typeless-resource fix so far relied on AuthorizationFilter binding an RDFS.Resource sentinel to $Type
in an else branch. That works but is caller-side: the query still fails OPEN if any consumer runs it
without binding $Type (e.g. Access.java, which re-runs the same aclQuery/ownerAclQuery and is already
drifted). The resource's type is genuinely external - it lives in fuseki-end-user while the ACL query runs
against fuseki-admin - so the binding cannot be removed, but the DEFAULT can be flipped from fail-open to
fail-closed.

Bake a default 'VALUES ?Type { rdfs:Resource }' into both aclQuery and ownerAclQuery (web.xml). rdfs:Resource
is a class no authorization uses as acl:accessToClass, so with the default in force both the direct and the
rdfs:subClassOf* branches match nothing. SetResultSetValues.setValuesDataBlock REPLACES that top-level VALUES
with the resource's real types when they exist (typed resource, or the parent container's types on a
PUT-create), so the grant path is unchanged; a typeless resource keeps the default and is denied. Verified:
Query.setValuesDataBlock replaces (not appends) a parsed VALUES clause (scratch Jena run), and arq confirms
the default yields zero accessToClass matches while an override matches. A VALUES variable is respected inside
the GRAPH ?g block (unlike a FILTER, which cannot see a top-level VALUES there - the reason the earlier
FILTER(bound(?Type)) and FILTER(?authClass = $Type) rewrites were rejected).

AuthorizationFilter's else-branch sentinel is removed (the baked-in default subsumes it, and setIri on $Type
would collide with the VALUES clause); the RDFS import goes with it. Net behaviour for typeless resources is
identical to the sentinel (deny), but now every consumer of the shared query - including the Access.java
duplicate - inherits the fail-closed default without having to remember to bind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
…under-reported)

The baked-in fail-closed default (VALUES ?Type { rdfs:Resource }) exposed a pre-existing drift the security
review already flagged: Access.java (the /access endpoint) loads the resource's document types at :125 but
never applied them to the ACL query - it ran authPss with $Type left to the query default. Before the default
existed, $Type was unbound and the wildcard over-reported every accessToClass authorization (which is what the
old /access behaviour relied on); with the default in place it under-reports, hiding every class-based grant.
That broke access/group-authorization.sh, which asserts a writers-group authorization surfaces on a container
(matched via acl:accessToClass dh:Container).

Fix: apply docTypesResult to the ACL query via SetResultSetValues before running it - the same override
AuthorizationFilter does - so /access reports the resource's real type-based authorizations. A typeless resource
still has no types to inject and keeps the fail-closed default, so /access reports only its exact-URI accessTo
grants, matching what AuthorizationFilter actually enforces.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
With the fail-closed ACL default, a typeless URL (a document that does not exist, or was just deleted)
matches no authorization, so AuthorizationFilter denies it with 403 before the request reaches the handler
that would have reported not-found. 404 was only ever produced because the unbound-$Type wildcard wrongly
authorized the request first, letting it travel far enough to discover the document was missing. Update the
nine impacted assertions (all non-owner requests to a non-existing URL) from STATUS_NOT_FOUND to
STATUS_FORBIDDEN: GET/PATCH/DELETE/POST-404, imports/GET-file-404, proxy/GET-proxied-404 and
proxy/GET-proxied-accept-html-not-preferred (the proxy relays the upstream 403), and the verify-deleted step
of PATCH-empty-container/item. Owner requests are unaffected (isOwner grants access regardless of type, so
they still reach the handler and get 404) - only the wildcard-dependent non-owner paths change.

Also fixes GET-oauth2-login-public.sh: its expected value was wrong (the endpoint returns 404 on the test
stack where the provider is unconfigured, 400 where it is). The regression it guards is that the ACL does not
DENY the login endpoint, so it now asserts the status is simply not 403 rather than a config-dependent code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSvgJqLDCaU5aMpmPjan9e
@namedgraph
namedgraph force-pushed the fix-system-http-tests branch from ad72c0a to f9ab51b Compare September 10, 2026 19:29
@namedgraph
namedgraph merged commit 1b85081 into develop Sep 10, 2026
1 check passed
@namedgraph
namedgraph deleted the fix-system-http-tests branch September 10, 2026 19:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant