From 0c894aabe53bc84c4027165a262cd1f46317c565 Mon Sep 17 00:00:00 2001 From: ecrum19 Date: Tue, 12 May 2026 14:31:08 +0200 Subject: [PATCH] fixed link-traversal example queries and small login error --- demonstrator/federated-automated-rhea-13.rq | 21 + demonstrator/link-traversal-solidbench-1.rq | 11 + demonstrator/link-traversal-solidbench-2.rq | 10 + .../solid-link-traversal-foaf-knows.rq | 10 - demonstrator/solid-link-traversal-see-also.rq | 9 - .../solid-no-traversal-basic-select.rq | 2 +- demonstrator/triple-solid-2.rq | 2 +- src/components/DataQuery.vue | 59 ++- src/components/Guides/DataQueryGuide.vue | 40 +- src/services/query/queryPod.ts | 378 ++++++++++-------- src/services/solid/login.ts | 17 +- tests/components/DataQueryExamples.test.ts | 90 +++++ tests/unit/login.test.ts | 21 + tests/unit/queryModeValidation.test.ts | 6 + 14 files changed, 481 insertions(+), 195 deletions(-) create mode 100644 demonstrator/federated-automated-rhea-13.rq create mode 100644 demonstrator/link-traversal-solidbench-1.rq create mode 100644 demonstrator/link-traversal-solidbench-2.rq delete mode 100644 demonstrator/solid-link-traversal-foaf-knows.rq delete mode 100644 demonstrator/solid-link-traversal-see-also.rq diff --git a/demonstrator/federated-automated-rhea-13.rq b/demonstrator/federated-automated-rhea-13.rq new file mode 100644 index 0000000..2e93705 --- /dev/null +++ b/demonstrator/federated-automated-rhea-13.rq @@ -0,0 +1,21 @@ +# Datasources: https://sparql.rhea-db.org/sparql https://sparql.uniprot.org/sparql/ +PREFIX rh: +PREFIX taxon: +PREFIX up: +# Query 13 +# Select all Rhea reactions used to annotate Escherichia coli (taxid=83333) in UniProtKB/Swiss-Prot +# return the number of UniProtKB entries +SELECT ?uniprot ?mnemo ?rhea ?accession ?equation +WHERE { + { + VALUES (?taxid) { (taxon:83333) } + GRAPH { + ?uniprot up:reviewed true . + ?uniprot up:mnemonic ?mnemo . + ?uniprot up:organism ?taxid . + ?uniprot up:annotation/up:catalyticActivity/up:catalyzedReaction ?rhea . + } + } + ?rhea rh:accession ?accession . + ?rhea rh:equation ?equation . +} \ No newline at end of file diff --git a/demonstrator/link-traversal-solidbench-1.rq b/demonstrator/link-traversal-solidbench-1.rq new file mode 100644 index 0000000..1f26d48 --- /dev/null +++ b/demonstrator/link-traversal-solidbench-1.rq @@ -0,0 +1,11 @@ +# Datasources: https://solidbench.linkeddatafragments.org/pods/00000000000000000933/profile/card +# QueryMode: solid-link-traversal +PREFIX rdf: +PREFIX snvoc: +SELECT ?messageId ?messageCreationDate ?messageContent WHERE { + ?message snvoc:hasCreator ; + rdf:type snvoc:Post; + snvoc:content ?messageContent; + snvoc:creationDate ?messageCreationDate; + snvoc:id ?messageId. +} diff --git a/demonstrator/link-traversal-solidbench-2.rq b/demonstrator/link-traversal-solidbench-2.rq new file mode 100644 index 0000000..8b07098 --- /dev/null +++ b/demonstrator/link-traversal-solidbench-2.rq @@ -0,0 +1,10 @@ +# Datasources: https://solidbench.linkeddatafragments.org/pods/00000015393162789111/posts +# QueryMode: solid-link-traversal +PREFIX snvoc: +SELECT ?personId ?firstName ?lastName WHERE { + snvoc:id ?messageId; + snvoc:hasCreator ?creator. + ?creator snvoc:id ?personId; + snvoc:firstName ?firstName; + snvoc:lastName ?lastName. +} diff --git a/demonstrator/solid-link-traversal-foaf-knows.rq b/demonstrator/solid-link-traversal-foaf-knows.rq deleted file mode 100644 index 0140d66..0000000 --- a/demonstrator/solid-link-traversal-foaf-knows.rq +++ /dev/null @@ -1,10 +0,0 @@ -# Datasources: https://triple.ilabt.imec.be/test/profile/card -# QueryMode: solid-link-traversal -PREFIX foaf: - -SELECT ?person ?name -WHERE { - foaf:knows ?person . - ?person foaf:name ?name . -} -LIMIT 10 diff --git a/demonstrator/solid-link-traversal-see-also.rq b/demonstrator/solid-link-traversal-see-also.rq deleted file mode 100644 index 5970bea..0000000 --- a/demonstrator/solid-link-traversal-see-also.rq +++ /dev/null @@ -1,9 +0,0 @@ -# Datasources:https://triple.ilabt.imec.be/test/profile/card -# QueryMode: solid-link-traversal -PREFIX rdfs: - -SELECT ?linkedDocument -WHERE { - rdfs:seeAlso ?linkedDocument . -} -LIMIT 10 diff --git a/demonstrator/solid-no-traversal-basic-select.rq b/demonstrator/solid-no-traversal-basic-select.rq index 8bec9f4..a7c53b1 100644 --- a/demonstrator/solid-no-traversal-basic-select.rq +++ b/demonstrator/solid-no-traversal-basic-select.rq @@ -1,4 +1,4 @@ -# Datasources: https://triple.ilabt.imec.be/test/bio-usecase/nbn-chist-era-annex-1-chemicals-custom-predicate.ttl +# Datasources: https://triple.ilabt.imec.be/test/ # QueryMode: solid-no-traversal SELECT ?s ?p ?o WHERE { diff --git a/demonstrator/triple-solid-2.rq b/demonstrator/triple-solid-2.rq index 0b7eff8..e65f646 100644 --- a/demonstrator/triple-solid-2.rq +++ b/demonstrator/triple-solid-2.rq @@ -1,4 +1,4 @@ -# Datasources: http://localhost:3000/test/random/nbn-chist-era-annex-1-chemicals-custom-predicate.ttl +# Datasources: https://triple.ilabt.imec.be/test/bio-usecase/nbn-chist-era-annex-1-chemicals-custom-predicate.ttl # QueryMode: solid-no-traversal PREFIX thd: SELECT DISTINCT ?CAS WHERE { diff --git a/src/components/DataQuery.vue b/src/components/DataQuery.vue index 7693675..a701352 100644 --- a/src/components/DataQuery.vue +++ b/src/components/DataQuery.vue @@ -2245,6 +2245,23 @@ export default { isLikelySolidOrRdfSource(source: string) { return !this.isLikelySparqlEndpointSource(source); }, + /** + * Infers query mode from canonical example filename prefixes so the sample + * picker remains stable even when files omit explicit # QueryMode metadata. + */ + inferModeFromExampleId(exampleId: string): QueryExecutionMode | null { + const normalizedId = exampleId.toLowerCase(); + if ( + normalizedId.startsWith("link-traversal-") || + normalizedId.startsWith("link-taversal-") + ) { + return "solid-link-traversal"; + } + if (normalizedId.startsWith("solid-no-traversal-")) { + return "solid-no-traversal"; + } + return null; + }, /** * Determines the target query engine mode for an example query. Authors can * explicitly pin a mode via `# QueryMode: ` in the .rq file. @@ -2253,6 +2270,7 @@ export default { _queryText: string, sources: string[], declaredMode?: string, + exampleId?: string, ): QueryExecutionMode { if ( declaredMode && @@ -2261,6 +2279,13 @@ export default { return declaredMode as QueryExecutionMode; } + if (exampleId) { + const modeFromName = this.inferModeFromExampleId(exampleId); + if (modeFromName) { + return modeFromName; + } + } + const endpointSourceCount = sources.filter((source) => this.isLikelySparqlEndpointSource(source), ).length; @@ -2283,6 +2308,7 @@ export default { queryText: string, sources: string[], mode: QueryExecutionMode, + exampleId?: string, ): ExampleQueryCategory { if (mode === "solid-link-traversal") { return "Solid query (link traversal)"; @@ -2291,13 +2317,32 @@ export default { return "Solid query (no traversal)"; } + const normalizedId = (exampleId || "").toLowerCase(); + if (normalizedId.startsWith("federated-")) { + return "Federated query"; + } + const hasServiceClause = /service\s*<[^>]+>/i.test(queryText); const endpointSourceCount = sources.filter((source) => this.isLikelySparqlEndpointSource(source), ).length; + const endpointHosts = new Set( + sources + .filter((source) => this.isLikelySparqlEndpointSource(source)) + .map((source) => this.normalizeSourceUrlForValidation(source)) + .map((source) => { + try { + return new URL(source).host; + } catch { + return ""; + } + }) + .filter((host) => host.length > 0), + ); if ( hasServiceClause || + endpointHosts.size > 1 || endpointSourceCount > 1 || (endpointSourceCount === 1 && sources.length > 1) ) { @@ -2576,8 +2621,14 @@ export default { query, sources, declaredMode, + rawName, + ); + const category = this.categorizeExampleQuery( + query, + sources, + mode, + rawName, ); - const category = this.categorizeExampleQuery(query, sources, mode); queries.push({ id: rawName, name, @@ -2604,8 +2655,10 @@ export default { ); if (!example) return; - // Clone example sources so user edits never mutate the static sample list. - this.currentQuery.sources = [...example.sources]; + // Link-traversal examples intentionally start with no explicit datasource. + // Comunica can derive traversal seeds from IRIs that appear in the query. + this.currentQuery.sources = + example.mode === "solid-link-traversal" ? [] : [...example.sources]; this.currentQuery.query = example.query || ""; this.queryMode = example.mode; this.syncYasqeFromExternalQuery(this.currentQuery.query, { diff --git a/src/components/Guides/DataQueryGuide.vue b/src/components/Guides/DataQueryGuide.vue index 5c9e6ce..95f0a71 100644 --- a/src/components/Guides/DataQueryGuide.vue +++ b/src/components/Guides/DataQueryGuide.vue @@ -52,12 +52,12 @@ Targets multiple SPARQL endpoints in one execution (commonly via SERVICE clauses).
  • - Solid query - Targets RDF resources from a Solid pod or local server. + Solid query (no traversal) + Targets explicit Solid RDF resources/containers without discovering linked documents.
  • - Mixed source federated query - Targets both Solid pod sources and SPARQL endpoint sources. + Solid query (link traversal) + Starts from Solid seed URLs and discovers additional RDF documents by following links.
  • @@ -70,6 +70,22 @@ baseline-test Quick concept-check query on the Rhea endpoint that returns a small set of predicate IRIs. +
  • + federated-automated-rhea-13 + Federates UniProt and Rhea to list E. coli-reviewed proteins and associated reactions. +
  • +
  • + federated-uniprot-rhea-human-reactions + Fetches human proteins in UniProt and joins to reaction labels from Rhea. +
  • +
  • + federated-wikidata-uniprot-human-proteins + Uses Wikidata + UniProt federation to enrich proteins with mnemonics. +
  • +
  • + federated-wikidata-cities + Single-endpoint Wikidata city query with label service for readable output. +
  • triple-wikidata-1 Aggregates toxicity-related compound data in Wikidata and ranks by average LD50. @@ -95,8 +111,20 @@ Retrieves OMA ortholog protein links and organism names for a selected UniProt protein.
  • - triple-combined-service - Full mixed-source federated workflow that chains Solid/local CAS data, IDSM similarity search, and Rhea reactions. + solid-no-traversal-basic-select + Reads triples directly from a selected Solid container without traversal. +
  • +
  • + solid-no-traversal-literal-preview + Extracts literal values from a specific Solid RDF document. +
  • +
  • + link-traversal-solidbench-1 + SolidBench link-traversal query that starts at a profile seed and discovers related posts. +
  • +
  • + link-traversal-solidbench-2 + SolidBench link-traversal query that resolves creator details from a message seed URL.
  • diff --git a/src/services/query/queryPod.ts b/src/services/query/queryPod.ts index f55a9bd..9912691 100644 --- a/src/services/query/queryPod.ts +++ b/src/services/query/queryPod.ts @@ -1,5 +1,6 @@ import { QueryEngine as SparqlEngineCache } from "query-sparql-remote-cache"; import { QueryEngine as SolidQueryEngine } from "@comunica/query-sparql-solid"; +import { QueryEngine as SolidLinkTraversalQueryEngine } from "@comunica/query-sparql-link-traversal-solid"; import { KeyRemoteCache } from "actor-query-process-remote-cache"; import { createCoiFetch } from "./z3-headers"; import { Bindings } from "@comunica/types"; @@ -24,7 +25,7 @@ import { setStringNoLocale, setDatetime, } from "@inrupt/solid-client"; -import { fetch } from "@inrupt/solid-client-authn-browser"; +import { fetch, getDefaultSession } from "@inrupt/solid-client-authn-browser"; import { stopQuery, cleanSourcesUrlsForCache, @@ -215,6 +216,11 @@ export function validateQuerySourcesForMode( mixedSources: ComunicaSources[] ): void { if (mixedSources.length === 0) { + if (mode === "solid-link-traversal") { + // Link-traversal execution may derive seed URLs directly from IRIs in the + // query text, so explicit sources are optional for this mode. + return; + } throw new Error( "Select at least one datasource URL before running the query." ); @@ -342,117 +348,231 @@ export async function executeQueryWithPodConnected( return output; } -/** - * Executes a SPARQL query over a list of provided Solid Pod URLs. - * - * @param inputQuery The string representation of a SPARQL query to be executed. - * @param mixedSources a ComunicaSources[] that provides the Solid Pod or SPARQL Endpoint sources for executing the specified query. - * @param cachePath a string designating the user's query cache url - * @returns either: - * a) a CacheOutput object (containing cache provenance + query results) - * b) a string indicating no cache was used - * c) null if there was an error - */ -async function sparqlQueryWithCache( - inputQuery: string, - mixedSources: ComunicaSources[], - cachePath: string, - queryMode: QueryExecutionMode = "endpoint" -): Promise { - // Current remote-cache engine is endpoint-focused; non-endpoint modes bypass - // cache-hit probing and fall back to direct execution in the caller. - if (queryMode !== "endpoint") { - return "no-cache"; - } +interface QueryBindingsEngine { + queryBindings: ( + inputQuery: string, + context: Record + ) => Promise; +} - const cacheLocation = { url: cachePath + "queries.ttl" }; - const mySparqlEngine = new SparqlEngineCache(); +interface QueryStreamToJsonOptions { + includeProvenance?: boolean; +} - try { - const fetchForCache = createCoiFetch(fetch, { - coepCredentialless: false, - passthroughOpaque: true, - noCors: false, - }); +interface BindingsStreamWithProperties extends AsyncIterable { + getProperty?: (name: string, callback: (value: unknown) => void) => void; +} - // Query executor using Comunica - const bindingsStream = await mySparqlEngine.queryBindings(inputQuery, { - lenient: true, - fetch: fetchForCache, - [KeyRemoteCache.location.name]: cacheLocation, - sources: mixedSources, - failOnCacheMiss: true, - }); +function normalizeTermType(termType: string): string { + switch (termType) { + case "Literal": + return "literal"; + case "NamedNode": + return "uri"; + case "BlankNode": + return "bnode"; + default: + return termType.toLowerCase(); + } +} - // extract provenance information - let provenance: ProvenanceData | null = null; - // Extract provenance information to display in the UI +/** + * Parses a Comunica bindings stream into the JSON structure consumed by YASR/UI. + * Provenance extraction is optional and used only for endpoint cache-hit reads. + */ +async function streamBindingsToOutput( + bindingsStream: BindingsStreamWithProperties, + options: QueryStreamToJsonOptions = {} +): Promise { + let provenanceOutput: ProvenanceData | null = null; + if (options.includeProvenance && typeof bindingsStream.getProperty === "function") { bindingsStream.getProperty("provenance", (val) => { - if (val && typeof val === "object" && "algorithm" in val && "id" in val) { - provenance = { - algorithm: val.algorithm, + if ( + val && + typeof val === "object" && + "algorithm" in val && + "id" in val && + val.id && + typeof val.id === "object" && + "termType" in val.id && + "value" in val.id + ) { + provenanceOutput = { + algorithm: String(val.algorithm), id: { - termType: val.id.termType, - value: val.id.value, + termType: String(val.id.termType), + value: String(val.id.value), }, }; } }); + } - // Displays the results of the query - const bindingsArray: any[] = []; - let firstBinding: Bindings | undefined = undefined; + const bindingsArray: any[] = []; + let firstBinding: Bindings | undefined = undefined; - // Process each binding from the stream. - for await (const binding of bindingsStream) { - // Capture the variable names from the first binding. - if (!firstBinding) { - firstBinding = binding; - } - const bindingObj: Record = {}; - binding.forEach((term, variable) => { - let termType: string; - switch (term.termType) { - case "Literal": - termType = "literal"; - break; - case "NamedNode": - termType = "uri"; - break; - case "BlankNode": - termType = "bnode"; - break; - default: - termType = term.termType.toLowerCase(); - } - bindingObj[variable.value] = { type: termType, value: term.value }; - }); - bindingsArray.push(bindingObj); + for await (const binding of bindingsStream) { + if (!firstBinding) { + firstBinding = binding; } + const bindingObj: Record = {}; + binding.forEach((term, variable) => { + bindingObj[variable.value] = { + type: normalizeTermType(term.termType), + value: term.value, + }; + }); + bindingsArray.push(bindingObj); + } - // If there were no results, use an empty array of variables. - const vars = firstBinding - ? Array.from(firstBinding.keys()).map((variable) => variable.value) - : []; + const vars = firstBinding + ? Array.from(firstBinding.keys()).map((variable) => variable.value) + : []; - // results as an object - const resultsOutput: QueryResultJson = { + return { + provenanceOutput, + resultsOutput: { head: { vars }, results: { bindings: bindingsArray }, - }; + }, + }; +} - const returnVal: CacheOutput = { - provenanceOutput: provenance, - resultsOutput: resultsOutput, - }; - return returnVal; +/** + * Creates an authenticated fetch wrapper for endpoint/cache-related Comunica + * contexts where response header normalization is needed. + */ +function createAuthenticatedQueryFetch(options: { noCors: boolean }): FetchLike { + return createCoiFetch(fetch, { + coepCredentialless: false, + passthroughOpaque: true, + noCors: options.noCors, + }); +} + +function createSolidQueryContext(mixedSources: ComunicaSources[]): Record { + const session = getDefaultSession(); + const hasAuthenticatedSession = Boolean(session.info.isLoggedIn); + const authenticatedFetch: FetchLike = hasAuthenticatedSession + ? session.fetch.bind(session) + : fetch; + + // Per Comunica Solid docs, provide the authn session in context so the Solid + // HTTP actor can attach credentials for protected pod resources. + const solidContext: Record = { + lenient: true, + fetch: authenticatedFetch, + // Solid engines are most stable when sources are explicit IRI strings. + sources: mixedSources.map((source) => source.value), + }; + + if (hasAuthenticatedSession) { + solidContext["@comunica/actor-http-inrupt-solid-client-authn:session"] = session; + } + + return solidContext; +} + +function createEndpointCacheContext( + mixedSources: ComunicaSources[], + cachePath: string +): Record { + const authenticatedFetch = createAuthenticatedQueryFetch({ noCors: false }); + return { + lenient: true, + fetch: authenticatedFetch, + [KeyRemoteCache.location.name]: { url: `${cachePath}queries.ttl` }, + sources: mixedSources, + failOnCacheMiss: true, + }; +} + +async function executeWithEngine( + engine: QueryBindingsEngine, + inputQuery: string, + context: Record, + options: QueryStreamToJsonOptions = {} +): Promise { + const bindingsStream = await engine.queryBindings(inputQuery, context); + return streamBindingsToOutput(bindingsStream, options); +} + +async function executeEndpointCacheLookup( + inputQuery: string, + mixedSources: ComunicaSources[], + cachePath: string +): Promise { + const endpointEngine = new SparqlEngineCache(); + try { + return await executeWithEngine( + endpointEngine, + inputQuery, + createEndpointCacheContext(mixedSources, cachePath), + { includeProvenance: true } + ); + } catch { + return "no-cache"; + } +} + +async function executeSolidNoTraversalQuery( + inputQuery: string, + mixedSources: ComunicaSources[] +): Promise { + try { + const solidEngine = new SolidQueryEngine(); + return await executeWithEngine( + solidEngine, + inputQuery, + createSolidQueryContext(mixedSources) + ); + } catch (err) { + return err instanceof Error ? err : new Error(String(err)); + } +} + +async function executeSolidLinkTraversalQuery( + inputQuery: string, + mixedSources: ComunicaSources[] +): Promise { + try { + return await executeWithEngine( + new SolidLinkTraversalQueryEngine(), + inputQuery, + createSolidQueryContext(mixedSources) + ); } catch (err) { + return err instanceof Error ? err : new Error(String(err)); + } +} + +/** + * Executes a SPARQL query with a pod cache conntected. + * + * @param inputQuery The string representation of a SPARQL query to be executed. + * @param mixedSources a ComunicaSources[] that provides the Solid Pod or SPARQL Endpoint sources for executing the specified query. + * @param cachePath a string designating the user's query cache url + * @returns either: + * a) a CacheOutput object (containing cache provenance + query results) + * b) a string indicating no cache was used + * c) null if there was an error + */ +async function sparqlQueryWithCache( + inputQuery: string, + mixedSources: ComunicaSources[], + cachePath: string, + queryMode: QueryExecutionMode = "endpoint" +): Promise { + // Current remote-cache engine is endpoint-focused; non-endpoint modes bypass + // cache-hit probing and fall back to direct execution in the caller. + if (queryMode !== "endpoint") { return "no-cache"; } + return executeEndpointCacheLookup(inputQuery, mixedSources, cachePath); } /** - * Executes a SPARQL query over a list of provided Solid Pod URLs. + * Executes a SPARQL query in a woker thread. * * @param inputQuery The string representation of a SPARQL query to be executed. * @param mixedSources a ComunicaSources[] that provides the Solid Pod or SPARQL Endpoint sources for executing the specified query. @@ -463,85 +583,17 @@ export async function executeQueryInMainThread( mixedSources: ComunicaSources[], queryMode: QueryExecutionMode = "solid-no-traversal" ): Promise { - let mySparqlEngine: { queryBindings: Function }; if (queryMode === "solid-link-traversal") { - try { - const traversalModuleName = - "@comunica/" + "query-sparql-link-traversal-solid"; - const traversalModule = await import(traversalModuleName); - mySparqlEngine = new traversalModule.QueryEngine(); - } catch { - return new Error( - "Link-traversal mode requires @comunica/query-sparql-link-traversal-solid. Install it and try again." - ); - } - } else { - mySparqlEngine = new SolidQueryEngine(); + return executeSolidLinkTraversalQuery(inputQuery, mixedSources); } - const fetchForCache = createCoiFetch(fetch, { - coepCredentialless: false, - passthroughOpaque: true, - noCors: true, - }); - try { - // Query executor using Comunica - const bindingsStream = await mySparqlEngine.queryBindings(inputQuery, { - lenient: true, - sources: mixedSources, - fetch: fetchForCache, - }); - - // Displays the results of the query - const bindingsArray: any[] = []; - let firstBinding: Bindings | undefined = undefined; - - // Process each binding from the stream. - for await (const binding of bindingsStream) { - // Capture the variable names from the first binding. - if (!firstBinding) { - firstBinding = binding; - } - const bindingObj: Record = {}; - binding.forEach((term, variable) => { - let termType: string; - switch (term.termType) { - case "Literal": - termType = "literal"; - break; - case "NamedNode": - termType = "uri"; - break; - case "BlankNode": - termType = "bnode"; - break; - default: - termType = term.termType.toLowerCase(); - } - bindingObj[variable.value] = { type: termType, value: term.value }; - }); - bindingsArray.push(bindingObj); - } - - // If there were no results, use an empty array of variables. - const vars = firstBinding - ? Array.from(firstBinding.keys()).map((variable) => variable.value) - : []; - - // results as an object - const resultsOutput: QueryResultJson = { - head: { vars }, - results: { bindings: bindingsArray }, - }; - - const returnVal: CacheOutput = { - provenanceOutput: null, - resultsOutput: resultsOutput, - }; - return returnVal; - } catch (err) { - return err; + if (queryMode === "solid-no-traversal") { + return executeSolidNoTraversalQuery(inputQuery, mixedSources); } + + return new Error( + `Main-thread query execution is only supported for Solid modes. Received "${queryMode}".` + ); } /** diff --git a/src/services/solid/login.ts b/src/services/solid/login.ts index d7740a1..75a2cd6 100644 --- a/src/services/solid/login.ts +++ b/src/services/solid/login.ts @@ -13,6 +13,19 @@ Crucially, stores credentials in session and fetch objects. */ export const session: Session = getDefaultSession() +/** + * Builds a redirect URL that is valid for Solid OIDC login callbacks. + * Hash fragments and reserved OIDC params are removed from the callback URL, + * while the full original page URL is preserved separately in sessionStorage. + */ +function buildSafeLoginRedirectUrl(currentHref: string): string { + const currentUrl = new URL(currentHref, window.location.origin); + currentUrl.hash = ""; + currentUrl.searchParams.delete("code"); + currentUrl.searchParams.delete("state"); + return currentUrl.toString(); +} + /** * Begins the User login process via the login() method from @inrupt/solid-client by following a Pod Provider URL link. * @@ -25,10 +38,11 @@ export async function startLogin(purl: string): Promise { if (!session.info.isLoggedIn) { try { sessionStorage.setItem("postLoginRedirect", window.location.href); + const safeRedirectUrl = buildSafeLoginRedirectUrl(window.location.href); await session.login({ oidcIssuer: purl, - redirectUrl: window.location.href, + redirectUrl: safeRedirectUrl, clientName: "Solid Cockpit" }); } catch (error) { @@ -118,4 +132,3 @@ export async function handleRedirectAfterPageLoad(): Promise { console.error("Error during session restoration:", error); } } - diff --git a/tests/components/DataQueryExamples.test.ts b/tests/components/DataQueryExamples.test.ts index 8e28dd6..9cafaeb 100644 --- a/tests/components/DataQueryExamples.test.ts +++ b/tests/components/DataQueryExamples.test.ts @@ -51,6 +51,96 @@ describe("DataQuery sample query editing flow", () => { expect(editor.focus).toHaveBeenCalledTimes(1); }); + it("infers solid link-traversal mode from link-traversal filename prefixes", () => { + const vm = { + queryModes: [ + { id: "endpoint" }, + { id: "solid-no-traversal" }, + { id: "solid-link-traversal" }, + ], + inferModeFromExampleId: + componentOptions.methods.inferModeFromExampleId, + isLikelySparqlEndpointSource: () => false, + isLikelySolidOrRdfSource: () => true, + }; + + const standardPrefix = componentOptions.methods.determineExampleQueryMode.call( + vm, + "SELECT * WHERE { ?s ?p ?o }", + [], + undefined, + "link-traversal-solidbench-1", + ); + const legacyTypoPrefix = + componentOptions.methods.determineExampleQueryMode.call( + vm, + "SELECT * WHERE { ?s ?p ?o }", + [], + undefined, + "link-taversal-demo", + ); + + expect(standardPrefix).toBe("solid-link-traversal"); + expect(legacyTypoPrefix).toBe("solid-link-traversal"); + }); + + it("categorizes federated-prefixed examples as Federated query", () => { + const vm = { + isLikelySparqlEndpointSource: () => true, + normalizeSourceUrlForValidation: (value: string) => value, + }; + + const category = componentOptions.methods.categorizeExampleQuery.call( + vm, + "SELECT * WHERE { ?s ?p ?o }", + ["https://query.wikidata.org/sparql"], + "endpoint", + "federated-example", + ); + + expect(category).toBe("Federated query"); + }); + + it("loads link-traversal examples with empty datasources", () => { + const editor = { + setValue: vi.fn(), + setCursor: vi.fn(), + focus: vi.fn(), + }; + + const linkTraversalExample = { + id: "link-traversal-solidbench-1", + name: "Link Traversal Example", + mode: "solid-link-traversal", + category: "Solid query (link traversal)", + description: "desc", + query: "SELECT * WHERE { ?s ?p ?o } LIMIT 10", + // Even if the example metadata provides sources, UI load should blank them. + sources: [""], + }; + + const vm = { + yasqe: editor, + exampleQueries: [linkTraversalExample], + availableExampleQueries: [linkTraversalExample], + queryMode: "endpoint", + currentQuery: { + query: "", + sources: [] as string[], + }, + syncYasqeFromExternalQuery: (query: string) => { + editor.setValue(query); + editor.setCursor({ line: 0, ch: 0 }); + editor.focus(); + }, + }; + + componentOptions.methods.onSelectExample.call(vm, linkTraversalExample.id); + + expect(vm.queryMode).toBe("solid-link-traversal"); + expect(vm.currentQuery.sources).toEqual([]); + }); + it("keeps one-way sync by never writing query watcher changes back to YASQE", () => { const editor = { setValue: vi.fn(), diff --git a/tests/unit/login.test.ts b/tests/unit/login.test.ts index b6b6c9d..9d9a9ed 100644 --- a/tests/unit/login.test.ts +++ b/tests/unit/login.test.ts @@ -77,6 +77,27 @@ test("startLogin stores redirect and calls session.login when logged out", async ); }); +test("startLogin strips hash and reserved OIDC params from redirectUrl", async () => { + (globalThis as any).window.location.href = + "https://example.org/dataQuery?foo=bar&code=abc123&state=xyz#query=SELECT%20*%20WHERE%20%7B%20?s%20?p%20?o%20%7D"; + + let capturedRedirectUrl = ""; + session.login = (async (options: any) => { + capturedRedirectUrl = options.redirectUrl; + }) as any; + + const status = await startLogin("https://issuer.example"); + assert.equal(status, ""); + assert.equal( + capturedRedirectUrl, + "https://example.org/dataQuery?foo=bar" + ); + assert.equal( + (globalThis as any).sessionStorage.getItem("postLoginRedirect"), + "https://example.org/dataQuery?foo=bar&code=abc123&state=xyz#query=SELECT%20*%20WHERE%20%7B%20?s%20?p%20?o%20%7D" + ); +}); + test("startLogin is a no-op when already logged in", async () => { session.info.isLoggedIn = true; let callCount = 0; diff --git a/tests/unit/queryModeValidation.test.ts b/tests/unit/queryModeValidation.test.ts index e0afcfe..039d960 100644 --- a/tests/unit/queryModeValidation.test.ts +++ b/tests/unit/queryModeValidation.test.ts @@ -50,3 +50,9 @@ test("solid traversal mode accepts Solid-like document/container targets", () => validateQuerySourcesForMode("solid-link-traversal", sources) ); }); + +test("solid traversal mode allows empty source lists", () => { + assert.doesNotThrow(() => + validateQuerySourcesForMode("solid-link-traversal", []) + ); +});