Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/DeleteSessionFunctionTests.cs
T
Oliver Kieselbach 51737174b2 feat(backend,mcp): typed API contract COMPLETED — every remaining top-level success body is a manifest-exported wire DTO, and the guard now catches the bypass shapes that let them escape. The 08-31 migration's ratchet only saw inline WriteAsJsonAsync(new {...}); four bypass classes had kept ~20 endpoint families untyped: (A) Shared classes without IApiResponse — GeographicMetricsResponse, FleetHealthMetrics, ImePatternHealthResponse, SlaMetricsResponse, PlatformUsageMetrics now implement it (wire byte-identical, interface is empty); (B) Functions-local named response families moved verbatim to Shared — PlatformAgentMetricsResponse/SessionAgentMetric/DeliveryLatencyMetrics/CrashRateMetrics/CrashExceptionSummary, AgentEfficiencyMetricsResponse family (CloneAsCacheHit relocated into the service; init→set for netstandard2.0), OffboardResponse; (C) anonymous/object builders converted to DTOs with old-vs-new wire-parity facts (TypedContractCompletionWireParityTests, 14 facts incl. every vanishing-key case): rule-stats x2 (RuleStatsResponse; uniqueRules is global-only via an optional slot at its exact wire position), verdict-calibration (builder renamed VerdictCalibrationResponseBuilder, PathRow/TrendWindow to Shared), metrics/app (MetricsMath), apps list/analytics/sessions x6 routes (AppsAnalyticsHelper; legacy + paged list share ONE DTO via optional paging keys), ime-versions lean projection (ImeVersionHistoryLeanEntry [WireContract]; bare-array envelope stays, typed as IReadOnlyList<object>), device-journeys + time-attribution fleet envelopes, feature-flags, diagnostics/paths, session restore, cascade-delete Enqueued arm (error arms stay anonymous by design), feedback eligibility/list; (D) Serialize+WriteString path — ListBackupsResponse under the backup surface's own serializer options; AppInsightsQuery's Serialize is an outgoing REQUEST body (not response debt, baselined as such). TypedResponseGuardTests gains three ratchets with mutation-proven red/green: WriteAsJsonAsync(<variable assigned an anonymous object>) (empty baseline), JsonSerializer.Serialize(new{ (error-tolerant; 1 documented request-body entry), and object/Task<object>-returning Build*/Compute*/*Payload/*Response methods (1 documented union entry); the local-WriteJson-wrapper class is closed structurally (generic T : IApiResponse — an interface-typed parameter would serialize zero properties, the generic keeps the concrete type). Manifest regenerated 208→298 exported types; MCP adopts the new types (get_rule_stats typed with the trend-drop as explicit output shaping, get_platform_metrics drops its hand-declared mirror for PlatformAgentMetricsResponse, verdict-calibration-shape derives its rows via extends Omit<generated wire row> so backend renames go tsc-red). Six backend shape-test files switched from default-options serialization to the production wire options via TestWire (ApiJsonOptions; the old default-options runs only matched while payloads were anonymous with lowercase members), and three assertions that pinned null-VALUED keys now pin key ABSENCE — production always omitted them (WhenWritingNull); the parity facts prove the wire itself is unchanged. Deliberately still untyped: auth/me, CheckMcpAccess dictionary, TenantAdmins/GlobalAdmins entity-row arrays and the object-slot payloads (RuleDryRun, HealthCheck, TenantConfigFieldSchema, ...) — the documented read-model follow-up. Suites: Backend 5323, DecisionCore 577, Agent 2805, SummaryDialog 44, MCP 617, Web tsc 0 + vitest 957, generator idempotent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 04:32:51 +02:00

182 lines
8.4 KiB
C#

