buildSearchUrl(String baseUrl, String defaultUrl, String title, @Nullable String author, String serviceName, boolean addAuthorIndex) {
+ // Converting LaTeX-formatted titles (e.g., containing braces) to plain Unicode to ensure compatibility with ShortScience's search URL.
+ // LatexToUnicodeAdapter.format() is being used because it attempts to parse LaTeX, but gracefully degrades to a normalized title on failure.
+ // This avoids sending malformed or literal LaTeX syntax titles that would give the wrong result.
+ String filteredTitle = LatexToUnicodeAdapter.format(title);
+
+ // Validate the base URL scheme to prevent injection attacks
+ // We cannot use URLUtil#isValidHttpUrl here as {title} placeholder will throw URISyntaxException till replaced (below)
+ String lowerUrl = baseUrl.toLowerCase().trim();
+ if (StringUtil.isBlank(lowerUrl) || !(lowerUrl.startsWith("http://") || lowerUrl.startsWith("https://"))) {
+ LOGGER.warn("Invalid URL scheme in {} preference: {}. Using default URL.", serviceName, baseUrl);
+ return buildUrlWithQueryParams(defaultUrl, filteredTitle, author, serviceName, addAuthorIndex);
+ }
+
+ // If URL doesn't contain {title}, it's not a valid template, use query parameters
+ if (!baseUrl.contains("{title}")) {
+ LOGGER.warn("URL template for {} doesn't contain {{title}} placeholder. Using query parameters.", serviceName);
+ return buildUrlWithQueryParams(defaultUrl, filteredTitle, author, serviceName, addAuthorIndex);
+ }
+
+ // Replace placeholders with URL-encoded values
+ try {
+ String encodedTitle = URLEncoder.encode(filteredTitle.trim(), StandardCharsets.UTF_8);
+ String urlWithTitle = baseUrl.replace("{title}", encodedTitle);
+ String finalUrl;
+
+ if (author != null) {
+ String encodedAuthor = URLEncoder.encode(author.trim(), StandardCharsets.UTF_8);
+ finalUrl = urlWithTitle.replace("{author}", encodedAuthor);
+ } else {
+ // Remove the {author} placeholder if no author is present
+ finalUrl = urlWithTitle.replace("{author}", "");
}
- // Converting LaTeX-formatted titles (e.g., containing braces) to plain Unicode to ensure compatibility with ShortScience's search URL.
- // LatexToUnicodeAdapter.format() is being used because it attempts to parse LaTeX, but gracefully degrades to a normalized title on failure.
- // This avoids sending malformed or literal LaTeX syntax titles that would give the wrong result.
- String filteredTitle = LatexToUnicodeAdapter.format(title);
- // Direct the user to the search results for the title.
- uriBuilder.addParameter("q", filteredTitle.trim());
- return uriBuilder.toString();
- });
+ // Validate the final constructed URL
+ if (URLUtil.isValidHttpUrl(finalUrl)) {
+ return Optional.of(finalUrl);
+ } else {
+ LOGGER.warn("Constructed URL for {} is invalid: {}. Using default URL.", serviceName, finalUrl);
+ return buildUrlWithQueryParams(defaultUrl, filteredTitle, author, serviceName, addAuthorIndex);
+ }
+ } catch (Exception ex) {
+ LOGGER.error("Error constructing URL for {}: {}", serviceName, ex.getMessage(), ex);
+ return buildUrlWithQueryParams(defaultUrl, filteredTitle, author, serviceName, addAuthorIndex);
+ }
+ }
+
+ /**
+ * Builds a URL using query parameters (fallback method).
+ *
+ * The parameter addAuthorIndex is used for Semantic Scholar service because it does not understand "author=XYZ", but it uses "author[0]=XYZ&author[1]=ABC".
+ */
+ private Optional buildUrlWithQueryParams(String baseUrl, String title, @Nullable String author, String serviceName, boolean addAuthorIndex) {
+ try {
+ URIBuilder uriBuilder = new URIBuilder(baseUrl);
+ // Title is already converted to Unicode by buildSearchUrl before reaching here
+ uriBuilder.addParameter("q", title.trim());
+ if (author != null) {
+ if (addAuthorIndex) {
+ AuthorListParser authorListParser = new AuthorListParser();
+ AuthorList authors = authorListParser.parse(author);
+
+ int idx = 0;
+ for (Author authorObject : authors) {
+ uriBuilder.addParameter("author[" + idx + "]", authorObject.getNameForAlphabetization());
+ ++idx;
+ }
+ } else {
+ uriBuilder.addParameter("author", author.trim());
+ }
+ }
+ return Optional.of(uriBuilder.toString());
+ } catch (URISyntaxException ex) {
+ LOGGER.error("Failed to construct {} URL: {}", serviceName, ex.getMessage());
+ return Optional.empty();
+ }
}
}
diff --git a/jablib/src/main/java/org/jabref/logic/util/URLUtil.java b/jablib/src/main/java/org/jabref/logic/util/URLUtil.java
index 7cd09569e75..d5e4374d099 100644
--- a/jablib/src/main/java/org/jabref/logic/util/URLUtil.java
+++ b/jablib/src/main/java/org/jabref/logic/util/URLUtil.java
@@ -9,7 +9,9 @@
import java.util.regex.Pattern;
import org.jabref.logic.util.io.FileUtil;
+import org.jabref.logic.util.strings.StringUtil;
+import org.apache.hc.core5.net.URIBuilder;
import org.jspecify.annotations.NonNull;
/// URL utilities for URLs in the JabRef logic.
@@ -138,7 +140,6 @@ public static boolean isURL(String url) {
* @param url the URL string to be converted into a {@link URI}.
* @return the {@link URI} object created from the string URL.
* @throws IllegalArgumentException if the string URL is not a valid URI or if the URI format is incorrect.
- * @throws URISyntaxException if the string URL has an invalid syntax and cannot be converted into a {@link URI}.
*/
public static URI createUri(String url) {
try {
@@ -164,4 +165,23 @@ public static String getFileNameFromUrl(String url) {
}
return FileUtil.getValidFileName(fileName);
}
+
+ /**
+ * Validates that a constructed URL is valid and conforms to RFC 3986 URI syntax (updated RFC 2396).
+ * And also validates that it has an HTTP or HTTPS scheme to prevent injection attacks.
+ * Does not perform complex checks such as opening connections.
+ */
+ public static boolean isValidHttpUrl(String url) {
+ try {
+ if (StringUtil.isBlank(url)) {
+ return false;
+ }
+
+ new URIBuilder(url);
+ String lowerUrl = url.toLowerCase().trim();
+ return lowerUrl.startsWith("http://") || lowerUrl.startsWith("https://");
+ } catch (URISyntaxException ex) {
+ return false;
+ }
+ }
}
diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties
index e9126cce139..49bac18a8d5 100644
--- a/jablib/src/main/resources/l10n/JabRef_en.properties
+++ b/jablib/src/main/resources/l10n/JabRef_en.properties
@@ -1495,6 +1495,7 @@ Save\ all=Save all
Synchronize\ files=Synchronize files
Unabbreviate=Unabbreviate
should\ contain\ a\ protocol=should contain a protocol
+invalid\ URL\ format=invalid URL format
Copy\ preview=Copy preview
Copy\ selection=Copy selection
Automatically\ setting\ file\ links=Automatically setting file links
@@ -2309,9 +2310,19 @@ Remove\ the\ current\ word\ backwards=Remove the current word backwards
Text\ editor=Text editor
+Search\ Google\ Scholar=Search Google Scholar
+Unable\ to\ open\ Google\ Scholar.=Unable to open Google Scholar.
+
+Search\ Semantic\ Scholar=Search Semantic Scholar
+Unable\ to\ open\ Semantic\ Scholar.=Unable to open Semantic Scholar.
+
Search\ ShortScience=Search ShortScience
Unable\ to\ open\ ShortScience.=Unable to open ShortScience.
+Search\ Engine=Search Engine
+URL\ Template=URL Template
+Search\ Engine\ URL\ Templates=Search Engine URL Templates
+
Shared\ database=Shared database
Lookup=Lookup
diff --git a/jablib/src/test/java/org/jabref/logic/net/URLUtilTest.java b/jablib/src/test/java/org/jabref/logic/net/URLUtilTest.java
index dcba77b9b23..505e57b22bb 100644
--- a/jablib/src/test/java/org/jabref/logic/net/URLUtilTest.java
+++ b/jablib/src/test/java/org/jabref/logic/net/URLUtilTest.java
@@ -163,4 +163,61 @@ void malformedSyntax() {
URLUtil.create("http://[invalid-url]"));
assertTrue(exception.getMessage().contains("Invalid URI"));
}
+
+ @ParameterizedTest
+ @CsvSource(
+ textBlock = """
+ http://www.google.com
+ https://www.google.com
+ http://example.com
+ https://example.com
+ http://example.com/path
+ https://example.com/path/to/resource
+ http://example.com:8080
+ https://example.com:443/path?query=value
+ HTTP://EXAMPLE.COM
+ HTTPS://EXAMPLE.COM
+ http://example.com/path/to/resource
+ https://sub.example.com
+ """
+ )
+ void isValidHttpUrlShouldAcceptValidHttpUrls(String url) {
+ assertTrue(URLUtil.isValidHttpUrl(url));
+ }
+
+ @ParameterizedTest
+ @CsvSource(
+ textBlock = """
+ ftp://example.com
+ file:///path/to/file
+ mailto:someone@example.com
+ www.google.com
+ example.com
+ //example.com
+ http
+ https
+ http://example.com/test|file
+ http://example.com/test<>file
+ http://example.com/test{file}
+ http://[invalid-url]
+ """
+ )
+ void isValidHttpUrlShouldRejectNonHttpUrls(String url) {
+ assertFalse(URLUtil.isValidHttpUrl(url));
+ }
+
+ @Test
+ void isValidHttpUrlShouldRejectNull() {
+ assertFalse(URLUtil.isValidHttpUrl(null));
+ }
+
+ @Test
+ void isValidHttpUrlShouldRejectEmptyString() {
+ assertFalse(URLUtil.isValidHttpUrl(""));
+ }
+
+ @Test
+ void isValidHttpUrlShouldRejectBlankString() {
+ assertFalse(URLUtil.isValidHttpUrl(" "));
+ }
}
diff --git a/jablib/src/test/java/org/jabref/logic/util/ExternalLinkCreatorTest.java b/jablib/src/test/java/org/jabref/logic/util/ExternalLinkCreatorTest.java
index 2f1165f84cf..bbac81147c1 100644
--- a/jablib/src/test/java/org/jabref/logic/util/ExternalLinkCreatorTest.java
+++ b/jablib/src/test/java/org/jabref/logic/util/ExternalLinkCreatorTest.java
@@ -1,80 +1,390 @@
package org.jabref.logic.util;
-import java.net.MalformedURLException;
+import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
+import org.jabref.logic.importer.ImporterPreferences;
import org.jabref.model.entry.BibEntry;
import org.jabref.model.entry.field.StandardField;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
-import static org.jabref.logic.util.ExternalLinkCreator.getShortScienceSearchURL;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
class ExternalLinkCreatorTest {
+ private static final String DEFAULT_GOOGLE_SCHOLAR_URL = "https://scholar.google.com/scholar";
+ private static final String DEFAULT_SEMANTIC_SCHOLAR_URL = "https://www.semanticscholar.org/search";
+ private static final String DEFAULT_SHORTSCIENCE_URL = "https://www.shortscience.org/internalsearch";
- /**
- * Validates URL conformance to RFC2396. Does not perform complex checks such as opening connections.
- */
- private boolean urlIsValid(String url) {
- try {
- // This will throw on non-compliance to RFC2396.
- URLUtil.create(url);
- return true;
- } catch (MalformedURLException e) {
- return false;
- }
+ private ImporterPreferences mockPreferences;
+ private ExternalLinkCreator linkCreator;
+
+ @BeforeEach
+ void setUp() {
+ mockPreferences = mock(ImporterPreferences.class);
+ // By default, assume no custom templates are set
+ when(mockPreferences.getSearchEngineUrlTemplates()).thenReturn(Map.of());
+ linkCreator = new ExternalLinkCreator(mockPreferences);
+ }
+
+ private BibEntry createEntryWithTitle(String title) {
+ return new BibEntry().withField(StandardField.TITLE, title);
+ }
+
+ private BibEntry createEntryWithTitleAndAuthor(String title, String author) {
+ return new BibEntry()
+ .withField(StandardField.TITLE, title)
+ .withField(StandardField.AUTHOR, author);
}
static Stream specialCharactersProvider() {
return Stream.of(
- Arguments.of("!*'();:@&=+$,/?#[]")
+ Arguments.of("!*'();:@&=+$,/?#[]"),
+ Arguments.of("100% Complete Research"),
+ Arguments.of("C++ Programming"),
+ Arguments.of("Research & Development")
);
}
- @ParameterizedTest
- @MethodSource("specialCharactersProvider")
- void getShortScienceSearchURLEncodesSpecialCharacters(String title) {
- BibEntry entry = new BibEntry();
- entry.setField(StandardField.TITLE, title);
- Optional url = getShortScienceSearchURL(entry);
- assertTrue(url.isPresent());
- assertTrue(urlIsValid(url.get()));
+ @Nested
+ class GoogleScholarTests {
+
+ @Test
+ void getGoogleScholarSearchURLReturnsEmptyOnMissingTitle() {
+ BibEntry entry = new BibEntry();
+ assertEquals(Optional.empty(), linkCreator.getGoogleScholarSearchURL(entry));
+ }
+
+ @Test
+ void getGoogleScholarSearchURLReturnsEmptyOnEmptyString() {
+ BibEntry entry = createEntryWithTitle("");
+ assertEquals(Optional.empty(), linkCreator.getGoogleScholarSearchURL(entry));
+ }
+
+ @Test
+ void getGoogleScholarSearchURLRemovesLatexBraces() {
+ // Equivalent test for Google Scholar
+ BibEntry entry = createEntryWithTitle("{The Difference Between Graph-Based and Block-Structured Business Process Modelling Languages}");
+ Optional url = linkCreator.getGoogleScholarSearchURL(entry);
+
+ String expectedUrl = "https://scholar.google.com/scholar?q=The%20Difference%20Between%20Graph-Based%20and%20Block-Structured%20Business%20Process%20Modelling%20Languages";
+ assertEquals(Optional.of(expectedUrl), url);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "' ', 'https://scholar.google.com/scholar?q='",
+ "' ', 'https://scholar.google.com/scholar?q=%C2%A0%20%C2%A0'"
+ })
+ void getGoogleScholarSearchURLHandlesWhitespace(String title, String expectedUrl) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getGoogleScholarSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertEquals(expectedUrl, url.get());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // Expect %20
+ "'JabRef bibliography management', 'https://scholar.google.com/scholar?q=JabRef%20bibliography%20management'",
+ "'Machine learning', 'https://scholar.google.com/scholar?q=Machine%20learning'"
+ })
+ void getGoogleScholarSearchURLLinksToSearchResults(String title, String expectedUrl) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getGoogleScholarSearchURL(entry);
+
+ assertEquals(Optional.of(expectedUrl), url);
+ assertTrue(url.get().startsWith(DEFAULT_GOOGLE_SCHOLAR_URL));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"!*'();:@&=+$,/?#[]", "100% Complete", "Question?"})
+ void getGoogleScholarSearchURLEncodesSpecialCharacters(String title) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getGoogleScholarSearchURL(entry);
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ }
+
+ @Test
+ void getGoogleScholarSearchURLIncludesAuthor() {
+ BibEntry entry = createEntryWithTitleAndAuthor("Quantum Computing", "Alice Smith");
+ Optional url = linkCreator.getGoogleScholarSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ assertTrue(url.get().contains("author=Alice%20Smith"));
+ }
}
- @ParameterizedTest
- @CsvSource({
- "'歷史書 📖 📚', 'https://www.shortscience.org/internalsearch?q=%E6%AD%B7%E5%8F%B2%E6%9B%B8%20%F0%9F%93%96%20%F0%9F%93%9A'",
- "' History Textbook ', 'https://www.shortscience.org/internalsearch?q=History%20Textbook'",
- "'History%20Textbook', 'https://www.shortscience.org/internalsearch?q=History%2520Textbook'",
- "'JabRef bibliography management', 'https://www.shortscience.org/internalsearch?q=JabRef%20bibliography%20management'"
- })
- void getShortScienceSearchURLEncodesCharacters(String title, String expectedUrl) {
- BibEntry entry = new BibEntry().withField(StandardField.TITLE, title);
- Optional url = getShortScienceSearchURL(entry);
- assertEquals(Optional.of(expectedUrl), url);
+ @Nested
+ class SemanticScholarTests {
+
+ @Test
+ void getSemanticScholarSearchURLReturnsEmptyOnMissingTitle() {
+ BibEntry entry = new BibEntry();
+ assertEquals(Optional.empty(), linkCreator.getSemanticScholarSearchURL(entry));
+ }
+
+ @Test
+ void getSemanticScholarSearchURLReturnsEmptyOnEmptyString() {
+ BibEntry entry = createEntryWithTitle("");
+ assertEquals(Optional.empty(), linkCreator.getSemanticScholarSearchURL(entry));
+ }
+
+ @Test
+ void getSemanticScholarSearchURLRemovesLatexBraces() {
+ // Equivalent test for Semantic Scholar
+ BibEntry entry = createEntryWithTitle("{The Difference Between Graph-Based and Block-Structured Business Process Modelling Languages}");
+ Optional url = linkCreator.getSemanticScholarSearchURL(entry);
+
+ String expectedUrl = "https://www.semanticscholar.org/search?q=The%20Difference%20Between%20Graph-Based%20and%20Block-Structured%20Business%20Process%20Modelling%20Languages";
+ assertEquals(Optional.of(expectedUrl), url);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "' ', 'https://www.semanticscholar.org/search?q='",
+ "' ', 'https://www.semanticscholar.org/search?q=%C2%A0%20%C2%A0'"
+ })
+ void getSemanticScholarSearchURLHandlesWhitespace(String title, String expectedUrl) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getSemanticScholarSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertEquals(expectedUrl, url.get());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // Expect %20
+ "'JabRef bibliography management', 'https://www.semanticscholar.org/search?q=JabRef%20bibliography%20management'",
+ "'Machine learning', 'https://www.semanticscholar.org/search?q=Machine%20learning'"
+ })
+ void getSemanticScholarSearchURLLinksToSearchResults(String title, String expectedUrl) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getSemanticScholarSearchURL(entry);
+
+ assertEquals(Optional.of(expectedUrl), url);
+ assertTrue(url.get().startsWith(DEFAULT_SEMANTIC_SCHOLAR_URL));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"!*'();:@&=+$,/?#[]", "100% Complete", "Question?"})
+ void getSemanticScholarSearchURLEncodesSpecialCharacters(String title) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getSemanticScholarSearchURL(entry);
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ }
+
+ @Test
+ void getSemanticScholarSearchURLIncludesAuthor() {
+ BibEntry entry = createEntryWithTitleAndAuthor("Quantum Computing", "Alice Smith");
+ Optional url = linkCreator.getSemanticScholarSearchURL(entry);
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ assertTrue(url.get().contains("author%5B0%5D=Smith%2C%20A."));
+ }
+
+ @Test
+ void getSemanticScholarSearchURLIncludesSeveralAuthors() {
+ BibEntry entry = createEntryWithTitleAndAuthor("Quantum Computing", "Alice Smith and Bob Jones");
+ Optional url = linkCreator.getSemanticScholarSearchURL(entry);
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ assertTrue(url.get().contains("author%5B0%5D=Smith%2C%20A.&author%5B1%5D=Jones%2C%20B."));
+ }
}
- @Test
- void getShortScienceSearchURLReturnsEmptyOnMissingTitle() {
- BibEntry entry = new BibEntry();
- assertEquals(Optional.empty(), getShortScienceSearchURL(entry));
+ @Nested
+ class ShortScienceTests {
+
+ @Test
+ void getShortScienceSearchURLReturnsEmptyOnMissingTitle() {
+ BibEntry entry = new BibEntry();
+ assertEquals(Optional.empty(), linkCreator.getShortScienceSearchURL(entry));
+ }
+
+ @Test
+ void getShortScienceSearchURLReturnsEmptyOnEmptyString() {
+ // BibEntry treats "" as a missing field, so we expect Empty
+ BibEntry entry = createEntryWithTitle("");
+ assertEquals(Optional.empty(), linkCreator.getShortScienceSearchURL(entry));
+ }
+
+ @Test
+ void getShortScienceSearchURLRemovesLatexBraces() {
+ BibEntry entry = createEntryWithTitle("{The Difference Between Graph-Based and Block-Structured Business Process Modelling Languages}");
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ // URIBuilder encodes spaces as %20, braces should be gone
+ String expectedUrl = "https://www.shortscience.org/internalsearch?q=The%20Difference%20Between%20Graph-Based%20and%20Block-Structured%20Business%20Process%20Modelling%20Languages";
+ assertEquals(Optional.of(expectedUrl), url);
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // Standard space: Java trim() removes it -> "q="
+ "' ', 'https://www.shortscience.org/internalsearch?q='",
+
+ // Non-Breaking Space (NBSP): Java trim() keeps it -> "q=%C2%A0%20%C2%A0"
+ // (Note: URIBuilder encodes NBSP as %C2%A0 and the middle regular space as %20)
+ "' ', 'https://www.shortscience.org/internalsearch?q=%C2%A0%20%C2%A0'"
+ })
+ void getShortScienceSearchURLHandlesWhitespace(String title, String expectedUrl) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertEquals(expectedUrl, url.get());
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // Standard cases using URIBuilder (%20 encoding)
+ "'JabRef bibliography management', 'https://www.shortscience.org/internalsearch?q=JabRef%20bibliography%20management'",
+ "'Machine learning', 'https://www.shortscience.org/internalsearch?q=Machine%20learning'",
+ })
+ void getShortScienceSearchURLLinksToSearchResults(String title, String expectedUrl) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertEquals(Optional.of(expectedUrl), url);
+ assertTrue(url.get().startsWith(DEFAULT_SHORTSCIENCE_URL));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ // Unicode characters
+ "'歷史書 📖 📚', 'q=%E6%AD%B7%E5%8F%B2%E6%9B%B8%20%F0%9F%93%96%20%F0%9F%93%9A'",
+ // Mixed NBSP and standard space
+ "' History Textbook ', 'q=%C2%A0%20%C2%A0%20History%20Textbook%C2%A0%20%C2%A0'",
+ // Literal symbols
+ "'History%20Textbook', 'q=History%2520Textbook'",
+ "'A&B Research', 'q=A%26B%20Research'"
+ })
+ void getShortScienceSearchURLEncodesCharacters(String title, String expectedQueryPart) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ assertTrue(url.get().contains(expectedQueryPart));
+ }
+
+ @ParameterizedTest
+ @MethodSource("org.jabref.logic.util.ExternalLinkCreatorTest#specialCharactersProvider")
+ void getShortScienceSearchURLEncodesSpecialCharacters(String title) {
+ BibEntry entry = createEntryWithTitle(title);
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ }
+
+ @Test
+ void getShortScienceSearchURLIncludesAuthor() {
+ BibEntry entry = createEntryWithTitleAndAuthor("Neural Networks", "John Doe");
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ // Expect %20 for space
+ assertTrue(url.get().contains("author=John%20Doe"));
+ }
+
+ @ParameterizedTest
+ @CsvSource({
+ "'Machine Learning', 'Smith & Jones', 'author=Smith%20%26%20Jones'",
+ "'Deep Learning', '李明', 'author=%E6%9D%8E%E6%98%8E'"
+ })
+ void getShortScienceSearchURLEncodesAuthorNames(String title, String author, String expectedAuthorEncoding) {
+ BibEntry entry = createEntryWithTitleAndAuthor(title, author);
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ assertTrue(url.get().contains(expectedAuthorEncoding));
+ }
+ }
+
+ @Nested
+ class CustomTemplateTests {
+
+ @Test
+ void usesCustomTemplateWithTitlePlaceholder() {
+ // Manual template logic uses URLEncoder, so we expect '+'
+ when(mockPreferences.getSearchEngineUrlTemplates())
+ .thenReturn(Map.of("Short Science", "https://custom.com/search?title={title}"));
+
+ BibEntry entry = createEntryWithTitle("Test Title");
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertEquals("https://custom.com/search?title=Test+Title", url.get());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ }
+
+ @Test
+ void fallsBackWhenTemplateMissingTitlePlaceholder() {
+ // Fallback logic uses URIBuilder, so we expect '%20'
+ when(mockPreferences.getSearchEngineUrlTemplates())
+ .thenReturn(Map.of("Short Science", "https://custom.com/search"));
+
+ BibEntry entry = createEntryWithTitle("Test Title");
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertTrue(url.get().startsWith(DEFAULT_SHORTSCIENCE_URL));
+ assertTrue(url.get().contains("q=Test%20Title"));
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ }
}
- @Test
- void getShortScienceSearchURLWithoutLaTeX() {
- BibEntry entry = new BibEntry();
- entry.withField(StandardField.TITLE, "{The Difference Between Graph-Based and Block-Structured Business Process Modelling Languages}");
+ @Nested
+ class SecurityTests {
+ @ParameterizedTest
+ @ValueSource(strings = {
+ "mailto:test@example.com",
+ "javascript:alert('xss')",
+ "file:///etc/passwd",
+ "ftp://malicious.com"
+ })
+ void rejectsNonHttpSchemesAndFallsBack(String maliciousUrl) {
+ when(mockPreferences.getSearchEngineUrlTemplates())
+ .thenReturn(Map.of("Short Science", maliciousUrl + "?q={title}"));
- Optional url = getShortScienceSearchURL(entry);
+ BibEntry entry = createEntryWithTitle("Test");
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
- String expectedUrl = "https://www.shortscience.org/internalsearch?q=The%20Difference%20Between%20Graph-Based%20and%20Block-Structured%20Business%20Process%20Modelling%20Languages";
- assertEquals(Optional.of(expectedUrl), url);
+ assertTrue(url.isPresent());
+ // Must fall back to the default secure HTTPS URL
+ assertTrue(url.get().startsWith("https://"));
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+ }
+
+ @Test
+ void handlesSqlInjectionAttempts() {
+ BibEntry entry = createEntryWithTitle("'; DROP TABLE entries; --");
+ Optional url = linkCreator.getShortScienceSearchURL(entry);
+
+ assertTrue(url.isPresent());
+ assertTrue(URLUtil.isValidHttpUrl(url.get()));
+
+ // URIBuilder encodes safely (%20)
+ assertTrue(url.get().contains("q=%27%3B%20DROP%20TABLE%20entries%3B%20%E2%80%93"));
+ }
}
}