From 43c6eb535473f2eff0dcad28a31900d1c58e6a74 Mon Sep 17 00:00:00 2001 From: frodehus Date: Thu, 24 Sep 2026 07:02:07 +0200 Subject: [PATCH] Core and CLI: drop activations whose end has passed Port of the macOS fix in #201. ActiveAssignment.HasLapsed in the C# Core; ElevateSession.DropLapsedAssignments, called by 'elevate watch' before every redraw, so a failed re-read no longer leaves ended activations in the table. --- CHANGELOG.md | 7 +++ cli/src/Elevate.Cli/Commands/RoleCommands.cs | 3 ++ .../Session/ElevateSession.Refresh.cs | 19 ++++++++ cli/tests/Elevate.Cli.Tests/SessionTests.cs | 17 ++++++++ windows/src/Elevate.Core/Models/Roles.cs | 10 +++++ .../LapsedAssignmentTests.cs | 43 +++++++++++++++++++ 6 files changed, 99 insertions(+) create mode 100644 windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 449ed2b9..125cf3b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- CLI: **`elevate watch` no longer keeps listing activations that have ended.** When a re-read + failed — a sign-in it could not renew silently, a dropped network — the table kept the last rows + it had, including activations whose end time had since passed. `watch` now drops a row as soon as + its end has passed, whether or not the last read got through. + ## [1.8.0] - 2026-09-19 ### Added diff --git a/cli/src/Elevate.Cli/Commands/RoleCommands.cs b/cli/src/Elevate.Cli/Commands/RoleCommands.cs index 5e8a22ee..4ea37910 100644 --- a/cli/src/Elevate.Cli/Commands/RoleCommands.cs +++ b/cli/src/Elevate.Cli/Commands/RoleCommands.cs @@ -151,6 +151,9 @@ await console.Live(Render(session, lastRead)).AutoClear(false).StartAsync(async lastRead = DateTimeOffset.UtcNow; } + // A read that failed (a sign-in it could not renew, a dropped network) + // leaves the last rows in place; one whose end has passed is over regardless. + session.DropLapsedAssignments(DateTimeOffset.UtcNow); live.UpdateTarget(Render(session, lastRead)); } } diff --git a/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs b/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs index 6f9ba1c3..f14e6496 100644 --- a/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs +++ b/cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs @@ -18,6 +18,25 @@ public async Task RefreshAsync(IEnumerable keys, CancellationToken ct public Task RefreshAllAsync(CancellationToken ct = default) => RefreshAsync(State.Tenants.Select(t => t.Key).ToList(), ct); + /// + /// Drops every row whose activation window has ended by . A refresh that + /// cannot read a tenant keeps its known rows, so a long-running view (watch) would + /// otherwise go on listing activations that are already over. Returns whether anything went. + /// + public bool DropLapsedAssignments(DateTimeOffset now) + { + var dropped = false; + Mutate(() => + { + foreach (var key in Active.Where(p => p.Value.HasLapsed(now)).Select(p => p.Key).ToList()) + { + Active.Remove(key); + dropped = true; + } + }); + return dropped; + } + public async Task RefreshTenantAsync(TenantKey key, CancellationToken ct = default, IReadOnlySet? requestedKinds = null) { if (Identity(key.IdentityId) is not { } identity || Tenant(key) is not { } tenant) diff --git a/cli/tests/Elevate.Cli.Tests/SessionTests.cs b/cli/tests/Elevate.Cli.Tests/SessionTests.cs index 0664500c..191a279e 100644 --- a/cli/tests/Elevate.Cli.Tests/SessionTests.cs +++ b/cli/tests/Elevate.Cli.Tests/SessionTests.cs @@ -26,6 +26,23 @@ public async Task RefreshLoadsRolesAndAssignments() t.Session.TenantErrors.Should().BeEmpty(); } + [Fact] + public async Task DropLapsedAssignmentsRemovesOnlyRowsWhoseEndHasPassed() + { + using var t = new TestSession(); + var now = DateTimeOffset.UtcNow; + t.Entra.Assignments.Add(new ActiveAssignment(TestSession.EntraKey("r1"), "a", now, now.AddHours(1), AssignmentStatus.Active)); + t.Entra.Assignments.Add(new ActiveAssignment(TestSession.EntraKey("r2"), "b", now, now.AddHours(3), AssignmentStatus.Active)); + t.Entra.Assignments.Add(new ActiveAssignment(TestSession.EntraKey("r3"), "p", now, null, AssignmentStatus.PendingApproval)); + await t.Session.RefreshAllAsync(); + + // Two hours on, with no read since (the one a sleep or a lost sign-in would have skipped). + t.Session.DropLapsedAssignments(now.AddHours(2)).Should().BeTrue(); + + t.Session.Active.Keys.Should().BeEquivalentTo([TestSession.EntraKey("r2"), TestSession.EntraKey("r3")]); + t.Session.DropLapsedAssignments(now.AddHours(2)).Should().BeFalse("nothing is left to drop"); + } + [Fact] public async Task ConsentRefusalSwitchesTenantToManualRoles() { diff --git a/windows/src/Elevate.Core/Models/Roles.cs b/windows/src/Elevate.Core/Models/Roles.cs index 97bbfb3d..e54ab7ba 100644 --- a/windows/src/Elevate.Core/Models/Roles.cs +++ b/windows/src/Elevate.Core/Models/Roles.cs @@ -89,6 +89,16 @@ public ActiveAssignment( : this(roleKey, assignmentId, startDateTime, endDateTime, status, scheduleId, activationRequestId) { } + + /// + /// Whether this activation's window is over by , whatever the last read + /// said. A refresh that cannot reach a tenant keeps its known rows; this is what lets a caller + /// drop the ones that have ended meanwhile. Requests and failures have no window of their own + /// and never lapse. + /// + public bool HasLapsed(DateTimeOffset now) => + Status.Kind is AssignmentStatusKind.Active or AssignmentStatusKind.Scheduled + && EndDateTime is { } end && end <= now; } public sealed record TicketInfo(string Number, string System); diff --git a/windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs b/windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs new file mode 100644 index 00000000..753ff028 --- /dev/null +++ b/windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs @@ -0,0 +1,43 @@ +using Elevate.Core.Models; +using FluentAssertions; + +namespace Elevate.Core.Tests; + +public class LapsedAssignmentTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1_000_000); + + private static ActiveAssignment Assignment(AssignmentStatus status, double? endsInSeconds) => + new(new RoleKey("i", "t", new EntraDirectoryScope("r", "/")), "a", Now.AddHours(-2), + endsInSeconds is { } s ? Now.AddSeconds(s) : null, status); + + [Fact] + public void ActiveLapsesOnceItsEndHasPassed() + { + Assignment(AssignmentStatus.Active, -1).HasLapsed(Now).Should().BeTrue(); + Assignment(AssignmentStatus.Active, 0).HasLapsed(Now).Should().BeTrue(); + Assignment(AssignmentStatus.Active, 1).HasLapsed(Now).Should().BeFalse(); + } + + [Fact] + public void ScheduledLapsesOnceItsEndHasPassed() + { + Assignment(AssignmentStatus.Scheduled, -60).HasLapsed(Now).Should().BeTrue(); + Assignment(AssignmentStatus.Scheduled, 60).HasLapsed(Now).Should().BeFalse(); + } + + [Fact] + public void WithoutAnEndNothingLapses() + { + Assignment(AssignmentStatus.Active, null).HasLapsed(Now).Should().BeFalse(); + } + + /// Requests and failures carry no activation window of their own; the service settles them. + [Fact] + public void RequestsAndFailuresNeverLapse() + { + Assignment(AssignmentStatus.PendingApproval, -60).HasLapsed(Now).Should().BeFalse(); + Assignment(AssignmentStatus.PendingProvisioning, -60).HasLapsed(Now).Should().BeFalse(); + Assignment(AssignmentStatus.Failed("x"), -60).HasLapsed(Now).Should().BeFalse(); + } +}