Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/RawEntityProjectionTests.cs
T
Oliver Kieselbach d7b42ad6e1 feat(mcp-raw): make query_raw_sessions/events return literal table rows
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>
2026-05-30 13:23:23 +02:00

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"]);
}
}
}