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:
@@ -0,0 +1,264 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
|
||||
/// consult (SEC-25 / TST-15).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every branch is asserted in its denying direction as well as its allowing one, because the
|
||||
/// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted
|
||||
/// cannot distinguish a working gate from no gate at all.
|
||||
/// </remarks>
|
||||
public sealed class DashboardSessionAclTests
|
||||
{
|
||||
private const string TaggedSessionId = "session-tagged";
|
||||
private const string UntaggedSessionId = "session-untagged";
|
||||
|
||||
/// <summary>An Administrator bypasses the tag check entirely, including for a tag they hold none of.</summary>
|
||||
[Fact]
|
||||
public void CanViewSession_Administrator_BypassesTagCheck()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId));
|
||||
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The admin bypass is what keeps <c>Dashboard:DisableLogin</c> auto-login (which stamps both
|
||||
/// roles and no tags) working exactly as before this change.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.True(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]),
|
||||
TaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unknown session id is denied even for a caller holding every configured tag: no
|
||||
/// subscription is created for a session the registry does not have.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_UnknownSession_Denied()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]),
|
||||
"session-does-not-exist"));
|
||||
}
|
||||
|
||||
/// <summary>A blank session id is denied without consulting anything.</summary>
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void CanViewSession_BlankSessionId_Denied(string sessionId)
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId));
|
||||
}
|
||||
|
||||
/// <summary>A null principal denies — the fail-closed reading of an unauthenticated hub context.</summary>
|
||||
[Fact]
|
||||
public void CanViewSession_NullPrincipal_Denied()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(null, UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Untagged sessions follow <c>Dashboard:UntaggedSessionVisibility</c>: hidden from Viewers
|
||||
/// under the shipped <see cref="UntaggedSessionVisibility.AdminOnly"/> default, visible under
|
||||
/// the opt-in <see cref="UntaggedSessionVisibility.AllViewers"/>.
|
||||
/// </summary>
|
||||
/// <param name="visibility">The configured untagged-session visibility.</param>
|
||||
/// <param name="expected">Whether a tagless Viewer may observe the untagged session.</param>
|
||||
[Theory]
|
||||
[InlineData(UntaggedSessionVisibility.AdminOnly, false)]
|
||||
[InlineData(UntaggedSessionVisibility.AllViewers, true)]
|
||||
public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility(
|
||||
UntaggedSessionVisibility visibility,
|
||||
bool expected)
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl(visibility);
|
||||
|
||||
Assert.Equal(
|
||||
expected,
|
||||
acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Viewer whose grant intersects the session's tags is allowed; the comparison is
|
||||
/// ordinal-ignore-case, matching the session's tag set and the config map.
|
||||
/// </summary>
|
||||
/// <param name="grantedTag">The single tag the Viewer holds.</param>
|
||||
[Theory]
|
||||
[InlineData("team-a")]
|
||||
[InlineData("TEAM-A")]
|
||||
public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag)
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.True(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]),
|
||||
TaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>A Viewer holding only another tenant's tag is denied — the load-bearing negative.</summary>
|
||||
[Fact]
|
||||
public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied()
|
||||
{
|
||||
DashboardSessionAcl acl = CreateAcl();
|
||||
|
||||
Assert.False(acl.CanViewSession(
|
||||
Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]),
|
||||
TaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
|
||||
/// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
|
||||
/// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_NoTagClaims_IsEmptyGrantViewer()
|
||||
{
|
||||
ClaimsPrincipal anonymous = new(new ClaimsIdentity());
|
||||
|
||||
Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId));
|
||||
Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId));
|
||||
Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unauthenticated principal that nonetheless carries an Administrator role claim does not
|
||||
/// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the
|
||||
/// dashboard (<c>DashboardSessionAdminService.CanManage</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass()
|
||||
{
|
||||
// No authentication type => IsAuthenticated is false.
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity(
|
||||
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
|
||||
authenticationType: null,
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role));
|
||||
|
||||
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
|
||||
}
|
||||
|
||||
private static DashboardSessionAcl CreateAcl(
|
||||
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
|
||||
};
|
||||
|
||||
return new DashboardSessionAcl(
|
||||
new TwoSessionManager(
|
||||
CreateSession(TaggedSessionId, ["team-a"]),
|
||||
CreateSession(UntaggedSessionId, tags: null)),
|
||||
Options.Create(options));
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
|
||||
{
|
||||
List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
claims.AddRange((tags ?? []).Select(tag => new Claim(
|
||||
DashboardAuthenticationDefaults.DashboardTagClaimType,
|
||||
tag)));
|
||||
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(
|
||||
claims,
|
||||
authenticationType: "test",
|
||||
nameType: ClaimTypes.Name,
|
||||
roleType: ClaimTypes.Role));
|
||||
}
|
||||
|
||||
private static GatewaySession CreateSession(string sessionId, string[]? tags)
|
||||
{
|
||||
return new GatewaySession(
|
||||
sessionId: sessionId,
|
||||
backendName: "backend",
|
||||
pipeName: $"pipe-{sessionId}",
|
||||
nonce: "nonce",
|
||||
clientIdentity: "client",
|
||||
ownerKeyId: "key-1",
|
||||
clientSessionName: "client-session",
|
||||
clientCorrelationId: "correlation",
|
||||
commandTimeout: TimeSpan.FromSeconds(5),
|
||||
startupTimeout: TimeSpan.FromSeconds(5),
|
||||
shutdownTimeout: TimeSpan.FromSeconds(5),
|
||||
leaseDuration: TimeSpan.FromMinutes(30),
|
||||
openedAt: DateTimeOffset.UnixEpoch,
|
||||
ownerDashboardTags: tags);
|
||||
}
|
||||
|
||||
/// <summary>Registry double serving exactly the two sessions the ACL cases need.</summary>
|
||||
private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<GatewaySession> OpenSessionAsync(
|
||||
SessionOpenRequest request,
|
||||
string? clientIdentity,
|
||||
string? ownerKeyId,
|
||||
CancellationToken cancellationToken) => Task.FromResult(tagged);
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||
{
|
||||
session = sessionId switch
|
||||
{
|
||||
TaggedSessionId => tagged,
|
||||
UntaggedSessionId => untagged,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return session is not null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> CloseSessionAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionCloseResult> KillWorkerAsync(
|
||||
string sessionId,
|
||||
string reason,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) => Task.FromResult(0);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user