using System.Net;
using System.Reflection;
using AutopilotMonitor.Functions.Functions.Sessions;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Deletion;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// HTTP-layer tests for <see cref="DeleteSessionFunction"/>. Verifies the policy-catalog
/// registration, the public status-mapping / body-shape helpers, and the single pre-dispatch
/// kill-switch gate without touching <c>HttpRequestData</c>. Everything else (existence check,
/// lock-state mapping, recovery resume) is owned by the producer and covered by
/// <see cref="SessionDeletionProducer"/> tests.
/// </summary>
public class DeleteSessionFunctionTests
{
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private const string ManifestId = "01J0123456789ABCDEFGHIJKLM";
// ── Policy catalog ────────────────────────────────────────────────────────────────────
[Fact]
public void Delete_route_is_registered_in_policy_catalog_as_TenantAdminOrGA()
{
// Memory feedback_route_policy_catalog: every HTTP route MUST be registered in
// EndpointAccessPolicyCatalog. Unregistered routes fail-closed → 403. PR5 keeps the
// legacy policy unchanged (V2 is a body/path-dispatch detail, not an authorization one).
var entry = EndpointAccessPolicyCatalog.FindPolicy("DELETE", "sessions/" + SessionId);
Assert.NotNull(entry);
Assert.Equal(EndpointPolicy.TenantAdminOrGA, entry!.Policy);
}
// ── MapEnqueueOutcomeToStatus ─────────────────────────────────────────────────────────
[Theory]
[InlineData(SessionDeletionEnqueueOutcome.Enqueued, HttpStatusCode.Accepted)]
[InlineData(SessionDeletionEnqueueOutcome.AlreadyInFlight, HttpStatusCode.Conflict)]
[InlineData(SessionDeletionEnqueueOutcome.Poisoned, HttpStatusCode.Conflict)]
[InlineData(SessionDeletionEnqueueOutcome.KillSwitchActive, HttpStatusCode.ServiceUnavailable)]
[InlineData(SessionDeletionEnqueueOutcome.CasExhausted, HttpStatusCode.ServiceUnavailable)]
[InlineData(SessionDeletionEnqueueOutcome.SessionNotFound, HttpStatusCode.NotFound)]
public void MapEnqueueOutcomeToStatus_returns_expected_status(
SessionDeletionEnqueueOutcome outcome, HttpStatusCode expected)
{
Assert.Equal(expected, DeleteSessionFunction.MapEnqueueOutcomeToStatus(outcome));
}
[Fact]
public void MapEnqueueOutcomeToStatus_falls_back_to_500_on_unknown_outcome()
{
// Defensive: a new outcome value added later without updating the mapping must surface
// as a 500 (visible bug) instead of silently masquerading as 200/202. Cast to bypass
// the enum bounds check — represents a hypothetical future enum addition.
var bogus = (SessionDeletionEnqueueOutcome)999;
Assert.Equal(HttpStatusCode.InternalServerError, DeleteSessionFunction.MapEnqueueOutcomeToStatus(bogus));
}
// ── BuildV2ResponseBody ───────────────────────────────────────────────────────────────
[Fact]
public void BuildV2ResponseBody_Enqueued_carries_manifestId_and_queued_status()
{
var result = new SessionDeletionEnqueueResult
{
Outcome = SessionDeletionEnqueueOutcome.Enqueued,
ManifestId = ManifestId,
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
AssertProperty(body, "success", true);
AssertProperty(body, "status", "queued");
AssertProperty(body, "manifestId", ManifestId);
}
[Fact]
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_hint()
{
var result = new SessionDeletionEnqueueResult
{
Outcome = SessionDeletionEnqueueOutcome.AlreadyInFlight,
ManifestId = ManifestId,
ExistingState = "Running",
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
AssertProperty(body, "success", false);
AssertProperty(body, "manifestId", ManifestId);
AssertProperty(body, "deletionState", "Running");
AssertProperty(body, "hint", "cascade_already_in_flight");
}
[Fact]
public void BuildV2ResponseBody_Poisoned_hints_at_restore_endpoint()
{
// Plan §13: the only recovery path from Poisoned is POST /restore. The HTTP body
// must say so explicitly so the UI/operator does not loop on the delete endpoint.
var result = new SessionDeletionEnqueueResult
{
Outcome = SessionDeletionEnqueueOutcome.Poisoned,
ManifestId = ManifestId,
ExistingState = "Poisoned",
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
AssertProperty(body, "hint", "cascade_poisoned_use_restore");
AssertProperty(body, "manifestId", ManifestId);
// The message string must mention the restore endpoint so the UI surfaces it verbatim.
var message = GetProperty<string>(body, "message");
Assert.Contains("/restore", message);
}
[Fact]
public void BuildV2ResponseBody_KillSwitchActive_does_not_leak_manifestId()
{
// Race: kill-switch flipped between step-1 admin check and the producer's CAS read.
// The producer never built a manifest, so the body must NOT carry a manifestId at all
// (avoid the UI rendering a "track this cascade" link to a non-existent manifest).
var result = new SessionDeletionEnqueueResult
{
Outcome = SessionDeletionEnqueueOutcome.KillSwitchActive,
};
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
AssertProperty(body, "hint", "kill_switch_active");
Assert.False(HasProperty(body, "manifestId"));
}
// ── EvaluateAdminDeleteGates (kill-switch short-circuit) ──────────────────────────────
[Fact]
public void Gates_killSwitch_active_short_circuits_to_503()
{
var gate = DeleteSessionFunction.EvaluateAdminDeleteGates(killSwitchActive: true);
Assert.NotNull(gate);
Assert.Equal(HttpStatusCode.ServiceUnavailable, gate!.Value.Status);
AssertProperty(gate.Value.Body, "hint", "kill_switch_active");
// The kill-switch body must NOT carry a manifestId so the UI doesn't render a
// "track this cascade" link for a request that was refused.
Assert.False(HasProperty(gate.Value.Body, "manifestId"));
}
[Fact]
public void Gates_killSwitch_inactive_returns_null_so_producer_is_invoked()
{
// The happy path: producer handles existence (404), lock-state (409), and recovery
// resume (202 for Queued/Preparing+Snapshot). Function must NOT short-circuit those.
var gate = DeleteSessionFunction.EvaluateAdminDeleteGates(killSwitchActive: false);
Assert.Null(gate);
}
// ── Anonymous-object reflection helpers ───────────────────────────────────────────────
// IgnoreCase: the Enqueued arm is a typed DTO (PascalCase properties, camelCased on the
// wire by ApiJsonOptions); the error arms stay anonymous (camelCase). Both resolve here.
private static void AssertProperty(object body, string propertyName, object? expected)
{
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop);
Assert.Equal(expected, prop!.GetValue(body));
}
private static T GetProperty<T>(object body, string propertyName)
{
var prop = body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
Assert.NotNull(prop);
var value = prop!.GetValue(body);
return (T)value!;
}
private static bool HasProperty(object body, string propertyName) =>
body.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance) != null;
}