feat(alarms): structural degraded-status signal for truncated alarm snapshots
The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. No client and no operator could tell a complete active set
from a capped one.
Two additive proto3 booleans carry the verdict out:
- QueryActiveAlarmsReplyPayload.snapshot_truncated = 2 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)
The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.
Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.
Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.
Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.
Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.
Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
@@ -19,7 +21,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
// Issue from a principal with NO Name claim and NO NameIdentifier
|
||||
// claim. The Issue method's payload will then carry
|
||||
@@ -43,7 +45,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
@@ -72,7 +74,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
@@ -93,7 +95,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_NullToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate(null));
|
||||
}
|
||||
@@ -102,7 +104,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_EmptyToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate(string.Empty));
|
||||
}
|
||||
@@ -111,7 +113,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_GarbageToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
||||
}
|
||||
@@ -123,7 +125,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "bob"),
|
||||
@@ -163,7 +165,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_ExpiredToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(ClaimTypes.Name, "carol")],
|
||||
authenticationType: "test");
|
||||
@@ -174,4 +176,85 @@ public sealed class HubTokenServiceTests
|
||||
|
||||
Assert.Null(service.Validate(expiredToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are
|
||||
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
|
||||
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
|
||||
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
|
||||
/// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
|
||||
/// <c>IDashboardSessionAcl</c> reads on the hub path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
|
||||
{
|
||||
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwViewer"] = ["team-a"],
|
||||
["TeamBViewers"] = ["team-b"],
|
||||
});
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "dana"),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
||||
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
||||
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"),
|
||||
],
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role);
|
||||
|
||||
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(
|
||||
["team-a", "team-b"],
|
||||
result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)
|
||||
.Select(c => c.Value)
|
||||
.Order(StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A caller whose groups map to nothing mints a token with no tags, and validating it yields a
|
||||
/// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This
|
||||
/// is also the shape of every token minted before the tag field existed (the payload field
|
||||
/// deserializes to null), so the fail-closed direction is covered for both.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
|
||||
{
|
||||
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["SomeOtherGroup"] = ["team-a"],
|
||||
});
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "erin"),
|
||||
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
|
||||
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
|
||||
],
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role);
|
||||
|
||||
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
|
||||
}
|
||||
|
||||
private static HubTokenService CreateService(Dictionary<string, string[]>? groupToTag = null)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
|
||||
},
|
||||
};
|
||||
|
||||
return new HubTokenService(new EphemeralDataProtectionProvider(), Options.Create(options));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user