diff --git a/dependencies-lock.json b/dependencies-lock.json index 428e37b5300..99acb8663e0 100644 --- a/dependencies-lock.json +++ b/dependencies-lock.json @@ -255,6 +255,14 @@ "type" : "jar", "optional" : false, "integrity" : "sha512:ak529k22lHqP1DCq+VRYn/k+WKRVFbfX2LPZlrBBSh6rRgD6olc8zroC3xwa2UkhjFSvQFFEUa7y3MbjkY24sQ==" + }, { + "groupId" : "com.github.jsqlparser", + "artifactId" : "jsqlparser", + "version" : "5.3", + "scope" : "compile", + "type" : "jar", + "optional" : false, + "integrity" : "sha512:YLJ1qDvLVOE0hRuDwPgQLT4mPEZjZnlObat+LOk5vNLw0dEZOkWdT+c6wYgSvyXpRPw7+iO2YK0XgVcYWESegA==" }, { "groupId" : "com.github.librepdf.openpdf", "artifactId" : "openpdf-html", diff --git a/pom.xml b/pom.xml index 4c9216803bd..057bd318f15 100644 --- a/pom.xml +++ b/pom.xml @@ -129,6 +129,8 @@ 1.4.0 4.5.0-jakarta + + 5.3 5.19.0 @@ -445,6 +447,20 @@ commons-csv 1.14.1 + + + + com.github.jsqlparser + jsqlparser + ${jsqlparser.version} + + + + org.openjdk.jmh + jmh-core + + + @@ -1446,6 +1462,13 @@ org.jacoco jacoco-maven-plugin 0.8.14 + + + + net.sf.jsqlparser.* + + diff --git a/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDao.java b/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDao.java index 98a84641b41..1ff587ddc1e 100644 --- a/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDao.java +++ b/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDao.java @@ -36,7 +36,15 @@ import io.github.carlos_emr.carlos.commn.model.ReportByExamplesFavorite; public interface ReportByExamplesFavoriteDao extends AbstractDao { - List findByQuery(String query); + /** + * Finds favorites owned by one provider whose saved SQL exactly matches the supplied query. + * + * @param providerNo owner provider number + * @param query exact saved query text + * @return matching favorites owned by the provider, or an empty list when none exist + * @since 2026-08-06 + */ + List findByProviderAndQuery(String providerNo, String query); List findByEverything(String providerNo, String favoriteName, String queryString); diff --git a/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoImpl.java b/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoImpl.java index 4bec1b1747d..9029125f9de 100644 --- a/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoImpl.java +++ b/src/main/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoImpl.java @@ -46,15 +46,16 @@ public ReportByExamplesFavoriteDaoImpl() { } @Override - public List findByQuery(String query) { - Query q = createQuery("ex", "ex.query LIKE ?1"); - q.setParameter(1, query); - return q.getResultList(); + public List findByProviderAndQuery(String providerNo, String queryString) { + Query query = createQuery("ex", "ex.providerNo = ?1 AND ex.query = ?2"); + query.setParameter(1, providerNo); + query.setParameter(2, queryString); + return query.getResultList(); } @Override public List findByEverything(String providerNo, String favoriteName, String queryString) { - Query query = createQuery("ex", "ex.providerNo = ?1 AND ex.name LIKE ?2 OR ex.query LIKE ?3"); + Query query = createQuery("ex", "ex.providerNo = ?1 AND ex.name = ?2 AND ex.query = ?3"); query.setParameter(1, providerNo); query.setParameter(2, favoriteName); query.setParameter(3, queryString); diff --git a/src/main/java/io/github/carlos_emr/carlos/db/LegacyJdbcQuery.java b/src/main/java/io/github/carlos_emr/carlos/db/LegacyJdbcQuery.java index 5d735d2c6fa..39a44c7c635 100644 --- a/src/main/java/io/github/carlos_emr/carlos/db/LegacyJdbcQuery.java +++ b/src/main/java/io/github/carlos_emr/carlos/db/LegacyJdbcQuery.java @@ -332,21 +332,22 @@ public static void validateSafeSelectQuery(String sql) throws SQLException { throw new SQLException("Potential SQL injection pattern detected"); } - if (containsSqlWord(normalized, "union")) { + String normalizedSqlSyntax = stripQuotedSqlSections(sql).toLowerCase(Locale.ROOT); + if (containsSqlWord(normalizedSqlSyntax, "union")) { throw new SQLException("Unsafe SQL detected: UNION not permitted"); } String[] blockedWords = {"insert", "update", "delete", "drop", "alter", "create", "truncate", "grant", "revoke", "exec", "execute", "call", "merge", "commit", "rollback"}; for (String word : blockedWords) { - if (containsSqlWord(normalized, word)) { + if (containsSqlWord(normalizedSqlSyntax, word)) { throw new SQLException("Unsafe SQL detected: prohibited keyword"); } } String[] blockedPhrases = {"into outfile", "into dumpfile", "load_file", "load data"}; for (String phrase : blockedPhrases) { - if (normalized.contains(phrase)) { + if (normalizedSqlSyntax.contains(phrase)) { throw new SQLException("Unsafe SQL detected: prohibited keyword"); } } @@ -417,6 +418,9 @@ private boolean containsUnsafeControlToken() { char current = sql.charAt(position); char next = nextChar(); if (insideQuotedLiteral()) { + if (isSqlModeDependentEscape(current)) { + return true; + } skipQuotedLiteralToken(current, next); } else if (opensQuotedLiteral(current)) { quote = current; @@ -436,15 +440,17 @@ private boolean insideQuotedLiteral() { } private void skipQuotedLiteralToken(char current, char next) { - if (quote != '`' && current == '\\' && next != '\0') { - position++; - } else if (current == quote && next == quote) { + if (current == quote && next == quote) { position++; } else if (current == quote) { quote = '\0'; } } + private boolean isSqlModeDependentEscape(char current) { + return quote != '`' && current == '\\'; + } + private static boolean opensQuotedLiteral(char current) { return current == '\'' || current == '"' || current == '`'; } @@ -615,6 +621,46 @@ private static boolean containsSqlWord(String sql, String word) { return false; } + /** + * Masks quoted sections while preserving input length for keyword checks. + * Backslash-containing string literals are rejected by the control-token scanner before this + * method is called because their boundaries depend on MySQL's {@code NO_BACKSLASH_ESCAPES} mode. + * This intentionally differs from the Query-by-Example validator's scanner, which must match + * JSqlParser's configured parsing. The two scanners must not be merged. + */ + private static String stripQuotedSqlSections(String sql) { + StringBuilder stripped = new StringBuilder(sql.length()); + char quote = '\0'; + int i = 0; + while (i < sql.length()) { + char current = sql.charAt(i); + char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0'; + if (quote == '\0') { + quote = sqlQuoteDelimiter(current); + stripped.append(quote == '\0' ? current : ' '); + i++; + } else if (isSqlEscapedPair(quote, current, next)) { + stripped.append(" "); + i += 2; + } else { + if (current == quote) { + quote = '\0'; + } + stripped.append(' '); + i++; + } + } + return stripped.toString(); + } + + private static boolean isSqlEscapedPair(char quote, char current, char next) { + return current == quote && next == quote; + } + + private static char sqlQuoteDelimiter(char candidate) { + return candidate == '\'' || candidate == '"' || candidate == '`' ? candidate : '\0'; + } + private static boolean startsWithSqlWord(String sql, String word) { return sql.startsWith(word) && (sql.length() == word.length() || !isSqlIdentifierPart(sql.charAt(word.length()))); diff --git a/src/main/java/io/github/carlos_emr/carlos/report/bean/RptByExampleQueryBean.java b/src/main/java/io/github/carlos_emr/carlos/report/bean/RptByExampleQueryBean.java index ab18d5069f4..65d4d09b2e9 100644 --- a/src/main/java/io/github/carlos_emr/carlos/report/bean/RptByExampleQueryBean.java +++ b/src/main/java/io/github/carlos_emr/carlos/report/bean/RptByExampleQueryBean.java @@ -31,7 +31,6 @@ package io.github.carlos_emr.carlos.report.bean; import org.owasp.encoder.Encode; -import io.github.carlos_emr.carlos.utility.MiscUtils; public class RptByExampleQueryBean { @@ -52,7 +51,6 @@ public RptByExampleQueryBean(int id, String query, String queryName) { this.query = query; this.queryName = queryName; this.queryWithEscapeChar = Encode.forJavaScript(query); - MiscUtils.getLogger().debug("query with javascript escape char: " + queryWithEscapeChar); } public RptByExampleQueryBean(String providerLastName, String providerFirstName, String query, String date) { diff --git a/src/main/java/io/github/carlos_emr/carlos/report/data/QueryByExampleSqlValidator.java b/src/main/java/io/github/carlos_emr/carlos/report/data/QueryByExampleSqlValidator.java new file mode 100644 index 00000000000..5bb76ff139b --- /dev/null +++ b/src/main/java/io/github/carlos_emr/carlos/report/data/QueryByExampleSqlValidator.java @@ -0,0 +1,356 @@ +/** + * Copyright (c) 2026 CARLOS Contributors. All Rights Reserved. + * + * This software is published under the GPL GNU General Public License. + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * + * CARLOS EMR Project + * https://github.com/carlos-emr/carlos + */ +package io.github.carlos_emr.carlos.report.data; + +import java.sql.SQLException; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import java.util.Set; +import java.util.regex.Pattern; + +import io.github.carlos_emr.carlos.db.LegacyJdbcQuery; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.AnalyticExpression; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.NextValExpression; +import net.sf.jsqlparser.expression.UserVariable; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.select.ParenthesedSelect; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SetOperationList; +import net.sf.jsqlparser.util.TablesNamesFinder; + +/** + * Fail-closed validation for request-submitted Query-by-Example SQL. + * + *

Accepted input is one non-locking, non-output {@code SELECT} whose table + * references are unqualified or belong to the configured application schema. + * Comments, statement separators, set operations, write/control keywords, and + * prohibited database functions are rejected.

+ * + * @since 2026-08-06 + */ +public final class QueryByExampleSqlValidator { + public static final int MAX_SQL_CHARACTERS = 16_384; + + private static final Pattern LOCKING_SELECT = Pattern.compile( + "\\bfor\\s+(?:update|share)\\b|\\block\\s+in\\s+share\\s+mode\\b", + Pattern.CASE_INSENSITIVE); + private static final Pattern OUTPUT_OR_PROCEDURE_OPERATION = Pattern.compile( + "\\b(?:into|procedure)\\b", Pattern.CASE_INSENSITIVE); + + /** + * Tables containing reusable authentication material or private cryptographic keys. + * Query-by-Example is a reporting surface, so even administrators must use the + * purpose-built management flows rather than exporting these records as report data. + */ + static final Set SECURITY_SENSITIVE_TABLES = Set.of( + "appdefinition", + "appuser", + "emailconfig", + "emaillog", + "fax_config", + "oscarcommlocations", + "oscarkeys", + "professionalspecialists", + "property", + "publickeys", + "security", + "securityarchive", + "securitytoken", + "serviceaccesstoken", + "serviceclient", + "serviceoauthnonce", + "servicerequesttoken"); + + /** + * Reporting-safe built-ins. Anything not listed is rejected so stored functions and + * newly introduced vendor functions cannot silently cross the validation boundary. + */ + private static final Set ALLOWED_FUNCTIONS = Set.of( + "abs", "acos", "adddate", "addtime", "ascii", "asin", "atan", "atan2", "avg", + "bin", "bit_and", "bit_length", "bit_or", "bit_xor", "ceil", "ceiling", "char_length", + "character_length", "coalesce", "concat", "concat_ws", "conv", "convert_tz", "cos", "cot", + "count", "crc32", "curdate", "current_date", "current_time", "current_timestamp", "curtime", + "date", "date_add", "date_format", "date_sub", "datediff", "day", "dayname", "dayofmonth", + "dayofweek", "dayofyear", "degrees", "elt", "exp", "field", "find_in_set", "floor", "format", + "from_base64", "from_days", "from_unixtime", "get_format", "greatest", "hex", "hour", "if", + "ifnull", "instr", "lcase", "least", "left", "length", "ln", "localtime", "localtimestamp", + "locate", "log", "log10", "log2", "lower", "ltrim", "makedate", "maketime", "max", + "md5", "microsecond", "mid", "min", "minute", "mod", "month", "monthname", "now", "nullif", + "oct", "octet_length", "ord", "period_add", "period_diff", "pi", "pow", "power", "quarter", + "quote", "radians", "rand", "replace", "reverse", "right", "round", "rtrim", "sec_to_time", + "second", "sha", "sha1", "sha2", "sign", "sin", "soundex", "sqrt", + "std", "stddev", "stddev_pop", "stddev_samp", "str_to_date", "strcmp", "subdate", "substr", + "substring", "substring_index", "subtime", "sum", "sysdate", "tan", "time", "time_format", + "time_to_sec", "timediff", "timestamp", "timestampadd", "timestampdiff", "to_base64", "to_days", + "trim", "truncate", "ucase", "unhex", "unix_timestamp", "upper", "utc_date", "utc_time", + "utc_timestamp", "variance", "var_pop", "var_samp", "week", "weekday", "weekofyear", "year", + "yearweek", "cume_dist", "dense_rank", "first_value", "lag", "last_value", "lead", "nth_value", + "ntile", "percent_rank", "rank", "row_number"); + + private QueryByExampleSqlValidator() { + } + + /** + * Validates SQL and returns the same text wrapped for the trusted JDBC boundary. + * + * @param sql request-submitted SQL to validate + * @param properties application properties containing a non-blank {@code db_name} + * @return the unchanged SQL represented as {@link LegacyJdbcQuery.TrustedSql} + * @throws QueryByExampleValidationException if the SQL is empty, cannot be parsed, + * is not one allowed {@code SELECT}, or references an unapproved schema or operation + */ + public static LegacyJdbcQuery.TrustedSql validate(String sql, Properties properties) + throws QueryByExampleValidationException { + validateSqlText(sql); + Select select = parseSingleSelect(sql); + rejectUnsafeTextOperations(sql); + rejectUnsafeExpressionsAndOtherSchemas(select, applicationSchema(properties)); + try { + return LegacyJdbcQuery.trustedSelectSql(sql); + } catch (SQLException e) { + throw new QueryByExampleValidationException(e.getMessage(), e); + } + } + + private static void validateSqlText(String sql) throws QueryByExampleValidationException { + if (sql == null) { + throw new QueryByExampleValidationException("SQL query must not be empty"); + } + if (sql.length() > MAX_SQL_CHARACTERS) { + throw new QueryByExampleValidationException("SQL query exceeds the allowed length"); + } + if (sql.isBlank()) { + throw new QueryByExampleValidationException("SQL query must not be empty"); + } + } + + private static Select parseSingleSelect(String sql) throws QueryByExampleValidationException { + Statement statement; + try { + statement = CCJSqlParserUtil.parse(sql); + } catch (JSQLParserException | RuntimeException e) { + throw new QueryByExampleValidationException("The query could not be parsed as a single SELECT", e); + } + + if (!(statement instanceof Select select) || containsSetOperation(select)) { + throw new QueryByExampleValidationException("Only one SELECT statement is allowed"); + } + return select; + } + + private static void rejectUnsafeTextOperations(String sql) throws QueryByExampleValidationException { + String sqlWithoutQuotedSections = stripQuotedSections(sql); + if (LOCKING_SELECT.matcher(sqlWithoutQuotedSections).find()) { + throw new QueryByExampleValidationException("Locking SELECT statements are not allowed"); + } + if (OUTPUT_OR_PROCEDURE_OPERATION.matcher(sqlWithoutQuotedSections).find()) { + throw new QueryByExampleValidationException("SELECT output operations are not allowed"); + } + } + + static String applicationSchema(Properties properties) throws QueryByExampleValidationException { + String configuredName = properties == null ? null : properties.getProperty("db_name"); + if (configuredName == null || configuredName.isBlank()) { + throw new QueryByExampleValidationException("The application database schema is not configured"); + } + String schema = configuredName.split("\\?", 2)[0].trim(); + if (schema.isEmpty()) { + throw new QueryByExampleValidationException("The application database schema is not configured"); + } + return unquoteIdentifier(schema); + } + + private static void rejectUnsafeExpressionsAndOtherSchemas(Statement statement, String applicationSchema) + throws QueryByExampleValidationException { + Set tables; + SqlSafetyVisitor visitor = new SqlSafetyVisitor(); + try { + tables = visitor.getTables(statement); + } catch (RuntimeException e) { + throw new QueryByExampleValidationException("The query table references could not be validated", e); + } + if (visitor.hasOutputOperation()) { + throw new QueryByExampleValidationException("SELECT output operations are not allowed"); + } + if (visitor.hasLockingOperation()) { + throw new QueryByExampleValidationException("Locking SELECT statements are not allowed"); + } + if (visitor.hasSetOperation()) { + throw new QueryByExampleValidationException("Set operations are not allowed"); + } + if (visitor.hasVariables()) { + throw new QueryByExampleValidationException("Session and system variables are not allowed"); + } + if (!visitor.disallowedFunctions().isEmpty()) { + throw new QueryByExampleValidationException("The query uses a function outside the allowed set"); + } + for (String table : tables) { + rejectUnsafeTableReference(table, applicationSchema); + } + } + + private static void rejectUnsafeTableReference(String table, String applicationSchema) + throws QueryByExampleValidationException { + String normalizedTable = unquoteIdentifier(table); + int lastDot = normalizedTable.lastIndexOf('.'); + if (lastDot > 0) { + String qualifier = normalizedTable.substring(0, lastDot); + if (!canonicalIdentifier(qualifier).equals(canonicalIdentifier(applicationSchema))) { + throw new QueryByExampleValidationException("Queries may only read the application database schema"); + } + } + String unqualifiedTable = lastDot >= 0 ? normalizedTable.substring(lastDot + 1) : normalizedTable; + if (SECURITY_SENSITIVE_TABLES.contains(canonicalIdentifier(unqualifiedTable))) { + throw new QueryByExampleValidationException( + "Queries may not read tables containing authentication secrets"); + } + } + + private static boolean containsSetOperation(Select select) { + if (select instanceof SetOperationList) { + return true; + } + return select instanceof ParenthesedSelect parenthesedSelect + && containsSetOperation(parenthesedSelect.getSelect()); + } + + private static String stripQuotedSections(String sql) { + StringBuilder stripped = new StringBuilder(sql.length()); + char quote = '\0'; + int i = 0; + while (i < sql.length()) { + char current = sql.charAt(i); + char next = i + 1 < sql.length() ? sql.charAt(i + 1) : '\0'; + if (quote == '\0') { + quote = sqlQuoteDelimiter(current); + stripped.append(quote == '\0' ? current : ' '); + i++; + } else if (current == quote && next == quote) { + stripped.append(" "); + i += 2; + } else { + if (current == quote) { + quote = '\0'; + } + stripped.append(' '); + i++; + } + } + return stripped.toString(); + } + + private static char sqlQuoteDelimiter(char candidate) { + return candidate == '\'' || candidate == '"' || candidate == '`' ? candidate : '\0'; + } + + private static String unquoteIdentifier(String identifier) { + return identifier.replace("`", "").replace("\"", "").trim(); + } + + private static String canonicalIdentifier(String identifier) { + return identifier.toLowerCase(Locale.ROOT); + } + + private static final class SqlSafetyVisitor extends TablesNamesFinder { + private final Set disallowedFunctions = new java.util.HashSet<>(); + private boolean variables; + private boolean outputOperation; + private boolean lockingOperation; + private boolean setOperation; + + @Override + public Void visit(Function function, S context) { + List nameParts = function.getMultipartName(); + String functionName = canonicalIdentifier(unquoteIdentifier(function.getName())); + if (nameParts == null || nameParts.size() != 1 || !ALLOWED_FUNCTIONS.contains(functionName)) { + disallowedFunctions.add(functionName); + } + return super.visit(function, context); + } + + @Override + public Void visit(AnalyticExpression function, S context) { + String functionName = canonicalIdentifier(unquoteIdentifier(function.getName())); + if (!ALLOWED_FUNCTIONS.contains(functionName)) { + disallowedFunctions.add(functionName); + } + return super.visit(function, context); + } + + @Override + public Void visit(UserVariable variable, S context) { + variables = true; + return super.visit(variable, context); + } + + @Override + public Void visit(NextValExpression sequence, S context) { + variables = true; + return super.visit(sequence, context); + } + + @Override + public Void visit(PlainSelect select, S context) { + if ((select.getIntoTables() != null && !select.getIntoTables().isEmpty()) + || select.getIntoTempTable() != null) { + outputOperation = true; + } + if (select.getForMode() != null || select.getForClause() != null + || select.getForUpdateTable() != null || select.isSkipLocked() + || select.isNoWait() || select.getWait() != null) { + lockingOperation = true; + } + return super.visit(select, context); + } + + @Override + public Void visit(SetOperationList operations, S context) { + setOperation = true; + return super.visit(operations, context); + } + + Set disallowedFunctions() { + return disallowedFunctions; + } + + boolean hasVariables() { + return variables; + } + + boolean hasOutputOperation() { + return outputOperation; + } + + boolean hasLockingOperation() { + return lockingOperation; + } + + boolean hasSetOperation() { + return setOperation; + } + } +} diff --git a/src/main/java/io/github/carlos_emr/carlos/report/data/QueryByExampleValidationException.java b/src/main/java/io/github/carlos_emr/carlos/report/data/QueryByExampleValidationException.java new file mode 100644 index 00000000000..ee7dc42072c --- /dev/null +++ b/src/main/java/io/github/carlos_emr/carlos/report/data/QueryByExampleValidationException.java @@ -0,0 +1,52 @@ +/** + * Copyright (c) 2026 CARLOS Contributors. All Rights Reserved. + * + * This software is published under the GPL GNU General Public License. + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * + * CARLOS EMR Project + * https://github.com/carlos-emr/carlos + */ +package io.github.carlos_emr.carlos.report.data; + +import java.sql.SQLException; + +/** + * Indicates that a Query-by-Example submission was rejected before execution. + * + * @since 2026-08-06 + */ +public class QueryByExampleValidationException extends SQLException { + private static final long serialVersionUID = 1L; + + /** + * Creates a validation exception with a user-safe diagnostic message. + * + * @param message description of why validation failed + */ + public QueryByExampleValidationException(String message) { + super(message); + } + + /** + * Creates a validation exception retaining the parser or validation failure. + * + * @param message description of why validation failed + * @param cause underlying parser or SQL validation failure + */ + public QueryByExampleValidationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/io/github/carlos_emr/carlos/report/data/RptByExampleData.java b/src/main/java/io/github/carlos_emr/carlos/report/data/RptByExampleData.java index c333cb01a25..d10cb84afae 100644 --- a/src/main/java/io/github/carlos_emr/carlos/report/data/RptByExampleData.java +++ b/src/main/java/io/github/carlos_emr/carlos/report/data/RptByExampleData.java @@ -30,64 +30,194 @@ package io.github.carlos_emr.carlos.report.data; -import java.util.ArrayList; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLTimeoutException; import java.util.Properties; +import org.apache.commons.codec.digest.DigestUtils; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import io.github.carlos_emr.carlos.db.LegacyJdbcQuery; import io.github.carlos_emr.carlos.utility.MiscUtils; /** - * This classes main function FluReportGenerate collects a group of patients with flu in the last specified date + * Validates and executes Query-by-Example SQL for authorized report users. + * Queries run read-only with row and timeout limits, and every outcome is audited + * without recording the submitted SQL text. */ public class RptByExampleData { - public static final String DIRECT_SQL_DISABLED_MESSAGE = - "Direct SQL execution has been disabled. Use curated report templates instead."; + public static final int MAX_ROWS = 1_000; + public static final int MAX_OUTPUT_CHARACTERS = 1_000_000; + public static final int QUERY_TIMEOUT_SECONDS = 15; + + @FunctionalInterface + interface ConnectionProvider { + Connection getConnection() throws SQLException; + } + + public record QueryResult(String html, int rowCount, boolean truncated, boolean rowLimitReached, + long durationMillis) { + } - public ArrayList demoList = null; - public String sql = ""; - public String results = null; - public String connect = null; - Properties oscarVariables = null; + private static final class ExecutionProgress { + private int rowCount; + + int rowCount() { + return rowCount; + } + + void recordRows(int renderedRows) { + rowCount = renderedRows; + } + } + + private final ConnectionProvider connectionProvider; public RptByExampleData() { + this(LegacyJdbcQuery::getConnection); + } + + RptByExampleData(ConnectionProvider connectionProvider) { + this.connectionProvider = connectionProvider; + } + + /** + * Validates and executes one bounded, read-only Query-by-Example submission. + * The outcome and elapsed time are audited in all cases; raw SQL text is not + * included in the audit event. + * + * @param sql request-submitted SQL; only one validated {@code SELECT} is permitted + * @param properties application properties used to resolve the allowed database schema + * @param providerNo requesting provider identifier used for audit metadata + * @return the encoded, size-bounded query result + * @throws QueryByExampleValidationException if the SQL fails structural validation + * @throws SQLTimeoutException if execution exceeds {@link #QUERY_TIMEOUT_SECONDS} + * @throws SQLException if the query or JDBC resource handling fails + * @throws RuntimeException if an unexpected validation, execution, or rendering failure occurs + */ + @SuppressFBWarnings( + value = "THROWS_METHOD_THROWS_RUNTIMEEXCEPTION", + justification = "Runtime failures are audited and handled by the action") + public QueryResult execute(String sql, Properties properties, String providerNo) throws SQLException { + long startedAt = System.nanoTime(); + String outcome = "failed"; + ExecutionProgress progress = new ExecutionProgress(); + try { + LegacyJdbcQuery.TrustedSql trustedSql = QueryByExampleSqlValidator.validate(sql, properties); + RptResultStruct.StructuredResult structured = executeWithConnection(trustedSql, progress); + outcome = "success"; + return new QueryResult(structured.html(), structured.rowCount(), structured.truncated(), + structured.rowLimitReached(), elapsedMillis(startedAt)); + } catch (QueryByExampleValidationException e) { + outcome = "rejected"; + throw e; + } catch (SQLTimeoutException e) { + outcome = "timeout"; + throw e; + } catch (SQLException e) { + logFailure(providerNo, sql, e.getSQLState(), e.getClass().getSimpleName()); + throw e; + } catch (RuntimeException e) { + logFailure(providerNo, sql, null, e.getClass().getSimpleName()); + throw e; + } finally { + audit(providerNo, sql, elapsedMillis(startedAt), progress.rowCount(), outcome); + } + } + + @SuppressFBWarnings( + value = {"SQL_INJECTION_JDBC", "SQL_PREPARED_STATEMENT_GENERATED_FROM_NONCONSTANT_STRING"}, + justification = "This narrow sink accepts only TrustedSql created by structural SELECT validation") + private static PreparedStatement prepareValidatedStatement(Connection connection, + LegacyJdbcQuery.TrustedSql trustedSql) throws SQLException { + // codeql[java/sql-injection] -- TrustedSql is created only after structural SELECT validation. + return connection.prepareStatement(trustedSql.sql(), ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY); // nosemgrep: java.lang.security.audit.formatted-sql-string-deepsemgrep.formatted-sql-string-deepsemgrep -- validated TrustedSql boundary } - public String exampleTextGenerate(String sql, Properties oscarVariables) { - return exampleReportGenerate(sql, oscarVariables); + private RptResultStruct.StructuredResult executeWithConnection(LegacyJdbcQuery.TrustedSql trustedSql, + ExecutionProgress progress) + throws SQLException { + try (Connection connection = connectionProvider.getConnection()) { + return executeValidatedQuery(connection, trustedSql, progress); + } } - public String exampleReportGenerate(String sql, Properties oscarVariables) { - if (sql == null || sql.trim().isEmpty()) { - return ""; + private static RptResultStruct.StructuredResult executeValidatedQuery(Connection connection, + LegacyJdbcQuery.TrustedSql trustedSql, ExecutionProgress progress) throws SQLException { + boolean originalReadOnly = connection.isReadOnly(); + try { + connection.setReadOnly(true); + } catch (SQLException | RuntimeException setupFailure) { + restoreReadOnlyAfterFailure(connection, originalReadOnly, setupFailure); + throw setupFailure; + } + RptResultStruct.StructuredResult result; + try { + result = executeStatement(connection, trustedSql, progress); + } catch (SQLException | RuntimeException executionFailure) { + restoreReadOnlyAfterFailure(connection, originalReadOnly, executionFailure); + throw executionFailure; } + connection.setReadOnly(originalReadOnly); + return result; + } - this.sql = sql; - this.oscarVariables = oscarVariables; - - // Direct request-submitted SQL is deliberately not executed. A - // denylist-validated SELECT can still read tables/columns outside the - // user's reporting workflow, so this legacy endpoint now preserves the - // page contract while blocking the unsafe database boundary. - MiscUtils.getLogger().warn("Blocked direct Query-by-Example SQL execution; queryLength={}", sql.length()); - results = DIRECT_SQL_DISABLED_MESSAGE; - return results; - } - - public static String replaceSQLString - (String oldString, String newString, String inputString) { - - String outputString = ""; - int i; - for (i = 0; i < inputString.length(); i++) { - if (!(inputString.regionMatches(true, i, oldString, - 0, oldString.length()))) - outputString += inputString.charAt(i); - else { - outputString += newString; - i += oldString.length() - 1; + private static RptResultStruct.StructuredResult executeStatement(Connection connection, + LegacyJdbcQuery.TrustedSql trustedSql, ExecutionProgress progress) throws SQLException { + try (PreparedStatement statement = prepareValidatedStatement(connection, trustedSql)) { + statement.setMaxRows(MAX_ROWS + 1); + statement.setQueryTimeout(QUERY_TIMEOUT_SECONDS); + return readStatementResult(statement, progress); + } + } + + private static RptResultStruct.StructuredResult readStatementResult(PreparedStatement statement, + ExecutionProgress progress) + throws SQLException { + try (ResultSet resultSet = statement.executeQuery()) { + RptResultStruct.StructuredResult structured = RptResultStruct.getStructureWithCount( + resultSet, MAX_OUTPUT_CHARACTERS, MAX_ROWS); + progress.recordRows(structured.rowCount()); + return structured; + } + } + + private static void restoreReadOnlyAfterFailure(Connection connection, boolean originalReadOnly, + Exception executionFailure) { + try { + connection.setReadOnly(originalReadOnly); + } catch (SQLException | RuntimeException restoreFailure) { + if (restoreFailure != executionFailure) { + executionFailure.addSuppressed(restoreFailure); } } - return outputString; } + public static void audit(String providerNo, String sql, long durationMillis, int rowCount, String outcome) { + String query = sql == null ? "" : sql; + String queryHash = queryHash(query); + MiscUtils.getLogger().info( + "Query-by-Example audit provider={} queryHash={} queryLength={} durationMs={} rowCount={} outcome={}", + providerNo, queryHash, query.length(), durationMillis, rowCount, outcome); + } + + private static long elapsedMillis(long startedAt) { + return (System.nanoTime() - startedAt) / 1_000_000L; + } + + private static void logFailure(String providerNo, String sql, String sqlState, String exceptionType) { + if (MiscUtils.getLogger().isWarnEnabled()) { + MiscUtils.getLogger().warn( + "Query-by-Example failure provider={} queryHash={} sqlState={} exceptionType={}", + providerNo, queryHash(sql), sqlState, exceptionType); + } + } -}; + private static String queryHash(String sql) { + return DigestUtils.sha256Hex(sql == null ? "" : sql); + } +} diff --git a/src/main/java/io/github/carlos_emr/carlos/report/data/RptResultStruct.java b/src/main/java/io/github/carlos_emr/carlos/report/data/RptResultStruct.java index 40871eff41e..58f10553ed8 100644 --- a/src/main/java/io/github/carlos_emr/carlos/report/data/RptResultStruct.java +++ b/src/main/java/io/github/carlos_emr/carlos/report/data/RptResultStruct.java @@ -40,56 +40,252 @@ import io.github.carlos_emr.Misc; import org.owasp.encoder.Encode; +import java.io.IOException; +import java.io.Reader; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; +import java.sql.Types; public class RptResultStruct { + + private static final int MIN_OUTPUT_CHARACTERS = 64; + private static final int CLOSING_MARKUP_RESERVE = 32; + private static final String TABLE_END = ""; + private static final String HEADER_END = ""; + private static final String ROW_END = ""; + private static final String CELL_START = ""; + private static final String CELL_END = ""; + + public record StructuredResult(String html, int rowCount, boolean truncated, boolean rowLimitReached) { + } public static String getStructure(ResultSet rs) throws SQLException { - + return getStructureWithCount(rs).html(); + } + /** - * Generates an HTML table from a ResultSet with enhanced styling for report templates. - * Includes proper thead/tbody structure, alternating row classes, and XSS protection. - * Each column header and cell value is HTML-encoded using OWASP Encoder. - * - * `@param` rs the ResultSet containing data to display; must be positioned before the first row - * `@return` an HTML string containing a complete table with id="results" - * `@throws` SQLException if a database access error occurs or the ResultSet is closed - * `@since` 1.0 - */ + * Generates an encoded HTML table and row count from a {@link ResultSet}. + * + * @param rs result set positioned before its first row + * @return encoded table markup and the number of rows rendered + * @throws SQLException if the result set cannot be read + */ + public static StructuredResult getStructureWithCount(ResultSet rs) throws SQLException { + return getStructureWithCount(rs, Integer.MAX_VALUE, Integer.MAX_VALUE); + } - // assuming multiple rows in rs - StringBuilder sb = new StringBuilder(); + /** + * Generates an encoded HTML table within a fixed output budget. + * + * @param rs result set positioned before its first row + * @param maxOutputCharacters maximum number of rendered HTML characters + * @return encoded table markup, rendered row count, and whether output was truncated + * @throws SQLException if the result set cannot be read + */ + public static StructuredResult getStructureWithCount(ResultSet rs, int maxOutputCharacters) throws SQLException { + return getStructureWithCount(rs, maxOutputCharacters, Integer.MAX_VALUE); + } + /** + * Generates an encoded HTML table within output and row budgets. + * + * @param rs result set positioned before its first row + * @param maxOutputCharacters maximum number of rendered HTML characters + * @param maxRows maximum number of rows to render + * @return encoded table markup, rendered row count, and truncation state + * @throws SQLException if the result set cannot be read + */ + public static StructuredResult getStructureWithCount(ResultSet rs, int maxOutputCharacters, int maxRows) + throws SQLException { + validateLimits(maxOutputCharacters, maxRows); + LimitedHtmlBuilder html = new LimitedHtmlBuilder(maxOutputCharacters); ResultSetMetaData rsmd = rs.getMetaData(); - int columns = rsmd.getColumnCount(); + int[] columnTypes = getColumnTypes(rsmd, columns); + html.appendMarkup(""); + if (!appendHeaders(html, rsmd, columns)) { + html.appendClosingMarkup(TABLE_END); + return new StructuredResult(html.toString(), 0, true, false); + } + + int rowCount = 0; + boolean stopRendering = false; String rowColor = "rowColor1"; - String[] columnNames = new String[columns]; - sb.append("
"); - for (int i = 0; i < columns; i++) { // for each column in result set - columnNames[i] = rsmd.getColumnName(i + 1); - // put names in array - // use i+1 or else you're going to get an exception - // insert headings for table - sb.append(""); + while (!stopRendering && rowCount < maxRows && rs.next()) { + rowCount++; + stopRendering = !appendRow(html, rs, columnTypes, rowColor); + rowColor = rowColor.equals("rowColor1") ? "rowColor2" : "rowColor1"; } - while (rs.next()) { - sb.append(""); - for (int j = 0; j < columns; j++) { - sb.append(""); + boolean rowLimitReached = !stopRendering && rowCount == maxRows && rs.next(); + html.appendClosingMarkup(TABLE_END); + return new StructuredResult(html.toString(), rowCount, html.isTruncated(), rowLimitReached); + } + + private static void validateLimits(int maxOutputCharacters, int maxRows) { + if (maxOutputCharacters < MIN_OUTPUT_CHARACTERS) { + throw new IllegalArgumentException("HTML output limit is too small"); + } + if (maxRows < 1) { + throw new IllegalArgumentException("Row limit must be positive"); + } + } + private static boolean appendHeaders(LimitedHtmlBuilder html, ResultSetMetaData metadata, int columns) + throws SQLException { + for (int i = 1; i <= columns; i++) { + if (!appendHeader(html, metadata.getColumnLabel(i))) { + return false; } - rowColor = rowColor.compareTo("rowColor1") == 0 ? "rowColor2" : "rowColor1"; - sb.append(""); } - sb.append("
"); - sb.append(Encode.forHtml(columnNames[i])); - sb.append("
"); - sb.append(Encode.forHtml(Misc.getString(rs, columnNames[j]))); - sb.append("
"); - return sb.toString(); + return true; + } + + private static int[] getColumnTypes(ResultSetMetaData metadata, int columns) throws SQLException { + int[] columnTypes = new int[columns]; + for (int column = 1; column <= columns; column++) { + columnTypes[column - 1] = metadata.getColumnType(column); + } + return columnTypes; + } + + private static boolean appendHeader(LimitedHtmlBuilder html, String label) { + if (!html.appendMarkup("")) { + return false; + } + if (!html.appendEncoded(label) || !html.appendMarkup(HEADER_END)) { + html.appendClosingMarkup(HEADER_END); + return false; + } + return true; + } + + private static boolean appendRow(LimitedHtmlBuilder html, ResultSet resultSet, int[] columnTypes, String rowColor) + throws SQLException { + if (!html.appendMarkup("")) { + return false; + } + for (int column = 1; column <= columnTypes.length; column++) { + if (!appendCell(html, resultSet, column, columnTypes[column - 1])) { + html.appendClosingMarkup(ROW_END); + return false; + } + } + if (!html.appendMarkup(ROW_END)) { + html.appendClosingMarkup(ROW_END); + return false; + } + return true; + } + + private static boolean appendCell(LimitedHtmlBuilder html, ResultSet resultSet, int column, int columnType) + throws SQLException { + if (!html.appendMarkup(CELL_START)) { + return false; + } + boolean complete; + if (supportsCharacterStream(columnType)) { + try (Reader value = resultSet.getCharacterStream(column)) { + complete = value == null || html.appendEncoded(value); + } catch (IOException e) { + throw new SQLException("Could not render query result", e); + } + } else { + complete = html.appendEncoded(resultSet.getString(column)); + } + if (!complete || !html.appendMarkup(CELL_END)) { + html.appendClosingMarkup(CELL_END); + return false; + } + return true; + } + + private static boolean supportsCharacterStream(int columnType) { + return switch (columnType) { + case Types.CHAR, Types.VARCHAR, Types.LONGVARCHAR, + Types.NCHAR, Types.NVARCHAR, Types.LONGNVARCHAR, + Types.CLOB, Types.NCLOB, + Types.BINARY, Types.VARBINARY, Types.LONGVARBINARY, Types.BLOB -> true; + default -> false; + }; + } + + private static final class LimitedHtmlBuilder { + private static final int READ_BUFFER_SIZE = 2_048; + + private final StringBuilder html; + private final int contentLimit; + private boolean truncated; + + LimitedHtmlBuilder(int maxOutputCharacters) { + contentLimit = maxOutputCharacters - CLOSING_MARKUP_RESERVE; + html = new StringBuilder(Math.min(maxOutputCharacters, 8_192)); + } + + boolean appendMarkup(String markup) { + if (markup.length() > remaining()) { + truncated = true; + return false; + } + html.append(markup); + return true; + } + + boolean appendEncoded(String value) { + return appendEncodedChunk(Encode.forHtml(value == null ? "" : value)); + } + + boolean appendEncoded(Reader value) throws IOException { + char[] buffer = new char[READ_BUFFER_SIZE]; + int read; + while ((read = value.read(buffer)) != -1) { + if (!appendEncodedChunk(Encode.forHtml(new String(buffer, 0, read)))) { + return false; + } + } + return true; + } + + private boolean appendEncodedChunk(String encoded) { + int remaining = remaining(); + if (encoded.length() <= remaining) { + html.append(encoded); + return true; + } + if (remaining > 0) { + int contentCharacters = Math.max(0, remaining - 1); + int safeCharacters = entitySafePrefixLength(encoded, contentCharacters); + html.append(encoded, 0, safeCharacters).append('\u2026'); + } + truncated = true; + return false; + } + + private static int entitySafePrefixLength(String encoded, int requestedLength) { + if (requestedLength == 0) { + return 0; + } + int lastEntityStart = encoded.lastIndexOf('&', requestedLength - 1); + int lastEntityEnd = encoded.lastIndexOf(';', requestedLength - 1); + return lastEntityStart > lastEntityEnd ? lastEntityStart : requestedLength; + } + + void appendClosingMarkup(String markup) { + html.append(markup); + } + + boolean isTruncated() { + return truncated; + } + + private int remaining() { + return contentLimit - html.length(); + } + + @Override + public String toString() { + return html.toString(); + } } //improvement over getStructure() - changed CSS naming conventions, added enterspaces for cleaner html, @@ -133,7 +329,7 @@ public static String getStructure2(ResultSet rs) throws SQLException { // insert headings for table sb.append(""); sb.append(Encode.forHtml(columnNames[i])); - sb.append(""); + sb.append(HEADER_END); } sb.append(""); @@ -146,10 +342,10 @@ public static String getStructure2(ResultSet rs) throws SQLException { for (int j = 0; j < columns; j++) { sb.append(""); sb.append(Encode.forHtml(Misc.getString(rs, columnNames[j]))); - sb.append(""); + sb.append(CELL_END); } - sb.append(""); + sb.append(ROW_END); } while (rs.next()); } if (results) { diff --git a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExample2Action.java b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExample2Action.java index 6785d8b46d3..1130263dc49 100644 --- a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExample2Action.java +++ b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExample2Action.java @@ -31,9 +31,11 @@ package io.github.carlos_emr.carlos.report.pageUtil; import java.io.IOException; +import java.sql.SQLException; +import java.sql.SQLTimeoutException; import java.util.Collection; import java.util.Date; -import java.util.List; +import java.util.Locale; import java.util.Properties; import jakarta.servlet.ServletException; @@ -41,9 +43,8 @@ import jakarta.servlet.http.HttpServletResponse; import io.github.carlos_emr.carlos.report.data.RptByExampleData; +import io.github.carlos_emr.carlos.report.data.QueryByExampleValidationException; import io.github.carlos_emr.carlos.managers.SecurityInfoManager; -import io.github.carlos_emr.carlos.PMmodule.dao.SecUserRoleDao; -import io.github.carlos_emr.carlos.PMmodule.model.SecUserRole; import io.github.carlos_emr.carlos.commn.dao.ReportByExamplesDao; import io.github.carlos_emr.carlos.commn.model.ReportByExamples; import io.github.carlos_emr.carlos.utility.LoggedInInfo; @@ -59,12 +60,14 @@ import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; /** - * Struts2 action for the Query-by-Example report tool. Allows admin users to execute - * custom SQL queries, persist them as recent searches, and display results. + * Struts2 action for the Query-by-Example report tool. Allows authorized report users + * to execute custom read-only SQL queries, persist successful searches, and display results. * * @since 2003-07-22 */ public class RptByExample2Action extends ActionSupport { + public static final String ENABLED_PROPERTY = "QUERY_BY_EXAMPLE_ENABLED"; + HttpServletRequest request = ServletActionContext.getRequest(); HttpServletResponse response = ServletActionContext.getResponse(); @@ -96,34 +99,73 @@ public String execute() String providerNo = loggedInInfo.getLoggedInProviderNo(); - SecUserRoleDao secUserRoleDao = SpringUtils.getBean(SecUserRoleDao.class); - - List userRoles = secUserRoleDao.findByRoleNameAndProviderNo("admin", providerNo); - if (userRoles.isEmpty()) { - throw new SecurityException("missing required admin privileges to run query by example"); - } - RptByExampleQueryBeanHandler hd = new RptByExampleQueryBeanHandler(); Collection favorites = hd.getFavoriteCollection(providerNo); request.setAttribute("favorites", favorites); - if (sql != null) { - write2Database(sql, providerNo); - } else - sql = ""; + sql = sql == null ? "" : sql; + request.setAttribute("submittedSql", sql); + + if (!"POST".equals(request.getMethod())) { + return SUCCESS; + } + + Properties properties = CarlosProperties.getInstance(); + if (!isEnabled(properties)) { + request.setAttribute("queryDisabled", true); + RptByExampleData.audit(providerNo, sql, 0, 0, "disabled"); + return SUCCESS; + } - RptByExampleData exampleData = new RptByExampleData(); - Properties proppies = CarlosProperties.getInstance(); + if (sql.isBlank()) { + request.setAttribute("queryValidationError", true); + RptByExampleData.audit(providerNo, sql, 0, 0, "rejected"); + return SUCCESS; + } - String results = exampleData.exampleReportGenerate(sql, proppies) == null ? null : exampleData.exampleReportGenerate(sql, proppies); - String resultText = exampleData.exampleTextGenerate(sql, proppies) == null ? null : exampleData.exampleTextGenerate(sql, proppies); + RptByExampleData.QueryResult result; + try { + result = new RptByExampleData().execute(sql, properties, providerNo); + } catch (QueryByExampleValidationException e) { + request.setAttribute("queryValidationError", true); + return SUCCESS; + } catch (SQLTimeoutException e) { + request.setAttribute("queryTimeout", true); + request.setAttribute("queryTimeoutSeconds", RptByExampleData.QUERY_TIMEOUT_SECONDS); + return SUCCESS; + } catch (SQLException | RuntimeException e) { + request.setAttribute("queryExecutionError", true); + return SUCCESS; + } - request.setAttribute("results", results); - request.setAttribute("resultText", resultText); + request.setAttribute("results", result.html()); + request.setAttribute("resultRowCount", result.rowCount()); + request.setAttribute("resultLimit", RptByExampleData.MAX_ROWS); + request.setAttribute("resultTruncated", result.truncated()); + request.setAttribute("resultRowLimitReached", result.rowLimitReached()); + request.setAttribute("resultCharacterLimit", RptByExampleData.MAX_OUTPUT_CHARACTERS); + try { + write2Database(sql, providerNo); + } catch (RuntimeException e) { + request.setAttribute("queryHistoryError", true); + RptByExampleData.audit(providerNo, sql, result.durationMillis(), result.rowCount(), "history_failed"); + } return SUCCESS; } + @SuppressFBWarnings( + value = "IMPROPER_UNICODE", + justification = "Locale.ROOT normalization is safe for controlled ASCII feature-flag values") + static boolean isEnabled(Properties properties) { + String configured = properties.getProperty(ENABLED_PROPERTY); + if (configured == null || configured.isBlank()) { + return true; + } + String normalized = configured.trim().toLowerCase(Locale.ROOT); + return normalized.equals("true") || normalized.equals("yes") || normalized.equals("on"); + } + public void write2Database(String query, String providerNo) { if (query != null && query.compareTo("") != 0) { ReportByExamples r = new ReportByExamples(); diff --git a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesAllFavorites2Action.java b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesAllFavorites2Action.java index b8f88146656..9307ea36813 100644 --- a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesAllFavorites2Action.java +++ b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesAllFavorites2Action.java @@ -55,12 +55,13 @@ public class RptByExamplesAllFavorites2Action extends ActionSupport { public String execute() throws ServletException, IOException { LoggedInInfo loggedInInfo = LoggedInInfo.getLoggedInInfoFromSession(request); - if (!securityInfoManager.hasPrivilege(loggedInInfo, "_report", "r", null)) { - throw new SecurityException("missing required sec object (_report)"); + if (!securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null) + && !securityInfoManager.hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null)) { + throw new SecurityException("missing required sec object (_admin or _report)"); } - String providerNo = (String) request.getSession().getAttribute("user"); + String providerNo = loggedInInfo.getLoggedInProviderNo(); RptByExampleQueryBeanHandler hd = new RptByExampleQueryBeanHandler(providerNo); request.setAttribute("allFavorites", hd); return SUCCESS; diff --git a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesFavorite2Action.java b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesFavorite2Action.java index 87a1147c931..d10e90926d0 100644 --- a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesFavorite2Action.java +++ b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptByExamplesFavorite2Action.java @@ -31,6 +31,7 @@ import java.io.IOException; import java.util.List; +import java.util.Objects; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -39,7 +40,6 @@ import org.apache.commons.lang3.StringUtils; import io.github.carlos_emr.carlos.commn.dao.ReportByExamplesFavoriteDao; import io.github.carlos_emr.carlos.commn.model.ReportByExamplesFavorite; -import io.github.carlos_emr.carlos.utility.MiscUtils; import io.github.carlos_emr.carlos.utility.SpringUtils; import io.github.carlos_emr.carlos.report.bean.RptByExampleQueryBeanHandler; @@ -60,31 +60,49 @@ public class RptByExamplesFavorite2Action extends ActionSupport { private ReportByExamplesFavoriteDao dao = SpringUtils.getBean(ReportByExamplesFavoriteDao.class); + /** + * Handles the POST-only create, edit, update, and delete workflows for the + * current provider's Query-by-Example favorites. The caller must hold + * {@code _admin} or {@code _report} read privilege, and stored records are + * checked for provider ownership before mutation. + * + * @return {@link #NONE} after rejecting a non-POST request, {@code "edit"} + * when preparing the editor, or {@link #SUCCESS} after a completed mutation + * @throws ServletException if servlet processing fails + * @throws IOException if the method-rejection response cannot be written + * @throws SecurityException if authorization or provider ownership validation fails + */ // FindSecBugs IMPROPER_UNICODE: case-insensitive comparison of an internal/domain value (status/flag/enum/MIME/code); not a security or authorization decision. See docs/static-analysis-workflows.md @SuppressFBWarnings(value = "IMPROPER_UNICODE", justification = "case-insensitive comparison of an internal/domain value (status/flag/enum/MIME/code); not a security or authorization decision") public String execute() throws ServletException, IOException { LoggedInInfo loggedInInfo = LoggedInInfo.getLoggedInInfoFromSession(request); - if (!securityInfoManager.hasPrivilege(loggedInInfo, "_report", "r", null)) { - throw new SecurityException("missing required sec object (_report)"); + if (!securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null) + && !securityInfoManager.hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null)) { + throw new SecurityException("missing required sec object (_admin or _report)"); + } + if (!"POST".equalsIgnoreCase(request.getMethod())) { + response.setHeader("Allow", "POST"); + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + return NONE; } - String providerNo = (String) request.getSession().getAttribute("user"); + String providerNo = loggedInInfo.getLoggedInProviderNo(); if (!StringUtils.isEmpty(this.getNewQuery())) { // Edit case - this.setQuery(this.getNewQuery()); - if (!StringUtils.isEmpty(this.getNewName())) { - this.setFavoriteName(this.getNewName()); + if (hasFavoriteId()) { + ReportByExamplesFavorite favorite = requireOwnedFavorite(providerNo, this.getId()); + this.setQuery(favorite.getQuery()); + this.setFavoriteName(favorite.getName()); } else { - ReportByExamplesFavoriteDao dao = SpringUtils.getBean(ReportByExamplesFavoriteDao.class); - for (ReportByExamplesFavorite f : dao.findByQuery(this.getNewQuery())) { - this.setFavoriteName(f.getName()); - } + prepareNewFavorite(providerNo); } return "edit"; } else if ("true".equalsIgnoreCase(this.getToDelete())) { // Deletion case - deleteQuery(this.getId()); + deleteQuery(providerNo, this.getId()); + } else if (hasFavoriteId()) { + updateFavorite(providerNo, this.getId(), this.getFavoriteName(), this.getQuery()); } else { // Add to favorite case String favoriteName = this.getFavoriteName(); @@ -99,14 +117,21 @@ public String execute() throws ServletException, IOException { return SUCCESS; } + /** + * Creates a favorite or updates the first exact provider/name/query match. + * A null or empty query is ignored. Authorization is enforced by the calling + * {@link #execute()} workflow. + * + * @param providerNo owner of the favorite + * @param favoriteName display name for the favorite + * @param query SQL text to store + * @throws RuntimeException if favorite lookup or persistence fails + */ public void write2Database(String providerNo, String favoriteName, String query) { if (query == null || query.compareTo("") == 0) { return; } - MiscUtils.getLogger().debug("Fav " + favoriteName + " query " + query); - - ReportByExamplesFavoriteDao dao = SpringUtils.getBean(ReportByExamplesFavoriteDao.class); List favorites = dao.findByEverything(providerNo, favoriteName, query); if (favorites.isEmpty()) { ReportByExamplesFavorite r = new ReportByExamplesFavorite(); @@ -125,8 +150,54 @@ public void write2Database(String providerNo, String favoriteName, String query) } - public void deleteQuery(String id) { - dao.remove(Integer.parseInt(id)); + /** + * Deletes a favorite after verifying that it belongs to the supplied provider. + * Authorization is enforced by the calling {@link #execute()} workflow. + * + * @param providerNo expected owner of the favorite + * @param id numeric favorite identifier + * @throws SecurityException if the identifier is invalid or the favorite is not provider-owned + * @throws RuntimeException if persistence fails + */ + public void deleteQuery(String providerNo, String id) { + dao.remove(requireOwnedFavorite(providerNo, id)); + } + + private void prepareNewFavorite(String providerNo) { + this.setQuery(this.getNewQuery()); + if (!StringUtils.isEmpty(this.getNewName())) { + this.setFavoriteName(this.getNewName()); + return; + } + List favorites = dao.findByProviderAndQuery(providerNo, this.getNewQuery()); + if (!favorites.isEmpty()) { + this.setFavoriteName(favorites.get(0).getName()); + } + } + + private void updateFavorite(String providerNo, String id, String favoriteName, String query) { + ReportByExamplesFavorite favorite = requireOwnedFavorite(providerNo, id); + favorite.setName(favoriteName); + favorite.setQuery(StringUtils.defaultIfEmpty(query, favorite.getQuery())); + dao.merge(favorite); + } + + private ReportByExamplesFavorite requireOwnedFavorite(String providerNo, String id) { + final int favoriteId; + try { + favoriteId = Integer.parseInt(id); + } catch (NumberFormatException e) { + throw new SecurityException("Invalid favorite selection", e); + } + ReportByExamplesFavorite favorite = dao.find(favoriteId); + if (favorite == null || !Objects.equals(providerNo, favorite.getProviderNo())) { + throw new SecurityException("Favorite does not belong to the current provider"); + } + return favorite; + } + + private boolean hasFavoriteId() { + return StringUtils.isNotBlank(this.getId()) && !"error".equals(this.getId()); } diff --git a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptViewAllQueryByExamples2Action.java b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptViewAllQueryByExamples2Action.java index ee213fcf534..07afe049d99 100644 --- a/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptViewAllQueryByExamples2Action.java +++ b/src/main/java/io/github/carlos_emr/carlos/report/pageUtil/RptViewAllQueryByExamples2Action.java @@ -59,8 +59,9 @@ public class RptViewAllQueryByExamples2Action extends ActionSupport { public String execute() throws ServletException, IOException { LoggedInInfo loggedInInfo = LoggedInInfo.getLoggedInInfoFromSession(request); - if (!securityInfoManager.hasPrivilege(loggedInInfo, "_report", "r", null)) { - throw new SecurityException("missing required sec object (_report)"); + if (!securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null) + && !securityInfoManager.hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null)) { + throw new SecurityException("missing required sec object (_admin or _report)"); } RptByExampleQueryBeanHandler hd = new RptByExampleQueryBeanHandler(startDate, endDate); diff --git a/src/main/resources/carlos.properties b/src/main/resources/carlos.properties index d4c28812ae3..2b475ecbcc0 100644 --- a/src/main/resources/carlos.properties +++ b/src/main/resources/carlos.properties @@ -50,6 +50,9 @@ buildVersion=${build.JOB_NAME} ${build.BUILD_NUMBER} # legacy functionality. Ensure that you leave the tags behind the field when renaming the database. db_name = oscar_mcmaster?zeroDateTimeBehavior=round&useOldAliasMetadataBehavior=true&jdbcCompliantTruncation=false +# Authorized Query-by-Example users may run validated, read-only SELECT queries. +QUERY_BY_EXAMPLE_ENABLED = true + # username db_username = root diff --git a/src/main/resources/oscarResources_en.properties b/src/main/resources/oscarResources_en.properties index d7494d7448b..646ae54056a 100644 --- a/src/main/resources/oscarResources_en.properties +++ b/src/main/resources/oscarResources_en.properties @@ -8923,6 +8923,14 @@ oscarReport.RptByExample.MsgViewQueryHistory=View Query History oscarReport.RptByExample.MsgLoadQuery=Load Query oscarReport.RptByExample.MsgRunQuery=Run Query +oscarReport.RptByExample.MsgDisabled=Direct query execution is currently disabled. Your query has not been run. +oscarReport.RptByExample.MsgValidationError=Only one read-only SELECT from the application database is allowed. Comments, UNION, locking, output operations, and prohibited functions are not permitted. +oscarReport.RptByExample.MsgTimeout=The query exceeded the {0}-second time limit and was stopped. +oscarReport.RptByExample.MsgExecutionError=The query could not be completed. Check the query and try again. +oscarReport.RptByExample.MsgHistoryError=The query ran successfully, but it could not be saved to your query history. +oscarReport.RptByExample.MsgResultLimit=Returned {0} rows (limit: {1}). +oscarReport.RptByExample.MsgOutputTruncated=Output was truncated after {0} characters. +oscarReport.RptByExample.MsgRowLimitReached=Additional rows were omitted after the {0}-row display limit was reached. oscarReport.RptByExample.MsgConfirmDelete=Are you sure you want to delete the selected query? diff --git a/src/main/resources/oscarResources_es.properties b/src/main/resources/oscarResources_es.properties index 35b70a92161..68a09ca4ac5 100644 --- a/src/main/resources/oscarResources_es.properties +++ b/src/main/resources/oscarResources_es.properties @@ -6446,6 +6446,22 @@ oscarReport.RptByExample.MsgViewQueryHistory=Ver historial de consultas oscarReport.RptByExample.MsgLoadQuery=Cargar consulta oscarReport.RptByExample.MsgRunQuery=Ejecutar +# TODO: translate +oscarReport.RptByExample.MsgDisabled=Direct query execution is currently disabled. Your query has not been run. +# TODO: translate +oscarReport.RptByExample.MsgValidationError=Only one read-only SELECT from the application database is allowed. Comments, UNION, locking, output operations, and prohibited functions are not permitted. +# TODO: translate +oscarReport.RptByExample.MsgTimeout=The query exceeded the {0}-second time limit and was stopped. +# TODO: translate +oscarReport.RptByExample.MsgExecutionError=The query could not be completed. Check the query and try again. +# TODO: translate +oscarReport.RptByExample.MsgHistoryError=The query ran successfully, but it could not be saved to your query history. +# TODO: translate +oscarReport.RptByExample.MsgResultLimit=Returned {0} rows (limit: {1}). +# TODO: translate +oscarReport.RptByExample.MsgOutputTruncated=Output was truncated after {0} characters. +# TODO: translate +oscarReport.RptByExample.MsgRowLimitReached=Additional rows were omitted after the {0}-row display limit was reached. oscarReport.RptByExample.MsgConfirmDelete=\u00bfEst\u00e1 seguro de que desea eliminar la consulta seleccionada? diff --git a/src/main/resources/oscarResources_fr.properties b/src/main/resources/oscarResources_fr.properties index 1a01c467cee..fb64fd3fecf 100644 --- a/src/main/resources/oscarResources_fr.properties +++ b/src/main/resources/oscarResources_fr.properties @@ -5628,6 +5628,22 @@ oscarReport.RptByExample.MsgRefresh=Actualiser oscarReport.RptByExample.MsgViewQueryHistory=Voir l\u2019historique des requ\u00eates oscarReport.RptByExample.MsgLoadQuery=Charger la requ\u00eate oscarReport.RptByExample.MsgRunQuery=Ex\u00e9cuter +# TODO: translate +oscarReport.RptByExample.MsgDisabled=Direct query execution is currently disabled. Your query has not been run. +# TODO: translate +oscarReport.RptByExample.MsgValidationError=Only one read-only SELECT from the application database is allowed. Comments, UNION, locking, output operations, and prohibited functions are not permitted. +# TODO: translate +oscarReport.RptByExample.MsgTimeout=The query exceeded the {0}-second time limit and was stopped. +# TODO: translate +oscarReport.RptByExample.MsgExecutionError=The query could not be completed. Check the query and try again. +# TODO: translate +oscarReport.RptByExample.MsgHistoryError=The query ran successfully, but it could not be saved to your query history. +# TODO: translate +oscarReport.RptByExample.MsgResultLimit=Returned {0} rows (limit: {1}). +# TODO: translate +oscarReport.RptByExample.MsgOutputTruncated=Output was truncated after {0} characters. +# TODO: translate +oscarReport.RptByExample.MsgRowLimitReached=Additional rows were omitted after the {0}-row display limit was reached. oscarReport.RptByExample.MsgConfirmDelete=\u00cates-vous s\u00fbr de vouloir supprimer la requ\u00eate s\u00e9lectionn\u00e9e\u00a0? diff --git a/src/main/resources/oscarResources_pl.properties b/src/main/resources/oscarResources_pl.properties index 8e4b767be72..e3fb3f7eb32 100644 --- a/src/main/resources/oscarResources_pl.properties +++ b/src/main/resources/oscarResources_pl.properties @@ -5864,6 +5864,22 @@ oscarReport.RptByExample.MsgViewQueryHistory=Historia zapyta\u0144 oscarReport.RptByExample.MsgLoadQuery=Za\u0142aduj zapytanie oscarReport.RptByExample.MsgRunQuery=Wykonaj +# TODO: translate +oscarReport.RptByExample.MsgDisabled=Direct query execution is currently disabled. Your query has not been run. +# TODO: translate +oscarReport.RptByExample.MsgValidationError=Only one read-only SELECT from the application database is allowed. Comments, UNION, locking, output operations, and prohibited functions are not permitted. +# TODO: translate +oscarReport.RptByExample.MsgTimeout=The query exceeded the {0}-second time limit and was stopped. +# TODO: translate +oscarReport.RptByExample.MsgExecutionError=The query could not be completed. Check the query and try again. +# TODO: translate +oscarReport.RptByExample.MsgHistoryError=The query ran successfully, but it could not be saved to your query history. +# TODO: translate +oscarReport.RptByExample.MsgResultLimit=Returned {0} rows (limit: {1}). +# TODO: translate +oscarReport.RptByExample.MsgOutputTruncated=Output was truncated after {0} characters. +# TODO: translate +oscarReport.RptByExample.MsgRowLimitReached=Additional rows were omitted after the {0}-row display limit was reached. oscarReport.RptByExample.MsgConfirmDelete=Czy na pewno chcesz usun\u0105\u0107 wybran\u0105 kwerend\u0119? diff --git a/src/main/resources/oscarResources_pt_BR.properties b/src/main/resources/oscarResources_pt_BR.properties index f48663bb5d6..b1c4fe8f0a7 100644 --- a/src/main/resources/oscarResources_pt_BR.properties +++ b/src/main/resources/oscarResources_pt_BR.properties @@ -7351,6 +7351,22 @@ oscarReport.RptByExample.MsgViewQueryHistory=Ver hist\u00f3rico de consultas oscarReport.RptByExample.MsgLoadQuery=Carregar consulta oscarReport.RptByExample.MsgRunQuery=Executar +# TODO: translate +oscarReport.RptByExample.MsgDisabled=Direct query execution is currently disabled. Your query has not been run. +# TODO: translate +oscarReport.RptByExample.MsgValidationError=Only one read-only SELECT from the application database is allowed. Comments, UNION, locking, output operations, and prohibited functions are not permitted. +# TODO: translate +oscarReport.RptByExample.MsgTimeout=The query exceeded the {0}-second time limit and was stopped. +# TODO: translate +oscarReport.RptByExample.MsgExecutionError=The query could not be completed. Check the query and try again. +# TODO: translate +oscarReport.RptByExample.MsgHistoryError=The query ran successfully, but it could not be saved to your query history. +# TODO: translate +oscarReport.RptByExample.MsgResultLimit=Returned {0} rows (limit: {1}). +# TODO: translate +oscarReport.RptByExample.MsgOutputTruncated=Output was truncated after {0} characters. +# TODO: translate +oscarReport.RptByExample.MsgRowLimitReached=Additional rows were omitted after the {0}-row display limit was reached. oscarReport.RptByExample.MsgConfirmDelete=Tem certeza de que deseja excluir a consulta selecionada? diff --git a/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExample.jsp b/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExample.jsp index ae034eb4eb4..3088af10b22 100644 --- a/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExample.jsp +++ b/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExample.jsp @@ -49,7 +49,7 @@ - results — HTML string of query results (rendered unescaped; backend-generated) Security: - - Requires _report or _admin.reporting read privilege + - Requires _report or _admin read privilege - CSRF token auto-injected by CsrfGuardScriptInjectionFilter @since 2001-2002 @@ -68,9 +68,9 @@ String roleName$ = session.getAttribute("userrole") + "," + session.getAttribute("user"); boolean authed = true; %> - + <%authed = false; %> - <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin.reporting");%> + <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin");%> <% if (!authed) { @@ -161,9 +161,37 @@ + class="form-control form-control-sm">${carlos:forHtmlContent(submittedSql)} + + + + + + + + + + + + + + + +
@@ -211,6 +239,26 @@
+

+ + + + +

+ + + + + + ${results}
diff --git a/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExamplesAllFavorites.jsp b/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExamplesAllFavorites.jsp index 4c0663a02ed..38843497af1 100644 --- a/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExamplesAllFavorites.jsp +++ b/src/main/webapp/WEB-INF/jsp/oscarReport/RptByExamplesAllFavorites.jsp @@ -47,7 +47,7 @@ (items expose: id, queryName, query) Security: - - Requires _report or _admin.reporting read privilege + - Requires _report or _admin read privilege - CSRF token auto-injected by CsrfGuardScriptInjectionFilter @since 2001-2002 @@ -61,9 +61,9 @@ String roleName$ = session.getAttribute("userrole") + "," + session.getAttribute("user"); boolean authed = true; %> - + <%authed = false; %> - <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin.reporting");%> + <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin");%> <% if (!authed) { @@ -104,9 +104,11 @@ * @param {string} text1 - The raw SQL query text (JS-attribute-encoded by JSP) * @param {string} text2 - The display name of the favourite (JS-attribute-encoded by JSP) */ - function set(text1, text2) { - document.getElementById('favoritesForm').newQuery.value = text1; - document.getElementById('favoritesForm').newName.value = text2; + function set(text1, text2, id) { + const form = document.getElementById('favoritesForm'); + form.elements['newQuery'].value = text1; + form.elements['newName'].value = text2; + form.elements['id'].value = id; } /** @@ -117,9 +119,10 @@ */ function confirmDelete(id) { if (confirm(msgConfirmDelete)) { - document.getElementById('favoritesForm').toDelete.value = 'true'; - document.getElementById('favoritesForm').id.value = id; - document.getElementById('favoritesForm').submit(); + const form = document.getElementById('favoritesForm'); + form.elements['toDelete'].value = 'true'; + form.elements['id'].value = id; + form.submit(); } } @@ -189,7 +192,7 @@ + onclick="set('${carlos:forJavaScript(favorite.query)}', '${carlos:forJavaScript(favorite.queryName)}', '${carlos:forJavaScript(favorite.id)}'); document.getElementById('favoritesForm').submit(); return false;"/> +<%-- + RptByExamplesFavorite.jsp + ========================= + Purpose: Edit a saved Query-by-Example favorite before returning to the + favorites list. + + Features: + - Requires _report or _admin read privilege + - Localized favorite-name and SQL editing form + - POST-only submission to RptByExamplesFavorite + + Parameters (set by backing action): + - favoriteName — Display name for the favorite + - newQuery — SQL text being edited + + @since 2001-2002 +--%> + <%@ taglib uri="/WEB-INF/security.tld" prefix="security" %> <% String roleName$ = (String) session.getAttribute("userrole") + "," + (String) session.getAttribute("user"); boolean authed = true; %> - + <%authed = false; %> - <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin.reporting");%> + <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin");%> <% if (!authed) { @@ -44,63 +62,93 @@ } %> -<%@ page import="java.util.*,io.github.carlos_emr.carlos.report.data.*" %> <%@ taglib uri="jakarta.tags.fmt" prefix="fmt" %> +<%@ taglib uri="carlos" prefix="carlos" %> - - + - - - + + + + <fmt:message key="oscarReport.RptByExample.MsgQueryByExamples"/> - <fmt:message key="oscarReport.RptByExample.MsgEditMyFavorite"/> - - - - - - - - - - - - - - -
- - - - -
-
-
- - - - - - - - - - - - -
-
- + + +
+
+ +
+
+
+ - + +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
diff --git a/src/main/webapp/WEB-INF/jsp/oscarReport/RptViewAllQueryByExamples.jsp b/src/main/webapp/WEB-INF/jsp/oscarReport/RptViewAllQueryByExamples.jsp index 7a94d7d5648..b012778d5e8 100644 --- a/src/main/webapp/WEB-INF/jsp/oscarReport/RptViewAllQueryByExamples.jsp +++ b/src/main/webapp/WEB-INF/jsp/oscarReport/RptViewAllQueryByExamples.jsp @@ -48,7 +48,7 @@ - endDate — End date filter currently applied (String) Security: - - Requires _report or _admin.reporting read privilege + - Requires _report or _admin read privilege - CSRF token auto-injected by CsrfGuardScriptInjectionFilter @since 2001-2002 @@ -62,9 +62,9 @@ String roleName$ = session.getAttribute("userrole") + "," + session.getAttribute("user"); boolean authed = true; %> - + <%authed = false; %> - <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin.reporting");%> + <%response.sendRedirect(request.getContextPath() + "/securityError?type=_report&type=_admin");%> <% if (!authed) { diff --git a/src/test/java/io/github/carlos_emr/carlos/app/contract/MutatorActionGetRejectionContractUnitTest.java b/src/test/java/io/github/carlos_emr/carlos/app/contract/MutatorActionGetRejectionContractUnitTest.java index 6da744eaf48..3df829e3dab 100644 --- a/src/test/java/io/github/carlos_emr/carlos/app/contract/MutatorActionGetRejectionContractUnitTest.java +++ b/src/test/java/io/github/carlos_emr/carlos/app/contract/MutatorActionGetRejectionContractUnitTest.java @@ -191,6 +191,8 @@ static Stream unconditionalMutators() { "_admin.reporting", "w"), Arguments.of("io.github.carlos_emr.carlos.report.pageUtil.DbReportAgeSex2Action", "_report", "r"), + Arguments.of("io.github.carlos_emr.carlos.report.pageUtil.RptByExamplesFavorite2Action", + "_admin", "r"), // --- signature --- Arguments.of("io.github.carlos_emr.carlos.signature.action.SaveSignatureUpload2Action", "_con", "w"), diff --git a/src/test/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoIntegrationTest.java b/src/test/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoIntegrationTest.java index ffd9b91e269..56d9301c6a1 100644 --- a/src/test/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoIntegrationTest.java +++ b/src/test/java/io/github/carlos_emr/carlos/commn/dao/ReportByExamplesFavoriteDaoIntegrationTest.java @@ -36,7 +36,7 @@ /** * Integration tests for {@link ReportByExamplesFavoriteDao} covering persist, - * findByQuery, findByEverything, and findByProvider. + * provider-scoped lookup, findByEverything, and findByProvider. * *

Migrated from legacy {@code ReportByExamplesFavoriteDaoTest} (JUnit 4 / DaoTestFixtures).

* @@ -92,32 +92,21 @@ void shouldFindFavorite_whenValidIdProvided() { } @Nested - @DisplayName("findByQuery") - class FindByQuery { + @DisplayName("findByProviderAndQuery") + class FindByProviderAndQuery { @Test @Tag("query") - @DisplayName("should return favorites with matching query string using LIKE") - void shouldReturnFavorites_whenQueryMatches() { - createFavorite("200001", "Fav1", "SELECT demographics"); - createFavorite("200002", "Fav2", "SELECT appointments"); - createFavorite("200003", "Fav3", "INSERT something"); + @DisplayName("should not return another provider's matching query") + void shouldReturnOnlyCurrentProviderFavorites_whenQueryMatches() { + createFavorite("200010", "Mine", "SELECT appointments"); + createFavorite("200011", "Theirs", "SELECT appointments"); - List results = dao.findByQuery("SELECT%"); + List results = + dao.findByProviderAndQuery("200010", "SELECT appointments"); - assertThat(results).hasSize(2); - assertThat(results).allMatch(f -> f.getQuery().startsWith("SELECT")); - } - - @Test - @Tag("query") - @DisplayName("should return empty list when no query matches") - void shouldReturnEmptyList_whenNoQueryMatches() { - createFavorite("200001", "Fav1", "SELECT something"); - - List results = dao.findByQuery("NO_MATCH%"); - - assertThat(results).isEmpty(); + assertThat(results).singleElement() + .satisfies(favorite -> assertThat(favorite.getName()).isEqualTo("Mine")); } } @@ -127,30 +116,27 @@ class FindByEverything { @Test @Tag("query") - @DisplayName("should return favorites matching provider and name") - void shouldReturnFavorites_whenProviderAndNameMatch() { + @DisplayName("should require provider, name, and query to match") + void shouldReturnFavorites_whenAllFieldsMatch() { createFavorite("300001", "MatchFav", "some query"); createFavorite("300001", "OtherFav", "other query"); createFavorite("300002", "MatchFav", "diff query"); - List results = dao.findByEverything("300001", "MatchFav", "NO_MATCH"); + List results = dao.findByEverything("300001", "MatchFav", "some query"); - assertThat(results).isNotEmpty(); - assertThat(results).anyMatch(f -> - f.getProviderNo().equals("300001") && f.getName().equals("MatchFav")); + assertThat(results).singleElement() + .satisfies(favorite -> assertThat(favorite.getProviderNo()).isEqualTo("300001")); } @Test @Tag("query") - @DisplayName("should return favorites matching query string via OR clause") - void shouldReturnFavorites_whenQueryStringMatchesViaOr() { + @DisplayName("should not return another provider's matching query") + void shouldReturnEmpty_whenOnlyQueryMatches() { createFavorite("300003", "SomeFav", "unique query string"); - // The findByEverything method uses OR for query: providerNo = ?1 AND name LIKE ?2 OR query LIKE ?3 List results = dao.findByEverything("NOPROVIDER", "NONAME", "unique query string"); - assertThat(results).isNotEmpty(); - assertThat(results).anyMatch(f -> f.getQuery().equals("unique query string")); + assertThat(results).isEmpty(); } } diff --git a/src/test/java/io/github/carlos_emr/carlos/db/LegacyJdbcQueryUnitTest.java b/src/test/java/io/github/carlos_emr/carlos/db/LegacyJdbcQueryUnitTest.java index 8cede3d535a..b6c2ccd42e1 100644 --- a/src/test/java/io/github/carlos_emr/carlos/db/LegacyJdbcQueryUnitTest.java +++ b/src/test/java/io/github/carlos_emr/carlos/db/LegacyJdbcQueryUnitTest.java @@ -61,6 +61,9 @@ void releaseLegacyJdbcResources() { void shouldAllowSelectOnlyQueries_forAdminReportBoundary() { assertThatCode(() -> validateSafeSelectQuery("select demographic_no from demographic")) .doesNotThrowAnyException(); + assertThatCode(() -> validateSafeSelectQuery( + "select 'update delete create drop' as instruction from demographic")) + .doesNotThrowAnyException(); } @Test @@ -138,6 +141,20 @@ void shouldRejectUnsafeQueries_forAdminReportBoundary() { .hasMessageContaining("UNION"); } + @Test + @DisplayName("should reject SQL-mode-dependent backslash quote escapes") + void shouldRejectBackslashQuotedSql_whenSqlModeIsUnknown() { + String ambiguousSql = "select 'safe\\' union select provider_no from provider"; + + assertThatThrownBy(() -> LegacyJdbcQuery.trustedSelectSql(ambiguousSql)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("comment or statement separator"); + assertThatThrownBy(() -> LegacyJdbcQuery.trustedReportSelectSql(ambiguousSql)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("comment or statement separator"); + assertThat(LegacyJdbcQuery.containsUnsafeSqlControlToken(ambiguousSql)).isTrue(); + } + @Test @DisplayName("shouldRejectBlockedPatterns_forAdminReportBoundary") void shouldRejectBlockedPatterns_forAdminReportBoundary() { diff --git a/src/test/java/io/github/carlos_emr/carlos/report/ReportActionSecurityMigrationUnitTest.java b/src/test/java/io/github/carlos_emr/carlos/report/ReportActionSecurityMigrationUnitTest.java index 30ef00ac5de..e1d8f02a86b 100644 --- a/src/test/java/io/github/carlos_emr/carlos/report/ReportActionSecurityMigrationUnitTest.java +++ b/src/test/java/io/github/carlos_emr/carlos/report/ReportActionSecurityMigrationUnitTest.java @@ -21,13 +21,20 @@ */ package io.github.carlos_emr.carlos.report; -import java.util.Collections; +import java.util.Properties; -import io.github.carlos_emr.carlos.PMmodule.dao.SecUserRoleDao; +import io.github.carlos_emr.CarlosProperties; import io.github.carlos_emr.carlos.commn.dao.ReportByExamplesDao; +import io.github.carlos_emr.carlos.commn.dao.ReportByExamplesFavoriteDao; import io.github.carlos_emr.carlos.commn.dao.ReportTemplatesDao; +import io.github.carlos_emr.carlos.commn.model.ReportByExamples; +import io.github.carlos_emr.carlos.commn.model.ReportByExamplesFavorite; import io.github.carlos_emr.carlos.managers.SecurityInfoManager; +import io.github.carlos_emr.carlos.report.data.RptByExampleData; import io.github.carlos_emr.carlos.report.pageUtil.RptByExample2Action; +import io.github.carlos_emr.carlos.report.pageUtil.RptByExamplesAllFavorites2Action; +import io.github.carlos_emr.carlos.report.pageUtil.RptByExamplesFavorite2Action; +import io.github.carlos_emr.carlos.report.pageUtil.RptViewAllQueryByExamples2Action; import io.github.carlos_emr.carlos.report.reportByTemplate.ReportFactory; import io.github.carlos_emr.carlos.report.reportByTemplate.ReportManager; import io.github.carlos_emr.carlos.report.reportByTemplate.Reporter; @@ -52,9 +59,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -71,15 +82,17 @@ class ReportActionSecurityMigrationUnitTest extends CarlosUnitTestBase { private MockHttpServletResponse response; private SecurityInfoManager securityInfoManager; private LoggedInInfo loggedInInfo; - private SecUserRoleDao secUserRoleDao; + private ReportByExamplesDao reportByExamplesDao; + private ReportByExamplesFavoriteDao favoritesDao; @BeforeEach void setUp() { securityInfoManager = mock(SecurityInfoManager.class); - secUserRoleDao = mock(SecUserRoleDao.class); + reportByExamplesDao = mock(ReportByExamplesDao.class); registerMock(SecurityInfoManager.class, securityInfoManager); - registerMock(SecUserRoleDao.class, secUserRoleDao); - registerMock(ReportByExamplesDao.class, mock(ReportByExamplesDao.class)); + registerMock(ReportByExamplesDao.class, reportByExamplesDao); + favoritesDao = mock(ReportByExamplesFavoriteDao.class); + registerMock(ReportByExamplesFavoriteDao.class, favoritesDao); registerMock(ReportTemplatesDao.class, mock(ReportTemplatesDao.class)); request = new MockHttpServletRequest(); @@ -92,7 +105,7 @@ void setUp() { loggedInInfo = mock(LoggedInInfo.class); when(loggedInInfo.getLoggedInProviderNo()).thenReturn("999998"); - when(secUserRoleDao.findByRoleNameAndProviderNo("admin", "999998")).thenReturn(Collections.emptyList()); + when(favoritesDao.findByProvider("999998")).thenReturn(java.util.Collections.emptyList()); } @AfterEach @@ -129,26 +142,29 @@ void shouldRequireAdminOrReportReadPrivilege_forMigratedActions() { when(securityInfoManager.hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null)).thenReturn(false); assertMissingPrivilegeFails(new RptByExample2Action()); + assertMissingPrivilegeFails(new RptViewAllQueryByExamples2Action()); + assertMissingPrivilegeFails(new RptByExamplesAllFavorites2Action()); + assertMissingPrivilegeFails(new RptByExamplesFavorite2Action()); assertMissingPrivilegeFails(new ExportTemplate2Action()); assertMissingPrivilegeFails(new GenerateOutFiles2Action()); assertMissingPrivilegeFails(new GenerateReport2Action()); assertMissingPrivilegeFails(new UploadTemplates2Action()); - verify(securityInfoManager, times(5)) + verify(securityInfoManager, times(8)) .hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null); - verify(securityInfoManager, times(5)) + verify(securityInfoManager, times(8)) .hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null); } @Test @DisplayName("migrated actions skip report privilege check when admin read is present") - void shouldSkipReportReadPrivilege_whenAdminReadPrivilegeAllowsAccess() { + void shouldSkipReportReadPrivilege_whenAdminReadPrivilegeAllowsAccess() throws Exception { LoggedInInfo.setLoggedInInfoIntoSession(request.getSession(), loggedInInfo); when(securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null)).thenReturn(true); assertAuthorizedMigrationGateAllowsActionBody(); - verify(securityInfoManager, times(5)) + verify(securityInfoManager, times(7)) .hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null); verify(securityInfoManager, never()) .hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null); @@ -156,19 +172,206 @@ void shouldSkipReportReadPrivilege_whenAdminReadPrivilegeAllowsAccess() { @Test @DisplayName("migrated actions allow report read privilege when admin read is missing") - void shouldAllowAccess_whenReportReadPrivilegeAllowsAccess() { + void shouldAllowAccess_whenReportReadPrivilegeAllowsAccess() throws Exception { LoggedInInfo.setLoggedInInfoIntoSession(request.getSession(), loggedInInfo); when(securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null)).thenReturn(false); when(securityInfoManager.hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null)).thenReturn(true); assertAuthorizedMigrationGateAllowsActionBody(); - verify(securityInfoManager, times(5)) + verify(securityInfoManager, times(7)) .hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null); - verify(securityInfoManager, times(5)) + verify(securityInfoManager, times(7)) .hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null); } + @Test + @DisplayName("RptByExample keeps the form available but does not execute or save when disabled") + void shouldKeepFormWithoutSaving_whenQueryByExampleIsDisabled() throws Exception { + LoggedInInfo.setLoggedInInfoIntoSession(request.getSession(), loggedInInfo); + when(securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null)).thenReturn(false); + when(securityInfoManager.hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null)).thenReturn(true); + request.setMethod("POST"); + RptByExample2Action action = new RptByExample2Action(); + action.setSql("select demographic_no from demographic"); + + CarlosProperties properties = CarlosProperties.getInstance(); + String previousValue = properties.getProperty(RptByExample2Action.ENABLED_PROPERTY); + try (MockedConstruction reportData = mockConstruction(RptByExampleData.class)) { + properties.setProperty(RptByExample2Action.ENABLED_PROPERTY, "false"); + assertThat(action.execute()).isEqualTo(ActionSupport.SUCCESS); + assertThat(reportData.constructed()).isEmpty(); + } finally { + if (previousValue == null) { + properties.remove(RptByExample2Action.ENABLED_PROPERTY); + } else { + properties.setProperty(RptByExample2Action.ENABLED_PROPERTY, previousValue); + } + } + + assertThat(request.getAttribute("queryDisabled")).isEqualTo(true); + assertThat(request.getAttribute("submittedSql")).isEqualTo("select demographic_no from demographic"); + verifyNoInteractions(reportByExamplesDao); + verify(favoritesDao).findByProvider("999998"); + } + + @Test + @DisplayName("RptByExamplesFavorite rejects non-POST requests before writing") + void shouldRejectNonPostMethods_beforeFavoriteWrite() throws Exception { + LoggedInInfo.setLoggedInInfoIntoSession(request.getSession(), loggedInInfo); + when(securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null)).thenReturn(true); + request.setMethod("GET"); + RptByExamplesFavorite2Action getAction = spy(new RptByExamplesFavorite2Action()); + getAction.setQuery("select demographic_no from demographic"); + + assertThat(getAction.execute()).isEqualTo(ActionSupport.NONE); + assertThat(response.getStatus()).isEqualTo(405); + assertThat(response.getHeader("Allow")).isEqualTo("POST"); + verify(getAction, never()).write2Database(any(), any(), any()); + + response = new MockHttpServletResponse(); + servletActionContextMock.when(ServletActionContext::getResponse).thenReturn(response); + request.setMethod("HEAD"); + RptByExamplesFavorite2Action headAction = spy(new RptByExamplesFavorite2Action()); + headAction.setQuery("select demographic_no from demographic"); + + assertThat(headAction.execute()).isEqualTo(ActionSupport.NONE); + assertThat(response.getStatus()).isEqualTo(405); + assertThat(response.getHeader("Allow")).isEqualTo("POST"); + verify(headAction, never()).write2Database(any(), any(), any()); + verifyNoInteractions(reportByExamplesDao, favoritesDao); + } + + @Test + @DisplayName("RptByExamplesFavorite permits an authorized POST mutation") + void shouldAllowFavoriteWrite_whenAuthorizedPostIsSubmitted() throws Exception { + LoggedInInfo.setLoggedInInfoIntoSession(request.getSession(), loggedInInfo); + when(securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null)).thenReturn(true); + request.getSession().setAttribute("user", "999998"); + request.setMethod("POST"); + RptByExamplesFavorite2Action action = spy(new RptByExamplesFavorite2Action()); + action.setFavoriteName("Active patients"); + action.setQuery("select demographic_no from demographic"); + org.mockito.Mockito.doNothing().when(action).write2Database( + "999998", "Active patients", "select demographic_no from demographic"); + + assertThat(action.execute()).isEqualTo(ActionSupport.SUCCESS); + + verify(action).write2Database("999998", "Active patients", "select demographic_no from demographic"); + verify(favoritesDao).findByProvider("999998"); + } + + @Test + @DisplayName("RptByExamplesFavorite rejects deletion of another provider's favorite") + void shouldRejectFavoriteDelete_whenFavoriteBelongsToAnotherProvider() { + authorizeFavoritePost(); + ReportByExamplesFavorite favorite = favorite(42, "100001", "Other provider", "select 1"); + when(favoritesDao.find(42)).thenReturn(favorite); + + RptByExamplesFavorite2Action action = new RptByExamplesFavorite2Action(); + action.setToDelete("true"); + action.setId("42"); + + assertThatThrownBy(action::execute) + .isInstanceOf(SecurityException.class) + .hasMessage("Favorite does not belong to the current provider"); + verify(favoritesDao, never()).remove(favorite); + } + + @Test + @DisplayName("RptByExamplesFavorite rejects editing another provider's favorite") + void shouldRejectFavoriteEdit_whenFavoriteBelongsToAnotherProvider() { + authorizeFavoritePost(); + ReportByExamplesFavorite favorite = favorite(42, "100001", "Other provider", "select 1"); + when(favoritesDao.find(42)).thenReturn(favorite); + + RptByExamplesFavorite2Action action = new RptByExamplesFavorite2Action(); + action.setId("42"); + action.setNewName("Spoofed name"); + action.setNewQuery("select 2"); + + assertThatThrownBy(action::execute) + .isInstanceOf(SecurityException.class) + .hasMessage("Favorite does not belong to the current provider"); + verify(favoritesDao, never()).merge(favorite); + } + + @Test + @DisplayName("RptByExamplesFavorite updates only the current provider's selected favorite") + void shouldUpdateFavorite_whenFavoriteBelongsToCurrentProvider() throws Exception { + authorizeFavoritePost(); + ReportByExamplesFavorite favorite = favorite(42, "999998", "Old name", "select 1"); + when(favoritesDao.find(42)).thenReturn(favorite); + + RptByExamplesFavorite2Action action = new RptByExamplesFavorite2Action(); + action.setId("42"); + action.setFavoriteName("New name"); + action.setQuery("select 2"); + + assertThat(action.execute()).isEqualTo(ActionSupport.SUCCESS); + + assertThat(favorite.getName()).isEqualTo("New name"); + assertThat(favorite.getQuery()).isEqualTo("select 2"); + verify(favoritesDao).merge(favorite); + verify(favoritesDao).findByProvider("999998"); + } + + @Test + @DisplayName("RptByExamplesFavorite preserves the saved query when an edit submits no query") + void shouldPreserveFavoriteQuery_whenEditedQueryIsEmpty() throws Exception { + authorizeFavoritePost(); + ReportByExamplesFavorite favorite = favorite(42, "999998", "Old name", "select 1"); + when(favoritesDao.find(42)).thenReturn(favorite); + + RptByExamplesFavorite2Action action = new RptByExamplesFavorite2Action(); + action.setId("42"); + action.setFavoriteName("New name"); + action.setQuery(""); + + assertThat(action.execute()).isEqualTo(ActionSupport.SUCCESS); + + assertThat(favorite.getName()).isEqualTo("New name"); + assertThat(favorite.getQuery()).isEqualTo("select 1"); + verify(favoritesDao).merge(favorite); + } + + @Test + @DisplayName("RptByExample keeps successful results when query history cannot be saved") + void shouldKeepResults_whenQueryHistorySaveFails() throws Exception { + LoggedInInfo.setLoggedInInfoIntoSession(request.getSession(), loggedInInfo); + when(securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null)).thenReturn(false); + when(securityInfoManager.hasPrivilege(loggedInInfo, "_report", SecurityInfoManager.READ, null)).thenReturn(true); + request.setMethod("POST"); + RptByExample2Action action = new RptByExample2Action(); + action.setSql("select demographic_no from demographic"); + doThrow(new IllegalStateException("history unavailable")) + .when(reportByExamplesDao).persist(any(ReportByExamples.class)); + + CarlosProperties properties = CarlosProperties.getInstance(); + String previousValue = properties.getProperty(RptByExample2Action.ENABLED_PROPERTY); + try (MockedConstruction reportData = mockConstruction( + RptByExampleData.class, + (mock, context) -> when(mock.execute( + eq("select demographic_no from demographic"), any(Properties.class), eq("999998"))) + .thenReturn(new RptByExampleData.QueryResult("
", 1, false, false, 23)))) { + properties.setProperty(RptByExample2Action.ENABLED_PROPERTY, " yes "); + assertThat(action.execute()).isEqualTo(ActionSupport.SUCCESS); + assertThat(reportData.constructed()).hasSize(1); + } finally { + if (previousValue == null) { + properties.remove(RptByExample2Action.ENABLED_PROPERTY); + } else { + properties.setProperty(RptByExample2Action.ENABLED_PROPERTY, previousValue); + } + } + + assertThat(request.getAttribute("results")).isEqualTo("
"); + assertThat(request.getAttribute("queryHistoryError")).isEqualTo(true); + assertThat(request.getAttribute("queryExecutionError")).isNull(); + assertThat(request.getAttribute("resultLimit")).isEqualTo(RptByExampleData.MAX_ROWS); + verify(reportByExamplesDao).persist(any(ReportByExamples.class)); + } + @Test @DisplayName("UploadTemplates passes LoggedInInfo when adding and editing templates") void shouldPassLoggedInInfo_whenAddingAndEditingTemplates() { @@ -200,16 +403,32 @@ private void assertMissingLoggedInInfoFails(ActionSupport action) { .hasMessage(MISSING_ADMIN_OR_REPORT); } + private void authorizeFavoritePost() { + LoggedInInfo.setLoggedInInfoIntoSession(request.getSession(), loggedInInfo); + when(securityInfoManager.hasPrivilege(loggedInInfo, "_admin", SecurityInfoManager.READ, null)) + .thenReturn(true); + request.setMethod("POST"); + } + + private static ReportByExamplesFavorite favorite(int id, String providerNo, String name, String query) { + ReportByExamplesFavorite favorite = new ReportByExamplesFavorite(); + favorite.setId(id); + favorite.setProviderNo(providerNo); + favorite.setName(name); + favorite.setQuery(query); + return favorite; + } + private void assertMissingPrivilegeFails(ActionSupport action) { assertThatThrownBy(action::execute) .isInstanceOf(SecurityException.class) .hasMessage(MISSING_ADMIN_OR_REPORT); } - private void assertAuthorizedMigrationGateAllowsActionBody() { - assertThatThrownBy(() -> new RptByExample2Action().execute()) - .isInstanceOf(SecurityException.class) - .hasMessage("missing required admin privileges to run query by example"); + private void assertAuthorizedMigrationGateAllowsActionBody() throws Exception { + assertThat(new RptByExample2Action().execute()).isEqualTo(ActionSupport.SUCCESS); + assertThat(new RptViewAllQueryByExamples2Action().execute()).isEqualTo(ActionSupport.SUCCESS); + assertThat(new RptByExamplesAllFavorites2Action().execute()).isEqualTo(ActionSupport.SUCCESS); request.setParameter("templateid", "template-1"); assertThat(new ExportTemplate2Action().execute()).isEqualTo(ActionSupport.SUCCESS); diff --git a/src/test/java/io/github/carlos_emr/carlos/report/data/QueryByExampleSqlValidatorUnitTest.java b/src/test/java/io/github/carlos_emr/carlos/report/data/QueryByExampleSqlValidatorUnitTest.java new file mode 100644 index 00000000000..58df515d1c3 --- /dev/null +++ b/src/test/java/io/github/carlos_emr/carlos/report/data/QueryByExampleSqlValidatorUnitTest.java @@ -0,0 +1,205 @@ +/** + * Copyright (c) 2026 CARLOS Contributors. All Rights Reserved. + * + * This software is published under the GPL GNU General Public License. + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * + * CARLOS EMR Project + * https://github.com/carlos-emr/carlos + */ +package io.github.carlos_emr.carlos.report.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Properties; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +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.MethodSource; + +@Tag("unit") +@Tag("report") +@Tag("security") +class QueryByExampleSqlValidatorUnitTest { + private static final Set EXPECTED_SECURITY_SENSITIVE_TABLES = Set.of( + "appdefinition", + "appuser", + "emailconfig", + "emaillog", + "fax_config", + "oscarcommlocations", + "oscarkeys", + "professionalspecialists", + "property", + "publickeys", + "security", + "securityarchive", + "securitytoken", + "serviceaccesstoken", + "serviceclient", + "serviceoauthnonce", + "servicerequesttoken"); + + private final Properties properties = properties("oscar_mcmaster?useUnicode=true"); + + @Test + @DisplayName("allows one SELECT using application tables, joins, and subqueries") + void shouldAllowReadOnlyApplicationSelects_whenSchemaIsConfigured() { + assertThatCode(() -> QueryByExampleSqlValidator.validate( + "select d.demographic_no from demographic d join provider p on p.provider_no=d.provider_no " + + "where exists (select 1 from appointment a where a.demographic_no=d.demographic_no)", + properties)).doesNotThrowAnyException(); + assertThatCode(() -> QueryByExampleSqlValidator.validate( + "select * from `oscar_mcmaster`.`demographic`", properties)).doesNotThrowAnyException(); + assertThatCode(() -> QueryByExampleSqlValidator.validate( + "select count(*), date_format(date_of_birth, '%Y') from demographic", properties)) + .doesNotThrowAnyException(); + assertThatCode(() -> QueryByExampleSqlValidator.validate( + "select row_number() over (order by demographic_no) from demographic", properties)) + .doesNotThrowAnyException(); + assertThatCode(() -> QueryByExampleSqlValidator.validate( + "select `into` from demographic", properties)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("does not treat prohibited function names inside string literals as invocations") + void shouldAllowBlockedFunctionNameInsideLiteral_whenFunctionTextIsQuoted() { + assertThatCode(() -> QueryByExampleSqlValidator.validate("select 'sleep(1)'", properties)) + .doesNotThrowAnyException(); + assertThatCode(() -> QueryByExampleSqlValidator.validate( + "select 'update delete create drop' as instruction", properties)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("structurally rejects set-operation SELECTs") + void shouldRejectSetOperationSelects_whenMultipleQueriesAreCombined() { + assertThatThrownBy(() -> QueryByExampleSqlValidator.validate( + "select demographic_no from demographic union select provider_no from provider", properties)) + .isInstanceOf(QueryByExampleValidationException.class) + .hasMessage("Only one SELECT statement is allowed"); + } + + @Test + @DisplayName("fails closed when the application schema is not configured") + void shouldRejectMissingApplicationSchema_whenSchemaIsUnavailable() { + Properties missing = new Properties(); + Properties blank = properties(" ?useUnicode=true"); + + assertThatThrownBy(() -> QueryByExampleSqlValidator.applicationSchema(missing)) + .isInstanceOf(QueryByExampleValidationException.class); + assertThatThrownBy(() -> QueryByExampleSqlValidator.applicationSchema(blank)) + .isInstanceOf(QueryByExampleValidationException.class); + } + + @Test + @DisplayName("rejects over-limit SQL before parsing") + void shouldRejectOverLimitSql_whenSubmissionIsTooLarge() { + String sql = "select '" + "x".repeat(QueryByExampleSqlValidator.MAX_SQL_CHARACTERS) + "'"; + + assertThatThrownBy(() -> QueryByExampleSqlValidator.validate(sql, properties)) + .isInstanceOf(QueryByExampleValidationException.class) + .hasMessage("SQL query exceeds the allowed length"); + } + + @ParameterizedTest(name = "rejects: {0}") + @MethodSource("unsafeQueries") + @DisplayName("rejects unsafe or out-of-scope SQL") + void shouldRejectUnsafeQueries(String sql) { + assertThatThrownBy(() -> QueryByExampleSqlValidator.validate(sql, properties)) + .isInstanceOf(QueryByExampleValidationException.class); + } + + @ParameterizedTest(name = "rejects sensitive table: {0}") + @MethodSource("securitySensitiveTables") + @DisplayName("rejects tables containing authentication secrets") + void shouldRejectSecuritySensitiveTables(String table) { + assertThatThrownBy(() -> QueryByExampleSqlValidator.validate("select * from " + table, properties)) + .isInstanceOf(QueryByExampleValidationException.class) + .hasMessage("Queries may not read tables containing authentication secrets"); + } + + @Test + @DisplayName("keeps the sensitive-table fixture synchronized with the validator policy") + void shouldMatchSensitiveTableFixture_whenValidatorPolicyChanges() { + assertThat(QueryByExampleSqlValidator.SECURITY_SENSITIVE_TABLES) + .containsExactlyInAnyOrderElementsOf(EXPECTED_SECURITY_SENSITIVE_TABLES); + } + + @Test + @DisplayName("rejects sensitive tables through qualified, quoted, and nested references") + void shouldRejectSecuritySensitiveTables_whenReferenceIsObscured() { + assertThatThrownBy(() -> QueryByExampleSqlValidator.validate( + "select * from `oscar_mcmaster`.`Security`", properties)) + .isInstanceOf(QueryByExampleValidationException.class); + assertThatThrownBy(() -> QueryByExampleSqlValidator.validate( + "select * from demographic where exists (select 1 from ServiceAccessToken)", properties)) + .isInstanceOf(QueryByExampleValidationException.class); + assertThatThrownBy(() -> QueryByExampleSqlValidator.validate( + "with tokens as (select * from SecurityToken) select * from tokens", properties)) + .isInstanceOf(QueryByExampleValidationException.class); + } + + private static Stream unsafeQueries() { + return Stream.of( + "show tables", + "describe demographic", + "explain select * from demographic", + "update demographic set last_name='x'", + "select * from demographic; select * from provider", + "(select demographic_no from demographic union select provider_no from provider)", + "select * from demographic -- comment", + "select * from other_database.demographic", + "select * from o\u017Fcar_mcmaster.demographic", + "select sleep(1)", + "select benchmark(1000, md5('x'))", + "select repeat('x', 2147483647)", + "select space(2147483647)", + "select lpad('x', 2147483647, 'x')", + "select rpad('x', 2147483647, 'x')", + "select get_lock('qbe', 1)", + "select release_lock('qbe')", + "select is_free_lock('qbe')", + "select load_file('/etc/passwd')", + "select custom_reporting_udf(demographic_no) from demographic", + "select oscar_mcmaster.custom_reporting_udf(demographic_no) from demographic", + "select custom_reporting_udf(demographic_no) over () from demographic", + "select @query_by_example_variable", + "select @@version", + "select next value for report_sequence", + "select * from demographic for update", + "select '\\\\' as value from demographic for update", + "select * from demographic for share", + "select demographic_no into @number from demographic"); + } + + private static Stream securitySensitiveTables() { + return EXPECTED_SECURITY_SENSITIVE_TABLES.stream() + .map(Arguments::of); + } + + private static Properties properties(String databaseName) { + Properties properties = new Properties(); + properties.setProperty("db_name", databaseName); + return properties; + } +} diff --git a/src/test/java/io/github/carlos_emr/carlos/report/data/RptByExampleDataUnitTest.java b/src/test/java/io/github/carlos_emr/carlos/report/data/RptByExampleDataUnitTest.java new file mode 100644 index 00000000000..49daacd6520 --- /dev/null +++ b/src/test/java/io/github/carlos_emr/carlos/report/data/RptByExampleDataUnitTest.java @@ -0,0 +1,332 @@ +/** + * Copyright (c) 2026 CARLOS Contributors. All Rights Reserved. + * + * This software is published under the GPL GNU General Public License. + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + * + * CARLOS EMR Project + * https://github.com/carlos-emr/carlos + */ +package io.github.carlos_emr.carlos.report.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.StringReader; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.SQLTimeoutException; +import java.sql.Types; +import java.util.Properties; + +import org.apache.logging.log4j.Logger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; +import org.mockito.MockedStatic; + +import io.github.carlos_emr.carlos.utility.MiscUtils; + +@Tag("unit") +@Tag("report") +@Tag("security") +class RptByExampleDataUnitTest { + private Connection connection; + private PreparedStatement statement; + private ResultSet resultSet; + private ResultSetMetaData metadata; + private RptByExampleData reportData; + private Properties properties; + + @BeforeEach + void setUp() throws SQLException { + connection = mock(Connection.class); + statement = mock(PreparedStatement.class); + resultSet = mock(ResultSet.class); + metadata = mock(ResultSetMetaData.class); + reportData = new RptByExampleData(() -> connection); + properties = new Properties(); + properties.setProperty("db_name", "oscar_mcmaster?useUnicode=true"); + + when(connection.isReadOnly()).thenReturn(true); + when(connection.prepareStatement("select demographic_no from demographic", + ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)).thenReturn(statement); + when(statement.executeQuery()).thenReturn(resultSet); + when(resultSet.getMetaData()).thenReturn(metadata); + when(metadata.getColumnCount()).thenReturn(1); + when(metadata.getColumnLabel(1)).thenReturn("demographic_no"); + when(metadata.getColumnType(1)).thenReturn(Types.VARCHAR); + when(resultSet.next()).thenReturn(true, false); + when(resultSet.getCharacterStream(1)).thenAnswer(ignored -> new StringReader("42")); + } + + @Test + @DisplayName("executes once using a bounded read-only JDBC statement and restores connection state") + void shouldExecuteBoundedReadOnlyQuery_whenConnectionStartsReadOnly() throws SQLException { + Logger logger = mock(Logger.class); + RptByExampleData.QueryResult result; + try (MockedStatic miscUtils = mockStatic(MiscUtils.class)) { + miscUtils.when(MiscUtils::getLogger).thenReturn(logger); + result = reportData.execute( + "select demographic_no from demographic", properties, "999998"); + } + + assertThat(result.rowCount()).isEqualTo(1); + assertThat(result.html()).contains("demographic_no").contains("42"); + verify(statement).setMaxRows(RptByExampleData.MAX_ROWS + 1); + verify(statement).setQueryTimeout(RptByExampleData.QUERY_TIMEOUT_SECONDS); + + InOrder order = inOrder(connection, statement, resultSet); + order.verify(connection).setReadOnly(true); + order.verify(statement).executeQuery(); + order.verify(resultSet).close(); + order.verify(statement).close(); + order.verify(connection).setReadOnly(true); + order.verify(connection).close(); + verify(logger).info( + eq("Query-by-Example audit provider={} queryHash={} queryLength={} durationMs={} rowCount={} outcome={}"), + eq("999998"), anyString(), eq(38), anyLong(), eq(1), eq("success")); + } + + @Test + @DisplayName("restores a writable connection after a successful query") + void shouldRestoreWritableConnection_whenQuerySucceeds() throws SQLException { + when(connection.isReadOnly()).thenReturn(false); + + reportData.execute("select demographic_no from demographic", properties, "999998"); + + InOrder order = inOrder(connection, statement, resultSet); + order.verify(connection).setReadOnly(true); + order.verify(statement).executeQuery(); + order.verify(resultSet).close(); + order.verify(statement).close(); + order.verify(connection).setReadOnly(false); + order.verify(connection).close(); + } + + @Test + @DisplayName("reports a read-only setup failure without executing SQL") + void shouldNotExecuteQuery_whenReadOnlySetupFails() throws SQLException { + SQLException setupFailure = new SQLException("read-only setup failed"); + when(connection.isReadOnly()).thenReturn(false); + doThrow(setupFailure).when(connection).setReadOnly(true); + + assertThatThrownBy(() -> reportData.execute( + "select demographic_no from demographic", properties, "999998")) + .isSameAs(setupFailure); + + verify(connection).setReadOnly(false); + verify(connection).close(); + verify(connection, never()).prepareStatement(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + @DisplayName("restores connection state after an unchecked read-only setup failure") + void shouldRestoreConnection_whenReadOnlySetupThrowsRuntimeException() throws SQLException { + IllegalStateException setupFailure = new IllegalStateException("read-only setup failed"); + when(connection.isReadOnly()).thenReturn(false); + doThrow(setupFailure).when(connection).setReadOnly(true); + + assertThatThrownBy(() -> reportData.execute( + "select demographic_no from demographic", properties, "999998")) + .isSameAs(setupFailure); + + verify(connection).setReadOnly(false); + verify(connection).close(); + verify(connection, never()).prepareStatement(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + @DisplayName("reports a connection restore failure after a successful query") + void shouldReportRestoreFailure_whenQuerySucceeds() throws SQLException { + SQLException restoreFailure = new SQLException("restore failed"); + when(connection.isReadOnly()).thenReturn(false); + doThrow(restoreFailure).when(connection).setReadOnly(false); + Logger logger = mock(Logger.class); + + try (MockedStatic miscUtils = mockStatic(MiscUtils.class)) { + miscUtils.when(MiscUtils::getLogger).thenReturn(logger); + + assertThatThrownBy(() -> reportData.execute( + "select demographic_no from demographic", properties, "999998")) + .isSameAs(restoreFailure); + } + + verify(statement).executeQuery(); + verify(connection).setReadOnly(false); + verify(connection).close(); + verify(logger).info( + eq("Query-by-Example audit provider={} queryHash={} queryLength={} durationMs={} rowCount={} outcome={}"), + eq("999998"), anyString(), eq(38), anyLong(), eq(1), eq("failed")); + } + + @Test + @DisplayName("preserves a timeout when restoring connection state also fails") + void shouldPreserveTimeout_whenReadOnlyRestoreFails() throws SQLException { + SQLTimeoutException timeout = new SQLTimeoutException("timed out"); + SQLException restoreFailure = new SQLException("restore failed"); + when(connection.isReadOnly()).thenReturn(false); + when(statement.executeQuery()).thenThrow(timeout); + doThrow(restoreFailure).when(connection).setReadOnly(false); + + assertThatThrownBy(() -> reportData.execute( + "select demographic_no from demographic", properties, "999998")) + .isSameAs(timeout) + .satisfies(thrown -> assertThat(thrown.getSuppressed()).containsExactly(restoreFailure)); + + verify(connection).setReadOnly(false); + verify(connection).close(); + } + + @Test + @DisplayName("preserves query failure when restoring state throws an unchecked exception") + void shouldPreserveQueryFailure_whenRestoreThrowsRuntimeException() throws SQLException { + SQLException queryFailure = new SQLException("query failed"); + IllegalStateException restoreFailure = new IllegalStateException("restore failed"); + when(connection.isReadOnly()).thenReturn(false); + when(statement.executeQuery()).thenThrow(queryFailure); + doThrow(restoreFailure).when(connection).setReadOnly(false); + + assertThatThrownBy(() -> reportData.execute( + "select demographic_no from demographic", properties, "999998")) + .isSameAs(queryFailure) + .satisfies(thrown -> assertThat(thrown.getSuppressed()).containsExactly(restoreFailure)); + + verify(connection).setReadOnly(false); + verify(connection).close(); + } + + @Test + @DisplayName("preserves query failure when restoring an originally read-only connection also fails") + void shouldPreserveQueryFailure_whenOriginallyReadOnlyRestoreFails() throws SQLException { + SQLException queryFailure = new SQLException("query failed"); + SQLException restoreFailure = new SQLException("restore failed"); + when(statement.executeQuery()).thenThrow(queryFailure); + org.mockito.Mockito.doNothing().doThrow(restoreFailure).when(connection).setReadOnly(true); + + assertThatThrownBy(() -> reportData.execute( + "select demographic_no from demographic", properties, "999998")) + .isSameAs(queryFailure) + .satisfies(thrown -> assertThat(thrown.getSuppressed()).containsExactly(restoreFailure)); + + verify(connection, times(2)).setReadOnly(true); + verify(connection).close(); + } + + @Test + @DisplayName("renders duplicate column labels using their positional values") + void shouldRenderPositionalValues_whenColumnLabelsAreDuplicated() throws SQLException { + when(metadata.getColumnCount()).thenReturn(2); + when(metadata.getColumnLabel(1)).thenReturn("id"); + when(metadata.getColumnLabel(2)).thenReturn("id"); + when(metadata.getColumnType(2)).thenReturn(Types.VARCHAR); + when(resultSet.getCharacterStream(1)).thenAnswer(ignored -> new StringReader("first")); + when(resultSet.getCharacterStream(2)).thenAnswer(ignored -> new StringReader("second")); + + RptResultStruct.StructuredResult result; + try (ResultSet closeableResultSet = resultSet) { + result = RptResultStruct.getStructureWithCount(closeableResultSet); + } + + assertThat(result.html()).contains("first").contains("second"); + assertThat(result.rowCount()).isEqualTo(1); + } + + @Test + @DisplayName("renders numeric columns without requesting an unsupported character stream") + void shouldRenderUsingStringValue_whenColumnIsNumeric() throws SQLException { + when(metadata.getColumnType(1)).thenReturn(Types.INTEGER); + when(resultSet.getString(1)).thenReturn("42"); + + RptResultStruct.StructuredResult result = RptResultStruct.getStructureWithCount(resultSet); + + assertThat(result.html()).contains("42"); + verify(resultSet).getString(1); + verify(resultSet, never()).getCharacterStream(1); + } + + @Test + @DisplayName("truncates encoded result output at the configured character budget") + void shouldTruncateEncodedOutput_whenCellExceedsCharacterBudget() throws SQLException { + when(resultSet.getCharacterStream(1)).thenAnswer(ignored -> new StringReader("<".repeat(1_000))); + + RptResultStruct.StructuredResult result; + try (ResultSet closeableResultSet = resultSet) { + result = RptResultStruct.getStructureWithCount(closeableResultSet, 128); + } + + assertThat(result.truncated()).isTrue(); + assertThat(result.rowCount()).isEqualTo(1); + assertThat(result.html()).hasSizeLessThanOrEqualTo(128).endsWith(""); + assertThat(result.html()).contains("<"); + String cell = result.html().substring(result.html().indexOf("") + 4, result.html().indexOf("")); + assertThat(cell).matches("(?:<)*…"); + } + + @Test + @DisplayName("reports omitted rows when the result exceeds the rendering row limit") + void shouldReportRowLimit_whenResultContainsAnotherRow() throws SQLException { + when(resultSet.next()).thenReturn(true, true); + + RptResultStruct.StructuredResult result; + try (ResultSet closeableResultSet = resultSet) { + result = RptResultStruct.getStructureWithCount(closeableResultSet, 1_000, 1); + } + + assertThat(result.rowCount()).isEqualTo(1); + assertThat(result.rowLimitReached()).isTrue(); + assertThat(result.html()).contains("42"); + } + + @Test + @DisplayName("does not report omitted rows when the result exactly reaches the row limit") + void shouldNotReportRowLimit_whenResultExactlyMatchesLimit() throws SQLException { + RptResultStruct.StructuredResult result; + try (ResultSet closeableResultSet = resultSet) { + result = RptResultStruct.getStructureWithCount(closeableResultSet, 1_000, 1); + } + + assertThat(result.rowCount()).isEqualTo(1); + assertThat(result.rowLimitReached()).isFalse(); + } + + @Test + @DisplayName("rejects unsafe SQL before acquiring a database connection") + void shouldRejectBeforeConnecting_whenSqlIsUnsafe() throws SQLException { + assertThatThrownBy(() -> reportData.execute("delete from demographic", properties, "999998")) + .isInstanceOf(QueryByExampleValidationException.class); + + verify(connection, never()).prepareStatement(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.anyInt()); + } +}