diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 924a9261..01d65e21 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-606
+628
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 837dd962..e5d76887 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1258
+1274
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogRowKeyOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogRowKeyOrderingTests.cs
new file mode 100644
index 00000000..9b109651
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuditLogRowKeyOrderingTests.cs
@@ -0,0 +1,91 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Audit-log RowKey ordering invariants. Replaces the prior bare-GUID RowKey,
+/// which made paged audit reads return random-order pages — Azure Tables sorts
+/// by RowKey ASC within a partition, so a random GUID gave random ordering, and
+/// the in-page Timestamp re-sort only fixed ordering inside that random slice
+/// (page 1 was not necessarily the newest 50 audits).
+///
+public class AuditLogRowKeyOrderingTests
+{
+ [Fact]
+ public void Newer_audit_rowkey_sorts_before_older_audit_rowkey()
+ {
+ // Azure Tables returns rows in (PK asc, RK asc); we want ASC = newest-first.
+ // Reverse-tick: newer timestamp → smaller revtick → smaller RowKey string.
+ var now = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc);
+ var oneHourAgo = now.AddHours(-1);
+
+ var rkNow = TableStorageService.BuildAuditLogRowKey(now, Guid.Empty);
+ var rkOld = TableStorageService.BuildAuditLogRowKey(oneHourAgo, Guid.Empty);
+
+ Assert.True(string.Compare(rkNow, rkOld, StringComparison.Ordinal) < 0,
+ $"Expected newer rowkey '{rkNow}' to sort before older rowkey '{rkOld}'");
+ }
+
+ [Fact]
+ public void New_audit_rowkey_sorts_before_legacy_bare_guid_rowkey_starting_with_digit()
+ {
+ // The biggest migration trap: legacy GUIDs starting with '0'-'9' would
+ // otherwise interleave with — or precede — current-era revtick values
+ // ("2…" or "3…"). The '!' prefix (0x21) sorts before all hex digits so
+ // every new entry lands ahead of every legacy entry.
+ var now = DateTime.UtcNow;
+ var newRk = TableStorageService.BuildAuditLogRowKey(now, Guid.NewGuid());
+
+ // Legacy: bare GUID, all 16 hex first chars are possible.
+ var legacyDigitGuid = "0a1b2c3d-4e5f-6789-abcd-ef0123456789";
+ var legacyHexGuid = "ffffffff-ffff-ffff-ffff-ffffffffffff";
+ var legacyZeroGuid = "00000000-0000-0000-0000-000000000000";
+
+ Assert.True(string.Compare(newRk, legacyDigitGuid, StringComparison.Ordinal) < 0,
+ $"New rowkey '{newRk}' should sort before legacy digit-GUID rowkey '{legacyDigitGuid}'");
+ Assert.True(string.Compare(newRk, legacyHexGuid, StringComparison.Ordinal) < 0,
+ $"New rowkey '{newRk}' should sort before legacy hex-GUID rowkey '{legacyHexGuid}'");
+ Assert.True(string.Compare(newRk, legacyZeroGuid, StringComparison.Ordinal) < 0,
+ $"New rowkey '{newRk}' should sort before legacy all-zero-GUID rowkey '{legacyZeroGuid}'");
+ }
+
+ [Fact]
+ public void Same_tick_writes_have_unique_rowkeys()
+ {
+ // Reverse-tick alone collides for two writes within the same 100-ns tick —
+ // the GUID suffix prevents one of them from silently overwriting the other.
+ var ts = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc);
+
+ var rk1 = TableStorageService.BuildAuditLogRowKey(ts, Guid.NewGuid());
+ var rk2 = TableStorageService.BuildAuditLogRowKey(ts, Guid.NewGuid());
+
+ Assert.NotEqual(rk1, rk2);
+ // Both share the same revtick prefix — only the suffix differs.
+ var prefix1 = rk1.Substring(0, 20); // "!" + 19 digits
+ var prefix2 = rk2.Substring(0, 20);
+ Assert.Equal(prefix1, prefix2);
+ }
+
+ [Fact]
+ public void RowKey_starts_with_marker_prefix()
+ {
+ // Sanity check on the wire format — the prefix is what guarantees
+ // separation from legacy rows; if it ever drifts the migration window
+ // promise breaks silently.
+ var rk = TableStorageService.BuildAuditLogRowKey(DateTime.UtcNow, Guid.NewGuid());
+ Assert.StartsWith("!", rk);
+ }
+
+ [Fact]
+ public void Audit_global_tenant_id_is_a_well_formed_guid()
+ {
+ // The synthetic global-tenant partition is added to the audit fan-out
+ // alongside real tenants from TenantConfiguration. It must parse as a
+ // GUID since BuildAuditLogFilterWithRowKeyBound interpolates it into an
+ // OData PartitionKey filter — a malformed value would fail the table
+ // query rather than silently skipping the partition.
+ Assert.True(Guid.TryParse(Constants.AuditGlobalTenantId, out _),
+ $"Constants.AuditGlobalTenantId='{Constants.AuditGlobalTenantId}' must be a valid GUID");
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ContinuationTokenTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ContinuationTokenTests.cs
new file mode 100644
index 00000000..abfae68d
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/ContinuationTokenTests.cs
@@ -0,0 +1,299 @@
+using System;
+using System.Collections.Generic;
+using AutopilotMonitor.Shared.Pagination;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Round-trip + tamper + replay tests for the pagination continuation token
+/// codec (PR-1 of mcp-pagination-rollout).
+///
+public class ContinuationTokenTests
+{
+ private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+ private const string AzureToken = "1!76!ZjQwYjBmYWMtMDcyNS00MzZjLWI2NTAtZmRl";
+
+ private static IEnumerable> FilterArgsA() => new[]
+ {
+ new KeyValuePair("tenantId", TenantA),
+ new KeyValuePair("eventType", "enrollment_complete"),
+ new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"),
+ new KeyValuePair("dateTo", "2026-05-01T00:00:00Z"),
+ };
+
+ private static IEnumerable> FilterArgsB() => new[]
+ {
+ new KeyValuePair("tenantId", TenantB),
+ new KeyValuePair("eventType", "enrollment_complete"),
+ new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"),
+ new KeyValuePair("dateTo", "2026-05-01T00:00:00Z"),
+ };
+
+ [Fact]
+ public void Encode_then_TryDecode_returns_original_azure_token()
+ {
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
+
+ Assert.True(ContinuationToken.TryDecode(encoded, TenantA, fp, out var decoded, out var reason));
+ Assert.Null(reason);
+ Assert.Equal(AzureToken, decoded);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_token_for_a_different_tenant()
+ {
+ // Token issued for tenant A, replayed by tenant B — must fail before any
+ // data is read regardless of fingerprint plausibility.
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
+
+ var fpForB = ContinuationToken.ComputeFingerprint(FilterArgsB());
+ Assert.False(ContinuationToken.TryDecode(encoded, TenantB, fpForB, out var decoded, out var reason));
+ Assert.Equal("cross_tenant", reason);
+ Assert.Equal(string.Empty, decoded);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_token_when_filter_fingerprint_changes()
+ {
+ // Token issued for filter X, caller now sends filter Y → reject; token
+ // would otherwise reseek into a different table layout.
+ var fpOriginal = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fpOriginal);
+
+ var fpDifferent = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("tenantId", TenantA),
+ new KeyValuePair("eventType", "enrollment_failed"), // changed
+ new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"),
+ new KeyValuePair("dateTo", "2026-05-01T00:00:00Z"),
+ });
+
+ Assert.False(ContinuationToken.TryDecode(encoded, TenantA, fpDifferent, out _, out var reason));
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_token_with_tampered_payload()
+ {
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
+
+ // Flip a byte in the middle of the base64url payload — decoder either
+ // fails parsing or rejects on tenant/fingerprint check; either way no
+ // entries are exposed.
+ var tampered = encoded.Substring(0, encoded.Length / 2) + "X" + encoded.Substring(encoded.Length / 2 + 1);
+
+ Assert.False(ContinuationToken.TryDecode(tampered, TenantA, fp, out _, out var reason));
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_expired_token()
+ {
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ var issued = DateTimeOffset.UtcNow - TimeSpan.FromHours(48);
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp, issued);
+
+ Assert.False(ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason));
+ Assert.Equal("expired", reason);
+ }
+
+ [Fact]
+ public void TryDecode_accepts_token_within_default_ttl()
+ {
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ var issued = DateTimeOffset.UtcNow - TimeSpan.FromHours(23);
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp, issued);
+
+ Assert.True(ContinuationToken.TryDecode(encoded, TenantA, fp, out var decoded, out _));
+ Assert.Equal(AzureToken, decoded);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_garbage_input()
+ {
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ Assert.False(ContinuationToken.TryDecode("not a token", TenantA, fp, out _, out var reason));
+ Assert.NotNull(reason);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_empty_input()
+ {
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ Assert.False(ContinuationToken.TryDecode("", TenantA, fp, out _, out var reason));
+ Assert.Equal("empty", reason);
+ }
+
+ [Fact]
+ public void ComputeFingerprint_is_stable_regardless_of_arg_order()
+ {
+ var fp1 = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("tenantId", TenantA),
+ new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"),
+ new KeyValuePair("eventType", "x"),
+ });
+ var fp2 = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("eventType", "x"),
+ new KeyValuePair("tenantId", TenantA),
+ new KeyValuePair("dateFrom", "2026-04-01T00:00:00Z"),
+ });
+ Assert.Equal(fp1, fp2);
+ }
+
+ [Fact]
+ public void ComputeFingerprint_treats_keys_case_insensitively()
+ {
+ var fp1 = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("TenantId", TenantA),
+ new KeyValuePair("EventType", "x"),
+ });
+ var fp2 = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("tenantid", TenantA),
+ new KeyValuePair("eventtype", "x"),
+ });
+ Assert.Equal(fp1, fp2);
+ }
+
+ [Fact]
+ public void ComputeFingerprint_drops_null_and_empty_values()
+ {
+ // Optional filter args (null/empty) must not perturb the fingerprint —
+ // otherwise endpoints that pass them through unconditionally would
+ // produce non-resumable tokens.
+ var withOptionals = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("tenantId", TenantA),
+ new KeyValuePair("eventType", "x"),
+ new KeyValuePair("dateFrom", null),
+ new KeyValuePair("dateTo", ""),
+ });
+ var withoutOptionals = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("tenantId", TenantA),
+ new KeyValuePair("eventType", "x"),
+ });
+ Assert.Equal(withOptionals, withoutOptionals);
+ }
+
+ [Fact]
+ public void ComputeFingerprint_changes_when_value_differs()
+ {
+ var a = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("tenantId", TenantA),
+ });
+ var b = ContinuationToken.ComputeFingerprint(new[]
+ {
+ new KeyValuePair("tenantId", TenantB),
+ });
+ Assert.NotEqual(a, b);
+ }
+
+ [Fact]
+ public void Encode_throws_on_empty_tenant_or_fingerprint()
+ {
+ Assert.Throws(() => ContinuationToken.Encode(AzureToken, "", "fp"));
+ Assert.Throws(() => ContinuationToken.Encode(AzureToken, TenantA, ""));
+ Assert.Throws(() => ContinuationToken.Encode(null!, TenantA, "fp"));
+ }
+
+ // ────────── HMAC signature integrity ────────────────────────────────────
+
+ [Fact]
+ public void TryDecode_rejects_iat_bypass_attempt()
+ {
+ // The whole point of HMAC: a client that hand-crafts a token with a
+ // fresh `iat` to bypass the 24h expiry must be rejected. The fingerprint
+ // and tenantId checks alone don't catch this — only the signature does.
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp,
+ issuedAt: DateTimeOffset.UtcNow - TimeSpan.FromHours(48));
+
+ // Decode the token's payload, push iat to "now", re-encode without
+ // re-signing — exactly what an attacker would try.
+ var jsonBytes = Base64UrlDecode(encoded);
+ using var doc = System.Text.Json.JsonDocument.Parse(jsonBytes);
+ var rebuilt = new System.Collections.Generic.Dictionary();
+ foreach (var prop in doc.RootElement.EnumerateObject())
+ {
+ rebuilt[prop.Name] = prop.Name == "iat"
+ ? (object)DateTimeOffset.UtcNow.ToUnixTimeSeconds()
+ : prop.Value.ValueKind == System.Text.Json.JsonValueKind.String
+ ? prop.Value.GetString()!
+ : (object)prop.Value.GetInt64();
+ }
+ var tamperedJson = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(rebuilt);
+ var tampered = Base64UrlEncode(tamperedJson);
+
+ Assert.False(ContinuationToken.TryDecode(tampered, TenantA, fp, out _, out var reason));
+ Assert.Equal("bad_signature", reason);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_payload_with_missing_signature()
+ {
+ // Crafted token without the `sig` field — the codec must refuse to
+ // proceed before any other field is even examined.
+ var unsigned = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(new
+ {
+ t = AzureToken,
+ tid = TenantA,
+ fp = "deadbeef",
+ iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ });
+ var raw = Base64UrlEncode(unsigned);
+
+ Assert.False(ContinuationToken.TryDecode(raw, TenantA, "deadbeef", out _, out var reason));
+ Assert.Equal("missing_signature", reason);
+ }
+
+ [Fact]
+ public void TryDecode_rejects_token_signed_with_a_different_key()
+ {
+ // Simulate key rotation: token issued under key A, validated under key B.
+ // (We toggle the test key around the encode/decode pair.)
+ var keyA = new byte[32]; for (int i = 0; i < keyA.Length; i++) keyA[i] = (byte)(i * 3 + 1);
+ var keyB = new byte[32]; for (int i = 0; i < keyB.Length; i++) keyB[i] = (byte)(i * 5 + 7);
+
+ var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
+
+ ContinuationToken.SetSigningKeyForTesting(keyA);
+ var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
+
+ ContinuationToken.SetSigningKeyForTesting(keyB);
+ var ok = ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason);
+
+ // Restore the test default before leaving so subsequent tests aren't affected.
+ TestSetup.Initialize();
+
+ Assert.False(ok);
+ Assert.Equal("bad_signature", reason);
+ }
+
+ // Test-only mirrors of the Base64Url helpers in ContinuationToken — they're
+ // private there, but we need the same encoding to construct adversarial tokens.
+ private static string Base64UrlEncode(byte[] bytes)
+ {
+ var s = Convert.ToBase64String(bytes);
+ return s.Replace('+', '-').Replace('/', '_').TrimEnd('=');
+ }
+
+ private static byte[] Base64UrlDecode(string s)
+ {
+ var padded = s.Replace('-', '+').Replace('_', '/');
+ switch (padded.Length % 4)
+ {
+ case 2: padded += "=="; break;
+ case 3: padded += "="; break;
+ }
+ return Convert.FromBase64String(padded);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DateWindowPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DateWindowPaginationTests.cs
new file mode 100644
index 00000000..81f2ec10
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DateWindowPaginationTests.cs
@@ -0,0 +1,247 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using AutopilotMonitor.Functions.Pagination;
+using AutopilotMonitor.Shared.Pagination;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// PR 3 (mcp-pagination-rollout) — date-windowed forensics endpoints
+/// (audit logs + ops events). Verifies the shared
+/// helper and its cross-tenant /
+/// cross-window guarantees.
+///
+public class DateWindowPaginationTests
+{
+ private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+
+ // ────────── ParseQuery ──────────────────────────────────────────────────
+
+ [Fact]
+ public void ParseQuery_with_no_window_defaults_to_30d_ending_at_now()
+ {
+ var anchor = new DateTimeOffset(2026, 5, 5, 14, 0, 0, TimeSpan.Zero);
+ var parsed = DateWindowPagination.ParseQuery(new NameValueCollection(), anchor);
+
+ Assert.Null(parsed.Error);
+ Assert.NotNull(parsed.DateFrom);
+ Assert.NotNull(parsed.DateTo);
+ Assert.Equal(anchor.UtcDateTime, parsed.DateTo);
+ Assert.Equal(anchor.UtcDateTime - TimeSpan.FromDays(30), parsed.DateFrom);
+ }
+
+ [Fact]
+ public void ParseQuery_honours_explicit_dateFrom_and_dateTo_exactly()
+ {
+ var qs = new NameValueCollection
+ {
+ { "dateFrom", "2026-04-01T00:00:00Z" },
+ { "dateTo", "2026-05-01T00:00:00Z" },
+ };
+ var parsed = DateWindowPagination.ParseQuery(qs);
+
+ Assert.Null(parsed.Error);
+ Assert.Equal(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), parsed.DateFrom);
+ Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), parsed.DateTo);
+ }
+
+ [Fact]
+ public void ParseQuery_with_only_dateFrom_leaves_dateTo_open()
+ {
+ var qs = new NameValueCollection { { "dateFrom", "2026-04-01T00:00:00Z" } };
+ var parsed = DateWindowPagination.ParseQuery(qs);
+
+ Assert.Null(parsed.Error);
+ Assert.Equal(new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc), parsed.DateFrom);
+ Assert.Null(parsed.DateTo);
+ }
+
+ [Fact]
+ public void ParseQuery_rejects_dateFrom_after_dateTo()
+ {
+ var qs = new NameValueCollection
+ {
+ { "dateFrom", "2026-06-01T00:00:00Z" },
+ { "dateTo", "2026-04-01T00:00:00Z" },
+ };
+ var parsed = DateWindowPagination.ParseQuery(qs);
+
+ Assert.NotNull(parsed.Error);
+ }
+
+ [Fact]
+ public void ParseQuery_rejects_garbage_dateFrom()
+ {
+ var qs = new NameValueCollection { { "dateFrom", "not-a-date" } };
+ var parsed = DateWindowPagination.ParseQuery(qs);
+ Assert.NotNull(parsed.Error);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("1001")]
+ [InlineData("banana")]
+ public void ParseQuery_rejects_invalid_pageSize(string raw)
+ {
+ var qs = new NameValueCollection { { "pageSize", raw } };
+ var parsed = DateWindowPagination.ParseQuery(qs);
+ Assert.NotNull(parsed.Error);
+ }
+
+ [Fact]
+ public void ParseQuery_drops_continuation_when_pageSize_absent()
+ {
+ var qs = new NameValueCollection { { "continuation", "STALE" } };
+ var parsed = DateWindowPagination.ParseQuery(qs);
+
+ Assert.Null(parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ }
+
+ [Fact]
+ public void ParseQuery_with_pageSize_activates_pagination()
+ {
+ var qs = new NameValueCollection
+ {
+ { "pageSize", "50" },
+ { "continuation", "ENCODED" },
+ };
+ var parsed = DateWindowPagination.ParseQuery(qs);
+
+ Assert.Equal(50, parsed.PageSize);
+ Assert.Equal("ENCODED", parsed.Continuation);
+ }
+
+ // ────────── Fingerprint + token round trip ──────────────────────────────
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_when_window_and_tenant_match()
+ {
+ var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc);
+ var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
+ var fp = DateWindowPagination.Fingerprint("audit:tenant", TenantA, dateFrom, dateTo);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = DateWindowPagination.TryAcceptContinuation(
+ encoded, "audit:tenant", TenantA, dateFrom, dateTo, extras: null,
+ out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_for_a_different_tenant()
+ {
+ var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc);
+ var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
+ var fp = DateWindowPagination.Fingerprint("audit:tenant", TenantA, dateFrom, dateTo);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = DateWindowPagination.TryAcceptContinuation(
+ encoded, "audit:tenant", TenantB, dateFrom, dateTo, extras: null,
+ out var _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("cross_tenant", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_window_changes()
+ {
+ var fpA = DateWindowPagination.Fingerprint(
+ "audit:tenant", TenantA,
+ new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc));
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA);
+
+ var ok = DateWindowPagination.TryAcceptContinuation(
+ encoded, "audit:tenant", TenantA,
+ // Caller now sends a different window — token must be rejected,
+ // otherwise the underlying Pk/Rk cursor would seek into rows that
+ // no longer match the active filter.
+ new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc),
+ new DateTime(2026, 5, 2, 0, 0, 0, DateTimeKind.Utc),
+ extras: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_scope_changes()
+ {
+ // A token issued for the tenant-scoped audit endpoint must not be
+ // accepted by the global-scoped endpoint (or vice versa) — the scope
+ // is part of the fingerprint precisely so the two are non-fungible.
+ var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc);
+ var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
+ var fpTenant = DateWindowPagination.Fingerprint("audit:tenant", TenantA, dateFrom, dateTo);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant);
+
+ var ok = DateWindowPagination.TryAcceptContinuation(
+ encoded, "audit:global", TenantA, dateFrom, dateTo, extras: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_for_ops_events_includes_category_in_fingerprint()
+ {
+ var dateFrom = new DateTime(2026, 4, 1, 0, 0, 0, DateTimeKind.Utc);
+ var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
+ var withConsent = new[] { new KeyValuePair("category", "Consent") };
+ var fp = DateWindowPagination.Fingerprint("ops-events", TenantA, dateFrom, dateTo, withConsent);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ // Same scope/tenant/window but different category → must reject.
+ var withSecurity = new[] { new KeyValuePair("category", "Security") };
+ var ok = DateWindowPagination.TryAcceptContinuation(
+ encoded, "ops-events", TenantA, dateFrom, dateTo, withSecurity,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ // ────────── BuildNextLink ───────────────────────────────────────────────
+
+ [Fact]
+ public void BuildNextLink_includes_pageSize_continuation_dateFrom_dateTo_and_extras()
+ {
+ var dateFrom = new DateTime(2026, 4, 1, 12, 30, 0, DateTimeKind.Utc);
+ var dateTo = new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc);
+ var extras = new[] { new KeyValuePair("category", "Consent") };
+
+ var link = DateWindowPagination.BuildNextLink(
+ "/api/global/ops-events", 50, "TOKEN+/=", dateFrom, dateTo, extras);
+
+ Assert.StartsWith("/api/global/ops-events?", link);
+ Assert.Contains("pageSize=50", link);
+ Assert.Contains("continuation=TOKEN%2B%2F%3D", link);
+ Assert.Contains("dateFrom=2026-04-01T12%3A30%3A00", link);
+ Assert.Contains("dateTo=2026-05-01T00%3A00%3A00", link);
+ Assert.Contains("category=Consent", link);
+ }
+
+ [Fact]
+ public void BuildNextLink_omits_optional_bounds_and_empty_extras()
+ {
+ var link = DateWindowPagination.BuildNextLink(
+ "/api/audit/logs", 50, "abc", dateFrom: null, dateTo: null,
+ extras: new[] { new KeyValuePair("category", null) });
+
+ Assert.StartsWith("/api/audit/logs?", link);
+ Assert.Contains("pageSize=50", link);
+ Assert.Contains("continuation=abc", link);
+ Assert.DoesNotContain("dateFrom=", link);
+ Assert.DoesNotContain("dateTo=", link);
+ Assert.DoesNotContain("category=", link);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PerPartitionFanOutMergeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PerPartitionFanOutMergeTests.cs
new file mode 100644
index 00000000..ef17404a
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/PerPartitionFanOutMergeTests.cs
@@ -0,0 +1,278 @@
+using AutopilotMonitor.Functions.DataAccess.TableStorage;
+using AutopilotMonitor.Shared.DataAccess;
+using static AutopilotMonitor.Functions.DataAccess.TableStorage.PerPartitionFanOutMerge;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Cross-partition merge used by both the global all-category ops-events page
+/// and the global cross-tenant audit-logs page. Without this fan-out, Azure
+/// pages cross-partition queries by (PK asc, RK asc) — so page 1 would be
+/// entirely the alphabetically-first partition, masking newer items from
+/// later-alphabet partitions. The merge picks top-pageSize by timestamp across
+/// all per-partition fetches.
+///
+public class PerPartitionFanOutMergeTests
+{
+ private static OpsEventEntry Entry(string partition, DateTime ts, string id)
+ => new() { Category = partition, Id = id, Timestamp = ts, EventType = "synthetic" };
+
+ private static (string RowKey, OpsEventEntry Item) Row(DateTime ts, string id)
+ {
+ // Mimics the storage-layer revtick rowkey: smaller string = newer timestamp.
+ var rk = $"{(DateTime.MaxValue.Ticks - ts.Ticks):D19}_{id}";
+ return (rk, Entry(partition: "", ts, id));
+ }
+
+ private static PartitionFetchResult Fetched(
+ string partition, params (string RowKey, OpsEventEntry Item)[] rows)
+ {
+ var aligned = rows
+ .Select(r => (r.RowKey, Item: new OpsEventEntry
+ {
+ Id = r.Item.Id,
+ Category = partition,
+ EventType = r.Item.EventType,
+ Timestamp = r.Item.Timestamp,
+ }))
+ .ToArray();
+ return new PartitionFetchResult(partition, aligned);
+ }
+
+ // ────────── Merge picks newest-across-partitions, not within-partition ──
+
+ [Fact]
+ public void First_page_returns_newest_across_partitions_not_alphabetically_first()
+ {
+ // Without the merge, Azure's (PK asc, RK asc) ordering would surface
+ // "Agent" partition items first (alphabetically), even though "Security"
+ // has a newer event. The merge must pick by timestamp, not partition.
+ var t0 = DateTime.UtcNow;
+ var fetched = new[]
+ {
+ Fetched("Agent", Row(t0.AddMinutes(-5), "agent-1")),
+ Fetched("Security", Row(t0, "sec-1")),
+ Fetched("Tenant", Row(t0.AddMinutes(-10), "tenant-1")),
+ };
+
+ var (items, _) = MergeAndAdvance(
+ fetched,
+ new Dictionary(),
+ pageSize: 3,
+ timestampSelector: e => e.Timestamp);
+
+ Assert.Equal(3, items.Count);
+ Assert.Equal("sec-1", items[0].Id); // newest first
+ Assert.Equal("agent-1", items[1].Id);
+ Assert.Equal("tenant-1", items[2].Id);
+ }
+
+ [Fact]
+ public void Empty_fetch_marks_partition_exhausted_so_next_page_skips_it()
+ {
+ var t0 = DateTime.UtcNow;
+ var fetched = new[]
+ {
+ Fetched("Agent"), // empty fetch
+ Fetched("Security", Row(t0, "sec-1")),
+ };
+
+ var (_, conts) = MergeAndAdvance(
+ fetched,
+ new Dictionary(),
+ pageSize: 5,
+ timestampSelector: e => e.Timestamp);
+
+ Assert.True(conts["Agent"].Exhausted);
+ Assert.False(conts["Security"].Exhausted);
+ }
+
+ // ────────── Continuation advance: avoid losing leftovers when a partition loses the cut ──
+
+ [Fact]
+ public void Continuation_keeps_position_for_partitions_whose_items_lost_the_merge_cut()
+ {
+ // Page 1: pageSize=3. Agent fetched 5 items, Security fetched 5 items, but
+ // Security's are all newer. Merge takes 3 from Security; Agent's 5 items
+ // all lost the cut. Agent's continuation MUST stay at its previous
+ // position so the items get re-fetched and compete on page 2.
+ var t0 = DateTime.UtcNow;
+ var fetched = new[]
+ {
+ Fetched("Agent",
+ Row(t0.AddMinutes(-100), "a1"),
+ Row(t0.AddMinutes(-101), "a2"),
+ Row(t0.AddMinutes(-102), "a3"),
+ Row(t0.AddMinutes(-103), "a4"),
+ Row(t0.AddMinutes(-104), "a5")),
+ Fetched("Security",
+ Row(t0, "s1"),
+ Row(t0.AddMinutes(-1), "s2"),
+ Row(t0.AddMinutes(-2), "s3"),
+ Row(t0.AddMinutes(-3), "s4"),
+ Row(t0.AddMinutes(-4), "s5")),
+ };
+
+ var (items, conts) = MergeAndAdvance(
+ fetched,
+ new Dictionary(),
+ pageSize: 3,
+ timestampSelector: e => e.Timestamp);
+
+ // Security wins all three slots.
+ Assert.Equal(new[] { "s1", "s2", "s3" }, items.Select(i => i.Id).ToArray());
+
+ // Agent had no items returned and a non-empty fetch → keep prior continuation
+ // (null = restart). The partition is NOT marked exhausted, so the next page
+ // will re-fetch its items and they'll be re-considered at a lower floor.
+ Assert.False(conts["Agent"].Exhausted);
+ Assert.Null(conts["Agent"].LastRowKey);
+
+ Assert.False(conts["Security"].Exhausted);
+ Assert.NotNull(conts["Security"].LastRowKey);
+ }
+
+ [Fact]
+ public void Continuation_advances_to_oldest_returned_rowkey_per_partition()
+ {
+ // When merge takes some-but-not-all from a partition, advance only past the
+ // items we actually returned. The leftover (older) items in that fetch
+ // will be re-fetched and re-considered on the next page.
+ var t0 = DateTime.UtcNow;
+ var (rkNew, _) = Row(t0, "x-new");
+ var (rkMid, _) = Row(t0.AddMinutes(-5), "x-mid");
+ var (rkOld, _) = Row(t0.AddMinutes(-10), "x-old");
+ var fetched = new[]
+ {
+ Fetched("Agent",
+ (rkNew, Entry("Agent", t0, "x-new")),
+ (rkMid, Entry("Agent", t0.AddMinutes(-5), "x-mid")),
+ (rkOld, Entry("Agent", t0.AddMinutes(-10), "x-old"))),
+ // A second partition whose only event is between Agent's mid and old
+ // items, ensuring the merge ends after Agent's mid item.
+ Fetched("Security",
+ Row(t0.AddMinutes(-7), "s-only")),
+ };
+
+ var (items, conts) = MergeAndAdvance(
+ fetched,
+ new Dictionary(),
+ pageSize: 3,
+ timestampSelector: e => e.Timestamp);
+
+ // Top 3 by timestamp desc: x-new, x-mid, s-only. x-old is dropped.
+ Assert.Equal(new[] { "x-new", "x-mid", "s-only" }, items.Select(i => i.Id).ToArray());
+
+ // Agent's continuation advances to rkMid (the oldest Agent rowkey we returned).
+ // x-old will be re-fetched and re-considered next page (filter: RowKey gt rkMid).
+ Assert.Equal(rkMid, conts["Agent"].LastRowKey);
+ Assert.False(conts["Agent"].Exhausted);
+ }
+
+ // ────────── Codec round-trip ────────────────────────────────────────────
+
+ [Fact]
+ public void Encode_then_decode_round_trips_continuation_map()
+ {
+ var original = new Dictionary
+ {
+ ["Agent"] = new PartitionContinuation("0123456789012345678_aaa", false),
+ ["Security"] = new PartitionContinuation(null, true),
+ ["Consent"] = new PartitionContinuation("0987654321098765432_bbb", false),
+ };
+
+ var encoded = EncodeMultiContinuation(original);
+ var decoded = DecodeMultiContinuation(encoded);
+
+ Assert.Equal(3, decoded.Count);
+ Assert.Equal("0123456789012345678_aaa", decoded["Agent"].LastRowKey);
+ Assert.False(decoded["Agent"].Exhausted);
+ Assert.True(decoded["Security"].Exhausted);
+ Assert.Equal("0987654321098765432_bbb", decoded["Consent"].LastRowKey);
+ }
+
+ [Fact]
+ public void Decode_returns_empty_for_malformed_or_empty_continuation()
+ {
+ Assert.Empty(DecodeMultiContinuation(null));
+ Assert.Empty(DecodeMultiContinuation(""));
+ Assert.Empty(DecodeMultiContinuation("not-base64-!@#"));
+ Assert.Empty(DecodeMultiContinuation("dGhpcyBpcyBub3QganNvbg==")); // valid base64, not JSON
+ }
+
+ // ────────── End-to-end: drain across pages preserves all items, in order ──
+
+ [Fact]
+ public void Multi_page_drain_returns_all_items_globally_newest_first_no_duplicates()
+ {
+ var t0 = new DateTime(2026, 5, 5, 12, 0, 0, DateTimeKind.Utc);
+
+ var allItems = new List<(string Part, OpsEventEntry Item, string Rk)>();
+ var partitions = new[] { "Agent", "Security", "Tenant" };
+ var minute = 0;
+ foreach (var part in partitions)
+ {
+ for (int i = 0; i < 4; i++)
+ {
+ minute++;
+ var ts = t0.AddMinutes(-minute);
+ var (rk, _) = Row(ts, $"{part}-{i}");
+ allItems.Add((part, new OpsEventEntry
+ {
+ Category = part,
+ Id = $"{part}-{i}",
+ Timestamp = ts,
+ EventType = "synthetic",
+ }, rk));
+ }
+ }
+
+ IReadOnlyList> SimulateFetch(
+ Dictionary continuations, int pageSize)
+ {
+ var fetched = new List>();
+ foreach (var part in partitions)
+ {
+ if (continuations.TryGetValue(part, out var c) && c.Exhausted) continue;
+ continuations.TryGetValue(part, out var prior);
+ var lastRk = prior?.LastRowKey;
+
+ var rows = allItems
+ .Where(t => t.Part == part)
+ .Where(t => lastRk == null || string.Compare(t.Rk, lastRk, StringComparison.Ordinal) > 0)
+ .OrderBy(t => t.Rk, StringComparer.Ordinal)
+ .Take(pageSize)
+ .Select(t => (t.Rk, t.Item))
+ .ToList();
+ fetched.Add(new PartitionFetchResult(part, rows));
+ }
+ return fetched;
+ }
+
+ const int pageSize = 5;
+ var seen = new List();
+ var conts = new Dictionary();
+ var loopGuard = 0;
+
+ while (true)
+ {
+ var fetched = SimulateFetch(conts, pageSize);
+ var (page, nextConts) = MergeAndAdvance(fetched, conts, pageSize, e => e.Timestamp);
+ seen.AddRange(page);
+ conts = nextConts;
+
+ bool anyActive = conts.Any(kv => !kv.Value.Exhausted);
+ if (!anyActive) break;
+ if (page.Count == 0) break;
+ if (++loopGuard > 50) Assert.Fail("Drain loop did not terminate");
+ }
+
+ Assert.Equal(12, seen.Count);
+ Assert.Equal(12, seen.Select(e => e.Id).Distinct().Count());
+ for (int i = 1; i < seen.Count; i++)
+ {
+ Assert.True(seen[i - 1].Timestamp >= seen[i].Timestamp,
+ $"Out-of-order at index {i}: {seen[i - 1].Id}({seen[i - 1].Timestamp}) before {seen[i].Id}({seen[i].Timestamp})");
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs
index d1953c86..34f01721 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlatformMetricsServiceWindowTests.cs
@@ -16,8 +16,8 @@ public class PlatformMetricsServiceWindowTests
{
var sessionRepo = new Mock();
sessionRepo
- .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny()))
- .ReturnsAsync(new SessionPage { Sessions = new List(), HasMore = false });
+ .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new List());
sessionRepo
.Setup(r => r.GetSessionEventsAsync(It.IsAny(), It.IsAny(), It.IsAny()))
.ReturnsAsync(new List());
@@ -37,9 +37,9 @@ public class PlatformMetricsServiceWindowTests
int? capturedDays = null;
repo
- .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny()))
- .Callback((_, _, d) => capturedDays = d)
- .ReturnsAsync(new SessionPage { Sessions = new List(), HasMore = false });
+ .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny()))
+ .Callback((_, d) => capturedDays = d)
+ .ReturnsAsync(new List());
var result = await service.ComputePlatformMetricsAsync(days);
@@ -54,9 +54,9 @@ public class PlatformMetricsServiceWindowTests
int? capturedDays = null;
repo
- .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny(), It.IsAny()))
- .Callback((_, _, d) => capturedDays = d)
- .ReturnsAsync(new SessionPage { Sessions = new List(), HasMore = false });
+ .Setup(r => r.GetAllSessionsAsync(It.IsAny(), It.IsAny()))
+ .Callback((_, d) => capturedDays = d)
+ .ReturnsAsync(new List());
var result = await service.ComputePlatformMetricsAsync(0);
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/QueryRawEventsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/QueryRawEventsPaginationTests.cs
new file mode 100644
index 00000000..4f93ddbe
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/QueryRawEventsPaginationTests.cs
@@ -0,0 +1,393 @@
+using System.Collections.Specialized;
+using AutopilotMonitor.Functions.Pagination;
+using AutopilotMonitor.Shared.Pagination;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// PR 6 (mcp-pagination-rollout) — pagination wiring on
+/// GET /api/raw/events + GET /api/global/raw/events. Replaces the
+/// legacy ?limit= wire shape and the hard-coded limit:20
+/// EventTypeIndex lookup that silently capped recall on large tenants.
+/// Includes the single-session path, which paginates session events so
+/// sessions with more matching events than pageSize stay reachable
+/// across multiple nextLink follow-ups.
+///
+public class QueryRawEventsPaginationTests
+{
+ private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+ private const string FilterA = "00000000-1111-2222-3333-444444444444";
+ private const string SessionA = "11111111-2222-3333-4444-555555555555";
+ private const string SessionB = "99999999-8888-7777-6666-555555555555";
+
+ // ────────── ParsePagination ─────────────────────────────────────────────
+
+ [Fact]
+ public void ParsePagination_with_no_params_returns_default_pageSize()
+ {
+ var parsed = QueryRawEventsPagination.ParsePagination(new NameValueCollection());
+
+ Assert.Null(parsed.Error);
+ Assert.Equal(QueryRawEventsPagination.DefaultPageSize, parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ }
+
+ [Fact]
+ public void ParsePagination_honours_pageSize_and_continuation()
+ {
+ var parsed = QueryRawEventsPagination.ParsePagination(new NameValueCollection
+ {
+ { "pageSize", "500" },
+ { "continuation", "ENCODED" },
+ });
+
+ Assert.Equal(500, parsed.PageSize);
+ Assert.Equal("ENCODED", parsed.Continuation);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-5")]
+ [InlineData("1001")]
+ [InlineData("banana")]
+ public void ParsePagination_rejects_invalid_pageSize(string raw)
+ {
+ var parsed = QueryRawEventsPagination.ParsePagination(new NameValueCollection { { "pageSize", raw } });
+ Assert.NotNull(parsed.Error);
+ }
+
+ // ────────── Cross-session token round-trip + cross-binding rejections ───
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_for_matching_filters()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant",
+ callerTenantId: TenantA,
+ filterTenantId: null,
+ sessionId: null,
+ eventType: "app_install_failed",
+ source: "Teams",
+ severity: "Error",
+ startedAfter: null,
+ startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant",
+ callerTenantId: TenantA,
+ filterTenantId: null,
+ sessionId: null,
+ eventType: "app_install_failed",
+ source: "Teams",
+ severity: "Error",
+ startedAfter: null,
+ startedBefore: null,
+ out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_for_a_different_caller()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantB,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("cross_tenant", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_eventType_changes()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "error_detected", source: null, severity: null,
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_severity_changes()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: "Error",
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: "Critical",
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_source_changes()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: "Teams", severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: "Office", severity: null,
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_date_window_changes()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: "2026-04-01T00:00:00Z", startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: "2026-05-01T00:00:00Z", startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_scope_changes()
+ {
+ var fpTenant = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:global", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_global_filterTenantId_changes()
+ {
+ var fpA = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:global", callerTenantId: TenantA,
+ filterTenantId: FilterA, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:global", callerTenantId: TenantA,
+ filterTenantId: "99999999-9999-9999-9999-999999999999",
+ sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ // ────────── Single-session path: round-trip + cross-binding ─────────────
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_on_single_session_path()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: SessionA,
+ eventType: null, source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: SessionA,
+ eventType: null, source: null, severity: null,
+ startedAfter: null, startedBefore: null,
+ out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_replayed_for_a_different_session()
+ {
+ // Cursor issued for sessionA must NOT be accepted when caller switches to
+ // sessionB — otherwise events from sessionB could be served against the
+ // /api/raw/events?sessionId= API contract.
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: SessionA,
+ eventType: null, source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: SessionB,
+ eventType: null, source: null, severity: null,
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_single_session_token_replayed_into_cross_session_path()
+ {
+ // Token bound to sessionId = SessionA must not silently work when the
+ // caller drops sessionId and queries cross-session by eventType only.
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: SessionA,
+ eventType: null, source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_single_session_token_when_severity_filter_changes()
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: SessionA,
+ eventType: null, source: null, severity: "Error",
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = QueryRawEventsPagination.TryAcceptContinuation(
+ raw: encoded,
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: SessionA,
+ eventType: null, source: null, severity: "Critical",
+ startedAfter: null, startedBefore: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ // ────────── BuildNextLink ───────────────────────────────────────────────
+
+ [Fact]
+ public void BuildNextLink_echoes_filter_params_and_drops_legacy_keys()
+ {
+ var query = new NameValueCollection
+ {
+ { "eventType", "app_install_failed" },
+ { "severity", "Error" },
+ { "source", "Teams" },
+ { "startedAfter", "2026-04-01T00:00:00Z" },
+ { "limit", "100" }, // legacy → must be dropped
+ { "pageSize", "999" }, // owned by new contract — overwritten
+ { "continuation", "STALE" }, // owned by new contract — overwritten
+ };
+
+ var link = QueryRawEventsPagination.BuildNextLink(
+ "/api/raw/events", 200, "FRESH+/=", query);
+
+ Assert.StartsWith("/api/raw/events?", link);
+ Assert.Contains("pageSize=200", link);
+ Assert.Contains("continuation=FRESH%2B%2F%3D", link);
+ Assert.Contains("eventType=app_install_failed", link);
+ Assert.Contains("severity=Error", link);
+ Assert.Contains("source=Teams", link);
+ Assert.Contains("startedAfter=2026-04-01T00%3A00%3A00Z", link);
+ Assert.DoesNotContain("limit=", link);
+ Assert.DoesNotContain("STALE", link);
+ }
+
+ [Fact]
+ public void BuildNextLink_preserves_sessionId_on_single_session_path()
+ {
+ // Single-session path must echo sessionId so the bookmark survives
+ // round-trips and the AI/UI doesn't lose context across pages.
+ var query = new NameValueCollection
+ {
+ { "sessionId", SessionA },
+ { "tenantId", TenantA },
+ };
+
+ var link = QueryRawEventsPagination.BuildNextLink(
+ "/api/raw/events", 200, "TOKEN", query);
+
+ Assert.Contains($"sessionId={SessionA}", link);
+ Assert.Contains($"tenantId={TenantA}", link);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsByEventPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsByEventPaginationTests.cs
new file mode 100644
index 00000000..bff03f22
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsByEventPaginationTests.cs
@@ -0,0 +1,183 @@
+using System.Collections.Specialized;
+using AutopilotMonitor.Functions.Pagination;
+using AutopilotMonitor.Shared.Pagination;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pagination wiring on GET /api/search/sessions-by-event +
+/// GET /api/global/search/sessions-by-event — closes the gap left after
+/// PR-6 where the repo's paged variant existed but the endpoint kept its
+/// legacy ?limit= max-100 contract.
+///
+public class SearchSessionsByEventPaginationTests
+{
+ private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+ private const string FilterA = "00000000-1111-2222-3333-444444444444";
+
+ // ────────── ParsePagination ─────────────────────────────────────────────
+
+ [Fact]
+ public void ParsePagination_with_no_params_returns_default_pageSize()
+ {
+ var parsed = SearchSessionsByEventPagination.ParsePagination(new NameValueCollection());
+
+ Assert.Null(parsed.Error);
+ Assert.Equal(SearchSessionsByEventPagination.DefaultPageSize, parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ }
+
+ [Fact]
+ public void ParsePagination_honours_pageSize_and_continuation()
+ {
+ var parsed = SearchSessionsByEventPagination.ParsePagination(new NameValueCollection
+ {
+ { "pageSize", "200" },
+ { "continuation", "ENCODED" },
+ });
+
+ Assert.Equal(200, parsed.PageSize);
+ Assert.Equal("ENCODED", parsed.Continuation);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-5")]
+ [InlineData("1001")]
+ [InlineData("banana")]
+ public void ParsePagination_rejects_invalid_pageSize(string raw)
+ {
+ var parsed = SearchSessionsByEventPagination.ParsePagination(new NameValueCollection { { "pageSize", raw } });
+ Assert.NotNull(parsed.Error);
+ }
+
+ // ────────── Token round-trip + cross-binding rejections ─────────────────
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_for_matching_eventType()
+ {
+ var fp = SearchSessionsByEventPagination.Fingerprint(
+ "search-by-event:tenant", TenantA, filterTenantId: null, eventType: "app_install_failed");
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = SearchSessionsByEventPagination.TryAcceptContinuation(
+ encoded, "search-by-event:tenant", TenantA, filterTenantId: null,
+ eventType: "app_install_failed", out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_for_different_caller()
+ {
+ var fp = SearchSessionsByEventPagination.Fingerprint(
+ "search-by-event:tenant", TenantA, null, "app_install_failed");
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = SearchSessionsByEventPagination.TryAcceptContinuation(
+ encoded, "search-by-event:tenant", TenantB, null, "app_install_failed",
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("cross_tenant", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_eventType_changes()
+ {
+ var fp = SearchSessionsByEventPagination.Fingerprint(
+ "search-by-event:tenant", TenantA, null, "app_install_failed");
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = SearchSessionsByEventPagination.TryAcceptContinuation(
+ encoded, "search-by-event:tenant", TenantA, null, "error_detected",
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_scope_changes()
+ {
+ // Tenant-scoped token replayed into the global endpoint — tokens must
+ // be non-fungible across the two endpoints even with the same caller +
+ // eventType (the underlying queries differ).
+ var fpTenant = SearchSessionsByEventPagination.Fingerprint(
+ "search-by-event:tenant", TenantA, null, "app_install_failed");
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant);
+
+ var ok = SearchSessionsByEventPagination.TryAcceptContinuation(
+ encoded, "search-by-event:global", TenantA, null, "app_install_failed",
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_global_filterTenantId_changes()
+ {
+ var fpA = SearchSessionsByEventPagination.Fingerprint(
+ "search-by-event:global", TenantA, FilterA, "app_install_failed");
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA);
+
+ var ok = SearchSessionsByEventPagination.TryAcceptContinuation(
+ encoded, "search-by-event:global", TenantA,
+ filterTenantId: "99999999-9999-9999-9999-999999999999",
+ eventType: "app_install_failed",
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_across_search_by_event_and_raw_events_scopes()
+ {
+ // A token from the cousin endpoint /api/raw/events (raw-events:tenant)
+ // must not be accepted here, even with identical eventType + caller.
+ var fpRaw = QueryRawEventsPagination.Fingerprint(
+ scope: "raw-events:tenant", callerTenantId: TenantA,
+ filterTenantId: null, sessionId: null,
+ eventType: "app_install_failed", source: null, severity: null,
+ startedAfter: null, startedBefore: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpRaw);
+
+ var ok = SearchSessionsByEventPagination.TryAcceptContinuation(
+ encoded, "search-by-event:tenant", TenantA, null, "app_install_failed",
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ // ────────── BuildNextLink ───────────────────────────────────────────────
+
+ [Fact]
+ public void BuildNextLink_echoes_filter_params_and_drops_legacy_keys()
+ {
+ var query = new NameValueCollection
+ {
+ { "eventType", "app_install_failed" },
+ { "tenantId", FilterA },
+ { "limit", "100" }, // legacy → must be dropped
+ { "pageSize", "999" }, // overwritten by the new contract
+ { "continuation", "STALE" }, // overwritten by the new contract
+ };
+
+ var link = SearchSessionsByEventPagination.BuildNextLink(
+ "/api/global/search/sessions-by-event", 50, "FRESH+/=", query);
+
+ Assert.StartsWith("/api/global/search/sessions-by-event?", link);
+ Assert.Contains("pageSize=50", link);
+ Assert.Contains("continuation=FRESH%2B%2F%3D", link);
+ Assert.Contains("eventType=app_install_failed", link);
+ Assert.Contains($"tenantId={FilterA}", link);
+ Assert.DoesNotContain("limit=", link);
+ Assert.DoesNotContain("STALE", link);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsPaginationTests.cs
new file mode 100644
index 00000000..fd00015a
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SearchSessionsPaginationTests.cs
@@ -0,0 +1,224 @@
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using AutopilotMonitor.Functions.Pagination;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// PR 5 (mcp-pagination-rollout) — pagination wiring on
+/// GET /api/search/sessions + GET /api/global/search/sessions.
+/// Replaces the legacy ?limit= wire shape with the rollout plan's
+/// ?pageSize= + ?continuation= + nextLink contract.
+///
+public class SearchSessionsPaginationTests
+{
+ private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+
+ private static SessionSearchFilter SimpleFilter() => new() { Status = "Failed", Manufacturer = "Microsoft" };
+
+ private static SessionSearchFilter HardwareFilter() => new()
+ {
+ Status = "Failed",
+ DeviceProperties = new Dictionary
+ {
+ ["tpm_status.specVersion"] = "2.0",
+ ["hardware_spec.ramTotalGB"] = ">=8",
+ },
+ };
+
+ // ────────── ParsePagination ─────────────────────────────────────────────
+
+ [Fact]
+ public void ParsePagination_with_no_params_returns_default_pageSize()
+ {
+ var parsed = SearchSessionsPagination.ParsePagination(new NameValueCollection());
+
+ Assert.Null(parsed.Error);
+ Assert.Equal(SearchSessionsPagination.DefaultPageSize, parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ }
+
+ [Fact]
+ public void ParsePagination_honours_pageSize_and_continuation()
+ {
+ var parsed = SearchSessionsPagination.ParsePagination(new NameValueCollection
+ {
+ { "pageSize", "200" },
+ { "continuation", "ENCODED" },
+ });
+
+ Assert.Equal(200, parsed.PageSize);
+ Assert.Equal("ENCODED", parsed.Continuation);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("1001")]
+ [InlineData("banana")]
+ public void ParsePagination_rejects_invalid_pageSize(string raw)
+ {
+ var parsed = SearchSessionsPagination.ParsePagination(
+ new NameValueCollection { { "pageSize", raw } });
+ Assert.NotNull(parsed.Error);
+ }
+
+ // ────────── Token round-trip + cross-binding rejections ─────────────────
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_for_matching_filter()
+ {
+ var filter = SimpleFilter();
+ var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, filterTenantId: null, filter);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = SearchSessionsPagination.TryAcceptContinuation(
+ encoded, "search:tenant", TenantA, filterTenantId: null, filter,
+ out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_for_different_caller()
+ {
+ var filter = SimpleFilter();
+ var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, filterTenantId: null, filter);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = SearchSessionsPagination.TryAcceptContinuation(
+ encoded, "search:tenant", TenantB, filterTenantId: null, filter,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("cross_tenant", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_status_changes()
+ {
+ var filterFailed = new SessionSearchFilter { Status = "Failed" };
+ var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, filterFailed);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var filterSucceeded = new SessionSearchFilter { Status = "Succeeded" };
+ var ok = SearchSessionsPagination.TryAcceptContinuation(
+ encoded, "search:tenant", TenantA, null, filterSucceeded,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_across_scan_and_snapshot_paths()
+ {
+ // Same other filters, but adding/removing deviceProperties switches the
+ // backend code path (scan ↔ device-snapshot). The fingerprint encodes
+ // "path" so the cursor can't be replayed across paths.
+ var simple = SimpleFilter();
+ var hardware = HardwareFilter();
+ var fpScan = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, simple);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpScan);
+
+ var ok = SearchSessionsPagination.TryAcceptContinuation(
+ encoded, "search:tenant", TenantA, null, hardware,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_device_property_changes()
+ {
+ var filterTpm20 = new SessionSearchFilter
+ {
+ DeviceProperties = new Dictionary { ["tpm_status.specVersion"] = "2.0" },
+ };
+ var fp = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, filterTpm20);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var filterTpm12 = new SessionSearchFilter
+ {
+ DeviceProperties = new Dictionary { ["tpm_status.specVersion"] = "1.2" },
+ };
+ var ok = SearchSessionsPagination.TryAcceptContinuation(
+ encoded, "search:tenant", TenantA, null, filterTpm12,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_scope_changes()
+ {
+ var filter = SimpleFilter();
+ var fpTenant = SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, filter);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant);
+
+ var ok = SearchSessionsPagination.TryAcceptContinuation(
+ encoded, "search:global", TenantA, null, filter,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ // ────────── BuildNextLink ───────────────────────────────────────────────
+
+ [Fact]
+ public void BuildNextLink_echoes_filter_params_and_drops_legacy_keys()
+ {
+ var query = new NameValueCollection
+ {
+ { "status", "Failed" },
+ { "manufacturer", "Microsoft" },
+ { "limit", "100" }, // legacy — must be dropped
+ { "pageSize", "200" }, // owned by new contract — overwritten
+ { "continuation", "STALE" }, // owned by new contract — overwritten
+ { "prop.tpm_status.specVersion", "2.0" },
+ };
+
+ var link = SearchSessionsPagination.BuildNextLink(
+ "/api/search/sessions", 50, "FRESH+/=", query);
+
+ Assert.StartsWith("/api/search/sessions?", link);
+ Assert.Contains("pageSize=50", link);
+ Assert.Contains("continuation=FRESH%2B%2F%3D", link);
+ Assert.Contains("status=Failed", link);
+ Assert.Contains("manufacturer=Microsoft", link);
+ Assert.Contains("prop.tpm_status.specVersion=2.0", link);
+ Assert.DoesNotContain("limit=", link);
+ Assert.DoesNotContain("STALE", link);
+ }
+
+ [Fact]
+ public void Fingerprint_is_stable_regardless_of_deviceProperties_iteration_order()
+ {
+ var f1 = new SessionSearchFilter
+ {
+ DeviceProperties = new Dictionary
+ {
+ ["a"] = "1",
+ ["b"] = "2",
+ },
+ };
+ var f2 = new SessionSearchFilter
+ {
+ DeviceProperties = new Dictionary
+ {
+ ["b"] = "2",
+ ["a"] = "1",
+ },
+ };
+ Assert.Equal(
+ SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, f1),
+ SearchSessionsPagination.Fingerprint("search:tenant", TenantA, null, f2));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs
new file mode 100644
index 00000000..1244c49e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionEventsPaginationTests.cs
@@ -0,0 +1,236 @@
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using AutopilotMonitor.Functions.Pagination;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// PR 2 (mcp-pagination-rollout) — pagination wiring on
+/// GET /api/sessions/{sessionId}/events.
+///
+public class SessionEventsPaginationTests
+{
+ private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string SessionA = "11111111-2222-3333-4444-555555555555";
+ private const string SessionB = "99999999-8888-7777-6666-555555555555";
+
+ // ---------- (a) no pageSize → unpaginated path; query parser surfaces null ----------
+
+ [Fact]
+ public void ParseQuery_without_pageSize_keeps_unpaginated_legacy_path()
+ {
+ var parsed = SessionEventsPagination.ParseQuery(new NameValueCollection());
+
+ Assert.Null(parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ Assert.Null(parsed.Error);
+ }
+
+ [Fact]
+ public void ParseQuery_drops_continuation_when_pageSize_absent()
+ {
+ // continuation alone is meaningless — silently dropped so a stale token in
+ // a bookmark does not force callers into an error path.
+ var qs = new NameValueCollection { { "continuation", "abc" } };
+ var parsed = SessionEventsPagination.ParseQuery(qs);
+
+ Assert.Null(parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ }
+
+ // ---------- (b) with pageSize → paginated path; helper surfaces values ----------
+
+ [Fact]
+ public void ParseQuery_with_pageSize_activates_paginated_path()
+ {
+ var qs = new NameValueCollection
+ {
+ { "pageSize", "100" },
+ { "continuation", "ENCODED" },
+ };
+ var parsed = SessionEventsPagination.ParseQuery(qs);
+
+ Assert.Equal(100, parsed.PageSize);
+ Assert.Equal("ENCODED", parsed.Continuation);
+ Assert.Null(parsed.Error);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-5")]
+ [InlineData("1001")]
+ public void ParseQuery_rejects_pageSize_outside_range(string raw)
+ {
+ var parsed = SessionEventsPagination.ParseQuery(new NameValueCollection { { "pageSize", raw } });
+ Assert.NotNull(parsed.Error);
+ }
+
+ [Fact]
+ public void ParseQuery_rejects_non_integer_pageSize()
+ {
+ var parsed = SessionEventsPagination.ParseQuery(new NameValueCollection { { "pageSize", "banana" } });
+ Assert.NotNull(parsed.Error);
+ }
+
+ [Fact]
+ public void BuildNextLink_includes_pageSize_continuation_and_optional_tenantId()
+ {
+ var link = SessionEventsPagination.BuildNextLink(SessionA, 200, "TOKEN+/=", TenantA);
+
+ Assert.StartsWith($"/api/sessions/{SessionA}/events?", link);
+ Assert.Contains("pageSize=200", link);
+ Assert.Contains("continuation=TOKEN%2B%2F%3D", link); // url-escaped
+ Assert.Contains($"tenantId={TenantA}", link);
+ }
+
+ [Fact]
+ public void BuildNextLink_omits_tenantId_when_caller_does_not_pass_it()
+ {
+ var link = SessionEventsPagination.BuildNextLink(SessionA, 200, "abc", null);
+
+ Assert.DoesNotContain("tenantId=", link);
+ }
+
+ // ---------- (e) GA cross-tenant nextLink → token round-trip ----------
+
+ [Fact]
+ public void NextLink_carries_resolved_tenantId_for_GA_cross_tenant_round_trip()
+ {
+ // Regression: previously the function called BuildNextLink with the raw
+ // ?tenantId= query param, which is null when GA reads /api/sessions/{id}/events
+ // without explicitly passing tenantId. Page 1 then bound the token to the
+ // resolved tenant (via FindSessionTenantIdAsync), but the nextLink omitted
+ // it — so page 2 validated against the GA's JWT tenant and the token was
+ // rejected as cross_tenant. Fix: nextLink now carries the resolved tenantId
+ // verbatim, and the function honours ?tenantId= on follow-up pages for GA.
+ const string ResolvedTenant = "c3d4e5f6-a7b8-9012-cdef-abcdef123456";
+
+ var fp = SessionEventsPagination.Fingerprint(ResolvedTenant, SessionA);
+ var encoded = ContinuationToken.Encode("rawAzureToken", ResolvedTenant, fp);
+ var link = SessionEventsPagination.BuildNextLink(SessionA, 200, encoded, ResolvedTenant);
+
+ // Wire-form: tenantId is in the URL so the next-call extractor sees it.
+ Assert.Contains($"tenantId={ResolvedTenant}", link);
+
+ // And the token validates against that same tenant — no cross_tenant reject.
+ var ok = SessionEventsPagination.TryAcceptContinuation(
+ encoded, ResolvedTenant, SessionA, out var azure, out var reason);
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzureToken", azure);
+ }
+
+ // ---------- (d) cross-session continuation rejection ----------
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_issued_for_a_different_session()
+ {
+ var fpA = SessionEventsPagination.Fingerprint(TenantA, SessionA);
+ var encodedForA = ContinuationToken.Encode("rawAzureToken", TenantA, fpA);
+
+ var ok = SessionEventsPagination.TryAcceptContinuation(
+ encodedForA, TenantA, SessionB, out var azure, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ Assert.Equal(string.Empty, azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_issued_for_a_different_tenant()
+ {
+ var fpA = SessionEventsPagination.Fingerprint(TenantA, SessionA);
+ var encodedForA = ContinuationToken.Encode("rawAzureToken", TenantA, fpA);
+
+ const string TenantB = "00000000-0000-0000-0000-000000000099";
+ var ok = SessionEventsPagination.TryAcceptContinuation(
+ encodedForA, TenantB, SessionA, out var azure, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("cross_tenant", reason);
+ Assert.Equal(string.Empty, azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_for_matching_session_and_tenant()
+ {
+ var fp = SessionEventsPagination.Fingerprint(TenantA, SessionA);
+ var encoded = ContinuationToken.Encode("rawAzureToken", TenantA, fp);
+
+ var ok = SessionEventsPagination.TryAcceptContinuation(
+ encoded, TenantA, SessionA, out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzureToken", azure);
+ }
+
+ // ---------- (c) consume-until-absent → repo total reachable across pages ----------
+
+ [Fact]
+ public async Task ConsumeUntilAbsent_drains_all_events_from_paged_repository()
+ {
+ // Simulates a 5-page session (200 events/page) plus a final partial page —
+ // the same loop the UI/AI follow until the response no longer carries a
+ // nextRawToken. Verifies the contract: full data is reachable across N
+ // calls without truncation.
+ const int totalEvents = 1083;
+ const int pageSize = 200;
+ var pages = SplitIntoPages(totalEvents, pageSize);
+
+ var repo = new Mock(MockBehavior.Strict);
+
+ var callIndex = 0;
+ repo
+ .Setup(r => r.GetSessionEventsPageAsync(TenantA, SessionA, pageSize, It.IsAny()))
+ .ReturnsAsync(() => pages[callIndex++]);
+
+ var collected = new List();
+ string? continuation = null;
+ var loopGuard = 0;
+ while (true)
+ {
+ var page = await repo.Object.GetSessionEventsPageAsync(TenantA, SessionA, pageSize, continuation);
+ collected.AddRange(page.Items);
+ if (string.IsNullOrEmpty(page.NextRawToken)) break;
+ continuation = page.NextRawToken;
+ if (++loopGuard > 50) Assert.Fail("Pagination loop did not terminate within 50 iterations");
+ }
+
+ Assert.Equal(totalEvents, collected.Count);
+ // Sequence must be unique and complete (1..N) — no events lost or duplicated.
+ var sequences = new HashSet(System.Linq.Enumerable.Select(collected, e => e.Sequence));
+ Assert.Equal(totalEvents, sequences.Count);
+ Assert.Equal(1L, System.Linq.Enumerable.Min(sequences));
+ Assert.Equal((long)totalEvents, System.Linq.Enumerable.Max(sequences));
+ }
+
+ private static List> SplitIntoPages(int total, int pageSize)
+ {
+ var result = new List>();
+ long seq = 1;
+ var emitted = 0;
+ while (emitted < total)
+ {
+ var take = System.Math.Min(pageSize, total - emitted);
+ var batch = new List(take);
+ for (int i = 0; i < take; i++)
+ {
+ batch.Add(new EnrollmentEvent
+ {
+ SessionId = SessionA,
+ TenantId = TenantA,
+ Sequence = seq++,
+ EventType = "synthetic",
+ });
+ }
+ emitted += take;
+ var hasMore = emitted < total;
+ result.Add(new RawPage(batch, hasMore ? $"raw-cursor-{result.Count + 1}" : null));
+ }
+ return result;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionListPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionListPaginationTests.cs
new file mode 100644
index 00000000..9573d34e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionListPaginationTests.cs
@@ -0,0 +1,266 @@
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using AutopilotMonitor.Functions.Pagination;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// PR 5 (mcp-pagination-rollout) — pagination wiring on
+/// GET /api/sessions + GET /api/global/sessions. Replaces the
+/// legacy ?limit= + ?cursor= wire shape with the rollout plan's
+/// ?pageSize= + ?continuation= + nextLink contract.
+///
+public class SessionListPaginationTests
+{
+ private const string TenantA = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string TenantB = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+
+ // ────────── ParseQuery ──────────────────────────────────────────────────
+
+ [Fact]
+ public void ParseQuery_with_no_params_returns_default_pageSize()
+ {
+ var parsed = SessionListPagination.ParseQuery(new NameValueCollection(), acceptFilterTenantId: false);
+
+ Assert.Null(parsed.Error);
+ Assert.Equal(SessionListPagination.DefaultPageSize, parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ Assert.Null(parsed.Days);
+ Assert.Null(parsed.FilterTenantId);
+ }
+
+ [Fact]
+ public void ParseQuery_honours_pageSize_and_continuation()
+ {
+ var parsed = SessionListPagination.ParseQuery(new NameValueCollection
+ {
+ { "pageSize", "10" },
+ { "continuation", "ENCODED" },
+ }, acceptFilterTenantId: false);
+
+ Assert.Equal(10, parsed.PageSize);
+ Assert.Equal("ENCODED", parsed.Continuation);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-5")]
+ [InlineData("1001")]
+ [InlineData("banana")]
+ public void ParseQuery_rejects_invalid_pageSize(string raw)
+ {
+ var parsed = SessionListPagination.ParseQuery(
+ new NameValueCollection { { "pageSize", raw } },
+ acceptFilterTenantId: false);
+ Assert.NotNull(parsed.Error);
+ }
+
+ [Fact]
+ public void ParseQuery_carries_days_filter()
+ {
+ var parsed = SessionListPagination.ParseQuery(
+ new NameValueCollection { { "days", "30" } },
+ acceptFilterTenantId: false);
+ Assert.Equal(30, parsed.Days);
+ }
+
+ [Fact]
+ public void ParseQuery_rejects_invalid_days()
+ {
+ var parsed = SessionListPagination.ParseQuery(
+ new NameValueCollection { { "days", "0" } },
+ acceptFilterTenantId: false);
+ Assert.NotNull(parsed.Error);
+ }
+
+ [Fact]
+ public void ParseQuery_ignores_filter_tenantId_when_not_accepted()
+ {
+ // The tenant-scoped endpoint must never honour ?tenantId= in the query
+ // — that's an authorization vector. acceptFilterTenantId=false skips it.
+ var parsed = SessionListPagination.ParseQuery(
+ new NameValueCollection { { "tenantId", TenantB } },
+ acceptFilterTenantId: false);
+ Assert.Null(parsed.FilterTenantId);
+ }
+
+ [Fact]
+ public void ParseQuery_carries_filter_tenantId_when_accepted()
+ {
+ var parsed = SessionListPagination.ParseQuery(
+ new NameValueCollection { { "tenantId", TenantB } },
+ acceptFilterTenantId: true);
+ Assert.Equal(TenantB, parsed.FilterTenantId);
+ }
+
+ // ────────── Token round-trip + cross-binding rejections ─────────────────
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_for_matching_caller()
+ {
+ var fp = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: 30);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = SessionListPagination.TryAcceptContinuation(
+ encoded, "sessions:tenant", TenantA, days: 30, filterTenantId: null,
+ out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_for_a_different_caller()
+ {
+ var fp = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp);
+
+ var ok = SessionListPagination.TryAcceptContinuation(
+ encoded, "sessions:tenant", TenantB, days: null, filterTenantId: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("cross_tenant", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_days_changes()
+ {
+ var fp30 = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: 30);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fp30);
+
+ var ok = SessionListPagination.TryAcceptContinuation(
+ encoded, "sessions:tenant", TenantA, days: 90, filterTenantId: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_scope_changes()
+ {
+ // Token from /api/sessions (tenant scope) replayed into /api/global/sessions
+ // (global scope) — must reject.
+ var fpTenant = SessionListPagination.Fingerprint("sessions:tenant", TenantA, days: null);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpTenant);
+
+ var ok = SessionListPagination.TryAcceptContinuation(
+ encoded, "sessions:global", TenantA, days: null, filterTenantId: null,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_filterTenantId_changes_on_global()
+ {
+ // Global endpoint with ?tenantId=A then ?tenantId=B → token from page 1
+ // must not seek into a different tenant's data on page 2.
+ var fpA = SessionListPagination.Fingerprint("sessions:global", TenantA, days: null, filterTenantId: TenantA);
+ var encoded = ContinuationToken.Encode("rawAzure", TenantA, fpA);
+
+ var ok = SessionListPagination.TryAcceptContinuation(
+ encoded, "sessions:global", TenantA, days: null, filterTenantId: TenantB,
+ out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ // ────────── BuildNextLink ───────────────────────────────────────────────
+
+ [Fact]
+ public void BuildNextLink_includes_pageSize_continuation_days_filterTenantId()
+ {
+ var link = SessionListPagination.BuildNextLink(
+ "/api/global/sessions", 10, "TOKEN+/=", days: 30, filterTenantId: TenantA);
+
+ Assert.StartsWith("/api/global/sessions?", link);
+ Assert.Contains("pageSize=10", link);
+ Assert.Contains("continuation=TOKEN%2B%2F%3D", link);
+ Assert.Contains("days=30", link);
+ Assert.Contains($"tenantId={TenantA}", link);
+ }
+
+ [Fact]
+ public void BuildNextLink_omits_optional_params_when_unset()
+ {
+ var link = SessionListPagination.BuildNextLink(
+ "/api/sessions", 50, "abc", days: null, filterTenantId: null);
+
+ Assert.Contains("pageSize=50", link);
+ Assert.Contains("continuation=abc", link);
+ Assert.DoesNotContain("days=", link);
+ Assert.DoesNotContain("tenantId=", link);
+ }
+
+ // ────────── Repo-contract: drain MUST keep paginating with days set ─────
+
+ [Fact]
+ public async Task ConsumeUntilAbsent_drains_all_sessions_with_days_via_continuation_loop()
+ {
+ // Regression guard for the storage-layer short-circuit that previously
+ // returned NextRawToken=null whenever `days` was set, capping callers at
+ // ~10k sessions silently. After the fix, the days cutoff is applied as a
+ // RowKey filter and pagination mechanics are independent of the window —
+ // so a tenant with thousands of in-window sessions remains fully reachable
+ // across multiple pages, exactly like the no-days case.
+ const int totalSessions = 1500;
+ const int pageSize = 200;
+ const int days = 30;
+ var pages = SplitSessionPages(totalSessions, pageSize);
+
+ var repo = new Mock(MockBehavior.Strict);
+ var callIndex = 0;
+ repo
+ .Setup(r => r.GetSessionsPageAsync(TenantA, days, pageSize, It.IsAny()))
+ .ReturnsAsync(() => pages[callIndex++]);
+
+ var collected = new List();
+ string? continuation = null;
+ var loopGuard = 0;
+ while (true)
+ {
+ var page = await repo.Object.GetSessionsPageAsync(TenantA, days, pageSize, continuation);
+ collected.AddRange(page.Items);
+ if (string.IsNullOrEmpty(page.NextRawToken)) break;
+ continuation = page.NextRawToken;
+ if (++loopGuard > 50) Assert.Fail("Pagination loop did not terminate within 50 iterations");
+ }
+
+ Assert.Equal(totalSessions, collected.Count);
+ // SessionIds must be unique — no session emitted twice across pages.
+ var ids = new HashSet(System.Linq.Enumerable.Select(collected, s => s.SessionId));
+ Assert.Equal(totalSessions, ids.Count);
+ }
+
+ private static List> SplitSessionPages(int total, int pageSize)
+ {
+ var result = new List>();
+ var emitted = 0;
+ while (emitted < total)
+ {
+ var take = System.Math.Min(pageSize, total - emitted);
+ var batch = new List(take);
+ for (int i = 0; i < take; i++)
+ {
+ batch.Add(new SessionSummary
+ {
+ TenantId = TenantA,
+ SessionId = $"sess-{emitted + i:D6}",
+ StartedAt = System.DateTime.UtcNow.AddMinutes(-(emitted + i)),
+ });
+ }
+ emitted += take;
+ var hasMore = emitted < total;
+ result.Add(new RawPage(batch, hasMore ? $"raw-cursor-{result.Count + 1}" : null));
+ }
+ return result;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportsPaginationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportsPaginationTests.cs
new file mode 100644
index 00000000..7092d619
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionReportsPaginationTests.cs
@@ -0,0 +1,185 @@
+using System.Collections.Specialized;
+using AutopilotMonitor.Functions.Pagination;
+using AutopilotMonitor.Shared.Pagination;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// PR 4 (mcp-pagination-rollout) — pagination wiring on
+/// GET /api/global/session-reports. The endpoint stays GA-only at the
+/// policy layer; tenantId here is a server-side filter, not an
+/// authorization scope. The continuation token still binds to the caller's
+/// identity + the active filter so a token issued for one filter view can't be
+/// silently retargeted to another tenant's reports.
+///
+public class SessionReportsPaginationTests
+{
+ private const string CallerTenant = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
+ private const string CallerTenantOther = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
+ private const string FilterTenantA = "00000000-1111-2222-3333-444444444444";
+ private const string FilterTenantB = "55555555-6666-7777-8888-999999999999";
+
+ // ────────── ParseQuery ──────────────────────────────────────────────────
+
+ [Fact]
+ public void ParseQuery_with_no_params_returns_unfiltered_unpaginated()
+ {
+ var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection());
+
+ Assert.Null(parsed.Error);
+ Assert.Null(parsed.FilterTenantId);
+ Assert.Null(parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ }
+
+ [Fact]
+ public void ParseQuery_keeps_tenantId_filter_independent_of_pagination()
+ {
+ var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection
+ {
+ { "tenantId", FilterTenantA },
+ });
+
+ Assert.Null(parsed.Error);
+ Assert.Equal(FilterTenantA, parsed.FilterTenantId);
+ Assert.Null(parsed.PageSize);
+ }
+
+ [Fact]
+ public void ParseQuery_with_pageSize_activates_pagination()
+ {
+ var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection
+ {
+ { "pageSize", "50" },
+ { "continuation", "ENCODED" },
+ { "tenantId", FilterTenantA },
+ });
+
+ Assert.Equal(50, parsed.PageSize);
+ Assert.Equal("ENCODED", parsed.Continuation);
+ Assert.Equal(FilterTenantA, parsed.FilterTenantId);
+ }
+
+ [Fact]
+ public void ParseQuery_drops_continuation_when_pageSize_absent()
+ {
+ var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection
+ {
+ { "continuation", "STALE" },
+ });
+
+ Assert.Null(parsed.PageSize);
+ Assert.Null(parsed.Continuation);
+ }
+
+ [Theory]
+ [InlineData("0")]
+ [InlineData("-5")]
+ [InlineData("1001")]
+ [InlineData("banana")]
+ public void ParseQuery_rejects_invalid_pageSize(string raw)
+ {
+ var parsed = SessionReportsPagination.ParseQuery(new NameValueCollection { { "pageSize", raw } });
+ Assert.NotNull(parsed.Error);
+ }
+
+ // ────────── Token round-trip + cross-binding rejections ─────────────────
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_for_matching_caller_and_filter()
+ {
+ var fp = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA);
+ var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fp);
+
+ var ok = SessionReportsPagination.TryAcceptContinuation(
+ encoded, CallerTenant, FilterTenantA, out var azure, out var reason);
+
+ Assert.True(ok);
+ Assert.Null(reason);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_round_trips_for_matching_caller_when_filter_absent()
+ {
+ // GA list-all-tenants view — filterTenantId null both times → fingerprint matches.
+ var fp = SessionReportsPagination.Fingerprint(CallerTenant, filterTenantId: null);
+ var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fp);
+
+ var ok = SessionReportsPagination.TryAcceptContinuation(
+ encoded, CallerTenant, filterTenantId: null, out var azure, out _);
+
+ Assert.True(ok);
+ Assert.Equal("rawAzure", azure);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_replayed_by_a_different_caller()
+ {
+ // Anti-replay: a token issued for caller A can't be reused by caller B,
+ // even though both could query session-reports — protects deep paginated
+ // bookmark links from being replayed by a third party.
+ var fp = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA);
+ var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fp);
+
+ var ok = SessionReportsPagination.TryAcceptContinuation(
+ encoded, CallerTenantOther, FilterTenantA, out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("cross_tenant", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_filter_changes_between_calls()
+ {
+ // Caller paginates with tenantId=A then rewrites to tenantId=B — token
+ // must be rejected so the cursor can't bleed across filters.
+ var fpA = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA);
+ var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fpA);
+
+ var ok = SessionReportsPagination.TryAcceptContinuation(
+ encoded, CallerTenant, FilterTenantB, out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ [Fact]
+ public void TryAcceptContinuation_rejects_token_when_filter_drops_to_null()
+ {
+ // Caller had tenantId=A applied then cleared the filter — different
+ // filter scope, token must restart.
+ var fpA = SessionReportsPagination.Fingerprint(CallerTenant, FilterTenantA);
+ var encoded = ContinuationToken.Encode("rawAzure", CallerTenant, fpA);
+
+ var ok = SessionReportsPagination.TryAcceptContinuation(
+ encoded, CallerTenant, filterTenantId: null, out _, out var reason);
+
+ Assert.False(ok);
+ Assert.Equal("filter_mismatch", reason);
+ }
+
+ // ────────── BuildNextLink ───────────────────────────────────────────────
+
+ [Fact]
+ public void BuildNextLink_includes_pageSize_continuation_and_tenantId_when_filtering()
+ {
+ var link = SessionReportsPagination.BuildNextLink(50, "TOKEN+/=", FilterTenantA);
+
+ Assert.StartsWith("/api/global/session-reports?", link);
+ Assert.Contains("pageSize=50", link);
+ Assert.Contains("continuation=TOKEN%2B%2F%3D", link);
+ Assert.Contains($"tenantId={FilterTenantA}", link);
+ }
+
+ [Fact]
+ public void BuildNextLink_omits_tenantId_when_no_filter_active()
+ {
+ var link = SessionReportsPagination.BuildNextLink(50, "abc", filterTenantId: null);
+
+ Assert.StartsWith("/api/global/session-reports?", link);
+ Assert.Contains("pageSize=50", link);
+ Assert.Contains("continuation=abc", link);
+ Assert.DoesNotContain("tenantId=", link);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestSetup.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestSetup.cs
new file mode 100644
index 00000000..b0682c56
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestSetup.cs
@@ -0,0 +1,24 @@
+using System.Runtime.CompilerServices;
+using AutopilotMonitor.Shared.Pagination;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Test-process-wide setup. Runs once before any test executes (via the
+/// [ModuleInitializer] hook) so individual tests don't have to bother
+/// with global state — in particular, the ContinuationToken signing key.
+///
+internal static class TestSetup
+{
+ [ModuleInitializer]
+ public static void Initialize()
+ {
+ // Fixed deterministic key for reproducible test runs. Any 32 bytes will
+ // do — production reads from the PaginationTokenSigningKey env var which
+ // we deliberately do NOT inspect here, so test runs aren't affected by
+ // (or able to leak into) production secrets.
+ var key = new byte[32];
+ for (int i = 0; i < key.Length; i++) key[i] = (byte)(i * 7 + 13);
+ ContinuationToken.SetSigningKeyForTesting(key);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/PerPartitionFanOutMerge.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/PerPartitionFanOutMerge.cs
new file mode 100644
index 00000000..e633cb7f
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/PerPartitionFanOutMerge.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+
+namespace AutopilotMonitor.Functions.DataAccess.TableStorage
+{
+ ///
+ /// Generic helpers driving cross-partition merge-pagination for global views
+ /// where Azure-Tables' native (PK asc, RK asc) ordering would otherwise
+ /// surface partitions one-at-a-time instead of globally newest-first.
+ /// Used by ops-events (categories as partitions) and global audit logs
+ /// (tenants as partitions).
+ ///
+ ///
+ ///
+ /// Per-partition continuation anchored on RowKey (not Azure's opaque token):
+ /// LastRowKey = the oldest RowKey we have already returned for this
+ /// partition. Next fetch filters RowKey gt LastRowKey — natural
+ /// ordering within the partition (revtick → newest-first) means this skips
+ /// exactly what we showed and re-considers everything older.
+ ///
+ ///
+ /// Why not Azure's continuation token? Cross-partition merge can drop items
+ /// that were fetched but didn't survive the top-pageSize cut. If we advanced
+ /// an Azure cursor past those leftovers, we'd lose them forever. With a
+ /// RowKey bound we simply don't advance the continuation for partitions whose
+ /// fetched items lost the merge — they get re-fetched next call and compete
+ /// again at a lower floor.
+ ///
+ ///
+ internal static class PerPartitionFanOutMerge
+ {
+ internal sealed record PartitionContinuation(string? LastRowKey, bool Exhausted);
+
+ internal sealed record PartitionFetchResult(
+ string Partition,
+ IReadOnlyList<(string RowKey, T Item)> Items);
+
+ ///
+ /// Pure merge-and-advance step. Given pre-fetched per-partition items
+ /// (already newest-first within each list) and the previous per-partition
+ /// continuation map, returns the top-pageSize merged items plus the next
+ /// continuation map.
+ ///
+ public static (List Items, Dictionary NextContinuations)
+ MergeAndAdvance(
+ IReadOnlyList> fetched,
+ IReadOnlyDictionary priorContinuations,
+ int pageSize,
+ Func timestampSelector)
+ {
+ var allFetched = fetched
+ .SelectMany(r => r.Items.Select(t => (part: r.Partition, rk: t.RowKey, item: t.Item)))
+ .OrderByDescending(t => timestampSelector(t.item))
+ .ToList();
+
+ var merged = allFetched.Take(pageSize).ToList();
+ var returnedItems = merged.Select(t => t.item).ToList();
+
+ var newContinuations = new Dictionary(StringComparer.Ordinal);
+ foreach (var r in fetched)
+ {
+ var returnedFromPart = merged.Where(m => m.part == r.Partition).ToList();
+ if (returnedFromPart.Count > 0)
+ {
+ // Advance to the oldest RowKey we returned for this partition.
+ // Revtick scheme: larger RowKey string = older timestamp, so
+ // OrdinalCompare-max gives us the oldest of the returned set.
+ var oldestRk = returnedFromPart
+ .Select(m => m.rk)
+ .OrderBy(rk => rk, StringComparer.Ordinal)
+ .Last();
+ newContinuations[r.Partition] = new PartitionContinuation(oldestRk, Exhausted: false);
+ }
+ else if (r.Items.Count == 0)
+ {
+ // No more items past current continuation → partition exhausted.
+ priorContinuations.TryGetValue(r.Partition, out var prior);
+ newContinuations[r.Partition] = new PartitionContinuation(prior?.LastRowKey, Exhausted: true);
+ }
+ else
+ {
+ // Fetched some but they all lost the merge cut to other partitions.
+ // Keep continuation where it was — they'll be re-fetched and likely
+ // win on the next page when the floor drops. Wasted bandwidth, but
+ // correct (no item silently dropped).
+ priorContinuations.TryGetValue(r.Partition, out var prior);
+ newContinuations[r.Partition] = prior ?? new PartitionContinuation(null, Exhausted: false);
+ }
+ }
+
+ // Carry over already-exhausted entries from the prior call so they
+ // stay marked across pages (we only fetch from active partitions on
+ // this round and don't see exhausted ones in `fetched`).
+ foreach (var kv in priorContinuations)
+ {
+ if (kv.Value.Exhausted && !newContinuations.ContainsKey(kv.Key))
+ newContinuations[kv.Key] = kv.Value;
+ }
+
+ return (returnedItems, newContinuations);
+ }
+
+ public static string EncodeMultiContinuation(IReadOnlyDictionary continuations)
+ {
+ var doc = new Dictionary
+ {
+ ["v"] = 1,
+ ["c"] = continuations.ToDictionary(
+ kv => kv.Key,
+ kv => (object?)new Dictionary
+ {
+ ["rk"] = kv.Value.LastRowKey,
+ ["x"] = kv.Value.Exhausted,
+ }),
+ };
+ var json = JsonSerializer.Serialize(doc);
+ return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
+ }
+
+ public static Dictionary DecodeMultiContinuation(string? raw)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrEmpty(raw)) return result;
+
+ try
+ {
+ var bytes = Convert.FromBase64String(raw);
+ using var doc = JsonDocument.Parse(bytes);
+ if (!doc.RootElement.TryGetProperty("c", out var entries)) return result;
+ foreach (var prop in entries.EnumerateObject())
+ {
+ string? rk = prop.Value.TryGetProperty("rk", out var rkEl) && rkEl.ValueKind == JsonValueKind.String
+ ? rkEl.GetString()
+ : null;
+ bool exhausted = prop.Value.TryGetProperty("x", out var xEl) && xEl.ValueKind == JsonValueKind.True;
+ result[prop.Name] = new PartitionContinuation(rk, exhausted);
+ }
+ }
+ catch
+ {
+ // Malformed continuation → treat as empty (start over). The wire-
+ // layer fingerprint already rejects tampered tokens before reaching here.
+ }
+ return result;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs
index 433bf2dc..a3c3bd75 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs
@@ -1,6 +1,7 @@
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Services;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
@@ -26,11 +27,19 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
string entityId, string performedBy, Dictionary? details = null)
=> _storage.LogAuditEntryAsync(tenantId, action, entityType, entityId, performedBy, details);
- public Task> GetAuditLogsAsync(string tenantId, int maxResults = 100)
- => _storage.GetAuditLogsAsync(tenantId, maxResults);
+ public Task> GetAuditLogsAsync(string tenantId, DateTime? dateFrom = null, DateTime? dateTo = null)
+ => _storage.GetAuditLogsAsync(tenantId, dateFrom, dateTo);
- public Task> GetAllAuditLogsAsync(int maxResults = 100)
- => _storage.GetAllAuditLogsAsync(maxResults);
+ public Task> GetAllAuditLogsAsync(DateTime? dateFrom = null, DateTime? dateTo = null)
+ => _storage.GetAllAuditLogsAsync(dateFrom, dateTo);
+
+ public Task> GetAuditLogsPageAsync(
+ string tenantId, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
+ => _storage.GetAuditLogsPageAsync(tenantId, dateFrom, dateTo, pageSize, continuation);
+
+ public Task> GetAllAuditLogsPageAsync(
+ DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
+ => _storage.GetAllAuditLogsPageAsync(dateFrom, dateTo, pageSize, continuation);
public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate)
=> _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate);
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs
index 42156953..6423d621 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs
@@ -1,7 +1,9 @@
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
@@ -178,25 +180,15 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
}
}
- public async Task> GetSessionReportsAsync(string? tenantId = null, int maxResults = 50)
+ public async Task> GetSessionReportsAsync(string? tenantId = null)
{
var results = new List();
-
+ var filter = BuildSessionReportFilter(tenantId);
try
{
- var count = 0;
- await foreach (var entity in _reportsTableClient.QueryAsync(
- filter: $"PartitionKey eq 'reports'"))
+ await foreach (var entity in _reportsTableClient.QueryAsync(filter: filter))
{
- var report = MapToSessionReportMetadata(entity);
-
- // Filter by tenantId if specified
- if (tenantId != null && report.TenantId != tenantId)
- continue;
-
- results.Add(report);
- count++;
- if (count >= maxResults) break;
+ results.Add(MapToSessionReportMetadata(entity));
}
}
catch (RequestFailedException ex) when (ex.Status == 404)
@@ -207,6 +199,48 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
return results;
}
+ public async Task> GetSessionReportsPageAsync(
+ string? tenantId, int pageSize, string? continuation)
+ {
+ if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
+ try
+ {
+ var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync(
+ client: _reportsTableClient,
+ filter: BuildSessionReportFilter(tenantId),
+ pageSize: pageSize,
+ continuation: continuation);
+
+ var page = new List(entities.Count);
+ foreach (var entity in entities) page.Add(MapToSessionReportMetadata(entity));
+ // RowKey is inverted-tick of SubmittedAt — entities arrive newest-first
+ // already; no client-side resort needed.
+ return new RawPage(page, nextRawToken);
+ }
+ catch (RequestFailedException ex) when (ex.Status == 404)
+ {
+ _logger.LogDebug("SessionReports table does not exist yet, returning empty page");
+ return RawPage.Empty;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get session reports page");
+ return RawPage.Empty;
+ }
+ }
+
+ private static string BuildSessionReportFilter(string? tenantId)
+ {
+ // All reports live in the single "reports" partition; tenantId is a
+ // property the SDK can filter server-side.
+ var filter = "PartitionKey eq 'reports'";
+ if (!string.IsNullOrEmpty(tenantId))
+ {
+ filter += $" and TenantId eq '{tenantId!.Replace("'", "''")}'";
+ }
+ return filter;
+ }
+
public async Task GetSessionReportAsync(string reportId)
{
try
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs
index 94c03691..a63116a7 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableOpsEventRepository.cs
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using System.Threading.Tasks;
using Azure.Data.Tables;
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Pagination;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
@@ -46,38 +49,148 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
await _table.UpsertEntityAsync(entity);
}
- public async Task> GetOpsEventsAsync(int maxResults = 200)
+ public async Task> GetOpsEventsAsync(
+ string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null)
{
var result = new List();
+ var filter = BuildFilter(category, dateFrom, dateTo);
+ var query = string.IsNullOrEmpty(filter)
+ ? _table.QueryAsync()
+ : _table.QueryAsync(filter: filter);
- // Query all partitions, sort by Timestamp descending client-side
- // (Azure Table Storage doesn't support cross-partition ordering)
- await foreach (var entity in _table.QueryAsync(maxPerPage: maxResults))
+ await foreach (var entity in query)
{
result.Add(MapToEntry(entity));
- if (result.Count >= maxResults)
- break;
}
-
- // Sort newest-first across categories
+ // Cross-partition order is undefined; sort newest-first to match prior behaviour.
result.Sort((a, b) => b.Timestamp.CompareTo(a.Timestamp));
return result;
}
- public async Task> GetOpsEventsByCategoryAsync(string category, int maxResults = 100)
+ public async Task> GetOpsEventsPageAsync(
+ string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
{
- var result = new List();
-
- await foreach (var entity in _table.QueryAsync(e => e.PartitionKey == category, maxPerPage: maxResults))
+ if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
+ try
{
- result.Add(MapToEntry(entity));
- if (result.Count >= maxResults)
- break;
- }
+ // Single-category path: PK-targeted query, RowKey is reverse-tick →
+ // Azure-native (PK asc, RK asc) already yields newest-first. No re-sort.
+ if (!string.IsNullOrEmpty(category))
+ {
+ var filter = BuildFilter(category, dateFrom, dateTo);
+ var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync(
+ client: _table,
+ filter: filter,
+ pageSize: pageSize,
+ continuation: continuation);
- return result;
+ var page = new List(entities.Count);
+ foreach (var entity in entities) page.Add(MapToEntry(entity));
+ return new RawPage(page, nextRawToken);
+ }
+
+ // All-category path: per-partition fan-out + merge-sort. Azure pages
+ // cross-partition queries by (PK asc, RK asc), so without this fan-out
+ // the first page would come entirely from the alphabetically-first
+ // category — defeating "newest first globally". Mirrors the same
+ // pattern used for cross-tenant session queries.
+ return await FanOutAcrossCategoriesAsync(dateFrom, dateTo, pageSize, continuation);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to get ops events page");
+ return RawPage.Empty;
+ }
}
+ // Fixed list — keep in sync with OpsEventCategory. New categories are rare
+ // and require a deploy anyway, so a runtime probe of the table partitions
+ // would just trade certainty for cost.
+ internal static readonly string[] AllCategories = new[]
+ {
+ OpsEventCategory.Consent,
+ OpsEventCategory.Maintenance,
+ OpsEventCategory.Security,
+ OpsEventCategory.Tenant,
+ OpsEventCategory.Agent,
+ OpsEventCategory.Sla,
+ };
+
+ private async Task> FanOutAcrossCategoriesAsync(
+ DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation)
+ {
+ var continuations = PerPartitionFanOutMerge.DecodeMultiContinuation(continuation);
+
+ var activeCats = AllCategories
+ .Where(cat => !(continuations.TryGetValue(cat, out var c) && c.Exhausted))
+ .ToList();
+ if (activeCats.Count == 0)
+ return new RawPage(new List(), null);
+
+ var fetchTasks = activeCats.Select(async cat =>
+ {
+ continuations.TryGetValue(cat, out var catContinuation);
+ var filter = BuildFilterWithRowKeyBound(cat, dateFrom, dateTo, catContinuation?.LastRowKey);
+
+ var fetched = new List<(string RowKey, OpsEventEntry Item)>();
+ await foreach (var e in _table.QueryAsync(filter: filter, maxPerPage: pageSize))
+ {
+ fetched.Add((e.RowKey, MapToEntry(e)));
+ if (fetched.Count >= pageSize) break;
+ }
+ return new PerPartitionFanOutMerge.PartitionFetchResult(cat, fetched);
+ }).ToList();
+
+ var results = await Task.WhenAll(fetchTasks);
+
+ var (items, nextContinuations) = PerPartitionFanOutMerge.MergeAndAdvance(
+ results, continuations, pageSize, e => e.Timestamp);
+
+ bool anyActive = nextContinuations.Any(c => !c.Value.Exhausted);
+ string? nextRawToken = anyActive
+ ? PerPartitionFanOutMerge.EncodeMultiContinuation(nextContinuations)
+ : null;
+ return new RawPage(items, nextRawToken);
+ }
+
+ private static string BuildFilterWithRowKeyBound(string category, DateTime? dateFrom, DateTime? dateTo, string? lastRowKey)
+ {
+ var clauses = new List
+ {
+ $"PartitionKey eq '{category.Replace("'", "''")}'",
+ };
+ if (!string.IsNullOrEmpty(lastRowKey))
+ clauses.Add($"RowKey gt '{lastRowKey!.Replace("'", "''")}'");
+ if (dateFrom.HasValue)
+ clauses.Add($"Timestamp ge datetime'{ToUtc(dateFrom.Value):o}'");
+ if (dateTo.HasValue)
+ clauses.Add($"Timestamp le datetime'{ToUtc(dateTo.Value):o}'");
+ return string.Join(" and ", clauses);
+ }
+
+ // Filters on the system-managed Timestamp; user-defined "Timestamp" property
+ // mirrors it on insert via SaveOpsEventAsync.
+ private static string? BuildFilter(string? category, DateTime? dateFrom, DateTime? dateTo)
+ {
+ var clauses = new List();
+ if (!string.IsNullOrEmpty(category))
+ {
+ clauses.Add($"PartitionKey eq '{category!.Replace("'", "''")}'");
+ }
+ if (dateFrom.HasValue)
+ {
+ clauses.Add($"Timestamp ge datetime'{ToUtc(dateFrom.Value):o}'");
+ }
+ if (dateTo.HasValue)
+ {
+ clauses.Add($"Timestamp le datetime'{ToUtc(dateTo.Value):o}'");
+ }
+ return clauses.Count == 0 ? null : string.Join(" and ", clauses);
+ }
+
+ private static DateTime ToUtc(DateTime dt)
+ => dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
+
public async Task DeleteOpsEventsOlderThanAsync(DateTime cutoff)
{
var deleted = 0;
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs
index 27cbc8e3..e9d81da3 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs
@@ -1,5 +1,6 @@
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
@@ -33,11 +34,17 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task FindSessionTenantIdAsync(string sessionId)
=> _storage.FindSessionTenantIdAsync(sessionId);
- public Task GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null, int? days = null)
- => _storage.GetSessionsAsync(tenantId, maxResults, cursor, days);
+ public Task> GetSessionsAsync(string tenantId, int? days = null)
+ => _storage.GetSessionsAsync(tenantId, days);
- public Task GetAllSessionsAsync(int maxResults = 100, string? cursor = null, int? days = null)
- => _storage.GetAllSessionsAsync(maxResults, cursor, days);
+ public Task> GetAllSessionsAsync(string? tenantIdFilter = null, int? days = null)
+ => _storage.GetAllSessionsAsync(tenantIdFilter, days);
+
+ public Task> GetSessionsPageAsync(string tenantId, int? days, int pageSize, string? continuation)
+ => _storage.GetSessionsPageAsync(tenantId, days, pageSize, continuation);
+
+ public Task> GetAllSessionsPageAsync(string? tenantIdFilter, int? days, int pageSize, string? continuation)
+ => _storage.GetAllSessionsPageAsync(tenantIdFilter, days, pageSize, continuation);
public Task DeleteSessionAsync(string tenantId, string sessionId)
=> _storage.DeleteSessionAsync(tenantId, sessionId);
@@ -120,12 +127,23 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task> GetSessionEventsByTypeAsync(string tenantId, string sessionId, string eventType, int maxResults = 200)
=> _storage.GetSessionEventsByTypeAsync(tenantId, sessionId, eventType, maxResults);
+ public Task> GetSessionEventsPageAsync(string tenantId, string sessionId, int pageSize, string? continuation)
+ => _storage.GetSessionEventsPageAsync(tenantId, sessionId, pageSize, continuation);
+
public Task> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10)
=> _storage.QuickSearchSessionsAsync(tenantId, query, limit);
public Task> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter)
=> _storage.SearchSessionsAsync(tenantId, filter);
+ public Task> SearchSessionsPageAsync(string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
+ => _storage.SearchSessionsPageAsync(tenantId, filter, pageSize, continuation);
+
+ public Task> SearchSessionsByEventPageAsync(
+ string? tenantId, string eventType, string? source, string? severity, string? phase,
+ int pageSize, string? continuation)
+ => _storage.SearchSessionsByEventPageAsync(tenantId, eventType, source, severity, phase, pageSize, continuation);
+
public Task> SearchSessionsByEventAsync(
string? tenantId, string eventType, string? source, string? severity,
string? phase, int limit = 50)
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs
index c441c4ca..b6baad8a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs
@@ -1,6 +1,9 @@
using System.Net;
+using System.Web;
using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Pagination;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -30,20 +33,78 @@ namespace AutopilotMonitor.Functions.Functions.Admin
string tenantId = TenantHelper.GetTenantId(req);
string userIdentifier = TenantHelper.GetUserIdentifier(req);
- _logger.LogInformation($"Fetching audit logs for tenant {tenantId} by user {userIdentifier}");
+ var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
+ var parsed = DateWindowPagination.ParseQuery(query);
+ if (parsed.Error != null)
+ {
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
+ return bad;
+ }
- // Get audit logs from storage
- var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, maxResults: 100);
+ _logger.LogInformation(
+ "Fetching audit logs (tenant={TenantId}, dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}, hasContinuation={HasContinuation}) for user {User}",
+ tenantId, parsed.DateFrom, parsed.DateTo,
+ parsed.PageSize?.ToString() ?? "all", parsed.Continuation != null,
+ userIdentifier);
- var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(new
+ if (parsed.PageSize == null)
+ {
+ var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, parsed.DateFrom, parsed.DateTo);
+ return await req.OkAsync(new { success = true, count = logs.Count, logs });
+ }
+
+ string? azureToken = null;
+ if (parsed.Continuation != null)
+ {
+ if (!DateWindowPagination.TryAcceptContinuation(
+ parsed.Continuation,
+ scope: "audit:tenant",
+ callerTenantId: tenantId,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo,
+ extras: null,
+ out azureToken,
+ out var rejectReason))
+ {
+ _logger.LogWarning("GetAuditLogs: continuation rejected ({Reason})", rejectReason);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new
+ {
+ success = false,
+ message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
+ });
+ return bad;
+ }
+ }
+
+ var page = await _maintenanceRepo.GetAuditLogsPageAsync(
+ tenantId, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken);
+
+ string? nextLink = null;
+ if (!string.IsNullOrEmpty(page.NextRawToken))
+ {
+ var fp = DateWindowPagination.Fingerprint(
+ scope: "audit:tenant",
+ callerTenantId: tenantId,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo);
+ var wireToken = ContinuationToken.Encode(page.NextRawToken!, tenantId, fp);
+ nextLink = DateWindowPagination.BuildNextLink(
+ basePath: "/api/audit/logs",
+ pageSize: parsed.PageSize.Value,
+ wireContinuation: wireToken,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo);
+ }
+
+ return await req.OkAsync(new
{
success = true,
- count = logs.Count,
- logs = logs
+ count = page.Items.Count,
+ logs = page.Items,
+ nextLink,
});
-
- return response;
}
catch (Exception ex)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs
index 5eadef58..c72eb8c9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs
@@ -1,6 +1,9 @@
using System.Net;
+using System.Web;
using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Pagination;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -30,20 +33,78 @@ namespace AutopilotMonitor.Functions.Functions.Admin
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var userEmail = TenantHelper.GetUserIdentifier(req);
+ var callerTenantId = TenantHelper.GetTenantId(req);
- _logger.LogInformation($"Fetching all audit logs across all tenants (User: {userEmail})");
+ var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
+ var parsed = DateWindowPagination.ParseQuery(query);
+ if (parsed.Error != null)
+ {
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
+ return bad;
+ }
- var logs = await _maintenanceRepo.GetAllAuditLogsAsync(maxResults: 100);
+ _logger.LogInformation(
+ "Fetching global audit logs (dateFrom={DateFrom}, dateTo={DateTo}, pageSize={PageSize}) for {User}",
+ parsed.DateFrom, parsed.DateTo, parsed.PageSize?.ToString() ?? "all", userEmail);
- var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(new
+ if (parsed.PageSize == null)
+ {
+ var logs = await _maintenanceRepo.GetAllAuditLogsAsync(parsed.DateFrom, parsed.DateTo);
+ return await req.OkAsync(new { success = true, count = logs.Count, logs });
+ }
+
+ string? azureToken = null;
+ if (parsed.Continuation != null)
+ {
+ if (!DateWindowPagination.TryAcceptContinuation(
+ parsed.Continuation,
+ scope: "audit:global",
+ callerTenantId: callerTenantId,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo,
+ extras: null,
+ out azureToken,
+ out var rejectReason))
+ {
+ _logger.LogWarning("GetGlobalAuditLogs: continuation rejected ({Reason})", rejectReason);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new
+ {
+ success = false,
+ message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
+ });
+ return bad;
+ }
+ }
+
+ var page = await _maintenanceRepo.GetAllAuditLogsPageAsync(
+ parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken);
+
+ string? nextLink = null;
+ if (!string.IsNullOrEmpty(page.NextRawToken))
+ {
+ var fp = DateWindowPagination.Fingerprint(
+ scope: "audit:global",
+ callerTenantId: callerTenantId,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo);
+ var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp);
+ nextLink = DateWindowPagination.BuildNextLink(
+ basePath: "/api/global/audit/logs",
+ pageSize: parsed.PageSize.Value,
+ wireContinuation: wireToken,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo);
+ }
+
+ return await req.OkAsync(new
{
success = true,
- count = logs.Count,
- logs = logs
+ count = page.Items.Count,
+ logs = page.Items,
+ nextLink,
});
-
- return response;
}
catch (Exception ex)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs
index f591738d..5bafe1d4 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetOpsEventsFunction.cs
@@ -1,6 +1,10 @@
+using System.Collections.Generic;
using System.Net;
+using System.Web;
using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Pagination;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -9,7 +13,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin
{
///
/// Global Admin endpoint: returns operational events from the OpsEvents table.
- /// Supports optional category filter via query parameter.
+ /// Supports optional category + dateFrom/dateTo filters and opt-in pagination.
/// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware.
///
public class GetOpsEventsFunction
@@ -31,31 +35,81 @@ namespace AutopilotMonitor.Functions.Functions.Admin
{
try
{
- var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
+ var callerTenantId = TenantHelper.GetTenantId(req);
+ var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
var category = query["category"];
- var maxResultsStr = query["maxResults"];
- var maxResults = 200;
- if (int.TryParse(maxResultsStr, out var parsed) && parsed > 0 && parsed <= 1000)
- maxResults = parsed;
- List events;
- if (!string.IsNullOrWhiteSpace(category))
+ var parsed = DateWindowPagination.ParseQuery(query);
+ if (parsed.Error != null)
{
- events = await _repository.GetOpsEventsByCategoryAsync(category, maxResults);
- }
- else
- {
- events = await _repository.GetOpsEventsAsync(maxResults);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
+ return bad;
}
- var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(new
+ var extras = string.IsNullOrEmpty(category)
+ ? null
+ : new[] { new KeyValuePair("category", category) };
+
+ if (parsed.PageSize == null)
+ {
+ var events = await _repository.GetOpsEventsAsync(category, parsed.DateFrom, parsed.DateTo);
+ return await req.OkAsync(new { success = true, count = events.Count, events });
+ }
+
+ string? azureToken = null;
+ if (parsed.Continuation != null)
+ {
+ if (!DateWindowPagination.TryAcceptContinuation(
+ parsed.Continuation,
+ scope: "ops-events",
+ callerTenantId: callerTenantId,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo,
+ extras: extras,
+ out azureToken,
+ out var rejectReason))
+ {
+ _logger.LogWarning("GetOpsEvents: continuation rejected ({Reason})", rejectReason);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new
+ {
+ success = false,
+ message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
+ });
+ return bad;
+ }
+ }
+
+ var page = await _repository.GetOpsEventsPageAsync(
+ category, parsed.DateFrom, parsed.DateTo, parsed.PageSize.Value, azureToken);
+
+ string? nextLink = null;
+ if (!string.IsNullOrEmpty(page.NextRawToken))
+ {
+ var fp = DateWindowPagination.Fingerprint(
+ scope: "ops-events",
+ callerTenantId: callerTenantId,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo,
+ extras: extras);
+ var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp);
+ nextLink = DateWindowPagination.BuildNextLink(
+ basePath: "/api/global/ops-events",
+ pageSize: parsed.PageSize.Value,
+ wireContinuation: wireToken,
+ dateFrom: parsed.DateFrom,
+ dateTo: parsed.DateTo,
+ extras: extras);
+ }
+
+ return await req.OkAsync(new
{
success = true,
- count = events.Count,
- events
+ count = page.Items.Count,
+ events = page.Items,
+ nextLink,
});
- return response;
}
catch (Exception ex)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs
index 13ed4553..16da298d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs
@@ -72,9 +72,9 @@ namespace AutopilotMonitor.Functions.Functions.Feedback
(DateTime.UtcNow - tenantConfig.OnboardedAt.Value).TotalDays < adminConfig.FeedbackMinTenantAgeDays)
return await WriteJson(req, new { eligible = false });
- // 5. Sessions check — at least 1 session exists
- var sessionPage = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 1);
- if (sessionPage.Sessions.Count == 0)
+ // 5. Sessions check — at least 1 session exists.
+ var sessionPage = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 1, continuation: null);
+ if (sessionPage.Items.Count == 0)
return await WriteJson(req, new { eligible = false });
// 6. Cooldown check
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs
index 0dc91043..931e0b4e 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs
@@ -43,14 +43,15 @@ public class ProgressPortalFunction
_logger.LogInformation("ProgressGetSessions: Fetching sessions for tenant {TenantId}", tenantId);
- var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 100);
+ // Progress portal renders only the latest 100 sessions — single page is enough.
+ var page = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: 100, continuation: null);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
success = true,
- count = page.Sessions.Count,
- sessions = page.Sessions
+ count = page.Items.Count,
+ sessions = page.Items
});
return response;
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs
index 69060fbf..523c3f5a 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs
@@ -1,8 +1,11 @@
using System.Net;
+using System.Web;
using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -30,7 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
try
{
var tenantId = TenantHelper.GetTenantId(req);
- return await QueryEvents(req, tenantId);
+ return await QueryEvents(req, tenantId, scope: "raw-events:tenant", basePath: "/api/raw/events", filterTenantId: null);
}
catch (UnauthorizedAccessException)
{
@@ -54,8 +57,10 @@ namespace AutopilotMonitor.Functions.Functions.Raw
{
try
{
- var tenantId = req.Query["tenantId"];
- return await QueryEvents(req, string.IsNullOrEmpty(tenantId) ? null : tenantId);
+ var filterTenantId = req.Query["tenantId"];
+ var effectiveTenantId = string.IsNullOrEmpty(filterTenantId) ? null : filterTenantId;
+ return await QueryEvents(req, effectiveTenantId, scope: "raw-events:global",
+ basePath: "/api/global/raw/events", filterTenantId: effectiveTenantId);
}
catch (Exception ex)
{
@@ -63,19 +68,31 @@ namespace AutopilotMonitor.Functions.Functions.Raw
}
}
- private async Task QueryEvents(HttpRequestData req, string? tenantId)
+ private async Task QueryEvents(
+ HttpRequestData req, string? tenantId, string scope, string basePath, string? filterTenantId)
{
- var sessionId = req.Query["sessionId"];
- var eventType = req.Query["eventType"];
- var severity = req.Query["severity"];
- var source = req.Query["source"];
- var startedAfter = req.Query["startedAfter"];
- var startedBefore = req.Query["startedBefore"];
- var limitStr = req.Query["limit"];
- var limit = int.TryParse(limitStr, out var l) ? Math.Clamp(l, 1, 500) : 100;
+ var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
+ var sessionId = query["sessionId"];
+ var eventType = query["eventType"];
+ var severity = query["severity"];
+ var source = query["source"];
+ var startedAfter = query["startedAfter"];
+ var startedBefore = query["startedBefore"];
- List events;
+ var pagination = QueryRawEventsPagination.ParsePagination(query);
+ if (pagination.Error != null)
+ {
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { error = pagination.Error });
+ return bad;
+ }
+ var callerTenantId = TenantHelper.GetTenantId(req);
+
+ // Single-session path — paginated session-events walk so sessions
+ // with more events than pageSize remain fully reachable across
+ // multiple calls. Continuation token binds caller + sessionId so
+ // a cursor from session A cannot be replayed for session B.
if (!string.IsNullOrEmpty(sessionId))
{
if (string.IsNullOrEmpty(tenantId))
@@ -84,63 +101,137 @@ namespace AutopilotMonitor.Functions.Functions.Raw
await bad.WriteAsJsonAsync(new { error = "tenantId is required when querying by sessionId" });
return bad;
}
- // Single session query — use existing repo method
- events = await _sessionRepo.GetSessionEventsAsync(tenantId, sessionId, limit);
- }
- else
- {
- // Cross-session: We need to search by event type via the EventTypeIndex
- // then fetch events for matched sessions
- if (string.IsNullOrEmpty(eventType))
+
+ string? singleAzureToken = null;
+ if (pagination.Continuation != null)
{
+ if (!QueryRawEventsPagination.TryAcceptContinuation(
+ pagination.Continuation, scope, callerTenantId, filterTenantId,
+ sessionId, eventType, source, severity, startedAfter, startedBefore,
+ out singleAzureToken, out var rejectReason))
+ {
+ _logger.LogWarning("QueryRawEvents: single-session continuation rejected ({Reason})", rejectReason);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new
+ {
+ error = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
+ });
+ return bad;
+ }
+ }
+
+ var sessionPage = await _sessionRepo.GetSessionEventsPageAsync(
+ tenantId, sessionId, pagination.PageSize, singleAzureToken);
+
+ var filtered = ApplyClientFilters(
+ sessionPage.Items.ToList(), eventType, severity, source, startedAfter, startedBefore);
+ filtered = filtered.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
+
+ string? singleNextLink = null;
+ if (!string.IsNullOrEmpty(sessionPage.NextRawToken))
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope, callerTenantId, filterTenantId,
+ sessionId, eventType, source, severity, startedAfter, startedBefore);
+ var wireToken = ContinuationToken.Encode(sessionPage.NextRawToken!, callerTenantId, fp);
+ singleNextLink = QueryRawEventsPagination.BuildNextLink(
+ basePath, pagination.PageSize, wireToken, query);
+ }
+
+ return await req.OkAsync(new
+ {
+ tenantId,
+ count = filtered.Count,
+ events = filtered,
+ nextLink = singleNextLink,
+ });
+ }
+
+ if (string.IsNullOrEmpty(eventType))
+ {
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { error = "Either sessionId or eventType is required for raw event queries" });
+ return bad;
+ }
+
+ // Cross-session path — paginated EventTypeIndex walk replaces the
+ // legacy hard-coded limit:20 (recall-loss bug from the audit).
+ string? azureToken = null;
+ if (pagination.Continuation != null)
+ {
+ if (!QueryRawEventsPagination.TryAcceptContinuation(
+ pagination.Continuation, scope, callerTenantId, filterTenantId,
+ sessionId: null, eventType, source, severity, startedAfter, startedBefore,
+ out azureToken, out var rejectReason))
+ {
+ _logger.LogWarning("QueryRawEvents: continuation rejected ({Reason})", rejectReason);
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
- await bad.WriteAsJsonAsync(new { error = "Either sessionId or eventType is required for raw event queries" });
+ await bad.WriteAsJsonAsync(new
+ {
+ error = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
+ });
return bad;
}
-
- // Use EventTypeIndex (pre-filtered by severity/source) to find sessions,
- // then fetch only matching event types per session via server-side OData filter
- var sessions = await _sessionRepo.SearchSessionsByEventAsync(tenantId, eventType, source, severity, null, limit: 20);
- events = new List();
- foreach (var session in sessions)
- {
- var sessionEvents = await _sessionRepo.GetSessionEventsByTypeAsync(session.TenantId, session.SessionId, eventType, limit);
- events.AddRange(sessionEvents.Where(e =>
- (string.IsNullOrEmpty(severity) || e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)) &&
- (string.IsNullOrEmpty(source) || (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase))
- ));
-
- if (events.Count >= limit) break;
- }
- events = events.Take(limit).ToList();
}
- // Apply client-side filters for single-session queries
- if (!string.IsNullOrEmpty(sessionId))
+ var sessionsPage = await _sessionRepo.SearchSessionsByEventPageAsync(
+ tenantId, eventType, source, severity, phase: null,
+ pageSize: pagination.PageSize, continuation: azureToken);
+
+ var events = new List();
+ foreach (var session in sessionsPage.Items)
{
- if (!string.IsNullOrEmpty(eventType))
- events = events.Where(e => e.EventType == eventType).ToList();
- if (!string.IsNullOrEmpty(severity))
- events = events.Where(e => e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList();
- if (!string.IsNullOrEmpty(source))
- events = events.Where(e => (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase)).ToList();
+ var sessionEvents = await _sessionRepo.GetSessionEventsByTypeAsync(
+ session.TenantId, session.SessionId, eventType, maxResults: 200);
+ events.AddRange(sessionEvents.Where(e =>
+ (string.IsNullOrEmpty(severity) || e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)) &&
+ (string.IsNullOrEmpty(source) || (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase))));
}
+ events = ApplyDateFilters(events, startedAfter, startedBefore)
+ .OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
+
+ string? nextLink = null;
+ if (!string.IsNullOrEmpty(sessionsPage.NextRawToken))
+ {
+ var fp = QueryRawEventsPagination.Fingerprint(
+ scope, callerTenantId, filterTenantId,
+ sessionId: null, eventType, source, severity, startedAfter, startedBefore);
+ var wireToken = ContinuationToken.Encode(sessionsPage.NextRawToken!, callerTenantId, fp);
+ nextLink = QueryRawEventsPagination.BuildNextLink(
+ basePath, pagination.PageSize, wireToken, query);
+ }
+
+ return await req.OkAsync(new
+ {
+ tenantId,
+ count = events.Count,
+ events,
+ nextLink,
+ });
+ }
+
+ private static List ApplyClientFilters(
+ List events, string? eventType, string? severity, string? source,
+ string? startedAfter, string? startedBefore)
+ {
+ if (!string.IsNullOrEmpty(eventType))
+ events = events.Where(e => e.EventType == eventType).ToList();
+ if (!string.IsNullOrEmpty(severity))
+ events = events.Where(e => e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList();
+ if (!string.IsNullOrEmpty(source))
+ events = events.Where(e => (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase)).ToList();
+ return ApplyDateFilters(events, startedAfter, startedBefore);
+ }
+
+ private static List ApplyDateFilters(
+ List events, string? startedAfter, string? startedBefore)
+ {
if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after))
events = events.Where(e => e.Timestamp >= after).ToList();
if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before))
events = events.Where(e => e.Timestamp <= before).ToList();
-
- events = events.Take(limit).OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
-
- var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(new
- {
- tenantId,
- count = events.Count,
- events
- });
- return response;
+ return events;
}
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs
index 651726ba..b00e27ed 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/GetSessionReportsFunction.cs
@@ -1,5 +1,9 @@
using System.Net;
+using System.Web;
+using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.Pagination;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -28,16 +32,60 @@ namespace AutopilotMonitor.Functions.Functions.Reports
try
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
+ var callerTenantId = TenantHelper.GetTenantId(req);
- var reports = await _sessionReportService.GetAllReportsAsync();
+ var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
+ var parsed = SessionReportsPagination.ParseQuery(query);
+ if (parsed.Error != null)
+ {
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
+ return bad;
+ }
- var response = req.CreateResponse(HttpStatusCode.OK);
- await response.WriteAsJsonAsync(new
+ if (parsed.PageSize == null)
+ {
+ var reports = await _sessionReportService.GetAllReportsAsync(parsed.FilterTenantId);
+ return await req.OkAsync(new { success = true, count = reports.Count, reports });
+ }
+
+ string? azureToken = null;
+ if (parsed.Continuation != null)
+ {
+ if (!SessionReportsPagination.TryAcceptContinuation(
+ parsed.Continuation, callerTenantId, parsed.FilterTenantId,
+ out azureToken, out var rejectReason))
+ {
+ _logger.LogWarning("GetSessionReports: continuation rejected ({Reason})", rejectReason);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new
+ {
+ success = false,
+ message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
+ });
+ return bad;
+ }
+ }
+
+ var page = await _sessionReportService.GetReportsPageAsync(
+ parsed.FilterTenantId, parsed.PageSize.Value, azureToken);
+
+ string? nextLink = null;
+ if (!string.IsNullOrEmpty(page.NextRawToken))
+ {
+ var fp = SessionReportsPagination.Fingerprint(callerTenantId, parsed.FilterTenantId);
+ var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp);
+ nextLink = SessionReportsPagination.BuildNextLink(
+ parsed.PageSize.Value, wireToken, parsed.FilterTenantId);
+ }
+
+ return await req.OkAsync(new
{
success = true,
- reports
+ count = page.Items.Count,
+ reports = page.Items,
+ nextLink,
});
- return response;
}
catch (Exception ex)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs
index 3db7d3fa..a5e35302 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs
@@ -1,7 +1,9 @@
using System.Net;
+using System.Web;
using AutopilotMonitor.Functions.Helpers;
+using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
-using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Pagination;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
@@ -31,46 +33,72 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
{
// Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware
var userEmail = TenantHelper.GetUserIdentifier(req);
- var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query);
- var cursor = query["cursor"];
- var tenantIdFilter = query["tenantId"];
- var daysParam = query["days"];
- int? days = null;
- if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0)
- days = parsedDays;
+ var callerTenantId = TenantHelper.GetTenantId(req);
+ var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
- var limitParam = query["limit"];
- int limit = 100;
- if (!string.IsNullOrEmpty(limitParam) && int.TryParse(limitParam, out var parsedLimit))
- limit = Math.Clamp(parsedLimit, 1, 100);
-
- SessionPage page;
-
- if (!string.IsNullOrEmpty(tenantIdFilter))
+ var parsed = SessionListPagination.ParseQuery(query, acceptFilterTenantId: true);
+ if (parsed.Error != null)
{
- if (!Guid.TryParse(tenantIdFilter, out _))
- {
- var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
- await badRequest.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId format" });
- return badRequest;
- }
-
- _logger.LogInformation("Fetching sessions for tenant {TenantId} (User: {UserEmail}, cursor: {Cursor}, days: {Days}, limit: {Limit})", tenantIdFilter, userEmail, cursor ?? "none", days?.ToString() ?? "all", limit);
- page = await _sessionRepo.GetSessionsAsync(tenantIdFilter, maxResults: limit, cursor: cursor, days: days);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
+ return bad;
}
- else
+
+ if (!string.IsNullOrEmpty(parsed.FilterTenantId) && !Guid.TryParse(parsed.FilterTenantId, out _))
{
- _logger.LogInformation("Fetching all sessions across all tenants (User: {UserEmail}, cursor: {Cursor}, days: {Days}, limit: {Limit})", userEmail, cursor ?? "none", days?.ToString() ?? "all", limit);
- page = await _sessionRepo.GetAllSessionsAsync(maxResults: limit, cursor: cursor, days: days);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new { success = false, message = "Invalid tenantId format" });
+ return bad;
+ }
+
+ _logger.LogInformation(
+ "Fetching sessions cross-tenant (filterTenantId={FilterTenant}, user={User}, days={Days}, pageSize={PageSize}, hasContinuation={HasContinuation})",
+ parsed.FilterTenantId ?? "none", userEmail, parsed.Days, parsed.PageSize, parsed.Continuation != null);
+
+ string? azureToken = null;
+ if (parsed.Continuation != null)
+ {
+ if (!SessionListPagination.TryAcceptContinuation(
+ parsed.Continuation, scope: "sessions:global",
+ callerTenantId: callerTenantId, days: parsed.Days,
+ filterTenantId: parsed.FilterTenantId,
+ out azureToken, out var rejectReason))
+ {
+ _logger.LogWarning("GetAllSessions: continuation rejected ({Reason})", rejectReason);
+ var bad = req.CreateResponse(HttpStatusCode.BadRequest);
+ await bad.WriteAsJsonAsync(new
+ {
+ success = false,
+ message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
+ });
+ return bad;
+ }
+ }
+
+ var page = await _sessionRepo.GetAllSessionsPageAsync(
+ parsed.FilterTenantId, parsed.Days, parsed.PageSize, azureToken);
+
+ string? nextLink = null;
+ if (!string.IsNullOrEmpty(page.NextRawToken))
+ {
+ var fp = SessionListPagination.Fingerprint(
+ scope: "sessions:global", callerTenantId: callerTenantId,
+ days: parsed.Days, filterTenantId: parsed.FilterTenantId);
+ var wireToken = ContinuationToken.Encode(page.NextRawToken!, callerTenantId, fp);
+ nextLink = SessionListPagination.BuildNextLink(
+ basePath: "/api/global/sessions",
+ pageSize: parsed.PageSize,
+ wireContinuation: wireToken,
+ days: parsed.Days,
+ filterTenantId: parsed.FilterTenantId);
}
return await req.OkAsync(new
{
success = true,
- count = page.Sessions.Count,
- hasMore = page.HasMore,
- cursor = page.Cursor,
- sessions = page.Sessions
+ count = page.Items.Count,
+ sessions = page.Items,
+ nextLink,
});
}
catch (Exception ex)
@@ -83,7 +111,6 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
success = false,
message = "Internal server error",
count = 0,
- hasMore = false,
sessions = Array.Empty