Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/RuleEngineDataPathResolverTests.cs
T
Oliver Kieselbach 98fd173fb8 rules+backend+mcp: rule-verify sweep fixes (dead factor, {{token}} evidence, stats cap, list stringify)
Full verify pass over all 54 built-in rules (validate_rule sweep, 30d/7d
get_rule_stats, test_analyze_rule spot checks). Fixes:

- ANALYZE-APP-007 v1.1.0: declare enrollment_failed as a non-required
  condition — the exists-factor referenced an undeclared signal and could
  never apply.
- ANALYZE-APP-014 v1.1.0: add non-required event_data conditions for
  espCategory/espErrorCode/topCandidatePackage/candidateCount/confidence
  so all explanation {{tokens}} resolve instead of rendering literally.
- ANALYZE-ENRL-002 v1.2.0: capture session_timeout.timeoutHours so
  {{timeoutHours}} interpolates (verified via dry-run: renders "5").
- RuleEngine TryFlatLookup/ResolveDotPath: flatten IEnumerable data
  values to a comma-joined string — List fields (e.g. app_tracking_summary
  .pendingNames) stringified to "System.Collections.Generic.List`1[…]",
  breaking contains-matching and {{pendingNames}} interpolation.
- GetRuleStatsAsync: raise the 500-row cap to 10000 and log when hit.
  The scan walks dates ascending, so the old cap silently dropped the
  NEWEST dates of a 30d window — newly added rules (e.g. ANALYZE-SEC-008)
  were entirely missing from global stats.
- MCP validate_rule lint: accept event_data_array itemField as a {{token}}
  resolver — {{identity}} in ANALYZE-SEC-005/006 was a false positive.

Backend 4157 tests green, MCP 548 green + tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 16:50:29 +02:00

143 lines
4.9 KiB
C#

using System.Text.Json;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for the dot-path resolver inside <see cref="RuleEngine.GetDataFieldValue"/>.
/// Exercises both nested <c>Dictionary&lt;string,object&gt;</c> and <see cref="JsonElement"/>
/// payload shapes — both occur in production depending on whether the event came in via
/// the in-process emitter (typed sidecar) or was rehydrated from Table Storage JSON.
/// </summary>
public class RuleEngineDataPathResolverTests
{
[Fact]
public void TryFlatLookup_LiteralKeyWithDot_StillResolves()
{
// Pre-existing v1 events used keys like "scan_summary.critical_cves" with a literal
// dot. The flat lookup must keep working so legacy rules don't break.
var data = new Dictionary<string, object>
{
["scan_summary.critical_cves"] = 7,
};
var ok = RuleEngine.TryFlatLookup(data, "scan_summary.critical_cves", out var value);
Assert.True(ok);
Assert.Equal("7", value);
}
[Fact]
public void TryFlatLookup_CaseInsensitive()
{
var data = new Dictionary<string, object> { ["FailedSubcategories"] = "Certificates,VPN" };
var ok = RuleEngine.TryFlatLookup(data, "failedsubcategories", out var value);
Assert.True(ok);
Assert.Equal("Certificates,VPN", value);
}
[Fact]
public void ResolveDotPath_NestedDictionary_ReturnsLeafScalar()
{
var data = new Dictionary<string, object>
{
["scan_summary"] = new Dictionary<string, object>
{
["critical_cves"] = 3,
["overall_risk"] = "high",
},
};
Assert.Equal("3", RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
Assert.Equal("high", RuleEngine.ResolveDotPath(data, "scan_summary.overall_risk"));
}
[Fact]
public void ResolveDotPath_NestedJsonElement_ReturnsLeafScalar()
{
// Events read back from Table Storage hit the resolver as JsonElement subtrees.
var json = """{"scan_summary":{"critical_cves":5,"kev_matches":2,"overall_risk":"critical"}}""";
using var doc = JsonDocument.Parse(json);
var data = new Dictionary<string, object>
{
["scan_summary"] = doc.RootElement.GetProperty("scan_summary").Clone(),
};
Assert.Equal("5", RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
Assert.Equal("2", RuleEngine.ResolveDotPath(data, "scan_summary.kev_matches"));
Assert.Equal("critical", RuleEngine.ResolveDotPath(data, "scan_summary.overall_risk"));
}
[Fact]
public void ResolveDotPath_CaseInsensitiveAtEachLevel()
{
var data = new Dictionary<string, object>
{
["Scan_Summary"] = new Dictionary<string, object>
{
["Critical_CVEs"] = 4,
},
};
Assert.Equal("4", RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
}
[Fact]
public void ResolveDotPath_MissingPath_ReturnsNull()
{
var data = new Dictionary<string, object>
{
["scan_summary"] = new Dictionary<string, object> { ["critical_cves"] = 1 },
};
Assert.Null(RuleEngine.ResolveDotPath(data, "scan_summary.missing"));
Assert.Null(RuleEngine.ResolveDotPath(data, "missing.critical_cves"));
}
[Fact]
public void ResolveDotPath_DescendIntoScalar_ReturnsNull()
{
// Path traverses past a scalar — must not throw, must return null so the rule
// misses cleanly instead of stringifying an unrelated value.
var data = new Dictionary<string, object>
{
["scan_summary"] = "not-an-object",
};
Assert.Null(RuleEngine.ResolveDotPath(data, "scan_summary.critical_cves"));
}
[Fact]
public void TryFlatLookup_ListValue_FlattensToJoinedString()
{
// Regression: app_tracking_summary.pendingNames is a List<object> after DataJson
// deserialization. ToString() on it yields the .NET type name, which broke both
// contains-matching and {{pendingNames}} interpolation in ANALYZE-APP-015 findings.
var data = new Dictionary<string, object>
{
["pendingNames"] = new List<object> { "Contoso VPN", "Fabrikam Agent" },
};
var ok = RuleEngine.TryFlatLookup(data, "pendingNames", out var value);
Assert.True(ok);
Assert.Equal("Contoso VPN, Fabrikam Agent", value);
}
[Fact]
public void ResolveDotPath_ListLeaf_FlattensToJoinedString()
{
var data = new Dictionary<string, object>
{
["summary"] = new Dictionary<string, object>
{
["names"] = new List<object> { "a", "b" },
},
};
Assert.Equal("a, b", RuleEngine.ResolveDotPath(data, "summary.names"));
}
}