Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/HelloWaitTimeoutConfigTests.cs
T
Oliver Kieselbach 821c41e2ba Tenant Hello wait timeout up to 1 hour (#167)
* feat: tenant Hello wait timeout up to 1 hour

The "Hello Wait Timeout" setting (30..300 s in the UI) had no effect in the
V2 agent: its only consumer, HelloTracker.StartHelloWaitTimer, has no
production caller, while the decision engine armed a fixed 300 s
hello_safety deadline after the ESP exit and the tracker ran a fixed 300 s
completion timer after the wizard start.

The setting is now the real Hello budget, clamped to 300..3600 s
(HelloWaitTimeout in AutopilotMonitor.Shared). Every value that was valid
before (<= 300, including the old 30 s default) keeps today's 5 minutes.

- Engine: the agent stamps helloWaitTimeoutSeconds on EnrollmentFactsObserved
  (only when > 300), recorded set-once in EnrollmentScenarioObservations.
  All six hello_safety arm sites share one builder with the tenant window;
  a fact that arrives after hello_safety was armed moves it out. The WDP
  backstop is never shorter than the tenant window; the signal census
  surfaces hello_wait_extended.
- Agent: the Hello tracker's wizard completion timer uses the same budget;
  the stall probe skips session_stalled while hello_safety is pending.
- Backend: ValidateModel rejects values outside 300..3600; stored rows are
  read as their effective value, so legacy 30 s rows never block unrelated
  saves or backup reverts. Default 30 -> 300; shared manifest and wire types
  regenerated.
- Web: input 300..3600, bounds pinned against the C# constants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG

* fix: keep the Hello wait's 30 s minimum and default, raise only the maximum

Product decision: the setting keeps its UI contract (30 s minimum and
default, same description); only the maximum moves to 3600 s. Configured
values up to 300 s keep the built-in 5-minute Hello window, larger values
extend it.

- HelloWaitTimeout: MinSeconds/DefaultSeconds 30; BuiltInSeconds 300 is the
  floor of EffectiveSeconds.
- Backend validates only a changed value (30..3600), like the retention cap;
  the read-time normalization is gone again.
- The agent stamps the tenant window only above BuiltInSeconds.
- Web keeps the original text and default; only the maximum and its hint
  change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG

* fix: Hello wait default 300 s, stale-fire guard, deferred stall report

Product decision: default 300 s (what V2 has always waited), minimum 30 s
still accepted, maximum 3600 s. Values up to 300 s keep the built-in
5-minute window, which the settings hint now says.

Review follow-ups:
- Engine: a hello_safety fire whose incarnation was replaced (armed due
  later than the fire's due time) is dead-ended, same guard as RealmJoin —
  a late tenant window could otherwise be undercut by the queued fire of
  the built-in deadline. The tenant fact is recorded only when it extends
  the built-in window.
- Stall probe: session_stalled is held back while a tenant-extended Hello
  wait (hello_safety or the WDP backstop) is pending and emitted once it is
  over and the session is still idle; real activity drops it. Default
  tenants never hit the guard.
- Comments: WDP backstop wording, 5-min reference, AgentConfiguration.
- Web pins the default against the shared manifest; tracker constructor
  defaults use HelloWaitTimeout.DefaultSeconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S17pipjWqjZ8ULAu1JrFyG

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-30 15:36:36 +02:00

124 lines
4.7 KiB
C#

using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tenant Hello wait (<see cref="TenantConfiguration.HelloWaitTimeoutSeconds"/>): a changed value
/// must lie in 30..3600 s. Like the retention cap, only a CHANGED value is checked, so a value
/// stored before the rule existed never blocks an unrelated save or a backup revert (both
/// validate the whole model).
/// </summary>
public class HelloWaitTimeoutConfigTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
[Theory]
[InlineData(30)]
[InlineData(300)]
[InlineData(1800)]
[InlineData(3600)]
public void ValidateModel_accepts_the_supported_range(int seconds)
{
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = seconds;
Assert.Null(TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: false));
}
[Theory]
[InlineData(29)]
[InlineData(3601)]
[InlineData(0)]
[InlineData(-1)]
public void ValidateModel_rejects_a_changed_value_outside_the_range(int seconds)
{
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = seconds;
var error = TenantConfigValidation.ValidateModel(candidate, TenantConfiguration.CreateDefault(TenantId), isGlobalAdmin: true);
Assert.Equal("Hello wait timeout must be between 30 and 3600 seconds.", error);
}
[Fact]
public void ValidateModel_lets_an_unchanged_out_of_range_value_through()
{
// Written through the API before the range was validated — an unrelated save (or a
// revert to a snapshot holding the same value) must not fail on it.
var existing = TenantConfiguration.CreateDefault(TenantId);
existing.HelloWaitTimeoutSeconds = 86400;
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = 86400;
candidate.ContactEmail = "it@example.com";
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
}
[Fact]
public void Default_is_the_built_in_five_minutes()
{
Assert.Equal(300, TenantConfiguration.CreateDefault(TenantId).HelloWaitTimeoutSeconds);
Assert.Equal(300, new CollectorConfiguration().HelloWaitTimeoutSeconds);
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(HelloWaitTimeout.DefaultSeconds));
}
[Fact]
public void Stored_old_default_stays_valid_and_keeps_the_built_in_window()
{
// Every tenant that ever saved the agent settings page has the old 30 s default stored.
var existing = TenantConfiguration.CreateDefault(TenantId);
existing.HelloWaitTimeoutSeconds = 30;
var candidate = TenantConfiguration.CreateDefault(TenantId);
candidate.HelloWaitTimeoutSeconds = 30;
candidate.ContactEmail = "it@example.com";
Assert.Null(TenantConfigValidation.ValidateModel(candidate, existing, isGlobalAdmin: false));
Assert.Equal(300, HelloWaitTimeout.EffectiveSeconds(30));
}
[Theory]
[InlineData(30, 300)]
[InlineData(300, 300)]
[InlineData(301, 301)]
[InlineData(3600, 3600)]
[InlineData(86400, 3600)]
[InlineData(0, 300)]
public void EffectiveSeconds_keeps_the_built_in_window_as_floor_and_caps_at_one_hour(int configured, int expected)
{
Assert.Equal(expected, HelloWaitTimeout.EffectiveSeconds(configured));
}
[Fact]
public void Row_without_the_column_reads_as_the_default()
{
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
entity.Remove("HelloWaitTimeoutSeconds");
Assert.Equal(300, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
}
[Fact]
public void Stored_value_is_read_verbatim()
{
var entity = TableConfigRepository.ConvertToTenantTableEntity(TenantConfiguration.CreateDefault(TenantId));
entity["HelloWaitTimeoutSeconds"] = 30;
Assert.Equal(30, TableConfigRepository.ConvertFromTenantTableEntity(entity).HelloWaitTimeoutSeconds);
}
[Fact]
public void Configured_value_roundtrips_store_and_map()
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.HelloWaitTimeoutSeconds = 2400;
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(config));
Assert.Equal(2400, mapped.HelloWaitTimeoutSeconds);
}
}