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:
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()));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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> · document <code>").append(DiagnosticsHtml.escape(OpenSearchDocument.buildDocumentId(tableProbe.tableName(), hit.recordId()))).append("</code>")
|
||||
.append(" · score ").append(hit.score() == null ? "n/a" : String.format("%.3f", hit.score()))
|
||||
.append(" · ").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(" · ").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());
|
||||
}
|
||||
|
||||
|
||||
|
||||
+9
-2
@@ -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")
|
||||
|
||||
+5
-37
@@ -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()));
|
||||
}
|
||||
|
||||
}
|
||||
+2
-8
@@ -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);
|
||||
|
||||
+1
-2
@@ -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
|
||||
{
|
||||
|
||||
+10
-1
@@ -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))
|
||||
|
||||
+34
-1
@@ -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]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user