Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
also marked with an orange warning on its header instead of the grey info glyph that plain
limitations use; press Refresh to sign in.

- 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
Expand Down
3 changes: 3 additions & 0 deletions cli/src/Elevate.Cli/Commands/RoleCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down
19 changes: 19 additions & 0 deletions cli/src/Elevate.Cli/Session/ElevateSession.Refresh.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ public async Task RefreshAsync(IEnumerable<TenantKey> keys, CancellationToken ct

public Task RefreshAllAsync(CancellationToken ct = default) => RefreshAsync(State.Tenants.Select(t => t.Key).ToList(), ct);

/// <summary>
/// Drops every row whose activation window has ended by <paramref name="now"/>. A refresh that
/// cannot read a tenant keeps its known rows, so a long-running view (<c>watch</c>) would
/// otherwise go on listing activations that are already over. Returns whether anything went.
/// </summary>
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<RoleScopeKind>? requestedKinds = null)
{
if (Identity(key.IdentityId) is not { } identity || Tenant(key) is not { } tenant)
Expand Down
17 changes: 17 additions & 0 deletions cli/tests/Elevate.Cli.Tests/SessionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
10 changes: 10 additions & 0 deletions windows/src/Elevate.Core/Models/Roles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ public ActiveAssignment(
: this(roleKey, assignmentId, startDateTime, endDateTime, status, scheduleId, activationRequestId)
{
}

/// <summary>
/// Whether this activation's window is over by <paramref name="now"/>, 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.
/// </summary>
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);
Expand Down
43 changes: 43 additions & 0 deletions windows/tests/Elevate.Core.Tests/LapsedAssignmentTests.cs
Original file line number Diff line number Diff line change
@@ -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();
}

/// <summary>Requests and failures carry no activation window of their own; the service settles them.</summary>
[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();
}
}
Loading