mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
d7b42ad6e1
The "raw" tools were oversold: query_raw_sessions returned curated SessionSummary DTOs and its fields= ran through a hard-coded whitelist (ProjectFields) that silently dropped ~20 real SessionsIndex columns (OsEdition, ImeAgentVersion, GeoRegion/City/Loc, FailureSource, CurrentPhaseDetail, LastEventAt, ResumedAt, StalledAt, DeletionState, ...), and it accepted fewer filters than search_sessions -> a strict subset. The raw endpoints are the deliberate "fall back to the real stored bytes" escape hatch; the enriched views already live on search_sessions/get_session and search_events/get_session_events. So both raw tools now return the LITERAL stored rows: every column verbatim, PascalCase, incl. PartitionKey/RowKey/Timestamp, with fields= a pure pass-through (narrows, never silently drops a real column; always keeps PartitionKey/RowKey). - New RawEntityProjection helper: ToDictionary(TableEntity) + Project(rows, fields) - 3 new ISessionRepository methods returning IReadOnlyDictionary<string,object?> (no Azure types leak into Shared); reuse BuildSearchScanFilter + MatchesScanClientFilters for the predicate, emit the raw entity - query_raw_sessions: delete ProjectFields; add filter parity (manufacturer, model, enrollmentType, deviceName, osBuild, geoCountry, isPreProvisioned, isHybridJoin) -- already covered by the continuation fingerprint - query_raw_events: literal rows, drop ErrorCodeEnricher + EventFieldProjection (DataJson raw string, Severity/Phase raw ints); severity/source/date filters reimplemented on raw dicts preserving prior match semantics - EventFieldProjection kept for the enriched get_session_events; doc-comment fixed - MCP admin.ts: rewrite both tool descriptions, add the new query_raw_sessions inputs, PascalCase pass-through fields= docs; prompts.ts hint updated Note: contract change for these two tools (camelCase curated keys -> PascalCase literal columns). deviceProperties join stays out of raw (owned by search_sessions); query_table remains the generic literal escape hatch. Tests: new RawEntityProjectionTests (8); full backend suite 2134 passed; MCP tsc clean + vitest 149 passed/41 skipped; tools-raw.test.ts updated to the PascalCase contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
6.5 KiB
C#
154 lines
6.5 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using Azure.Data.Tables;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests
|
|
{
|
|
/// <summary>
|
|
/// Unit coverage for <see cref="RawEntityProjection"/> — the shared serialiser behind the
|
|
/// "raw" reader endpoints (/api/raw/sessions, /api/raw/events). These tests pin the two
|
|
/// guarantees the finding hinged on:
|
|
/// 1. EVERY stored column is preserved verbatim (no curated whitelist that silently drops
|
|
/// real columns like OsEdition / ImeAgentVersion / FailureSource).
|
|
/// 2. fields= is a pure pass-through (narrows, never drops a real requested column) and always
|
|
/// retains PartitionKey + RowKey.
|
|
/// </summary>
|
|
public class RawEntityProjectionTests
|
|
{
|
|
private static TableEntity MakeSessionRow() => new TableEntity("tenant-1", "0834:session-1")
|
|
{
|
|
// A representative SessionsIndex row including columns the OLD whitelist dropped.
|
|
{ "SessionId", "session-1" },
|
|
{ "Status", "Succeeded" },
|
|
{ "Manufacturer", "Contoso" },
|
|
{ "Model", "X1" },
|
|
{ "EnrollmentType", "v2" },
|
|
{ "OsEdition", "Enterprise" }, // formerly dropped
|
|
{ "OsDisplayVersion", "24H2" }, // formerly dropped
|
|
{ "ImeAgentVersion", "1.23.456.789" }, // formerly dropped
|
|
{ "GeoCity", "Berlin" }, // formerly dropped
|
|
{ "FailureSource", "ESP" }, // formerly dropped
|
|
{ "DeletionState", "None" }, // formerly dropped
|
|
{ "EventCount", 42 },
|
|
{ "IsPreProvisioned", true },
|
|
};
|
|
|
|
[Fact]
|
|
public void ToDictionary_PreservesEveryStoredColumn_IncludingFormerlyDropped()
|
|
{
|
|
var dict = RawEntityProjection.ToDictionary(MakeSessionRow());
|
|
|
|
// System columns present.
|
|
Assert.Equal("tenant-1", dict["PartitionKey"]);
|
|
Assert.Equal("0834:session-1", dict["RowKey"]);
|
|
Assert.True(dict.ContainsKey("Timestamp"));
|
|
|
|
// Columns the old ProjectFields whitelist could never return are all here now.
|
|
foreach (var col in new[] { "OsEdition", "OsDisplayVersion", "ImeAgentVersion", "GeoCity", "FailureSource", "DeletionState" })
|
|
Assert.True(dict.ContainsKey(col), $"raw row must include stored column '{col}'");
|
|
|
|
// Values are the literal stored values (no string coercion of ints/bools).
|
|
Assert.Equal("Enterprise", dict["OsEdition"]);
|
|
Assert.Equal(42, dict["EventCount"]);
|
|
Assert.Equal(true, dict["IsPreProvisioned"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDictionary_DropsOnlyTheAzureEtagBookkeepingKey()
|
|
{
|
|
var dict = RawEntityProjection.ToDictionary(MakeSessionRow());
|
|
Assert.False(dict.ContainsKey("odata.etag"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Project_NullOrEmptyFields_ReturnsFullRawRow()
|
|
{
|
|
var rows = new List<IReadOnlyDictionary<string, object?>>
|
|
{
|
|
RawEntityProjection.ToDictionary(MakeSessionRow()),
|
|
};
|
|
|
|
foreach (var fields in new string?[] { null, "", " " })
|
|
{
|
|
var projected = RawEntityProjection.Project(rows, fields);
|
|
Assert.Single(projected);
|
|
// Full shape retained — pick a few representative columns.
|
|
Assert.True(projected[0].ContainsKey("OsEdition"));
|
|
Assert.True(projected[0].ContainsKey("Status"));
|
|
Assert.True(projected[0].ContainsKey("PartitionKey"));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Project_Fields_IsCaseInsensitivePassThrough_AndKeepsRealColumns()
|
|
{
|
|
var rows = new List<IReadOnlyDictionary<string, object?>>
|
|
{
|
|
RawEntityProjection.ToDictionary(MakeSessionRow()),
|
|
};
|
|
|
|
// Request a formerly-dropped column using lower-case to prove case-insensitivity.
|
|
var projected = RawEntityProjection.Project(rows, "status,osedition,imeagentversion");
|
|
var row = projected.Single();
|
|
|
|
// Real requested columns are present (output keeps the stored PascalCase key name).
|
|
Assert.True(row.ContainsKey("Status"));
|
|
Assert.True(row.ContainsKey("OsEdition"));
|
|
Assert.True(row.ContainsKey("ImeAgentVersion"));
|
|
|
|
// Non-requested columns are narrowed away.
|
|
Assert.False(row.ContainsKey("Manufacturer"));
|
|
Assert.False(row.ContainsKey("Model"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Project_Fields_AlwaysRetainsPartitionKeyAndRowKey()
|
|
{
|
|
var rows = new List<IReadOnlyDictionary<string, object?>>
|
|
{
|
|
RawEntityProjection.ToDictionary(MakeSessionRow()),
|
|
};
|
|
|
|
// Caller asks only for Status — identity columns must still come back for cursor stability.
|
|
var row = RawEntityProjection.Project(rows, "Status").Single();
|
|
Assert.True(row.ContainsKey("PartitionKey"));
|
|
Assert.True(row.ContainsKey("RowKey"));
|
|
Assert.True(row.ContainsKey("Status"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Project_Fields_UnknownKeyIsSimplyAbsent_NeverThrows()
|
|
{
|
|
var rows = new List<IReadOnlyDictionary<string, object?>>
|
|
{
|
|
RawEntityProjection.ToDictionary(MakeSessionRow()),
|
|
};
|
|
|
|
var row = RawEntityProjection.Project(rows, "Status,NoSuchColumn").Single();
|
|
Assert.True(row.ContainsKey("Status"));
|
|
Assert.False(row.ContainsKey("NoSuchColumn"));
|
|
}
|
|
|
|
[Fact]
|
|
public void ToDictionary_EventRow_KeepsDataJsonAsRawStringAndSeverityAsInt()
|
|
{
|
|
// A raw Events row: the raw tool must NOT parse DataJson or decode Severity.
|
|
var entity = new TableEntity("tenant-1_session-1", "rowkey-1")
|
|
{
|
|
{ "EventType", "app_install_failed" },
|
|
{ "Severity", 3 }, // EventSeverity.Error, stored as int
|
|
{ "DataJson", "{\"win32\":\"0x80070002\"}" },
|
|
{ "Sequence", 17L },
|
|
};
|
|
|
|
var dict = RawEntityProjection.ToDictionary(entity);
|
|
Assert.Equal(3, dict["Severity"]); // raw int, not "Error"
|
|
Assert.Equal("{\"win32\":\"0x80070002\"}", dict["DataJson"]); // raw string, not parsed
|
|
Assert.Equal(17L, dict["Sequence"]);
|
|
}
|
|
}
|
|
}
|