mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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>
|
||||
|
||||
+107
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user