mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-27 05:57:16 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69c6353fb1 | |||
| 2b9bf3cd3f | |||
| d99e16314b | |||
| e4203e0f15 | |||
| 2a5366d9c3 | |||
| 9c9cf24ddc | |||
| ec0d8ad735 | |||
| fc9e9d13e6 | |||
| d0cb7e66f4 | |||
| bd66480ccc | |||
| b05f1f6bcf | |||
| 12ad9c9436 | |||
| c8ce7cdb37 | |||
| 56d4dd8b84 | |||
| 86fdc5aca9 | |||
| 87c5bc59dc | |||
| 21b30b21c2 | |||
| f94f1c0c4e | |||
| a6a9883451 | |||
| 03116a97d1 | |||
| 2abc78ab6d | |||
| 6e54b52376 | |||
| 7cef2ea361 | |||
| 5b5ee0e8fd | |||
| 7bcc4f00b1 | |||
| 34ec44e3d4 |
+1
-1
Submodule .claude/commands updated: 6b39d14ed0...e8b8c08c1b
+1
-1
Submodule internal updated: 9f75da246a...54efd666b0
@@ -168,7 +168,7 @@
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source of the signal. phase_duration and app_install_duration never derive a duration from an IME-log timestamp whose sourceOffsetOrigin is reader-zone-fallback (writer timezone unknown to the agent): such a condition evaluates false with evidence timestampProvenance=reader-zone-fallback.",
|
||||
"description": "Source of the signal. phase_duration and app_install_duration never derive a duration from a span with mixed timestamp provenance (exactly one IME-log endpoint with sourceOffsetOrigin=reader-zone-fallback, i.e. the writer timezone was unknown to the agent for one endpoint only): such a condition evaluates false with evidence timestampProvenance=reader-zone-fallback. Two fallback endpoints share the same error and stay measurable.",
|
||||
"enum": ["event_type", "event_data", "event_data_array", "phase_duration", "event_count", "app_install_duration", "event_correlation", "clock_skew"]
|
||||
},
|
||||
"skewMetric": {
|
||||
|
||||
@@ -152,18 +152,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests
|
||||
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}")
|
||||
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}"));
|
||||
|
||||
private static string RepoRoot()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent == null || parent == dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
|
||||
}
|
||||
private static string RepoRoot() => Harness.RepoPaths.Root();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
|
||||
{
|
||||
/// <summary>Repository paths for tests that read or write checked-in files (guards, fixtures).</summary>
|
||||
internal static class RepoPaths
|
||||
{
|
||||
/// <summary>Walks up from the test binary to the directory holding <c>AutopilotMonitor.sln</c>.</summary>
|
||||
public static string Root()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
for (int i = 0; i < 12; i++)
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AutopilotMonitor.sln"))) return dir;
|
||||
var parent = Directory.GetParent(dir)?.FullName;
|
||||
if (parent == null || parent == dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new DirectoryNotFoundException(
|
||||
"Could not locate repo root (AutopilotMonitor.sln) from " + AppContext.BaseDirectory);
|
||||
}
|
||||
|
||||
/// <summary><c>tests/fixtures/<name></c> under the repo root.</summary>
|
||||
public static string Fixtures(string name) => Path.Combine(Root(), "tests", "fixtures", name);
|
||||
}
|
||||
}
|
||||
+1
@@ -13,6 +13,7 @@ using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Integration
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031 // SpinWait.SpinUntil for uploader-batch-arrival assertion
|
||||
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
{
|
||||
|
||||
+3
-2
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
@@ -42,7 +43,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
// items must be a whiteglove_resumed event.
|
||||
Assert.DoesNotContain(
|
||||
rig.Uploader.Received.SelectMany(b => b),
|
||||
item => item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
|
||||
item => item.Kind == TelemetryItemKind.Event &&
|
||||
item.PayloadJson != null &&
|
||||
item.PayloadJson.Contains("\"whiteglove_resumed\""));
|
||||
}
|
||||
@@ -75,7 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
var resumedEvents = rig.Uploader.Received
|
||||
.SelectMany(b => b)
|
||||
.Where(item =>
|
||||
item.Kind == AutopilotMonitor.Agent.V2.Core.Transport.Telemetry.TelemetryItemKind.Event &&
|
||||
item.Kind == TelemetryItemKind.Event &&
|
||||
item.PayloadJson != null &&
|
||||
item.PayloadJson.Contains("\"whiteglove_resumed\""))
|
||||
.ToList();
|
||||
|
||||
@@ -15,6 +15,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
|
||||
+1
@@ -9,6 +9,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
#pragma warning disable xUnit1031
|
||||
|
||||
|
||||
+1
@@ -5,6 +5,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Signals
|
||||
{
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Transitions
|
||||
{
|
||||
|
||||
+1
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -8,6 +8,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Text;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using Xunit;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Events;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Signals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events;
|
||||
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// The agent → backend wire contract of <c>POST /api/agent/telemetry</c>, proven from the
|
||||
/// WRITER side. <see cref="TelemetryItemDto"/> (Shared) is the one class both ends serialise;
|
||||
/// <see cref="TelemetryItem.ToWire"/> is the only mapping onto it.
|
||||
/// <list type="bullet">
|
||||
/// <item>Round trip + reflection: every DTO property is populated by <c>ToWire()</c> and
|
||||
/// survives Newtonsoft defaults — a DTO field added without a mapping fails here.</item>
|
||||
/// <item>Frozen body: the real batch serialiser's output for one item per
|
||||
/// <see cref="TelemetryItemKind"/> is pinned under <c>tests/fixtures/telemetry-wire/</c>.
|
||||
/// The backend's <c>TelemetryWireFixtureTests</c> reads the same files and demands zero
|
||||
/// rejections, so a rename on either side breaks a test until both sides and the fixture
|
||||
/// agree. Regenerate with <c>AM_WRITE_WIRE_FIXTURES=1</c>.</item>
|
||||
/// <item>Poison body: the exact 422 shape the backend writes (pinned there by
|
||||
/// <c>TelemetryWireFixtureTests</c>) is parsed as an item-level poison signal here.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class TelemetryWireContractTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
private const string FixtureFile = "agent-v2.json";
|
||||
|
||||
private static readonly DateTime At = new DateTime(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TelemetryItem FullyPopulatedItem() =>
|
||||
new TelemetryItem(
|
||||
kind: TelemetryItemKind.DecisionTransition,
|
||||
partitionKey: $"{TenantId}_{SessionId}",
|
||||
rowKey: "0000000007",
|
||||
telemetryItemId: 42,
|
||||
sessionTraceOrdinal: 7,
|
||||
payloadJson: "{\"StepIndex\":7}",
|
||||
requiresImmediateFlush: true,
|
||||
enqueuedAtUtc: At,
|
||||
retryCount: 3);
|
||||
|
||||
// ============================================================ ToWire mapping
|
||||
|
||||
[Fact]
|
||||
public void ToWire_round_trips_every_property_through_Newtonsoft_defaults()
|
||||
{
|
||||
var item = FullyPopulatedItem();
|
||||
|
||||
var json = JsonConvert.SerializeObject(item.ToWire());
|
||||
var dto = JsonConvert.DeserializeObject<TelemetryItemDto>(json)!;
|
||||
|
||||
Assert.Equal("DecisionTransition", dto.Kind);
|
||||
Assert.Equal(item.PartitionKey, dto.PartitionKey);
|
||||
Assert.Equal(item.RowKey, dto.RowKey);
|
||||
Assert.Equal(item.TelemetryItemId, dto.TelemetryItemId);
|
||||
Assert.Equal(item.SessionTraceOrdinal, dto.SessionTraceOrdinal);
|
||||
Assert.Equal(item.PayloadJson, dto.PayloadJson);
|
||||
Assert.Equal(item.RequiresImmediateFlush, dto.RequiresImmediateFlush);
|
||||
Assert.Equal(item.EnqueuedAtUtc, dto.EnqueuedAtUtc);
|
||||
Assert.Equal(DateTimeKind.Utc, dto.EnqueuedAtUtc.Kind);
|
||||
Assert.Equal(item.RetryCount, dto.RetryCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToWire_populates_every_TelemetryItemDto_property()
|
||||
{
|
||||
// A DTO property that ToWire() does not set keeps its default — this catches a field
|
||||
// added to the wire type without a mapping (the backend would read the default forever).
|
||||
var dto = FullyPopulatedItem().ToWire();
|
||||
|
||||
var unset = typeof(TelemetryItemDto)
|
||||
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.Where(p => IsDefault(p.GetValue(dto), p.PropertyType))
|
||||
.Select(p => p.Name)
|
||||
.ToList();
|
||||
|
||||
Assert.True(unset.Count == 0,
|
||||
"TelemetryItem.ToWire() leaves these TelemetryItemDto properties at their default: " + string.Join(", ", unset));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TelemetryItemDto_properties_mirror_TelemetryItem()
|
||||
{
|
||||
var itemProps = typeof(TelemetryItem).GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
||||
.ToDictionary(p => p.Name, p => p.PropertyType);
|
||||
|
||||
foreach (var dtoProp in typeof(TelemetryItemDto).GetProperties(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
Assert.True(itemProps.TryGetValue(dtoProp.Name, out var itemType),
|
||||
$"TelemetryItemDto.{dtoProp.Name} has no counterpart on TelemetryItem");
|
||||
var expected = dtoProp.Name == nameof(TelemetryItemDto.Kind) ? typeof(TelemetryItemKind) : dtoProp.PropertyType;
|
||||
Assert.True(itemType == expected,
|
||||
$"TelemetryItemDto.{dtoProp.Name} is {dtoProp.PropertyType.Name}, TelemetryItem.{dtoProp.Name} is {itemType.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_kind_serialises_by_name_on_the_wire_and_on_the_spool_line()
|
||||
{
|
||||
foreach (var kind in TelemetryItemKinds.All)
|
||||
{
|
||||
var item = new TelemetryItem(kind, "T_S", "rk", 1, 1, "{}", false, At);
|
||||
|
||||
var wire = JObject.Parse(JsonConvert.SerializeObject(item.ToWire()));
|
||||
Assert.Equal(kind.ToString(), (string?)wire["Kind"]);
|
||||
|
||||
// The spool line (TelemetryItemSerializer) has always written the NAME too; the enum's
|
||||
// move to Shared must not turn it into an ordinal (a crash-restart rehydrates it).
|
||||
var spool = JObject.Parse(TelemetryItemSerializer.Serialize(item));
|
||||
Assert.Equal(kind.ToString(), (string?)spool["Kind"]);
|
||||
Assert.Equal(kind, TelemetryItemSerializer.Deserialize(spool.ToString(Formatting.None)).Kind);
|
||||
|
||||
Assert.True(TelemetryItemKinds.TryParse(kind.ToString(), out var parsed));
|
||||
Assert.Equal(kind, parsed);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================ Frozen wire body
|
||||
|
||||
[Fact]
|
||||
public void Frozen_wire_body_matches_the_current_serializer()
|
||||
{
|
||||
var items = OneItemPerKind();
|
||||
Assert.Equal(TelemetryItemKinds.All.OrderBy(k => k), items.Select(i => i.Kind).Distinct().OrderBy(k => k));
|
||||
|
||||
var body = BackendTelemetryUploader.SerializeBatch(items);
|
||||
var path = Path.Combine(RepoPaths.Fixtures("telemetry-wire"), FixtureFile);
|
||||
|
||||
if (Environment.GetEnvironmentVariable("AM_WRITE_WIRE_FIXTURES") == "1")
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
File.WriteAllText(path, body + "\n", new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
Assert.True(File.Exists(path), "Missing wire fixture " + path + " — run this test with AM_WRITE_WIRE_FIXTURES=1 to create it.");
|
||||
var frozen = File.ReadAllText(path).TrimEnd('\r', '\n');
|
||||
Assert.True(string.Equals(frozen, body, StringComparison.Ordinal),
|
||||
"The agent's telemetry wire body changed. If the change is intended, update the backend ingest " +
|
||||
"(TelemetryWireFixtureTests must still dispatch every frozen body) and regenerate with " +
|
||||
"AM_WRITE_WIRE_FIXTURES=1.\nExpected: " + frozen + "\nActual: " + body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One item per kind through the REAL emitters (payload shapes are theirs, not hand-typed),
|
||||
/// re-based onto fixed transport fields so the body is byte-stable.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<TelemetryItem> OneItemPerKind()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json")));
|
||||
|
||||
new TelemetryEventEmitter(transport, counter, SessionId, TenantId).Emit(new EnrollmentEvent
|
||||
{
|
||||
EventId = "00000000-0000-4000-8000-000000000001",
|
||||
EventType = Constants.EventTypes.AgentStarted,
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "Agent",
|
||||
Timestamp = At,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "Agent started",
|
||||
Data = new Dictionary<string, object> { ["agentVersion"] = "2.0.0" },
|
||||
});
|
||||
|
||||
new TelemetrySignalEmitter(transport, SessionId, TenantId).Emit(new DecisionSignal(
|
||||
sessionSignalOrdinal: 1,
|
||||
sessionTraceOrdinal: 1,
|
||||
kind: DecisionSignalKind.SessionStarted,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: At,
|
||||
sourceOrigin: "Fixture",
|
||||
evidence: new Evidence(EvidenceKind.Raw, "raw-1", "session start")));
|
||||
|
||||
new TelemetryTransitionEmitter(transport, SessionId, TenantId).Emit(new DecisionTransition(
|
||||
stepIndex: 1,
|
||||
sessionTraceOrdinal: 2,
|
||||
signalOrdinalRef: 1,
|
||||
occurredAtUtc: At,
|
||||
trigger: "SessionStarted",
|
||||
fromStage: SessionStage.Unknown,
|
||||
toStage: SessionStage.AwaitingEspPhaseChange,
|
||||
taken: true,
|
||||
deadEndReason: null,
|
||||
reducerVersion: "1.0.0"));
|
||||
|
||||
return transport.Enqueued
|
||||
.Select(e => new TelemetryItem(e.Kind, e.PartitionKey, e.RowKey, e.TelemetryItemId, e.SessionTraceOrdinal,
|
||||
e.PayloadJson, e.RequiresImmediateFlush, At))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// ============================================================ Poison body
|
||||
|
||||
/// <summary>
|
||||
/// The exact 422 body the backend writes for rejected items (pinned byte-for-byte in the
|
||||
/// backend's TelemetryWireFixtureTests). camelCase because the backend serialises with
|
||||
/// its wire options; the agent's parser accepts both casings.
|
||||
/// </summary>
|
||||
internal const string BackendPoisonBody =
|
||||
"{\"error\":\"2 of 3 telemetry item(s) cannot be ingested (unknown Kind or unusable payload); they were not stored.\"," +
|
||||
"\"code\":\"TelemetryItemsRejected\",\"correlationId\":\"cid-1\",\"poison\":true,\"rejectedRowKeys\":[\"rk-1\",\"rk-3\"]," +
|
||||
"\"reason\":\"unknown_kind=1;unparseable=1\",\"received\":3,\"rejected\":2}";
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_422_poison_body_is_parsed_as_item_level_poison()
|
||||
{
|
||||
using var handler = new RecordingHttpMessageHandler().QueueStatus((HttpStatusCode)422, BackendPoisonBody);
|
||||
using var http = new HttpClient(handler);
|
||||
var sut = new BackendTelemetryUploader(http, baseUrl: "https://backend.example.invalid", tenantId: TenantId, agentVersion: "2.0.0");
|
||||
|
||||
var result = await sut.UploadBatchAsync(new[] { FullyPopulatedItem() }, CancellationToken.None);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.True(result.IsPoison);
|
||||
Assert.Equal(new[] { "rk-1", "rk-3" }, result.PoisonRowKeys);
|
||||
Assert.Equal("unknown_kind=1;unparseable=1", result.ErrorReason);
|
||||
}
|
||||
|
||||
private static bool IsDefault(object? value, Type type)
|
||||
{
|
||||
if (value == null) return true;
|
||||
if (type == typeof(string)) return ((string)value).Length == 0;
|
||||
var underlying = Nullable.GetUnderlyingType(type) ?? type;
|
||||
return underlying.IsValueType && value.Equals(Activator.CreateInstance(underlying));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Signals
|
||||
{
|
||||
|
||||
+1
@@ -4,6 +4,7 @@ using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Transitions
|
||||
{
|
||||
|
||||
+9
-5
@@ -395,12 +395,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
|
||||
/// <summary>
|
||||
/// The batch body: a JSON array of <see cref="TelemetryItemDto"/> — the Shared class the
|
||||
/// ingest deserialises — with Newtonsoft defaults (PascalCase, ISO-8601 UTC, Kind by name).
|
||||
/// Internal so <c>TelemetryWireContractTests</c> can freeze the real output as a fixture.
|
||||
/// </summary>
|
||||
internal static string SerializeBatch(IReadOnlyList<TelemetryItem> items)
|
||||
{
|
||||
// Newtonsoft PascalCase default + TelemetryItemKind carries [StringEnumConverter] →
|
||||
// Backend routes by string "Event"/"Signal"/"DecisionTransition". Wire-format
|
||||
// matches Plan §4.x M5 contract (PascalCase, StringEnum, ISO-8601 UTC).
|
||||
return JsonConvert.SerializeObject(items, Formatting.None);
|
||||
var wire = new TelemetryItemDto[items.Count];
|
||||
for (var i = 0; i < items.Count; i++) wire[i] = items[i].ToWire();
|
||||
return JsonConvert.SerializeObject(wire, Formatting.None);
|
||||
}
|
||||
|
||||
private static byte[] CompressWithGzip(string text)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
@@ -106,6 +107,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// <summary>Anzahl der bisher fehlgeschlagenen Upload-Versuche. Wird vom Orchestrator incrementiert.</summary>
|
||||
public int RetryCount { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The wire shape of this item. <see cref="TelemetryItemDto"/> (Shared) is the ONE class both
|
||||
/// ends serialise; this mapping is the only place the agent's validated model meets it.
|
||||
/// <c>TelemetryWireContractTests</c> proves every DTO property is populated here and
|
||||
/// freezes the resulting body under <c>tests/fixtures/telemetry-wire/</c> for the backend.
|
||||
/// </summary>
|
||||
public TelemetryItemDto ToWire() =>
|
||||
new TelemetryItemDto
|
||||
{
|
||||
Kind = Kind.ToString(),
|
||||
PartitionKey = PartitionKey,
|
||||
RowKey = RowKey,
|
||||
TelemetryItemId = TelemetryItemId,
|
||||
SessionTraceOrdinal = SessionTraceOrdinal,
|
||||
PayloadJson = PayloadJson,
|
||||
RequiresImmediateFlush = RequiresImmediateFlush,
|
||||
EnqueuedAtUtc = EnqueuedAtUtc,
|
||||
RetryCount = RetryCount,
|
||||
};
|
||||
|
||||
/// <summary>Copy-with-incremented <see cref="RetryCount"/>. Immutable-Erhalt (§2.3 L.3).</summary>
|
||||
public TelemetryItem WithRetryIncremented() =>
|
||||
new TelemetryItem(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
#nullable enable
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Typ eines <see cref="TelemetryItem"/>. Plan §2.7a / L.10.
|
||||
/// <para>
|
||||
/// Drei getrennte Backend-Tabellen:
|
||||
/// <see cref="Event"/> → <c>Events</c>,
|
||||
/// <see cref="Signal"/> → <c>Signals</c>,
|
||||
/// <see cref="DecisionTransition"/> → <c>DecisionTransitions</c>.
|
||||
/// Der generische Transport routet auf Backend-Seite nach <c>Kind</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// String-serialisiert via <see cref="StringEnumConverter"/> — Backend-Routing darf
|
||||
/// nicht von Integer-Ordering abhängen, das bei zukünftigen Erweiterungen bricht
|
||||
/// (L.14 forward-compat).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public enum TelemetryItemKind
|
||||
{
|
||||
Event = 0,
|
||||
Signal,
|
||||
DecisionTransition,
|
||||
}
|
||||
}
|
||||
+18
-2
@@ -2,6 +2,7 @@
|
||||
using System;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
{
|
||||
@@ -16,10 +17,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
/// </summary>
|
||||
public static class TelemetryItemSerializer
|
||||
{
|
||||
/// <summary>
|
||||
/// DecisionCore settings plus a <see cref="StringEnumConverter"/>: <see cref="TelemetryItem.Kind"/>
|
||||
/// is the only enum on a spool line and has always been written by NAME (<c>"Event"</c>),
|
||||
/// which is also what the wire carries. The enum lives in Shared (no Newtonsoft there), so
|
||||
/// the converter is registered here instead of as an attribute on the type.
|
||||
/// </summary>
|
||||
private static readonly JsonSerializerSettings SpoolSettings = CreateSpoolSettings();
|
||||
|
||||
private static JsonSerializerSettings CreateSpoolSettings()
|
||||
{
|
||||
var settings = DecisionCoreJsonSettings.Create();
|
||||
settings.Converters.Add(new StringEnumConverter());
|
||||
return settings;
|
||||
}
|
||||
|
||||
public static string Serialize(TelemetryItem item)
|
||||
{
|
||||
if (item == null) throw new ArgumentNullException(nameof(item));
|
||||
return JsonConvert.SerializeObject(item, DecisionCoreJsonSettings.Shared);
|
||||
return JsonConvert.SerializeObject(item, SpoolSettings);
|
||||
}
|
||||
|
||||
public static TelemetryItem Deserialize(string line)
|
||||
@@ -28,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Transport.Telemetry
|
||||
|
||||
try
|
||||
{
|
||||
var result = JsonConvert.DeserializeObject<TelemetryItem>(line, DecisionCoreJsonSettings.Shared);
|
||||
var result = JsonConvert.DeserializeObject<TelemetryItem>(line, SpoolSettings);
|
||||
if (result == null)
|
||||
{
|
||||
throw new JsonSerializationException($"TelemetryItem deserialization produced null: {line}");
|
||||
|
||||
@@ -11,9 +11,8 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
{
|
||||
/// <summary>
|
||||
/// Every payload-derived string that lands in <see cref="DecisionState"/> is bounded by
|
||||
/// <see cref="FactStringBounds"/> at the reducer boundary. The server-side semantic replay
|
||||
/// (<c>ReducerVerifier</c>) folds device-uploaded payloads through the same engine, so an
|
||||
/// unbounded string there is a per-request memory/CPU amplifier. These tests pin that the
|
||||
/// <see cref="FactStringBounds"/> at the reducer boundary — an unbounded string there is a
|
||||
/// per-signal memory/CPU amplifier in every snapshot persist. These tests pin that the
|
||||
/// bound is applied BEFORE any dedupe comparison (so a forged multi-hundred-KB key neither
|
||||
/// costs a full-length compare nor lands in state) and that real-sized values pass through
|
||||
/// unchanged.
|
||||
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Default-value + round-trip tests for <see cref="AdminConfiguration.EnableIndexDualWrite"/>
|
||||
/// (Plan §M5.d). The flag gates the index-table dual-write rollout — defaulting to false
|
||||
/// keeps pre-M5.d behaviour bit-exact and lets operators flip it on deliberately.
|
||||
/// </summary>
|
||||
public class AdminConfigurationEnableIndexDualWriteTests
|
||||
{
|
||||
[Fact]
|
||||
public void EnableIndexDualWrite_defaults_to_false_on_new_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration();
|
||||
Assert.False(cfg.EnableIndexDualWrite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableIndexDualWrite_defaults_to_false_on_CreateDefault()
|
||||
{
|
||||
// CreateDefault() is called when no admin-config row exists yet — if it somehow
|
||||
// enabled dual-write it would break the "default-off" contract immediately.
|
||||
var cfg = AdminConfiguration.CreateDefault();
|
||||
Assert.False(cfg.EnableIndexDualWrite);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnableIndexDualWrite_true_persists_on_the_config()
|
||||
{
|
||||
var cfg = new AdminConfiguration { EnableIndexDualWrite = true };
|
||||
Assert.True(cfg.EnableIndexDualWrite);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// JSON round-trip tests for <see cref="AnalyzeOnEnrollmentEndEnvelope"/>. The producer
|
||||
/// (<c>AzureQueueAnalyzeOnEnrollmentEndProducer</c>) serializes with Newtonsoft and the
|
||||
/// queue worker (<c>AnalyzeOnEnrollmentEndQueueWorker</c>) deserializes the same shape —
|
||||
/// queue worker (<c>AnalyzeOnEnrollmentEndQueueFunction</c>) deserializes the same shape —
|
||||
/// pinning the contract here keeps the queue from silently breaking when fields are added.
|
||||
/// </summary>
|
||||
public class AnalyzeOnEnrollmentEndEnvelopeSerializationTests
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The middleware-side error-envelope writer: status, content type, Retry-After and the exact body
|
||||
/// (production wire options — camelCase, absent-when-null) for the generic envelope and for a
|
||||
/// specialised <see cref="IApiErrorResponse"/>. Whatever the authentication, policy, rate-limit and
|
||||
/// quota middlewares write flows through here, so this is the one place their body shape is pinned.
|
||||
/// </summary>
|
||||
public class ApiErrorWriterTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Generic_envelope_writes_status_content_type_and_the_three_prefix_keys()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-42", HttpStatusCode.Unauthorized,
|
||||
Constants.ApiErrorCodes.AuthenticationRequired, "Authentication required.");
|
||||
|
||||
Assert.Equal(401, ctx.Response.StatusCode);
|
||||
Assert.StartsWith("application/json", ctx.Response.ContentType);
|
||||
Assert.False(ctx.Response.Headers.ContainsKey("Retry-After"));
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Authentication required.\",\"code\":\"AuthenticationRequired\",\"correlationId\":\"cid-42\"}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generic_envelope_with_retry_window_sets_the_header_and_mirrors_it_in_the_body()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-7", HttpStatusCode.TooManyRequests,
|
||||
Constants.ApiErrorCodes.RateLimited, "Rate limit exceeded: 60 requests per minute",
|
||||
retryAfterSeconds: 23);
|
||||
|
||||
Assert.Equal(429, ctx.Response.StatusCode);
|
||||
Assert.Equal("23", ctx.Response.Headers["Retry-After"]);
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Rate limit exceeded: 60 requests per minute\",\"code\":\"RateLimited\",\"correlationId\":\"cid-7\",\"retryAfterSeconds\":23}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Generic_envelope_carries_hint_and_operation_only_when_given()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-9", HttpStatusCode.InternalServerError,
|
||||
Constants.ApiErrorCodes.InternalError, "QueryTable failed.",
|
||||
hint: "Retry with a narrower filter.", operation: "QueryTable");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"QueryTable failed.\",\"code\":\"InternalError\",\"correlationId\":\"cid-9\"," +
|
||||
"\"hint\":\"Retry with a narrower filter.\",\"operation\":\"QueryTable\"}",
|
||||
Body(ctx));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Specialised_body_is_stamped_with_the_correlation_id_and_serialized_as_its_runtime_type()
|
||||
{
|
||||
var ctx = NewContext();
|
||||
var body = new DelegatedSlotLimitReachedResponse
|
||||
{
|
||||
Error = "Slot limit reached.",
|
||||
HomeTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0001",
|
||||
Used = 2,
|
||||
Limit = 2,
|
||||
Required = 1,
|
||||
};
|
||||
|
||||
await ApiErrorWriter.WriteAsync(ctx, "cid-slot", HttpStatusCode.Conflict, body);
|
||||
|
||||
Assert.Equal(409, ctx.Response.StatusCode);
|
||||
Assert.Equal("cid-slot", body.CorrelationId);
|
||||
var json = JsonDocument.Parse(Body(ctx)).RootElement;
|
||||
// Envelope prefix first, then the specialised fields — and nothing of the marker interface.
|
||||
var keys = json.EnumerateObject().Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { "error", "code", "correlationId", "homeTenantId", "used", "limit", "required" }, keys);
|
||||
Assert.Equal("DelegatedSlotLimitReached", json.GetProperty("code").GetString());
|
||||
}
|
||||
|
||||
private static DefaultHttpContext NewContext()
|
||||
{
|
||||
var ctx = new DefaultHttpContext();
|
||||
ctx.Response.Body = new MemoryStream();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static string Body(HttpContext ctx)
|
||||
{
|
||||
ctx.Response.Body.Position = 0;
|
||||
using var reader = new StreamReader(ctx.Response.Body);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
@@ -29,6 +30,33 @@ public class ApiResponseWireParityTests
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
// ---- Error envelope (2026-09, deliberate wire change) ----------------------------------
|
||||
// Non-2xx bodies used to be ~10 anonymous shapes ({ success = false, message }, { error },
|
||||
// { error, message }, { error, code }, …). They are ONE typed envelope now, so these pins fix
|
||||
// the NEW shape exactly rather than proving parity with any single legacy literal.
|
||||
|
||||
[Fact]
|
||||
public void ApiErrorResponse_pins_the_minimal_envelope_shape()
|
||||
{
|
||||
var body = ApiErrorWriter.Build("cid-1", Constants.ApiErrorCodes.NotFound, "Session not found.");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Session not found.\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}",
|
||||
JsonSerializer.Serialize(body, WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApiErrorResponse_pins_the_full_envelope_shape_and_key_order()
|
||||
{
|
||||
var body = ApiErrorWriter.Build("cid-2", Constants.ApiErrorCodes.ServiceUnavailable,
|
||||
"Authorization service temporarily unavailable.", hint: "Retry.", retryAfterSeconds: 5, operation: "PolicyEnforcement");
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"Authorization service temporarily unavailable.\",\"code\":\"ServiceUnavailable\"," +
|
||||
"\"correlationId\":\"cid-2\",\"hint\":\"Retry.\",\"retryAfterSeconds\":5,\"operation\":\"PolicyEnforcement\"}",
|
||||
JsonSerializer.Serialize(body, WireOptions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessMessageResponse_matches_the_canonical_anonymous_shape()
|
||||
{
|
||||
|
||||
+5
-3
@@ -61,7 +61,8 @@ public class AzureQueueImeMsiArchiveProducerTests
|
||||
public async Task EnqueueAsync_send_failure_is_swallowed()
|
||||
{
|
||||
var (producer, queue, _) = BuildSut();
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
queue.Setup(q => q.SendMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "queue busy"));
|
||||
|
||||
// Fire-and-forget continuation on the ingest path — must never throw.
|
||||
@@ -107,8 +108,9 @@ public class AzureQueueImeMsiArchiveProducerTests
|
||||
queue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
queue.Setup(q => q.SendMessageAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, CancellationToken>((body, _) =>
|
||||
queue.Setup(q => q.SendMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan?>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, TimeSpan?, TimeSpan?, CancellationToken>((body, _, _, _) =>
|
||||
{
|
||||
sentBodies.Add(body);
|
||||
var receipt = QueuesModelFactory.SendReceipt(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrent cache misses on the two configuration services share one repository read
|
||||
/// (audit 2026-09-05 F12). The MemoryCache stays: the hit path and the in-place projection
|
||||
/// semantics are untouched, only the miss path is single-flighted.
|
||||
/// </summary>
|
||||
public class ConfigurationServiceSingleFlightTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Admin_config_concurrent_misses_issue_one_repository_read()
|
||||
{
|
||||
var gate = new TaskCompletionSource<AdminConfiguration?>();
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync()).Returns(gate.Task);
|
||||
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync()).ToArray();
|
||||
gate.SetResult(AdminConfiguration.CreateDefault());
|
||||
var results = await Task.WhenAll(calls);
|
||||
|
||||
repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Once);
|
||||
Assert.All(results, r => Assert.Same(results[0], r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_config_after_completion_the_next_miss_reads_again()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetAdminConfigurationAsync()).ReturnsAsync(AdminConfiguration.CreateDefault());
|
||||
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await sut.GetConfigurationAsync();
|
||||
sut.InvalidateCache();
|
||||
await sut.GetConfigurationAsync();
|
||||
|
||||
repo.Verify(r => r.GetAdminConfigurationAsync(), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_config_concurrent_misses_for_one_tenant_issue_one_repository_read()
|
||||
{
|
||||
const string tenantId = "11111111-1111-1111-1111-111111111111";
|
||||
var gate = new TaskCompletionSource<TenantConfiguration?>();
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(tenantId)).Returns(gate.Task);
|
||||
var sut = new TenantConfigurationService(repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
var calls = Enumerable.Range(0, 10).Select(_ => sut.GetConfigurationAsync(tenantId)).ToArray();
|
||||
gate.SetResult(TenantConfiguration.CreateDefault(tenantId));
|
||||
var results = await Task.WhenAll(calls);
|
||||
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(tenantId), Times.Once);
|
||||
Assert.All(results, r => Assert.Same(results[0], r));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_config_different_tenants_do_not_share_a_flight()
|
||||
{
|
||||
const string a = "11111111-1111-1111-1111-111111111111";
|
||||
const string b = "22222222-2222-2222-2222-222222222222";
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.Returns<string>(id => Task.FromResult<TenantConfiguration?>(TenantConfiguration.CreateDefault(id)));
|
||||
var sut = new TenantConfigurationService(repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
await Task.WhenAll(sut.GetConfigurationAsync(a), sut.GetConfigurationAsync(b));
|
||||
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(a), Times.Once);
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(b), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DecisionGraphBuilder"/> — pure projection, zero IO. Covers node
|
||||
/// de-duplication, terminal classification, dead-end preservation, visit-count semantics, and
|
||||
/// empty / out-of-order inputs.
|
||||
/// </summary>
|
||||
public class DecisionGraphBuilderTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static DecisionTransitionRecord Transition(
|
||||
int step, string from, string to, bool taken = true, string? deadEnd = null, string trigger = "t",
|
||||
string reducer = "1.0.0")
|
||||
=> new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = step,
|
||||
FromStage = from,
|
||||
ToStage = to,
|
||||
Taken = taken,
|
||||
DeadEndReason = deadEnd,
|
||||
Trigger = trigger,
|
||||
ReducerVersion = reducer,
|
||||
IsTerminal = DecisionGraphBuilder.IsTerminalStage(to),
|
||||
OccurredAtUtc = new DateTime(2026, 4, 21, 10, step, 0, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
// ============================================================ Empty input
|
||||
|
||||
[Fact]
|
||||
public void Build_returns_empty_projection_when_no_transitions()
|
||||
{
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, Array.Empty<DecisionTransitionRecord>());
|
||||
|
||||
Assert.Equal(TenantId, graph.TenantId);
|
||||
Assert.Equal(SessionId, graph.SessionId);
|
||||
Assert.Empty(graph.Nodes);
|
||||
Assert.Empty(graph.Edges);
|
||||
Assert.Equal(string.Empty, graph.ReducerVersion);
|
||||
}
|
||||
|
||||
// ============================================================ Node de-duplication
|
||||
|
||||
[Fact]
|
||||
public void Build_deduplicates_stages_into_unique_nodes()
|
||||
{
|
||||
// Same stage visited twice through different paths — appears once in Nodes.
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "SessionStarted", "EspInProgress"),
|
||||
Transition(1, "EspInProgress", "AccountSetup"),
|
||||
Transition(2, "AccountSetup", "EspInProgress"), // back-edge (hypothetical)
|
||||
Transition(3, "EspInProgress", "Completed"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
// Unique stages: SessionStarted, EspInProgress, AccountSetup, Completed = 4 nodes.
|
||||
Assert.Equal(4, graph.Nodes.Count);
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "SessionStarted");
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "EspInProgress");
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "AccountSetup");
|
||||
Assert.Contains(graph.Nodes, n => n.Id == "Completed");
|
||||
}
|
||||
|
||||
// ============================================================ Terminal classification
|
||||
|
||||
[Theory]
|
||||
[InlineData("Completed", "Succeeded")]
|
||||
[InlineData("Failed", "Failed")]
|
||||
[InlineData("WhiteGloveSealed", "PausedForPart2")]
|
||||
public void Build_marks_terminal_stages_and_derives_outcome(string terminalStage, string expectedOutcome)
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "Start", terminalStage),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
var node = graph.Nodes.Single(n => n.Id == terminalStage);
|
||||
|
||||
Assert.True(node.IsTerminal);
|
||||
Assert.Equal(expectedOutcome, node.TerminalOutcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_leaves_non_terminal_nodes_unmarked()
|
||||
{
|
||||
var transitions = new[] { Transition(0, "SessionStarted", "EspInProgress") };
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.All(graph.Nodes, n =>
|
||||
{
|
||||
Assert.False(n.IsTerminal);
|
||||
Assert.Null(n.TerminalOutcome);
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================ Edges
|
||||
|
||||
[Fact]
|
||||
public void Build_preserves_all_edges_including_dead_ends()
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "EspInProgress", "Completed", taken: true),
|
||||
Transition(1, "EspInProgress", "EspInProgress", taken: false,
|
||||
deadEnd: "hybrid_reboot_gate_blocking", trigger: "EspExiting"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.Equal(2, graph.Edges.Count);
|
||||
Assert.Contains(graph.Edges, e => e.Taken && e.ToStage == "Completed");
|
||||
var deadEnd = graph.Edges.Single(e => !e.Taken);
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", deadEnd.DeadEndReason);
|
||||
Assert.Equal("EspExiting", deadEnd.Trigger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_orders_edges_by_StepIndex_even_if_input_is_shuffled()
|
||||
{
|
||||
// Repository sorts, but defend anyway — the builder is used directly in tests and
|
||||
// could be fed arbitrary lists.
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(2, "AccountSetup", "Completed"),
|
||||
Transition(0, "SessionStarted", "EspInProgress"),
|
||||
Transition(1, "EspInProgress", "AccountSetup"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.Equal(new[] { 0, 1, 2 }, graph.Edges.Select(e => e.StepIndex));
|
||||
}
|
||||
|
||||
// ============================================================ VisitCount
|
||||
|
||||
[Fact]
|
||||
public void Build_counts_only_taken_transitions_toward_VisitCount()
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "Start", "EspInProgress", taken: true),
|
||||
Transition(1, "EspInProgress", "EspInProgress", taken: false, deadEnd: "blocked"),
|
||||
Transition(2, "EspInProgress", "Completed", taken: true),
|
||||
Transition(3, "Start", "EspInProgress", taken: true), // hypothetical retry
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
var esp = graph.Nodes.Single(n => n.Id == "EspInProgress");
|
||||
Assert.Equal(2, esp.VisitCount); // two taken → EspInProgress; the dead-end does not count
|
||||
|
||||
var completed = graph.Nodes.Single(n => n.Id == "Completed");
|
||||
Assert.Equal(1, completed.VisitCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_exposes_ReducerVersion_from_first_row()
|
||||
{
|
||||
var transitions = new[]
|
||||
{
|
||||
Transition(0, "Start", "EspInProgress", reducer: "1.4.2"),
|
||||
Transition(1, "EspInProgress", "Completed", reducer: "1.4.2"),
|
||||
};
|
||||
|
||||
var graph = DecisionGraphBuilder.Build(TenantId, SessionId, transitions);
|
||||
|
||||
Assert.Equal("1.4.2", graph.ReducerVersion);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
error = "Delegated tenant slot limit reached for partner.example: 2 of 2 slot(s) in use, 1 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
|
||||
code = "DelegatedSlotLimitReached",
|
||||
correlationId = "",
|
||||
homeTenantId = Home,
|
||||
homeTenantDomain = "partner.example",
|
||||
used = 2,
|
||||
@@ -47,6 +48,7 @@ public class DelegationWireParityTests
|
||||
{
|
||||
error = $"Delegated tenant slot limit reached for {Home}: 3 of 3 slot(s) in use, 2 more needed. Raise the tenant's limit (plan package or Global Admin override) and retry.",
|
||||
code = "DelegatedSlotLimitReached",
|
||||
correlationId = "",
|
||||
homeTenantId = Home,
|
||||
homeTenantDomain,
|
||||
used = 3,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using AutopilotMonitor.Functions.Functions.Sessions;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Deletion;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
@@ -59,6 +60,9 @@ public class DeleteSessionFunctionTests
|
||||
}
|
||||
|
||||
// ── BuildV2ResponseBody ───────────────────────────────────────────────────────────────
|
||||
// The success arm is SessionDeletionQueuedResponse; every rejected arm is the typed error body
|
||||
// SessionDeletionRejectedResponse (envelope prefix error/code/correlationId + lock diagnostics).
|
||||
// The correlation id is stamped by the writer, so the builder leaves it empty here.
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_Enqueued_carries_manifestId_and_queued_status()
|
||||
@@ -69,15 +73,15 @@ public class DeleteSessionFunctionTests
|
||||
ManifestId = ManifestId,
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Assert.IsType<SessionDeletionQueuedResponse>(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "success", true);
|
||||
AssertProperty(body, "status", "queued");
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
Assert.True(body.Success);
|
||||
Assert.Equal("queued", body.Status);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_hint()
|
||||
public void BuildV2ResponseBody_AlreadyInFlight_carries_state_and_manifestId_with_code()
|
||||
{
|
||||
var result = new SessionDeletionEnqueueResult
|
||||
{
|
||||
@@ -86,12 +90,11 @@ public class DeleteSessionFunctionTests
|
||||
ExistingState = "Running",
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "success", false);
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
AssertProperty(body, "deletionState", "Running");
|
||||
AssertProperty(body, "hint", "cascade_already_in_flight");
|
||||
Assert.Equal(Constants.ApiErrorCodes.CascadeAlreadyInFlight, body.Code);
|
||||
Assert.Equal(ManifestId, body.ManifestId);
|
||||
Assert.Equal("Running", body.DeletionState);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -106,13 +109,12 @@ public class DeleteSessionFunctionTests
|
||||
ExistingState = "Poisoned",
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "hint", "cascade_poisoned_use_restore");
|
||||
AssertProperty(body, "manifestId", ManifestId);
|
||||
Assert.Equal(Constants.ApiErrorCodes.CascadePoisonedUseRestore, body.Code);
|
||||
Assert.Equal(ManifestId, body.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);
|
||||
Assert.Contains("/restore", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,10 +128,27 @@ public class DeleteSessionFunctionTests
|
||||
Outcome = SessionDeletionEnqueueOutcome.KillSwitchActive,
|
||||
};
|
||||
|
||||
var body = DeleteSessionFunction.BuildV2ResponseBody(result, SessionId);
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
|
||||
AssertProperty(body, "hint", "kill_switch_active");
|
||||
Assert.False(HasProperty(body, "manifestId"));
|
||||
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, body.Code);
|
||||
Assert.Null(body.ManifestId);
|
||||
Assert.DoesNotContain("manifestId", TestWire.Serialize(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildV2ResponseBody_rejected_arms_serialize_with_the_envelope_prefix()
|
||||
{
|
||||
var result = new SessionDeletionEnqueueResult
|
||||
{
|
||||
Outcome = SessionDeletionEnqueueOutcome.SessionNotFound,
|
||||
};
|
||||
|
||||
var body = Rejected(DeleteSessionFunction.BuildV2ResponseBody(result, SessionId));
|
||||
body.CorrelationId = "cid-1";
|
||||
|
||||
Assert.Equal(
|
||||
$"{{\"error\":\"Session {SessionId} not found\",\"code\":\"NotFound\",\"correlationId\":\"cid-1\"}}",
|
||||
TestWire.Serialize(body));
|
||||
}
|
||||
|
||||
// ── EvaluateAdminDeleteGates (kill-switch short-circuit) ──────────────────────────────
|
||||
@@ -141,10 +160,10 @@ public class DeleteSessionFunctionTests
|
||||
|
||||
Assert.NotNull(gate);
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, gate!.Value.Status);
|
||||
AssertProperty(gate.Value.Body, "hint", "kill_switch_active");
|
||||
Assert.Equal(Constants.ApiErrorCodes.KillSwitchActive, gate.Value.Body.Code);
|
||||
// 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"));
|
||||
Assert.Null(gate.Value.Body.ManifestId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -157,25 +176,6 @@ public class DeleteSessionFunctionTests
|
||||
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;
|
||||
private static SessionDeletionRejectedResponse Rejected(IApiResponse body)
|
||||
=> Assert.IsType<SessionDeletionRejectedResponse>(body);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,7 @@ public class DeletionManifestBuilderTests
|
||||
.WithSignals(15)
|
||||
.WithDecisionTransitions(4)
|
||||
.WithEventTypeIndex(5)
|
||||
.WithCveIndex(2)
|
||||
.WithSessionsByTerminal(1)
|
||||
.WithSessionsByStage(3)
|
||||
.WithDeadEndsByReason(0)
|
||||
.WithClassifierVerdictsByIdLevel(2)
|
||||
.WithSignalsByKind(11));
|
||||
.WithCveIndex(2));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -69,15 +64,10 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(4, c["decisionTransitions"]);
|
||||
Assert.Equal(5, c["eventTypeIndex"]);
|
||||
Assert.Equal(2, c["cveIndex"]);
|
||||
Assert.Equal(1, c["sessionsByTerminal"]);
|
||||
Assert.Equal(3, c["sessionsByStage"]);
|
||||
Assert.Equal(0, c["deadEndsByReason"]);
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 20 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
|
||||
// SessionTenantLookup P6.1) + tombstone = 21 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(21, manifest.Steps.Count);
|
||||
// 15 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
|
||||
// SessionTenantLookup P6.1) + tombstone = 16 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(16, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -99,11 +89,6 @@ public class DeletionManifestBuilderTests
|
||||
.WithDecisionTransitions(1, propValue: "transitions_marker")
|
||||
.WithEventTypeIndex(1, propValue: "eti_marker")
|
||||
.WithCveIndex(1, propValue: "cve_marker")
|
||||
.WithSessionsByTerminal(1, propValue: "terminal_marker")
|
||||
.WithSessionsByStage(1, propValue: "stage_marker")
|
||||
.WithDeadEndsByReason(1, propValue: "deadend_marker")
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
|
||||
.WithSessionAnnotations(propValue: "annotation_marker")
|
||||
.WithSessionTenantLookup(propValue: "lookup_marker"));
|
||||
@@ -149,9 +134,9 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 20 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
|
||||
Assert.Equal(20, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 15 cascade-table steps
|
||||
// (11 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
|
||||
Assert.Equal(15, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -180,8 +165,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 20 cascade + 2 inventory + 1 tombstone = 23 steps.
|
||||
Assert.Equal(23, manifest.Steps.Count);
|
||||
// Expect: 15 cascade + 2 inventory + 1 tombstone = 18 steps.
|
||||
Assert.Equal(18, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -310,8 +295,7 @@ public class DeletionManifestBuilderTests
|
||||
.WithSessionsRow()
|
||||
.WithSessionsIndexRow()
|
||||
.WithEvents(3)
|
||||
.WithSignals(5)
|
||||
.WithSessionsByTerminal(1));
|
||||
.WithSignals(5));
|
||||
|
||||
var first = await NewBuilder(reader).BuildAsync(TenantId, SessionId, "admin_delete",
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
@@ -345,7 +329,6 @@ public class DeletionManifestBuilderTests
|
||||
.WithSessionsIndexRow()
|
||||
.WithEvents(2, propValue: "events_check")
|
||||
.WithRuleResults(1, propValue: "rules_check")
|
||||
.WithSessionsByTerminal(1, propValue: "terminal_check")
|
||||
.WithSessionInventoryContributions(MakeContributionsRow(new List<DeletionDecrementKey>
|
||||
{
|
||||
new DeletionDecrementKey { Vendor = "Contoso", Name = "App", Version = "1.0" },
|
||||
@@ -778,12 +761,6 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionsByTerminal(int n, string propValue = "term") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByTerminal, n, propValue, "Failed");
|
||||
public ReaderSeed WithSessionsByStage(int n, string propValue = "stg") => SeedDiscriminatorPkProp(Constants.TableNames.SessionsByStage, n, propValue, "AccountSetup");
|
||||
public ReaderSeed WithDeadEndsByReason(int n, string propValue = "de") => SeedDiscriminatorPkProp(Constants.TableNames.DeadEndsByReason, n, propValue, "Timeout");
|
||||
public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
|
||||
public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
|
||||
|
||||
public ReaderSeed WithSessionAnnotations(string propValue = "anno")
|
||||
{
|
||||
// One row per lane — the builder emits one exact-RK step per lane.
|
||||
@@ -887,22 +864,5 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
private ReaderSeed SeedDiscriminatorPkProp(string tableName, int n, string propValue, string discriminator)
|
||||
{
|
||||
// PK = {tenantId}_{discriminator}, server-side filter on PK prefix + SessionId == sessionId.
|
||||
var rows = new List<TableEntity>(n);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
var e = new TableEntity($"{TenantId}_{discriminator}", $"{tableName}_{i:D5}")
|
||||
{
|
||||
["Marker"] = propValue,
|
||||
["SessionId"] = SessionId,
|
||||
};
|
||||
e.ETag = new ETag("0x" + tableName + i);
|
||||
rows.Add(e);
|
||||
}
|
||||
_query[tableName] = rows;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,17 +726,6 @@ public class DeviceJourneyAndFtrTests
|
||||
Assert.Equal(3, totals.ExcludedSessions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)] // default window
|
||||
[InlineData("garbage", 30)]
|
||||
[InlineData("7", 7)]
|
||||
[InlineData("0", 1)] // clamped low
|
||||
[InlineData("999", 180)] // clamped to aggregate retention
|
||||
public void ClampDays_DefaultsAndClamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InclusiveWindowStart_YieldsExactlyNCalendarDays()
|
||||
{
|
||||
|
||||
-25
@@ -277,31 +277,6 @@ public class EndpointPolicyCatalogCompletenessTests
|
||||
Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inspector v1 endpoints (signals, decision-graph, reducer-verification) MUST stay locked to a
|
||||
/// platform-scope tier while the UI matures (Plan §M6 — primary use case is modelling 2-stage
|
||||
/// WhiteGlove deployments). They are now GlobalReadOrAdmin (GA + read-only GlobalReader): still
|
||||
/// invisible to tenant admins/members, but visible to the read-only platform tier. Defense-in-depth
|
||||
/// against an accidental downgrade to a tenant tier (MemberRead/TenantAdminOrGA) that would expose
|
||||
/// decision internals before the lift.
|
||||
///
|
||||
/// When the v2 adminMode lift happens, signals+decision-graph move to MemberRead with
|
||||
/// TenantScoping.QueryParam. reducer-verification stays platform-scope (never tenant-visible).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/sessions/abc-123/signals", "sessions/{sessionId}/signals")]
|
||||
[InlineData("GET", "/api/sessions/abc-123/decision-graph", "sessions/{sessionId}/decision-graph")]
|
||||
[InlineData("GET", "/api/sessions/abc-123/reducer-verification", "sessions/{sessionId}/reducer-verification")]
|
||||
public void InspectorRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
|
||||
{
|
||||
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
|
||||
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(expectedTemplate, entry!.RouteTemplate);
|
||||
Assert.Equal(EndpointPolicy.GlobalReadOrAdmin, entry.Policy);
|
||||
Assert.Equal(TenantScoping.None, entry.TenantScoping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SignalR group join/leave MUST be AuthenticatedUserWithRole — NOT MemberRead and NOT plain
|
||||
/// AuthenticatedUser. The Progress Portal admits non-member end users, so MemberRead 403s them
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 3;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -94,7 +94,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 15;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 2;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 2;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -117,7 +117,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
}
|
||||
};
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
@@ -134,7 +134,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 4;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
// warn=5 → 4 messages now classifies as healthy (would be warning under defaults)
|
||||
var svc = BuildService(probe, new Dictionary<string, string?>
|
||||
@@ -153,7 +153,7 @@ public class HealthCheckServicePoisonQueueTests
|
||||
var probe = new FakePoisonQueueProbe();
|
||||
probe.Counts[Constants.QueueNames.AnalyzeOnEnrollmentEnd + "-poison"] = 1;
|
||||
probe.Counts[Constants.QueueNames.VulnerabilityCorrelate + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TelemetryIndexReconcile + "-poison"] = 0;
|
||||
probe.Counts[Constants.QueueNames.TenantAutoApprove + "-poison"] = 0;
|
||||
|
||||
var svc = BuildService(probe);
|
||||
var check = await svc.CheckPoisonQueuesAsync();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Queue;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Contract of the ime-msi-archive queue function (successor of the self-polling worker):
|
||||
/// merge the archiver's outcome onto the ImeVersionHistory row on EVERY attempt (also
|
||||
/// failures, so operators see a stuck download), return for terminal outcomes so the host
|
||||
/// deletes the message, throw for retryable ones so the host's retry → poison ladder applies,
|
||||
/// re-enqueue with a delay — not drop — while ImeMsiArchivingEnabled is off, and drop
|
||||
/// version-less envelopes before the archiver runs.
|
||||
/// </summary>
|
||||
public class ImeMsiArchiveQueueFunctionTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task Success_merges_row_and_completes()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Archiver.Result = new ImeMsiArchiveResult(
|
||||
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
|
||||
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
|
||||
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.Archived,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
h.Producer.Verify(p => p.EnqueueAsync(
|
||||
It.IsAny<ImeMsiArchiveEnvelope>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retryable_failure_merges_row_and_throws_for_host_retry()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None));
|
||||
|
||||
// Row always tells the truth — the failure status IS merged before the throw.
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedDownload,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Permanent_failure_merges_row_and_completes()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
|
||||
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
|
||||
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Paused_requeues_with_delay_and_skips_archiver()
|
||||
{
|
||||
var h = new Harness(archivingEnabled: false);
|
||||
|
||||
await h.Sut.ProcessAsync(Message(Envelope()), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, h.Archiver.Calls);
|
||||
h.Producer.Verify(p => p.EnqueueAsync(
|
||||
It.Is<ImeMsiArchiveEnvelope>(e => e.Version == Version),
|
||||
ImeMsiArchiveQueueFunction.PauseRequeueDelay,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Envelope_without_version_is_dropped_before_archiver()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.Sut.ProcessAsync(Message(new ImeMsiArchiveEnvelope
|
||||
{
|
||||
Version = string.Empty,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
}), CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, h.Archiver.Calls);
|
||||
h.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
h.Producer.Verify(p => p.EnqueueAsync(
|
||||
It.IsAny<ImeMsiArchiveEnvelope>(), It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private static QueueMessage Message(ImeMsiArchiveEnvelope envelope) => QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + Guid.NewGuid().ToString("N"),
|
||||
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
|
||||
body: new BinaryData(JsonConvert.SerializeObject(envelope)),
|
||||
dequeueCount: 1);
|
||||
|
||||
/// <summary>Scripted archiver — the function only consumes the classified result.</summary>
|
||||
private sealed class StubArchiver : ImeMsiArchiver
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public ImeMsiArchiveResult Result { get; set; } = new(
|
||||
true, Statuses.Archived, Retryable: false, null, null, null, null);
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public override Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public StubArchiver Archiver { get; } = new();
|
||||
public Mock<ISessionRepository> Repo { get; } = new();
|
||||
public Mock<IImeMsiArchiveProducer> Producer { get; } = new();
|
||||
public ImeMsiArchiveQueueFunction Sut { get; }
|
||||
|
||||
public Harness(bool archivingEnabled = true)
|
||||
{
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
|
||||
|
||||
Sut = new ImeMsiArchiveQueueFunction(
|
||||
Archiver, Repo.Object, adminConfig.Object, Producer.Object,
|
||||
NullLogger<ImeMsiArchiveQueueFunction>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Queues;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Ime;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Worker-level tests for the ime-msi-archive queue consumer (daily review 2026-08-18:
|
||||
/// previously untested). The worker's contract: merge the archiver's outcome onto the
|
||||
/// ImeVersionHistory row on EVERY attempt (also failures, so operators see a stuck
|
||||
/// download), delete the message for terminal outcomes, rethrow for retryable ones
|
||||
/// (visibility-timeout ladder), pause — not drop — while ImeMsiArchivingEnabled is off,
|
||||
/// and drop version-less envelopes without invoking the archiver.
|
||||
/// </summary>
|
||||
public class ImeMsiArchiveQueueWorkerTests
|
||||
{
|
||||
private const string Version = "1.104.102.0";
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_success_merges_row_and_deletes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
true, ImeMsiArchiver.Statuses.Archived, Retryable: false,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.Archived,
|
||||
$"{Version}/IntuneWindowsAgent.msi", "abc123", 42L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_retryable_failure_merges_row_but_leaves_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedDownload, Retryable: true,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.Archiver.Calls > 0);
|
||||
|
||||
// Row always tells the truth — the failure status IS merged...
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedDownload,
|
||||
null, null, null, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.AtLeastOnce);
|
||||
// ...but the message stays for the visibility-timeout retry → poison ladder.
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_permanent_failure_merges_row_and_completes_message()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.Archiver.Result = new ImeMsiArchiveResult(
|
||||
false, ImeMsiArchiver.Statuses.FailedTooLarge, Retryable: false,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
Version, ImeMsiArchiver.Statuses.FailedTooLarge,
|
||||
null, null, 999L, ImeMsiArchiver.FallbackMsiUrls[1]),
|
||||
Times.Once);
|
||||
harness.MainQueue.Verify(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_pauses_while_archiving_disabled()
|
||||
{
|
||||
var harness = new Harness(archivingEnabled: false);
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(Envelope()));
|
||||
|
||||
await harness.RunForAsync(TimeSpan.FromMilliseconds(500));
|
||||
|
||||
// Pause, not drop: no receive, no archive attempt — the message parks in the queue.
|
||||
harness.MainQueue.Verify(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_drops_envelope_without_version_before_archiver()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.EnqueueMessage(JsonConvert.SerializeObject(new ImeMsiArchiveEnvelope
|
||||
{
|
||||
Version = string.Empty,
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
}));
|
||||
|
||||
await harness.RunUntilAsync(() => harness.MainQueueDeleted());
|
||||
|
||||
Assert.Equal(0, harness.Archiver.Calls);
|
||||
harness.Repo.Verify(r => r.UpdateImeVersionArchiveInfoAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string?>(),
|
||||
It.IsAny<string?>(), It.IsAny<long?>(), It.IsAny<string?>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static ImeMsiArchiveEnvelope Envelope() => new()
|
||||
{
|
||||
Version = Version,
|
||||
MsiDownloadUrl = ImeMsiArchiver.FallbackMsiUrls[1],
|
||||
MsiMatchedBy = "productVersion",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
/// <summary>Scripted archiver — the worker only consumes the classified result.</summary>
|
||||
private sealed class StubArchiver : ImeMsiArchiver
|
||||
{
|
||||
private int _calls;
|
||||
|
||||
public ImeMsiArchiveResult Result { get; set; } = new(
|
||||
true, Statuses.Archived, Retryable: false, null, null, null, null);
|
||||
|
||||
public int Calls => Volatile.Read(ref _calls);
|
||||
|
||||
public override Task<ImeMsiArchiveResult> ArchiveAsync(
|
||||
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _calls);
|
||||
return Task.FromResult(Result);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mock-queue poll-loop harness, same shape as SessionDeletionWorkerTests.</summary>
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<QueueClient> MainQueue { get; }
|
||||
public Mock<QueueClient> PoisonQueue { get; }
|
||||
public StubArchiver Archiver { get; } = new();
|
||||
public Mock<ISessionRepository> Repo { get; } = new();
|
||||
public ImeMsiArchiveQueueWorker Sut { get; }
|
||||
|
||||
private readonly Queue<QueueMessage> _pendingMessages = new();
|
||||
|
||||
public Harness(bool archivingEnabled = true)
|
||||
{
|
||||
MainQueue = new Mock<QueueClient>();
|
||||
PoisonQueue = new Mock<QueueClient>();
|
||||
|
||||
MainQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
PoisonQueue.Setup(q => q.CreateIfNotExistsAsync(
|
||||
It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
|
||||
MainQueue.Setup(q => q.ReceiveMessagesAsync(
|
||||
It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<int, TimeSpan?, CancellationToken>((maxMessages, _, _) =>
|
||||
{
|
||||
var batch = new List<QueueMessage>();
|
||||
lock (_pendingMessages)
|
||||
{
|
||||
while (batch.Count < maxMessages && _pendingMessages.Count > 0)
|
||||
batch.Add(_pendingMessages.Dequeue());
|
||||
}
|
||||
return Task.FromResult(Response.FromValue(batch.ToArray(), new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
MainQueue.Setup(q => q.DeleteMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Mock<Response>().Object);
|
||||
|
||||
MainQueue.Setup(q => q.UpdateMessageAsync(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, string, TimeSpan, CancellationToken>((_, _, _, vis, _) =>
|
||||
{
|
||||
var updated = QueuesModelFactory.UpdateReceipt(
|
||||
"pop-extended-" + Guid.NewGuid().ToString("N"),
|
||||
DateTimeOffset.UtcNow.Add(vis));
|
||||
return Task.FromResult(Response.FromValue(updated, new Mock<Response>().Object));
|
||||
});
|
||||
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { ImeMsiArchivingEnabled = archivingEnabled });
|
||||
|
||||
Sut = new ImeMsiArchiveQueueWorker(
|
||||
MainQueue.Object, PoisonQueue.Object,
|
||||
Archiver, Repo.Object, adminConfig.Object,
|
||||
NullLogger<ImeMsiArchiveQueueWorker>.Instance,
|
||||
pollInterval: TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
public void EnqueueMessage(string body)
|
||||
{
|
||||
var msg = QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + Guid.NewGuid().ToString("N"),
|
||||
popReceipt: "pop-" + Guid.NewGuid().ToString("N"),
|
||||
body: new BinaryData(body),
|
||||
dequeueCount: 1);
|
||||
lock (_pendingMessages) _pendingMessages.Enqueue(msg);
|
||||
}
|
||||
|
||||
public async Task RunForAsync(TimeSpan duration)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(duration);
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Task.Delay(duration, cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public async Task RunUntilAsync(Func<bool> condition, TimeSpan? timeout = null)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(10));
|
||||
using var cts = new CancellationTokenSource();
|
||||
try { await Sut.StartAsync(cts.Token); }
|
||||
catch (OperationCanceledException) { }
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
cts.Cancel();
|
||||
try { await Sut.StopAsync(CancellationToken.None); }
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
public bool MainQueueDeleted() =>
|
||||
MainQueue.Invocations.Any(i => i.Method.Name == nameof(QueueClient.DeleteMessageAsync));
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services.Indexing;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <b>M5 release-gate</b> (Plan §M5-Pflicht line 1395): per-index-table PK/RK consistency
|
||||
/// between primary rows and their derived index rows. For each index table, walk a
|
||||
/// canonical primary row through the Factory + Handler pipeline and verify that the
|
||||
/// captured index record carries enough back-ref information to reconstruct the
|
||||
/// primary's <c>(PartitionKey, RowKey)</c> via <see cref="TableSignalRepository"/> /
|
||||
/// <see cref="TableDecisionTransitionRepository"/> key builders.
|
||||
/// <para>
|
||||
/// Also asserts pipeline idempotency: running the same primary row through twice
|
||||
/// produces identical upsert keys — the consumer's <c>UpsertReplace</c> batches stay
|
||||
/// deduplicating even under timer-triggered resubmits.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class IndexDualWriteConsistencyTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut()
|
||||
{
|
||||
var repo = new FakeIndexTableRepository();
|
||||
var handler = new IndexReconcileHandler(repo, NullLogger<IndexReconcileHandler>.Instance);
|
||||
return (handler, repo);
|
||||
}
|
||||
|
||||
// ============================================================ SignalsByKind ↔ Signals
|
||||
|
||||
[Fact]
|
||||
public async Task SignalsByKind_row_carries_back_ref_to_primary_Signals_row()
|
||||
{
|
||||
var primary = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = 42,
|
||||
Kind = "EspTerminalFailure",
|
||||
OccurredAtUtc = Occurred,
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(primary));
|
||||
|
||||
var index = Assert.Single(repo.SignalsByKind);
|
||||
|
||||
// Back-ref fields on the index row must reconstruct the primary PK/RK.
|
||||
var reconstructedPk = TableSignalRepository.BuildPartitionKey(index.TenantId, index.SessionId);
|
||||
var reconstructedRk = TableSignalRepository.BuildRowKey(index.SessionSignalOrdinal);
|
||||
|
||||
Assert.Equal(
|
||||
TableSignalRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
reconstructedPk);
|
||||
Assert.Equal(
|
||||
TableSignalRepository.BuildRowKey(primary.SessionSignalOrdinal),
|
||||
reconstructedRk);
|
||||
}
|
||||
|
||||
// ============================================================ SessionsByTerminal ↔ DecisionTransitions
|
||||
|
||||
[Fact]
|
||||
public async Task SessionsByTerminal_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = TerminalTransition();
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.SessionsByTerminal);
|
||||
|
||||
var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId);
|
||||
var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
reconstructedPk);
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
reconstructedRk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionsByStage_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 3,
|
||||
FromStage = "AwaitingEsp",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = true,
|
||||
IsTerminal = false,
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.SessionsByStage);
|
||||
|
||||
var reconstructedPk = TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId);
|
||||
var reconstructedRk = TableDecisionTransitionRepository.BuildRowKey(index.StepIndex);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
reconstructedPk);
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
reconstructedRk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeadEndsByReason_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 5,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = false,
|
||||
IsTerminal = false,
|
||||
DeadEndReason = "hybrid_reboot_gate_blocking",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.DeadEndsByReason);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId));
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
TableDecisionTransitionRepository.BuildRowKey(index.StepIndex));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClassifierVerdictsByIdLevel_row_carries_back_ref_to_primary_DecisionTransitions_row()
|
||||
{
|
||||
var primary = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var (handler, repo) = Sut();
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(primary));
|
||||
|
||||
var index = Assert.Single(repo.ClassifierVerdictsByIdLevel);
|
||||
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(primary.TenantId, primary.SessionId),
|
||||
TableDecisionTransitionRepository.BuildPartitionKey(index.TenantId, index.SessionId));
|
||||
Assert.Equal(
|
||||
TableDecisionTransitionRepository.BuildRowKey(primary.StepIndex),
|
||||
TableDecisionTransitionRepository.BuildRowKey(index.StepIndex));
|
||||
}
|
||||
|
||||
// ============================================================ pipeline idempotency
|
||||
|
||||
[Fact]
|
||||
public async Task Resubmitting_same_primary_rows_produces_same_index_keys()
|
||||
{
|
||||
// Simulates the M5.d.4 timer re-enqueueing primary rows it already handled —
|
||||
// the handler's index-row keys must be key-stable so the real TableIndexRepository's
|
||||
// UpsertReplace collapses duplicates rather than appending rows.
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
var signal = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = 1,
|
||||
Kind = "DesktopArrived",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
var transition = TerminalTransition();
|
||||
|
||||
// First pass: initial ingest fan-out.
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal));
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition));
|
||||
|
||||
// Second pass: 2h-timer reconcile resubmits the same primaries.
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromSignal(signal));
|
||||
await handler.HandleAsync(IndexReconcileEnvelopeFactory.FromDecisionTransition(transition));
|
||||
|
||||
// The fake repo captures one row per call, but all duplicated rows must map to the
|
||||
// same (PK, RK) — the real Azure Tables Upsert would collapse them.
|
||||
Assert.Equal(2, repo.SignalsByKind.Count);
|
||||
var signalKeys = repo.SignalsByKind
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildSignalsByKindPk(r.TenantId, r.SignalKind),
|
||||
Rk: IndexRowKeys.BuildSignalsByKindRk(r.OccurredAtUtc, r.SessionId, r.SessionSignalOrdinal)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(signalKeys);
|
||||
|
||||
Assert.Equal(2, repo.SessionsByTerminal.Count);
|
||||
var terminalKeys = repo.SessionsByTerminal
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildSessionsByTerminalPk(r.TenantId, r.TerminalStage),
|
||||
Rk: IndexRowKeys.BuildSessionsByTerminalRk(r.OccurredAtUtc, r.SessionId)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(terminalKeys);
|
||||
|
||||
Assert.Equal(2, repo.SessionsByStage.Count);
|
||||
var stageKeys = repo.SessionsByStage
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildSessionsByStagePk(r.TenantId, r.Stage),
|
||||
Rk: IndexRowKeys.BuildSessionsByStageRk(r.LastUpdatedUtc, r.SessionId)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(stageKeys);
|
||||
|
||||
Assert.Equal(2, repo.ClassifierVerdictsByIdLevel.Count);
|
||||
var verdictKeys = repo.ClassifierVerdictsByIdLevel
|
||||
.Select(r => (
|
||||
Pk: IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(r.TenantId, r.ClassifierId, r.HypothesisLevel),
|
||||
Rk: IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(r.OccurredAtUtc, r.SessionId, r.StepIndex)))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
Assert.Single(verdictKeys);
|
||||
}
|
||||
|
||||
// ============================================================ helpers
|
||||
|
||||
private static DecisionTransitionRecord TerminalTransition()
|
||||
=> new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
private sealed class FakeIndexTableRepository : IIndexTableRepository
|
||||
{
|
||||
public List<SessionsByTerminalRecord> SessionsByTerminal { get; } = new();
|
||||
public List<SessionsByStageRecord> SessionsByStage { get; } = new();
|
||||
public List<DeadEndsByReasonRecord> DeadEndsByReason { get; } = new();
|
||||
public List<ClassifierVerdictsByIdLevelRecord> ClassifierVerdictsByIdLevel { get; } = new();
|
||||
public List<SignalsByKindRecord> SignalsByKind { get; } = new();
|
||||
|
||||
public Task<int> StoreSessionsByTerminalAsync(IReadOnlyList<SessionsByTerminalRecord> records, CancellationToken ct = default)
|
||||
{ SessionsByTerminal.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreSessionsByStageAsync(IReadOnlyList<SessionsByStageRecord> records, CancellationToken ct = default)
|
||||
{ SessionsByStage.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreDeadEndsByReasonAsync(IReadOnlyList<DeadEndsByReasonRecord> records, CancellationToken ct = default)
|
||||
{ DeadEndsByReason.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreClassifierVerdictsByIdLevelAsync(IReadOnlyList<ClassifierVerdictsByIdLevelRecord> records, CancellationToken ct = default)
|
||||
{ ClassifierVerdictsByIdLevel.AddRange(records); return Task.FromResult(records.Count); }
|
||||
public Task<int> StoreSignalsByKindAsync(IReadOnlyList<SignalsByKindRecord> records, CancellationToken ct = default)
|
||||
{ SignalsByKind.AddRange(records); return Task.FromResult(records.Count); }
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services.Indexing;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure tests for <see cref="IndexReconcileEnvelopeFactory"/> (Plan §2.8, §M5.d).
|
||||
/// Verifies the shape-only projection from primary record → queue envelope — the
|
||||
/// consumer (M5.d.3) relies on these fields being copied verbatim.
|
||||
/// </summary>
|
||||
public class IndexReconcileEnvelopeFactoryTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
// ============================================================ FromSignal
|
||||
|
||||
[Fact]
|
||||
public void FromSignal_copies_discriminators_and_back_ref()
|
||||
{
|
||||
var record = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = 42,
|
||||
SessionTraceOrdinal = 117,
|
||||
Kind = "EspTerminalFailure",
|
||||
KindSchemaVersion = 2,
|
||||
OccurredAtUtc = Occurred,
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
PayloadJson = "{}",
|
||||
};
|
||||
|
||||
var envelope = IndexReconcileEnvelopeFactory.FromSignal(record);
|
||||
|
||||
Assert.Equal("1", envelope.EnvelopeVersion);
|
||||
Assert.Equal("Signal", envelope.SourceKind);
|
||||
Assert.Equal(TenantId, envelope.TenantId);
|
||||
Assert.Equal(SessionId, envelope.SessionId);
|
||||
Assert.Equal(Occurred, envelope.OccurredAtUtc);
|
||||
Assert.Equal(42L, envelope.SessionSignalOrdinal);
|
||||
Assert.Equal("EspTerminalFailure", envelope.SignalKind);
|
||||
Assert.Equal("EspAndHelloTrackerAdapter", envelope.SourceOrigin);
|
||||
|
||||
// DecisionTransition-only fields stay null on Signal envelopes.
|
||||
Assert.Null(envelope.StepIndex);
|
||||
Assert.Null(envelope.FromStage);
|
||||
Assert.Null(envelope.ToStage);
|
||||
Assert.Null(envelope.Taken);
|
||||
Assert.Null(envelope.IsTerminal);
|
||||
Assert.Null(envelope.DeadEndReason);
|
||||
Assert.Null(envelope.ClassifierVerdictId);
|
||||
Assert.Null(envelope.ClassifierHypothesisLevel);
|
||||
}
|
||||
|
||||
// ============================================================ FromDecisionTransition
|
||||
|
||||
[Fact]
|
||||
public void FromDecisionTransition_taken_terminal_with_classifier_copies_all_discriminators()
|
||||
{
|
||||
var record = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 7,
|
||||
SessionTraceOrdinal = 118,
|
||||
SignalOrdinalRef = 42,
|
||||
OccurredAtUtc = Occurred,
|
||||
Trigger = "EspExiting",
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
DeadEndReason = null,
|
||||
ReducerVersion = "1.0.0",
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
PayloadJson = "{}",
|
||||
};
|
||||
|
||||
var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record);
|
||||
|
||||
Assert.Equal("1", envelope.EnvelopeVersion);
|
||||
Assert.Equal("DecisionTransition", envelope.SourceKind);
|
||||
Assert.Equal(TenantId, envelope.TenantId);
|
||||
Assert.Equal(SessionId, envelope.SessionId);
|
||||
Assert.Equal(Occurred, envelope.OccurredAtUtc);
|
||||
Assert.Equal(7, envelope.StepIndex);
|
||||
Assert.Equal("EspInProgress", envelope.FromStage);
|
||||
Assert.Equal("WhiteGloveSealed", envelope.ToStage);
|
||||
Assert.Equal(true, envelope.Taken);
|
||||
Assert.Equal(true, envelope.IsTerminal);
|
||||
Assert.Null(envelope.DeadEndReason);
|
||||
Assert.Equal("whiteglove-sealing", envelope.ClassifierVerdictId);
|
||||
Assert.Equal("Strong", envelope.ClassifierHypothesisLevel);
|
||||
|
||||
// Signal-only fields stay null on DecisionTransition envelopes.
|
||||
Assert.Null(envelope.SessionSignalOrdinal);
|
||||
Assert.Null(envelope.SignalKind);
|
||||
Assert.Null(envelope.SourceOrigin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDecisionTransition_dead_end_carries_reason_without_classifier_fields()
|
||||
{
|
||||
var record = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = 3,
|
||||
OccurredAtUtc = Occurred,
|
||||
Trigger = "EspExiting",
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = false,
|
||||
DeadEndReason = "hybrid_reboot_gate_blocking",
|
||||
ReducerVersion = "1.0.0",
|
||||
IsTerminal = false,
|
||||
PayloadJson = "{}",
|
||||
};
|
||||
|
||||
var envelope = IndexReconcileEnvelopeFactory.FromDecisionTransition(record);
|
||||
|
||||
Assert.Equal(false, envelope.Taken);
|
||||
Assert.Equal(false, envelope.IsTerminal);
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", envelope.DeadEndReason);
|
||||
Assert.Null(envelope.ClassifierVerdictId);
|
||||
Assert.Null(envelope.ClassifierHypothesisLevel);
|
||||
}
|
||||
|
||||
// ============================================================ BuildBatch
|
||||
|
||||
[Fact]
|
||||
public void BuildBatch_preserves_input_order_and_kind_tags()
|
||||
{
|
||||
var signals = new List<SignalRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Occurred },
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Occurred },
|
||||
};
|
||||
var transitions = new List<DecisionTransitionRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, FromStage = "A", ToStage = "B", Taken = true, OccurredAtUtc = Occurred },
|
||||
};
|
||||
|
||||
var envelopes = IndexReconcileEnvelopeFactory.BuildBatch(signals, transitions);
|
||||
|
||||
Assert.Equal(3, envelopes.Count);
|
||||
Assert.Equal("Signal", envelopes[0].SourceKind);
|
||||
Assert.Equal(1L, envelopes[0].SessionSignalOrdinal);
|
||||
Assert.Equal("Signal", envelopes[1].SourceKind);
|
||||
Assert.Equal(2L, envelopes[1].SessionSignalOrdinal);
|
||||
Assert.Equal("DecisionTransition", envelopes[2].SourceKind);
|
||||
Assert.Equal(1, envelopes[2].StepIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBatch_returns_empty_for_null_or_empty_inputs()
|
||||
{
|
||||
Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch(
|
||||
new List<SignalRecord>(), new List<DecisionTransitionRecord>()));
|
||||
Assert.Empty(IndexReconcileEnvelopeFactory.BuildBatch(null!, null!));
|
||||
}
|
||||
}
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// JSON round-trip tests for <see cref="IndexReconcileEnvelope"/> (Plan §2.8, §M5.d).
|
||||
/// The producer (M5.d.2) serializes with Newtonsoft; the queue-triggered consumer
|
||||
/// (M5.d.3) must deserialize the same shape back — verified here so the contract
|
||||
/// doesn't drift silently as fields are added.
|
||||
/// </summary>
|
||||
public class IndexReconcileEnvelopeSerializationTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Signal_envelope_round_trips_through_newtonsoft()
|
||||
{
|
||||
var original = new IndexReconcileEnvelope
|
||||
{
|
||||
EnvelopeVersion = "1",
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = 42,
|
||||
SignalKind = "EspTerminalFailure",
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(original);
|
||||
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("1", restored!.EnvelopeVersion);
|
||||
Assert.Equal("Signal", restored.SourceKind);
|
||||
Assert.Equal(TenantId, restored.TenantId);
|
||||
Assert.Equal(SessionId, restored.SessionId);
|
||||
Assert.Equal(Occurred, restored.OccurredAtUtc.ToUniversalTime());
|
||||
Assert.Equal(42L, restored.SessionSignalOrdinal);
|
||||
Assert.Equal("EspTerminalFailure", restored.SignalKind);
|
||||
Assert.Equal("EspAndHelloTrackerAdapter", restored.SourceOrigin);
|
||||
Assert.Null(restored.StepIndex);
|
||||
Assert.Null(restored.ClassifierVerdictId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecisionTransition_envelope_round_trips_all_discriminators()
|
||||
{
|
||||
var original = new IndexReconcileEnvelope
|
||||
{
|
||||
EnvelopeVersion = "1",
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
DeadEndReason = null,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
};
|
||||
|
||||
var json = JsonConvert.SerializeObject(original);
|
||||
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("DecisionTransition", restored!.SourceKind);
|
||||
Assert.Equal(7, restored.StepIndex);
|
||||
Assert.Equal("EspInProgress", restored.FromStage);
|
||||
Assert.Equal("WhiteGloveSealed", restored.ToStage);
|
||||
Assert.Equal(true, restored.Taken);
|
||||
Assert.Equal(true, restored.IsTerminal);
|
||||
Assert.Null(restored.DeadEndReason);
|
||||
Assert.Equal("whiteglove-sealing", restored.ClassifierVerdictId);
|
||||
Assert.Equal("Strong", restored.ClassifierHypothesisLevel);
|
||||
Assert.Null(restored.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialization_tolerates_missing_optional_fields()
|
||||
{
|
||||
// A minimal envelope from a consumer-compatible-but-older writer should still parse.
|
||||
const string minimal =
|
||||
"{\"envelopeVersion\":\"1\",\"sourceKind\":\"Signal\",\"tenantId\":\"t\",\"sessionId\":\"s\",\"occurredAtUtc\":\"2026-04-21T14:07:33Z\"}";
|
||||
|
||||
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(minimal);
|
||||
|
||||
Assert.NotNull(restored);
|
||||
Assert.Equal("Signal", restored!.SourceKind);
|
||||
Assert.Null(restored.SessionSignalOrdinal);
|
||||
Assert.Null(restored.SignalKind);
|
||||
Assert.Null(restored.StepIndex);
|
||||
}
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services.Indexing;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Routing tests for <see cref="IndexReconcileHandler"/> (Plan §2.8, §M5.d.3). Verifies
|
||||
/// the 0–3 fan-out decisions per envelope shape using a capture-only fake repo —
|
||||
/// the real <c>TableIndexRepository</c> mapping is covered by <see cref="TableIndexRepositoryMappingTests"/>.
|
||||
/// </summary>
|
||||
public class IndexReconcileHandlerTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
private static (IndexReconcileHandler handler, FakeIndexTableRepository repo) Sut()
|
||||
{
|
||||
var repo = new FakeIndexTableRepository();
|
||||
var handler = new IndexReconcileHandler(repo, NullLogger<IndexReconcileHandler>.Instance);
|
||||
return (handler, repo);
|
||||
}
|
||||
|
||||
// ============================================================ Signal routing
|
||||
|
||||
[Fact]
|
||||
public async Task Signal_envelope_writes_exactly_one_SignalsByKind_row()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = 42,
|
||||
SignalKind = "EspTerminalFailure",
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
});
|
||||
|
||||
Assert.Single(repo.SignalsByKind);
|
||||
var row = repo.SignalsByKind[0];
|
||||
Assert.Equal(TenantId, row.TenantId);
|
||||
Assert.Equal(SessionId, row.SessionId);
|
||||
Assert.Equal("EspTerminalFailure", row.SignalKind);
|
||||
Assert.Equal(42L, row.SessionSignalOrdinal);
|
||||
Assert.Equal("EspAndHelloTrackerAdapter", row.SourceOrigin);
|
||||
Assert.Equal(Occurred, row.OccurredAtUtc);
|
||||
|
||||
// Other tables untouched.
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signal_envelope_missing_ordinal_is_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = null,
|
||||
SignalKind = "EspPhaseChanged",
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Signal_envelope_missing_kind_is_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "Signal",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
SessionSignalOrdinal = 1,
|
||||
SignalKind = string.Empty,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
// ============================================================ DecisionTransition routing — all 4 paths
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_terminal_taken_with_classifier_writes_three_index_rows()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
// WhiteGlove-sealing: Taken=true, IsTerminal=true, classifier verdict attached.
|
||||
// Expected: SessionsByTerminal + SessionsByStage + ClassifierVerdictsByIdLevel (3 rows).
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Strong",
|
||||
});
|
||||
|
||||
Assert.Single(repo.SessionsByTerminal);
|
||||
Assert.Equal("WhiteGloveSealed", repo.SessionsByTerminal[0].TerminalStage);
|
||||
Assert.Equal(7, repo.SessionsByTerminal[0].StepIndex);
|
||||
|
||||
Assert.Single(repo.SessionsByStage);
|
||||
Assert.Equal("WhiteGloveSealed", repo.SessionsByStage[0].Stage);
|
||||
Assert.Equal(Occurred, repo.SessionsByStage[0].LastUpdatedUtc);
|
||||
|
||||
Assert.Single(repo.ClassifierVerdictsByIdLevel);
|
||||
Assert.Equal("whiteglove-sealing", repo.ClassifierVerdictsByIdLevel[0].ClassifierId);
|
||||
Assert.Equal("Strong", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel);
|
||||
|
||||
// Dead-end path not triggered for Taken=true.
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
// Signal path never touched on a DecisionTransition envelope.
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_taken_non_terminal_no_classifier_writes_only_SessionsByStage()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 3,
|
||||
FromStage = "AwaitingEsp",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = true,
|
||||
IsTerminal = false,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Single(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_dead_end_with_classifier_writes_DeadEndsByReason_and_verdict()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 5,
|
||||
FromStage = "EspInProgress",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = false,
|
||||
IsTerminal = false,
|
||||
DeadEndReason = "hybrid_reboot_gate_blocking",
|
||||
ClassifierVerdictId = "whiteglove-sealing",
|
||||
ClassifierHypothesisLevel = "Weak",
|
||||
});
|
||||
|
||||
Assert.Single(repo.DeadEndsByReason);
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", repo.DeadEndsByReason[0].DeadEndReason);
|
||||
Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].FromStage);
|
||||
Assert.Equal("EspInProgress", repo.DeadEndsByReason[0].AttemptedToStage);
|
||||
|
||||
Assert.Single(repo.ClassifierVerdictsByIdLevel);
|
||||
Assert.Equal("Weak", repo.ClassifierVerdictsByIdLevel[0].HypothesisLevel);
|
||||
|
||||
// Taken=false → SessionsByStage is NOT updated (no stage entry).
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_dead_end_without_reason_writes_nothing_to_DeadEnds()
|
||||
{
|
||||
// Defense in depth: an envelope with Taken=false but no reason should not create a
|
||||
// DeadEnd row (empty reason would collide into a single PK across unrelated blocks).
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 1,
|
||||
FromStage = "A",
|
||||
ToStage = "A",
|
||||
Taken = false,
|
||||
DeadEndReason = null,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DecisionTransition_missing_stepIndex_is_dropped_entirely()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "DecisionTransition",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = null,
|
||||
ToStage = "WhiteGloveSealed",
|
||||
Taken = true,
|
||||
IsTerminal = true,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
// ============================================================ Misc
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_SourceKind_is_logged_and_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(new IndexReconcileEnvelope
|
||||
{
|
||||
SourceKind = "FutureThingWeDontKnowAbout",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
OccurredAtUtc = Occurred,
|
||||
});
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
Assert.Empty(repo.SessionsByTerminal);
|
||||
Assert.Empty(repo.SessionsByStage);
|
||||
Assert.Empty(repo.DeadEndsByReason);
|
||||
Assert.Empty(repo.ClassifierVerdictsByIdLevel);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Null_envelope_is_logged_and_dropped()
|
||||
{
|
||||
var (handler, repo) = Sut();
|
||||
|
||||
await handler.HandleAsync(null!);
|
||||
|
||||
Assert.Empty(repo.SignalsByKind);
|
||||
}
|
||||
|
||||
// ============================================================ FakeIndexTableRepository
|
||||
|
||||
private sealed class FakeIndexTableRepository : IIndexTableRepository
|
||||
{
|
||||
public List<SessionsByTerminalRecord> SessionsByTerminal { get; } = new();
|
||||
public List<SessionsByStageRecord> SessionsByStage { get; } = new();
|
||||
public List<DeadEndsByReasonRecord> DeadEndsByReason { get; } = new();
|
||||
public List<ClassifierVerdictsByIdLevelRecord> ClassifierVerdictsByIdLevel { get; } = new();
|
||||
public List<SignalsByKindRecord> SignalsByKind { get; } = new();
|
||||
|
||||
public Task<int> StoreSessionsByTerminalAsync(
|
||||
IReadOnlyList<SessionsByTerminalRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SessionsByTerminal.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreSessionsByStageAsync(
|
||||
IReadOnlyList<SessionsByStageRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SessionsByStage.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreDeadEndsByReasonAsync(
|
||||
IReadOnlyList<DeadEndsByReasonRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
DeadEndsByReason.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreClassifierVerdictsByIdLevelAsync(
|
||||
IReadOnlyList<ClassifierVerdictsByIdLevelRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ClassifierVerdictsByIdLevel.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
|
||||
public Task<int> StoreSignalsByKindAsync(
|
||||
IReadOnlyList<SignalsByKindRecord> records, CancellationToken cancellationToken = default)
|
||||
{
|
||||
SignalsByKind.AddRange(records);
|
||||
return Task.FromResult(records.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the 2h safety-net <see cref="IndexReconcileTimer"/> (Plan §2.8, §M5.d.4).
|
||||
/// Covers the flag-gate short-circuit, the happy path (enqueue every primary row
|
||||
/// in the window), and the empty-window case.
|
||||
/// </summary>
|
||||
public class IndexReconcileTimerTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Cutoff = new(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ============================================================ flag-off short-circuit
|
||||
|
||||
[Fact]
|
||||
public async Task Feature_flag_off_skips_queries_and_enqueue()
|
||||
{
|
||||
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: false);
|
||||
var signalRepo = new Mock<ISignalRepository>(MockBehavior.Strict);
|
||||
var transitionRepo = new Mock<IDecisionTransitionRepository>(MockBehavior.Strict);
|
||||
var producer = new Mock<IIndexReconcileProducer>(MockBehavior.Strict);
|
||||
|
||||
var timer = new IndexReconcileTimer(
|
||||
adminConfigSvc,
|
||||
signalRepo.Object,
|
||||
transitionRepo.Object,
|
||||
producer.Object,
|
||||
NullLogger<IndexReconcileTimer>.Instance);
|
||||
|
||||
await timer.RunReconcileAsync(Cutoff, default);
|
||||
|
||||
// Strict mocks throw if any unconfigured call happens — so zero setups + no throw
|
||||
// proves the timer short-circuited before any query / enqueue.
|
||||
signalRepo.VerifyNoOtherCalls();
|
||||
transitionRepo.VerifyNoOtherCalls();
|
||||
producer.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ============================================================ happy path
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_on_with_primary_rows_enqueues_all_envelopes()
|
||||
{
|
||||
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true);
|
||||
|
||||
var signals = new List<SignalRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 1, Kind = "EspPhaseChanged", OccurredAtUtc = Cutoff.AddMinutes(5) },
|
||||
new() { TenantId = TenantId, SessionId = SessionId, SessionSignalOrdinal = 2, Kind = "DesktopArrived", OccurredAtUtc = Cutoff.AddMinutes(10) },
|
||||
};
|
||||
var transitions = new List<DecisionTransitionRecord>
|
||||
{
|
||||
new() { TenantId = TenantId, SessionId = SessionId, StepIndex = 1, Taken = true, FromStage = "A", ToStage = "B", OccurredAtUtc = Cutoff.AddMinutes(7) },
|
||||
};
|
||||
|
||||
var signalRepo = new Mock<ISignalRepository>();
|
||||
signalRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(signals);
|
||||
var transitionRepo = new Mock<IDecisionTransitionRepository>();
|
||||
transitionRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(transitions);
|
||||
|
||||
List<IndexReconcileEnvelope>? captured = null;
|
||||
var producer = new Mock<IIndexReconcileProducer>();
|
||||
producer
|
||||
.Setup(p => p.EnqueueBatchAsync(It.IsAny<IReadOnlyList<IndexReconcileEnvelope>>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<IReadOnlyList<IndexReconcileEnvelope>, CancellationToken>((e, _) => captured = e.ToList())
|
||||
.ReturnsAsync(3);
|
||||
|
||||
var timer = new IndexReconcileTimer(
|
||||
adminConfigSvc,
|
||||
signalRepo.Object,
|
||||
transitionRepo.Object,
|
||||
producer.Object,
|
||||
NullLogger<IndexReconcileTimer>.Instance);
|
||||
|
||||
await timer.RunReconcileAsync(Cutoff, default);
|
||||
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(3, captured!.Count);
|
||||
Assert.Equal("Signal", captured[0].SourceKind);
|
||||
Assert.Equal(1L, captured[0].SessionSignalOrdinal);
|
||||
Assert.Equal("Signal", captured[1].SourceKind);
|
||||
Assert.Equal(2L, captured[1].SessionSignalOrdinal);
|
||||
Assert.Equal("DecisionTransition", captured[2].SourceKind);
|
||||
Assert.Equal(1, captured[2].StepIndex);
|
||||
}
|
||||
|
||||
// ============================================================ empty-window: no enqueue
|
||||
|
||||
[Fact]
|
||||
public async Task Flag_on_empty_window_skips_enqueue()
|
||||
{
|
||||
var adminConfigSvc = BuildAdminConfigService(enableIndexDualWrite: true);
|
||||
|
||||
var signalRepo = new Mock<ISignalRepository>();
|
||||
signalRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<SignalRecord>());
|
||||
var transitionRepo = new Mock<IDecisionTransitionRepository>();
|
||||
transitionRepo
|
||||
.Setup(r => r.QueryByTimestampAtOrAfterAsync(Cutoff, It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<DecisionTransitionRecord>());
|
||||
|
||||
var producer = new Mock<IIndexReconcileProducer>(MockBehavior.Strict);
|
||||
|
||||
var timer = new IndexReconcileTimer(
|
||||
adminConfigSvc,
|
||||
signalRepo.Object,
|
||||
transitionRepo.Object,
|
||||
producer.Object,
|
||||
NullLogger<IndexReconcileTimer>.Instance);
|
||||
|
||||
await timer.RunReconcileAsync(Cutoff, default);
|
||||
|
||||
// Empty batch → no producer call (strict mock throws otherwise).
|
||||
producer.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ============================================================ cutoff window invariant
|
||||
|
||||
[Fact]
|
||||
public void ReconcileWindow_is_4_hours_and_larger_than_timer_cadence()
|
||||
{
|
||||
// The 2h cron fires every 2h but scans a 4h window — so every primary row is
|
||||
// re-checked at least twice before falling out of range. Protect the invariant.
|
||||
Assert.Equal(TimeSpan.FromHours(4), IndexReconcileTimer.ReconcileWindow);
|
||||
}
|
||||
|
||||
// ============================================================ helpers
|
||||
|
||||
private static AdminConfigurationService BuildAdminConfigService(bool enableIndexDualWrite)
|
||||
{
|
||||
var mock = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<AdminConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
mock.Setup(s => s.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { EnableIndexDualWrite = enableIndexDualWrite });
|
||||
|
||||
return mock.Object;
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure PK/RK format tests for <see cref="IndexRowKeys"/> (Plan §2.8 query matrix, §M5.d).
|
||||
/// These verify the string shape contract the queue-driven index writer + the reconciliation
|
||||
/// timer both depend on — any drift here silently breaks cross-session queries.
|
||||
/// </summary>
|
||||
public class IndexRowKeysTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
// ============================================================ SessionsByTerminal
|
||||
|
||||
[Fact]
|
||||
public void SessionsByTerminal_pk_is_tenant_underscore_terminalStage()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_WhiteGloveSealed",
|
||||
IndexRowKeys.BuildSessionsByTerminalPk(TenantId, "WhiteGloveSealed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsByTerminal_rk_is_yyyyMMddHHmmss_underscore_sessionId()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}",
|
||||
IndexRowKeys.BuildSessionsByTerminalRk(Occurred, SessionId));
|
||||
}
|
||||
|
||||
// ============================================================ SessionsByStage
|
||||
|
||||
[Fact]
|
||||
public void SessionsByStage_pk_is_tenant_underscore_stage()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_EspInProgress",
|
||||
IndexRowKeys.BuildSessionsByStagePk(TenantId, "EspInProgress"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsByStage_rk_pads_ticks_to_19_digits_for_lex_ordering()
|
||||
{
|
||||
var rk = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
|
||||
|
||||
// 19-digit zero-padding is the contract — string comparison then matches numeric time order.
|
||||
var parts = rk.Split('_');
|
||||
Assert.Equal(19, parts[0].Length);
|
||||
Assert.Equal(Occurred.Ticks.ToString("D19"), parts[0]);
|
||||
Assert.Equal(SessionId, parts[1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SessionsByStage_rk_orders_lexicographically_matching_chronology()
|
||||
{
|
||||
var earlier = IndexRowKeys.BuildSessionsByStageRk(Occurred, SessionId);
|
||||
var later = IndexRowKeys.BuildSessionsByStageRk(Occurred.AddSeconds(1), SessionId);
|
||||
|
||||
Assert.True(string.CompareOrdinal(earlier, later) < 0);
|
||||
}
|
||||
|
||||
// ============================================================ DeadEndsByReason
|
||||
|
||||
[Fact]
|
||||
public void DeadEndsByReason_pk_is_tenant_underscore_reason()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_hybrid_reboot_gate_blocking",
|
||||
IndexRowKeys.BuildDeadEndsByReasonPk(TenantId, "hybrid_reboot_gate_blocking"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeadEndsByReason_rk_combines_timestamp_session_stepIndex_padded_to_6()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}_000042",
|
||||
IndexRowKeys.BuildDeadEndsByReasonRk(Occurred, SessionId, 42));
|
||||
}
|
||||
|
||||
// ============================================================ ClassifierVerdictsByIdLevel
|
||||
|
||||
[Fact]
|
||||
public void ClassifierVerdictsByIdLevel_pk_is_tenant_underscore_classifierId_underscore_level()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_whiteglove-sealing_Weak",
|
||||
IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(TenantId, "whiteglove-sealing", "Weak"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassifierVerdictsByIdLevel_rk_combines_timestamp_session_stepIndex_padded_to_6()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}_000007",
|
||||
IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(Occurred, SessionId, 7));
|
||||
}
|
||||
|
||||
// ============================================================ SignalsByKind
|
||||
|
||||
[Fact]
|
||||
public void SignalsByKind_pk_is_tenant_underscore_signalKind()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"{TenantId}_EspTerminalFailure",
|
||||
IndexRowKeys.BuildSignalsByKindPk(TenantId, "EspTerminalFailure"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SignalsByKind_rk_combines_timestamp_session_ordinal_padded_to_10()
|
||||
{
|
||||
Assert.Equal(
|
||||
$"20260421140733_{SessionId}_0000000123",
|
||||
IndexRowKeys.BuildSignalsByKindRk(Occurred, SessionId, 123));
|
||||
}
|
||||
|
||||
// ============================================================ UTC coercion + sanitizing
|
||||
|
||||
[Fact]
|
||||
public void Timestamp_always_normalizes_to_UTC()
|
||||
{
|
||||
// If a caller passes Local-kind DateTime, conversion must still produce the UTC wall clock.
|
||||
var localKind = DateTime.SpecifyKind(Occurred, DateTimeKind.Local);
|
||||
var rk = IndexRowKeys.BuildSessionsByTerminalRk(localKind, SessionId);
|
||||
|
||||
Assert.Equal($"{localKind.ToUniversalTime():yyyyMMddHHmmss}_{SessionId}", rk);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("foo/bar", "foo_bar")]
|
||||
[InlineData("foo\\bar", "foo_bar")]
|
||||
[InlineData("foo#bar", "foo_bar")]
|
||||
[InlineData("foo?bar", "foo_bar")]
|
||||
[InlineData("clean_name", "clean_name")]
|
||||
[InlineData("", "")]
|
||||
public void Sanitize_replaces_forbidden_key_chars_with_underscore(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, IndexRowKeys.Sanitize(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sanitize_preserves_enum_style_identifiers_unchanged()
|
||||
{
|
||||
// Discriminators are overwhelmingly enum names — sanitize must not mangle them.
|
||||
Assert.Equal("WhiteGloveSealed", IndexRowKeys.Sanitize("WhiteGloveSealed"));
|
||||
Assert.Equal("EspTerminalFailure", IndexRowKeys.Sanitize("EspTerminalFailure"));
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", IndexRowKeys.Sanitize("hybrid_reboot_gate_blocking"));
|
||||
}
|
||||
}
|
||||
@@ -641,6 +641,10 @@ public class InfrastructureWireParityTests
|
||||
}
|
||||
|
||||
// ---- McpQuotaEnforcementMiddleware: 429 quota body -----------------------------------
|
||||
// DELIBERATE wire change (error-envelope pass): the body now leads with the envelope prefix
|
||||
// error/code/correlationId (error carries the former `message`); quotaExceeded stays as the
|
||||
// MCP's discriminator. These literals pin the NEW shape (correlationId is stamped by the writer,
|
||||
// so the builder leaves it empty).
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
|
||||
@@ -654,14 +658,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope = "daily",
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
@@ -679,14 +685,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "power",
|
||||
scope = "monthly",
|
||||
level = "tenant",
|
||||
limit = 60000,
|
||||
used = 60000L,
|
||||
resetUtc = "2026-09-01T00:00:00Z",
|
||||
message = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z."
|
||||
resetUtc = "2026-09-01T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
@@ -701,17 +709,20 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope,
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
Error = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z.",
|
||||
QuotaExceeded = true,
|
||||
Plan = "community",
|
||||
Scope = null,
|
||||
@@ -719,7 +730,6 @@ public class InfrastructureWireParityTests
|
||||
Limit = 200,
|
||||
Used = 200,
|
||||
ResetUtc = "2026-08-31T00:00:00Z",
|
||||
Message = "MCP request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -737,6 +747,9 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "pro",
|
||||
scope = "daily",
|
||||
@@ -744,7 +757,6 @@ public class InfrastructureWireParityTests
|
||||
limit = 300,
|
||||
used = 300L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP daily request quota of the managed tenant 'customer.example' exceeded (tenant plan 'community', shared by all its members and delegated admins). That tenant is on the Community plan, which is sized for occasional use; its own plan governs this window, not yours. Upgrading that tenant to Pro lifts its organization windows. Resets at 2026-08-31T00:00:00Z.",
|
||||
targetTenantId = "7aa20c11-0002-4b7c-a1d2-52f3aaaa0002"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, "customer.example"));
|
||||
@@ -763,14 +775,16 @@ public class InfrastructureWireParityTests
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
error = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z.",
|
||||
code = "QuotaExceeded",
|
||||
correlationId = "",
|
||||
quotaExceeded = true,
|
||||
plan = "pro",
|
||||
scope = "daily",
|
||||
level = "tenant",
|
||||
limit = 300,
|
||||
used = 300L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP request quota exceeded for all 2 managed tenants in scope (each managed tenant's own plan governs its organization windows; upgrading a managed tenant to Pro lifts them). Earliest reset at 2026-08-31T00:00:00Z."
|
||||
resetUtc = "2026-08-31T00:00:00Z"
|
||||
},
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision, exhaustedTenantCount: 2));
|
||||
}
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using System.Net;
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent's uploader halves its batch only on 413 and treats every 5xx as "replay later";
|
||||
/// <see cref="IngestTelemetryFunction.ClassifyStorageFailure"/> is what keeps an oversize
|
||||
/// transaction from surfacing as a 500 the device would retry forever.
|
||||
/// </summary>
|
||||
public class IngestStorageFailureClassificationTests
|
||||
{
|
||||
private static RequestFailedException Failure(int status, string? code = null)
|
||||
=> new RequestFailedException(status, "storage", code, null);
|
||||
|
||||
[Fact]
|
||||
public void Oversize_transaction_becomes_413_without_retry_after()
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(413, "RequestBodyTooLarge"));
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
|
||||
Assert.Null(retryAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oversize_reported_as_400_with_size_code_still_becomes_413()
|
||||
{
|
||||
var (status, _, _) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(400, "EntityTooLarge"));
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, status);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(503)]
|
||||
[InlineData(429)]
|
||||
[InlineData(500)]
|
||||
public void Transient_storage_failure_becomes_503_with_retry_after(int storageStatus)
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus));
|
||||
Assert.Equal(HttpStatusCode.ServiceUnavailable, status);
|
||||
Assert.Equal(IngestTelemetryFunction.StorageRetryAfterSeconds, retryAfter);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(404)]
|
||||
[InlineData(409)]
|
||||
[InlineData(400)]
|
||||
public void Anything_else_stays_500(int storageStatus)
|
||||
{
|
||||
var (status, _, retryAfter) = IngestTelemetryFunction.ClassifyStorageFailure(Failure(storageStatus, "InvalidInput"));
|
||||
Assert.Equal(HttpStatusCode.InternalServerError, status);
|
||||
Assert.Null(retryAfter);
|
||||
}
|
||||
}
|
||||
+23
-23
@@ -198,17 +198,17 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
|
||||
Assert.Equal("tenant", body.Level);
|
||||
Assert.Equal(TenantB, body.TargetTenantId);
|
||||
Assert.Contains("managed tenant 'customer.example'", body.Message);
|
||||
Assert.Contains("tenant plan 'community'", body.Message);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Message);
|
||||
Assert.DoesNotContain("your organization", body.Message);
|
||||
Assert.Contains("managed tenant 'customer.example'", body.Error);
|
||||
Assert.Contains("tenant plan 'community'", body.Error);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Error);
|
||||
Assert.DoesNotContain("your organization", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ManagedTarget_WithoutLabel_FallsBackToTheId()
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB));
|
||||
Assert.Contains($"managed tenant '{TenantB}'", body.Message);
|
||||
Assert.Contains($"managed tenant '{TenantB}'", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -216,8 +216,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", TenantB, 3000, 3000), "customer.example");
|
||||
Assert.Equal(TenantB, body.TargetTenantId);
|
||||
Assert.DoesNotContain("Upgrading", body.Message);
|
||||
Assert.Contains("shared by all its members and delegated admins", body.Message);
|
||||
Assert.DoesNotContain("Upgrading", body.Error);
|
||||
Assert.Contains("shared by all its members and delegated admins", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -225,8 +225,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Contains("of your organization", body.Message);
|
||||
Assert.DoesNotContain("managed tenant", body.Message);
|
||||
Assert.Contains("of your organization", body.Error);
|
||||
Assert.DoesNotContain("managed tenant", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -235,13 +235,13 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
// The quota is the upgrade lever: a member blocked by their own Community organization window is told
|
||||
// that Community is sized for occasional use and that Pro lifts it — Pro members never see the hint.
|
||||
var community = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", target: null));
|
||||
Assert.Contains("sized for occasional use", community.Message);
|
||||
Assert.Contains("upgrading your organization to Pro", community.Message);
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Message);
|
||||
Assert.Contains("sized for occasional use", community.Error);
|
||||
Assert.Contains("upgrading your organization to Pro", community.Error);
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", community.Error);
|
||||
|
||||
var pro = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("pro", target: null, 3000, 3000));
|
||||
Assert.DoesNotContain("upgrading", pro.Message);
|
||||
Assert.DoesNotContain("occasional use", pro.Message);
|
||||
Assert.DoesNotContain("upgrading", pro.Error);
|
||||
Assert.DoesNotContain("occasional use", pro.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -249,9 +249,9 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
{
|
||||
// The delegated admin must learn that the CUSTOMER's plan (not their own Pro/MSP plan) governs the window.
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), "customer.example");
|
||||
Assert.Contains("on the Community plan", body.Message);
|
||||
Assert.Contains("its own plan governs this window, not yours", body.Message);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Message);
|
||||
Assert.Contains("on the Community plan", body.Error);
|
||||
Assert.Contains("its own plan governs this window, not yours", body.Error);
|
||||
Assert.Contains("Upgrading that tenant to Pro", body.Error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -266,9 +266,9 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 1, tenantMonthlyUsed: 1, Now);
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(decision);
|
||||
Assert.Equal("user", body.Level);
|
||||
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Message);
|
||||
Assert.Equal(expectHint, body.Message.Contains("Pro raises your daily and monthly windows"));
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Message);
|
||||
Assert.StartsWith($"MCP daily request quota exceeded for plan '{userPlan}'.", body.Error);
|
||||
Assert.Equal(expectHint, body.Error.Contains("Pro raises your daily and monthly windows"));
|
||||
Assert.EndsWith("Resets at 2026-09-03T00:00:00Z.", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -277,8 +277,8 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
var body = McpQuotaEnforcementMiddleware.BuildExceededResponse(Blocked("community", TenantB), exhaustedTenantCount: 3);
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Equal("tenant", body.Level);
|
||||
Assert.Contains("all 3 managed tenants", body.Message);
|
||||
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Message);
|
||||
Assert.Contains("all 3 managed tenants", body.Error);
|
||||
Assert.Contains("Earliest reset at 2026-09-03T00:00:00Z", body.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -293,6 +293,6 @@ public class McpQuotaEnforcementMiddlewareTests
|
||||
Assert.Null(body.TargetTenantId);
|
||||
Assert.Equal(
|
||||
"MCP daily request quota exceeded for plan 'community'. The Community plan is sized for occasional use; Pro raises your daily and monthly windows. Resets at 2026-09-03T00:00:00Z.",
|
||||
body.Message);
|
||||
body.Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
@@ -11,31 +12,34 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class MetricsFilterInjectionTests
|
||||
{
|
||||
// ---- TableStorageService.BuildRuleStatsFilter (metrics/rule-stats, MemberRead) ----
|
||||
// ---- TableStorageService.BuildRuleStatsFilter (metrics/rule-stats, MemberRead; layout D-199) ----
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_NormalInput_ProducesScopedFilter()
|
||||
public void BuildRuleStatsFilter_NormalInput_IsOnePartitionRangePerScope()
|
||||
{
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", "2024-01-01", "2024-02-01", "analyze");
|
||||
|
||||
Assert.Equal(
|
||||
"PartitionKey ge '2024-01-01' and PartitionKey le '2024-02-01' and " +
|
||||
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'analyze'",
|
||||
"PartitionKey ge 'tenant1_2024-01-01' and PartitionKey le 'tenant1_2024-02-01' and RuleType eq 'analyze'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_OpenWindow_FallsBackToTheScopePrefixBounds()
|
||||
{
|
||||
Assert.Equal(
|
||||
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_~'",
|
||||
TableStorageService.BuildRuleStatsFilter("tenant1", null, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_RuleTypeInjection_StaysInsideLiteral()
|
||||
{
|
||||
// The reported HIGH-severity payload.
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", null, null, "x' or RowKey ge '");
|
||||
|
||||
// The tenant scope must survive...
|
||||
Assert.Contains("RowKey ge 'tenant1_'", filter);
|
||||
Assert.Contains("RowKey lt 'tenant1_~'", filter);
|
||||
// ...and the injected quote must be doubled, so the OR can't escape the string literal.
|
||||
Assert.Equal(
|
||||
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'x'' or RowKey ge '''",
|
||||
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_~' and RuleType eq 'x'' or RowKey ge '''",
|
||||
filter);
|
||||
}
|
||||
|
||||
@@ -45,14 +49,42 @@ public class MetricsFilterInjectionTests
|
||||
{
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("tenant1", payload, null, null);
|
||||
|
||||
Assert.Contains("PartitionKey ge ''' or RowKey ge '''", filter);
|
||||
Assert.Contains("RowKey ge 'tenant1_'", filter);
|
||||
Assert.Equal("PartitionKey ge 'tenant1_'' or RowKey ge ''' and PartitionKey lt 'tenant1_~'", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsFilter_AllNull_ReturnsNull()
|
||||
public void BuildRuleStatsFilter_ScopeInjection_CannotWidenToAnotherTenant()
|
||||
{
|
||||
Assert.Null(TableStorageService.BuildRuleStatsFilter(null, null, null, null));
|
||||
var filter = TableStorageService.BuildRuleStatsFilter("t' or PartitionKey ge '", "2024-01-01", null, null);
|
||||
|
||||
Assert.Equal(
|
||||
"PartitionKey ge 't'' or PartitionKey ge ''_2024-01-01' and PartitionKey lt 't'' or PartitionKey ge ''_~'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLegacyRuleStatsFilter_KeepsTheOldShapeWhileLegacyRowsCanExist()
|
||||
{
|
||||
var filter = TableStorageService.BuildLegacyRuleStatsFilter("tenant1", "2024-01-01", "2024-02-01", "analyze", new DateTime(2026, 10, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(
|
||||
"PartitionKey ge '2024-01-01' and PartitionKey le '2024-02-01' and " +
|
||||
"RowKey ge 'tenant1_' and RowKey lt 'tenant1_~' and RuleType eq 'analyze'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildLegacyRuleStatsFilter_IsGoneAfterTheRetentionHorizon()
|
||||
{
|
||||
Assert.Null(TableStorageService.BuildLegacyRuleStatsFilter("tenant1", null, null, null, new DateTime(2026, 12, 5, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRuleStatsCleanupFilter_IsAScopedRangeBelowTheCutoff()
|
||||
{
|
||||
Assert.Equal(
|
||||
"PartitionKey ge 'tenant1_' and PartitionKey lt 'tenant1_2026-06-07'",
|
||||
TableStorageService.BuildRuleStatsCleanupFilter("tenant1", "2026-06-07"));
|
||||
}
|
||||
|
||||
// ---- TableUserUsageRepository.BuildUserUsageFilter (metrics/mcp-usage/user/{userId}) ----
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Notifications;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The notification-dispatch consumer resolves channel ids against the tenant's CURRENT
|
||||
/// configuration — no URL or secret ever rides in the queue — and sends only to channels that
|
||||
/// are still enabled. Plus the wire contract: the envelope round-trips through Newtonsoft with
|
||||
/// the alert's facts, sections and severity intact.
|
||||
/// </summary>
|
||||
public class NotificationDispatchHandlerTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Fact]
|
||||
public async Task Sends_to_enabled_channels_matching_the_envelope_ids_only()
|
||||
{
|
||||
var h = new Harness(new[]
|
||||
{
|
||||
Channel("teams", enabled: true),
|
||||
Channel("slack", enabled: true),
|
||||
Channel("old", enabled: false),
|
||||
});
|
||||
|
||||
await h.Sut.HandleAsync(Envelope("teams", "old"), CancellationToken.None);
|
||||
|
||||
var sent = Assert.Single(h.Sent);
|
||||
Assert.Equal(new[] { "teams" }, sent.Channels.Select(c => c.Id));
|
||||
Assert.Equal("enrollment_succeeded", sent.Alert.EventType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_channel_ids_are_skipped_without_sending()
|
||||
{
|
||||
var h = new Harness(new[] { Channel("teams", enabled: true) });
|
||||
|
||||
await h.Sut.HandleAsync(Envelope("deleted-meanwhile"), CancellationToken.None);
|
||||
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_tenant_configuration_drops_the_alert()
|
||||
{
|
||||
var h = new Harness(channels: null);
|
||||
|
||||
await h.Sut.HandleAsync(Envelope("teams"), CancellationToken.None);
|
||||
|
||||
Assert.Empty(h.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Envelope_round_trips_through_newtonsoft()
|
||||
{
|
||||
var envelope = Envelope("teams");
|
||||
envelope.Alert.Facts.Add(new NotificationFact { Name = "Device", Value = "PC-0001" });
|
||||
envelope.Alert.Severity = NotificationSeverity.Error;
|
||||
|
||||
var restored = JsonConvert.DeserializeObject<NotificationDispatchEnvelope>(
|
||||
JsonConvert.SerializeObject(envelope))!;
|
||||
|
||||
Assert.Equal(Tenant, restored.TenantId);
|
||||
Assert.Equal(new[] { "teams" }, restored.ChannelIds);
|
||||
Assert.Equal("enrollment_succeeded", restored.Alert.EventType);
|
||||
Assert.Equal(NotificationSeverity.Error, restored.Alert.Severity);
|
||||
var fact = Assert.Single(restored.Alert.Facts);
|
||||
Assert.Equal("PC-0001", fact.Value);
|
||||
}
|
||||
|
||||
// ============================================================ Helpers ====
|
||||
|
||||
private static NotificationChannel Channel(string id, bool enabled) => new()
|
||||
{
|
||||
Id = id,
|
||||
Name = id,
|
||||
ProviderType = (int)WebhookProviderType.Slack,
|
||||
Url = $"https://example.invalid/{id}",
|
||||
Enabled = enabled,
|
||||
NotifyOnSuccess = true,
|
||||
};
|
||||
|
||||
private static NotificationDispatchEnvelope Envelope(params string[] channelIds) => new()
|
||||
{
|
||||
TenantId = Tenant,
|
||||
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
ChannelIds = channelIds.ToList(),
|
||||
Alert = new NotificationAlert
|
||||
{
|
||||
EventType = "enrollment_succeeded",
|
||||
Title = "Enrollment Succeeded",
|
||||
Summary = "PC-0001 enrolled",
|
||||
Severity = NotificationSeverity.Info,
|
||||
ThemeColor = "00AA00",
|
||||
},
|
||||
EnqueuedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public List<(IReadOnlyList<NotificationChannel> Channels, NotificationAlert Alert)> Sent { get; } = new();
|
||||
public NotificationDispatchHandler Sut { get; }
|
||||
|
||||
public Harness(IEnumerable<NotificationChannel>? channels)
|
||||
{
|
||||
var configService = new Mock<TenantConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(),
|
||||
NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
|
||||
if (channels is null)
|
||||
{
|
||||
configService.Setup(c => c.TryGetConfigurationAsync(Tenant))
|
||||
.ReturnsAsync((new TenantConfiguration { TenantId = Tenant }, false));
|
||||
}
|
||||
else
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = Tenant,
|
||||
NotificationChannelsJson = NotificationChannel.SerializeList(channels),
|
||||
};
|
||||
configService.Setup(c => c.TryGetConfigurationAsync(Tenant))
|
||||
.ReturnsAsync((config, true));
|
||||
}
|
||||
|
||||
var dispatcher = new Mock<NotificationChannelDispatcher>(null!, null!);
|
||||
dispatcher.Setup(d => d.SendToChannelsAsync(It.IsAny<IEnumerable<NotificationChannel>>(), It.IsAny<NotificationAlert>()))
|
||||
.Callback<IEnumerable<NotificationChannel>, NotificationAlert>((c, a) => Sent.Add((c.ToList(), a)))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
Sut = new NotificationDispatchHandler(
|
||||
configService.Object, dispatcher.Object, NullLogger<NotificationDispatchHandler>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public class PlanManagementTrialGateTests
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("ContactProfileRequired", deny?.Error);
|
||||
Assert.Equal("ContactProfileRequired", deny?.Code);
|
||||
Assert.Contains("contact address", deny?.Message);
|
||||
Assert.DoesNotContain("company name", deny?.Message);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public class PlanManagementTrialGateTests
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("ContactProfileRequired", deny?.Error);
|
||||
Assert.Equal("ContactProfileRequired", deny?.Code);
|
||||
Assert.Contains("company name", deny?.Message);
|
||||
Assert.DoesNotContain("contact address", deny?.Message);
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public class PlanManagementTrialGateTests
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("ContactProfileRequired", deny?.Error);
|
||||
Assert.Equal("ContactProfileRequired", deny?.Code);
|
||||
Assert.Contains("contact address and company name", deny?.Message);
|
||||
Assert.Equal(new[] { "contact address", "company name" },
|
||||
PlanManagementFunction.MissingContactProfileParts(config));
|
||||
@@ -77,7 +77,7 @@ public class PlanManagementTrialGateTests
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("TrialAlreadyConsumed", deny?.Error);
|
||||
Assert.Equal("TrialAlreadyConsumed", deny?.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -89,7 +89,7 @@ public class PlanManagementTrialGateTests
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
Assert.Equal("AlreadyPro", deny?.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -105,6 +105,6 @@ public class PlanManagementTrialGateTests
|
||||
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, Now);
|
||||
|
||||
Assert.Equal("AlreadyPro", deny?.Error);
|
||||
Assert.Equal("AlreadyPro", deny?.Code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Ratchet: HTTP query parameters are parsed through <c>Helpers/QueryParams.cs</c> only. A
|
||||
/// <c>int/long/DateTime/DateTimeOffset.TryParse</c> under <c>Functions/</c> or <c>Pagination/</c>
|
||||
/// is a new private parser copy — the state the 2026-09-04 API audit found six times over for
|
||||
/// <c>days</c> alone, each with its own default, cap and out-of-range rule. The baseline lists
|
||||
/// the sites that parse something other than a query parameter (a header, a stored cell, a
|
||||
/// version string); it only shrinks.
|
||||
/// </summary>
|
||||
public class QueryParamGuardTests
|
||||
{
|
||||
private static readonly Regex TryParseCall = new(
|
||||
@"\b(int|long|DateTime|DateTimeOffset)\.TryParse(Exact)?\(", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Non-query parses, reviewed: file → why it is allowed.</summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header",
|
||||
["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)",
|
||||
["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows",
|
||||
["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL",
|
||||
// Content-Length header guard on body-taking routes (long.TryParse on the header value).
|
||||
["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = "Content-Length header",
|
||||
["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/UpdateAdminConfigurationFunction.cs"] = "Content-Length header",
|
||||
["Functions/Config/UpdateTenantConfigurationFunction.cs"] = "Content-Length header",
|
||||
["Functions/Feedback/FeedbackFunction.cs"] = "Content-Length header",
|
||||
["Functions/Infrastructure/SignalRAddToGroupFunction.cs"] = "Content-Length header",
|
||||
["Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header",
|
||||
["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header",
|
||||
["Functions/Reports/SubmitDiagFilesReportFunction.cs"] = "Content-Length header",
|
||||
["Functions/Reports/SubmitSessionReportFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/AnalyzeRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/GatherRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/GlobalRulesFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/ImeLogPatternsFunction.cs"] = "Content-Length header",
|
||||
["Functions/Rules/TestLogPatternFunction.cs"] = "Content-Length header",
|
||||
["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header",
|
||||
};
|
||||
|
||||
private static string FunctionsRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions");
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ScannedFiles(string root) =>
|
||||
new[] { "Functions", "Pagination" }
|
||||
.SelectMany(sub => Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories))
|
||||
.Select(p => Path.GetRelativePath(root, p).Replace('\\', '/'))
|
||||
.OrderBy(p => p, StringComparer.Ordinal);
|
||||
|
||||
[Fact]
|
||||
public void Query_parameters_are_parsed_through_QueryParams_only()
|
||||
{
|
||||
var root = FunctionsRoot();
|
||||
var offenders = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var rel in ScannedFiles(root))
|
||||
{
|
||||
var source = File.ReadAllText(Path.Combine(root, rel));
|
||||
if (!TryParseCall.IsMatch(source)) continue;
|
||||
seen.Add(rel);
|
||||
if (!Baseline.ContainsKey(rel)) offenders.Add(rel);
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"TryParse outside Helpers/QueryParams.cs — parse query parameters through QueryParams " +
|
||||
"(Int / TryInt / TryPageSize / UtcInstant / TryUtcInstant), or add the file to the baseline " +
|
||||
"with the non-query reason:\n " + string.Join("\n ", offenders));
|
||||
|
||||
var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList();
|
||||
Assert.True(stale.Count == 0,
|
||||
"Baseline entries without a TryParse any more — remove them (the ratchet only shrinks):\n " +
|
||||
string.Join("\n ", stale));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Globalization;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The one query-parameter parser (<see cref="QueryParams"/>). The lenient theories carry the
|
||||
/// cases the former per-function copies pinned (RuleHitSessions, DeviceJourney, VerdictCalibration,
|
||||
/// the Apps reject-to-default variant) so their behaviour is decided once, here: garbage and
|
||||
/// below-minimum mean default, above-maximum clamps. The strict theories pin the 400 messages
|
||||
/// the paginators and admin routes return.
|
||||
/// </summary>
|
||||
public class QueryParamsTests
|
||||
{
|
||||
private static readonly DateTime Occurred = new(2026, 8, 29, 20, 11, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── Lenient ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 14)]
|
||||
[InlineData("", 14)]
|
||||
[InlineData(" ", 14)]
|
||||
[InlineData("garbage", 14)]
|
||||
[InlineData("14", 14)]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("0", 14)] // below min ⇒ default (the old ClampDays copies answered 1)
|
||||
[InlineData("-5", 14)]
|
||||
[InlineData("90", 90)]
|
||||
[InlineData("365", 90)] // above max ⇒ clamped (the old Apps copies answered the default)
|
||||
[InlineData("2147483648", 14)] // overflow ⇒ default
|
||||
public void Int_defaults_below_min_and_clamps_above_max(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, QueryParams.Int(raw, @default: 14, min: 1, max: 90));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int_reads_the_named_parameter_from_a_query_collection()
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString("days=7&limit=abc");
|
||||
Assert.Equal(7, QueryParams.Int(query, "days", 30, 1, 365));
|
||||
Assert.Equal(100, QueryParams.Int(query, "limit", 100, 1, 2000));
|
||||
Assert.Equal(30, QueryParams.Int((System.Collections.Specialized.NameValueCollection?)null, "days", 30, 1, 365));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int_is_culture_invariant()
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.Equal(1000, QueryParams.Int("1000", 1, 1, 5000));
|
||||
Assert.Equal(1, QueryParams.Int("1.000", 1, 1, 5000)); // thousands separator is not a number
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null)]
|
||||
[InlineData("", null)]
|
||||
[InlineData("x", null)]
|
||||
[InlineData("-3", -3)]
|
||||
[InlineData("12", 12)]
|
||||
public void IntOrNull_has_no_range(string? raw, int? expected)
|
||||
{
|
||||
Assert.Equal(expected, QueryParams.IntOrNull(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UtcInstant_is_null_for_absent_or_garbage_and_utc_otherwise()
|
||||
{
|
||||
Assert.Null(QueryParams.UtcInstant(null));
|
||||
Assert.Null(QueryParams.UtcInstant(" "));
|
||||
Assert.Null(QueryParams.UtcInstant("yesterday"));
|
||||
var parsed = QueryParams.UtcInstant("2026-08-29T22:11:00+02:00");
|
||||
Assert.Equal(Occurred, parsed);
|
||||
Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind);
|
||||
}
|
||||
|
||||
// ── Strict ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void TryInt_absent_is_null_and_ok(string? raw)
|
||||
{
|
||||
Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
|
||||
Assert.Null(value);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("banana", "days must be an integer")]
|
||||
[InlineData("1.5", "days must be an integer")]
|
||||
[InlineData("0", "days must be between 1 and 365")]
|
||||
[InlineData("-1", "days must be between 1 and 365")]
|
||||
[InlineData("366", "days must be between 1 and 365")]
|
||||
public void TryInt_rejects_garbage_and_out_of_range_with_a_named_message(string raw, string expectedError)
|
||||
{
|
||||
Assert.False(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
|
||||
Assert.Null(value);
|
||||
Assert.Equal(expectedError, error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("365", 365)]
|
||||
public void TryInt_accepts_the_range_ends(string raw, int expected)
|
||||
{
|
||||
Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error));
|
||||
Assert.Equal(expected, value);
|
||||
Assert.Null(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryInt_without_an_upper_bound_names_only_the_minimum()
|
||||
{
|
||||
Assert.False(QueryParams.TryInt("-1", "skip", 0, int.MaxValue, out _, out var error));
|
||||
Assert.Equal("skip must be an integer of at least 0", error);
|
||||
Assert.True(QueryParams.TryInt("2147483647", "skip", 0, int.MaxValue, out var max, out _));
|
||||
Assert.Equal(int.MaxValue, max);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, null)]
|
||||
[InlineData("1", 1, null)]
|
||||
[InlineData("1000", 1000, null)]
|
||||
[InlineData("1001", null, "pageSize must be between 1 and 1000")]
|
||||
[InlineData("0", null, "pageSize must be between 1 and 1000")]
|
||||
[InlineData("ten", null, "pageSize must be an integer")]
|
||||
public void TryPageSize_is_strict_within_one_to_MaxPageSize(string? raw, int? expected, string? expectedError)
|
||||
{
|
||||
var ok = QueryParams.TryPageSize(raw, out var pageSize, out var error);
|
||||
Assert.Equal(expectedError == null, ok);
|
||||
Assert.Equal(expected, pageSize);
|
||||
Assert.Equal(expectedError, error);
|
||||
}
|
||||
|
||||
// ── Instants ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void TryUtcInstant_reads_offset_bare_and_date_only_values_as_utc()
|
||||
{
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00Z", "d", out var z, out _));
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29T22:11:00+02:00", "d", out var offset, out _));
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00", "d", out var bare, out _));
|
||||
Assert.True(QueryParams.TryUtcInstant("2026-08-29", "d", out var dateOnly, out _));
|
||||
|
||||
Assert.Equal(Occurred, z);
|
||||
Assert.Equal(Occurred, offset);
|
||||
Assert.Equal(Occurred, bare);
|
||||
Assert.Equal(Occurred.Date, dateOnly);
|
||||
Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, dateOnly!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryUtcInstant_treats_absent_and_whitespace_as_no_filter_and_garbage_as_error()
|
||||
{
|
||||
Assert.True(QueryParams.TryUtcInstant(null, "dateFrom", out var none, out var noneError));
|
||||
Assert.Null(none);
|
||||
Assert.Null(noneError);
|
||||
Assert.True(QueryParams.TryUtcInstant(" ", "dateFrom", out var blank, out _));
|
||||
Assert.Null(blank);
|
||||
Assert.False(QueryParams.TryUtcInstant("yesterday", "dateFrom", out var bad, out var error));
|
||||
Assert.Null(bad);
|
||||
Assert.Equal("dateFrom must be an ISO 8601 date or date-time", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryUtcInstant_is_culture_invariant()
|
||||
{
|
||||
var previous = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
// de-DE would read "08/29/2026" day-first and fail; invariant parsing does not.
|
||||
CultureInfo.CurrentCulture = new CultureInfo("de-DE");
|
||||
Assert.True(QueryParams.TryUtcInstant("08/29/2026 20:11", "d", out var parsed, out _));
|
||||
Assert.Equal(Occurred, parsed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CultureInfo.CurrentCulture = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Services.Queueing;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The shared decode step of every [QueueTrigger] function: a <c>false</c> means the message is
|
||||
/// permanently unusable and the function returns so the host deletes it — never a throw, which
|
||||
/// would burn dequeue attempts on a message that can never succeed.
|
||||
/// </summary>
|
||||
public class QueueEnvelopeReaderTests
|
||||
{
|
||||
private sealed class Envelope
|
||||
{
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public int Count { get; set; }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Valid_json_yields_envelope()
|
||||
{
|
||||
var msg = Message(JsonConvert.SerializeObject(new Envelope { TenantId = "t1", Count = 7 }));
|
||||
|
||||
var ok = QueueEnvelopeReader.TryRead<Envelope>(msg, NullLogger.Instance, out var envelope);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("t1", envelope.TenantId);
|
||||
Assert.Equal(7, envelope.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_json_is_rejected_without_throwing()
|
||||
{
|
||||
var msg = Message("{ not json");
|
||||
|
||||
var ok = QueueEnvelopeReader.TryRead<Envelope>(msg, NullLogger.Instance, out var envelope);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(envelope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_literal_is_rejected()
|
||||
{
|
||||
var msg = Message("null");
|
||||
|
||||
var ok = QueueEnvelopeReader.TryRead<Envelope>(msg, NullLogger.Instance, out _);
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failed_validation_is_rejected()
|
||||
{
|
||||
var msg = Message(JsonConvert.SerializeObject(new Envelope { TenantId = string.Empty }));
|
||||
|
||||
var ok = QueueEnvelopeReader.TryRead<Envelope>(
|
||||
msg, NullLogger.Instance, out _, validate: e => !string.IsNullOrEmpty(e.TenantId));
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validation_passes_when_predicate_holds()
|
||||
{
|
||||
var msg = Message(JsonConvert.SerializeObject(new Envelope { TenantId = "t1" }));
|
||||
|
||||
var ok = QueueEnvelopeReader.TryRead<Envelope>(
|
||||
msg, NullLogger.Instance, out var envelope, validate: e => !string.IsNullOrEmpty(e.TenantId));
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("t1", envelope.TenantId);
|
||||
}
|
||||
|
||||
private static QueueMessage Message(string body) => QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + Guid.NewGuid().ToString("N"),
|
||||
popReceipt: "pop",
|
||||
body: new BinaryData(body),
|
||||
dequeueCount: 1);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services.Queueing;
|
||||
using Azure;
|
||||
using Azure.Storage.Queues;
|
||||
using Azure.Storage.Queues.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Audit 2026-09-05 F02: the self-managed poll loop processes a batch sequentially and the
|
||||
/// heartbeat only covers the in-flight message, so every message received beyond the first sat
|
||||
/// invisible with its clock running and could reappear for another worker before this loop
|
||||
/// reached it. The base therefore receives ONE message per poll and re-receives immediately
|
||||
/// after a non-empty result — no worker may inherit a larger batch by accident.
|
||||
/// </summary>
|
||||
public class QueuePollingWorkerBatchSizeTests
|
||||
{
|
||||
private sealed class Envelope
|
||||
{
|
||||
public int Seq { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ProbeWorker : QueuePollingWorker<Envelope>
|
||||
{
|
||||
public List<int> Handled { get; } = new();
|
||||
|
||||
public ProbeWorker(QueueClient main, QueueClient poison)
|
||||
: base(main, poison, NullLogger.Instance, pollIntervalOverride: TimeSpan.FromMilliseconds(20))
|
||||
{
|
||||
}
|
||||
|
||||
public int ExposedBatchSize => BatchSize;
|
||||
|
||||
protected override Task HandleAsync(Envelope envelope, CancellationToken ct)
|
||||
{
|
||||
lock (Handled) Handled.Add(envelope.Seq);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_batch_size_is_one()
|
||||
{
|
||||
var sut = new ProbeWorker(new Mock<QueueClient>().Object, new Mock<QueueClient>().Object);
|
||||
Assert.Equal(1, sut.ExposedBatchSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Loop_receives_one_message_at_a_time_and_drains_all_pending()
|
||||
{
|
||||
var pending = new Queue<QueueMessage>();
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
pending.Enqueue(QueuesModelFactory.QueueMessage(
|
||||
messageId: "msg-" + i,
|
||||
popReceipt: "pop-" + i,
|
||||
body: new BinaryData(JsonConvert.SerializeObject(new Envelope { Seq = i })),
|
||||
dequeueCount: 1));
|
||||
}
|
||||
|
||||
var requestedBatchSizes = new List<int>();
|
||||
var main = new Mock<QueueClient>();
|
||||
var poison = new Mock<QueueClient>();
|
||||
main.Setup(q => q.CreateIfNotExistsAsync(It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
poison.Setup(q => q.CreateIfNotExistsAsync(It.IsAny<IDictionary<string, string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Response?)null);
|
||||
main.Setup(q => q.ReceiveMessagesAsync(It.IsAny<int>(), It.IsAny<TimeSpan>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<int, TimeSpan?, CancellationToken>((max, _, _) =>
|
||||
{
|
||||
var batch = new List<QueueMessage>();
|
||||
lock (pending)
|
||||
{
|
||||
requestedBatchSizes.Add(max);
|
||||
while (batch.Count < max && pending.Count > 0)
|
||||
batch.Add(pending.Dequeue());
|
||||
}
|
||||
return Task.FromResult(Response.FromValue(batch.ToArray(), new Mock<Response>().Object));
|
||||
});
|
||||
main.Setup(q => q.DeleteMessageAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new Mock<Response>().Object);
|
||||
|
||||
var sut = new ProbeWorker(main.Object, poison.Object);
|
||||
using var cts = new CancellationTokenSource();
|
||||
await sut.StartAsync(cts.Token);
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(10);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
lock (sut.Handled) if (sut.Handled.Count == 3) break;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
cts.Cancel();
|
||||
await sut.StopAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { 1, 2, 3 }, sut.Handled);
|
||||
Assert.NotEmpty(requestedBatchSizes);
|
||||
Assert.All(requestedBatchSizes, n => Assert.Equal(1, n));
|
||||
}
|
||||
}
|
||||
@@ -116,30 +116,4 @@ public class RawEventTimeTests
|
||||
Assert.Equal(Occurred - expectedSlack, QueryRawEventsPagination.IndexWrittenAfterHint(Occurred));
|
||||
Assert.Null(QueryRawEventsPagination.IndexWrittenAfterHint(null));
|
||||
}
|
||||
|
||||
// ===== TryParseUtc =====
|
||||
|
||||
[Fact]
|
||||
public void TryParseUtc_reads_offset_and_bare_values_as_utc()
|
||||
{
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00Z", out var z));
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T22:11:00+02:00", out var offset));
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00", out var bare));
|
||||
|
||||
Assert.Equal(Occurred, z);
|
||||
Assert.Equal(Occurred, offset);
|
||||
Assert.Equal(Occurred, bare);
|
||||
Assert.Equal(DateTimeKind.Utc, z!.Value.Kind);
|
||||
Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParseUtc_treats_empty_as_no_filter_and_garbage_as_error()
|
||||
{
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc(null, out var none));
|
||||
Assert.Null(none);
|
||||
Assert.True(QueryRawEventsPagination.TryParseUtc(" ", out var blank));
|
||||
Assert.Null(blank);
|
||||
Assert.False(QueryRawEventsPagination.TryParseUtc("yesterday", out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// DoS hardening of the reducer-verification replay: device-uploaded signal payloads are
|
||||
/// attacker-shaped (chunk-stored up to ~1 MB each, 5000 per request). The verifier must
|
||||
/// (a) decode-fold-discard so peak memory is one signal, not the whole stream, and
|
||||
/// (b) finish in seconds on a forged RealmJoin package stream because
|
||||
/// <see cref="FactStringBounds"/> bounds the dedupe keys inside the engine. The
|
||||
/// signal/transition count-imbalance semantics of the pre-streaming implementation are pinned
|
||||
/// here as well because the streaming rewrite changed how that count is produced.
|
||||
/// </summary>
|
||||
public class ReducerVerifierBoundedReplayTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
private static readonly DateTime T0 = new(2026, 8, 30, 10, 0, 0, DateTimeKind.Utc);
|
||||
private static string CurrentReducerVersion { get; } = new DecisionEngine().ReducerVersion;
|
||||
|
||||
private static DecisionSignal MakeSignal(long ordinal, DecisionSignalKind kind, IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: T0.AddSeconds(ordinal),
|
||||
sourceOrigin: "replay-test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"replay:ord-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
|
||||
/// <summary>Fold <paramref name="sigs"/> through the live reducer and materialise the stored shapes.</summary>
|
||||
private static (SignalRecord[] signals, DecisionTransitionRecord[] transitions) Materialise(IReadOnlyList<DecisionSignal> sigs)
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial(SessionId, TenantId);
|
||||
var transitions = new DecisionTransitionRecord[sigs.Count];
|
||||
var signals = new SignalRecord[sigs.Count];
|
||||
|
||||
for (var i = 0; i < sigs.Count; i++)
|
||||
{
|
||||
var step = engine.Reduce(state, sigs[i]);
|
||||
state = step.NewState;
|
||||
var t = step.Transition;
|
||||
transitions[i] = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = t.StepIndex,
|
||||
SessionTraceOrdinal = t.SessionTraceOrdinal,
|
||||
SignalOrdinalRef = t.SignalOrdinalRef,
|
||||
OccurredAtUtc = t.OccurredAtUtc,
|
||||
Trigger = t.Trigger,
|
||||
FromStage = t.FromStage.ToString(),
|
||||
ToStage = t.ToStage.ToString(),
|
||||
Taken = t.Taken,
|
||||
DeadEndReason = t.DeadEndReason,
|
||||
ReducerVersion = t.ReducerVersion,
|
||||
PayloadJson = TransitionSerializer.Serialize(t),
|
||||
};
|
||||
signals[i] = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = sigs[i].SessionSignalOrdinal,
|
||||
SessionTraceOrdinal = sigs[i].SessionTraceOrdinal,
|
||||
Kind = sigs[i].Kind.ToString(),
|
||||
KindSchemaVersion = sigs[i].KindSchemaVersion,
|
||||
OccurredAtUtc = sigs[i].OccurredAtUtc,
|
||||
SourceOrigin = sigs[i].SourceOrigin,
|
||||
PayloadJson = SignalSerializer.Serialize(sigs[i]),
|
||||
};
|
||||
}
|
||||
|
||||
return (signals, transitions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Forged_realmjoin_package_stream_replays_in_bounded_time_with_zero_divergence()
|
||||
{
|
||||
// Exploit shape: many equal-length package ids sharing all but the trailing digits.
|
||||
// Bounded to 256 chars inside the engine they collapse to a single package row, so the
|
||||
// per-signal dedupe scan compares one short key instead of 200 × 200 KB.
|
||||
const int packageSignals = 250;
|
||||
const int idLength = 200_000;
|
||||
var sigs = new List<DecisionSignal>(packageSignals + 2)
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.RealmJoinDetected,
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" }),
|
||||
};
|
||||
for (var i = 0; i < packageSignals; i++)
|
||||
{
|
||||
sigs.Add(MakeSignal(2 + i, DecisionSignalKind.RealmJoinPackageStarted, new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = new string('p', idLength - 8) + i.ToString("D8"),
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
|
||||
}));
|
||||
}
|
||||
var (signals, transitions) = Materialise(sigs);
|
||||
Assert.All(signals.Skip(2), s => Assert.True(s.PayloadJson.Length > idLength));
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
sw.Stop();
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Equal(0, report.TransitionDivergenceCount);
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_divergence");
|
||||
// Deserialising 250 × 200 KB JSON blobs is the dominant (linear) cost here; the
|
||||
// pre-fix quadratic scan alone was tens of seconds for this shape.
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10), $"verification took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Signal_surplus_beyond_the_journal_is_still_reported_as_count_imbalance()
|
||||
{
|
||||
var sigs = new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
|
||||
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
|
||||
};
|
||||
var (signals, transitions) = Materialise(sigs);
|
||||
var shortJournal = new[] { transitions[0], transitions[1] };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, shortJournal, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_divergence" && i.Message.StartsWith("Signal count (3) != transition count (2)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Mid_stream_deserialisation_failure_truncates_replay_and_counts_only_decoded_signals()
|
||||
{
|
||||
var sigs = new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
|
||||
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
|
||||
};
|
||||
var (signals, transitions) = Materialise(sigs);
|
||||
signals[1].PayloadJson = "{not json";
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_deserialization_error" && i.Message.Contains("ordinal 1"));
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_divergence" && i.Message.StartsWith("Signal count (1) != transition count (3)"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Head_deserialisation_failure_skips_replay_silently()
|
||||
{
|
||||
var (signals, transitions) = Materialise(new[] { MakeSignal(0, DecisionSignalKind.SessionStarted) });
|
||||
signals[0].PayloadJson = "stub";
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.SemanticReplayPerformed);
|
||||
Assert.Equal("deserialization_failure", report.SemanticReplaySkipReason);
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_deserialization_error");
|
||||
}
|
||||
|
||||
// ---- Repository payload budget ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Payload_budget_exhaustion_mirrors_the_repository_stop_condition()
|
||||
{
|
||||
SignalRecord Row(int chars) => new() { PayloadJson = new string('x', chars) };
|
||||
|
||||
Assert.False(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(10), Row(10) }, maxTotalPayloadChars: 100));
|
||||
Assert.True(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(60), Row(40) }, maxTotalPayloadChars: 100));
|
||||
Assert.True(SignalQueryLimits.IsPayloadBudgetExhausted(new[] { Row(60), Row(60) }, maxTotalPayloadChars: 100));
|
||||
Assert.False(SignalQueryLimits.IsPayloadBudgetExhausted(Array.Empty<SignalRecord>(), maxTotalPayloadChars: 100));
|
||||
}
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Serialization;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Codex follow-up #6 — semantic replay coverage for <see cref="ReducerVerifier"/>.
|
||||
/// Structural-only cases live in <see cref="ReducerVerifierTests"/>; this file pins the
|
||||
/// contract that when preconditions hold (contiguous ordinals / matching ReducerVersion)
|
||||
/// the verifier folds the real signal stream through a live <see cref="DecisionEngine"/>
|
||||
/// and reports divergence.
|
||||
/// </summary>
|
||||
public class ReducerVerifierSemanticReplayTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
/// <summary>The live backend reducer version — matches what Verify uses for drift checks.</summary>
|
||||
private static string CurrentReducerVersion { get; } = new DecisionEngine().ReducerVersion;
|
||||
|
||||
// ---- Seed helpers ---------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Build a canonical 3-signal stream + a matching journal by folding the live reducer
|
||||
/// over them. The helper is the "truth" data for agreement tests — anything that differs
|
||||
/// from this output in a test fixture is by definition divergence.
|
||||
/// </summary>
|
||||
private static (SignalRecord[] signals, DecisionTransitionRecord[] transitions, DecisionState finalState)
|
||||
BuildAgreedStream()
|
||||
{
|
||||
var sigs = new[]
|
||||
{
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted),
|
||||
MakeSignal(1, DecisionSignalKind.AppInstallCompleted),
|
||||
MakeSignal(2, DecisionSignalKind.AppInstallCompleted),
|
||||
};
|
||||
|
||||
var engine = new DecisionEngine();
|
||||
var state = DecisionState.CreateInitial(SessionId, TenantId);
|
||||
var transitionRecords = new DecisionTransitionRecord[sigs.Length];
|
||||
|
||||
for (var i = 0; i < sigs.Length; i++)
|
||||
{
|
||||
var step = engine.Reduce(state, sigs[i]);
|
||||
state = step.NewState;
|
||||
|
||||
transitionRecords[i] = new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = step.Transition.StepIndex,
|
||||
SessionTraceOrdinal = step.Transition.SessionTraceOrdinal,
|
||||
SignalOrdinalRef = step.Transition.SignalOrdinalRef,
|
||||
OccurredAtUtc = step.Transition.OccurredAtUtc,
|
||||
Trigger = step.Transition.Trigger,
|
||||
FromStage = step.Transition.FromStage.ToString(),
|
||||
ToStage = step.Transition.ToStage.ToString(),
|
||||
Taken = step.Transition.Taken,
|
||||
DeadEndReason = step.Transition.DeadEndReason,
|
||||
ReducerVersion = step.Transition.ReducerVersion,
|
||||
PayloadJson = TransitionSerializer.Serialize(step.Transition),
|
||||
};
|
||||
}
|
||||
|
||||
var signalRecords = new SignalRecord[sigs.Length];
|
||||
for (var i = 0; i < sigs.Length; i++)
|
||||
{
|
||||
signalRecords[i] = new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = sigs[i].SessionSignalOrdinal,
|
||||
SessionTraceOrdinal = sigs[i].SessionTraceOrdinal,
|
||||
Kind = sigs[i].Kind.ToString(),
|
||||
KindSchemaVersion = sigs[i].KindSchemaVersion,
|
||||
OccurredAtUtc = sigs[i].OccurredAtUtc,
|
||||
SourceOrigin = sigs[i].SourceOrigin,
|
||||
PayloadJson = SignalSerializer.Serialize(sigs[i]),
|
||||
};
|
||||
}
|
||||
|
||||
return (signalRecords, transitionRecords, state);
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeSignal(long ordinal, DecisionSignalKind kind) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: new DateTime(2026, 4, 24, 10, 0, 0, DateTimeKind.Utc).AddSeconds(ordinal),
|
||||
sourceOrigin: "replay-test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"replay:ord-{ordinal}", "test"));
|
||||
|
||||
// ---- Skip conditions ------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Replay_skipped_when_stored_reducer_version_drifts()
|
||||
{
|
||||
var (signals, transitions, _) = BuildAgreedStream();
|
||||
// Override ReducerVersion on the first transition to simulate drift.
|
||||
transitions[0] = CloneWithReducerVersion(transitions[0], "0.9.0.0");
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.SemanticReplayPerformed);
|
||||
Assert.Equal("reducer_version_drift", report.SemanticReplaySkipReason);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_skipped");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_skipped_when_signal_stream_has_ordinal_gap()
|
||||
{
|
||||
var (signals, transitions, _) = BuildAgreedStream();
|
||||
// Drop the middle signal → ordinal gap.
|
||||
var gapped = new[] { signals[0], signals[2] };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, gapped, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.SemanticReplayPerformed);
|
||||
Assert.Equal("non_contiguous_signal_ordinals", report.SemanticReplaySkipReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_skipped_when_transition_journal_has_step_index_gap()
|
||||
{
|
||||
var (signals, transitions, _) = BuildAgreedStream();
|
||||
var gapped = new[] { transitions[0], transitions[2] }; // StepIndex gap
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, gapped, CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.SemanticReplayPerformed);
|
||||
Assert.Equal("non_contiguous_step_indices", report.SemanticReplaySkipReason);
|
||||
}
|
||||
|
||||
// ---- Agreement + divergence ----------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Replay_perfect_agreement_marks_final_stage_matches_and_zero_divergence()
|
||||
{
|
||||
var (signals, transitions, finalState) = BuildAgreedStream();
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Null(report.SemanticReplaySkipReason);
|
||||
Assert.True(report.SemanticReplayFinalStageMatches);
|
||||
Assert.Equal(finalState.Stage.ToString(), report.ReplayedFinalStage);
|
||||
Assert.Equal(0, report.TransitionDivergenceCount);
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_divergence");
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_final_stage_mismatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_divergence_reports_issue_when_stored_trigger_was_tampered()
|
||||
{
|
||||
var (signals, transitions, _) = BuildAgreedStream();
|
||||
// Tamper with a stored transition: swap Trigger. Replay will produce the
|
||||
// authentic trigger and divergence is reported.
|
||||
transitions[1] = CloneWithTrigger(transitions[1], "TamperedTrigger");
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Equal(1, report.TransitionDivergenceCount);
|
||||
var issue = Assert.Single(report.Issues, i => i.Kind == "replay_divergence");
|
||||
Assert.Contains("TamperedTrigger", issue.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_final_stage_mismatch_raises_error_issue_when_last_stored_ToStage_differs()
|
||||
{
|
||||
var (signals, transitions, _) = BuildAgreedStream();
|
||||
transitions[^1] = CloneWithToStage(transitions[^1], "Completed"); // pretend the last stored landed in Completed
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.False(report.SemanticReplayFinalStageMatches);
|
||||
Assert.Contains(report.Issues,
|
||||
i => i.Kind == "replay_final_stage_mismatch" && i.Severity == "Error");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_seed_preserves_session_and_tenant_identity_distinctly()
|
||||
{
|
||||
// Codex follow-up (post-#50 #D) regression: seeding CreateInitial with swapped
|
||||
// (tenantId, sessionId) used to produce a state with mirrored IDs, yet all tests
|
||||
// passed because the fixture AND the verifier agreed on the swap. Using sentinel
|
||||
// IDs where the tenant and session tokens are distinguishable (different prefixes)
|
||||
// catches the swap at the identity-check step the verifier now performs.
|
||||
var (signals, transitions, _) = BuildAgreedStream();
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Equal(TenantId, report.TenantId);
|
||||
Assert.Equal(SessionId, report.SessionId);
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "replay_identity_mismatch");
|
||||
// Sanity on the sentinel prefixes (keeps the regression explicit if someone edits
|
||||
// the constants and accidentally mirrors them).
|
||||
Assert.StartsWith("a1b2", TenantId);
|
||||
Assert.StartsWith("b2c3", SessionId);
|
||||
Assert.NotEqual(TenantId, SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Replay_handles_mid_stream_signal_deserialization_failure_without_aborting()
|
||||
{
|
||||
var (signals, transitions, _) = BuildAgreedStream();
|
||||
// Corrupt the second signal's PayloadJson so deserialization fails there.
|
||||
signals[1].PayloadJson = "this is not json";
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
// Replay ran on the valid prefix (signal 0); deserialization error reported for the
|
||||
// truncated tail rather than aborting the whole verification.
|
||||
Assert.True(report.SemanticReplayPerformed);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "replay_deserialization_error");
|
||||
}
|
||||
|
||||
// ---- Clone helpers (records are POCO, so tweaks need full copies) --------------
|
||||
|
||||
private static DecisionTransitionRecord CloneWithReducerVersion(
|
||||
DecisionTransitionRecord source, string reducerVersion) =>
|
||||
new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = source.TenantId,
|
||||
SessionId = source.SessionId,
|
||||
StepIndex = source.StepIndex,
|
||||
SessionTraceOrdinal = source.SessionTraceOrdinal,
|
||||
SignalOrdinalRef = source.SignalOrdinalRef,
|
||||
OccurredAtUtc = source.OccurredAtUtc,
|
||||
Trigger = source.Trigger,
|
||||
FromStage = source.FromStage,
|
||||
ToStage = source.ToStage,
|
||||
Taken = source.Taken,
|
||||
DeadEndReason = source.DeadEndReason,
|
||||
ReducerVersion = reducerVersion,
|
||||
PayloadJson = source.PayloadJson,
|
||||
};
|
||||
|
||||
private static DecisionTransitionRecord CloneWithTrigger(
|
||||
DecisionTransitionRecord source, string trigger)
|
||||
{
|
||||
// We rewrite PayloadJson too so TransitionSerializer sees the tampered trigger
|
||||
// and the verifier's comparison picks it up.
|
||||
var transition = TransitionSerializer.Deserialize(source.PayloadJson);
|
||||
var tampered = new DecisionTransition(
|
||||
stepIndex: transition.StepIndex,
|
||||
sessionTraceOrdinal: transition.SessionTraceOrdinal,
|
||||
signalOrdinalRef: transition.SignalOrdinalRef,
|
||||
occurredAtUtc: transition.OccurredAtUtc,
|
||||
trigger: trigger,
|
||||
fromStage: transition.FromStage,
|
||||
toStage: transition.ToStage,
|
||||
taken: transition.Taken,
|
||||
deadEndReason: transition.DeadEndReason,
|
||||
reducerVersion: transition.ReducerVersion);
|
||||
return new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = source.TenantId,
|
||||
SessionId = source.SessionId,
|
||||
StepIndex = source.StepIndex,
|
||||
SessionTraceOrdinal = source.SessionTraceOrdinal,
|
||||
SignalOrdinalRef = source.SignalOrdinalRef,
|
||||
OccurredAtUtc = source.OccurredAtUtc,
|
||||
Trigger = trigger,
|
||||
FromStage = source.FromStage,
|
||||
ToStage = source.ToStage,
|
||||
Taken = source.Taken,
|
||||
DeadEndReason = source.DeadEndReason,
|
||||
ReducerVersion = source.ReducerVersion,
|
||||
PayloadJson = TransitionSerializer.Serialize(tampered),
|
||||
};
|
||||
}
|
||||
|
||||
private static DecisionTransitionRecord CloneWithToStage(
|
||||
DecisionTransitionRecord source, string toStage)
|
||||
{
|
||||
var transition = TransitionSerializer.Deserialize(source.PayloadJson);
|
||||
var tampered = new DecisionTransition(
|
||||
stepIndex: transition.StepIndex,
|
||||
sessionTraceOrdinal: transition.SessionTraceOrdinal,
|
||||
signalOrdinalRef: transition.SignalOrdinalRef,
|
||||
occurredAtUtc: transition.OccurredAtUtc,
|
||||
trigger: transition.Trigger,
|
||||
fromStage: transition.FromStage,
|
||||
toStage: (SessionStage)Enum.Parse(typeof(SessionStage), toStage),
|
||||
taken: transition.Taken,
|
||||
deadEndReason: transition.DeadEndReason,
|
||||
reducerVersion: transition.ReducerVersion);
|
||||
return new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = source.TenantId,
|
||||
SessionId = source.SessionId,
|
||||
StepIndex = source.StepIndex,
|
||||
SessionTraceOrdinal = source.SessionTraceOrdinal,
|
||||
SignalOrdinalRef = source.SignalOrdinalRef,
|
||||
OccurredAtUtc = source.OccurredAtUtc,
|
||||
Trigger = source.Trigger,
|
||||
FromStage = source.FromStage,
|
||||
ToStage = toStage,
|
||||
Taken = source.Taken,
|
||||
DeadEndReason = source.DeadEndReason,
|
||||
ReducerVersion = source.ReducerVersion,
|
||||
PayloadJson = TransitionSerializer.Serialize(tampered),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function tests for <see cref="ReducerVerifier"/>. Covers the five detectors:
|
||||
/// empty session, signal ordinal gaps, step index gaps, orphaned transitions, and
|
||||
/// ReducerVersion drift — plus the happy-path (all contiguous, versions match).
|
||||
/// </summary>
|
||||
public class ReducerVerifierTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
private const string CurrentReducerVersion = "1.4.2.0";
|
||||
|
||||
private static SignalRecord Signal(long ordinal)
|
||||
=> new SignalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SessionSignalOrdinal = ordinal,
|
||||
SessionTraceOrdinal = ordinal,
|
||||
Kind = "EspPhaseChanged",
|
||||
KindSchemaVersion = 1,
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
SourceOrigin = "test",
|
||||
PayloadJson = "{}",
|
||||
};
|
||||
|
||||
private static DecisionTransitionRecord Transition(
|
||||
int stepIndex, long signalRef, string reducerVersion = CurrentReducerVersion)
|
||||
=> new DecisionTransitionRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StepIndex = stepIndex,
|
||||
SessionTraceOrdinal = signalRef,
|
||||
SignalOrdinalRef = signalRef,
|
||||
OccurredAtUtc = DateTime.UtcNow,
|
||||
Trigger = "t",
|
||||
FromStage = "SessionStarted",
|
||||
ToStage = "EspInProgress",
|
||||
Taken = true,
|
||||
ReducerVersion = reducerVersion,
|
||||
};
|
||||
|
||||
// ============================================================ Empty
|
||||
|
||||
[Fact]
|
||||
public void Verify_empty_session_emits_info_issue_and_returns_zero_counts()
|
||||
{
|
||||
var report = ReducerVerifier.Verify(
|
||||
TenantId, SessionId,
|
||||
Array.Empty<SignalRecord>(),
|
||||
Array.Empty<DecisionTransitionRecord>(),
|
||||
CurrentReducerVersion);
|
||||
|
||||
Assert.Equal(0, report.SignalCount);
|
||||
Assert.Equal(0, report.TransitionCount);
|
||||
Assert.Single(report.Issues);
|
||||
Assert.Equal("empty_session", report.Issues[0].Kind);
|
||||
Assert.Equal("Info", report.Issues[0].Severity);
|
||||
}
|
||||
|
||||
// ============================================================ Happy path
|
||||
|
||||
[Fact]
|
||||
public void Verify_happy_path_contiguous_signals_and_transitions_no_drift()
|
||||
{
|
||||
var signals = new[] { Signal(1), Signal(2), Signal(3) };
|
||||
var transitions = new[] { Transition(1, 1), Transition(2, 2), Transition(3, 3) };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.Equal(3, report.SignalCount);
|
||||
Assert.Equal(3, report.TransitionCount);
|
||||
Assert.True(report.SignalOrdinalsContiguous);
|
||||
Assert.True(report.StepIndicesContiguous);
|
||||
Assert.False(report.ReducerVersionDrift);
|
||||
Assert.Equal(0, report.OrphanedTransitionCount);
|
||||
Assert.Empty(report.Issues);
|
||||
}
|
||||
|
||||
// ============================================================ Signal ordinal gaps
|
||||
|
||||
[Fact]
|
||||
public void Verify_detects_signal_ordinal_gap()
|
||||
{
|
||||
var signals = new[] { Signal(1), Signal(2), Signal(5) }; // gap: 3, 4 missing
|
||||
|
||||
var report = ReducerVerifier.Verify(
|
||||
TenantId, SessionId, signals, Array.Empty<DecisionTransitionRecord>(), CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.SignalOrdinalsContiguous);
|
||||
Assert.Equal(1, report.SignalOrdinalFirst);
|
||||
Assert.Equal(5, report.SignalOrdinalLast);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "signal_ordinal_gap" && i.Message.Contains("missing 2"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_caps_ordinal_gap_issues_and_adds_overflow_message()
|
||||
{
|
||||
// Construct >20 gaps: alternating contiguous-then-jump pattern.
|
||||
var ordinals = new List<long>();
|
||||
for (var i = 0; i < 25; i++) ordinals.Add((long)(i * 10 + 1)); // 1,11,21,31,... → 24 gaps
|
||||
var signals = ordinals.Select(Signal).ToArray();
|
||||
|
||||
var report = ReducerVerifier.Verify(
|
||||
TenantId, SessionId, signals, Array.Empty<DecisionTransitionRecord>(), CurrentReducerVersion);
|
||||
|
||||
var gapIssues = report.Issues.Where(i => i.Kind == "signal_ordinal_gap").ToList();
|
||||
// 20 detailed + 1 overflow summary = 21
|
||||
Assert.Equal(21, gapIssues.Count);
|
||||
Assert.Contains(gapIssues, i => i.Message.StartsWith("... ") && i.Message.Contains("additional"));
|
||||
}
|
||||
|
||||
// ============================================================ Step-index gaps
|
||||
|
||||
[Fact]
|
||||
public void Verify_detects_step_index_gap()
|
||||
{
|
||||
var signals = new[] { Signal(1), Signal(2), Signal(3) };
|
||||
var transitions = new[] { Transition(1, 1), Transition(3, 3) }; // step 2 missing
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.StepIndicesContiguous);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "step_index_gap");
|
||||
}
|
||||
|
||||
// ============================================================ Orphaned transitions
|
||||
|
||||
[Fact]
|
||||
public void Verify_flags_transition_referencing_missing_signal()
|
||||
{
|
||||
var signals = new[] { Signal(1), Signal(2) };
|
||||
// Transition step 2 references signal ordinal 99 which doesn't exist.
|
||||
var transitions = new[] { Transition(1, 1), Transition(2, 99) };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.Equal(1, report.OrphanedTransitionCount);
|
||||
Assert.Contains(report.Issues, i => i.Kind == "orphaned_transition" && i.Message.Contains("99"));
|
||||
}
|
||||
|
||||
// ============================================================ ReducerVersion drift
|
||||
|
||||
[Fact]
|
||||
public void Verify_flags_ReducerVersion_drift_as_Info_level()
|
||||
{
|
||||
var signals = new[] { Signal(1) };
|
||||
var transitions = new[] { Transition(1, 1, reducerVersion: "1.2.0.0") };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.ReducerVersionDrift);
|
||||
Assert.Equal("1.2.0.0", report.StoredReducerVersion);
|
||||
Assert.Equal(CurrentReducerVersion, report.CurrentReducerVersion);
|
||||
|
||||
var drift = report.Issues.Single(i => i.Kind == "reducer_version_drift");
|
||||
// Drift is informational, not an error — it's a known condition when replays span
|
||||
// reducer versions and the report makes sense to show regardless.
|
||||
Assert.Equal("Info", drift.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_does_not_flag_drift_when_stored_version_matches_current()
|
||||
{
|
||||
var signals = new[] { Signal(1) };
|
||||
var transitions = new[] { Transition(1, 1, reducerVersion: CurrentReducerVersion) };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.False(report.ReducerVersionDrift);
|
||||
Assert.DoesNotContain(report.Issues, i => i.Kind == "reducer_version_drift");
|
||||
}
|
||||
|
||||
// ============================================================ Ordering
|
||||
|
||||
[Fact]
|
||||
public void Verify_handles_out_of_order_input_because_the_verifier_sorts_before_checking()
|
||||
{
|
||||
var signals = new[] { Signal(3), Signal(1), Signal(2) };
|
||||
var transitions = new[] { Transition(3, 3), Transition(1, 1), Transition(2, 2) };
|
||||
|
||||
var report = ReducerVerifier.Verify(TenantId, SessionId, signals, transitions, CurrentReducerVersion);
|
||||
|
||||
Assert.True(report.SignalOrdinalsContiguous);
|
||||
Assert.True(report.StepIndicesContiguous);
|
||||
Assert.Equal(1, report.SignalOrdinalFirst);
|
||||
Assert.Equal(3, report.SignalOrdinalLast);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,9 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// Timestamp provenance in the duration evaluators (phase_duration, app_install_duration).
|
||||
/// IME-log-derived events carry <c>data.sourceOffsetOrigin</c>; <c>reader-zone-fallback</c>
|
||||
/// means the agent assumed its own timezone for the log writer, so the Timestamp can be hours
|
||||
/// off. A duration with such an endpoint is not measurable: the condition is false with a
|
||||
/// provenance reason. Other origins and agent-native events (no tag) stay measurable.
|
||||
/// off. A span with MIXED provenance — one fallback endpoint, the other anchored or "now" — is
|
||||
/// not measurable: the condition is false with a provenance reason. Two fallback endpoints share
|
||||
/// the writer's error and stay measurable, as do other origins and agent-native events (no tag).
|
||||
/// Field case: a 20-minute DeviceSetup rendered as 3 h 22 min fired ANALYZE-ESP-001 (high).
|
||||
/// </summary>
|
||||
public class RuleEngineDurationProvenanceTests
|
||||
@@ -130,8 +131,42 @@ public class RuleEngineDurationProvenanceTests
|
||||
Assert.Equal("start", AsString(AsDict(fallbackCondition.Evidence!)["provenanceEndpoint"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PhaseDuration_BothEndsFallback_IsComputed()
|
||||
{
|
||||
// Two fallback endpoints carry the same writer-zone error, so the span is right — and
|
||||
// this is the shape of every session from an agent without an era anchor, because the
|
||||
// DeviceSetup line is written before the agent starts.
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
PhaseEvent("DeviceSetup", T0, 10, Fallback),
|
||||
PhaseEvent("AccountSetup", T0.AddHours(3), 20, Fallback),
|
||||
};
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(PhaseRule("DeviceSetup"), events);
|
||||
|
||||
var condition = Assert.Single(dry.Conditions);
|
||||
Assert.True(condition.Matched);
|
||||
Assert.Equal(10800d, Convert.ToDouble(AsDict(condition.Evidence!)["durationSeconds"]));
|
||||
}
|
||||
|
||||
// ===== app_install_duration =====
|
||||
|
||||
[Fact]
|
||||
public async Task AppInstallDuration_BothEndsFallback_IsComputed()
|
||||
{
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppEvent("app_install_started", "app-a", T0, 10, Fallback),
|
||||
AppEvent("app_install_completed", "app-a", T0.AddHours(1), 20, Fallback),
|
||||
};
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(AppDurationRule(minSeconds: 600), events);
|
||||
|
||||
var condition = Assert.Single(dry.Conditions);
|
||||
Assert.True(condition.Matched);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppInstallDuration_StartWithFallbackProvenance_IsNotMeasurable()
|
||||
{
|
||||
@@ -242,6 +277,22 @@ public class RuleEngineDurationProvenanceTests
|
||||
Assert.Empty(outcome.Results);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_ESP_001_still_fires_when_both_endpoints_are_fallback()
|
||||
{
|
||||
var rule = BuiltInAnalyzeRules.GetAll().First(r => r.RuleId == "ANALYZE-ESP-001");
|
||||
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
PhaseEvent("DeviceSetup", T0, 10, Fallback),
|
||||
PhaseEvent("AccountSetup", T0.AddHours(3), 20, Fallback),
|
||||
};
|
||||
|
||||
var outcome = await RunBuiltInAsync(rule, events);
|
||||
var result = Assert.Single(outcome.Results);
|
||||
Assert.Equal("ANALYZE-ESP-001", result.RuleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANALYZE_ESP_001_fires_on_the_same_span_when_line_anchored()
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// Pins the queue-path retry contract: <see cref="RuleEngine.AnalyzeSessionAsync"/> must
|
||||
/// propagate storage exceptions to the caller. The previous outer try/catch swallowed all
|
||||
/// errors and returned an empty <see cref="AnalysisOutcome"/>, which caused
|
||||
/// <c>AnalyzeOnEnrollmentEndQueueWorker</c> to delete the message as success and never retry —
|
||||
/// <c>AnalyzeOnEnrollmentEndQueueFunction</c> to delete the message as success and never retry —
|
||||
/// rule results were silently lost on transient Table Storage failures.
|
||||
/// </summary>
|
||||
public class RuleEngineFailurePropagationTests
|
||||
|
||||
@@ -10,21 +10,6 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class RuleHitSessionsFunctionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, 14)] // not supplied → default window
|
||||
[InlineData("", 14)] // empty → default
|
||||
[InlineData("garbage", 14)] // non-numeric → default
|
||||
[InlineData("14", 14)]
|
||||
[InlineData("1", 1)]
|
||||
[InlineData("0", 1)] // below range → clamped up
|
||||
[InlineData("-5", 1)]
|
||||
[InlineData("90", 90)]
|
||||
[InlineData("365", 90)] // above range → clamped down
|
||||
public void ParseDays_Defaults_And_Clamps(string? raw, int expected)
|
||||
{
|
||||
Assert.Equal(expected, RuleHitSessionsFunction.ParseDays(raw));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MaxSessionIds_Matches_Repository_Default_Cap()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>Key layout D-199 for RuleStats plus the legacy-layout detection the readers rely on.</summary>
|
||||
public class RuleStatsLayoutTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Fact]
|
||||
public void Keys_are_scope_date_partition_and_rule_id_row()
|
||||
{
|
||||
Assert.Equal($"{Tenant}_2026-09-05", RuleStatsKeys.PartitionKey(Tenant, "2026-09-05"));
|
||||
Assert.Equal("global_2026-09-05", RuleStatsKeys.PartitionKey(RuleStatsKeys.GlobalScope, "2026-09-05"));
|
||||
Assert.Equal("ANALYZE-APP-001", RuleStatsKeys.RowKey("ANALYZE-APP-001"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("2026-09-05", true)]
|
||||
[InlineData("2020-01-01", true)]
|
||||
[InlineData("global_2026-09-05", false)]
|
||||
[InlineData("2026abcd-1111-2222-3333-444444444444_2026-09-05", false)]
|
||||
[InlineData("2026-09-5", false)]
|
||||
[InlineData("2026_09_05", false)]
|
||||
[InlineData("", false)]
|
||||
[InlineData(null, false)]
|
||||
public void Legacy_partition_key_is_exactly_a_bare_date(string? partitionKey, bool expected)
|
||||
=> Assert.Equal(expected, RuleStatsKeys.IsLegacyPartitionKey(partitionKey));
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_the_new_layout_and_keeps_underscores_in_the_rule_id()
|
||||
{
|
||||
var (scope, date, ruleId) = RuleStatsKeys.Parse($"{Tenant}_2026-09-05", "CONTOSO_WIFI_001");
|
||||
|
||||
Assert.Equal(Tenant, scope);
|
||||
Assert.Equal("2026-09-05", date);
|
||||
Assert.Equal("CONTOSO_WIFI_001", ruleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_reads_the_legacy_layout_splitting_at_the_first_underscore()
|
||||
{
|
||||
var (scope, date, ruleId) = RuleStatsKeys.Parse("2026-09-05", $"{Tenant}_CONTOSO_WIFI_001");
|
||||
|
||||
Assert.Equal(Tenant, scope);
|
||||
Assert.Equal("2026-09-05", date);
|
||||
Assert.Equal("CONTOSO_WIFI_001", ruleId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_layout_is_consulted_until_the_retention_horizon_only()
|
||||
{
|
||||
Assert.True(RuleStatsKeys.LegacyLayoutActive(new DateTime(2026, 12, 4, 23, 59, 0, DateTimeKind.Utc)));
|
||||
Assert.False(RuleStatsKeys.LegacyLayoutActive(RuleStatsKeys.LegacyLayoutUntil));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Merge_sums_evaluations_fires_and_confidence_per_rule_and_keeps_the_latest_metadata()
|
||||
{
|
||||
var increments = new[]
|
||||
{
|
||||
new RuleStatIncrement("A", "analyze", "old title", "apps", "warning", fired: true, confidenceScore: 80),
|
||||
new RuleStatIncrement("B", "analyze", "b", "net", "info", fired: false, confidenceScore: null),
|
||||
new RuleStatIncrement("A", "analyze", "new title", "apps", "high", fired: false, confidenceScore: 99),
|
||||
new RuleStatIncrement("A", "analyze", "new title", "apps", "high", fired: true, confidenceScore: null),
|
||||
};
|
||||
|
||||
var deltas = RuleStatDelta.Merge(increments).OrderBy(d => d.RuleId).ToList();
|
||||
|
||||
var a = deltas[0];
|
||||
Assert.Equal(("A", 3, 2, 80L, "new title", "high"), (a.RuleId, a.Evaluations, a.Fires, a.ConfidenceScoreSum, a.RuleTitle, a.Severity));
|
||||
var b = deltas[1];
|
||||
Assert.Equal(("B", 1, 0, 0L), (b.RuleId, b.Evaluations, b.Fires, b.ConfidenceScoreSum));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// RuleStats after D-199: the writer folds a session into one partition read plus one CAS
|
||||
/// transaction per scope; readers are PartitionKey ranges per scope (plus the legacy query
|
||||
/// while legacy rows can exist); the cleanup deletes per scope and never mistakes a tenant
|
||||
/// GUID that sorts inside a date range for a legacy row.
|
||||
/// </summary>
|
||||
public class RuleStatsRepositoryTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Tenant2 = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Date = "2026-09-05";
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public ConcurrentBag<string> Filters { get; } = new();
|
||||
public List<List<TableTransactionAction>> Submitted { get; } = new();
|
||||
public List<(string Pk, string Rk)> Deleted { get; } = new();
|
||||
public Func<string, TableEntity[]> RowsFor { get; set; } = _ => Array.Empty<TableEntity>();
|
||||
public Queue<Func<Response<IReadOnlyList<Response>>>> SubmitBehaviors { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(c => c.QueryAsync<TableEntity>(It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((string filter, int? _, IEnumerable<string> _, CancellationToken _) =>
|
||||
{
|
||||
Filters.Add(filter);
|
||||
var page = Page<TableEntity>.FromValues(RowsFor(filter), null, Mock.Of<Response>());
|
||||
return AsyncPageable<TableEntity>.FromPages(new[] { page });
|
||||
});
|
||||
Table.Setup(c => c.SubmitTransactionAsync(It.IsAny<IEnumerable<TableTransactionAction>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<IEnumerable<TableTransactionAction>, CancellationToken>((actions, _) =>
|
||||
{
|
||||
var snapshot = actions.ToList();
|
||||
Submitted.Add(snapshot);
|
||||
if (SubmitBehaviors.Count > 0) return Task.FromResult(SubmitBehaviors.Dequeue()());
|
||||
var inner = (IReadOnlyList<Response>)snapshot.Select(_ => Mock.Of<Response>()).ToList();
|
||||
return Task.FromResult(Response.FromValue(inner, Mock.Of<Response>()));
|
||||
});
|
||||
Table.Setup(c => c.DeleteEntityAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
|
||||
{
|
||||
Deleted.Add((pk, rk));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.RuleStats)).Returns(Table.Object);
|
||||
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static TableEntity ExistingRow(string pk, string ruleId, int fires, int evals, long confSum, string etag)
|
||||
{
|
||||
var e = new TableEntity(pk, ruleId)
|
||||
{
|
||||
["RuleId"] = ruleId, ["RuleType"] = "analyze", ["RuleTitle"] = "t", ["Category"] = "c", ["Severity"] = "s",
|
||||
["FireCount"] = fires, ["EvaluationCount"] = evals, ["SessionsEvaluated"] = evals,
|
||||
["ConfidenceScoreSum"] = confSum, ["AvgConfidenceScore"] = fires > 0 ? (double)confSum / fires : 0.0,
|
||||
};
|
||||
e.ETag = new ETag(etag);
|
||||
return e;
|
||||
}
|
||||
|
||||
/// <summary>A row in the pre-D-199 layout: PartitionKey = date, RowKey = "{scope}_{ruleId}", RuleId column = ruleId.</summary>
|
||||
private static TableEntity LegacyRow(string date, string scope, string ruleId)
|
||||
{
|
||||
var e = ExistingRow(date, ruleId, fires: 0, evals: 1, confSum: 0, etag: "e");
|
||||
return new TableEntity(date, $"{scope}_{ruleId}") { ["RuleId"] = ruleId, ["RuleType"] = e.GetString("RuleType"), ["EvaluationCount"] = 1 };
|
||||
}
|
||||
|
||||
// ---------- writer ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Writer_reads_the_partition_once_and_submits_one_transaction_with_etag_updates_and_adds()
|
||||
{
|
||||
var pk = $"{Tenant}_{Date}";
|
||||
var h = new Harness { RowsFor = f => f == $"PartitionKey eq '{pk}'" ? new[] { ExistingRow(pk, "A", fires: 2, evals: 5, confSum: 160, etag: "eA") } : Array.Empty<TableEntity>() };
|
||||
|
||||
await h.Sut.RecordRuleStatsAsync(Date, Tenant, new[]
|
||||
{
|
||||
new RuleStatIncrement("A", "analyze", "A title", "apps", "warning", fired: true, confidenceScore: 80),
|
||||
new RuleStatIncrement("B", "analyze", "B title", "net", "info", fired: false, confidenceScore: null),
|
||||
});
|
||||
|
||||
Assert.Single(h.Filters);
|
||||
var batch = Assert.Single(h.Submitted);
|
||||
Assert.Equal(2, batch.Count);
|
||||
|
||||
var update = batch.Single(a => a.ActionType == TableTransactionActionType.UpdateReplace);
|
||||
Assert.Equal(new ETag("eA"), update.ETag);
|
||||
var a = (TableEntity)update.Entity;
|
||||
Assert.Equal(3, a.GetInt32("FireCount"));
|
||||
Assert.Equal(6, a.GetInt32("EvaluationCount"));
|
||||
Assert.Equal(240L, a.GetInt64("ConfidenceScoreSum"));
|
||||
Assert.Equal(80.0, a.GetDouble("AvgConfidenceScore"));
|
||||
Assert.Equal("A title", a.GetString("RuleTitle"));
|
||||
|
||||
var add = batch.Single(x => x.ActionType == TableTransactionActionType.Add);
|
||||
var b = (TableEntity)add.Entity;
|
||||
Assert.Equal(pk, b.PartitionKey);
|
||||
Assert.Equal("B", b.RowKey);
|
||||
Assert.Equal(0, b.GetInt32("FireCount"));
|
||||
Assert.Equal(1, b.GetInt32("EvaluationCount"));
|
||||
Assert.Equal("analyze", b.GetString("RuleType"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writer_re_reads_and_retries_after_a_precondition_failure()
|
||||
{
|
||||
var pk = $"{Tenant}_{Date}";
|
||||
var reads = 0;
|
||||
var h = new Harness();
|
||||
h.RowsFor = _ => new[] { ExistingRow(pk, "A", fires: 0, evals: reads++, confSum: 0, etag: $"e{reads}") };
|
||||
h.SubmitBehaviors.Enqueue(() => throw new RequestFailedException(412, "precondition failed"));
|
||||
|
||||
await h.Sut.RecordRuleStatsAsync(Date, Tenant, new[] { new RuleStatIncrement("A", "analyze", "t", "c", "s", fired: false, confidenceScore: null) });
|
||||
|
||||
Assert.Equal(2, h.Filters.Count);
|
||||
Assert.Equal(2, h.Submitted.Count);
|
||||
Assert.Equal(new ETag("e2"), h.Submitted[1].Single().ETag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Writer_with_nothing_to_record_touches_storage_not_at_all()
|
||||
{
|
||||
var h = new Harness();
|
||||
await h.Sut.RecordRuleStatsAsync(Date, Tenant, Array.Empty<RuleStatIncrement>());
|
||||
Assert.Empty(h.Filters);
|
||||
Assert.Empty(h.Submitted);
|
||||
}
|
||||
|
||||
// ---------- readers ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_maps_both_layouts_and_queries_one_partition_range_per_scope()
|
||||
{
|
||||
var h = new Harness
|
||||
{
|
||||
RowsFor = f =>
|
||||
f.StartsWith($"PartitionKey ge '{Tenant}_")
|
||||
? new[] { ExistingRow($"{Tenant}_2026-09-03", "ANALYZE-X", 1, 1, 50, "e") }
|
||||
: f.Contains($"RowKey ge '{Tenant}_'")
|
||||
? new[] { LegacyRow("2026-09-02", Tenant, "ANALYZE-Y") }
|
||||
: Array.Empty<TableEntity>()
|
||||
};
|
||||
|
||||
var entries = await h.Sut.GetRuleStatsAsync(Tenant, "2026-09-01", "2026-09-05");
|
||||
|
||||
Assert.Contains($"PartitionKey ge '{Tenant}_2026-09-01' and PartitionKey le '{Tenant}_2026-09-05'", h.Filters);
|
||||
Assert.Contains($"PartitionKey ge '2026-09-01' and PartitionKey le '2026-09-05' and RowKey ge '{Tenant}_' and RowKey lt '{Tenant}_~'", h.Filters);
|
||||
|
||||
var x = Assert.Single(entries, e => e.RuleId == "ANALYZE-X");
|
||||
Assert.Equal((Tenant, "2026-09-03"), (x.TenantId, x.Date));
|
||||
var y = Assert.Single(entries, e => e.RuleId == "ANALYZE-Y");
|
||||
Assert.Equal((Tenant, "2026-09-02"), (y.TenantId, y.Date));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_for_many_tenants_fans_out_per_tenant_and_skips_global()
|
||||
{
|
||||
var h = new Harness();
|
||||
|
||||
await h.Sut.GetRuleStatsForTenantsAsync(new[] { Tenant, "global", Tenant2 }, "2026-09-01", "2026-09-05", "analyze");
|
||||
|
||||
Assert.Contains(h.Filters, f => f.StartsWith($"PartitionKey ge '{Tenant}_2026-09-01'") && f.EndsWith("RuleType eq 'analyze'"));
|
||||
Assert.Contains(h.Filters, f => f.StartsWith($"PartitionKey ge '{Tenant2}_2026-09-01'"));
|
||||
Assert.DoesNotContain(h.Filters, f => f.StartsWith("PartitionKey ge 'global_"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reader_without_a_scope_is_a_caller_bug()
|
||||
{
|
||||
var h = new Harness();
|
||||
await Assert.ThrowsAsync<ArgumentException>(() => h.Sut.GetRuleStatsAsync(string.Empty));
|
||||
}
|
||||
|
||||
// ---------- cleanup ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Cleanup_deletes_per_scope_and_only_bare_date_rows_from_the_legacy_query()
|
||||
{
|
||||
var h = new Harness
|
||||
{
|
||||
RowsFor = f => f.StartsWith("PartitionKey ge '2000-01-01'")
|
||||
? new[]
|
||||
{
|
||||
new TableEntity("2026-05-01", $"{Tenant}_ANALYZE-A"), // legacy → delete
|
||||
new TableEntity("2020a3f1-0000-0000-0000-000000000000_2026-05-01", "ANALYZE-A"), // GUID inside the date range → keep
|
||||
}
|
||||
: f.StartsWith($"PartitionKey ge '{Tenant}_'")
|
||||
? new[] { new TableEntity($"{Tenant}_2026-05-30", "ANALYZE-B") }
|
||||
: Array.Empty<TableEntity>()
|
||||
};
|
||||
|
||||
var deleted = await h.Sut.DeleteRuleStatsOlderThanAsync(new DateTime(2026, 6, 7, 0, 0, 0, DateTimeKind.Utc), new[] { Tenant });
|
||||
|
||||
Assert.Contains($"PartitionKey ge '{Tenant}_' and PartitionKey lt '{Tenant}_2026-06-07'", h.Filters);
|
||||
Assert.Contains("PartitionKey ge 'global_' and PartitionKey lt 'global_2026-06-07'", h.Filters);
|
||||
Assert.Equal(2, deleted);
|
||||
Assert.Contains(("2026-05-01", $"{Tenant}_ANALYZE-A"), h.Deleted);
|
||||
Assert.Contains(($"{Tenant}_2026-05-30", "ANALYZE-B"), h.Deleted);
|
||||
Assert.DoesNotContain(h.Deleted, d => d.Pk.StartsWith("2020a3f1"));
|
||||
}
|
||||
|
||||
// ---------- platform stats (D-198) ----------
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_stat_increment_merges_only_the_field_with_the_read_etag()
|
||||
{
|
||||
var table = new Mock<TableClient>();
|
||||
var row = new TableEntity("global", "current") { ["IssuesDetected"] = 5L, ["TotalEnrollments"] = 100L };
|
||||
row.ETag = new ETag("e9");
|
||||
table.Setup(t => t.GetEntityAsync<TableEntity>("global", "current", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(row, Mock.Of<Response>()));
|
||||
(TableEntity Entity, ETag ETag, TableUpdateMode Mode)? update = null;
|
||||
table.Setup(t => t.UpdateEntityAsync(It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, tag, mode, _) => { update = (e, tag, mode); return Task.FromResult(Mock.Of<Response>()); });
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(Constants.TableNames.PlatformStats)).Returns(table.Object);
|
||||
var sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
|
||||
await sut.IncrementPlatformStatAsync("IssuesDetected", 3);
|
||||
|
||||
Assert.NotNull(update);
|
||||
Assert.Equal(new ETag("e9"), update!.Value.ETag);
|
||||
Assert.Equal(TableUpdateMode.Merge, update.Value.Mode);
|
||||
Assert.Equal(8L, update.Value.Entity.GetInt64("IssuesDetected"));
|
||||
Assert.False(update.Value.Entity.ContainsKey("TotalEnrollments"), "the patch must not carry fields it did not change");
|
||||
}
|
||||
}
|
||||
@@ -106,10 +106,10 @@ public class SessionDeletionGuardTests
|
||||
.ReturnsAsync(row);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<SessionDeletionLockedException>(
|
||||
() => guard.EnsureWritableAsync(TenantId, SessionId, callerContext: "IndexReconcile"));
|
||||
() => guard.EnsureWritableAsync(TenantId, SessionId, callerContext: "AnalyzeQueue"));
|
||||
|
||||
Assert.Equal(SessionDeletionState.Running, ex.CurrentState);
|
||||
Assert.Equal("IndexReconcile", ex.CallerContext);
|
||||
Assert.Equal("AnalyzeQueue", ex.CallerContext);
|
||||
Assert.Equal("MANIFEST-ABC", ex.ManifestId);
|
||||
}
|
||||
|
||||
|
||||
@@ -752,15 +752,15 @@ public class SessionDeletionHandlerTests
|
||||
Rows = new List<DeletionRowDump> { new DeletionRowDump { Pk = $"{TenantId}_install_failed", Rk = $"6299999999999999_{SessionId}" } } },
|
||||
new DeletionStep { Order = 10, Table = Constants.TableNames.CveIndex, Class = DeletionStepClass.DiscriminatorPkRkExact, RowCount = 1,
|
||||
Rows = new List<DeletionRowDump> { new DeletionRowDump { Pk = $"{TenantId}_CVE-2024-0001", Rk = SessionId } } },
|
||||
new DeletionStep { Order = 11, Table = Constants.TableNames.SessionsByTerminal, Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
new DeletionStep { Order = 11, Table = "SessionsByTerminal", Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
Rows = new List<DeletionRowDump> { new DeletionRowDump { Pk = $"{TenantId}_failed", Rk = "rk-1" } } },
|
||||
new DeletionStep { Order = 12, Table = Constants.TableNames.SessionsByStage, Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
new DeletionStep { Order = 12, Table = "SessionsByStage", Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
Rows = new List<DeletionRowDump> { new DeletionRowDump { Pk = $"{TenantId}_AccountSetup", Rk = "rk-2" } } },
|
||||
new DeletionStep { Order = 13, Table = Constants.TableNames.DeadEndsByReason, Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 0,
|
||||
new DeletionStep { Order = 13, Table = "DeadEndsByReason", Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 0,
|
||||
Rows = new List<DeletionRowDump>() },
|
||||
new DeletionStep { Order = 14, Table = Constants.TableNames.ClassifierVerdictsByIdLevel, Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
new DeletionStep { Order = 14, Table = "ClassifierVerdictsByIdLevel", Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
Rows = new List<DeletionRowDump> { new DeletionRowDump { Pk = $"{TenantId}_clf_warn", Rk = "rk-3" } } },
|
||||
new DeletionStep { Order = 15, Table = Constants.TableNames.SignalsByKind, Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
new DeletionStep { Order = 15, Table = "SignalsByKind", Class = DeletionStepClass.DiscriminatorPkProp, RowCount = 1,
|
||||
Rows = new List<DeletionRowDump> { new DeletionRowDump { Pk = $"{TenantId}_hello", Rk = "rk-4" } } },
|
||||
new DeletionStep
|
||||
{
|
||||
|
||||
@@ -279,134 +279,6 @@ public class SessionsWireParityTests
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionSignals ---------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionSignalsResponse_matches_the_signal_log_shape()
|
||||
{
|
||||
var sessionId = "7aa20c11-0001-4b7c-a1d2-52f3aaaa0007";
|
||||
var signals = new List<SignalRecord>
|
||||
{
|
||||
new SignalRecord
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = sessionId,
|
||||
SessionSignalOrdinal = 1,
|
||||
SessionTraceOrdinal = 5,
|
||||
Kind = "EspPhaseObserved",
|
||||
KindSchemaVersion = 1,
|
||||
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 25, 0, DateTimeKind.Utc),
|
||||
SourceOrigin = "agent",
|
||||
PayloadJson = "{\"phase\":\"DeviceSetup\"}",
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
count = signals.Count,
|
||||
truncated = false,
|
||||
signals,
|
||||
},
|
||||
new GetSessionSignalsResponse
|
||||
{
|
||||
Success = true,
|
||||
SessionId = sessionId,
|
||||
Count = signals.Count,
|
||||
Truncated = false,
|
||||
Signals = signals,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionReducerVerification ---------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionReducerVerificationResponse_matches_the_report_shape()
|
||||
{
|
||||
var report = new ReducerVerificationReport
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "8bb31d22-0002-4b7c-a1d2-52f3aaaa0008",
|
||||
SignalCount = 12,
|
||||
TransitionCount = 11,
|
||||
StoredReducerVersion = "r-2026.08",
|
||||
CurrentReducerVersion = "r-2026.08",
|
||||
SignalOrdinalsContiguous = true,
|
||||
SignalOrdinalFirst = 1,
|
||||
SignalOrdinalLast = 12,
|
||||
StepIndicesContiguous = true,
|
||||
StepIndexFirst = 0,
|
||||
StepIndexLast = 10,
|
||||
SemanticReplayPerformed = true,
|
||||
SemanticReplayFinalStageMatches = true,
|
||||
ReplayedFinalStage = "Completed",
|
||||
Issues = new List<VerificationIssue>
|
||||
{
|
||||
new VerificationIssue { Severity = "Info", Kind = "empty_session", Message = "sample" },
|
||||
},
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
truncated = false,
|
||||
report,
|
||||
},
|
||||
new GetSessionReducerVerificationResponse
|
||||
{
|
||||
Success = true,
|
||||
Truncated = false,
|
||||
Report = report,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDecisionGraph ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void GetSessionDecisionGraphResponse_matches_the_graph_shape()
|
||||
{
|
||||
var projection = new DecisionGraphProjection
|
||||
{
|
||||
TenantId = "6a6a35a2-30b2-4f2f-9a1b-6d9f1a2b3c4d",
|
||||
SessionId = "9cc42e33-0003-4b7c-a1d2-52f3aaaa0009",
|
||||
Nodes = new List<DecisionGraphNode>
|
||||
{
|
||||
new DecisionGraphNode { Id = "EspInProgress", IsTerminal = false, VisitCount = 1 },
|
||||
},
|
||||
Edges = new List<DecisionGraphEdge>
|
||||
{
|
||||
new DecisionGraphEdge
|
||||
{
|
||||
StepIndex = 0,
|
||||
FromStage = "Registered",
|
||||
ToStage = "EspInProgress",
|
||||
Trigger = "EspPhaseObserved",
|
||||
Taken = true,
|
||||
SignalOrdinalRef = 1,
|
||||
OccurredAtUtc = new DateTime(2026, 8, 30, 11, 26, 0, DateTimeKind.Utc),
|
||||
},
|
||||
},
|
||||
ReducerVersion = "r-2026.08",
|
||||
};
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
success = true,
|
||||
truncated = false,
|
||||
graph = projection,
|
||||
},
|
||||
new GetSessionDecisionGraphResponse
|
||||
{
|
||||
Success = true,
|
||||
Truncated = false,
|
||||
Graph = projection,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- GetSessionDeletionsList ---------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -83,6 +83,12 @@ public sealed class SharedManifestParityTests
|
||||
["opsEventTypes"] = OpsEventTypes.All,
|
||||
// Telemetry stores behind the operator KQL proxy (query_backend_logs `source`).
|
||||
["logSources"] = LogQuerySources.All,
|
||||
// Every machine-readable `code` an error envelope (IApiErrorResponse) can carry: the
|
||||
// generic catalog first, then the domain code classes. Declaration order kept.
|
||||
["apiErrorCodes"] = ConstStrings(typeof(Constants.ApiErrorCodes))
|
||||
.Concat(ConstStrings(typeof(Constants.DelegationCodes)))
|
||||
.Concat(ConstStrings(typeof(Constants.DelegatedSlots)))
|
||||
.ToArray(),
|
||||
["tenantRoles"] = ConstStrings(typeof(Constants.TenantRoles)),
|
||||
["globalRoles"] = ConstStrings(typeof(Constants.GlobalRoles)),
|
||||
["delegatedRoles"] = ConstStrings(typeof(Constants.DelegatedRoles)),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.ApplicationInsights.Channel;
|
||||
using Microsoft.ApplicationInsights.DataContracts;
|
||||
@@ -39,6 +40,47 @@ public class StorageDependencyFilterProcessorTests
|
||||
Assert.Empty(next.Received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowSuccessfulStorageDependency_IsKeptAndStamped()
|
||||
{
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep("", success: true, type: "InProc | Microsoft.Tables");
|
||||
dep.Name = "TableClient.SubmitTransaction";
|
||||
dep.Duration = StorageDependencyFilterProcessor.SlowStorageThreshold;
|
||||
|
||||
processor.Process(dep);
|
||||
|
||||
var kept = Assert.Single(next.Received);
|
||||
Assert.Equal("true", ((DependencyTelemetry)kept).Properties[StorageDependencyFilterProcessor.SlowStoragePropertyKey]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FastSuccessfulStorageDependency_JustUnderThreshold_IsDropped()
|
||||
{
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep("myacct.table.core.windows.net", success: true);
|
||||
dep.Duration = StorageDependencyFilterProcessor.SlowStorageThreshold - TimeSpan.FromMilliseconds(1);
|
||||
|
||||
processor.Process(dep);
|
||||
|
||||
Assert.Empty(next.Received);
|
||||
Assert.False(dep.Properties.ContainsKey(StorageDependencyFilterProcessor.SlowStoragePropertyKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlowExpectedFailure_IsStillDropped()
|
||||
{
|
||||
// 412/409/404 are control flow whatever their duration; only successes get the slow pass.
|
||||
var (processor, next) = Build();
|
||||
var dep = Dep("myacct.table.core.windows.net", success: false);
|
||||
dep.ResultCode = "412";
|
||||
dep.Duration = TimeSpan.FromSeconds(10);
|
||||
|
||||
processor.Process(dep);
|
||||
|
||||
Assert.Empty(next.Received);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SuccessfulStorageDependency_WithNullSuccess_IsDropped()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using Azure;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class StorageErrorsTests
|
||||
{
|
||||
private static RequestFailedException Failure(int status, string? code = null)
|
||||
=> new RequestFailedException(status, "storage said no", code, null);
|
||||
|
||||
[Theory]
|
||||
[InlineData(408)]
|
||||
[InlineData(429)]
|
||||
[InlineData(500)]
|
||||
[InlineData(502)]
|
||||
[InlineData(503)]
|
||||
[InlineData(504)]
|
||||
public void Transient_statuses_are_transient(int status)
|
||||
=> Assert.True(StorageErrors.IsTransient(Failure(status)));
|
||||
|
||||
[Theory]
|
||||
[InlineData(400)]
|
||||
[InlineData(404)]
|
||||
[InlineData(409)]
|
||||
[InlineData(412)]
|
||||
[InlineData(413)]
|
||||
public void Client_side_statuses_are_not_transient(int status)
|
||||
=> Assert.False(StorageErrors.IsTransient(Failure(status)));
|
||||
|
||||
[Fact]
|
||||
public void Payload_too_large_by_status()
|
||||
=> Assert.True(StorageErrors.IsPayloadTooLarge(Failure(413)));
|
||||
|
||||
[Theory]
|
||||
[InlineData("RequestBodyTooLarge")]
|
||||
[InlineData("EntityTooLarge")]
|
||||
[InlineData("PropertyValueTooLarge")]
|
||||
public void Payload_too_large_by_error_code_even_with_a_400(string code)
|
||||
=> Assert.True(StorageErrors.IsPayloadTooLarge(Failure(400, code)));
|
||||
|
||||
[Fact]
|
||||
public void A_plain_400_is_not_payload_too_large()
|
||||
=> Assert.False(StorageErrors.IsPayloadTooLarge(Failure(400, "InvalidInput")));
|
||||
|
||||
[Fact]
|
||||
public void Already_exists_is_409_or_the_azurite_400_shape()
|
||||
{
|
||||
Assert.True(StorageErrors.IsAlreadyExists(Failure(409)));
|
||||
Assert.True(StorageErrors.IsAlreadyExists(Failure(400, "EntityAlreadyExists")));
|
||||
Assert.False(StorageErrors.IsAlreadyExists(Failure(400, "InvalidInput")));
|
||||
Assert.False(StorageErrors.IsAlreadyExists(Failure(412)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_is_never_classified()
|
||||
{
|
||||
Assert.False(StorageErrors.IsTransient(null!));
|
||||
Assert.False(StorageErrors.IsPayloadTooLarge(null!));
|
||||
Assert.False(StorageErrors.IsAlreadyExists(null!));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using Microsoft.ApplicationInsights;
|
||||
using Microsoft.ApplicationInsights.Extensibility;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class StorageMetricsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Without_a_client_every_call_is_a_no_op()
|
||||
{
|
||||
var metrics = new StorageMetrics(null);
|
||||
metrics.CasConflict("op", "Sessions", CasOutcome.Retried);
|
||||
metrics.CasConflict("op", "Sessions", CasOutcome.Exhausted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_a_client_the_metric_is_registered_once_and_accepts_values()
|
||||
{
|
||||
using var config = new TelemetryConfiguration { DisableTelemetry = true };
|
||||
var client = new TelemetryClient(config);
|
||||
|
||||
var metrics = new StorageMetrics(client);
|
||||
metrics.CasConflict("IncrementSessionEventCount", "Sessions", CasOutcome.Retried);
|
||||
metrics.CasConflict("IncrementSessionEventCount", "Sessions", CasOutcome.Exhausted);
|
||||
|
||||
// GetMetric returns the same pre-aggregator for the same identifier; a second wrapper
|
||||
// over the same client must not fail on re-registration.
|
||||
var again = new StorageMetrics(client);
|
||||
again.CasConflict("MutateTenantStat", "PlatformStats", CasOutcome.Retried);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class TableCasRetryTests
|
||||
{
|
||||
private sealed class Harness
|
||||
{
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<TableEntity> Added { get; } = new();
|
||||
public List<(TableEntity Entity, ETag ETag, TableUpdateMode Mode)> Updates { get; } = new();
|
||||
public Queue<Func<Response<TableEntity>>> Reads { get; } = new();
|
||||
public Queue<Func<Response>> UpdateBehaviors { get; } = new();
|
||||
public Queue<Func<Response>> AddBehaviors { get; } = new();
|
||||
|
||||
public Harness()
|
||||
{
|
||||
Table.Setup(t => t.GetEntityAsync<TableEntity>(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(() => Task.FromResult(Reads.Dequeue()()));
|
||||
Table.Setup(t => t.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, CancellationToken>((e, _) =>
|
||||
{
|
||||
Added.Add(e);
|
||||
return Task.FromResult(AddBehaviors.Count > 0 ? AddBehaviors.Dequeue()() : Mock.Of<Response>());
|
||||
});
|
||||
Table.Setup(t => t.UpdateEntityAsync(It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, tag, mode, _) =>
|
||||
{
|
||||
Updates.Add((e, tag, mode));
|
||||
return Task.FromResult(UpdateBehaviors.Count > 0 ? UpdateBehaviors.Dequeue()() : Mock.Of<Response>());
|
||||
});
|
||||
}
|
||||
|
||||
public static Response<TableEntity> Row(long value, string etag)
|
||||
{
|
||||
var e = new TableEntity("pk", "rk") { ["Counter"] = value };
|
||||
e.ETag = new ETag(etag);
|
||||
return Response.FromValue(e, Mock.Of<Response>());
|
||||
}
|
||||
|
||||
public static Func<Response<TableEntity>> Missing => () => throw new RequestFailedException(404, "not found");
|
||||
}
|
||||
|
||||
private static Task<bool> Increment(Harness h, int retries = TableCasRetry.DefaultRetries)
|
||||
=> TableCasRetry.MutateAsync(
|
||||
h.Table.Object, "pk", "rk",
|
||||
patch: read => new TableEntity("pk", "rk") { ["Counter"] = (read.GetInt64("Counter") ?? 0) + 1 },
|
||||
createMissing: () => new TableEntity("pk", "rk") { ["Counter"] = 1L },
|
||||
operation: "test", tableName: "T", metrics: null, logger: NullLogger.Instance, retries: retries);
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_row_is_patched_with_its_etag_as_a_merge()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Reads.Enqueue(() => Harness.Row(5, "e1"));
|
||||
|
||||
Assert.True(await Increment(h));
|
||||
|
||||
var (entity, etag, mode) = Assert.Single(h.Updates);
|
||||
Assert.Equal(6L, entity.GetInt64("Counter"));
|
||||
Assert.Equal(new ETag("e1"), etag);
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Empty(h.Added);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_row_is_created_with_add_not_upsert()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Reads.Enqueue(Harness.Missing);
|
||||
|
||||
Assert.True(await Increment(h));
|
||||
|
||||
Assert.Equal(1L, Assert.Single(h.Added).GetInt64("Counter"));
|
||||
Assert.Empty(h.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_creator_turns_the_add_into_a_retry_that_lands_in_the_update_branch()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Reads.Enqueue(Harness.Missing);
|
||||
h.AddBehaviors.Enqueue(() => throw new RequestFailedException(409, "exists", "EntityAlreadyExists", null));
|
||||
h.Reads.Enqueue(() => Harness.Row(1, "e2"));
|
||||
|
||||
Assert.True(await Increment(h));
|
||||
|
||||
Assert.Single(h.Added);
|
||||
Assert.Equal(2L, Assert.Single(h.Updates).Entity.GetInt64("Counter"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Etag_conflict_re_reads_and_retries()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Reads.Enqueue(() => Harness.Row(5, "e1"));
|
||||
h.UpdateBehaviors.Enqueue(() => throw new RequestFailedException(412, "precondition failed"));
|
||||
h.Reads.Enqueue(() => Harness.Row(9, "e2"));
|
||||
|
||||
Assert.True(await Increment(h));
|
||||
|
||||
Assert.Equal(2, h.Updates.Count);
|
||||
Assert.Equal(10L, h.Updates[1].Entity.GetInt64("Counter"));
|
||||
Assert.Equal(new ETag("e2"), h.Updates[1].ETag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retries_are_bounded()
|
||||
{
|
||||
var h = new Harness();
|
||||
for (var i = 0; i < 3; i++)
|
||||
{
|
||||
h.Reads.Enqueue(() => Harness.Row(1, "e"));
|
||||
h.UpdateBehaviors.Enqueue(() => throw new RequestFailedException(412, "precondition failed"));
|
||||
}
|
||||
|
||||
Assert.False(await Increment(h, retries: 3));
|
||||
Assert.Equal(3, h.Updates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task No_op_patch_writes_nothing()
|
||||
{
|
||||
var h = new Harness();
|
||||
h.Reads.Enqueue(() => Harness.Row(5, "e1"));
|
||||
|
||||
var landed = await TableCasRetry.MutateAsync(
|
||||
h.Table.Object, "pk", "rk",
|
||||
patch: _ => null,
|
||||
createMissing: () => new TableEntity("pk", "rk"),
|
||||
operation: "test", tableName: "T", metrics: null, logger: NullLogger.Instance);
|
||||
|
||||
Assert.False(landed);
|
||||
Assert.Empty(h.Updates);
|
||||
Assert.Empty(h.Added);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pure mapping tests for <see cref="TableIndexRepository"/>'s 5 <c>ToEntity</c> overloads
|
||||
/// (Plan §2.8, §M5.d). Transaction-batching + Azurite round-trip are out of scope here;
|
||||
/// this file verifies that every projected column and key lands on the entity as expected.
|
||||
/// </summary>
|
||||
public class TableIndexRepositoryMappingTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime Occurred =
|
||||
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
||||
|
||||
// ============================================================ SessionsByTerminal
|
||||
|
||||
[Fact]
|
||||
public void SessionsByTerminal_ToEntity_projects_keys_and_typed_columns()
|
||||
{
|
||||
var record = new SessionsByTerminalRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
TerminalStage = "WhiteGloveSealed",
|
||||
OccurredAtUtc = Occurred,
|
||||
StepIndex = 12,
|
||||
};
|
||||
|
||||
var entity = TableIndexRepository.ToEntity(record);
|
||||
|
||||
Assert.Equal($"{TenantId}_WhiteGloveSealed", entity.PartitionKey);
|
||||
Assert.Equal($"20260421140733_{SessionId}", entity.RowKey);
|
||||
Assert.Equal(TenantId, entity.GetString("TenantId"));
|
||||
Assert.Equal(SessionId, entity.GetString("SessionId"));
|
||||
Assert.Equal("WhiteGloveSealed", entity.GetString("TerminalStage"));
|
||||
Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc"));
|
||||
Assert.Equal(12, entity.GetInt32("StepIndex"));
|
||||
}
|
||||
|
||||
// ============================================================ SessionsByStage
|
||||
|
||||
[Fact]
|
||||
public void SessionsByStage_ToEntity_uses_padded_ticks_rowkey_for_lex_ordering()
|
||||
{
|
||||
var record = new SessionsByStageRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
Stage = "EspInProgress",
|
||||
LastUpdatedUtc = Occurred,
|
||||
StepIndex = 4,
|
||||
};
|
||||
|
||||
var entity = TableIndexRepository.ToEntity(record);
|
||||
|
||||
Assert.Equal($"{TenantId}_EspInProgress", entity.PartitionKey);
|
||||
Assert.Equal($"{Occurred.Ticks:D19}_{SessionId}", entity.RowKey);
|
||||
Assert.Equal("EspInProgress", entity.GetString("Stage"));
|
||||
Assert.Equal(Occurred, entity.GetDateTime("LastUpdatedUtc"));
|
||||
Assert.Equal(4, entity.GetInt32("StepIndex"));
|
||||
}
|
||||
|
||||
// ============================================================ DeadEndsByReason
|
||||
|
||||
[Fact]
|
||||
public void DeadEndsByReason_ToEntity_includes_attempted_stage_and_from_stage()
|
||||
{
|
||||
var record = new DeadEndsByReasonRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
DeadEndReason = "hybrid_reboot_gate_blocking",
|
||||
StepIndex = 7,
|
||||
FromStage = "EspInProgress",
|
||||
AttemptedToStage = "WhiteGloveSealed",
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var entity = TableIndexRepository.ToEntity(record);
|
||||
|
||||
Assert.Equal($"{TenantId}_hybrid_reboot_gate_blocking", entity.PartitionKey);
|
||||
Assert.Equal($"20260421140733_{SessionId}_000007", entity.RowKey);
|
||||
Assert.Equal("hybrid_reboot_gate_blocking", entity.GetString("DeadEndReason"));
|
||||
Assert.Equal(7, entity.GetInt32("StepIndex"));
|
||||
Assert.Equal("EspInProgress", entity.GetString("FromStage"));
|
||||
Assert.Equal("WhiteGloveSealed", entity.GetString("AttemptedToStage"));
|
||||
Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc"));
|
||||
}
|
||||
|
||||
// ============================================================ ClassifierVerdictsByIdLevel
|
||||
|
||||
[Fact]
|
||||
public void ClassifierVerdictsByIdLevel_ToEntity_combines_classifierId_and_level_in_pk()
|
||||
{
|
||||
var record = new ClassifierVerdictsByIdLevelRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
ClassifierId = "whiteglove-sealing",
|
||||
HypothesisLevel = "Weak",
|
||||
StepIndex = 5,
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var entity = TableIndexRepository.ToEntity(record);
|
||||
|
||||
Assert.Equal($"{TenantId}_whiteglove-sealing_Weak", entity.PartitionKey);
|
||||
Assert.Equal($"20260421140733_{SessionId}_000005", entity.RowKey);
|
||||
Assert.Equal("whiteglove-sealing", entity.GetString("ClassifierId"));
|
||||
Assert.Equal("Weak", entity.GetString("HypothesisLevel"));
|
||||
Assert.Equal(5, entity.GetInt32("StepIndex"));
|
||||
Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc"));
|
||||
}
|
||||
|
||||
// ============================================================ SignalsByKind
|
||||
|
||||
[Fact]
|
||||
public void SignalsByKind_ToEntity_carries_ordinal_backref_for_primary_row_hop()
|
||||
{
|
||||
var record = new SignalsByKindRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
SignalKind = "EspTerminalFailure",
|
||||
SessionSignalOrdinal = 987L,
|
||||
OccurredAtUtc = Occurred,
|
||||
SourceOrigin = "EspAndHelloTrackerAdapter",
|
||||
};
|
||||
|
||||
var entity = TableIndexRepository.ToEntity(record);
|
||||
|
||||
Assert.Equal($"{TenantId}_EspTerminalFailure", entity.PartitionKey);
|
||||
Assert.Equal($"20260421140733_{SessionId}_0000000987", entity.RowKey);
|
||||
Assert.Equal("EspTerminalFailure", entity.GetString("SignalKind"));
|
||||
Assert.Equal(987L, entity.GetInt64("SessionSignalOrdinal"));
|
||||
Assert.Equal(Occurred, entity.GetDateTime("OccurredAtUtc"));
|
||||
Assert.Equal("EspAndHelloTrackerAdapter", entity.GetString("SourceOrigin"));
|
||||
}
|
||||
|
||||
// ============================================================ Null-tolerance on optional string columns
|
||||
|
||||
[Fact]
|
||||
public void DeadEndsByReason_ToEntity_tolerates_null_strings_by_defaulting_to_empty()
|
||||
{
|
||||
// The repo is called from a queue worker; a malformed upstream message shouldn't NRE the mapper.
|
||||
var record = new DeadEndsByReasonRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
DeadEndReason = null!,
|
||||
StepIndex = 1,
|
||||
FromStage = null!,
|
||||
AttemptedToStage = null!,
|
||||
OccurredAtUtc = Occurred,
|
||||
};
|
||||
|
||||
var entity = TableIndexRepository.ToEntity(record);
|
||||
|
||||
Assert.Equal(string.Empty, entity.GetString("DeadEndReason"));
|
||||
Assert.Equal(string.Empty, entity.GetString("FromStage"));
|
||||
Assert.Equal(string.Empty, entity.GetString("AttemptedToStage"));
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,6 @@ public class TableLifecycleBucketTests
|
||||
[Constants.TableNames.OpsEvents] = "platform ops stream; time-retention only",
|
||||
[Constants.TableNames.ImeVersionHistory] = "permanent platform archive by design",
|
||||
[Constants.TableNames.ImePatternStats] = "permanent per-IME-version pattern statistic (PK=version); the drift baseline needs old versions",
|
||||
[Constants.TableNames.RuleStats] = "per-rule daily counters; time-retention only",
|
||||
[Constants.TableNames.OffboardingAudit] = "the audit trail OF the offboarding itself",
|
||||
[Constants.TableNames.TenantOffboardingCustomsArchive] = "operator-driven cleanup by design",
|
||||
[Constants.TableNames.BackupJobs] = "platform backup job log (PK='BackupJobs'); 365d retention",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TableStorageService.StoreEventsBatchAsync"/> after audit F07: byte-aware
|
||||
/// transactions, and a per-row fallback only for data-shaped rejections. Throttling, outages
|
||||
/// and oversize batches propagate so the ingest can answer 503 / 413 and the agent replays.
|
||||
/// </summary>
|
||||
public class TableStorageEventBatchTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static EnrollmentEvent Event(int i, int payloadChars = 0)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
if (payloadChars > 0) data["blob"] = new string('d', payloadChars);
|
||||
return new EnrollmentEvent
|
||||
{
|
||||
EventId = $"e{i:D4}",
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "test_event",
|
||||
Source = "test",
|
||||
Message = payloadChars > 0 ? new string('m', payloadChars) : $"message {i}",
|
||||
Timestamp = new DateTime(2026, 9, 5, 12, 0, 0, DateTimeKind.Utc).AddMilliseconds(i),
|
||||
ReceivedAt = DateTime.UtcNow,
|
||||
Sequence = i,
|
||||
Data = data,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transient_failure_propagates_without_per_row_fallback()
|
||||
{
|
||||
var h = new Harness { BatchBehavior = _ => throw new RequestFailedException(503, "busy", "ServerBusy", null) };
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.StoreEventsBatchAsync(Enumerable.Range(0, 5).Select(i => Event(i)).ToList()));
|
||||
|
||||
Assert.Single(h.SubmittedBatches);
|
||||
Assert.Empty(h.PerRowEventUpserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Oversize_batch_propagates_without_per_row_fallback()
|
||||
{
|
||||
var h = new Harness { BatchBehavior = _ => throw new RequestFailedException(413, "too big", "RequestBodyTooLarge", null) };
|
||||
|
||||
await Assert.ThrowsAsync<RequestFailedException>(() => h.Sut.StoreEventsBatchAsync(Enumerable.Range(0, 5).Select(i => Event(i)).ToList()));
|
||||
|
||||
Assert.Empty(h.PerRowEventUpserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Data_shaped_rejection_falls_back_to_per_row_writes()
|
||||
{
|
||||
var h = new Harness { BatchBehavior = _ => throw new RequestFailedException(400, "bad row", "InvalidInput", null) };
|
||||
var events = Enumerable.Range(0, 5).Select(i => Event(i)).ToList();
|
||||
|
||||
var stored = await h.Sut.StoreEventsBatchAsync(events);
|
||||
|
||||
Assert.Equal(5, stored.Count);
|
||||
Assert.Equal(5, h.PerRowEventUpserts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Payload_heavy_events_split_into_several_transactions_under_the_byte_budget()
|
||||
{
|
||||
// Each event carries ~30k chars in Message and ~30k in DataJson (truncation bound) ≈ 120 KB;
|
||||
// 40 of them exceed the 3.5 MB budget, so the writer must submit more than one transaction.
|
||||
var events = Enumerable.Range(0, 40).Select(i => Event(i, payloadChars: 40_000)).ToList();
|
||||
var h = new Harness();
|
||||
|
||||
var stored = await h.Sut.StoreEventsBatchAsync(events);
|
||||
|
||||
Assert.Equal(40, stored.Count);
|
||||
Assert.True(h.SubmittedBatches.Count >= 2, $"expected ≥2 transactions, got {h.SubmittedBatches.Count}");
|
||||
Assert.Equal(40, h.SubmittedBatches.Sum(b => b.Count));
|
||||
Assert.Empty(h.PerRowEventUpserts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Small_batches_stay_one_transaction_and_dedup_last_wins()
|
||||
{
|
||||
var h = new Harness();
|
||||
var a = Event(1); a.Message = "first";
|
||||
var b = Event(1); b.Message = "second"; // same Timestamp + Sequence → same RowKey
|
||||
|
||||
var stored = await h.Sut.StoreEventsBatchAsync(new List<EnrollmentEvent> { a, b, Event(2) });
|
||||
|
||||
Assert.Equal(2, stored.Count);
|
||||
var batch = Assert.Single(h.SubmittedBatches);
|
||||
Assert.Equal(2, batch.Count);
|
||||
Assert.Equal("second", ((TableEntity)batch[0].Entity).GetString("Message"));
|
||||
}
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public List<List<TableTransactionAction>> SubmittedBatches { get; } = new();
|
||||
public List<(string Pk, string Rk)> PerRowEventUpserts { get; } = new();
|
||||
public Func<List<TableTransactionAction>, Response<IReadOnlyList<Response>>>? BatchBehavior { get; set; }
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public Harness()
|
||||
{
|
||||
var table = new Mock<TableClient>();
|
||||
table
|
||||
.Setup(c => c.SubmitTransactionAsync(It.IsAny<IEnumerable<TableTransactionAction>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<IEnumerable<TableTransactionAction>, CancellationToken>((actions, _) =>
|
||||
{
|
||||
var snapshot = actions.ToList();
|
||||
SubmittedBatches.Add(snapshot);
|
||||
if (BatchBehavior != null) return Task.FromResult(BatchBehavior(snapshot));
|
||||
var inner = (IReadOnlyList<Response>)snapshot.Select(_ => new Mock<Response>().Object).ToList();
|
||||
return Task.FromResult(Response.FromValue(inner, new Mock<Response>().Object));
|
||||
});
|
||||
table
|
||||
.Setup(c => c.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, TableUpdateMode, CancellationToken>((entity, _, _) =>
|
||||
{
|
||||
// Event rows live in the composite {tenant}_{session} partition; the
|
||||
// EventSessionIndex side row uses the bare tenant id.
|
||||
if (entity.PartitionKey.Contains('_'))
|
||||
PerRowEventUpserts.Add((entity.PartitionKey, entity.RowKey));
|
||||
return Task.FromResult(new Mock<Response>().Object);
|
||||
});
|
||||
|
||||
var service = new Mock<TableServiceClient>();
|
||||
service.Setup(s => s.GetTableClient(It.IsAny<string>())).Returns(table.Object);
|
||||
Sut = new TableStorageService(service.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TableTransactionBatcher"/> is the one place that knows all three Azure Tables
|
||||
/// limits (100 actions, ~4 MiB per transaction, ~1 MiB per entity). These tests pin the split
|
||||
/// rules; the writers only iterate what it returns.
|
||||
/// </summary>
|
||||
public class TableTransactionBatcherTests
|
||||
{
|
||||
private static TableEntity Row(string pk, string rk, int payloadChars = 0)
|
||||
{
|
||||
var e = new TableEntity(pk, rk) { ["Kind"] = "x", ["Ordinal"] = 1L };
|
||||
if (payloadChars > 0) e["PayloadJson"] = new string('p', payloadChars);
|
||||
return e;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_caps_at_100_actions_per_transaction_and_preserves_order()
|
||||
{
|
||||
var rows = Enumerable.Range(0, 250).Select(i => Row("pk", $"r{i:D4}")).ToList();
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.UpsertReplace);
|
||||
|
||||
Assert.Equal(new[] { 100, 100, 50 }, batches.Select(b => b.Count).ToArray());
|
||||
Assert.All(batches.SelectMany(b => b), a => Assert.Equal(TableTransactionActionType.UpsertReplace, a.ActionType));
|
||||
Assert.Equal(rows.Select(r => r.RowKey), batches.SelectMany(b => b).Select(a => a.Entity.RowKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_starts_a_new_transaction_when_the_byte_budget_would_overflow()
|
||||
{
|
||||
// 200k chars ≈ 400 KB each → 8 fit under 3.5 MB, the 9th does not.
|
||||
var rows = Enumerable.Range(0, 20).Select(i => Row("pk", $"r{i:D2}", payloadChars: 200_000)).ToList();
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.UpsertReplace);
|
||||
|
||||
Assert.Equal(new[] { 8, 8, 4 }, batches.Select(b => b.Count).ToArray());
|
||||
Assert.All(batches, b => Assert.True(
|
||||
b.Sum(a => TableTransactionBatcher.EstimateEntityBytes((TableEntity)a.Entity)) <= TableTransactionBatcher.TransactionByteBudget));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_never_mixes_partitions_in_one_transaction()
|
||||
{
|
||||
var rows = new[] { Row("a", "1"), Row("a", "2"), Row("b", "1"), Row("a", "3") };
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.Add);
|
||||
|
||||
Assert.Equal(3, batches.Count);
|
||||
Assert.All(batches, b => Assert.Single(b.Select(a => a.Entity.PartitionKey).Distinct()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_throws_for_an_entity_that_can_never_fit()
|
||||
{
|
||||
var rows = new[] { Row("pk", "ok"), Row("pk", "huge", payloadChars: 600_000) };
|
||||
|
||||
var ex = Assert.Throws<TableEntityTooLargeException>(
|
||||
() => TableTransactionBatcher.Split(rows, TableTransactionActionType.UpsertReplace));
|
||||
|
||||
Assert.Equal("pk", ex.PartitionKey);
|
||||
Assert.Equal("huge", ex.RowKey);
|
||||
Assert.True(ex.EstimatedBytes > TableTransactionBatcher.EntityByteLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_honours_a_smaller_action_cap()
|
||||
{
|
||||
var rows = Enumerable.Range(0, 7).Select(i => Row("pk", $"r{i}")).ToList();
|
||||
|
||||
var batches = TableTransactionBatcher.Split(rows, TableTransactionActionType.Delete, maxActions: 3);
|
||||
|
||||
Assert.Equal(new[] { 3, 3, 1 }, batches.Select(b => b.Count).ToArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Split_of_nothing_is_nothing()
|
||||
{
|
||||
Assert.Empty(TableTransactionBatcher.Split(Array.Empty<TableEntity>(), TableTransactionActionType.UpsertReplace));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Estimate_counts_strings_at_utf16_width_binaries_at_base64_growth_and_scalars_flat()
|
||||
{
|
||||
var e = new TableEntity("pk", "rk")
|
||||
{
|
||||
["S"] = new string('s', 1000),
|
||||
["B"] = new byte[300],
|
||||
["I"] = 7,
|
||||
["L"] = 7L,
|
||||
["D"] = 1.5,
|
||||
["F"] = true,
|
||||
["T"] = DateTime.UtcNow,
|
||||
["G"] = Guid.NewGuid(),
|
||||
["N"] = null,
|
||||
};
|
||||
|
||||
var estimate = TableTransactionBatcher.EstimateEntityBytes(e);
|
||||
|
||||
var expected = 256 + 2 * 2 + 2 * 2 // envelope + keys
|
||||
+ (2 + 2000) // "S"
|
||||
+ (2 + 400) // "B": 300 bytes → 400 base64 chars
|
||||
+ 6 * (2 + 48) // I L D F T G
|
||||
+ (2 + 0); // N
|
||||
Assert.Equal(expected, estimate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Estimate_of_a_chunked_payload_counts_every_chunk_and_its_name()
|
||||
{
|
||||
var e = new TableEntity("pk", "rk");
|
||||
foreach (var kv in TableStorageChunking.ChunkProperty("PayloadJson", new string('x', 75_000)))
|
||||
e[kv.Key] = kv.Value;
|
||||
|
||||
var estimate = TableTransactionBatcher.EstimateEntityBytes(e);
|
||||
|
||||
Assert.True(estimate > 150_000, $"estimate {estimate} must cover 75k UTF-16 chars");
|
||||
Assert.True(estimate < TableTransactionBatcher.EntityByteLimit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Ingest;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The agent → backend wire contract of <c>POST /api/agent/telemetry</c>, proven from the
|
||||
/// READER side. The agent's <c>TelemetryWireContractTests</c> freezes the real batch body its
|
||||
/// serialiser produces (one item per <see cref="TelemetryItemKind"/>) under
|
||||
/// <c>tests/fixtures/telemetry-wire/</c>; every frozen body must deserialise and dispatch here
|
||||
/// with zero rejections. Old fixtures stay: a body an older agent line still sends must keep
|
||||
/// parsing after a backend-side change. Together the two suites make a rename on either side a
|
||||
/// test failure instead of a silently dropped item.
|
||||
/// </summary>
|
||||
public class TelemetryWireFixtureTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
private static readonly DateTime At = new(2026, 4, 20, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static string FixtureDir()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
dir = dir.Parent;
|
||||
Assert.NotNull(dir);
|
||||
return Path.Combine(dir!.FullName, "tests", "fixtures", "telemetry-wire");
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> FrozenBodies() =>
|
||||
Directory.EnumerateFiles(FixtureDir(), "*.json").OrderBy(p => p, StringComparer.Ordinal)
|
||||
.Select(p => new object[] { Path.GetFileName(p) });
|
||||
|
||||
[Fact]
|
||||
public void At_least_one_frozen_body_exists()
|
||||
{
|
||||
Assert.NotEmpty(FrozenBodies());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(FrozenBodies))]
|
||||
public async Task Frozen_agent_body_dispatches_every_kind_with_zero_rejections(string fileName)
|
||||
{
|
||||
var bytes = await File.ReadAllBytesAsync(Path.Combine(FixtureDir(), fileName));
|
||||
|
||||
var (exceeded, items) = await IngestTelemetryFunction.ReadBodyWithSizeCapAsync(new MemoryStream(bytes), 5 * 1024 * 1024);
|
||||
Assert.False(exceeded);
|
||||
Assert.NotNull(items);
|
||||
|
||||
var kinds = items!.Select(i => i.Kind).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var kind in TelemetryItemKinds.All)
|
||||
Assert.Contains(kind.ToString(), kinds);
|
||||
|
||||
var batch = IngestTelemetryFunction.PartitionBatch(items, TenantId, SessionId);
|
||||
|
||||
Assert.True(batch.Rejected.Count == 0,
|
||||
$"{fileName}: the ingest rejects items the agent serialiser produced — " +
|
||||
string.Join("; ", batch.Rejected.Select(r => $"id={r.TelemetryItemId} kind='{r.Kind}' cause={r.Cause}")));
|
||||
Assert.Single(batch.Events);
|
||||
Assert.Single(batch.Signals);
|
||||
Assert.Single(batch.Transitions);
|
||||
Assert.Equal(items.Count, batch.Events.Count + batch.Signals.Count + batch.Transitions.Count);
|
||||
}
|
||||
|
||||
// ============================================================ Dispatch is total
|
||||
|
||||
/// <summary>Minimal payload the parser accepts per kind — a new kind needs its sample here.</summary>
|
||||
private static string SamplePayload(TelemetryItemKind kind) => kind switch
|
||||
{
|
||||
TelemetryItemKind.Event => "{\"EventType\":\"agent_started\",\"Timestamp\":\"2026-04-20T10:00:00Z\"}",
|
||||
TelemetryItemKind.Signal => "{\"SessionSignalOrdinal\":1}",
|
||||
TelemetryItemKind.DecisionTransition => "{\"StepIndex\":1}",
|
||||
_ => throw new Xunit.Sdk.XunitException($"No sample payload for TelemetryItemKind.{kind} — add one here and an ingest arm in PartitionBatch"),
|
||||
};
|
||||
|
||||
private static TelemetryItemDto Dto(string kind, string payload, long id = 1) => new()
|
||||
{
|
||||
Kind = kind,
|
||||
PartitionKey = $"{TenantId}_{SessionId}",
|
||||
RowKey = $"rk-{id}",
|
||||
TelemetryItemId = id,
|
||||
SessionTraceOrdinal = id,
|
||||
PayloadJson = payload,
|
||||
EnqueuedAtUtc = At,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Every_defined_kind_has_an_ingest_arm()
|
||||
{
|
||||
var items = TelemetryItemKinds.All.Select((k, i) => Dto(k.ToString(), SamplePayload(k), i + 1)).ToList();
|
||||
|
||||
var batch = IngestTelemetryFunction.PartitionBatch(items, TenantId, SessionId);
|
||||
|
||||
Assert.True(batch.Rejected.Count == 0,
|
||||
"PartitionBatch has no arm for: " + string.Join(", ", batch.Rejected.Select(r => r.Kind)));
|
||||
Assert.Equal(items.Count, batch.Events.Count + batch.Signals.Count + batch.Transitions.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Bogus")]
|
||||
[InlineData("event")] // names are the contract — case matters
|
||||
[InlineData("0")] // ordinals are not
|
||||
[InlineData("")]
|
||||
public void Unknown_kind_is_rejected_not_dropped(string kind)
|
||||
{
|
||||
var items = new List<TelemetryItemDto> { Dto("Event", SamplePayload(TelemetryItemKind.Event), 1), Dto(kind, "{}", 2) };
|
||||
|
||||
var batch = IngestTelemetryFunction.PartitionBatch(items, TenantId, SessionId);
|
||||
|
||||
var rejected = Assert.Single(batch.Rejected);
|
||||
Assert.Equal(IngestTelemetryFunction.RejectionCause.UnknownKind, rejected.Cause);
|
||||
Assert.Equal("rk-2", rejected.RowKey);
|
||||
Assert.Equal(2, rejected.TelemetryItemId);
|
||||
Assert.Single(batch.Events);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Event", "")]
|
||||
[InlineData("Event", "not json")]
|
||||
[InlineData("Signal", "{}")] // SessionSignalOrdinal missing
|
||||
[InlineData("DecisionTransition", "{\"x\":1}")] // StepIndex missing
|
||||
public void Unparseable_payload_is_rejected_not_dropped(string kind, string payload)
|
||||
{
|
||||
var items = new List<TelemetryItemDto> { Dto(kind, payload, 7) };
|
||||
|
||||
var batch = IngestTelemetryFunction.PartitionBatch(items, TenantId, SessionId);
|
||||
|
||||
var rejected = Assert.Single(batch.Rejected);
|
||||
Assert.Equal(IngestTelemetryFunction.RejectionCause.UnparseablePayload, rejected.Cause);
|
||||
Assert.Equal(7, rejected.TelemetryItemId);
|
||||
Assert.Empty(batch.Events);
|
||||
Assert.Empty(batch.Signals);
|
||||
Assert.Empty(batch.Transitions);
|
||||
}
|
||||
|
||||
// ============================================================ Poison body
|
||||
|
||||
/// <summary>
|
||||
/// Byte-for-byte pin of the 422 body. The agent's <c>TelemetryWireContractTests</c> feeds this
|
||||
/// exact string to its uploader and expects an item-level poison result — change both together.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Poison_body_pins_the_shape_the_agent_parses()
|
||||
{
|
||||
var body = new TelemetryItemsRejectedResponse
|
||||
{
|
||||
Error = "2 of 3 telemetry item(s) cannot be ingested (unknown Kind or unusable payload); they were not stored.",
|
||||
CorrelationId = "cid-1",
|
||||
RejectedRowKeys = new List<string> { "rk-1", "rk-3" },
|
||||
Reason = "unknown_kind=1;unparseable=1",
|
||||
Received = 3,
|
||||
Rejected = 2,
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
"{\"error\":\"2 of 3 telemetry item(s) cannot be ingested (unknown Kind or unusable payload); they were not stored.\"," +
|
||||
"\"code\":\"TelemetryItemsRejected\",\"correlationId\":\"cid-1\",\"poison\":true,\"rejectedRowKeys\":[\"rk-1\",\"rk-3\"]," +
|
||||
"\"reason\":\"unknown_kind=1;unparseable=1\",\"received\":3,\"rejected\":2}",
|
||||
JsonSerializer.Serialize(body, ApiJsonOptions.Create()));
|
||||
Assert.Equal(Constants.ApiErrorCodes.TelemetryItemsRejected, body.Code);
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,11 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// match is a straight failure:
|
||||
/// - Regex A: <c>OkAsync/CreatedAsync/JsonAsync(new { ... })</c> through ResponseHelper
|
||||
/// (typed initializers <c>OkAsync(new SomeResponse { ... })</c> do not match),
|
||||
/// - Regex B: raw success <c>WriteAsJsonAsync(new { ... })</c>. Error bodies — first
|
||||
/// property <c>error</c>/<c>message</c>, or literal <c>success = false</c> — are
|
||||
/// tolerated and stay anonymous by design (one shape).
|
||||
/// - Regex B: raw <c>WriteAsJsonAsync(new { ... })</c> — since the error-envelope pass
|
||||
/// (2026-09) this covers ERROR bodies too: every non-2xx body is an
|
||||
/// <see cref="IApiErrorResponse"/> written by <c>ResponseHelper.ErrorAsync</c> /
|
||||
/// <c>ApiErrorWriter</c>, so the former error-shape tolerance is gone and
|
||||
/// <see cref="WriteBaseline"/> is empty for error bodies as well.
|
||||
/// Endpoints return typed DTOs implementing <see cref="IApiResponse"/>
|
||||
/// (AutopilotMonitor.Shared.Models, exported to TypeScript by SharedManifestParityTests),
|
||||
/// and each conversion carries an ordinal old-vs-new proof in the *WireParityTests files.
|
||||
@@ -41,9 +43,12 @@ public class TypedResponseGuardTests
|
||||
private static readonly Dictionary<string, int> HelperBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// EMPTY since 2026-08-31 — every anonymous success WriteAsJsonAsync body is typed (the
|
||||
/// migration started from the frozen 2026-08-30 baseline of 134 sites / 76 files). Any
|
||||
/// entry that would need to be ADDED here is a regression: return a typed DTO instead.
|
||||
/// EMPTY. Success bodies since 2026-08-31 (migrated from the frozen 2026-08-30 baseline of
|
||||
/// 134 sites / 76 files); error bodies since 2026-09-05, when the error envelope
|
||||
/// (<see cref="IApiErrorResponse"/>) landed and the 458 anonymous
|
||||
/// <c>{ success = false, message }</c> / <c>{ error }</c> sites in 149 files were migrated to
|
||||
/// <c>req.ErrorAsync</c> / <c>req.BadRequestAsync</c> etc. in the same pass. Any entry that
|
||||
/// would need to be ADDED here is a regression: use the typed helpers.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> WriteBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -73,20 +78,16 @@ public class TypedResponseGuardTests
|
||||
/// <summary>
|
||||
/// JsonSerializer.Serialize(new { ... }) sites that are NOT responses. EMPTY: the last entry (the
|
||||
/// App Insights query POST body) became a typed request record when the proxy learned sources.
|
||||
/// Everything must be a typed DTO (error shapes are tolerated by the shape check).
|
||||
/// Everything must be a typed DTO — error bodies included (IApiErrorResponse).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> SerializeBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// A Build*/Compute*/*Payload/*Response method declared object hides its wire shape from
|
||||
/// every type check. The single tolerated site is BuildV2ResponseBody: its SUCCESS arm is
|
||||
/// the typed <c>SessionDeletionQueuedResponse</c>, but the method returns a union with the
|
||||
/// anonymous ERROR arms (tolerated by design), so its declared type must stay object.
|
||||
/// every type check. EMPTY: the last entry (DeleteSession's success/error union) returns
|
||||
/// <see cref="IApiResponse"/> since the error arms became <c>SessionDeletionRejectedResponse</c>.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Functions/Sessions/DeleteSessionFunction.cs"] = 1,
|
||||
};
|
||||
private static readonly Dictionary<string, int> ObjectBuilderBaseline = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_helper_success_bodies_never_exceed_the_frozen_baseline()
|
||||
@@ -98,13 +99,12 @@ public class TypedResponseGuardTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_WriteAsJsonAsync_success_bodies_never_exceed_the_frozen_baseline()
|
||||
public void Anonymous_WriteAsJsonAsync_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => AnonymousWriteAsJson.Matches(text)
|
||||
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
|
||||
text => AnonymousWriteAsJson.Matches(text).Count,
|
||||
WriteBaseline,
|
||||
"anonymous success WriteAsJsonAsync(new { ... })");
|
||||
"anonymous WriteAsJsonAsync(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -116,22 +116,19 @@ public class TypedResponseGuardTests
|
||||
var ident = m.Groups[1].Value;
|
||||
if (ident == "new") return false;
|
||||
var assign = new Regex(@"(?:var\s+)?" + Regex.Escape(ident) + @"\s*=\s*new\s*\{");
|
||||
var a = assign.Match(text);
|
||||
// Tolerate error-shaped variables the same way inline literals are tolerated.
|
||||
return a.Success && !IsErrorShape(text, a.Index + a.Length);
|
||||
return assign.IsMatch(text);
|
||||
}),
|
||||
IdentifierBaseline,
|
||||
"WriteAsJsonAsync(<variable holding an anonymous object>)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anonymous_JsonSerializer_Serialize_success_bodies_never_exceed_the_frozen_baseline()
|
||||
public void Anonymous_JsonSerializer_Serialize_bodies_never_exceed_the_frozen_baseline()
|
||||
{
|
||||
AssertRatchet(
|
||||
text => SerializeAnonymous.Matches(text)
|
||||
.Count(m => !IsErrorShape(text, m.Index + m.Length)),
|
||||
text => SerializeAnonymous.Matches(text).Count,
|
||||
SerializeBaseline,
|
||||
"anonymous success JsonSerializer.Serialize(new { ... })");
|
||||
"anonymous JsonSerializer.Serialize(new { ... })");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,6 +170,34 @@ public class TypedResponseGuardTests
|
||||
+ string.Join("\n ", nonFlat));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The error envelope is a PREFIX contract: every <see cref="IApiErrorResponse"/> implementer
|
||||
/// (generic <see cref="ApiErrorResponse"/> and the specialised bodies alike) declares
|
||||
/// <c>Error</c>, <c>Code</c>, <c>CorrelationId</c> as its first three properties, in that
|
||||
/// order, so any consumer can read the envelope from any error body without knowing the
|
||||
/// specialised type. Declaration order == wire order (see the flatness fact above).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Every_IApiErrorResponse_implementer_starts_with_the_envelope_prefix()
|
||||
{
|
||||
var implementers = typeof(IApiResponse).Assembly.GetTypes()
|
||||
.Where(t => typeof(IApiErrorResponse).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract)
|
||||
.ToList();
|
||||
Assert.Contains(typeof(ApiErrorResponse), implementers);
|
||||
|
||||
var expected = new[] { nameof(IApiErrorResponse.Error), nameof(IApiErrorResponse.Code), nameof(IApiErrorResponse.CorrelationId) };
|
||||
var offenders = implementers
|
||||
.Select(t => (Type: t, Prefix: t.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||
.Select(p => p.Name).Take(3).ToArray()))
|
||||
.Where(x => !x.Prefix.SequenceEqual(expected))
|
||||
.Select(x => $"{x.Type.FullName}: [{string.Join(", ", x.Prefix)}]")
|
||||
.ToList();
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"IApiErrorResponse implementers must declare Error, Code, CorrelationId first (wire prefix):\n "
|
||||
+ string.Join("\n ", offenders));
|
||||
}
|
||||
|
||||
// ── Object-slot ratchet (closed 2026-08-31, typisierung follow-up) ──────────────────
|
||||
// Regexes A-E police the FUNCTION side; this one polices the DTOs themselves: a Shared
|
||||
// wire type carrying an `object` (or collection-of-object) property hides that slot's
|
||||
@@ -268,58 +293,6 @@ public class TypedResponseGuardTests
|
||||
return element;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Error shape = first property of the anonymous object is <c>error</c> or <c>message</c>
|
||||
/// (assigned or C# shorthand), or the literal <c>success = false</c>. Everything else —
|
||||
/// including <c>success = someExpression</c> (dual success/failure sites) — counts as a
|
||||
/// success body that must become a typed DTO.
|
||||
/// </summary>
|
||||
private static bool IsErrorShape(string text, int afterBraceIndex)
|
||||
{
|
||||
var i = SkipTrivia(text, afterBraceIndex);
|
||||
var start = i;
|
||||
while (i < text.Length && (char.IsLetterOrDigit(text[i]) || text[i] == '_'))
|
||||
i++;
|
||||
var identifier = text.Substring(start, i - start);
|
||||
|
||||
if (identifier is "error" or "message")
|
||||
return true;
|
||||
|
||||
if (identifier == "success")
|
||||
{
|
||||
i = SkipTrivia(text, i);
|
||||
if (i < text.Length && text[i] == '=' && (i + 1 >= text.Length || text[i + 1] != '='))
|
||||
{
|
||||
i = SkipTrivia(text, i + 1);
|
||||
if (string.CompareOrdinal(text, i, "false", 0, 5) == 0 &&
|
||||
(i + 5 >= text.Length || !char.IsLetterOrDigit(text[i + 5])))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int SkipTrivia(string text, int i)
|
||||
{
|
||||
while (i < text.Length)
|
||||
{
|
||||
if (char.IsWhiteSpace(text[i]))
|
||||
{
|
||||
i++;
|
||||
}
|
||||
else if (text[i] == '/' && i + 1 < text.Length && text[i + 1] == '/')
|
||||
{
|
||||
while (i < text.Length && text[i] != '\n') i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
private static void AssertRatchet(
|
||||
Func<string, int> countSites, Dictionary<string, int> baseline, string label)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Storage.Blobs;
|
||||
using Azure.Storage.Blobs.Models;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class UserDelegationKeyCacheTests
|
||||
{
|
||||
private static (UserDelegationKeyCache cache, Mock<BlobServiceClient> client, Func<DateTimeOffset> now, Action<TimeSpan> advance) Build()
|
||||
{
|
||||
var clock = new DateTimeOffset(2026, 9, 5, 12, 0, 0, TimeSpan.Zero);
|
||||
var client = new Mock<BlobServiceClient>();
|
||||
client
|
||||
.Setup(c => c.GetUserDelegationKeyAsync(It.IsAny<DateTimeOffset?>(), It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<DateTimeOffset?, DateTimeOffset, CancellationToken>((start, expires, _) =>
|
||||
{
|
||||
var key = BlobsModelFactory.UserDelegationKey("oid", "tid", start ?? clock, expires, "b", "2024-11-04", "c2lnbmVk");
|
||||
return Task.FromResult(Response.FromValue(key, Mock.Of<Response>()));
|
||||
});
|
||||
var cache = new UserDelegationKeyCache(client.Object, () => clock);
|
||||
return (cache, client, () => clock, delta => clock += delta);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Second_sas_reuses_the_key_instead_of_minting_again()
|
||||
{
|
||||
var (cache, client, now, _) = Build();
|
||||
|
||||
var first = await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
|
||||
var second = await cache.GetAsync(now() + TimeSpan.FromMinutes(60));
|
||||
|
||||
Assert.Same(first, second);
|
||||
client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny<DateTimeOffset?>(), It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Key_is_minted_for_the_full_lifetime_with_clock_skew_on_the_start()
|
||||
{
|
||||
var (cache, _, now, _) = Build();
|
||||
|
||||
var key = await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
|
||||
|
||||
Assert.Equal(now() - TimeSpan.FromMinutes(5), key.SignedStartsOn);
|
||||
Assert.Equal(now() + UserDelegationKeyCache.KeyLifetime, key.SignedExpiresOn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Key_is_re_minted_once_the_refresh_margin_is_reached()
|
||||
{
|
||||
var (cache, client, now, advance) = Build();
|
||||
await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
|
||||
|
||||
advance(UserDelegationKeyCache.KeyLifetime - UserDelegationKeyCache.RefreshMargin + TimeSpan.FromMinutes(1));
|
||||
await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
|
||||
|
||||
client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny<DateTimeOffset?>(), It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Key_is_re_minted_when_the_sas_would_outlive_it()
|
||||
{
|
||||
var (cache, client, now, _) = Build();
|
||||
await cache.GetAsync(now() + TimeSpan.FromMinutes(15));
|
||||
|
||||
await cache.GetAsync(now() + UserDelegationKeyCache.KeyLifetime + TimeSpan.FromMinutes(1));
|
||||
|
||||
client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny<DateTimeOffset?>(), It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Concurrent_first_callers_share_one_mint()
|
||||
{
|
||||
var (cache, client, now, _) = Build();
|
||||
|
||||
var tasks = new Task<UserDelegationKey>[8];
|
||||
for (var i = 0; i < tasks.Length; i++)
|
||||
tasks[i] = Task.Run(() => cache.GetAsync(now() + TimeSpan.FromMinutes(15)));
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
client.Verify(c => c.GetUserDelegationKeyAsync(It.IsAny<DateTimeOffset?>(), It.IsAny<DateTimeOffset>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -289,13 +289,4 @@ public class VerdictCalibrationTests
|
||||
Assert.Equal(0, json.GetProperty("totals").GetProperty("sessions").GetInt32());
|
||||
Assert.Equal(JsonValueKind.Null, json.GetProperty("computedAt").ValueKind);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 30)]
|
||||
[InlineData("abc", 30)]
|
||||
[InlineData("0", 1)]
|
||||
[InlineData("500", 180)]
|
||||
[InlineData("14", 14)]
|
||||
public void ClampDays_bounds_the_window(string? raw, int expected)
|
||||
=> Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw));
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// <summary>
|
||||
/// JSON round-trip tests for <see cref="VulnerabilityCorrelateEnvelope"/>. The producer
|
||||
/// (<c>AzureQueueVulnerabilityCorrelateProducer</c>) serializes with Newtonsoft and the
|
||||
/// queue worker (<c>VulnerabilityCorrelateQueueWorker</c>) deserializes the same shape —
|
||||
/// queue worker (<c>VulnerabilityCorrelateQueueFunction</c>) deserializes the same shape —
|
||||
/// pinning the contract here keeps the queue from silently breaking when fields are added.
|
||||
/// </summary>
|
||||
public class VulnerabilityCorrelateEnvelopeSerializationTests
|
||||
|
||||
@@ -46,7 +46,6 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
// V2 Decision Engine repositories (Plan §M5).
|
||||
services.AddSingleton<ISignalRepository, TableSignalRepository>();
|
||||
services.AddSingleton<IDecisionTransitionRepository, TableDecisionTransitionRepository>();
|
||||
services.AddSingleton<IIndexTableRepository, TableIndexRepository>();
|
||||
|
||||
// Tenant-offboarding audit (Marker / History / ByTenant pointer).
|
||||
services.AddSingleton<IOffboardingAuditRepository, TableOffboardingAuditRepository>();
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure PK/RK builders for the 5 V2 Decision Engine index tables
|
||||
/// (Plan §2.8 query matrix, §M5.d). State-less + zero-IO so every key shape is
|
||||
/// unit-testable against canned inputs without an Azurite harness.
|
||||
/// </summary>
|
||||
internal static class IndexRowKeys
|
||||
{
|
||||
private const string TimestampFormat = "yyyyMMddHHmmss";
|
||||
|
||||
// Azure Tables forbids these characters in PK/RK values. Discriminators are typically
|
||||
// enum names (safe ASCII); the sanitize step is defensive cover for free-form strings
|
||||
// like DeadEndReason that could in principle include a slash.
|
||||
private static readonly char[] ForbiddenKeyChars = { '/', '\\', '#', '?' };
|
||||
|
||||
// ============================================================ SessionsByTerminal
|
||||
|
||||
public static string BuildSessionsByTerminalPk(string tenantId, string terminalStage)
|
||||
=> $"{tenantId}_{Sanitize(terminalStage)}";
|
||||
|
||||
public static string BuildSessionsByTerminalRk(DateTime occurredAtUtc, string sessionId)
|
||||
=> $"{FormatTimestamp(occurredAtUtc)}_{sessionId}";
|
||||
|
||||
// ============================================================ SessionsByStage
|
||||
|
||||
public static string BuildSessionsByStagePk(string tenantId, string stage)
|
||||
=> $"{tenantId}_{Sanitize(stage)}";
|
||||
|
||||
/// <summary>
|
||||
/// Ticks are zero-padded to 19 digits (DateTime.MaxValue has 19-digit ticks) so
|
||||
/// lexicographic RK ordering matches numeric time order.
|
||||
/// </summary>
|
||||
public static string BuildSessionsByStageRk(DateTime lastUpdatedUtc, string sessionId)
|
||||
=> $"{lastUpdatedUtc.ToUniversalTime().Ticks.ToString("D19", CultureInfo.InvariantCulture)}_{sessionId}";
|
||||
|
||||
// ============================================================ DeadEndsByReason
|
||||
|
||||
public static string BuildDeadEndsByReasonPk(string tenantId, string deadEndReason)
|
||||
=> $"{tenantId}_{Sanitize(deadEndReason)}";
|
||||
|
||||
public static string BuildDeadEndsByReasonRk(DateTime occurredAtUtc, string sessionId, int stepIndex)
|
||||
=> $"{FormatTimestamp(occurredAtUtc)}_{sessionId}_{stepIndex.ToString("D6", CultureInfo.InvariantCulture)}";
|
||||
|
||||
// ============================================================ ClassifierVerdictsByIdLevel
|
||||
|
||||
public static string BuildClassifierVerdictsByIdLevelPk(string tenantId, string classifierId, string hypothesisLevel)
|
||||
=> $"{tenantId}_{Sanitize(classifierId)}_{Sanitize(hypothesisLevel)}";
|
||||
|
||||
public static string BuildClassifierVerdictsByIdLevelRk(DateTime occurredAtUtc, string sessionId, int stepIndex)
|
||||
=> $"{FormatTimestamp(occurredAtUtc)}_{sessionId}_{stepIndex.ToString("D6", CultureInfo.InvariantCulture)}";
|
||||
|
||||
// ============================================================ SignalsByKind
|
||||
|
||||
public static string BuildSignalsByKindPk(string tenantId, string signalKind)
|
||||
=> $"{tenantId}_{Sanitize(signalKind)}";
|
||||
|
||||
public static string BuildSignalsByKindRk(DateTime occurredAtUtc, string sessionId, long sessionSignalOrdinal)
|
||||
=> $"{FormatTimestamp(occurredAtUtc)}_{sessionId}_{sessionSignalOrdinal.ToString("D10", CultureInfo.InvariantCulture)}";
|
||||
|
||||
// ============================================================ Helpers
|
||||
|
||||
private static string FormatTimestamp(DateTime utc)
|
||||
=> utc.ToUniversalTime().ToString(TimestampFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Replaces characters Azure Tables disallows in PK/RK values with <c>_</c>. Enum-derived
|
||||
/// discriminators never trigger this; it's defensive cover for free-form strings.
|
||||
/// </summary>
|
||||
internal static string Sanitize(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
if (value.IndexOfAny(ForbiddenKeyChars) < 0) return value;
|
||||
|
||||
var buffer = new char[value.Length];
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var c = value[i];
|
||||
buffer[i] = Array.IndexOf(ForbiddenKeyChars, c) >= 0 ? '_' : c;
|
||||
}
|
||||
return new string(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Key layout of the <c>RuleStats</c> table (D-199): PartitionKey <c>{scope}_{yyyy-MM-dd}</c>
|
||||
/// where scope is a tenant id or <see cref="GlobalScope"/>, RowKey = ruleId. One partition
|
||||
/// per scope and day: the writer folds a whole analysis into one read plus one transaction,
|
||||
/// every reader is a PartitionKey range, and tenant offboarding wipes <c>{tenantId}_*</c>
|
||||
/// like every other composite-key table.
|
||||
/// <para>
|
||||
/// Rows written before the cutover use the legacy layout (PartitionKey = date, RowKey =
|
||||
/// <c>{scope}_{ruleId}</c>). They age out through the 90-day retention; until
|
||||
/// <see cref="LegacyLayoutUntil"/> the readers and the cleanup consult both layouts, after
|
||||
/// that date the legacy branches are dead code and can be removed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class RuleStatsKeys
|
||||
{
|
||||
public const string GlobalScope = "global";
|
||||
|
||||
/// <summary>First day on which no legacy row can exist any more (cutover 2026-09-06 + 90 days retention).</summary>
|
||||
public static readonly DateTime LegacyLayoutUntil = new(2026, 12, 5, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public static string PartitionKey(string scope, string date) => $"{scope}_{date}";
|
||||
|
||||
public static string RowKey(string ruleId) => ruleId;
|
||||
|
||||
public static bool LegacyLayoutActive(DateTime utcNow) => utcNow < LegacyLayoutUntil;
|
||||
|
||||
/// <summary>A bare <c>yyyy-MM-dd</c> PartitionKey is a legacy row.</summary>
|
||||
public static bool IsLegacyPartitionKey(string? partitionKey)
|
||||
{
|
||||
if (partitionKey == null || partitionKey.Length != 10) return false;
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
var c = partitionKey[i];
|
||||
if (i == 4 || i == 7) { if (c != '-') return false; }
|
||||
else if (c < '0' || c > '9') return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Scope, date and rule id of a row in either layout.</summary>
|
||||
public static (string Scope, string Date, string RuleId) Parse(string partitionKey, string rowKey)
|
||||
{
|
||||
if (IsLegacyPartitionKey(partitionKey))
|
||||
{
|
||||
var idx = rowKey.IndexOf('_');
|
||||
return idx > 0
|
||||
? (rowKey.Substring(0, idx), partitionKey, rowKey.Substring(idx + 1))
|
||||
: (rowKey, partitionKey, string.Empty);
|
||||
}
|
||||
|
||||
var split = partitionKey.LastIndexOf('_');
|
||||
return split > 0
|
||||
? (partitionKey.Substring(0, split), partitionKey.Substring(split + 1), rowKey)
|
||||
: (partitionKey, string.Empty, rowKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-rule sum of a batch of <see cref="RuleStatIncrement"/>s: what one transaction
|
||||
/// adds to a row. Metadata comes from the last increment of the rule (title/category/
|
||||
/// severity are refreshed on every write, as before).
|
||||
/// </summary>
|
||||
internal sealed record RuleStatDelta(
|
||||
string RuleId,
|
||||
string RuleType,
|
||||
string RuleTitle,
|
||||
string Category,
|
||||
string Severity,
|
||||
int Evaluations,
|
||||
int Fires,
|
||||
long ConfidenceScoreSum)
|
||||
{
|
||||
public static List<RuleStatDelta> Merge(IEnumerable<RuleStatIncrement> increments)
|
||||
{
|
||||
var byRule = new Dictionary<string, RuleStatDelta>(StringComparer.Ordinal);
|
||||
foreach (var inc in increments)
|
||||
{
|
||||
var fires = inc.Fired ? 1 : 0;
|
||||
var confidence = inc.Fired && inc.ConfidenceScore.HasValue ? inc.ConfidenceScore.Value : 0L;
|
||||
byRule[inc.RuleId] = byRule.TryGetValue(inc.RuleId, out var existing)
|
||||
? existing with
|
||||
{
|
||||
RuleTitle = inc.RuleTitle,
|
||||
Category = inc.Category,
|
||||
Severity = inc.Severity,
|
||||
Evaluations = existing.Evaluations + 1,
|
||||
Fires = existing.Fires + fires,
|
||||
ConfidenceScoreSum = existing.ConfidenceScoreSum + confidence,
|
||||
}
|
||||
: new RuleStatDelta(inc.RuleId, inc.RuleType, inc.RuleTitle, inc.Category, inc.Severity, 1, fires, confidence);
|
||||
}
|
||||
return byRule.Values.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
-2
@@ -901,7 +901,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "WhiteGloveSealingPatternIdsJson", config.WhiteGloveSealingPatternIdsJson ?? string.Empty },
|
||||
{ "NvdApiKey", config.NvdApiKey },
|
||||
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
|
||||
{ "EnableIndexDualWrite", config.EnableIndexDualWrite },
|
||||
{ "AutoApproveNewTenants", config.AutoApproveNewTenants },
|
||||
{ "ImeMsiArchivingEnabled", config.ImeMsiArchivingEnabled },
|
||||
{ "MaxImeMsiDownloadSizeMB", config.MaxImeMsiDownloadSizeMB },
|
||||
@@ -973,7 +972,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
WhiteGloveSealingPatternIdsJson = entity.GetString("WhiteGloveSealingPatternIdsJson"),
|
||||
NvdApiKey = entity.GetString("NvdApiKey"),
|
||||
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
|
||||
EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false,
|
||||
AutoApproveNewTenants = entity.GetBoolean("AutoApproveNewTenants") ?? false,
|
||||
ImeMsiArchivingEnabled = entity.GetBoolean("ImeMsiArchivingEnabled") ?? true,
|
||||
MaxImeMsiDownloadSizeMB = entity.GetInt32("MaxImeMsiDownloadSizeMB") ?? 250,
|
||||
|
||||
+4
-63
@@ -20,8 +20,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// </summary>
|
||||
public sealed class TableDecisionTransitionRepository : IDecisionTransitionRepository
|
||||
{
|
||||
/// <summary>Azure Table Storage limit per entity-group-transaction.</summary>
|
||||
internal const int TransactionChunkSize = 100;
|
||||
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly ILogger<TableDecisionTransitionRepository> _logger;
|
||||
@@ -60,18 +58,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"DecisionTransitions: dropped {Dropped} duplicate-RowKey row(s) (last-wins) for {Tenant}_{Session} — agent likely replayed overlapping StepIndex",
|
||||
dropped, group.Key.TenantId, group.Key.SessionId);
|
||||
|
||||
for (var offset = 0; offset < deduped.Count; offset += TransactionChunkSize)
|
||||
// Byte-aware batching (100 actions / 3.5 MB per transaction / 1 MB per entity);
|
||||
// storage failures propagate classified — the ingest maps them to 413 / 503 / 500.
|
||||
foreach (var actions in TableTransactionBatcher.Split(deduped, TableTransactionActionType.UpsertReplace))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunk = deduped.Skip(offset).Take(TransactionChunkSize).ToList();
|
||||
var actions = chunk
|
||||
.Select(e => new TableTransactionAction(
|
||||
TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false);
|
||||
committed += chunk.Count;
|
||||
committed += actions.Count;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -82,58 +75,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return committed;
|
||||
}
|
||||
|
||||
public async Task<List<DecisionTransitionRecord>> 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.DecisionTransitions);
|
||||
var pk = BuildPartitionKey(tenantId, sessionId);
|
||||
|
||||
var results = new List<DecisionTransitionRecord>(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));
|
||||
}
|
||||
|
||||
results.Sort((a, b) => a.StepIndex.CompareTo(b.StepIndex));
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<List<DecisionTransitionRecord>> QueryByTimestampAtOrAfterAsync(
|
||||
DateTime cutoffUtc, int maxResults = 50_000, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var table = _storage.GetTableClient(Constants.TableNames.DecisionTransitions);
|
||||
var filter = $"Timestamp ge datetime'{cutoffUtc.ToUniversalTime():yyyy-MM-ddTHH:mm:ss.fffffffZ}'";
|
||||
|
||||
var results = new List<DecisionTransitionRecord>(capacity: 128);
|
||||
var pages = table.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: 1000,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await foreach (var entity in pages.ConfigureAwait(false))
|
||||
{
|
||||
if (results.Count >= maxResults)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"DecisionTransitions: time-range query reached maxResults cap {Cap} at cutoff {Cutoff:o} — narrow window or bump cap",
|
||||
maxResults, cutoffUtc);
|
||||
break;
|
||||
}
|
||||
results.Add(FromEntity(entity));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Projects an Azure <see cref="TableEntity"/> back into a <see cref="DecisionTransitionRecord"/>,
|
||||
/// reassembling chunked <c>PayloadJson</c> if present. Internal for mapping tests.
|
||||
|
||||
-175
@@ -1,175 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Azure Tables implementation of <see cref="IIndexTableRepository"/> (Plan §2.8, §M5.d).
|
||||
/// One class covers all 5 index tables — each table's projection lives in a dedicated
|
||||
/// <c>ToEntity</c> overload, while the shared batched-transaction write path
|
||||
/// (group-by-PK, 100-op chunk) mirrors <see cref="TableSignalRepository"/>.
|
||||
/// </summary>
|
||||
public sealed class TableIndexRepository : IIndexTableRepository
|
||||
{
|
||||
/// <summary>Azure Table Storage limit per entity-group-transaction.</summary>
|
||||
internal const int TransactionChunkSize = 100;
|
||||
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly ILogger<TableIndexRepository> _logger;
|
||||
|
||||
public TableIndexRepository(TableStorageService storage, ILogger<TableIndexRepository> logger)
|
||||
{
|
||||
_storage = storage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ============================================================ public Store methods
|
||||
|
||||
public Task<int> StoreSessionsByTerminalAsync(
|
||||
IReadOnlyList<SessionsByTerminalRecord> records,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> StoreAsync(Constants.TableNames.SessionsByTerminal, records, ToEntity, cancellationToken);
|
||||
|
||||
public Task<int> StoreSessionsByStageAsync(
|
||||
IReadOnlyList<SessionsByStageRecord> records,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> StoreAsync(Constants.TableNames.SessionsByStage, records, ToEntity, cancellationToken);
|
||||
|
||||
public Task<int> StoreDeadEndsByReasonAsync(
|
||||
IReadOnlyList<DeadEndsByReasonRecord> records,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> StoreAsync(Constants.TableNames.DeadEndsByReason, records, ToEntity, cancellationToken);
|
||||
|
||||
public Task<int> StoreClassifierVerdictsByIdLevelAsync(
|
||||
IReadOnlyList<ClassifierVerdictsByIdLevelRecord> records,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> StoreAsync(Constants.TableNames.ClassifierVerdictsByIdLevel, records, ToEntity, cancellationToken);
|
||||
|
||||
public Task<int> StoreSignalsByKindAsync(
|
||||
IReadOnlyList<SignalsByKindRecord> records,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> StoreAsync(Constants.TableNames.SignalsByKind, records, ToEntity, cancellationToken);
|
||||
|
||||
// ============================================================ shared write path
|
||||
|
||||
private async Task<int> StoreAsync<T>(
|
||||
string tableName,
|
||||
IReadOnlyList<T> records,
|
||||
Func<T, TableEntity> toEntity,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (records is null || records.Count == 0) return 0;
|
||||
|
||||
var entities = new List<TableEntity>(records.Count);
|
||||
foreach (var r in records)
|
||||
{
|
||||
var entity = toEntity(r);
|
||||
SecurityValidator.EnsureValidGuid(entity.GetString("TenantId"), "TenantId");
|
||||
SecurityValidator.EnsureValidGuid(entity.GetString("SessionId"), "SessionId");
|
||||
entities.Add(entity);
|
||||
}
|
||||
|
||||
var table = _storage.GetTableClient(tableName);
|
||||
var committed = 0;
|
||||
|
||||
foreach (var group in entities.GroupBy(e => e.PartitionKey))
|
||||
{
|
||||
// Collapse duplicate RowKeys before building the transaction — Azure Tables
|
||||
// rejects the whole batch with InvalidDuplicateRow otherwise. Dedup also yields
|
||||
// RowKey ordering, replacing the previous explicit OrderBy.
|
||||
var (deduped, dropped) = TableBatchDedup.ByRowKey(group);
|
||||
if (dropped > 0)
|
||||
_logger.LogWarning(
|
||||
"{Table}: dropped {Dropped} duplicate-RowKey index row(s) (last-wins) for partition {Pk}",
|
||||
tableName, dropped, group.Key);
|
||||
|
||||
for (var offset = 0; offset < deduped.Count; offset += TransactionChunkSize)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunk = deduped.Skip(offset).Take(TransactionChunkSize).ToList();
|
||||
var actions = chunk
|
||||
.Select(e => new TableTransactionAction(TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false);
|
||||
committed += chunk.Count;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("{Table}: committed {Count} index rows", tableName, committed);
|
||||
return committed;
|
||||
}
|
||||
|
||||
// ============================================================ per-record entity mapping
|
||||
|
||||
internal static TableEntity ToEntity(SessionsByTerminalRecord r) => new(
|
||||
IndexRowKeys.BuildSessionsByTerminalPk(r.TenantId, r.TerminalStage),
|
||||
IndexRowKeys.BuildSessionsByTerminalRk(r.OccurredAtUtc, r.SessionId))
|
||||
{
|
||||
["TenantId"] = r.TenantId,
|
||||
["SessionId"] = r.SessionId,
|
||||
["TerminalStage"] = r.TerminalStage ?? string.Empty,
|
||||
["OccurredAtUtc"] = r.OccurredAtUtc,
|
||||
["StepIndex"] = r.StepIndex,
|
||||
};
|
||||
|
||||
internal static TableEntity ToEntity(SessionsByStageRecord r) => new(
|
||||
IndexRowKeys.BuildSessionsByStagePk(r.TenantId, r.Stage),
|
||||
IndexRowKeys.BuildSessionsByStageRk(r.LastUpdatedUtc, r.SessionId))
|
||||
{
|
||||
["TenantId"] = r.TenantId,
|
||||
["SessionId"] = r.SessionId,
|
||||
["Stage"] = r.Stage ?? string.Empty,
|
||||
["LastUpdatedUtc"] = r.LastUpdatedUtc,
|
||||
["StepIndex"] = r.StepIndex,
|
||||
};
|
||||
|
||||
internal static TableEntity ToEntity(DeadEndsByReasonRecord r) => new(
|
||||
IndexRowKeys.BuildDeadEndsByReasonPk(r.TenantId, r.DeadEndReason),
|
||||
IndexRowKeys.BuildDeadEndsByReasonRk(r.OccurredAtUtc, r.SessionId, r.StepIndex))
|
||||
{
|
||||
["TenantId"] = r.TenantId,
|
||||
["SessionId"] = r.SessionId,
|
||||
["DeadEndReason"] = r.DeadEndReason ?? string.Empty,
|
||||
["StepIndex"] = r.StepIndex,
|
||||
["FromStage"] = r.FromStage ?? string.Empty,
|
||||
["AttemptedToStage"] = r.AttemptedToStage ?? string.Empty,
|
||||
["OccurredAtUtc"] = r.OccurredAtUtc,
|
||||
};
|
||||
|
||||
internal static TableEntity ToEntity(ClassifierVerdictsByIdLevelRecord r) => new(
|
||||
IndexRowKeys.BuildClassifierVerdictsByIdLevelPk(r.TenantId, r.ClassifierId, r.HypothesisLevel),
|
||||
IndexRowKeys.BuildClassifierVerdictsByIdLevelRk(r.OccurredAtUtc, r.SessionId, r.StepIndex))
|
||||
{
|
||||
["TenantId"] = r.TenantId,
|
||||
["SessionId"] = r.SessionId,
|
||||
["ClassifierId"] = r.ClassifierId ?? string.Empty,
|
||||
["HypothesisLevel"] = r.HypothesisLevel ?? string.Empty,
|
||||
["StepIndex"] = r.StepIndex,
|
||||
["OccurredAtUtc"] = r.OccurredAtUtc,
|
||||
};
|
||||
|
||||
internal static TableEntity ToEntity(SignalsByKindRecord r) => new(
|
||||
IndexRowKeys.BuildSignalsByKindPk(r.TenantId, r.SignalKind),
|
||||
IndexRowKeys.BuildSignalsByKindRk(r.OccurredAtUtc, r.SessionId, r.SessionSignalOrdinal))
|
||||
{
|
||||
["TenantId"] = r.TenantId,
|
||||
["SessionId"] = r.SessionId,
|
||||
["SignalKind"] = r.SignalKind ?? string.Empty,
|
||||
["SessionSignalOrdinal"] = r.SessionSignalOrdinal,
|
||||
["OccurredAtUtc"] = r.OccurredAtUtc,
|
||||
["SourceOrigin"] = r.SourceOrigin ?? string.Empty,
|
||||
};
|
||||
}
|
||||
}
|
||||
+9
-6
@@ -101,9 +101,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<List<MetricsSummaryTenantItem>> GetMetricsSummaryAsync(string? tenantId, int days = 30)
|
||||
=> _storage.GetMetricsSummaryAsync(tenantId, days);
|
||||
|
||||
public Task IncrementRuleStatAsync(string date, string tenantId, string ruleId, string ruleType,
|
||||
string ruleTitle, string category, string severity, bool fired, int? confidenceScore)
|
||||
=> _storage.IncrementRuleStatAsync(date, tenantId, ruleId, ruleType, ruleTitle, category, severity, fired, confidenceScore);
|
||||
public Task RecordRuleStatsAsync(string date, string scope, IReadOnlyList<RuleStatIncrement> increments)
|
||||
=> _storage.RecordRuleStatsAsync(date, scope, increments);
|
||||
|
||||
public Task<bool> SaveRuleStatsEntryAsync(RuleStatsEntry entry)
|
||||
=> _storage.SaveRuleStatsEntryAsync(entry);
|
||||
@@ -117,12 +116,16 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<bool> TryMarkImePatternDriftFlaggedAsync(string imeVersion, string patternId, DateTime nowUtc)
|
||||
=> _storage.TryMarkImePatternDriftFlaggedAsync(imeVersion, patternId, nowUtc);
|
||||
|
||||
public Task<List<RuleStatsEntry>> GetRuleStatsAsync(string? tenantId = null, string? startDate = null,
|
||||
public Task<List<RuleStatsEntry>> GetRuleStatsAsync(string tenantId, string? startDate = null,
|
||||
string? endDate = null, string? ruleType = null, int maxResults = 10000)
|
||||
=> _storage.GetRuleStatsAsync(tenantId, startDate, endDate, ruleType, maxResults);
|
||||
|
||||
public Task<int> DeleteRuleStatsOlderThanAsync(DateTime cutoffDate)
|
||||
=> _storage.DeleteRuleStatsOlderThanAsync(cutoffDate);
|
||||
public Task<List<RuleStatsEntry>> GetRuleStatsForTenantsAsync(IReadOnlyCollection<string> tenantIds, string? startDate = null,
|
||||
string? endDate = null, string? ruleType = null, int maxResultsPerTenant = 10000)
|
||||
=> _storage.GetRuleStatsForTenantsAsync(tenantIds, startDate, endDate, ruleType, maxResultsPerTenant);
|
||||
|
||||
public Task<int> DeleteRuleStatsOlderThanAsync(DateTime cutoffDate, IReadOnlyCollection<string> tenantIds)
|
||||
=> _storage.DeleteRuleStatsOlderThanAsync(cutoffDate, tenantIds);
|
||||
|
||||
public Task<SessionTimeBreakdown?> ComputeAndStoreSessionTimeBreakdownAsync(string tenantId, string sessionId)
|
||||
=> _storage.ComputeAndStoreSessionTimeBreakdownAsync(tenantId, sessionId);
|
||||
|
||||
+4
-76
@@ -20,9 +20,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// </summary>
|
||||
public sealed class TableSignalRepository : ISignalRepository
|
||||
{
|
||||
/// <summary>Azure Table Storage limit per entity-group-transaction.</summary>
|
||||
internal const int TransactionChunkSize = 100;
|
||||
|
||||
private readonly TableStorageService _storage;
|
||||
private readonly ILogger<TableSignalRepository> _logger;
|
||||
|
||||
@@ -59,18 +56,13 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
"Signals: dropped {Dropped} duplicate-RowKey row(s) (last-wins) for {Tenant}_{Session} — agent likely replayed overlapping SessionSignalOrdinal",
|
||||
dropped, group.Key.TenantId, group.Key.SessionId);
|
||||
|
||||
for (var offset = 0; offset < deduped.Count; offset += TransactionChunkSize)
|
||||
// Byte-aware batching (100 actions / 3.5 MB per transaction / 1 MB per entity);
|
||||
// storage failures propagate classified — the ingest maps them to 413 / 503 / 500.
|
||||
foreach (var actions in TableTransactionBatcher.Split(deduped, TableTransactionActionType.UpsertReplace))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var chunk = deduped.Skip(offset).Take(TransactionChunkSize).ToList();
|
||||
var actions = chunk
|
||||
.Select(e => new TableTransactionAction(
|
||||
TableTransactionActionType.UpsertReplace, e))
|
||||
.ToList();
|
||||
|
||||
await table.SubmitTransactionAsync(actions, cancellationToken).ConfigureAwait(false);
|
||||
committed += chunk.Count;
|
||||
committed += actions.Count;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
@@ -81,70 +73,6 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return committed;
|
||||
}
|
||||
|
||||
public async Task<List<SignalRecord>> QueryBySessionAsync(
|
||||
string tenantId,
|
||||
string sessionId,
|
||||
int maxResults = 1000,
|
||||
CancellationToken cancellationToken = default,
|
||||
long maxTotalPayloadChars = SignalQueryLimits.DefaultMaxTotalPayloadChars)
|
||||
{
|
||||
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);
|
||||
|
||||
long totalPayloadChars = 0;
|
||||
await foreach (var entity in pages.ConfigureAwait(false))
|
||||
{
|
||||
if (results.Count >= maxResults || totalPayloadChars >= maxTotalPayloadChars) break;
|
||||
var record = FromEntity(entity);
|
||||
results.Add(record);
|
||||
totalPayloadChars += record.PayloadJson.Length;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
public async Task<List<SignalRecord>> QueryByTimestampAtOrAfterAsync(
|
||||
DateTime cutoffUtc, int maxResults = 50_000, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var table = _storage.GetTableClient(Constants.TableNames.Signals);
|
||||
// Timestamp is the Azure Tables system-managed server-side property. Filter shape
|
||||
// follows OData datetime literal format.
|
||||
var filter = $"Timestamp ge datetime'{cutoffUtc.ToUniversalTime():yyyy-MM-ddTHH:mm:ss.fffffffZ}'";
|
||||
|
||||
var results = new List<SignalRecord>(capacity: 128);
|
||||
var pages = table.QueryAsync<TableEntity>(
|
||||
filter: filter,
|
||||
maxPerPage: 1000,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await foreach (var entity in pages.ConfigureAwait(false))
|
||||
{
|
||||
if (results.Count >= maxResults)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Signals: time-range query reached maxResults cap {Cap} at cutoff {Cutoff:o} — narrow window or bump cap",
|
||||
maxResults, cutoffUtc);
|
||||
break;
|
||||
}
|
||||
results.Add(FromEntity(entity));
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
+7
-2
@@ -13,14 +13,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
/// </summary>
|
||||
public static class TableStorageChunking
|
||||
{
|
||||
private const int DefaultMaxChunkSize = 30_000;
|
||||
/// <summary>
|
||||
/// Largest string kept in one property: 30,000 UTF-16 chars stay under the 64 KiB
|
||||
/// per-property limit with headroom. The event writer truncates to the same bound;
|
||||
/// <see cref="TableTransactionBatcher"/> guards the entity and transaction totals.
|
||||
/// </summary>
|
||||
public const int MaxPropertyChars = 30_000;
|
||||
|
||||
/// <summary>
|
||||
/// Splits a string value into chunked properties if it exceeds maxChunkSize.
|
||||
/// Small values: { "Prop": value }
|
||||
/// Large values: { "Prop_0": chunk0, "Prop_1": chunk1, "Prop_ChunkCount": "2" }
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> ChunkProperty(string propertyName, string value, int maxChunkSize = DefaultMaxChunkSize)
|
||||
public static Dictionary<string, string> ChunkProperty(string propertyName, string value, int maxChunkSize = MaxPropertyChars)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxChunkSize)
|
||||
return new Dictionary<string, string> { { propertyName, value ?? "" } };
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Azure.Data.Tables;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Splits entities into entity-group transactions that respect all three Azure Tables
|
||||
/// limits at once: at most 100 actions per transaction, a transaction payload under 4 MiB
|
||||
/// and a single entity under 1 MiB. Every other writer used to chunk by count only, which
|
||||
/// let 100 payload-heavy rows (Signals with chunked PayloadJson, Events with 30k-char
|
||||
/// Message + DataJson) exceed the 4 MiB cap and surface as an opaque 500 the agent
|
||||
/// retries forever (audit 2026-09-05 F07).
|
||||
/// <para>
|
||||
/// Sizes are estimates on the safe side (UTF-16 width for strings, base64 growth for
|
||||
/// binaries, a fixed envelope per entity); the budget leaves headroom for the OData
|
||||
/// batch framing. A batch never spans partitions: a PartitionKey change starts a new
|
||||
/// transaction, so callers may pass a pre-grouped sequence and rely on the order.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static class TableTransactionBatcher
|
||||
{
|
||||
/// <summary>Azure Tables hard limit on actions per entity-group transaction.</summary>
|
||||
public const int MaxActionsPerTransaction = 100;
|
||||
|
||||
/// <summary>Conservative payload budget per transaction (service limit: 4 MiB).</summary>
|
||||
public const int TransactionByteBudget = 3_500_000;
|
||||
|
||||
/// <summary>Conservative size guard per entity (service limit: 1 MiB).</summary>
|
||||
public const int EntityByteLimit = 1_000_000;
|
||||
|
||||
private const int EntityEnvelopeBytes = 256;
|
||||
private const int ScalarPropertyBytes = 48;
|
||||
|
||||
/// <summary>
|
||||
/// Groups <paramref name="entities"/> into transactions of <paramref name="actionType"/>
|
||||
/// actions. Preserves order; starts a new transaction on the action cap, the byte
|
||||
/// budget or a PartitionKey change. Throws <see cref="TableEntityTooLargeException"/>
|
||||
/// for an entity that can never fit on its own — splitting cannot fix that, the caller
|
||||
/// has to shrink or reject the row.
|
||||
/// </summary>
|
||||
public static List<List<TableTransactionAction>> Split(
|
||||
IEnumerable<TableEntity> entities,
|
||||
TableTransactionActionType actionType,
|
||||
int maxActions = MaxActionsPerTransaction,
|
||||
int byteBudget = TransactionByteBudget)
|
||||
{
|
||||
if (entities == null) throw new ArgumentNullException(nameof(entities));
|
||||
if (maxActions < 1 || maxActions > MaxActionsPerTransaction)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxActions));
|
||||
if (byteBudget < 1) throw new ArgumentOutOfRangeException(nameof(byteBudget));
|
||||
|
||||
var batches = new List<List<TableTransactionAction>>();
|
||||
var current = new List<TableTransactionAction>();
|
||||
var currentBytes = 0;
|
||||
string? currentPartition = null;
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var bytes = EstimateEntityBytes(entity);
|
||||
if (bytes > EntityByteLimit)
|
||||
throw new TableEntityTooLargeException(entity.PartitionKey, entity.RowKey, bytes);
|
||||
|
||||
var partitionChanged = currentPartition != null
|
||||
&& !string.Equals(currentPartition, entity.PartitionKey, StringComparison.Ordinal);
|
||||
if (current.Count > 0
|
||||
&& (partitionChanged || current.Count >= maxActions || currentBytes + bytes > byteBudget))
|
||||
{
|
||||
batches.Add(current);
|
||||
current = new List<TableTransactionAction>();
|
||||
currentBytes = 0;
|
||||
}
|
||||
|
||||
current.Add(new TableTransactionAction(actionType, entity));
|
||||
currentBytes += bytes;
|
||||
currentPartition = entity.PartitionKey;
|
||||
}
|
||||
|
||||
if (current.Count > 0) batches.Add(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper-bound estimate of the serialized size of one entity: UTF-16 width for strings
|
||||
/// (property names included), base64 growth for binaries, a fixed cost for every
|
||||
/// scalar, plus the per-entity envelope.
|
||||
/// </summary>
|
||||
public static int EstimateEntityBytes(TableEntity entity)
|
||||
{
|
||||
if (entity == null) throw new ArgumentNullException(nameof(entity));
|
||||
|
||||
long total = EntityEnvelopeBytes
|
||||
+ 2L * (entity.PartitionKey?.Length ?? 0)
|
||||
+ 2L * (entity.RowKey?.Length ?? 0);
|
||||
|
||||
foreach (var kv in entity)
|
||||
{
|
||||
// TableEntity enumerates the keys as properties too; they are counted above.
|
||||
if (kv.Key == "PartitionKey" || kv.Key == "RowKey") continue;
|
||||
|
||||
total += 2L * kv.Key.Length;
|
||||
total += kv.Value switch
|
||||
{
|
||||
null => 0,
|
||||
string s => 2L * s.Length,
|
||||
byte[] b => (b.Length * 4L + 2) / 3,
|
||||
BinaryData bd => (bd.ToMemory().Length * 4L + 2) / 3,
|
||||
_ => ScalarPropertyBytes,
|
||||
};
|
||||
}
|
||||
|
||||
return total > int.MaxValue ? int.MaxValue : (int)total;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single entity exceeds what Azure Tables accepts in one row. Permanent by nature: no
|
||||
/// retry and no smaller batch can make it fit. The ingest maps it to HTTP 413 so the agent
|
||||
/// isolates the item instead of replaying the batch.
|
||||
/// </summary>
|
||||
public sealed class TableEntityTooLargeException : Exception
|
||||
{
|
||||
public string PartitionKey { get; }
|
||||
public string RowKey { get; }
|
||||
public int EstimatedBytes { get; }
|
||||
|
||||
public TableEntityTooLargeException(string partitionKey, string rowKey, int estimatedBytes)
|
||||
: base($"Entity {partitionKey}/{rowKey} is about {estimatedBytes} bytes, above the {TableTransactionBatcher.EntityByteLimit}-byte entity limit")
|
||||
{
|
||||
PartitionKey = partitionKey;
|
||||
RowKey = rowKey;
|
||||
EstimatedBytes = estimatedBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -1,6 +1,7 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Telemetry;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Azure;
|
||||
@@ -20,12 +21,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly TableClient _tenantTableClient;
|
||||
private readonly ILogger<TableUserUsageRepository> _logger;
|
||||
private readonly StorageMetrics? _metrics;
|
||||
|
||||
public TableUserUsageRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableUserUsageRepository> logger)
|
||||
ILogger<TableUserUsageRepository> logger,
|
||||
StorageMetrics? metrics = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_metrics = metrics;
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.UserUsageLog);
|
||||
_tenantTableClient = storage.GetTableClient(Constants.TableNames.McpTenantUsage);
|
||||
}
|
||||
@@ -67,15 +71,18 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_metrics?.CasConflict("IncrementUserUsage", Constants.TableNames.UserUsageLog, CasOutcome.Exhausted);
|
||||
_logger.LogWarning("Failed to increment user usage after {MaxRetries} retries: user={UserId}, endpoint={Endpoint}",
|
||||
maxRetries, LogSanitizer.Clean(userId), LogSanitizer.Clean(endpoint));
|
||||
}
|
||||
@@ -253,15 +260,18 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Retried);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_metrics?.CasConflict("IncrementTenantUsage", Constants.TableNames.McpTenantUsage, CasOutcome.Exhausted);
|
||||
_logger.LogWarning("Failed to increment tenant usage after {MaxRetries} retries: tenant={TenantId}, user={UserId}",
|
||||
maxRetries, LogSanitizer.Clean(tenantId), LogSanitizer.Clean(userId));
|
||||
}
|
||||
|
||||
+1
-6
@@ -89,12 +89,7 @@ public class AutopilotDeviceValidationConsentFunction
|
||||
: _appRegistry.ResolveForTenant(tenantConfig).ClientId;
|
||||
if (string.IsNullOrWhiteSpace(validatorClientId))
|
||||
{
|
||||
var badConfig = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await badConfig.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Validator app client ID is not configured on the backend."
|
||||
});
|
||||
return badConfig;
|
||||
return await req.ErrorAsync(HttpStatusCode.InternalServerError, Constants.ApiErrorCodes.InternalError, "Validator app client ID is not configured on the backend.");
|
||||
}
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
|
||||
|
||||
+5
-19
@@ -51,28 +51,17 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
var table = (req.Query["table"] ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (table != OccurredUtcBackfillService.TableAudit && table != OccurredUtcBackfillService.TableOps)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new
|
||||
{
|
||||
error = $"table must be '{OccurredUtcBackfillService.TableAudit}' or '{OccurredUtcBackfillService.TableOps}'"
|
||||
});
|
||||
return bad;
|
||||
return await req.BadRequestAsync($"table must be '{OccurredUtcBackfillService.TableAudit}' or '{OccurredUtcBackfillService.TableOps}'");
|
||||
}
|
||||
|
||||
var dryRun = !string.Equals(req.Query["dryRun"], "false", StringComparison.OrdinalIgnoreCase);
|
||||
var continuation = req.Query["continuation"];
|
||||
|
||||
var maxRows = DefaultMaxRows;
|
||||
if (!string.IsNullOrEmpty(req.Query["maxRows"]))
|
||||
if (!QueryParams.TryInt(req.Query["maxRows"], "maxRows", 1, MaxMaxRows, out var maxRowsOrNull, out var maxRowsError))
|
||||
{
|
||||
if (!int.TryParse(req.Query["maxRows"], out maxRows) || maxRows < 1)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error = $"maxRows must be a positive integer (max {MaxMaxRows})" });
|
||||
return bad;
|
||||
}
|
||||
maxRows = Math.Min(maxRows, MaxMaxRows);
|
||||
return await req.BadRequestAsync(maxRowsError!);
|
||||
}
|
||||
var maxRows = maxRowsOrNull ?? DefaultMaxRows;
|
||||
|
||||
_logger.LogInformation(
|
||||
"OccurredUtc backfill requested by {User}: table={Table} dryRun={DryRun} maxRows={MaxRows} hasContinuation={HasContinuation}",
|
||||
@@ -94,10 +83,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OccurredUtc backfill run failed");
|
||||
var error = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await error.WriteAsJsonAsync(new { success = false, message = "Internal server error" });
|
||||
return error;
|
||||
return await req.InternalServerErrorAsync(_logger, ex, "BackfillOccurredUtc");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-21
@@ -134,14 +134,10 @@ public class DelegatedAdminManagementFunction
|
||||
var removed = await RevokeCoreAsync(upn, tenantId, currentUpn);
|
||||
if (!removed)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { error = "Delegated assignment not found" });
|
||||
return notFound;
|
||||
return await req.NotFoundAsync("Delegated assignment not found");
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { message = "Delegated assignment revoked" });
|
||||
return response;
|
||||
return await req.OkAsync(new MessageResponse { Message = "Delegated assignment revoked" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -190,14 +186,10 @@ public class DelegatedAdminManagementFunction
|
||||
var ok = await SetEnabledCoreAsync(upn, tenantId, isEnabled, currentUpn);
|
||||
if (!ok)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { error = "Delegated assignment not found" });
|
||||
return notFound;
|
||||
return await req.NotFoundAsync("Delegated assignment not found");
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { message = isEnabled ? "Delegated assignment enabled" : "Delegated assignment disabled" });
|
||||
return response;
|
||||
return await req.OkAsync(new MessageResponse { Message = isEnabled ? "Delegated assignment enabled" : "Delegated assignment disabled" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -253,23 +245,17 @@ public class DelegatedAdminManagementFunction
|
||||
|
||||
private static async Task<HttpResponseData> Bad(HttpRequestData req, string error)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error });
|
||||
return bad;
|
||||
return await req.BadRequestAsync(error);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> Conflict(HttpRequestData req, string error)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new { error });
|
||||
return conflict;
|
||||
return await req.ConflictAsync(error);
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> Unresolved(HttpRequestData req)
|
||||
{
|
||||
var unresolved = req.CreateResponse(HttpStatusCode.UnprocessableEntity);
|
||||
await unresolved.WriteAsJsonAsync(new { error = IdentityBindingRequest.HomeTenantUnresolvedMessage, code = IdentityBindingRequest.HomeTenantUnresolvedCode });
|
||||
return unresolved;
|
||||
return await req.ErrorAsync(HttpStatusCode.UnprocessableEntity, IdentityBindingRequest.HomeTenantUnresolvedCode, IdentityBindingRequest.HomeTenantUnresolvedMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -60,9 +60,7 @@ public class DelegatedSlotManagementFunction
|
||||
var body = await req.ReadFromJsonAsync<ReleaseDelegatedSlotHoldRequest>();
|
||||
if (body == null || (!body.All && string.IsNullOrWhiteSpace(body.InvitationId)))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error = "invitationId or all=true is required" });
|
||||
return bad;
|
||||
return await req.BadRequestAsync("invitationId or all=true is required");
|
||||
}
|
||||
|
||||
var released = await _slots.ReleaseHoldAsync(target, body.InvitationId, body.All, ctx.UserPrincipalName);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user