feat(backend): M5.c.1 GET /api/sessions/{id}/signals — Inspector SignalLog read

First of three V2 Inspector read endpoints (Plan §M5). Returns the SignalLog
for a session — ordered by SessionSignalOrdinal, bounded by a caller-supplied
maxResults with a hard 5000 cap.

- ISignalRepository.QueryBySessionAsync added alongside the existing
  StoreBatchAsync. Uses Azure Tables filter on PartitionKey = {tenant}_{session};
  the D19(ordinal) RowKey padding means lex order already matches numeric
  order, so the explicit sort on return is a redundancy guard — cheap
  invariant check, not a real cost.
- TableSignalRepository.FromEntity — reverse mapping (entity → SignalRecord)
  that mirrors ToEntity, including TableStorageChunking.ReassembleProperty
  for oversized PayloadJson. Round-trip tested end-to-end.
- GetSessionSignalsFunction.Run — follows the same shape as
  GetSessionEventsFunction: RequestContext.TargetTenantId resolved by
  PolicyEnforcementMiddleware, Global-Admin cross-tenant fallback via
  SessionsIndex when the effective-tenant query comes up empty.
- ParseMaxResults extracted as a pure helper taking the raw query string
  (not HttpRequestData) so it's unit-testable without mocking the abstract
  Worker HttpRequestData class.
- Endpoint registered in EndpointAccessPolicyCatalog under MemberRead +
  TenantScoping.QueryParam (same policy as sessions/{id}/events). Without
  this the fail-closed catalog would reject the route with 403 — verified
  by the EndpointPolicyCatalogCompletenessTests.

