From 2b9bf3cd3fa18e373b9e90c94dff2bad800a27d3 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 5 Sep 2026 23:26:21 +0200 Subject: [PATCH] backend: one query-parameter parser (QueryParams) with a lenient and a strict contract, ratcheted (D-202) Helpers/QueryParams replaces fourteen private days/limit parsers, the three TryParseUtc copies, eleven identical pageSize blocks and ~50 inline TryParse sites under Functions/ and Pagination/. Lenient (Int/IntOrNull/UtcInstant): absent, garbage or below-minimum means the call site's default, above-maximum clamps. Strict (TryInt/TryPageSize/TryUtcInstant): absent is null, anything else invalid is a 400 with a message that names the parameter. Integers are invariant-culture, instants are invariant UTC (AssumeUniversal | AdjustToUniversal), whitespace counts as absent. Defaults and ranges stay arguments at every call site. Settled differences: the Apps windows clamp above 365 instead of falling back to 30, days=0 or negative yields the default everywhere, the two global geo endpoints and the session list cap days at 365, SLA months is capped at SlaMetricsService.MaxMonths on the HTTP layer, a whitespace date means no filter on every route, the admin batch sizes reject values above their maximum, and the three culture-dependent DateTime.TryParse sites parse as UTC. QueryParamGuardTests ratchets TryParse out of Functions/ and Pagination/ (baseline: headers, raw cells, the agent version major, the se= of a stored SAS URL); QueryParamsTests carries the cases the removed copies pinned. Co-Authored-By: Claude Fable 5.1 --- .../DeviceJourneyAndFtrTests.cs | 11 - .../QueryParamGuardTests.cs | 88 ++++++++ .../QueryParamsTests.cs | 192 ++++++++++++++++++ .../RawEventTimeTests.cs | 26 --- .../RuleHitSessionsFunctionTests.cs | 15 -- .../VerdictCalibrationTests.cs | 9 - .../Admin/BackfillOccurredUtcFunction.cs | 10 +- .../Functions/Admin/GetActiveUsersFunction.cs | 4 +- .../Admin/ReclassifyLegacySessionsFunction.cs | 10 +- .../Admin/TriggerMaintenanceFunction.cs | 18 +- .../Functions/Apps/AppsAnalyticsHelper.cs | 16 +- .../Functions/Apps/GetAppAnalyticsFunction.cs | 4 +- .../Functions/Apps/GetAppSessionsFunction.cs | 12 +- .../Functions/Apps/GetAppsListFunction.cs | 4 +- .../Apps/GetGlobalAppAnalyticsFunction.cs | 4 +- .../Apps/GetGlobalAppSessionsFunction.cs | 12 +- .../Apps/GetGlobalAppsListFunction.cs | 4 +- .../Config/ListTenantConfigBackupsFunction.cs | 4 +- .../Metrics/GetAppMetricsFunction.cs | 7 +- .../Metrics/GetDeviceJourneyFunctions.cs | 10 +- .../Metrics/GetFleetHealthMetricsFunction.cs | 7 +- .../GetGeographicLocationSessionsFunction.cs | 7 +- .../Metrics/GetGeographicMetricsFunction.cs | 7 +- .../GetGlobalAgentEfficiencyFunction.cs | 14 +- .../Metrics/GetGlobalAppMetricsFunction.cs | 7 +- .../GetGlobalFleetHealthMetricsFunction.cs | 7 +- ...lobalGeographicLocationSessionsFunction.cs | 5 +- .../GetGlobalGeographicMetricsFunction.cs | 5 +- .../GetGlobalPlatformMetricsFunction.cs | 39 +--- .../Metrics/GetGlobalSlaMetricsFunction.cs | 3 +- .../Metrics/GetVerdictCalibrationFunction.cs | 8 +- .../Metrics/MetricsSummaryFunction.cs | 16 +- .../Metrics/PlatformUsageMetricsFunction.cs | 13 +- .../Metrics/RuleHitSessionsFunction.cs | 15 +- .../Functions/Metrics/SlaMetricsFunction.cs | 3 +- .../Functions/Metrics/UsageMetricsFunction.cs | 14 +- .../Functions/Raw/QueryRawEventsFunction.cs | 9 +- .../Functions/Raw/QueryRawSessionsFunction.cs | 10 +- .../GetSessionDeletionsListFunction.cs | 19 +- .../Sessions/GetSessionStatsFunction.cs | 16 +- .../Sessions/SearchSessionsFunction.cs | 8 +- .../GetUnmatchedSoftwareFunction.cs | 8 +- .../VulnerabilitySummaryFunction.cs | 4 +- .../Helpers/QueryParams.cs | 140 +++++++++++++ .../Pagination/DateWindowPagination.cs | 48 +---- .../Pagination/QueryRawEventsPagination.cs | 30 +-- .../Pagination/RawTablePagination.cs | 14 +- .../SearchSessionsByCvePagination.cs | 14 +- .../SearchSessionsByEventPagination.cs | 14 +- .../Pagination/SearchSessionsPagination.cs | 14 +- .../SessionAnnotationsPagination.cs | 34 +--- .../Pagination/SessionEventsPagination.cs | 17 +- .../Pagination/SessionListPagination.cs | 25 +-- .../Pagination/SessionReportsPagination.cs | 13 +- .../Pagination/TenantConfigPagination.cs | 13 +- .../Services/SlaMetricsService.cs | 5 +- 56 files changed, 564 insertions(+), 521 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/QueryParamsTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Helpers/QueryParams.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs index f4fa401b..6461b673 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeviceJourneyAndFtrTests.cs @@ -726,17 +726,6 @@ public class DeviceJourneyAndFtrTests Assert.Equal(3, totals.ExcludedSessions); } - [Theory] - [InlineData(null, 30)] // default window - [InlineData("garbage", 30)] - [InlineData("7", 7)] - [InlineData("0", 1)] // clamped low - [InlineData("999", 180)] // clamped to aggregate retention - public void ClampDays_DefaultsAndClamps(string? raw, int expected) - { - Assert.Equal(expected, DeviceJourneyMetricsResponseBuilder.ClampDays(raw)); - } - [Fact] public void InclusiveWindowStart_YieldsExactlyNCalendarDays() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs new file mode 100644 index 00000000..94d2c6cb --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamGuardTests.cs @@ -0,0 +1,88 @@ +using System.Text.RegularExpressions; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Ratchet: HTTP query parameters are parsed through Helpers/QueryParams.cs only. A +/// int/long/DateTime/DateTimeOffset.TryParse under Functions/ or Pagination/ +/// is a new private parser copy — the state the 2026-09-04 API audit found six times over for +/// days alone, each with its own default, cap and out-of-range rule. The baseline lists +/// the sites that parse something other than a query parameter (a header, a stored cell, a +/// version string); it only shrinks. +/// +public class QueryParamGuardTests +{ + private static readonly Regex TryParseCall = new( + @"\b(int|long|DateTime|DateTimeOffset)\.TryParse(Exact)?\(", RegexOptions.Compiled); + + /// Non-query parses, reviewed: file → why it is allowed. + private static readonly IReadOnlyDictionary Baseline = new Dictionary(StringComparer.Ordinal) + { + ["Functions/Config/GetAgentConfigFunction.cs"] = "major of the X-Agent-Version header", + ["Functions/Ingest/IngestTelemetryFunction.cs"] = "X-Send-Time-Utc header (RoundtripKind, deliberately not a query instant)", + ["Functions/Raw/QueryRawEventsFunction.cs"] = "Severity / Sequence cells of raw table rows", + ["Functions/Diagnostics/GetDiagnosticsUploadUrlFunction.cs"] = "se= expiry inside the tenant's stored SAS URL", + // Content-Length header guard on body-taking routes (long.TryParse on the header value). + ["Functions/Admin/SubmitOffboardingFeedbackFunction.cs"] = "Content-Length header", + ["Functions/Bootstrap/BootstrapRegisterSessionFunction.cs"] = "Content-Length header", + ["Functions/Config/PatchTenantConfigurationFieldsFunction.cs"] = "Content-Length header", + ["Functions/Config/UpdateAdminConfigurationFunction.cs"] = "Content-Length header", + ["Functions/Config/UpdateTenantConfigurationFunction.cs"] = "Content-Length header", + ["Functions/Feedback/FeedbackFunction.cs"] = "Content-Length header", + ["Functions/Infrastructure/SignalRAddToGroupFunction.cs"] = "Content-Length header", + ["Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs"] = "Content-Length header", + ["Functions/Ingest/ReportAgentErrorFunction.cs"] = "Content-Length header", + ["Functions/Ingest/ReportDistressFunction.cs"] = "Content-Length header", + ["Functions/Reports/SubmitDiagFilesReportFunction.cs"] = "Content-Length header", + ["Functions/Reports/SubmitSessionReportFunction.cs"] = "Content-Length header", + ["Functions/Rules/AnalyzeRulesFunction.cs"] = "Content-Length header", + ["Functions/Rules/DryRunAnalyzeRuleFunction.cs"] = "Content-Length header", + ["Functions/Rules/GatherRulesFunction.cs"] = "Content-Length header", + ["Functions/Rules/GlobalRulesFunction.cs"] = "Content-Length header", + ["Functions/Rules/ImeLogPatternsFunction.cs"] = "Content-Length header", + ["Functions/Rules/TestLogPatternFunction.cs"] = "Content-Length header", + ["Functions/Sessions/RegisterSessionFunction.cs"] = "Content-Length header", + }; + + private static string FunctionsRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln"))) + dir = dir.Parent; + Assert.NotNull(dir); + return Path.Combine(dir!.FullName, "src", "Backend", "AutopilotMonitor.Functions"); + } + + private static IEnumerable ScannedFiles(string root) => + new[] { "Functions", "Pagination" } + .SelectMany(sub => Directory.EnumerateFiles(Path.Combine(root, sub), "*.cs", SearchOption.AllDirectories)) + .Select(p => Path.GetRelativePath(root, p).Replace('\\', '/')) + .OrderBy(p => p, StringComparer.Ordinal); + + [Fact] + public void Query_parameters_are_parsed_through_QueryParams_only() + { + var root = FunctionsRoot(); + var offenders = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (var rel in ScannedFiles(root)) + { + var source = File.ReadAllText(Path.Combine(root, rel)); + if (!TryParseCall.IsMatch(source)) continue; + seen.Add(rel); + if (!Baseline.ContainsKey(rel)) offenders.Add(rel); + } + + Assert.True(offenders.Count == 0, + "TryParse outside Helpers/QueryParams.cs — parse query parameters through QueryParams " + + "(Int / TryInt / TryPageSize / UtcInstant / TryUtcInstant), or add the file to the baseline " + + "with the non-query reason:\n " + string.Join("\n ", offenders)); + + var stale = Baseline.Keys.Where(k => !seen.Contains(k)).ToList(); + Assert.True(stale.Count == 0, + "Baseline entries without a TryParse any more — remove them (the ratchet only shrinks):\n " + + string.Join("\n ", stale)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamsTests.cs new file mode 100644 index 00000000..275931e1 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/QueryParamsTests.cs @@ -0,0 +1,192 @@ +using System.Globalization; +using AutopilotMonitor.Functions.Helpers; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// The one query-parameter parser (). The lenient theories carry the +/// cases the former per-function copies pinned (RuleHitSessions, DeviceJourney, VerdictCalibration, +/// the Apps reject-to-default variant) so their behaviour is decided once, here: garbage and +/// below-minimum mean default, above-maximum clamps. The strict theories pin the 400 messages +/// the paginators and admin routes return. +/// +public class QueryParamsTests +{ + private static readonly DateTime Occurred = new(2026, 8, 29, 20, 11, 0, DateTimeKind.Utc); + + // ── Lenient ──────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(null, 14)] + [InlineData("", 14)] + [InlineData(" ", 14)] + [InlineData("garbage", 14)] + [InlineData("14", 14)] + [InlineData("1", 1)] + [InlineData("0", 14)] // below min ⇒ default (the old ClampDays copies answered 1) + [InlineData("-5", 14)] + [InlineData("90", 90)] + [InlineData("365", 90)] // above max ⇒ clamped (the old Apps copies answered the default) + [InlineData("2147483648", 14)] // overflow ⇒ default + public void Int_defaults_below_min_and_clamps_above_max(string? raw, int expected) + { + Assert.Equal(expected, QueryParams.Int(raw, @default: 14, min: 1, max: 90)); + } + + [Fact] + public void Int_reads_the_named_parameter_from_a_query_collection() + { + var query = System.Web.HttpUtility.ParseQueryString("days=7&limit=abc"); + Assert.Equal(7, QueryParams.Int(query, "days", 30, 1, 365)); + Assert.Equal(100, QueryParams.Int(query, "limit", 100, 1, 2000)); + Assert.Equal(30, QueryParams.Int((System.Collections.Specialized.NameValueCollection?)null, "days", 30, 1, 365)); + } + + [Fact] + public void Int_is_culture_invariant() + { + var previous = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.Equal(1000, QueryParams.Int("1000", 1, 1, 5000)); + Assert.Equal(1, QueryParams.Int("1.000", 1, 1, 5000)); // thousands separator is not a number + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } + + [Theory] + [InlineData(null, null)] + [InlineData("", null)] + [InlineData("x", null)] + [InlineData("-3", -3)] + [InlineData("12", 12)] + public void IntOrNull_has_no_range(string? raw, int? expected) + { + Assert.Equal(expected, QueryParams.IntOrNull(raw)); + } + + [Fact] + public void UtcInstant_is_null_for_absent_or_garbage_and_utc_otherwise() + { + Assert.Null(QueryParams.UtcInstant(null)); + Assert.Null(QueryParams.UtcInstant(" ")); + Assert.Null(QueryParams.UtcInstant("yesterday")); + var parsed = QueryParams.UtcInstant("2026-08-29T22:11:00+02:00"); + Assert.Equal(Occurred, parsed); + Assert.Equal(DateTimeKind.Utc, parsed!.Value.Kind); + } + + // ── Strict ───────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryInt_absent_is_null_and_ok(string? raw) + { + Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error)); + Assert.Null(value); + Assert.Null(error); + } + + [Theory] + [InlineData("banana", "days must be an integer")] + [InlineData("1.5", "days must be an integer")] + [InlineData("0", "days must be between 1 and 365")] + [InlineData("-1", "days must be between 1 and 365")] + [InlineData("366", "days must be between 1 and 365")] + public void TryInt_rejects_garbage_and_out_of_range_with_a_named_message(string raw, string expectedError) + { + Assert.False(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error)); + Assert.Null(value); + Assert.Equal(expectedError, error); + } + + [Theory] + [InlineData("1", 1)] + [InlineData("365", 365)] + public void TryInt_accepts_the_range_ends(string raw, int expected) + { + Assert.True(QueryParams.TryInt(raw, "days", 1, 365, out var value, out var error)); + Assert.Equal(expected, value); + Assert.Null(error); + } + + [Fact] + public void TryInt_without_an_upper_bound_names_only_the_minimum() + { + Assert.False(QueryParams.TryInt("-1", "skip", 0, int.MaxValue, out _, out var error)); + Assert.Equal("skip must be an integer of at least 0", error); + Assert.True(QueryParams.TryInt("2147483647", "skip", 0, int.MaxValue, out var max, out _)); + Assert.Equal(int.MaxValue, max); + } + + [Theory] + [InlineData(null, null, null)] + [InlineData("1", 1, null)] + [InlineData("1000", 1000, null)] + [InlineData("1001", null, "pageSize must be between 1 and 1000")] + [InlineData("0", null, "pageSize must be between 1 and 1000")] + [InlineData("ten", null, "pageSize must be an integer")] + public void TryPageSize_is_strict_within_one_to_MaxPageSize(string? raw, int? expected, string? expectedError) + { + var ok = QueryParams.TryPageSize(raw, out var pageSize, out var error); + Assert.Equal(expectedError == null, ok); + Assert.Equal(expected, pageSize); + Assert.Equal(expectedError, error); + } + + // ── Instants ─────────────────────────────────────────────────────────────────────── + + [Fact] + public void TryUtcInstant_reads_offset_bare_and_date_only_values_as_utc() + { + Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00Z", "d", out var z, out _)); + Assert.True(QueryParams.TryUtcInstant("2026-08-29T22:11:00+02:00", "d", out var offset, out _)); + Assert.True(QueryParams.TryUtcInstant("2026-08-29T20:11:00", "d", out var bare, out _)); + Assert.True(QueryParams.TryUtcInstant("2026-08-29", "d", out var dateOnly, out _)); + + Assert.Equal(Occurred, z); + Assert.Equal(Occurred, offset); + Assert.Equal(Occurred, bare); + Assert.Equal(Occurred.Date, dateOnly); + Assert.Equal(DateTimeKind.Utc, z!.Value.Kind); + Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind); + Assert.Equal(DateTimeKind.Utc, dateOnly!.Value.Kind); + } + + [Fact] + public void TryUtcInstant_treats_absent_and_whitespace_as_no_filter_and_garbage_as_error() + { + Assert.True(QueryParams.TryUtcInstant(null, "dateFrom", out var none, out var noneError)); + Assert.Null(none); + Assert.Null(noneError); + Assert.True(QueryParams.TryUtcInstant(" ", "dateFrom", out var blank, out _)); + Assert.Null(blank); + Assert.False(QueryParams.TryUtcInstant("yesterday", "dateFrom", out var bad, out var error)); + Assert.Null(bad); + Assert.Equal("dateFrom must be an ISO 8601 date or date-time", error); + } + + [Fact] + public void TryUtcInstant_is_culture_invariant() + { + var previous = CultureInfo.CurrentCulture; + try + { + // de-DE would read "08/29/2026" day-first and fail; invariant parsing does not. + CultureInfo.CurrentCulture = new CultureInfo("de-DE"); + Assert.True(QueryParams.TryUtcInstant("08/29/2026 20:11", "d", out var parsed, out _)); + Assert.Equal(Occurred, parsed); + } + finally + { + CultureInfo.CurrentCulture = previous; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs index ea608ae3..8899b13f 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RawEventTimeTests.cs @@ -116,30 +116,4 @@ public class RawEventTimeTests Assert.Equal(Occurred - expectedSlack, QueryRawEventsPagination.IndexWrittenAfterHint(Occurred)); Assert.Null(QueryRawEventsPagination.IndexWrittenAfterHint(null)); } - - // ===== TryParseUtc ===== - - [Fact] - public void TryParseUtc_reads_offset_and_bare_values_as_utc() - { - Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00Z", out var z)); - Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T22:11:00+02:00", out var offset)); - Assert.True(QueryRawEventsPagination.TryParseUtc("2026-08-29T20:11:00", out var bare)); - - Assert.Equal(Occurred, z); - Assert.Equal(Occurred, offset); - Assert.Equal(Occurred, bare); - Assert.Equal(DateTimeKind.Utc, z!.Value.Kind); - Assert.Equal(DateTimeKind.Utc, bare!.Value.Kind); - } - - [Fact] - public void TryParseUtc_treats_empty_as_no_filter_and_garbage_as_error() - { - Assert.True(QueryRawEventsPagination.TryParseUtc(null, out var none)); - Assert.Null(none); - Assert.True(QueryRawEventsPagination.TryParseUtc(" ", out var blank)); - Assert.Null(blank); - Assert.False(QueryRawEventsPagination.TryParseUtc("yesterday", out _)); - } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs index 64219ba2..4fcbaaef 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RuleHitSessionsFunctionTests.cs @@ -10,21 +10,6 @@ namespace AutopilotMonitor.Functions.Tests; /// public class RuleHitSessionsFunctionTests { - [Theory] - [InlineData(null, 14)] // not supplied → default window - [InlineData("", 14)] // empty → default - [InlineData("garbage", 14)] // non-numeric → default - [InlineData("14", 14)] - [InlineData("1", 1)] - [InlineData("0", 1)] // below range → clamped up - [InlineData("-5", 1)] - [InlineData("90", 90)] - [InlineData("365", 90)] // above range → clamped down - public void ParseDays_Defaults_And_Clamps(string? raw, int expected) - { - Assert.Equal(expected, RuleHitSessionsFunction.ParseDays(raw)); - } - [Fact] public void MaxSessionIds_Matches_Repository_Default_Cap() { diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs index d1810856..2e372d8b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/VerdictCalibrationTests.cs @@ -289,13 +289,4 @@ public class VerdictCalibrationTests Assert.Equal(0, json.GetProperty("totals").GetProperty("sessions").GetInt32()); Assert.Equal(JsonValueKind.Null, json.GetProperty("computedAt").ValueKind); } - - [Theory] - [InlineData(null, 30)] - [InlineData("abc", 30)] - [InlineData("0", 1)] - [InlineData("500", 180)] - [InlineData("14", 14)] - public void ClampDays_bounds_the_window(string? raw, int expected) - => Assert.Equal(expected, VerdictCalibrationResponseBuilder.ClampDays(raw)); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs index d31d9baf..65feddc0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/BackfillOccurredUtcFunction.cs @@ -57,15 +57,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin var dryRun = !string.Equals(req.Query["dryRun"], "false", StringComparison.OrdinalIgnoreCase); var continuation = req.Query["continuation"]; - var maxRows = DefaultMaxRows; - if (!string.IsNullOrEmpty(req.Query["maxRows"])) + if (!QueryParams.TryInt(req.Query["maxRows"], "maxRows", 1, MaxMaxRows, out var maxRowsOrNull, out var maxRowsError)) { - if (!int.TryParse(req.Query["maxRows"], out maxRows) || maxRows < 1) - { - return await req.BadRequestAsync($"maxRows must be a positive integer (max {MaxMaxRows})"); - } - maxRows = Math.Min(maxRows, MaxMaxRows); + return await req.BadRequestAsync(maxRowsError!); } + var maxRows = maxRowsOrNull ?? DefaultMaxRows; _logger.LogInformation( "OccurredUtc backfill requested by {User}: table={Table} dryRun={DryRun} maxRows={MaxRows} hasContinuation={HasContinuation}", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs index dbd6247f..7fe7cfa9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetActiveUsersFunction.cs @@ -36,9 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin try { var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); - var windowMinutes = DefaultWindowMinutes; - if (int.TryParse(query["windowMinutes"], out var parsed)) - windowMinutes = Math.Clamp(parsed, MinWindowMinutes, MaxWindowMinutes); + var windowMinutes = QueryParams.Int(query["windowMinutes"], DefaultWindowMinutes, MinWindowMinutes, MaxWindowMinutes); var now = DateTime.UtcNow; var active = await _metricsRepo.GetActivePresenceAsync(TimeSpan.FromMinutes(windowMinutes)); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs index 11f032c6..6c376949 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReclassifyLegacySessionsFunction.cs @@ -59,15 +59,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin return await req.BadRequestAsync("tenantId must be a GUID"); } - var maxSessions = DefaultMaxSessions; - if (!string.IsNullOrEmpty(req.Query["maxSessions"])) + if (!QueryParams.TryInt(req.Query["maxSessions"], "maxSessions", 1, MaxMaxSessions, out var maxSessionsOrNull, out var maxSessionsError)) { - if (!int.TryParse(req.Query["maxSessions"], out maxSessions) || maxSessions < 1) - { - return await req.BadRequestAsync($"maxSessions must be a positive integer (max {MaxMaxSessions})"); - } - maxSessions = Math.Min(maxSessions, MaxMaxSessions); + return await req.BadRequestAsync(maxSessionsError!); } + var maxSessions = maxSessionsOrNull ?? DefaultMaxSessions; _logger.LogInformation( "Legacy reclassification requested by {User}: mode={Mode} dryRun={DryRun} tenantScope={TenantScope} maxSessions={MaxSessions}", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs index 8b8c8fc7..f9003e9a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TriggerMaintenanceFunction.cs @@ -51,18 +51,14 @@ namespace AutopilotMonitor.Functions.Functions.Admin var dateParam = req.Query["date"]; var aggregateOnlyParam = req.Query["aggregateOnly"]; - DateTime? targetDate = null; - if (!string.IsNullOrEmpty(dateParam)) + if (!QueryParams.TryUtcInstant(dateParam, "date", out var dateInstant, out _)) { - if (DateTime.TryParse(dateParam, out var parsedDate)) - { - targetDate = parsedDate.Date; - _logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}"); - } - else - { - return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd"); - } + return await req.BadRequestAsync("Invalid date format. Use yyyy-MM-dd"); + } + DateTime? targetDate = dateInstant?.Date; + if (targetDate.HasValue) + { + _logger.LogInformation($"Manual maintenance for date: {targetDate:yyyy-MM-dd}"); } bool aggregateOnly = aggregateOnlyParam?.ToLower() == "true"; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs index b38c3b82..fe09c5d5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/AppsAnalyticsHelper.cs @@ -51,19 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps /// public static AppsPaging ParseAppsPaging(NameValueCollection query) { - var pageSizeRaw = query["pageSize"]; - if (string.IsNullOrEmpty(pageSizeRaw)) + if (!QueryParams.TryInt(query["pageSize"], "pageSize", 1, MaxAppsPageSize, out var pageSize, out var error)) + return new AppsPaging { Error = error }; + if (pageSize == null) return new AppsPaging { PageSize = null, Skip = 0 }; - if (!int.TryParse(pageSizeRaw, out var pageSize) || pageSize < 1 || pageSize > MaxAppsPageSize) - return new AppsPaging { Error = $"pageSize must be between 1 and {MaxAppsPageSize}" }; + if (!QueryParams.TryInt(query["skip"], "skip", 0, int.MaxValue, out var skip, out error)) + return new AppsPaging { Error = error }; - var skip = 0; - var skipRaw = query["skip"]; - if (!string.IsNullOrEmpty(skipRaw) && (!int.TryParse(skipRaw, out skip) || skip < 0)) - return new AppsPaging { Error = "skip must be a non-negative integer" }; - - return new AppsPaging { PageSize = pageSize, Skip = skip }; + return new AppsPaging { PageSize = pageSize, Skip = skip ?? 0 }; } // ── Data loaders ──────────────────────────────────────────────────── diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs index cef046fd..25d8061c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppAnalyticsFunction.cs @@ -47,9 +47,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps } var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - int days = 30; - if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days); // Active duration-regression episodes for this app (fail-soft: empty on error). diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs index e6586fd2..cc9fb503 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppSessionsFunction.cs @@ -46,21 +46,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps } var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - int days = 30; - if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant(); var modelFilter = query["model"]; var versionFilter = query["version"]; - int offset = 0; - if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0) - offset = parsedOffset; + var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue); - int limit = DefaultLimit; - if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0) - limit = Math.Min(parsedLimit, MaxLimit); + var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit); var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days); var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync( diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs index 3a72c35d..f22b5bde 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetAppsListFunction.cs @@ -34,9 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps var tenantId = TenantHelper.GetTenantId(req); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - int days = 30; - if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var paging = AppsAnalyticsHelper.ParseAppsPaging(query); if (paging.Error != null) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs index 92656909..9fd89df5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppAnalyticsFunction.cs @@ -52,9 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps { return await req.BadRequestAsync("tenantId must be a valid GUID"); } - int days = 30; - if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); _logger.LogInformation( "Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs index d328de23..ac4ca2dc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppSessionsFunction.cs @@ -51,21 +51,15 @@ namespace AutopilotMonitor.Functions.Functions.Apps { return await req.BadRequestAsync("tenantId must be a valid GUID"); } - int days = 30; - if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var statusFilter = (query["status"] ?? "all").Trim().ToLowerInvariant(); var modelFilter = query["model"]; var versionFilter = query["version"]; - int offset = 0; - if (int.TryParse(query["offset"], out var parsedOffset) && parsedOffset >= 0) - offset = parsedOffset; + var offset = QueryParams.Int(query["offset"], @default: 0, min: 0, max: int.MaxValue); - int limit = DefaultLimit; - if (int.TryParse(query["limit"], out var parsedLimit) && parsedLimit > 0) - limit = Math.Min(parsedLimit, MaxLimit); + var limit = QueryParams.Int(query["limit"], DefaultLimit, 1, MaxLimit); _logger.LogInformation( "Global apps/{App}/sessions requested (user: {User}, tenantId: {TenantId})", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs index d1f405cc..54a655ed 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Apps/GetGlobalAppsListFunction.cs @@ -39,9 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps { return await req.BadRequestAsync("tenantId must be a valid GUID"); } - int days = 30; - if (int.TryParse(query["days"], out var parsedDays) && parsedDays > 0 && parsedDays <= 365) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); _logger.LogInformation( "Global apps/list requested (user: {User}, tenantId: {TenantId}, days: {Days})", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs index cbe4f342..26329c37 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/ListTenantConfigBackupsFunction.cs @@ -44,9 +44,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var requestCtx = req.GetRequestContext(); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var max = int.TryParse(query["max"], out var parsed) - ? Math.Clamp(parsed, 1, MaxListSize) - : MaxListSize; + var max = QueryParams.Int(query["max"], MaxListSize, 1, MaxListSize); var backups = await _backupRepo.ListByPartitionAsync(requestCtx.TargetTenantId, max); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs index a00114b6..d66a511e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs @@ -31,12 +31,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Optional time filter: ?days=7 (default: 30, clamped to [1, 365] for consistency // with metrics/summary, metrics/usage, and metrics/platform). var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; - if (days < 1) days = 1; - if (days > 365) days = 365; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var cutoff = DateTime.UtcNow.AddDays(-days); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs index 8f6f45c6..88419938 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetDeviceJourneyFunctions.cs @@ -124,7 +124,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware var tenantId = TenantHelper.GetTenantId(req); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]); + var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(await DeviceJourneyMetricsResponseBuilder.BuildAsync( @@ -169,7 +169,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var tenantIdFilter = query["tenantId"]; - var days = DeviceJourneyMetricsResponseBuilder.ClampDays(query["days"]); + var days = QueryParams.Int(query["days"], DeviceJourneyMetricsResponseBuilder.DefaultWindowDays, 1, DeviceJourneyMetricsResponseBuilder.MaxWindowDays); var partition = string.IsNullOrWhiteSpace(tenantIdFilter) ? "global" : tenantIdFilter!; var response = req.CreateResponse(HttpStatusCode.OK); @@ -196,12 +196,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics internal const int MaxWindowDays = 180; // aggregate retention — older rows no longer exist internal const int MaxRepeatDevices = 10; - internal static int ClampDays(string? raw) - { - if (!int.TryParse(raw, out var days)) return DefaultWindowDays; - return Math.Clamp(days, 1, MaxWindowDays); - } - /// /// "Last N days" = exactly N calendar day keys including today — both range ends are /// inclusive, so subtracting the full N would return N+1 days (Codex review: days=1 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs index 5e940b59..7d74456c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetFleetHealthMetricsFunction.cs @@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var tenantId = TenantHelper.GetTenantId(req); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; - if (days < 1) days = 1; - if (days > 365) days = 365; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); _logger.LogInformation("Fetching fleet health for tenant {TenantId} (days={Days})", tenantId, days); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs index 8216029f..0f99a8e4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs @@ -46,12 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.BadRequestAsync("locationKey or country parameter is required"); } - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; - if (days < 1) days = 1; - if (days > 365) days = 365; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var groupBy = query["groupBy"] ?? "city"; // Default response shape is lean (~15 fields/row) so MCP-driven callers fit diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs index 8299db81..d620fedc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs @@ -36,12 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics _logger.LogInformation("Fetching geographic metrics for tenant {TenantId}", tenantId); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; - if (days < 1) days = 1; - if (days > 365) days = 365; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var groupBy = query["groupBy"] ?? "city"; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs index 6266fe1b..44f838c3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAgentEfficiencyFunction.cs @@ -35,8 +35,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var days = ParseIntParam(query["days"], defaultValue: 30, min: 1, max: 365); - var limit = ParseIntParam(query["limit"], defaultValue: 500, min: 1, max: 2000); + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); + var limit = QueryParams.Int(query["limit"], @default: 500, min: 1, max: 2000); var tenantIdRaw = query["tenantId"]; string? tenantId = null; @@ -62,15 +62,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalAgentEfficiency"); } } - - private static int ParseIntParam(string? raw, int defaultValue, int min, int max) - { - var value = defaultValue; - if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0) - value = parsed; - if (value < min) value = min; - if (value > max) value = max; - return value; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs index 90ae5dd5..a91d12e4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs @@ -32,14 +32,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics _logger.LogInformation($"Fetching global app metrics (User: {userEmail})"); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; // Clamped to [1, 365] like the tenant variant and the other metrics endpoints — an // unbounded days= here was the one way to request an unlimited cross-tenant scan. - if (days < 1) days = 1; - if (days > 365) days = 365; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var tenantIdFilter = query["tenantId"]; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs index 34a82400..04900381 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalFleetHealthMetricsFunction.cs @@ -35,12 +35,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var userEmail = TenantHelper.GetUserIdentifier(req); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; - if (days < 1) days = 1; - if (days > 365) days = 365; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var tenantIdFilter = query["tenantId"]; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs index e49c97f0..d0049008 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs @@ -45,10 +45,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.BadRequestAsync("locationKey or country parameter is required"); } - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var groupBy = query["groupBy"] ?? "city"; var full = string.Equals(query["full"], "1", StringComparison.Ordinal); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs index 04a3da01..2ff8ffa9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs @@ -36,10 +36,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics _logger.LogInformation("Fetching global geographic metrics (User: {UserEmail})", userEmail); var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var daysParam = query["days"]; - int days = 30; - if (!string.IsNullOrEmpty(daysParam) && int.TryParse(daysParam, out var parsedDays) && parsedDays > 0) - days = parsedDays; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); var groupBy = query["groupBy"] ?? "city"; var tenantIdFilter = query["tenantId"]; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs index 2363ac7a..5151e00d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalPlatformMetricsFunction.cs @@ -34,8 +34,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics { // Authentication + GlobalAdminOnly authorization enforced by PolicyEnforcementMiddleware - var days = ParseDays(req); - var limit = ParseLimit(req); + var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365); + // limit drives the per-session work the service performs (each session + // triggers its own GetSessionEventsAsync call). The UI dropdown sets it + // explicitly so the user controls the analysis depth (e.g. 90 days x 1000 + // sessions); the service bounds its concurrency internally, and the hard + // ceiling of 2000 protects against runaway query strings. + var limit = QueryParams.Int(req.Query["limit"], @default: 100, min: 1, max: 2000); var metrics = await _platformMetricsService.ComputePlatformMetricsAsync(days, limit); var response = req.CreateResponse(HttpStatusCode.OK); @@ -48,35 +53,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.InternalServerErrorAsync(_logger, ex, "GetGlobalPlatformMetrics"); } } - - private static int ParseDays(HttpRequestData req) - { - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var raw = query["days"]; - var days = 90; - if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0) - days = parsed; - if (days < 1) days = 1; - if (days > 365) days = 365; - return days; - } - - // Drives the per-session work the service performs (each session - // triggers its own GetSessionEventsAsync call). The UI dropdown sets - // this explicitly so the user controls the analysis depth (e.g. - // 90 days × 1000 sessions). The service uses bounded concurrency - // internally so even large limits don't blow the Function timeout. - // Hard ceiling 2000 protects against runaway query strings. - private static int ParseLimit(HttpRequestData req) - { - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var raw = query["limit"]; - var limit = 100; - if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0) - limit = parsed; - if (limit < 1) limit = 1; - if (limit > 2000) limit = 2000; - return limit; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs index a882029e..e714e4b7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalSlaMetricsFunction.cs @@ -50,8 +50,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.BadRequestAsync("tenantId query parameter is required"); } - if (int.TryParse(qs.Get("months"), out var parsedMonths)) - months = parsedMonths; + months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths); fresh = qs.Get("fresh") == "1"; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs index 933b111d..ff27311d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetVerdictCalibrationFunction.cs @@ -42,7 +42,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var tenantIdFilter = query["tenantId"]; - var days = VerdictCalibrationResponseBuilder.ClampDays(query["days"]); + var days = QueryParams.Int(query["days"], VerdictCalibrationResponseBuilder.DefaultWindowDays, 1, VerdictCalibrationResponseBuilder.MaxWindowDays); var partition = "global"; if (!string.IsNullOrWhiteSpace(tenantIdFilter)) { @@ -85,12 +85,6 @@ namespace AutopilotMonitor.Functions.Functions.Metrics internal const int TrendBaselineDays = 28; internal const int TrendHorizonDays = TrendWindowDays + TrendBaselineDays; - internal static int ClampDays(string? raw) - { - if (!int.TryParse(raw, out var days)) return DefaultWindowDays; - return Math.Clamp(days, 1, MaxWindowDays); - } - /// "Last N days" = exactly N calendar day keys including today (both range ends inclusive). internal static DateTime InclusiveWindowStart(DateTime today, int days) => today.AddDays(-(days - 1)); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs index 8cc6c709..09466cc0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs @@ -26,7 +26,7 @@ public class MetricsSummaryFunction try { var tenantId = TenantHelper.GetTenantId(req); - var days = ParseDays(req); + var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365); var summary = await _metricsRepo.GetMetricsSummaryAsync(tenantId, days); var response = req.CreateResponse(HttpStatusCode.OK); @@ -45,7 +45,7 @@ public class MetricsSummaryFunction { try { - var days = ParseDays(req); + var days = QueryParams.Int(req.Query["days"], @default: 30, min: 1, max: 365); // Optional tenantId filter — when set, GA scopes the cross-tenant summary to one // tenant. When absent, returns the full cross-tenant view (null → all tenants). var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); @@ -73,16 +73,4 @@ public class MetricsSummaryFunction return await req.InternalServerErrorAsync(_logger, ex, "Get global metrics summary"); } } - - private static int ParseDays(HttpRequestData req) - { - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var raw = query["days"]; - var days = 30; - if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0) - days = parsed; - if (days < 1) days = 1; - if (days > 365) days = 365; - return days; - } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs index 771b451e..e18fd957 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/PlatformUsageMetricsFunction.cs @@ -41,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics // Optional tenantId query parameter: when provided, return tenant-specific metrics var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); var tenantId = query["tenantId"]; - var days = ParseDays(query); + var days = QueryParams.Int(query["days"], @default: 90, min: 1, max: 365); var metrics = !string.IsNullOrEmpty(tenantId) ? await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days) @@ -57,16 +57,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.InternalServerErrorAsync(_logger, ex, "PlatformUsageMetrics"); } } - - private static int ParseDays(System.Collections.Specialized.NameValueCollection query) - { - var raw = query["days"]; - var days = 90; - if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0) - days = parsed; - if (days < 1) days = 1; - if (days > 365) days = 365; - return days; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs index a1110b82..790d1cdf 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/RuleHitSessionsFunction.cs @@ -48,7 +48,9 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.BadRequestAsync("ruleId is required"); } - var days = ParseDays(query["days"]); + // Window in days: default 14, capped at 90 (RuleResults follow the session + // retention cascade, so anything past 90 days cannot widen the result). + var days = QueryParams.Int(query["days"], @default: 14, min: 1, max: 90); var sinceUtc = DateTime.UtcNow.AddDays(-days); var sessionIds = await _ruleRepo.GetRuleHitSessionIdsAsync( requestCtx.TargetTenantId, ruleId, sinceUtc, MaxSessionIds); @@ -68,16 +70,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.InternalServerErrorAsync(_logger, ex, "RuleHitSessions"); } } - - /// - /// Window in days: default 14, clamped to 1..90 (RuleResults follow the session - /// retention cascade, so anything past 90 days cannot widen the result). - /// - internal static int ParseDays(string? raw) - { - if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var parsed)) - return 14; - return Math.Clamp(parsed, 1, 90); - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs index 59c3ff44..f5af21a4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/SlaMetricsFunction.cs @@ -43,8 +43,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics { var qs = System.Web.HttpUtility.ParseQueryString(req.Url.Query ?? ""); - if (int.TryParse(qs.Get("months"), out var parsedMonths)) - months = parsedMonths; + months = QueryParams.Int(qs.Get("months"), months, 1, SlaMetricsService.MaxMonths); fresh = qs.Get("fresh") == "1"; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs index ab756677..84905d13 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/UsageMetricsFunction.cs @@ -40,7 +40,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics string tenantId = TenantHelper.GetTenantId(req); string userIdentifier = TenantHelper.GetUserIdentifier(req); - var days = ParseDays(req); + var days = QueryParams.Int(req.Query["days"], @default: 90, min: 1, max: 365); _logger.LogInformation("Fetching usage metrics for tenant {TenantId} by user {User} (days={Days})", tenantId, userIdentifier, days); var metrics = await _usageMetricsService.ComputeTenantUsageMetricsAsync(tenantId, days); @@ -55,17 +55,5 @@ namespace AutopilotMonitor.Functions.Functions.Metrics return await req.InternalServerErrorAsync(_logger, ex, "UsageMetrics"); } } - - private static int ParseDays(HttpRequestData req) - { - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - var raw = query["days"]; - var days = 90; - if (!string.IsNullOrEmpty(raw) && int.TryParse(raw, out var parsed) && parsed > 0) - days = parsed; - if (days < 1) days = 1; - if (days > 365) days = 365; - return days; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs index ab596e2f..d34a9ec2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs @@ -87,10 +87,13 @@ namespace AutopilotMonitor.Functions.Functions.Raw // Date window: an unparsable value is an error, not a silently dropped filter — // a caller that believes it narrowed the window must never get the whole table. - if (!QueryRawEventsPagination.TryParseUtc(startedAfter, out var afterUtc) - || !QueryRawEventsPagination.TryParseUtc(startedBefore, out var beforeUtc)) + if (!QueryParams.TryUtcInstant(startedAfter, "startedAfter", out var afterUtc, out var dateError)) { - return await req.BadRequestAsync("startedAfter/startedBefore must be ISO 8601 datetimes"); + return await req.BadRequestAsync(dateError!); + } + if (!QueryParams.TryUtcInstant(startedBefore, "startedBefore", out var beforeUtc, out dateError)) + { + return await req.BadRequestAsync(dateError!); } var callerTenantId = TenantHelper.GetTenantId(req); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs index d2858484..3ec9f7d9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs @@ -115,10 +115,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw OsBuild = string.IsNullOrEmpty(osBuild) ? null : osBuild, GeoCountry = string.IsNullOrEmpty(geoCountry) ? null : geoCountry, }; - if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after)) - filter.StartedAfter = after; - if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before)) - filter.StartedBefore = before; + filter.StartedAfter = QueryParams.UtcInstant(startedAfter); + filter.StartedBefore = QueryParams.UtcInstant(startedBefore); if (!string.IsNullOrEmpty(isPreProvisioned) && bool.TryParse(isPreProvisioned, out var pp)) filter.IsPreProvisioned = pp; if (!string.IsNullOrEmpty(isHybridJoin) && bool.TryParse(isHybridJoin, out var hj)) @@ -127,8 +125,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw filter.IsSelfDeployingProfile = sdp; if (!string.IsNullOrEmpty(isCloudPc) && bool.TryParse(isCloudPc, out var icp)) filter.IsCloudPc = icp; - if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin; - if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax; + filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]); + filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]); var connectionType = query["connectionType"]; if (!string.IsNullOrEmpty(connectionType)) filter.ConnectionType = connectionType; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs index edbb7858..2427423a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionDeletionsListFunction.cs @@ -61,19 +61,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions return await req.BadRequestAsync("Query parameter 'state' must be one of: Preparing, Queued, Running, Poisoned."); } - int? strandedSinceMinutes = null; - var strandedRaw = query["strandedSinceMinutes"]; - if (!string.IsNullOrEmpty(strandedRaw)) + // 10080 minutes = 7 days. + if (!QueryParams.TryInt(query["strandedSinceMinutes"], "strandedSinceMinutes", 1, 10080, out var strandedSinceMinutes, out var strandedError)) { - if (state != SessionDeletionState.Queued) - { - return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued."); - } - if (!int.TryParse(strandedRaw, out var minutes) || minutes <= 0 || minutes > 10080) - { - return await req.BadRequestAsync("strandedSinceMinutes must be a positive integer up to 10080 (7 days)."); - } - strandedSinceMinutes = minutes; + return await req.BadRequestAsync(strandedError!); + } + if (strandedSinceMinutes.HasValue && state != SessionDeletionState.Queued) + { + return await req.BadRequestAsync("strandedSinceMinutes is only valid with state=Queued."); } var cutoffUtc = strandedSinceMinutes.HasValue diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs index 0e969cc6..fe68251d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionStatsFunction.cs @@ -65,19 +65,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions internal static bool TryParseDays(string? raw, out int days, out string? error) { - days = DefaultDays; - error = null; - if (string.IsNullOrEmpty(raw)) return true; - - if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) - || parsed < 1 || parsed > MaxDays) - { - error = $"days must be a positive integer between 1 and {MaxDays}"; - return false; - } - - days = parsed; - return true; + var ok = QueryParams.TryInt(raw, "days", 1, MaxDays, out var parsed, out error); + days = parsed ?? DefaultDays; + return ok; } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs index 882ee70a..086f4e94 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs @@ -158,10 +158,10 @@ public class SearchSessionsFunction if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj; if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp; if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp; - if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa; - if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb; - if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin; - if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax; + filter.StartedAfter = QueryParams.UtcInstant(query["startedAfter"]); + filter.StartedBefore = QueryParams.UtcInstant(query["startedBefore"]); + filter.RebootCountMin = QueryParams.IntOrNull(query["rebootCountMin"]); + filter.RebootCountMax = QueryParams.IntOrNull(query["rebootCountMax"]); // Dynamic device property filters: any query param starting with "prop." var deviceProperties = new Dictionary(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs index 08d7a23a..ac193e28 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs @@ -51,12 +51,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability var userIdentifier = TenantHelper.GetUserIdentifier(req); var query = HttpUtility.ParseQueryString(req.Url.Query); - var skip = 0; - var take = DefaultTake; - if (int.TryParse(query["skip"], out var parsedSkip) && parsedSkip >= 0) - skip = parsedSkip; - if (int.TryParse(query["take"], out var parsedTake) && parsedTake >= 1) - take = Math.Min(parsedTake, MaxTake); + var skip = QueryParams.Int(query["skip"], @default: 0, min: 0, max: int.MaxValue); + var take = QueryParams.Int(query["take"], DefaultTake, 1, MaxTake); // Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri). // Server-side filtering on CpeUri is NOT possible: rows re-created by the cascade-delete diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs index f2dd7447..ba94dd51 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilitySummaryFunction.cs @@ -66,8 +66,8 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability try { var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); - var days = int.TryParse(query["days"], out var d) ? Math.Clamp(d, 1, 365) : 30; - var topN = int.TryParse(query["topN"], out var t) ? Math.Clamp(t, 1, 100) : 20; + var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365); + var topN = QueryParams.Int(query["topN"], @default: 20, min: 1, max: 100); _logger.LogInformation( "Vulnerability summary requested (scope={Scope}, days={Days}, topN={TopN})", diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/QueryParams.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/QueryParams.cs new file mode 100644 index 00000000..f8fbb9af --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/QueryParams.cs @@ -0,0 +1,140 @@ +using System.Collections.Specialized; +using System.Globalization; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// The one parser for HTTP query parameters. Every function reads its numbers and instants +/// through here (QueryParamGuardTests fails on a stray int.TryParse / +/// DateTime.TryParse under Functions/ or Pagination/); the default and +/// the range stay arguments at the call site because they are per-endpoint wire behaviour +/// (a 7-day stats window and a 90-day usage window are both right). +/// +/// Two contracts, deliberately not one: +/// +/// Lenient (, , +/// ): a missing, unparseable or below-minimum value means "use the +/// default", a value above the maximum is clamped. For windows and top-N knobs where a +/// dashboard link with a stale or odd value should still render. +/// Strict (, , +/// ): a missing value is null (the caller applies its +/// default), anything else that does not parse or falls outside the range is an error +/// message for a 400. For pagination and admin batch sizes, where a silently altered +/// value would misreport what was done. +/// +/// Numbers are parsed invariant (); instants are parsed +/// invariant with AssumeUniversal | AdjustToUniversal, so 2026-04-20, +/// 2026-04-20T10:00:00 and 2026-04-20T12:00:00+02:00 all yield the same UTC +/// value with . Whitespace-only counts as absent. +/// +/// +internal static class QueryParams +{ + /// Upper bound of every pageSize parameter (continuation-token pagination). + public const int MaxPageSize = 1000; + + private const DateTimeStyles UtcStyles = DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal; + + // ── Lenient ───────────────────────────────────────────────────────────────────────── + + /// Lenient integer: absent / unparseable / below ⇒ ; above ⇒ . + public static int Int(string? raw, int @default, int min, int max) + { + if (!TryParseInvariant(raw, out var value) || value < min) return @default; + return value > max ? max : value; + } + + /// + public static int Int(NameValueCollection? query, string name, int @default, int min, int max) + => Int(query?[name], @default, min, max); + + /// Lenient optional integer without a range: absent or unparseable ⇒ null. + public static int? IntOrNull(string? raw) + => TryParseInvariant(raw, out var value) ? value : (int?)null; + + /// Lenient optional instant: absent or unparseable ⇒ null, otherwise UTC. + public static DateTime? UtcInstant(string? raw) + => TryParseUtc(raw, out var value) ? value : (DateTime?)null; + + // ── Strict ────────────────────────────────────────────────────────────────────────── + + /// + /// Strict integer: absent ⇒ null + true; unparseable or outside + /// [, ] ⇒ false with an error message that names + /// the parameter. Pass int.MaxValue as for "at least min". + /// + public static bool TryInt(string? raw, string name, int min, int max, out int? value, out string? error) + { + value = null; + error = null; + if (string.IsNullOrWhiteSpace(raw)) return true; + + if (!TryParseInvariant(raw, out var parsed)) + { + error = $"{name} must be an integer"; + return false; + } + + if (parsed < min || parsed > max) + { + error = max == int.MaxValue + ? $"{name} must be an integer of at least {min}" + : $"{name} must be between {min} and {max}"; + return false; + } + + value = parsed; + return true; + } + + /// Strict pageSize: absent ⇒ null (the paginator applies its default), otherwise 1... + public static bool TryPageSize(string? raw, out int? pageSize, out string? error) + => TryInt(raw, "pageSize", 1, MaxPageSize, out pageSize, out error); + + /// Strict instant: absent ⇒ null + true; unparseable ⇒ false with an error message; otherwise UTC. + public static bool TryUtcInstant(string? raw, string name, out DateTime? value, out string? error) + { + value = null; + error = null; + if (string.IsNullOrWhiteSpace(raw)) return true; + + if (!TryParseUtc(raw, out var parsed)) + { + error = $"{name} must be an ISO 8601 date or date-time"; + return false; + } + + value = parsed; + return true; + } + + // ── Core ──────────────────────────────────────────────────────────────────────────── + + private static bool TryParseInvariant(string? raw, out int value) + { + if (string.IsNullOrWhiteSpace(raw)) + { + value = 0; + return false; + } + return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value); + } + + private static bool TryParseUtc(string? raw, out DateTime value) + { + if (string.IsNullOrWhiteSpace(raw)) + { + value = default; + return false; + } + if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, UtcStyles, out var parsed)) + { + value = default; + return false; + } + // AdjustToUniversal yields Kind=Utc; SpecifyKind is belt and braces for the one + // shape (a bare date) where some runtimes leave it Unspecified. + value = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc); + return true; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs index 21654ca9..11d1a252 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/DateWindowPagination.cs @@ -5,6 +5,7 @@ using System.Globalization; using System.Linq; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -35,7 +36,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class DateWindowPagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; public static readonly TimeSpan DefaultWindow = TimeSpan.FromDays(30); /// @@ -67,19 +67,10 @@ namespace AutopilotMonitor.Functions.Pagination var pageSizeRaw = query?["pageSize"]; var continuationRaw = query?["continuation"]; - DateTime? dateFrom = null, dateTo = null; - if (!string.IsNullOrEmpty(dateFromRaw)) - { - if (!TryParseUtc(dateFromRaw!, out var parsed)) - return new Parsed { Error = "dateFrom must be ISO 8601" }; - dateFrom = parsed; - } - if (!string.IsNullOrEmpty(dateToRaw)) - { - if (!TryParseUtc(dateToRaw!, out var parsed)) - return new Parsed { Error = "dateTo must be ISO 8601" }; - dateTo = parsed; - } + if (!QueryParams.TryUtcInstant(dateFromRaw, "dateFrom", out var dateFrom, out var dateError)) + return new Parsed { Error = dateError }; + if (!QueryParams.TryUtcInstant(dateToRaw, "dateTo", out var dateTo, out dateError)) + return new Parsed { Error = dateError }; if (dateFrom.HasValue && dateTo.HasValue && dateFrom > dateTo) { return new Parsed { Error = "dateFrom must be <= dateTo" }; @@ -97,15 +88,8 @@ namespace AutopilotMonitor.Functions.Pagination dateTo = anchor; } - int? pageSize = null; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError)) + return new Parsed { Error = pageSizeError }; // continuation is meaningless without pageSize — silently drop. var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw) @@ -196,23 +180,5 @@ namespace AutopilotMonitor.Functions.Pagination } return sb.ToString(); } - - private static bool TryParseUtc(string s, out DateTime result) - { - // Accept ISO 8601 in any of the standard forms; force UTC. - // AdjustToUniversal + AssumeUniversal handles strings without a TZ - // offset (treat as UTC) and converts strings with an offset to UTC; - // RoundtripKind is mutually exclusive with these and not needed. - if (DateTime.TryParse( - s, CultureInfo.InvariantCulture, - DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, - out var parsed)) - { - result = parsed.Kind == DateTimeKind.Utc ? parsed : DateTime.SpecifyKind(parsed, DateTimeKind.Utc); - return true; - } - result = default; - return false; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs index d16ff864..0a39a815 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/QueryRawEventsPagination.cs @@ -4,6 +4,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -32,7 +33,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class QueryRawEventsPagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; /// /// Slack between an event's sanitized time and the write time of its EventTypeIndex @@ -48,22 +48,6 @@ namespace AutopilotMonitor.Functions.Pagination public static DateTime? IndexWrittenAfterHint(DateTime? startedAfterUtc) => startedAfterUtc.HasValue ? startedAfterUtc.Value - IndexWriteTimeSlack : null; - /// - /// Parses an ISO-8601 query value as a UTC instant. Returns false only for a non-empty - /// value that does not parse — an absent/empty value yields null and true. - /// A bare (offset-less) value is taken as UTC, never as server-local time. - /// - public static bool TryParseUtc(string? raw, out DateTime? utc) - { - utc = null; - if (string.IsNullOrWhiteSpace(raw)) return true; - if (!DateTime.TryParse(raw, CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) - return false; - utc = parsed; - return true; - } - public static string Fingerprint( string scope, string callerTenantId, @@ -104,15 +88,9 @@ namespace AutopilotMonitor.Functions.Pagination var pageSizeRaw = query?["pageSize"]; var continuationRaw = query?["continuation"]; - int pageSize = DefaultPageSize; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError)) + return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError }; + var pageSize = pageSizeOrNull ?? DefaultPageSize; return new Parsed { diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs index 6442749b..52807ff4 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/RawTablePagination.cs @@ -4,6 +4,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class RawTablePagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; public static string Fingerprint( string callerTenantId, @@ -49,15 +49,9 @@ namespace AutopilotMonitor.Functions.Pagination var pageSizeRaw = query?["pageSize"]; var continuationRaw = query?["continuation"]; - int pageSize = DefaultPageSize; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError)) + return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError }; + var pageSize = pageSizeOrNull ?? DefaultPageSize; return new Parsed { diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs index 4091f2bb..515c6147 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByCvePagination.cs @@ -4,6 +4,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class SearchSessionsByCvePagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; public static string Fingerprint( string scope, @@ -50,15 +50,9 @@ namespace AutopilotMonitor.Functions.Pagination var pageSizeRaw = query?["pageSize"]; var continuationRaw = query?["continuation"]; - int pageSize = DefaultPageSize; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError)) + return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError }; + var pageSize = pageSizeOrNull ?? DefaultPageSize; return new Parsed { diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs index daf02b39..8f886183 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsByEventPagination.cs @@ -4,6 +4,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class SearchSessionsByEventPagination { public const int DefaultPageSize = 50; - public const int MaxPageSize = 1000; public static string Fingerprint( string scope, @@ -46,15 +46,9 @@ namespace AutopilotMonitor.Functions.Pagination var pageSizeRaw = query?["pageSize"]; var continuationRaw = query?["continuation"]; - int pageSize = DefaultPageSize; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError)) + return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError }; + var pageSize = pageSizeOrNull ?? DefaultPageSize; return new Parsed { diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs index b2f56ad2..c7e9d909 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SearchSessionsPagination.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -20,7 +21,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class SearchSessionsPagination { public const int DefaultPageSize = 50; - public const int MaxPageSize = 1000; public static string Fingerprint(string scope, string callerTenantId, string? filterTenantId, SessionSearchFilter filter) { @@ -79,15 +79,9 @@ namespace AutopilotMonitor.Functions.Pagination var pageSizeRaw = query?["pageSize"]; var continuationRaw = query?["continuation"]; - int pageSize = DefaultPageSize; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError)) + return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError }; + var pageSize = pageSizeOrNull ?? DefaultPageSize; return new Parsed { PageSize = pageSize, diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs index 1485f0a2..f03c966a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Text; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -19,7 +20,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class SessionAnnotationsPagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; /// /// Cap on the free-text note search (?q=). Notes themselves are capped at 4096 /// characters; a search term longer than this is never a real query. @@ -62,16 +62,10 @@ namespace AutopilotMonitor.Functions.Pagination public static Parsed ParseQuery(NameValueCollection? query) { - var pageSize = DefaultPageSize; var pageSizeRaw = query?["pageSize"]; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError)) + return new Parsed { Error = pageSizeError }; + var pageSize = pageSizeOrNull ?? DefaultPageSize; var lane = Normalize(query?["lane"]); if (lane != null && !AnnotationLanes.All.Contains(lane)) @@ -81,10 +75,10 @@ namespace AutopilotMonitor.Functions.Pagination if (verdict != null && !AnnotationVerdicts.All.Contains(verdict)) return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" }; - if (!TryParseDate(query?["dateFrom"], out var dateFrom)) - return new Parsed { Error = "dateFrom must be an ISO 8601 date" }; - if (!TryParseDate(query?["dateTo"], out var dateTo)) - return new Parsed { Error = "dateTo must be an ISO 8601 date" }; + if (!QueryParams.TryUtcInstant(query?["dateFrom"], "dateFrom", out var dateFrom, out var dateError)) + return new Parsed { Error = dateError }; + if (!QueryParams.TryUtcInstant(query?["dateTo"], "dateTo", out var dateTo, out dateError)) + return new Parsed { Error = dateError }; var search = NullIfEmpty(query?["q"]?.Trim()); if (search != null && search.Length > MaxQueryLength) @@ -149,18 +143,6 @@ namespace AutopilotMonitor.Functions.Pagination sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!)); } - private static bool TryParseDate(string? raw, out DateTime? value) - { - value = null; - if (string.IsNullOrEmpty(raw)) return true; - if (!DateTime.TryParse( - raw, CultureInfo.InvariantCulture, - DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed)) - return false; - value = parsed; - return true; - } - private static string? Normalize(string? raw) => string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant(); diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs index 92a8a3d6..30c530e2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionEventsPagination.cs @@ -3,6 +3,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -14,7 +15,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class SessionEventsPagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; /// Fingerprint binding tokens to (tenantId, sessionId). public static string Fingerprint(string tenantId, string sessionId) => @@ -51,19 +51,8 @@ namespace AutopilotMonitor.Functions.Pagination var rawPageSize = query["pageSize"]; var rawContinuation = query["continuation"]; - int? pageSize = null; - if (!string.IsNullOrEmpty(rawPageSize)) - { - if (!int.TryParse(rawPageSize, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - { - return new Parsed { Error = "pageSize must be an integer" }; - } - if (n < 1 || n > MaxPageSize) - { - return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; - } - pageSize = n; - } + if (!QueryParams.TryPageSize(rawPageSize, out var pageSize, out var pageSizeError)) + return new Parsed { Error = pageSizeError }; // continuation without pageSize is meaningless — silently drop it // rather than 400, so a caller that toggles pageSize off does not get diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs index 2d4613bb..c89e99c8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionListPagination.cs @@ -3,6 +3,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -16,7 +17,8 @@ namespace AutopilotMonitor.Functions.Pagination public static class SessionListPagination { public const int DefaultPageSize = 100; - public const int MaxPageSize = 1000; + /// Scan-cost cap on days; a wider window is a 400, never a silent narrowing. + public const int MaxDays = 365; /// /// Fingerprint binding the token to (scope, callerTenantId, days, filterTenantId). @@ -47,23 +49,12 @@ namespace AutopilotMonitor.Functions.Pagination var daysRaw = query?["days"]; var filterTenantIdRaw = acceptFilterTenantId ? query?["tenantId"] : null; - int pageSize = DefaultPageSize; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { PageSize = DefaultPageSize, Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { PageSize = DefaultPageSize, Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSizeOrNull, out var pageSizeError)) + return new Parsed { PageSize = DefaultPageSize, Error = pageSizeError }; + var pageSize = pageSizeOrNull ?? DefaultPageSize; - int? days = null; - if (!string.IsNullOrEmpty(daysRaw)) - { - if (!int.TryParse(daysRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var d) || d < 1) - return new Parsed { PageSize = pageSize, Error = "days must be a positive integer" }; - days = d; - } + if (!QueryParams.TryInt(daysRaw, "days", 1, MaxDays, out var days, out var daysError)) + return new Parsed { PageSize = pageSize, Error = daysError }; return new Parsed { diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs index fcfb1889..837c8b4c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/SessionReportsPagination.cs @@ -3,6 +3,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -18,7 +19,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class SessionReportsPagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; public static string Fingerprint(string callerTenantId, string? filterTenantId) => ContinuationToken.ComputeFingerprint(new[] @@ -42,15 +42,8 @@ namespace AutopilotMonitor.Functions.Pagination var pageSizeRaw = query?["pageSize"]; var continuationRaw = query?["continuation"]; - int? pageSize = null; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError)) + return new Parsed { Error = pageSizeError }; // continuation is meaningless without pageSize — silently drop. var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw) diff --git a/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs b/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs index 3c3951f1..53fcd729 100644 --- a/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs +++ b/src/Backend/AutopilotMonitor.Functions/Pagination/TenantConfigPagination.cs @@ -3,6 +3,7 @@ using System.Collections.Specialized; using System.Globalization; using System.Text; using AutopilotMonitor.Shared.Pagination; +using AutopilotMonitor.Functions.Helpers; namespace AutopilotMonitor.Functions.Pagination { @@ -17,7 +18,6 @@ namespace AutopilotMonitor.Functions.Pagination public static class TenantConfigPagination { public const int DefaultPageSize = 200; - public const int MaxPageSize = 1000; public static string Fingerprint(string callerTenantId) => ContinuationToken.ComputeFingerprint(new[] @@ -46,15 +46,8 @@ namespace AutopilotMonitor.Functions.Pagination var continuationRaw = query?["continuation"]; var fieldsRaw = query?["fields"]; - int? pageSize = null; - if (!string.IsNullOrEmpty(pageSizeRaw)) - { - if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n)) - return new Parsed { Error = "pageSize must be an integer" }; - if (n < 1 || n > MaxPageSize) - return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" }; - pageSize = n; - } + if (!QueryParams.TryPageSize(pageSizeRaw, out var pageSize, out var pageSizeError)) + return new Parsed { Error = pageSizeError }; // continuation is meaningless without pageSize — silently drop. var continuation = pageSize.HasValue && !string.IsNullOrEmpty(continuationRaw) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs index 729432b5..61d3f815 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SlaMetricsService.cs @@ -55,9 +55,12 @@ namespace AutopilotMonitor.Functions.Services _cache.TryRemove(key, out _); } + /// Widest window the SLA aggregates serve; the HTTP layer caps months at this too. + public const int MaxMonths = 6; + public async Task ComputeSlaMetricsAsync(string tenantId, int months = 3, bool fresh = false) { - months = Math.Clamp(months, 1, 6); + months = Math.Clamp(months, 1, MaxMonths); var cacheKey = $"{tenantId}:{months}";