fix(#35): review findings: never store the term, hide unreadable hits, server-side targets

- Search failure events redact the user term (and its words) from the
  error message and detail: the client wrap message names the term.
- Playground: hits the user cannot read are counted, never described (no
  id, score or matched fields); they are removed from the raw response with
  the total and max score; provider ids are filtered to readable records.
- Inspect / Reindex run steps take their targets from the records the
  backend loaded into the process state, never from client values; failed
  event targets must be indexed tables; reindex skips ids that are not
  valid keys instead of deleting documents for them.
- History tables disable insert, update and delete for users and the API,
  so validation report HTML is always the QBit output.
- A validation counts as scheduled only without a user session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Opus 5.5 committed 2026-10-12 06:13:48 -05:00
1 parent 7287093afa
commit be5df8e00a
13 files changed
+186 -86

No files matched your search

@@ -481,7 +481,7 @@ public class QuickSearchAction
*******************************************************************************/
private static void recordFailure(List<String> tables, String term, long start, Exception e)
{
DiagnosticsSupport.recordSearchFailure(QuickSearchRuntime.get(), QuickSearchDiagnosticEvent.SOURCE_QUICK_SEARCH_ACTION, tables, null,
DiagnosticsSupport.recordSearchFailure(QuickSearchRuntime.get(), QuickSearchDiagnosticEvent.SOURCE_QUICK_SEARCH_ACTION, tables, term,
System.currentTimeMillis() - start, e, "term length " + (term == null ? 0 : term.length()));
}
@@ -148,7 +148,7 @@ public class QuickSearchRecordSearchProvider implements RecordSearchProviderInte
// the user's term is not recorded (it may hold personal data); its //
// length is, so a length-related failure is still visible //
//////////////////////////////////////////////////////////////////////
DiagnosticsSupport.recordSearchFailure(runtime, QuickSearchDiagnosticEvent.SOURCE_CORE_RECORD_SEARCH, tableNames, null, System.currentTimeMillis() - start, e,
DiagnosticsSupport.recordSearchFailure(runtime, QuickSearchDiagnosticEvent.SOURCE_CORE_RECORD_SEARCH, tableNames, term, System.currentTimeMillis() - start, e,
"term length " + term.length() + ", " + idsPerTable + " ids per table, timeout " + timeoutMillis + " ms; core searched these tables itself");
LOG.info("Quick Search record search failed; core searches these tables itself, and Quick Search claims none for a while", logPair("tables", tableNames),
logPair("backoffMillis", QuickSearchRuntime.RECORD_SEARCH_BACKOFF_MILLIS), logPair("error", e.getMessage()));
@@ -139,9 +139,10 @@ public final class DiagnosticsSupport
/*******************************************************************************
** Record a failed search (OpenSearch error or timeout).
** Record a failed search (OpenSearch error or timeout). userTerm is the
** user's term: never stored, and redacted from the message and detail.
*******************************************************************************/
public static void recordSearchFailure(QuickSearchRuntime runtime, String source, Collection<String> tableNames, String term, long durationMillis, Throwable error, String detail)
public static void recordSearchFailure(QuickSearchRuntime runtime, String source, Collection<String> tableNames, String userTerm, long durationMillis, Throwable error, String detail)
{
if(runtime == null)
{
@@ -155,11 +156,10 @@ public final class DiagnosticsSupport
.withSource(source)
.withOutcome(QuickSearchDiagnosticEvent.OUTCOME_ERROR)
.withTableNames(tableNames == null || tableNames.isEmpty() ? null : String.join(",", tableNames))
.withSearchTerm(term)
.withDurationMillis((int) Math.min(Integer.MAX_VALUE, durationMillis))
.withErrorClass(cause == null ? null : cause.getClass().getName())
.withErrorMessage(error == null ? null : error.getMessage())
.withDetail(detail));
.withErrorMessage(redactTerm(error == null ? null : error.getMessage(), userTerm))
.withDetail(redactTerm(detail, userTerm)));
}
catch(Exception e)
{
@@ -169,6 +169,30 @@ public final class DiagnosticsSupport
/*******************************************************************************
** The text with the user's search term, and each of its words of three or
** more characters, replaced by a marker (case-insensitive): error messages
** (ours and OpenSearch's) can echo the term, which may hold personal data.
*******************************************************************************/
public static String redactTerm(String text, String userTerm)
{
if(text == null || userTerm == null || userTerm.isBlank())
{
return (text);
}
String redacted = text.replaceAll("(?i)" + java.util.regex.Pattern.quote(userTerm.trim()), "[search term]");
for(String word : userTerm.trim().split("\\s+"))
{
if(word.length() >= 3)
{
redacted = redacted.replaceAll("(?i)" + java.util.regex.Pattern.quote(word), "[search term]");
}
}
return (redacted);
}
/*******************************************************************************
** The innermost cause of a throwable (the one that names the real error
** class: timeout, connection refused, OpenSearch error).
@@ -80,6 +80,8 @@ public class RecordReindexer
Set<String> ids = new LinkedHashSet<>(recordIds);
Map<String, QRecord> readable = DiagnosticRecordReads.loadForIndexing(tableName, ids);
Set<String> validIds = new HashSet<>();
DiagnosticRecordReads.typedIds(QContext.getQInstance().getTable(tableName), ids).forEach(id -> validIds.add(String.valueOf(id)));
////////////////////////////////////////////////////////////////////
// ids the user cannot read: exists at all (system view)? if not, //
@@ -132,6 +134,10 @@ public class RecordReindexer
outcomes.add(failedIds.contains(id) ? new Outcome(id, OUTCOME_FAILED, "OpenSearch rejected the document; see the run's errors")
: new Outcome(id, OUTCOME_INDEXED, "Rebuilt from the database and written to the index."));
}
else if(!validIds.contains(id))
{
outcomes.add(new Outcome(id, OUTCOME_SKIPPED, "Not a valid id for this table."));
}
else if(existing.contains(id))
{
outcomes.add(new Outcome(id, OUTCOME_SKIPPED, "The record exists but you cannot read it, so it was not reindexed."));
@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qqq.backend.core.actions.tables.RecordSearchAction;
import com.kingsrook.qqq.backend.core.context.QContext;
@@ -92,7 +93,7 @@ public class SearchPlayground
** One table's probe: the search, which hit ids the user can read, which
** the global search showed, and an error when the probe failed.
*******************************************************************************/
public record TableProbe(String tableName, SearchProbe probe, Set<String> readableIds, Set<String> shownIds, String error)
public record TableProbe(String tableName, SearchProbe probe, Set<String> readableIds, Set<String> shownIds, Integer hiddenCount, String error)
{
}
@@ -103,7 +104,7 @@ public class SearchPlayground
*******************************************************************************/
public record Report(String term, List<String> notices, List<AnalyzedToken> termTokens, String termTokensError,
SearchPathDiagnosis.ProviderState providerState, List<SearchPathDiagnosis.TablePath> paths,
Map<String, List<String>> providerIds, Long providerMillis, String providerError,
Map<String, List<String>> providerIds, Map<String, Integer> providerHiddenCounts, Long providerMillis, String providerError,
Map<String, List<RecordSearchResult>> coreResults, Long coreMillis, String coreError,
List<TableProbe> probes, String explainDocumentId, ExplainResult explain, String explainError,
Integer limitPerTable, Integer idsPerTable)
@@ -172,6 +173,7 @@ public class SearchPlayground
///////////////////////////////////////////////////////////
List<String> claimed = paths.stream().filter(p -> SearchPathDiagnosis.PATH_OPENSEARCH.equals(p.path())).map(SearchPathDiagnosis.TablePath::tableName).toList();
Map<String, List<String>> providerIds = null;
Map<String, Integer> providerHidden = new LinkedHashMap<>();
Long providerMillis = null;
String providerError = null;
if(!claimed.isEmpty() && ops != null && term.length() >= QuickSearchAction.MIN_TERM_LENGTH)
@@ -187,6 +189,7 @@ public class SearchPlayground
providerError = e.getClass().getSimpleName() + ": " + e.getMessage();
}
providerMillis = System.currentTimeMillis() - start;
providerIds = providerIds == null ? null : readableOnly(providerIds, providerHidden);
}
///////////////////////////////////////////////////////
@@ -226,7 +229,7 @@ public class SearchPlayground
}
if(ops == null)
{
probes.add(new TableProbe(path.tableName(), null, Set.of(), Set.of(), opsError));
probes.add(new TableProbe(path.tableName(), null, Set.of(), Set.of(), 0, opsError));
continue;
}
probes.add(probeTable(ops, runtime, term, path.tableName(), idsPerTable, coreResults.get(path.tableName())));
@@ -250,7 +253,7 @@ public class SearchPlayground
}
}
return (new Report(term, notices, termTokens, termTokensError, state, paths, providerIds, providerMillis, providerError,
return (new Report(term, notices, termTokens, termTokensError, state, paths, providerIds, providerHidden, providerMillis, providerError,
coreResults, coreMillis, coreError, probes, explainId, explain, explainError, limitPerTable, idsPerTable));
}
@@ -309,38 +312,81 @@ public class SearchPlayground
shownIds.add(String.valueOf(result.getRecordId()));
}
List<ProbeHit> visible = probe.hits().stream().filter(h -> readable.contains(h.recordId())).toList();
int hidden = probe.hits().size() - visible.size();
SearchProbe redacted = new SearchProbe(probe.tableName(), probe.requestJson(), redactResponse(probe.responseJson(), readable),
probe.tookMillis(), probe.wallMillis(), probe.totalHits(), probe.totalIsLowerBound(),
probe.hits().stream().map(h -> readable.contains(h.recordId()) ? h : new ProbeHit(h.recordId(), null, h.score(), h.matchedClauses(), Map.of())).toList());
return (new TableProbe(tableName, redacted, readable, shownIds, null));
probe.tookMillis(), probe.wallMillis(), hidden == 0 ? probe.totalHits() : null, probe.totalIsLowerBound(), visible);
return (new TableProbe(tableName, redacted, readable, shownIds, hidden, null));
}
catch(QException e)
{
return (new TableProbe(tableName, null, Set.of(), Set.of(), e.getMessage()));
return (new TableProbe(tableName, null, Set.of(), Set.of(), 0, e.getMessage()));
}
}
/*******************************************************************************
** The raw response with the source and highlights of hits the user cannot
** read replaced by a redaction marker.
** The provider's ids per table, keeping only records the user can read;
** how many were dropped goes in hiddenCounts. Ids of records the user
** cannot read are never shown (they would reveal what a locked record
** contains).
*******************************************************************************/
static Map<String, List<String>> readableOnly(Map<String, List<String>> idsByTable, Map<String, Integer> hiddenCounts)
{
Map<String, List<String>> readable = new LinkedHashMap<>();
for(Map.Entry<String, List<String>> entry : idsByTable.entrySet())
{
List<String> ids = entry.getValue() == null ? List.of() : entry.getValue();
Set<String> ok;
try
{
ok = new HashSet<>(DiagnosticRecordReads.existingIds(entry.getKey(), ids));
}
catch(QException e)
{
ok = Set.of();
}
Set<String> finalOk = ok;
readable.put(entry.getKey(), ids.stream().filter(finalOk::contains).toList());
hiddenCounts.put(entry.getKey(), (int) ids.stream().filter(id -> !finalOk.contains(id)).count());
}
return (readable);
}
/*******************************************************************************
** The raw response without the hits the user cannot read: they are
** removed whole (id, score, source, highlights, matched queries), and
** when any was removed the total and max score are redacted too, so the
** response reveals nothing about records the user cannot read.
*******************************************************************************/
static String redactResponse(String responseJson, Set<String> readableIds)
{
try
{
JsonNode root = MAPPER.readTree(responseJson);
for(JsonNode hit : root.path("hits").path("hits"))
JsonNode root = MAPPER.readTree(responseJson);
JsonNode hitList = root.path("hits").path("hits");
int removed = 0;
if(hitList.isArray())
{
String recordId = hit.path("_source").path("recordId").asText(null);
if(recordId == null || !readableIds.contains(recordId))
ArrayNode array = (ArrayNode) hitList;
for(int i = array.size() - 1; i >= 0; i--)
{
ObjectNode hitObject = (ObjectNode) hit;
hitObject.putObject("_source").put("redacted", "you cannot read this record (deleted, or hidden by a record security lock)");
hitObject.remove("highlight");
String recordId = array.get(i).path("_source").path("recordId").asText(null);
if(recordId == null || !readableIds.contains(recordId))
{
array.remove(i);
removed++;
}
}
}
if(removed > 0 && root.path("hits") instanceof ObjectNode hits)
{
hits.put("total", "redacted: " + removed + " hit(s) you cannot read were removed");
hits.remove("max_score");
}
return (root.toString());
}
catch(Exception e)
@@ -446,7 +492,9 @@ public class SearchPlayground
List<String> items = new ArrayList<>();
for(Map.Entry<String, List<String>> entry : report.providerIds().entrySet())
{
items.add("<strong>" + DiagnosticsHtml.escape(entry.getKey()) + "</strong>: " + entry.getValue().size() + " id(s)"
Integer hidden = report.providerHiddenCounts().getOrDefault(entry.getKey(), 0);
items.add("<strong>" + DiagnosticsHtml.escape(entry.getKey()) + "</strong>: " + entry.getValue().size() + " id(s) you can read"
+ (hidden > 0 ? " (" + hidden + " more you cannot read, not shown)" : "")
+ (entry.getValue().isEmpty() ? "" : ": " + DiagnosticsHtml.escape(String.join(", ", entry.getValue().subList(0, Math.min(20, entry.getValue().size()))))
+ (entry.getValue().size() > 20 ? ", ..." : "")));
}
@@ -544,21 +592,25 @@ public class SearchPlayground
}
SearchProbe probe = tableProbe.probe();
html.paragraph(probe.totalHits() + (Boolean.TRUE.equals(probe.totalIsLowerBound()) ? "+" : "") + " matching document(s); OpenSearch took " + probe.tookMillis()
+ " ms (" + probe.wallMillis() + " ms wall clock). Showing the top " + probe.hits().size() + ".");
html.paragraph((probe.totalHits() == null ? "Matching documents: not shown (some are records you cannot read)"
: probe.totalHits() + (Boolean.TRUE.equals(probe.totalIsLowerBound()) ? "+" : "") + " matching document(s)")
+ "; OpenSearch took " + probe.tookMillis() + " ms (" + probe.wallMillis() + " ms wall clock). Showing the " + probe.hits().size() + " top hit(s) you can read.");
if(tableProbe.hiddenCount() != null && tableProbe.hiddenCount() > 0)
{
html.status(Level.INFO, tableProbe.hiddenCount() + " of the top hits are records you cannot read (deleted, or hidden by a record security lock); they are not shown.");
}
List<String> items = new ArrayList<>();
int position = 0;
for(ProbeHit hit : probe.hits())
{
position++;
boolean readable = tableProbe.readableIds().contains(hit.recordId());
StringBuilder item = new StringBuilder();
StringBuilder item = new StringBuilder();
item.append("#").append(position).append(" <strong>")
.append(readable ? DiagnosticsHtml.escape(hit.recordLabel() == null ? "(no label)" : hit.recordLabel()) : "(not readable by you)")
.append(DiagnosticsHtml.escape(hit.recordLabel() == null ? "(no label)" : hit.recordLabel()))
.append("</strong> &middot; document <code>").append(DiagnosticsHtml.escape(OpenSearchDocument.buildDocumentId(tableProbe.tableName(), hit.recordId()))).append("</code>")
.append(" &middot; score ").append(hit.score() == null ? "n/a" : String.format("%.3f", hit.score()))
.append(" &middot; ").append(tableProbe.shownIds().contains(hit.recordId()) ? "shown in global search" : (readable ? "not shown (beyond the per-table limit)" : "dropped by core's re-read"));
.append(" &middot; ").append(tableProbe.shownIds().contains(hit.recordId()) ? "shown in global search" : "not shown (beyond the per-table limit)");
if(!hit.matchedClauses().isEmpty())
{
List<String> fields = hit.matchedClauses().stream().map(c -> c.startsWith(QuickSearchOpenSearchClient.FIELD_CLAUSE_PREFIX) ? c.substring(QuickSearchOpenSearchClient.FIELD_CLAUSE_PREFIX.length()) : c).toList();
@@ -573,11 +625,11 @@ public class SearchPlayground
}
if(items.isEmpty())
{
html.paragraph("No document of this table matches every term.");
html.paragraph("No document of this table that you can read matches every term.");
}
html.rawList(items);
html.jsonDetails("Query DSL", probe.requestJson());
html.jsonDetails("Raw response (unreadable hits redacted)", probe.responseJson());
html.jsonDetails("Raw response (hits you cannot read removed)", probe.responseJson());
}
@@ -22,6 +22,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
@@ -133,13 +134,16 @@ public class QuickSearchTableMetaDataHelper
/*******************************************************************************
** quickSearchDiagnosticEvent: failed searches, diagnostic tool runs (the
** audit trail) and refused runs. Written by the QBit only.
** audit trail) and refused runs. Written by the QBit only: insert, update
** and delete are disabled for users and the API (core actions, which the
** QBit uses, do not check capabilities).
*******************************************************************************/
public static QTableMetaData buildDiagnosticEventTable(QuickSearchQBitConfig config)
{
return (new QTableMetaData()
.withName(config.applyPrefix(QuickSearchDiagnosticEvent.TABLE_NAME))
.withLabel("Quick Search Diagnostic Event")
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE)
.withIcon(new QIcon("bug_report"))
.withBackendName(config.getBackendName())
.withPrimaryKeyField("id")
@@ -168,13 +172,16 @@ public class QuickSearchTableMetaDataHelper
/*******************************************************************************
** quickSearchValidationRun: index validation reports (history). The
** report is an HTML field, shown in full on the record view.
** report is an HTML field, shown in full on the record view; only the
** QBit writes rows (insert, update and delete are disabled for users and
** the API), so the HTML is always the QBit's escaped output.
*******************************************************************************/
public static QTableMetaData buildValidationRunTable(QuickSearchQBitConfig config)
{
return (new QTableMetaData()
.withName(config.applyPrefix(QuickSearchValidationRun.TABLE_NAME))
.withLabel("Quick Search Validation Run")
.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE)
.withIcon(new QIcon("fact_check"))
.withBackendName(config.getBackendName())
.withPrimaryKeyField("id")
@@ -56,8 +56,6 @@ public abstract class AbstractDiagnosticToolStep implements BackendStep
public static final String STEP_RUN = "run";
public static final String STEP_RESULT = "result";
public static final String FIELD_TARGETS = "diagnosticTargets";
/*******************************************************************************
@@ -122,7 +120,10 @@ public abstract class AbstractDiagnosticToolStep implements BackendStep
/*******************************************************************************
** The records the process was started on, as targets: for a source table,
** its primary keys; for the failed event table, each row's table and
** record id. At most max; more is a user-facing error.
** record id (only tables Quick Search indexes). Read from the records the
** backend loaded into the process state (never from client values), so
** every step works on exactly the records the user selected and may read.
** At most max; more is a user-facing error.
*******************************************************************************/
protected static List<Target> targetsFromInput(RunBackendStepInput input, QuickSearchRuntime runtime, int max) throws QException
{
@@ -150,7 +151,7 @@ public abstract class AbstractDiagnosticToolStep implements BackendStep
{
String targetTable = failedEvents ? record.getValueString("tableName") : tableName;
String recordId = failedEvents ? record.getValueString("recordId") : record.getValueString(table.getPrimaryKeyField());
if(StringUtils.hasContent(targetTable) && StringUtils.hasContent(recordId))
if(StringUtils.hasContent(targetTable) && StringUtils.hasContent(recordId) && runtime.getTableConfig(targetTable) != null)
{
targets.add(new Target(targetTable, recordId));
}
@@ -158,37 +159,4 @@ public abstract class AbstractDiagnosticToolStep implements BackendStep
return (new ArrayList<>(targets));
}
/*******************************************************************************
** Targets saved in the process value FIELD_TARGETS.
*******************************************************************************/
protected static List<Target> savedTargets(RunBackendStepInput input)
{
List<Target> targets = new ArrayList<>();
String saved = input.getValueString(FIELD_TARGETS);
if(StringUtils.hasContent(saved))
{
for(String part : saved.split("\n"))
{
Target target = Target.parse(part.trim());
if(target != null)
{
targets.add(target);
}
}
}
return (targets);
}
/*******************************************************************************
** Save targets in the process value FIELD_TARGETS.
*******************************************************************************/
protected static void saveTargets(RunBackendStepOutput output, List<Target> targets)
{
output.addValue(FIELD_TARGETS, String.join("\n", targets.stream().map(Target::toString).toList()));
}
}
@@ -51,17 +51,11 @@ public class InspectRecordStep extends AbstractDiagnosticToolStep
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
QuickSearchRuntime runtime = DiagnosticsSupport.requireRuntime();
List<Target> targets;
if(STEP_PREPARE.equals(input.getStepName()))
{
targets = targetsFromInput(input, runtime, MAX_RECORDS);
saveTargets(output, targets);
}
else
if(!STEP_PREPARE.equals(input.getStepName()))
{
loopBackToForm(output);
targets = savedTargets(input);
}
List<Target> targets = targetsFromInput(input, runtime, MAX_RECORDS);
long start = System.currentTimeMillis();
String term = STEP_PREPARE.equals(input.getStepName()) ? null : input.getValueString(FIELD_TERM);
@@ -54,7 +54,6 @@ public class ReindexRecordsStep extends AbstractDiagnosticToolStep
if(STEP_PREPARE.equals(input.getStepName()))
{
List<Target> targets = targetsFromInput(input, runtime, MAX_RECORDS);
saveTargets(output, targets);
Map<String, List<String>> byTable = groupByTable(targets);
List<String> lines = new ArrayList<>();
byTable.forEach((table, ids) -> lines.add(ids.size() + " record(s) of " + table));
@@ -69,7 +68,7 @@ public class ReindexRecordsStep extends AbstractDiagnosticToolStep
DiagnosticsSupport.checkRateLimit(runtime, DiagnosticsSupport.TOOL_REINDEX_RECORDS);
StringBuilder html = new StringBuilder();
for(Map.Entry<String, List<String>> entry : groupByTable(savedTargets(input)).entrySet())
for(Map.Entry<String, List<String>> entry : groupByTable(targetsFromInput(input, runtime, MAX_RECORDS)).entrySet())
{
try
{
@@ -18,11 +18,14 @@ package com.kingsrook.qbits.quicksearch.processes.diagnostics;
import java.util.List;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
import com.kingsrook.qbits.quicksearch.QuickSearchQBitConfig;
import com.kingsrook.qbits.quicksearch.QuickSearchRuntime;
import com.kingsrook.qbits.quicksearch.diagnostics.DiagnosticsHtml;
@@ -72,7 +75,13 @@ public class ValidateIndexStep extends AbstractDiagnosticToolStep
return;
}
boolean scheduled = input.getFrontendStepBehavior() == RunProcessInput.FrontendStepBehavior.SKIP;
//////////////////////////////////////////////////////////////////////
// a scheduled run skips the frontend and has no user; an API caller //
// asking to skip the frontend is still a user, so still rate-limited //
//////////////////////////////////////////////////////////////////////
QSession session = QContext.getQSession();
boolean scheduled = input.getFrontendStepBehavior() == RunProcessInput.FrontendStepBehavior.SKIP
&& (session == null || session instanceof QSystemUserSession || session.getUser() == null);
long start = System.currentTimeMillis();
if(!scheduled)
{
@@ -212,6 +212,8 @@ class DiagnosticsIntegrationTest
SearchPlayground.TableProbe probe = report.probes().get(0);
assertThat(probe.error()).isNull();
assertThat(probe.hiddenCount()).isZero();
assertThat(report.providerHiddenCounts().get(PRODUCT)).isZero();
assertThat(probe.probe().hits()).hasSize(2);
QuickSearchDiagnosticsOperations.ProbeHit top = probe.probe().hits().get(0);
assertThat(top.recordId()).isEqualTo("1");
@@ -351,9 +353,9 @@ class DiagnosticsIntegrationTest
@Order(6)
void testReindex_fixesStaleMissingAndOrphan() throws Exception
{
List<RecordReindexer.Outcome> outcomes = new RecordReindexer().reindex(runtime, PRODUCT, List.of("3", "5", "99"));
List<RecordReindexer.Outcome> outcomes = new RecordReindexer().reindex(runtime, PRODUCT, List.of("3", "5", "99", "abc"));
assertThat(outcomes).extracting(RecordReindexer.Outcome::outcome)
.containsExactly(RecordReindexer.OUTCOME_INDEXED, RecordReindexer.OUTCOME_INDEXED, RecordReindexer.OUTCOME_DELETED);
.containsExactly(RecordReindexer.OUTCOME_INDEXED, RecordReindexer.OUTCOME_INDEXED, RecordReindexer.OUTCOME_DELETED, RecordReindexer.OUTCOME_SKIPPED);
assertThat(new RecordInspector().inspect(runtime, PRODUCT, "3", null, new QueryInput()).verdict()).isEqualTo(RecordInspector.VERDICT_CURRENT);
assertThat(new RecordInspector().inspect(runtime, PRODUCT, "5", null, new QueryInput()).verdict()).isEqualTo(RecordInspector.VERDICT_CURRENT);
@@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.QSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.quartz.QuartzSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.scheduleing.simple.SimpleSchedulerMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
@@ -118,6 +119,11 @@ class DiagnosticsMetaDataTest
assertThat(qInstance.getTable(QuickSearchDiagnosticEvent.TABLE_NAME)).isNotNull();
assertThat(qInstance.getTable(QuickSearchValidationRun.TABLE_NAME)).isNotNull();
assertThat(qInstance.getTable(QuickSearchValidationRun.TABLE_NAME).getField("reportHtml").getType()).isEqualTo(QFieldType.HTML);
for(String history : List.of(QuickSearchDiagnosticEvent.TABLE_NAME, QuickSearchValidationRun.TABLE_NAME))
{
assertThat(qInstance.getTable(history).getDisabledCapabilities()).as(history)
.contains(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
}
for(String name : List.of(QuickSearchDiagnosticsProcessMetaDataHelper.PLAYGROUND_PROCESS_NAME, QuickSearchDiagnosticsProcessMetaDataHelper.VALIDATE_PROCESS_NAME,
QuickSearchDiagnosticsProcessMetaDataHelper.ANALYZE_PROCESS_NAME))
@@ -227,7 +227,7 @@ class DiagnosticsOfflineTest
String json = "{\"hits\":{\"hits\":[{\"_source\":{\"recordId\":\"1\",\"recordLabel\":\"ok\"},\"highlight\":{\"x\":[\"y\"]}},"
+ "{\"_source\":{\"recordId\":\"2\",\"recordLabel\":\"private\"},\"highlight\":{\"x\":[\"secret\"]}}]}}";
String redacted = SearchPlayground.redactResponse(json, Set.of("1"));
assertThat(redacted).contains("\"recordLabel\":\"ok\"").doesNotContain("private").doesNotContain("secret").contains("redacted");
assertThat(redacted).contains("\"recordLabel\":\"ok\"").doesNotContain("private").doesNotContain("secret").doesNotContain("\"2\"").contains("redacted: 1 hit(s)");
assertThat(SearchPlayground.redactResponse("nope", Set.of())).contains("could not be parsed");
}
@@ -346,6 +346,39 @@ class DiagnosticsOfflineTest
assertThat(event.getValueString("detail")).contains("term length 6");
assertThat(event.getValueString("tableNames")).isEqualTo(PRODUCT);
assertThat(event.getValueString("errorClass")).isNotBlank();
assertThat(event.getValueString("errorMessage")).doesNotContainIgnoringCase("widget");
}
/*******************************************************************************
** QuickSearchAction's failure message names the term; the event never
** keeps it.
*******************************************************************************/
@Test
void testQuickSearchActionFailure_termNeverStored() throws QException
{
assertThatThrownBy(() -> new com.kingsrook.qbits.quicksearch.actions.QuickSearchAction().execute(
new com.kingsrook.qbits.quicksearch.actions.QuickSearchInput().withSearchTerm("Jane Doe-Smith")))
.hasMessageContaining("Jane Doe-Smith");
runtime.getDiagnosticEventRecorder().flush(5000);
QRecord event = events().stream().filter(r -> QuickSearchDiagnosticEvent.SOURCE_QUICK_SEARCH_ACTION.equals(r.getValueString("source"))).findFirst().orElseThrow();
assertThat(event.getValueString("searchTerm")).isNull();
assertThat(event.getValueString("errorMessage")).contains("[search term]").doesNotContainIgnoringCase("jane").doesNotContainIgnoringCase("smith");
assertThat(event.getValueString("detail")).contains("term length 14");
}
@Test
void testRedactTerm()
{
assertThat(DiagnosticsSupport.redactTerm("Search failed for term [Blue Widget]: parse error near 'widget'", "blue widget"))
.isEqualTo("Search failed for term [[search term]]: parse error near '[search term]'");
assertThat(DiagnosticsSupport.redactTerm("no term here", "a b")).isEqualTo("no term here");
assertThat(DiagnosticsSupport.redactTerm(null, "x")).isNull();
assertThat(DiagnosticsSupport.redactTerm("x (y)", "(y)")).isEqualTo("x [search term]");
}