9 new tests (614/614 backend green): ParseMaxResults defaults / garbage /
in-range / cap / negative / null query string (6), plus FromEntity
round-trip (simple payload + oversized-chunked payload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-21 10:37:27 +02:00
parent 260d9afd8d
commit f1e89146cb
6 changed files with 283 additions and 0 deletions
@@ -0,0 +1,61 @@
using AutopilotMonitor.Functions.Functions.Sessions;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for the pure helpers on <see cref="GetSessionSignalsFunction"/>. The HTTP-trigger
/// end needs a live runtime harness; this file covers the deterministic query-string
/// normalisation that decides how many rows we load.
/// </summary>
public class GetSessionSignalsFunctionTests
{
[Fact]
public void ParseMaxResults_defaults_when_query_param_missing()
{
var result = GetSessionSignalsFunction.ParseMaxResults("");
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
}
[Fact]
public void ParseMaxResults_defaults_when_query_param_is_garbage()
{
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=banana");
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
}
[Fact]
public void ParseMaxResults_honours_valid_value_inside_range()
{
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=250");
Assert.Equal(250, result);
}
[Fact]
public void ParseMaxResults_caps_at_MaxResultsCap()
{
var result = GetSessionSignalsFunction.ParseMaxResults("?maxResults=999999");
Assert.Equal(GetSessionSignalsFunction.MaxResultsCap, result);
}
[Theory]
[InlineData("?maxResults=0")]
[InlineData("?maxResults=-5")]
public void ParseMaxResults_floors_at_one_for_non_positive_values(string query)
{
var result = GetSessionSignalsFunction.ParseMaxResults(query);
Assert.Equal(1, result);
}
[Fact]
public void ParseMaxResults_handles_null_query_string()
{
var result = GetSessionSignalsFunction.ParseMaxResults(null!);
Assert.Equal(GetSessionSignalsFunction.DefaultMaxResults, result);
}
}
@@ -114,4 +114,61 @@ public class TableSignalRepositoryMappingTests
Assert.Equal(string.Empty, entity.GetString("PayloadJson"));
}
// ============================================================ Reverse mapping (read path)
[Fact]
public void FromEntity_round_trips_a_small_payload()
{
var original = new SignalRecord
{
TenantId = TenantId,
SessionId = SessionId,
SessionSignalOrdinal = 42,
SessionTraceOrdinal = 117,
Kind = "EspExiting",
KindSchemaVersion = 2,
OccurredAtUtc = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc),
SourceOrigin = "EspAndHelloTrackerAdapter",
PayloadJson = "{\"kind\":\"EspExiting\",\"evidence\":{\"kind\":\"Raw\"}}",
};
var entity = TableSignalRepository.ToEntity(original);
var restored = TableSignalRepository.FromEntity(entity);
Assert.Equal(original.TenantId, restored.TenantId);
Assert.Equal(original.SessionId, restored.SessionId);
Assert.Equal(original.SessionSignalOrdinal, restored.SessionSignalOrdinal);
Assert.Equal(original.SessionTraceOrdinal, restored.SessionTraceOrdinal);
Assert.Equal(original.Kind, restored.Kind);
Assert.Equal(original.KindSchemaVersion, restored.KindSchemaVersion);
Assert.Equal(original.OccurredAtUtc, restored.OccurredAtUtc.ToUniversalTime());
Assert.Equal(original.SourceOrigin, restored.SourceOrigin);
Assert.Equal(original.PayloadJson, restored.PayloadJson);
}
[Fact]
public void FromEntity_reassembles_chunked_PayloadJson()
{
// Same-sized chunking behaviour we wrote — verifies the read path undoes it.
var oversized = new string('x', 75_000);
var original = new SignalRecord
{
TenantId = TenantId,
SessionId = SessionId,
SessionSignalOrdinal = 1,
SessionTraceOrdinal = 1,
Kind = "EspPhaseChanged",
KindSchemaVersion = 1,
OccurredAtUtc = DateTime.UtcNow,
SourceOrigin = "t",
PayloadJson = oversized,
};
var entity = TableSignalRepository.ToEntity(original);
var restored = TableSignalRepository.FromEntity(entity);
Assert.Equal(75_000, restored.PayloadJson.Length);
Assert.Equal(oversized, restored.PayloadJson);
}
}
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Azure;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -72,6 +73,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return committed;
}
public async Task<List<SignalRecord>> QueryBySessionAsync(
string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default)
{
SecurityValidator.EnsureValidGuid(tenantId, "TenantId");
SecurityValidator.EnsureValidGuid(sessionId, "SessionId");
var table = _storage.GetTableClient(Constants.TableNames.Signals);
var pk = BuildPartitionKey(tenantId, sessionId);
var results = new List<SignalRecord>(capacity: Math.Min(maxResults, 128));
var pages = table.QueryAsync<TableEntity>(
filter: $"PartitionKey eq '{pk}'",
maxPerPage: Math.Min(maxResults, 1000),
cancellationToken: cancellationToken);
await foreach (var entity in pages.ConfigureAwait(false))
{
if (results.Count >= maxResults) break;
results.Add(FromEntity(entity));
}
// RowKey is D19(SessionSignalOrdinal) — Azure Tables returns PK-scoped rows in RowKey
// lex order which matches numeric ordinal order (that's the whole point of the padding).
// Explicit sort is redundant but cheap; keeps the invariant under a test.
results.Sort((a, b) => a.SessionSignalOrdinal.CompareTo(b.SessionSignalOrdinal));
return results;
}
/// <summary>
/// Projects an Azure <see cref="TableEntity"/> back into a <see cref="SignalRecord"/>,
/// reassembling chunked <c>PayloadJson</c> if present. Internal for mapping tests.
/// </summary>
internal static SignalRecord FromEntity(TableEntity entity)
{
return new SignalRecord
{
TenantId = entity.GetString("TenantId") ?? string.Empty,
SessionId = entity.GetString("SessionId") ?? string.Empty,
SessionSignalOrdinal = entity.GetInt64("SessionSignalOrdinal") ?? 0,
SessionTraceOrdinal = entity.GetInt64("SessionTraceOrdinal") ?? 0,
Kind = entity.GetString("Kind") ?? string.Empty,
KindSchemaVersion = entity.GetInt32("KindSchemaVersion") ?? 0,
OccurredAtUtc = entity.GetDateTime("OccurredAtUtc") ?? default,
SourceOrigin = entity.GetString("SourceOrigin") ?? string.Empty,
PayloadJson = TableStorageChunking.ReassembleProperty(entity, "PayloadJson") ?? string.Empty,
};
}
/// <summary>
/// Projects a <see cref="SignalRecord"/> onto its Azure <see cref="TableEntity"/> shape.
/// Keys: PK = <c>{TenantId}_{SessionId}</c>, RK = <c>{SessionSignalOrdinal:D19}</c>
@@ -0,0 +1,107 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Sessions
{
/// <summary>
/// <c>GET /api/sessions/{sessionId}/signals</c> — returns the SignalLog for a V2 session
/// (Plan §M5 read endpoint, consumed by the Inspector's SignalStream panel and the MCP
/// <c>get_decision_signals</c> tool).
/// <para>
/// Tenant scoping is resolved upstream by <c>PolicyEnforcementMiddleware</c> via
/// <see cref="RequestContext.TargetTenantId"/>; Global-Admin fallback mirrors the other
/// session-detail read endpoints (resolve actual tenant via SessionsIndex when the
/// effective tenant has no data).
/// </para>
/// </summary>
public class GetSessionSignalsFunction
{
/// <summary>Hard upper bound on page size — protects against unbounded result sets.</summary>
internal const int MaxResultsCap = 5000;
/// <summary>Default result set size when the caller doesn't specify <c>?maxResults=</c>.</summary>
internal const int DefaultMaxResults = 1000;
private readonly ILogger<GetSessionSignalsFunction> _logger;
private readonly ISignalRepository _signalRepo;
private readonly ISessionRepository _sessionRepo;
public GetSessionSignalsFunction(
ILogger<GetSessionSignalsFunction> logger,
ISignalRepository signalRepo,
ISessionRepository sessionRepo)
{
_logger = logger;
_signalRepo = signalRepo;
_sessionRepo = sessionRepo;
}
[Function("GetSessionSignals")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/signals")] HttpRequestData req,
string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId))
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = "SessionId is required" });
return badRequest;
}
var maxResults = ParseMaxResults(req.Url.Query);
var sessionPrefix = $"[Session: {sessionId.Substring(0, Math.Min(8, sessionId.Length))}]";
_logger.LogInformation("{Prefix} GetSessionSignals: maxResults={Max}", sessionPrefix, maxResults);
try
{
var requestCtx = req.GetRequestContext();
var signals = await _signalRepo.QueryBySessionAsync(
requestCtx.TargetTenantId, sessionId, maxResults);
// Global Admin cross-tenant fallback — mirror GetSessionEventsFunction pattern.
if (signals.Count == 0 && requestCtx.IsGlobalAdmin)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null &&
!string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
{
signals = await _signalRepo.QueryBySessionAsync(resolvedTenantId, sessionId, maxResults);
}
}
return await req.OkAsync(new
{
success = true,
sessionId,
count = signals.Count,
truncated = signals.Count >= maxResults,
signals,
});
}
catch (Exception ex)
{
return await req.InternalServerErrorAsync(_logger, ex, $"Get signals for session '{sessionId}'");
}
}
/// <summary>
/// Reads <c>?maxResults=</c> from the query string, clamping to [1, <see cref="MaxResultsCap"/>].
/// Returns <see cref="DefaultMaxResults"/> when the parameter is missing or malformed.
/// Takes the raw query string so the helper is unit-testable without mocking
/// <c>HttpRequestData</c> (abstract, painful to fake).
/// </summary>
internal static int ParseMaxResults(string queryString)
{
var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty);
var raw = query["maxResults"];
if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed)) return DefaultMaxResults;
if (parsed < 1) return 1;
if (parsed > MaxResultsCap) return MaxResultsCap;
return parsed;
}
}
}
@@ -139,6 +139,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "sessions", EndpointPolicy.MemberRead),
new("GET", "sessions/{sessionId}", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/{sessionId}/events", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/{sessionId}/signals", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "metrics/app", EndpointPolicy.MemberRead),
@@ -21,5 +21,13 @@ namespace AutopilotMonitor.Shared.DataAccess
/// Upserts a batch of signal records. Returns the number of rows committed.
/// </summary>
Task<int> StoreBatchAsync(IReadOnlyList<SignalRecord> records, CancellationToken cancellationToken = default);
/// <summary>
/// Reads up to <paramref name="maxResults"/> signal records for a single session,
/// ordered by <see cref="SignalRecord.SessionSignalOrdinal"/> ascending. Used by the
/// Inspector read endpoint (<c>GET /api/sessions/{id}/signals</c>, Plan §M5).
/// </summary>
Task<List<SignalRecord>> QueryBySessionAsync(
string tenantId, string sessionId, int maxResults = 1000, CancellationToken cancellationToken = default);
}
}