Code-review 2026-05-20 sweep: re-review at 1cd51bb, resolve 72 findings across all 11 modules
Re-reviewed every module/client against the 10-category checklist
(REVIEW-PROCESS.md) at commit 1cd51bb, filed 72 new findings, and
fixed them in three priority waves (3 High, 17 Medium, 52 Low).
Highs
- Server-017: enumerate AcknowledgeAlarm / QueryActiveAlarms in
GatewayGrpcScopeResolver so non-admin keys can use them; document
the mapping in docs/Authorization.md; add interceptor tests.
- Client.Java-013: add the five missing bulk-method stubs to the
CLI FakeSession so the test module compiles on a clean tree.
- Client.Rust-013: fix the clippy::doc_lazy_continuation regression
in generated tonic code by reformatting the ReadBulkCommand proto
comment and scoping a #![allow(...)] to the generated submodules.
Mediums (highlights)
- Server: unify GatewaySession state-lock discipline (-015) and
make DisposeAsync race-safe against in-flight CloseAsync (-016);
add constraint-enforcement test coverage for the bulk-plan path
(-021).
- Worker: introduce StaRuntimeShutdownException so RunAlarmPollLoop
can distinguish graceful shutdown from a real STA-affinity
violation (-016); have the watchdog skip StaHung while
CurrentCommandCorrelationId is non-empty so a legitimate slow
ReadBulk no longer self-faults (-017).
- Tests: add per-method round-trip + cancellation coverage for the
11 GatewaySession bulk methods (-013); replace the real TCP probe
in GalaxyHierarchyCacheTests with an IGalaxyRepository fake
(-016).
- IntegrationTests: drive the StreamEvents writer in the live Write
test and assert OnWriteComplete (-012); add live tests for
Unadvise/RemoveItem/Unregister ordering, WriteSecured, and
abnormal worker exit (-014).
- Worker.Tests: replace MxAccessSession reflection with an internal
CreateForTesting factory (-016); cover WorkerCancel and
unexpected-body envelope branches (-017).
- Client.Java: cancel MxEventStream when close() races
beforeStart() (-014); return a CancellingCompletableFuture that
actually forwards cancellation through .thenApply chains (-015).
- Client.Python: drop the silent localhost-plaintext downgrade in
the CLI; require explicit --plaintext (-013).
- Client.Rust: stop bench-read-bulk from polluting success-latency
histograms with failed-call durations (-015); add coverage for
the five MalformedReply paths, the bulk-write helpers, the
Error::Unavailable mapping, and the unary-fault path (-016).
- Contracts: extend docs/Contracts.md with the bulk read/write
command family (-009).
Lows (highlights)
- Server: cap GalaxyGlobMatcher.RegexCache; align
WorkerAlarmRpcDispatcher missing-session handling; drop the
duplicate dashboard @page routes; refresh IAlarmRpcDispatcher
XML doc.
- Worker: surface SetXmlAlarmQuery COM failures; remove dead
subscriptionExpression / ExecutingCommand arms; preserve
factory-supplied runtime sessions; split MxAlarmSnapshot.cs into
three files.
- Tests: dispose the WebApplication in seven test classes; rebuild
FakeWorkerProcess.WaitForExitAsync against a real TaskCompletion
source; switch the heartbeat-expires test to ManualTimeProvider;
add InvariantCulture to the remaining DateTimeOffset.Parse sites;
document GalaxyFilterInputSafetyTests in GatewayTesting.md.
- IntegrationTests: comment fixes, RecordingServerStreamWriter
IDisposable, class-level [Trait], single-source ZB default
connection string.
- Worker.Tests: replace silent-return gating with LiveMxAccessFact
so absent env vars SKIP not pass; PascalCase rename of probe
[Fact]s; deterministic deadline test; new frame-protocol error
tests; ComputeTransitions diff-coverage; relocate dev-rig probes
to Probes/.
- Contracts: add round-trip coverage and per-field redaction /
Galaxy-identifier comments to the protos.
- Client.Dotnet: introduce clients/dotnet/Directory.Build.props so
TreatWarningsAsErrors / analysers apply; document
DiscoverHierarchyOptions and IMxGatewayCliClient; require typed
bulk-read handles in CLI; surface AcknowledgeAlarm transport
faults through Translate().
- Client.Go: kill dead code in alarms_test / fakeGalaxyServer /
runWriteBulkVariant; document the six new subcommands in
writeUsage; drain galaxy-watch events on limit; switch io.EOF
comparisons to errors.Is.
- Client.Java: shared shutdown helpers + new shutdownTimeout
option; regex-based credential redaction; Long.toUnsignedString
for uint64 sequence; doc fixes.
- Client.Python: combine duplicate imports; add coverage for
_percentile / bench-read-bulk / MAX_AGGREGATE_EVENTS /
_api_key_from_env; populate pyproject metadata and ship py.typed.
- Client.Rust: expose next_correlation_id() so CLI ping/close
stop hard-coding correlation IDs; resync RustClientDesign.md
with the current Session / Error surface and CLI subcommand set.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,18 @@ public sealed class GatewayContractInfoTests
|
||||
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the gateway protocol version is bumped to three after the alarm proto extension.</summary>
|
||||
/// <summary>
|
||||
/// Pins the current <see cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
||||
/// constant at 3. Both the alarm proto extension (`AcknowledgeAlarm` /
|
||||
/// `QueryActiveAlarms` RPCs, the `OnAlarmTransitionEvent` body, and the
|
||||
/// alarm command/reply payload cases) and the bulk write/read command
|
||||
/// family extension (`WriteBulk` / `Write2Bulk` / `WriteSecuredBulk` /
|
||||
/// `WriteSecured2Bulk` / `ReadBulk` plus their `BulkWriteReply` and
|
||||
/// `BulkReadReply` payloads) shipped under version 3 — both were strictly
|
||||
/// additive contract changes, so neither required a further bump. A
|
||||
/// future breaking contract change should bump this constant and update
|
||||
/// this test in lock-step.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GatewayProtocolVersion_IsVersionThree()
|
||||
{
|
||||
|
||||
@@ -770,4 +770,463 @@ public sealed class ProtobufContractRoundTripTests
|
||||
Assert.Equal(lastDeploy, GetLastDeployTimeReply.Parser.ParseFrom(lastDeploy.ToByteArray()));
|
||||
Assert.Equal(testConnection, TestConnectionReply.Parser.ParseFrom(testConnection.ToByteArray()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="WriteBulkCommand"/> carrying multiple
|
||||
/// <see cref="WriteBulkEntry"/> items round-trips, including the
|
||||
/// per-entry <c>value</c> and <c>user_id</c> fields.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WriteBulkCommand_RoundTripsEntries()
|
||||
{
|
||||
var original = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
WriteBulk = new WriteBulkCommand
|
||||
{
|
||||
ServerHandle = 10,
|
||||
Entries =
|
||||
{
|
||||
new WriteBulkEntry
|
||||
{
|
||||
ItemHandle = 21,
|
||||
UserId = 7,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
FloatValue = 1.25f,
|
||||
VariantType = "VT_R4",
|
||||
},
|
||||
},
|
||||
new WriteBulkEntry
|
||||
{
|
||||
ItemHandle = 22,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Integer,
|
||||
Int32Value = 42,
|
||||
VariantType = "VT_I4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxCommand.PayloadOneofCase.WriteBulk, parsed.PayloadCase);
|
||||
Assert.Equal(2, parsed.WriteBulk.Entries.Count);
|
||||
Assert.Equal(7, parsed.WriteBulk.Entries[0].UserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="Write2BulkCommand"/> round-trips, including
|
||||
/// the per-entry <c>timestamp_value</c> field that distinguishes Write2
|
||||
/// from Write.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Write2BulkCommand_RoundTripsEntriesWithTimestampValue()
|
||||
{
|
||||
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 0, 0, DateTimeKind.Utc));
|
||||
var original = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = new Write2BulkCommand
|
||||
{
|
||||
ServerHandle = 10,
|
||||
Entries =
|
||||
{
|
||||
new Write2BulkEntry
|
||||
{
|
||||
ItemHandle = 21,
|
||||
UserId = 7,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
FloatValue = 99.9f,
|
||||
VariantType = "VT_R4",
|
||||
},
|
||||
TimestampValue = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Time,
|
||||
TimestampValue = timestamp,
|
||||
VariantType = "VT_DATE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxCommand.PayloadOneofCase.Write2Bulk, parsed.PayloadCase);
|
||||
Assert.NotNull(parsed.Write2Bulk.Entries[0].TimestampValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="WriteSecuredBulkCommand"/> round-trips,
|
||||
/// pinning the credential-bearing entry shape
|
||||
/// (<c>current_user_id</c>, <c>verifier_user_id</c>, <c>value</c>).
|
||||
/// See Contracts-011 for the credential-sensitivity comment on
|
||||
/// <c>WriteSecuredBulkEntry.value</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WriteSecuredBulkCommand_RoundTripsCredentialBearingEntries()
|
||||
{
|
||||
var original = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = new WriteSecuredBulkCommand
|
||||
{
|
||||
ServerHandle = 10,
|
||||
Entries =
|
||||
{
|
||||
new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = 21,
|
||||
CurrentUserId = 100,
|
||||
VerifierUserId = 200,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
FloatValue = 75.0f,
|
||||
VariantType = "VT_R4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecuredBulk, parsed.PayloadCase);
|
||||
Assert.Equal(100, parsed.WriteSecuredBulk.Entries[0].CurrentUserId);
|
||||
Assert.Equal(200, parsed.WriteSecuredBulk.Entries[0].VerifierUserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="WriteSecured2BulkCommand"/> round-trips,
|
||||
/// including both the credential-sensitive <c>value</c> and the
|
||||
/// <c>timestamp_value</c> per entry.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WriteSecured2BulkCommand_RoundTripsCredentialBearingEntriesWithTimestamp()
|
||||
{
|
||||
var timestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 11, 30, 0, DateTimeKind.Utc));
|
||||
var original = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = new WriteSecured2BulkCommand
|
||||
{
|
||||
ServerHandle = 10,
|
||||
Entries =
|
||||
{
|
||||
new WriteSecured2BulkEntry
|
||||
{
|
||||
ItemHandle = 21,
|
||||
CurrentUserId = 100,
|
||||
VerifierUserId = 200,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
FloatValue = 50.0f,
|
||||
VariantType = "VT_R4",
|
||||
},
|
||||
TimestampValue = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Time,
|
||||
TimestampValue = timestamp,
|
||||
VariantType = "VT_DATE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxCommand.PayloadOneofCase.WriteSecured2Bulk, parsed.PayloadCase);
|
||||
Assert.NotNull(parsed.WriteSecured2Bulk.Entries[0].TimestampValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="ReadBulkCommand"/> round-trips, including
|
||||
/// the <c>tag_addresses</c> list and the <c>timeout_ms</c> field that
|
||||
/// distinguishes the cached-vs-snapshot lifecycle.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ReadBulkCommand_RoundTripsTagAddressesAndTimeout()
|
||||
{
|
||||
var original = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ReadBulk = new ReadBulkCommand
|
||||
{
|
||||
ServerHandle = 10,
|
||||
TagAddresses = { "Provider!Tank01.Level", "Provider!Tank02.Level" },
|
||||
TimeoutMs = 2500,
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxCommand.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxCommand.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
|
||||
Assert.Equal(2, parsed.ReadBulk.TagAddresses.Count);
|
||||
Assert.Equal(2500u, parsed.ReadBulk.TimeoutMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="BulkWriteReply"/> carrying mixed-outcome
|
||||
/// <see cref="BulkWriteResult"/> entries round-trips and that the
|
||||
/// proto3 <c>optional int32 hresult</c> presence flag survives both the
|
||||
/// "hresult set" and "hresult unset" cases.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BulkWriteReply_RoundTripsResultsWithOptionalHresultPresence()
|
||||
{
|
||||
var original = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
ItemHandle = 21,
|
||||
WasSuccessful = true,
|
||||
Hresult = 0,
|
||||
Statuses =
|
||||
{
|
||||
new MxStatusProxy
|
||||
{
|
||||
Success = 1,
|
||||
Category = MxStatusCategory.Ok,
|
||||
DetectedBy = MxStatusSource.RespondingLmx,
|
||||
},
|
||||
},
|
||||
},
|
||||
new BulkWriteResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
ItemHandle = 22,
|
||||
WasSuccessful = false,
|
||||
Hresult = unchecked((int)0x80004005),
|
||||
ErrorMessage = "item not advised",
|
||||
},
|
||||
new BulkWriteResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
ItemHandle = 23,
|
||||
WasSuccessful = false,
|
||||
// Hresult deliberately UNSET — exercises the proto3
|
||||
// `optional int32` HasField() = false arm.
|
||||
ErrorMessage = "tag rejected by allowlist",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = BulkWriteReply.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(3, parsed.Results.Count);
|
||||
Assert.True(parsed.Results[0].HasHresult);
|
||||
Assert.True(parsed.Results[1].HasHresult);
|
||||
Assert.False(parsed.Results[2].HasHresult);
|
||||
Assert.True(parsed.Results[0].WasSuccessful);
|
||||
Assert.False(parsed.Results[2].WasSuccessful);
|
||||
Assert.Single(parsed.Results[0].Statuses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a <see cref="BulkReadReply"/> carrying both cached
|
||||
/// (<c>was_cached = true</c>) and uncached (<c>was_cached = false</c>)
|
||||
/// <see cref="BulkReadResult"/> entries round-trips. Pins the
|
||||
/// deliberate absence of <c>hresult</c> on <see cref="BulkReadResult"/>
|
||||
/// — failures are carried as <c>was_successful = false</c> plus
|
||||
/// <c>error_message</c> only.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BulkReadReply_RoundTripsCachedAndSnapshotResults()
|
||||
{
|
||||
var sourceTimestamp = Timestamp.FromDateTime(new DateTime(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc));
|
||||
var original = new BulkReadReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
TagAddress = "Provider!Tank01.Level",
|
||||
ItemHandle = 21,
|
||||
WasSuccessful = true,
|
||||
WasCached = true,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
FloatValue = 42.5f,
|
||||
VariantType = "VT_R4",
|
||||
},
|
||||
Quality = 192,
|
||||
SourceTimestamp = sourceTimestamp,
|
||||
Statuses =
|
||||
{
|
||||
new MxStatusProxy
|
||||
{
|
||||
Success = 1,
|
||||
Category = MxStatusCategory.Ok,
|
||||
DetectedBy = MxStatusSource.RespondingNmx,
|
||||
},
|
||||
},
|
||||
},
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
TagAddress = "Provider!Tank02.Level",
|
||||
ItemHandle = 22,
|
||||
WasSuccessful = true,
|
||||
WasCached = false,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Integer,
|
||||
Int32Value = 0,
|
||||
VariantType = "VT_I4",
|
||||
},
|
||||
SourceTimestamp = sourceTimestamp,
|
||||
},
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 10,
|
||||
TagAddress = "Provider!Bad.Tag",
|
||||
WasSuccessful = false,
|
||||
WasCached = false,
|
||||
ErrorMessage = "snapshot timed out before first OnDataChange",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = BulkReadReply.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(3, parsed.Results.Count);
|
||||
Assert.True(parsed.Results[0].WasCached);
|
||||
Assert.False(parsed.Results[1].WasCached);
|
||||
Assert.False(parsed.Results[2].WasSuccessful);
|
||||
Assert.Equal("snapshot timed out before first OnDataChange", parsed.Results[2].ErrorMessage);
|
||||
// BulkReadResult has no `hresult` field — pin that contract.
|
||||
Assert.DoesNotContain(
|
||||
BulkReadResult.Descriptor.Fields.InDeclarationOrder(),
|
||||
field => field.Name == "hresult");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an <see cref="MxCommandReply"/> with each of the new
|
||||
/// bulk write/read payload oneof cases round-trips and that
|
||||
/// <see cref="MxCommandReply.PayloadOneofCase"/> resolves to the
|
||||
/// expected value. Pins every new oneof case added by the bulk
|
||||
/// write/read extension.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(MxCommandKind.WriteBulk, MxCommandReply.PayloadOneofCase.WriteBulk)]
|
||||
[InlineData(MxCommandKind.Write2Bulk, MxCommandReply.PayloadOneofCase.Write2Bulk)]
|
||||
[InlineData(MxCommandKind.WriteSecuredBulk, MxCommandReply.PayloadOneofCase.WriteSecuredBulk)]
|
||||
[InlineData(MxCommandKind.WriteSecured2Bulk, MxCommandReply.PayloadOneofCase.WriteSecured2Bulk)]
|
||||
public void MxCommandReply_RoundTripsBulkWritePayloadCases(
|
||||
MxCommandKind kind,
|
||||
MxCommandReply.PayloadOneofCase expectedPayloadCase)
|
||||
{
|
||||
var reply = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult
|
||||
{
|
||||
ServerHandle = 5,
|
||||
ItemHandle = 7,
|
||||
WasSuccessful = true,
|
||||
Hresult = 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
var original = new MxCommandReply
|
||||
{
|
||||
SessionId = "session-1",
|
||||
CorrelationId = "gateway-correlation-bulk-write",
|
||||
Kind = kind,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Hresult = 0,
|
||||
};
|
||||
switch (expectedPayloadCase)
|
||||
{
|
||||
case MxCommandReply.PayloadOneofCase.WriteBulk:
|
||||
original.WriteBulk = reply;
|
||||
break;
|
||||
case MxCommandReply.PayloadOneofCase.Write2Bulk:
|
||||
original.Write2Bulk = reply;
|
||||
break;
|
||||
case MxCommandReply.PayloadOneofCase.WriteSecuredBulk:
|
||||
original.WriteSecuredBulk = reply;
|
||||
break;
|
||||
case MxCommandReply.PayloadOneofCase.WriteSecured2Bulk:
|
||||
original.WriteSecured2Bulk = reply;
|
||||
break;
|
||||
default:
|
||||
throw new System.ArgumentOutOfRangeException(nameof(expectedPayloadCase));
|
||||
}
|
||||
|
||||
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(expectedPayloadCase, parsed.PayloadCase);
|
||||
Assert.Equal(kind, parsed.Kind);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that an <see cref="MxCommandReply"/> with kind
|
||||
/// <see cref="MxCommandKind.ReadBulk"/> and a populated
|
||||
/// <see cref="BulkReadReply"/> payload round-trips and resolves to
|
||||
/// <see cref="MxCommandReply.PayloadOneofCase.ReadBulk"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MxCommandReply_RoundTripsReadBulkPayload()
|
||||
{
|
||||
var original = new MxCommandReply
|
||||
{
|
||||
SessionId = "session-1",
|
||||
CorrelationId = "gateway-correlation-read-bulk",
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
Hresult = 0,
|
||||
ReadBulk = new BulkReadReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 5,
|
||||
TagAddress = "Provider!Tank01.Level",
|
||||
ItemHandle = 7,
|
||||
WasSuccessful = true,
|
||||
WasCached = true,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
FloatValue = 12.5f,
|
||||
VariantType = "VT_R4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxCommandReply.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxCommandReply.PayloadOneofCase.ReadBulk, parsed.PayloadCase);
|
||||
Assert.Single(parsed.ReadBulk.Results);
|
||||
Assert.True(parsed.ReadBulk.Results[0].WasCached);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,29 @@ public sealed class GalaxyFilterInputSafetyTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression guard for finding Server-018: <see cref="GalaxyGlobMatcher"/>'s
|
||||
/// internal compiled-regex cache must stay bounded so a client cannot grow it
|
||||
/// without limit by submitting unique <c>TagNameGlob</c> values over the
|
||||
/// process lifetime. Feeding the matcher far more distinct globs than the cap
|
||||
/// must leave <c>CurrentCacheSize</c> at or below <c>RegexCacheCapacity</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GlobMatcher_WithManyDistinctPatterns_CacheStaysBounded()
|
||||
{
|
||||
// Submit well past the cap from a single thread to exercise the eviction path
|
||||
// deterministically. The cap is internal; assert on it directly so the test
|
||||
// tracks the source of truth.
|
||||
int submissions = GalaxyGlobMatcher.RegexCacheCapacity * 4;
|
||||
for (int i = 0; i < submissions; i++)
|
||||
{
|
||||
string uniqueGlob = $"client_supplied_{i}_*";
|
||||
GalaxyGlobMatcher.IsMatch($"client_supplied_{i}_thing", uniqueGlob);
|
||||
}
|
||||
|
||||
Assert.InRange(GalaxyGlobMatcher.CurrentCacheSize, 0, GalaxyGlobMatcher.RegexCacheCapacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a pathological glob does not cause catastrophic regex backtracking —
|
||||
/// <see cref="GalaxyGlobMatcher"/> escapes every literal character and applies a
|
||||
|
||||
@@ -12,7 +12,8 @@ public sealed class GalaxyHierarchyCacheTests
|
||||
public void Current_BeforeAnyRefresh_ReturnsEmpty()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
GalaxyHierarchyCache cache = CreateCache(notifier, new ManualTimeProvider());
|
||||
ThrowingGalaxyRepository repository = new(new InvalidOperationException("not invoked"));
|
||||
GalaxyHierarchyCache cache = new(repository, notifier, new ManualTimeProvider());
|
||||
|
||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
@@ -23,21 +24,28 @@ public sealed class GalaxyHierarchyCacheTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies cache marks unavailable and does not publish when SQL is unreachable.
|
||||
/// Verifies cache marks unavailable and does not publish when the repository
|
||||
/// surface throws — the production trigger for this code path is a SQL
|
||||
/// connection failure, but it is fully covered by the cache's exception
|
||||
/// branch and does not require a real TCP probe from a unit test.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish()
|
||||
public async Task RefreshAsync_WhenRepositoryThrows_MarksUnavailableAndDoesNotPublish()
|
||||
{
|
||||
GalaxyDeployNotifier notifier = new();
|
||||
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z"));
|
||||
GalaxyHierarchyCache cache = CreateCache(notifier, clock);
|
||||
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
|
||||
ThrowingGalaxyRepository repository = new(new InvalidOperationException("Galaxy repository unreachable"));
|
||||
GalaxyHierarchyCache cache = new(repository, notifier, clock);
|
||||
|
||||
await cache.RefreshAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
|
||||
Assert.False(string.IsNullOrWhiteSpace(cache.Current.LastError));
|
||||
Assert.Equal("Galaxy repository unreachable", cache.Current.LastError);
|
||||
Assert.Null(notifier.Latest);
|
||||
Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
|
||||
Assert.Equal(1, repository.GetLastDeployTimeCount);
|
||||
Assert.Equal(0, repository.GetHierarchyCount);
|
||||
Assert.Equal(0, repository.GetAttributesCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -112,15 +120,40 @@ public sealed class GalaxyHierarchyCacheTests
|
||||
Assert.Same(root, index.ObjectViewsById[1].Object);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
|
||||
private sealed class ThrowingGalaxyRepository(Exception toThrow) : IGalaxyRepository
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
/// <summary>Gets the number of times <see cref="GetLastDeployTimeAsync"/> was called.</summary>
|
||||
public int GetLastDeployTimeCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times <see cref="GetHierarchyAsync"/> was called.</summary>
|
||||
public int GetHierarchyCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the number of times <see cref="GetAttributesAsync"/> was called.</summary>
|
||||
public int GetAttributesCount { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TestConnectionAsync(CancellationToken ct = default) => Task.FromResult(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
|
||||
CommandTimeoutSeconds = 1,
|
||||
};
|
||||
MxGateway.Server.Galaxy.GalaxyRepository repository = new(options);
|
||||
return new GalaxyHierarchyCache(repository, notifier, clock);
|
||||
GetLastDeployTimeCount++;
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetHierarchyCount++;
|
||||
throw toThrow;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
GetAttributesCount++;
|
||||
throw toThrow;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider
|
||||
|
||||
@@ -12,9 +12,9 @@ public sealed class DashboardCookieOptionsTests
|
||||
{
|
||||
/// <summary>Verifies that the application configures secure dashboard authentication cookies.</summary>
|
||||
[Fact]
|
||||
public void Build_ConfiguresSecureDashboardCookie()
|
||||
public async Task Build_ConfiguresSecureDashboardCookie()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IOptionsMonitor<CookieAuthenticationOptions> optionsMonitor = app.Services
|
||||
.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>();
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Configuration;
|
||||
@@ -42,19 +43,19 @@ public sealed class DashboardSnapshotServiceTests
|
||||
GatewaySession activeSession = CreateSession(
|
||||
"session-active",
|
||||
"client-one",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
|
||||
activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready));
|
||||
activeSession.MarkReady();
|
||||
GatewaySession faultedSession = CreateSession(
|
||||
"session-faulted",
|
||||
"client-two",
|
||||
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
|
||||
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
|
||||
faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted));
|
||||
faultedSession.MarkFaulted("worker pipe disconnected");
|
||||
GatewaySession closedSession = CreateSession(
|
||||
"session-closed",
|
||||
"client-three",
|
||||
DateTimeOffset.Parse("2026-04-26T09:59:00Z"));
|
||||
DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture));
|
||||
closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed));
|
||||
closedSession.TransitionTo(SessionState.Closed);
|
||||
registry.TryAdd(activeSession);
|
||||
@@ -102,7 +103,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
GatewaySession session = CreateSession(
|
||||
"session-redacted",
|
||||
"Bearer mxgw_admin_super-secret",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z"),
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture),
|
||||
clientSessionName: "password=hunter2",
|
||||
clientCorrelationId: "token=abc123");
|
||||
session.MarkFaulted("secret=credential-value");
|
||||
@@ -131,7 +132,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
GatewaySession session = CreateSession(
|
||||
"session-active",
|
||||
"client-one",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
|
||||
FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready);
|
||||
session.AttachWorkerClient(workerClient);
|
||||
session.MarkReady();
|
||||
@@ -160,11 +161,11 @@ public sealed class DashboardSnapshotServiceTests
|
||||
GatewaySession olderSession = CreateSession(
|
||||
"session-older",
|
||||
"client-one",
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z"));
|
||||
DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture));
|
||||
GatewaySession newerSession = CreateSession(
|
||||
"session-newer",
|
||||
"client-two",
|
||||
DateTimeOffset.Parse("2026-04-26T10:01:00Z"));
|
||||
DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture));
|
||||
olderSession.MarkFaulted("older fault");
|
||||
newerSession.MarkFaulted("newer fault");
|
||||
registry.TryAdd(olderSession);
|
||||
@@ -199,14 +200,14 @@ public sealed class DashboardSnapshotServiceTests
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 7,
|
||||
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
|
||||
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
|
||||
DashboardSummary = new DashboardGalaxySummary(
|
||||
DashboardGalaxyStatus.Healthy,
|
||||
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
|
||||
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
|
||||
LastQueriedAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastSuccessAt: DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture),
|
||||
LastDeployTime: DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastError: null,
|
||||
ObjectCount: 3,
|
||||
AreaCount: 1,
|
||||
@@ -281,7 +282,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
{
|
||||
BrowseSubtrees = ["Area1/*"],
|
||||
},
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
@@ -314,7 +315,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
DisplayName: "Operator",
|
||||
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
|
||||
Constraints: ApiKeyConstraints.Empty,
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z"),
|
||||
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: null,
|
||||
RevokedUtc: null));
|
||||
DashboardSnapshotService service = CreateService(
|
||||
@@ -520,7 +521,7 @@ public sealed class DashboardSnapshotServiceTests
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the last heartbeat.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z");
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of start invocations.
|
||||
|
||||
@@ -13,9 +13,9 @@ public sealed class GatewayApplicationTests
|
||||
{
|
||||
/// <summary>Verifies that Build maps the live health check endpoint.</summary>
|
||||
[Fact]
|
||||
public void Build_MapsLiveHealthEndpoint()
|
||||
public async Task Build_MapsLiveHealthEndpoint()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
|
||||
RouteEndpoint endpoint = Assert.Single(
|
||||
((IEndpointRouteBuilder)app).DataSources
|
||||
@@ -28,9 +28,9 @@ public sealed class GatewayApplicationTests
|
||||
|
||||
/// <summary>Verifies that Build registers the gateway metrics service.</summary>
|
||||
[Fact]
|
||||
public void Build_RegistersGatewayMetrics()
|
||||
public async Task Build_RegistersGatewayMetrics()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
|
||||
GatewayMetrics metrics = app.Services.GetRequiredService<GatewayMetrics>();
|
||||
|
||||
@@ -39,9 +39,9 @@ public sealed class GatewayApplicationTests
|
||||
|
||||
/// <summary>Verifies that Build maps dashboard and authentication endpoints when the dashboard is enabled.</summary>
|
||||
[Fact]
|
||||
public void Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
||||
public async Task Build_WhenDashboardEnabled_MapsBlazorDashboardAndAuthEndpoints()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
Assert.Contains(endpoints, endpoint => endpoint.RoutePattern.RawText == "/dashboard/");
|
||||
@@ -57,9 +57,9 @@ public sealed class GatewayApplicationTests
|
||||
|
||||
/// <summary>Verifies that the dashboard login, logout, and denied endpoints allow anonymous access.</summary>
|
||||
[Fact]
|
||||
public void Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
|
||||
public async Task Build_WhenDashboardEnabled_AuthEndpointsAllowAnonymousAccess()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
string[] anonymousEndpointNames =
|
||||
@@ -76,9 +76,9 @@ public sealed class GatewayApplicationTests
|
||||
|
||||
/// <summary>Verifies that dashboard Razor component routes require the dashboard authorization policy.</summary>
|
||||
[Fact]
|
||||
public void Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
|
||||
public async Task Build_WhenDashboardEnabled_ComponentRoutesRequireAuthorization()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build([]);
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
string[] componentRoutes =
|
||||
@@ -99,10 +99,42 @@ public sealed class GatewayApplicationTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-020 regression: every dashboard Razor page used to declare both
|
||||
/// <c>@page "/X"</c> and <c>@page "/dashboard/X"</c>, which — once
|
||||
/// <c>MapGroup("/dashboard")</c> prepended the path base — produced both
|
||||
/// <c>/dashboard/X</c> AND <c>/dashboard/dashboard/X</c> routes. The second
|
||||
/// shape was almost certainly accidental and is no longer registered. The
|
||||
/// check covers every dashboard page so a future page that brings back the
|
||||
/// duplicated <c>@page</c> directive fails CI.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
|
||||
public async Task Build_WhenDashboardEnabled_DoesNotRegisterDoubledDashboardPrefixRoutes()
|
||||
{
|
||||
WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
|
||||
await using WebApplication app = GatewayApplication.Build([]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
string[] doubledRoutes =
|
||||
[
|
||||
"/dashboard/dashboard/",
|
||||
"/dashboard/dashboard/sessions",
|
||||
"/dashboard/dashboard/workers",
|
||||
"/dashboard/dashboard/events",
|
||||
"/dashboard/dashboard/settings",
|
||||
"/dashboard/dashboard/galaxy",
|
||||
"/dashboard/dashboard/apikeys",
|
||||
"/dashboard/dashboard/sessions/{SessionId}",
|
||||
];
|
||||
foreach (string doubled in doubledRoutes)
|
||||
{
|
||||
Assert.DoesNotContain(endpoints, endpoint => endpoint.RoutePattern.RawText == doubled);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Build_WhenDashboardDisabled_DoesNotMapDashboardRoutes()
|
||||
{
|
||||
await using WebApplication app = GatewayApplication.Build(["--MxGateway:Dashboard:Enabled=false"]);
|
||||
IReadOnlyList<RouteEndpoint> endpoints = GetRouteEndpoints(app);
|
||||
|
||||
Assert.DoesNotContain(endpoints, endpoint =>
|
||||
|
||||
@@ -85,6 +85,10 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
Assert.Equal(ProtocolStatusCode.Ok, closeReply.ProtocolStatus.Code);
|
||||
Assert.Equal(SessionState.Closed, closeReply.FinalState);
|
||||
Assert.True(launcher.Process.HasExited);
|
||||
// MarkExited(0) is reached only after the scripted worker observed a WorkerShutdown
|
||||
// envelope and emitted its WorkerShutdownAck — anything else (a kill, a fault) would
|
||||
// have produced a non-zero exit code, so this pins the shutdown-ack handshake.
|
||||
Assert.Equal(0, launcher.Process.ExitCode);
|
||||
Assert.Equal(
|
||||
[MxCommandKind.Register, MxCommandKind.AddItem, MxCommandKind.Advise],
|
||||
launcher.CommandKinds);
|
||||
@@ -351,6 +355,8 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
|
||||
private sealed class FakeWorkerProcess(int processId) : IWorkerProcess
|
||||
{
|
||||
private readonly TaskCompletionSource _exited = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process identifier.
|
||||
/// </summary>
|
||||
@@ -367,15 +373,15 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the process to exit asynchronously.
|
||||
/// Waits for the process to exit asynchronously. Completes only when <see cref="Kill"/>
|
||||
/// or <see cref="MarkExited"/> has been called, so callers that observe completion can
|
||||
/// trust that exit actually happened (e.g., via the worker shutdown-ack path).
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Completed task.</returns>
|
||||
/// <returns>A task that completes when the process has actually exited.</returns>
|
||||
public ValueTask WaitForExitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
HasExited = true;
|
||||
ExitCode ??= 0;
|
||||
return ValueTask.CompletedTask;
|
||||
return new ValueTask(_exited.Task.WaitAsync(cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -402,6 +408,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
|
||||
{
|
||||
HasExited = true;
|
||||
ExitCode = exitCode;
|
||||
_exited.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
using Grpc.Core;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Grpc;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
using MxGateway.Tests.TestSupport;
|
||||
|
||||
namespace MxGateway.Tests.Gateway.Grpc;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for Server-021. <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and
|
||||
/// the <c>BulkConstraintPlan</c> / <c>ReadBulkConstraintPlan</c> /
|
||||
/// <c>WriteBulkConstraintPlan</c> / <c>SubscribeBulkConstraintPlan</c> reply-merge
|
||||
/// logic was previously exercised only with an allow-all enforcer, so denial
|
||||
/// filtering, the no-allowed-items short-circuit, and the index-ordered
|
||||
/// denied/allowed interleave were dead code at test time. The fixtures below
|
||||
/// inject a <see cref="PredicateConstraintEnforcer"/> that denies a subset of
|
||||
/// tags or handles, and assert the post-merge reply contents and that the
|
||||
/// session manager is (or is not) invoked.
|
||||
/// </summary>
|
||||
public sealed class MxAccessGatewayServiceConstraintTests
|
||||
{
|
||||
private const string SessionId = "session-constraint";
|
||||
|
||||
// === SubscribeBulk family: AddItemBulk / SubscribeBulk / AdviseItemBulk ===
|
||||
|
||||
/// <summary>
|
||||
/// <c>AddItemBulk</c> with a mix of allowed and denied tags must invoke the
|
||||
/// worker once with only the allowed tags, then splice the denied entries
|
||||
/// back into the reply at their original indices.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_AddItemBulk_WithMixedDenials_InterleavesDeniedAndAllowedInOriginalIndexOrder()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new()
|
||||
{
|
||||
DenyTag = tag => tag == "Tank01.Locked" || tag == "Tank03.Secret",
|
||||
};
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
sessionManager.InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Kind = MxCommandKind.AddItemBulk,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
AddItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
// Worker only sees the two allowed tags — Tank02.Open at original
|
||||
// index 1 and Tank04.Public at original index 3.
|
||||
new SubscribeResult { ServerHandle = 7, TagAddress = "Tank02.Open", ItemHandle = 102, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 7, TagAddress = "Tank04.Public", ItemHandle = 104, WasSuccessful = true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateAddItemBulkRequest(7, ["Tank01.Locked", "Tank02.Open", "Tank03.Secret", "Tank04.Public"]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(1, sessionManager.InvokeCount);
|
||||
// Worker saw only the allowed subset, in original order, with denied entries dropped.
|
||||
AddItemBulkCommand forwardedCommand = sessionManager.LastWorkerCommand!.Command.AddItemBulk;
|
||||
Assert.Equal(["Tank02.Open", "Tank04.Public"], forwardedCommand.TagAddresses);
|
||||
// Final reply preserves the original 4-entry index order, with denied entries
|
||||
// at index 0 and 2 and worker-allowed entries at index 1 and 3.
|
||||
BulkSubscribeReply merged = reply.AddItemBulk;
|
||||
Assert.Equal(4, merged.Results.Count);
|
||||
Assert.False(merged.Results[0].WasSuccessful);
|
||||
Assert.Equal("Tank01.Locked", merged.Results[0].TagAddress);
|
||||
Assert.Contains("Tank01.Locked", merged.Results[0].ErrorMessage, StringComparison.Ordinal);
|
||||
Assert.True(merged.Results[1].WasSuccessful);
|
||||
Assert.Equal("Tank02.Open", merged.Results[1].TagAddress);
|
||||
Assert.Equal(102, merged.Results[1].ItemHandle);
|
||||
Assert.False(merged.Results[2].WasSuccessful);
|
||||
Assert.Equal("Tank03.Secret", merged.Results[2].TagAddress);
|
||||
Assert.True(merged.Results[3].WasSuccessful);
|
||||
Assert.Equal("Tank04.Public", merged.Results[3].TagAddress);
|
||||
Assert.Equal(104, merged.Results[3].ItemHandle);
|
||||
// Both denied tags recorded.
|
||||
Assert.Equal(2, enforcer.RecordedDenials.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>SubscribeBulk</c> when every tag is denied must short-circuit
|
||||
/// <see cref="BulkConstraintPlan.HasAllowedItems"/> false, return the
|
||||
/// denied-only reply, and never call the session manager.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_SubscribeBulk_WhenAllTagsDenied_DoesNotCallWorkerAndReturnsDeniedReply()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true };
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateSubscribeBulkRequest(7, ["A", "B", "C"]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(0, sessionManager.InvokeCount);
|
||||
Assert.Equal(3, reply.SubscribeBulk.Results.Count);
|
||||
Assert.All(reply.SubscribeBulk.Results, r => Assert.False(r.WasSuccessful));
|
||||
Assert.Equal(["A", "B", "C"], reply.SubscribeBulk.Results.Select(r => r.TagAddress));
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>AdviseItemBulk</c> takes handle inputs (not tags) and routes through
|
||||
/// <c>FilterHandleBulkAsync</c> against <c>CheckReadHandleAsync</c>. Partial
|
||||
/// denial must still produce a merged-by-index <c>BulkSubscribeReply</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_AdviseItemBulk_WithMixedHandleDenials_MergesDeniedIntoReply()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new()
|
||||
{
|
||||
DenyReadHandle = (_, itemHandle) => itemHandle == 502,
|
||||
};
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
sessionManager.InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Kind = MxCommandKind.AdviseItemBulk,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
AdviseItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 7, ItemHandle = 501, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 7, ItemHandle = 503, WasSuccessful = true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateAdviseItemBulkRequest(7, [501, 502, 503]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(1, sessionManager.InvokeCount);
|
||||
Assert.Equal([501, 503], sessionManager.LastWorkerCommand!.Command.AdviseItemBulk.ItemHandles);
|
||||
BulkSubscribeReply merged = reply.AdviseItemBulk;
|
||||
Assert.Equal(3, merged.Results.Count);
|
||||
Assert.True(merged.Results[0].WasSuccessful);
|
||||
Assert.Equal(501, merged.Results[0].ItemHandle);
|
||||
Assert.False(merged.Results[1].WasSuccessful);
|
||||
Assert.Equal(502, merged.Results[1].ItemHandle);
|
||||
Assert.True(merged.Results[2].WasSuccessful);
|
||||
Assert.Equal(503, merged.Results[2].ItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>SubscribeBulk</c> with an allow-all enforcer must leave the worker reply
|
||||
/// unchanged — the constraint plan is null and no merge occurs. Regression
|
||||
/// guard against accidentally engaging the merge path for the common case.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_SubscribeBulk_WithAllowAllEnforcer_PassesThroughUnchanged()
|
||||
{
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
sessionManager.InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Kind = MxCommandKind.SubscribeBulk,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
SubscribeBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 7, TagAddress = "A", ItemHandle = 1, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 7, TagAddress = "B", ItemHandle = 2, WasSuccessful = true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
MxAccessGatewayService service = CreateService(sessionManager);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateSubscribeBulkRequest(7, ["A", "B"]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(1, sessionManager.InvokeCount);
|
||||
Assert.Equal(["A", "B"], sessionManager.LastWorkerCommand!.Command.SubscribeBulk.TagAddresses);
|
||||
// Reply identical to worker reply — no synthetic denial rows added.
|
||||
Assert.Equal(2, reply.SubscribeBulk.Results.Count);
|
||||
Assert.All(reply.SubscribeBulk.Results, r => Assert.True(r.WasSuccessful));
|
||||
}
|
||||
|
||||
// === ReadBulk family ===
|
||||
|
||||
/// <summary>
|
||||
/// <c>ReadBulk</c> with a mix of allowed and denied tags merges denied entries
|
||||
/// into the <c>BulkReadReply</c> in original-index order, distinguishable from
|
||||
/// the SubscribeBulk family because the reply slot is
|
||||
/// <c>BulkReadReply</c>, not <c>BulkSubscribeReply</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_ReadBulk_WithMixedDenials_MergesDeniedBulkReadResults()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new()
|
||||
{
|
||||
DenyTag = tag => tag == "Secret.Tag",
|
||||
};
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
sessionManager.InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Kind = MxCommandKind.ReadBulk,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
ReadBulk = new BulkReadReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkReadResult { ServerHandle = 7, TagAddress = "Public.A", WasSuccessful = true },
|
||||
new BulkReadResult { ServerHandle = 7, TagAddress = "Public.B", WasSuccessful = true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateReadBulkRequest(7, ["Public.A", "Secret.Tag", "Public.B"]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(1, sessionManager.InvokeCount);
|
||||
Assert.Equal(["Public.A", "Public.B"], sessionManager.LastWorkerCommand!.Command.ReadBulk.TagAddresses);
|
||||
BulkReadReply merged = reply.ReadBulk;
|
||||
Assert.Equal(3, merged.Results.Count);
|
||||
Assert.True(merged.Results[0].WasSuccessful);
|
||||
Assert.False(merged.Results[1].WasSuccessful);
|
||||
Assert.Equal("Secret.Tag", merged.Results[1].TagAddress);
|
||||
Assert.True(merged.Results[2].WasSuccessful);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>ReadBulk</c> with all tags denied must short-circuit and produce a
|
||||
/// denied-only <c>BulkReadReply</c> — verifying
|
||||
/// <see cref="MxAccessGatewayService"/>'s <c>ReadBulkConstraintPlan</c>
|
||||
/// <c>CreateDeniedReply</c> path.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_ReadBulk_WhenAllTagsDenied_ShortCircuitsWithDeniedOnlyReply()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new() { DenyTag = _ => true };
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateReadBulkRequest(7, ["X", "Y"]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(0, sessionManager.InvokeCount);
|
||||
Assert.Equal(2, reply.ReadBulk.Results.Count);
|
||||
Assert.All(reply.ReadBulk.Results, r => Assert.False(r.WasSuccessful));
|
||||
Assert.Equal(MxCommandKind.ReadBulk, reply.Kind);
|
||||
}
|
||||
|
||||
// === WriteBulk family: WriteBulk / Write2Bulk / WriteSecuredBulk / WriteSecured2Bulk ===
|
||||
|
||||
/// <summary>
|
||||
/// <c>WriteBulk</c> with one denied handle must drop that entry from the
|
||||
/// forwarded command and splice a denied <c>BulkWriteResult</c> back in at
|
||||
/// the original index.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteBulk_WithDeniedHandle_DropsEntryFromWorkerCallAndMergesDenialIntoReply()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new()
|
||||
{
|
||||
DenyWriteHandle = (_, itemHandle) => itemHandle == 902,
|
||||
};
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
sessionManager.InvokeReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Kind = MxCommandKind.WriteBulk,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
WriteBulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult { ServerHandle = 7, ItemHandle = 901, WasSuccessful = true },
|
||||
new BulkWriteResult { ServerHandle = 7, ItemHandle = 903, WasSuccessful = true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateWriteBulkRequest(7, [901, 902, 903]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(1, sessionManager.InvokeCount);
|
||||
// 902 dropped from forwarded entries; only 901 and 903 reach the worker.
|
||||
WriteBulkCommand forwarded = sessionManager.LastWorkerCommand!.Command.WriteBulk;
|
||||
Assert.Equal([901, 903], forwarded.Entries.Select(e => e.ItemHandle));
|
||||
BulkWriteReply merged = reply.WriteBulk;
|
||||
Assert.Equal(3, merged.Results.Count);
|
||||
Assert.True(merged.Results[0].WasSuccessful);
|
||||
Assert.Equal(901, merged.Results[0].ItemHandle);
|
||||
Assert.False(merged.Results[1].WasSuccessful);
|
||||
Assert.Equal(902, merged.Results[1].ItemHandle);
|
||||
Assert.True(merged.Results[2].WasSuccessful);
|
||||
Assert.Equal(903, merged.Results[2].ItemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>WriteSecuredBulk</c> exercises a different <c>ReplaceWriteBulkEntries</c>
|
||||
/// switch arm than plain <c>WriteBulk</c>. The merge logic is shared, so a
|
||||
/// full denial here is enough to prove the secured-bulk routing.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteSecuredBulk_WhenAllHandlesDenied_ShortCircuitsWithDeniedOnlyReply()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
MxCommandReply reply = await service.Invoke(
|
||||
CreateWriteSecuredBulkRequest(7, [10, 11]),
|
||||
new TestServerCallContext());
|
||||
|
||||
Assert.Equal(0, sessionManager.InvokeCount);
|
||||
Assert.Equal(MxCommandKind.WriteSecuredBulk, reply.Kind);
|
||||
Assert.Equal(2, reply.WriteSecuredBulk.Results.Count);
|
||||
Assert.All(reply.WriteSecuredBulk.Results, r => Assert.False(r.WasSuccessful));
|
||||
}
|
||||
|
||||
// === Unary write-handle enforcement (EnforceWriteHandleAsync) ===
|
||||
|
||||
/// <summary>
|
||||
/// Unary <c>Write</c> against a denied (server, item) handle must surface
|
||||
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceWriteHandleAsync</c>
|
||||
/// and never reach the session manager.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_Write_WithDeniedHandle_ThrowsPermissionDeniedAndDoesNotCallWorker()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new()
|
||||
{
|
||||
DenyWriteHandle = (serverHandle, itemHandle) => serverHandle == 7 && itemHandle == 42,
|
||||
};
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.Invoke(
|
||||
CreateWriteRequest(serverHandle: 7, itemHandle: 42),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
|
||||
Assert.Equal(0, sessionManager.InvokeCount);
|
||||
Assert.Single(enforcer.RecordedDenials);
|
||||
Assert.Equal("42", enforcer.RecordedDenials[0].Target);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unary <c>WriteSecured</c> against a denied handle takes the same enforce path
|
||||
/// and rejects identically — proving the four-arm switch in
|
||||
/// <c>ApplyConstraintsAsync</c> (Write/Write2/WriteSecured/WriteSecured2) is
|
||||
/// reachable for at least one of the secured kinds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_WriteSecured_WithDeniedHandle_ThrowsPermissionDenied()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new() { DenyWriteHandle = (_, _) => true };
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.Invoke(
|
||||
CreateWriteSecuredRequest(serverHandle: 7, itemHandle: 42),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
|
||||
Assert.Equal(0, sessionManager.InvokeCount);
|
||||
}
|
||||
|
||||
// === Unary read-tag enforcement (EnforceReadTagAsync via AddItem) ===
|
||||
|
||||
/// <summary>
|
||||
/// Unary <c>AddItem</c> against a denied tag must surface
|
||||
/// <see cref="StatusCode.PermissionDenied"/> via <c>EnforceReadTagAsync</c>
|
||||
/// and never reach the session manager.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Invoke_AddItem_WithDeniedTag_ThrowsPermissionDeniedAndDoesNotCallWorker()
|
||||
{
|
||||
PredicateConstraintEnforcer enforcer = new()
|
||||
{
|
||||
DenyTag = tag => tag == "Secret.Tag",
|
||||
};
|
||||
FakeSessionManager sessionManager = CreateSessionManagerWithSeed();
|
||||
MxAccessGatewayService service = CreateService(sessionManager, enforcer);
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
async () => await service.Invoke(
|
||||
CreateAddItemRequest(serverHandle: 7, tagAddress: "Secret.Tag"),
|
||||
new TestServerCallContext()));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
|
||||
Assert.Equal(0, sessionManager.InvokeCount);
|
||||
Assert.Single(enforcer.RecordedDenials);
|
||||
Assert.Equal("Secret.Tag", enforcer.RecordedDenials[0].Target);
|
||||
}
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
private static MxAccessGatewayService CreateService(
|
||||
FakeSessionManager sessionManager,
|
||||
IConstraintEnforcer? constraintEnforcer = null)
|
||||
{
|
||||
return new MxAccessGatewayService(
|
||||
sessionManager,
|
||||
new GatewayRequestIdentityAccessor(),
|
||||
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
|
||||
new MxAccessGrpcRequestValidator(),
|
||||
new MxAccessGrpcMapper(),
|
||||
new FakeEventStreamService(sessionManager),
|
||||
new GatewayMetrics(),
|
||||
NullLogger<MxAccessGatewayService>.Instance);
|
||||
}
|
||||
|
||||
private static FakeSessionManager CreateSessionManagerWithSeed()
|
||||
{
|
||||
FakeSessionManager sessionManager = new() { ResolveOnlySeededSessions = true };
|
||||
sessionManager.SeedSession(CreateSession(SessionId));
|
||||
return sessionManager;
|
||||
}
|
||||
|
||||
private static GatewaySession CreateSession(string sessionId)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
sessionId,
|
||||
GatewayContractInfo.DefaultBackendName,
|
||||
"pipe",
|
||||
"nonce",
|
||||
"Operator Key",
|
||||
"operator-session",
|
||||
"client-correlation",
|
||||
TimeSpan.FromSeconds(7),
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(10),
|
||||
DateTimeOffset.UtcNow);
|
||||
session.AttachWorkerClient(new FakeWorkerClient());
|
||||
session.MarkReady();
|
||||
return session;
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateAddItemBulkRequest(int serverHandle, IReadOnlyList<string> tags)
|
||||
{
|
||||
AddItemBulkCommand cmd = new() { ServerHandle = serverHandle };
|
||||
cmd.TagAddresses.Add(tags);
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.AddItemBulk, AddItemBulk = cmd },
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateSubscribeBulkRequest(int serverHandle, IReadOnlyList<string> tags)
|
||||
{
|
||||
SubscribeBulkCommand cmd = new() { ServerHandle = serverHandle };
|
||||
cmd.TagAddresses.Add(tags);
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.SubscribeBulk, SubscribeBulk = cmd },
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateAdviseItemBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
|
||||
{
|
||||
AdviseItemBulkCommand cmd = new() { ServerHandle = serverHandle };
|
||||
cmd.ItemHandles.Add(itemHandles);
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.AdviseItemBulk, AdviseItemBulk = cmd },
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateReadBulkRequest(int serverHandle, IReadOnlyList<string> tags)
|
||||
{
|
||||
ReadBulkCommand cmd = new() { ServerHandle = serverHandle, TimeoutMs = 1000 };
|
||||
cmd.TagAddresses.Add(tags);
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.ReadBulk, ReadBulk = cmd },
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateWriteBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
|
||||
{
|
||||
WriteBulkCommand cmd = new() { ServerHandle = serverHandle };
|
||||
foreach (int handle in itemHandles)
|
||||
{
|
||||
cmd.Entries.Add(new WriteBulkEntry { ItemHandle = handle, Value = new MxValue { StringValue = "v" } });
|
||||
}
|
||||
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.WriteBulk, WriteBulk = cmd },
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateWriteSecuredBulkRequest(int serverHandle, IReadOnlyList<int> itemHandles)
|
||||
{
|
||||
WriteSecuredBulkCommand cmd = new() { ServerHandle = serverHandle };
|
||||
foreach (int handle in itemHandles)
|
||||
{
|
||||
cmd.Entries.Add(new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = handle,
|
||||
CurrentUserId = 1,
|
||||
VerifierUserId = 2,
|
||||
Value = new MxValue { StringValue = "v" },
|
||||
});
|
||||
}
|
||||
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand { Kind = MxCommandKind.WriteSecuredBulk, WriteSecuredBulk = cmd },
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateWriteRequest(int serverHandle, int itemHandle)
|
||||
{
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write,
|
||||
Write = new WriteCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
Value = new MxValue { StringValue = "v" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateWriteSecuredRequest(int serverHandle, int itemHandle)
|
||||
{
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
WriteSecured = new WriteSecuredCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
CurrentUserId = 1,
|
||||
VerifierUserId = 2,
|
||||
Value = new MxValue { StringValue = "v" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandRequest CreateAddItemRequest(int serverHandle, string tagAddress)
|
||||
{
|
||||
return new MxCommandRequest
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemDefinition = tagAddress,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// FakeSessionManager / FakeEventStreamService / FakeWorkerClient mirror the
|
||||
// implementations in MxAccessGatewayServiceTests; the duplication is intentional
|
||||
// so the constraint tests are self-contained and changes to the existing fakes
|
||||
// don't accidentally couple the two suites.
|
||||
private sealed class FakeSessionManager : ISessionManager
|
||||
{
|
||||
private readonly Dictionary<string, GatewaySession> seededSessions = new(StringComparer.Ordinal);
|
||||
|
||||
public bool ResolveOnlySeededSessions { get; init; }
|
||||
|
||||
public WorkerCommand? LastWorkerCommand { get; private set; }
|
||||
|
||||
public int InvokeCount { get; private set; }
|
||||
|
||||
public WorkerCommandReply InvokeReply { get; set; } = new()
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
Kind = MxCommandKind.Ping,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
},
|
||||
};
|
||||
|
||||
public List<WorkerEvent> Events { get; } = [];
|
||||
|
||||
public void SeedSession(GatewaySession session) => seededSessions[session.SessionId] = session;
|
||||
|
||||
public Task<GatewaySession> OpenSessionAsync(
|
||||
SessionOpenRequest request,
|
||||
string? clientIdentity,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(seededSessions.Values.First());
|
||||
|
||||
public bool TryGetSession(string sessionId, out GatewaySession session)
|
||||
{
|
||||
if (seededSessions.TryGetValue(sessionId, out GatewaySession? seeded))
|
||||
{
|
||||
session = seeded;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ResolveOnlySeededSessions)
|
||||
{
|
||||
session = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
session = CreateFallbackSession(sessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
string sessionId,
|
||||
WorkerCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
InvokeCount++;
|
||||
LastWorkerCommand = command;
|
||||
return Task.FromResult(InvokeReply);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
string sessionId,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (WorkerEvent ev in Events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Yield();
|
||||
yield return ev;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<SessionCloseResult> CloseSessionAsync(
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
|
||||
|
||||
public Task<int> CloseExpiredLeasesAsync(
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken) => Task.FromResult(0);
|
||||
|
||||
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static GatewaySession CreateFallbackSession(string sessionId)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
sessionId,
|
||||
GatewayContractInfo.DefaultBackendName,
|
||||
"pipe",
|
||||
"nonce",
|
||||
"Operator Key",
|
||||
"operator-session",
|
||||
"client-correlation",
|
||||
TimeSpan.FromSeconds(7),
|
||||
TimeSpan.FromSeconds(30),
|
||||
TimeSpan.FromSeconds(10),
|
||||
DateTimeOffset.UtcNow);
|
||||
session.AttachWorkerClient(new FakeWorkerClient());
|
||||
session.MarkReady();
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeEventStreamService(FakeSessionManager sessionManager) : IEventStreamService
|
||||
{
|
||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||
StreamEventsRequest request,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (WorkerEvent ev in sessionManager.Events)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
await Task.Yield();
|
||||
yield return ev.Event;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
public string SessionId { get; } = MxAccessGatewayServiceConstraintTests.SessionId;
|
||||
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public void Kill(string reason)
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -422,12 +422,13 @@ public sealed class MxAccessGatewayServiceTests
|
||||
private static MxAccessGatewayService CreateService(
|
||||
FakeSessionManager sessionManager,
|
||||
IGatewayRequestIdentityAccessor? identityAccessor = null,
|
||||
GatewayMetrics? metrics = null)
|
||||
GatewayMetrics? metrics = null,
|
||||
IConstraintEnforcer? constraintEnforcer = null)
|
||||
{
|
||||
return new MxAccessGatewayService(
|
||||
sessionManager,
|
||||
identityAccessor ?? new GatewayRequestIdentityAccessor(),
|
||||
new AllowAllConstraintEnforcer(),
|
||||
constraintEnforcer ?? new AllowAllConstraintEnforcer(),
|
||||
new MxAccessGrpcRequestValidator(),
|
||||
new MxAccessGrpcMapper(),
|
||||
new FakeEventStreamService(sessionManager),
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
|
||||
namespace MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Concurrency and disposal regression tests for <see cref="GatewaySession"/>.
|
||||
/// Server-015 and Server-016 audited the split lock discipline between
|
||||
/// <c>_syncRoot</c> (state transitions) and <c>_closeLock</c> (close serialization)
|
||||
/// and the un-gated <c>DisposeAsync</c>; these tests pin the post-fix behavior.
|
||||
/// </summary>
|
||||
public sealed class GatewaySessionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-015 regression. A <c>TransitionTo(Ready)</c> issued after
|
||||
/// <see cref="GatewaySession.CloseAsync"/> has set <see cref="SessionState.Closing"/>
|
||||
/// must not flip the session back to <see cref="SessionState.Ready"/>. The
|
||||
/// blocking worker shutdown keeps <c>CloseAsync</c> parked between the
|
||||
/// <c>Closing</c> write and the <c>Closed</c> write, which is precisely the
|
||||
/// window the audit identified.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task TransitionTo_AfterCloseStarted_DoesNotOverwriteClosing()
|
||||
{
|
||||
BlockingShutdownWorkerClient workerClient = new();
|
||||
GatewaySession session = CreateReadySession(workerClient);
|
||||
|
||||
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
|
||||
await workerClient.WaitForShutdownStartAsync();
|
||||
|
||||
// Close has set _state = Closing under _syncRoot and is parked inside
|
||||
// worker.ShutdownAsync. A concurrent transition (e.g. a late
|
||||
// SessionWorkerClientFactory lifecycle callback) must not revive the session.
|
||||
Assert.Equal(SessionState.Closing, session.State);
|
||||
session.TransitionTo(SessionState.Ready);
|
||||
Assert.Equal(SessionState.Closing, session.State);
|
||||
|
||||
workerClient.ReleaseShutdown();
|
||||
SessionCloseResult result = await closeTask;
|
||||
|
||||
Assert.Equal(SessionState.Closed, result.FinalState);
|
||||
Assert.Equal(SessionState.Closed, session.State);
|
||||
|
||||
await session.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-015 regression. Once <see cref="GatewaySession.CloseAsync"/> finishes,
|
||||
/// <see cref="GatewaySession.MarkFaulted"/> must not be able to move the
|
||||
/// session out of <see cref="SessionState.Closed"/> either — the close path's
|
||||
/// terminal write goes through the same <c>_syncRoot</c> the rest of the state
|
||||
/// machine uses, so the existing "Closed is terminal" invariant holds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task MarkFaulted_AfterCloseCompletes_DoesNotResurrectSession()
|
||||
{
|
||||
FakeWorkerClient workerClient = new();
|
||||
GatewaySession session = CreateReadySession(workerClient);
|
||||
|
||||
await session.CloseAsync("test-close", CancellationToken.None);
|
||||
Assert.Equal(SessionState.Closed, session.State);
|
||||
|
||||
session.MarkFaulted("late-fault");
|
||||
Assert.Equal(SessionState.Closed, session.State);
|
||||
|
||||
await session.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-016 regression. <see cref="GatewaySession.DisposeAsync"/> must wait
|
||||
/// for an in-flight <see cref="GatewaySession.CloseAsync"/> before disposing
|
||||
/// its semaphore. Without the fix, the close's <c>_closeLock.Release()</c>
|
||||
/// would race the dispose and raise <see cref="ObjectDisposedException"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_WhileCloseInFlight_WaitsForCloseAndDoesNotThrow()
|
||||
{
|
||||
BlockingShutdownWorkerClient workerClient = new();
|
||||
GatewaySession session = CreateReadySession(workerClient);
|
||||
|
||||
Task<SessionCloseResult> closeTask = session.CloseAsync("test-close", CancellationToken.None);
|
||||
await workerClient.WaitForShutdownStartAsync();
|
||||
|
||||
// Start disposing while close is still parked inside worker.ShutdownAsync.
|
||||
ValueTask disposeTask = session.DisposeAsync();
|
||||
|
||||
// Now release the worker shutdown so close can complete.
|
||||
workerClient.ReleaseShutdown();
|
||||
|
||||
// Both must complete cleanly — the close's Release() must run before the
|
||||
// dispose actually tears the semaphore down.
|
||||
SessionCloseResult result = await closeTask;
|
||||
await disposeTask;
|
||||
|
||||
Assert.Equal(SessionState.Closed, result.FinalState);
|
||||
Assert.Equal(1, workerClient.ShutdownCount);
|
||||
// Worker dispose ran exactly once even with the close/dispose interleave.
|
||||
Assert.Equal(1, workerClient.DisposeCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Double-dispose is tolerated: the second call must swallow
|
||||
/// <see cref="ObjectDisposedException"/> from the already-disposed semaphore
|
||||
/// rather than propagating it.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task DisposeAsync_CalledTwice_DoesNotThrow()
|
||||
{
|
||||
FakeWorkerClient workerClient = new();
|
||||
GatewaySession session = CreateReadySession(workerClient);
|
||||
await session.CloseAsync("test-close", CancellationToken.None);
|
||||
|
||||
await session.DisposeAsync();
|
||||
// No second exception — the dispose's defensive ObjectDisposedException catch
|
||||
// covers the doubled call path that SessionManager.ShutdownAsync could trigger
|
||||
// if it re-removed a session.
|
||||
await session.DisposeAsync();
|
||||
}
|
||||
|
||||
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
sessionId: "session-test",
|
||||
backendName: "mxaccess",
|
||||
pipeName: "mxaccess-gateway-1-session-test",
|
||||
nonce: "nonce",
|
||||
clientIdentity: "client-1",
|
||||
clientSessionName: "test-session",
|
||||
clientCorrelationId: "client-correlation-1",
|
||||
commandTimeout: TimeSpan.FromSeconds(5),
|
||||
startupTimeout: TimeSpan.FromSeconds(5),
|
||||
shutdownTimeout: TimeSpan.FromSeconds(5),
|
||||
leaseDuration: TimeSpan.FromMinutes(30),
|
||||
openedAt: DateTimeOffset.UtcNow);
|
||||
session.AttachWorkerClient(workerClient);
|
||||
session.MarkReady();
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal worker client that parks <see cref="ShutdownAsync"/> until the test
|
||||
/// explicitly releases it. Used to keep <see cref="GatewaySession.CloseAsync"/>
|
||||
/// stuck between its <c>Closing</c> and <c>Closed</c> writes so the test can
|
||||
/// observe and act on the intermediate state.
|
||||
/// </summary>
|
||||
private sealed class BlockingShutdownWorkerClient : IWorkerClient
|
||||
{
|
||||
private readonly TaskCompletionSource _shutdownStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly TaskCompletionSource _shutdownReleased = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public string SessionId { get; } = "session-test";
|
||||
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
public WorkerClientState State { get; private set; } = WorkerClientState.Ready;
|
||||
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public int ShutdownCount { get; private set; }
|
||||
|
||||
public int DisposeCount { get; private set; }
|
||||
|
||||
public Task WaitForShutdownStartAsync()
|
||||
{
|
||||
return _shutdownStarted.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
public void ReleaseShutdown()
|
||||
{
|
||||
_shutdownReleased.TrySetResult();
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
yield break;
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
ShutdownCount++;
|
||||
_shutdownStarted.TrySetResult();
|
||||
await _shutdownReleased.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
State = WorkerClientState.Closed;
|
||||
}
|
||||
|
||||
public void Kill(string reason)
|
||||
{
|
||||
State = WorkerClientState.Faulted;
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeWorkerClient : IWorkerClient
|
||||
{
|
||||
public string SessionId { get; } = "session-test";
|
||||
|
||||
public int? ProcessId { get; } = 1234;
|
||||
|
||||
public WorkerClientState State { get; } = WorkerClientState.Ready;
|
||||
|
||||
public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public int DisposeCount { get; private set; }
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
|
||||
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(false);
|
||||
yield break;
|
||||
}
|
||||
|
||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
public void Kill(string reason)
|
||||
{
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
DisposeCount++;
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
|
||||
namespace MxGateway.Tests.Gateway.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Tests-013: per-method gateway-side coverage for every
|
||||
/// <c>GatewaySession.*BulkAsync</c> entry point. Each method gets a
|
||||
/// round-trip test that pins the <see cref="MxCommandKind"/> sent to the
|
||||
/// worker, the per-entry payload shape, a failure-mode (per-entry failure
|
||||
/// surfaced or protocol-status failure) check, and a cancellation-propagation
|
||||
/// check. The secured-write variants additionally pin that the credential
|
||||
/// payload (<c>current_user_id</c>, <c>verifier_user_id</c>) is preserved
|
||||
/// end-to-end and not flattened/redacted by the gateway's command shape.
|
||||
/// </summary>
|
||||
public sealed class SessionManagerBulkTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AddItemBulkAsync_ForwardsOneAddItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.AddItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Ok", ItemHandle = 511, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Tag.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "invalid tag" },
|
||||
},
|
||||
}, MxCommandKind.AddItemBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<SubscribeResult> results = await session.AddItemBulkAsync(
|
||||
12,
|
||||
["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, workerClient.InvokeCount);
|
||||
Assert.Equal(MxCommandKind.AddItemBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(12, workerClient.LastCommand?.Command.AddItemBulk.ServerHandle);
|
||||
Assert.Equal(["Galaxy.Tag.Ok", "Galaxy.Tag.Bad"], workerClient.LastCommand?.Command.AddItemBulk.TagAddresses);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results[0].WasSuccessful);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
Assert.Equal("invalid tag", results[1].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.AddItemBulkAsync(12, ["Tag.A"], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdviseItemBulkAsync_ForwardsOneAdviseItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.AdviseItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "invalid item handle" },
|
||||
},
|
||||
}, MxCommandKind.AdviseItemBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<SubscribeResult> results = await session.AdviseItemBulkAsync(
|
||||
12,
|
||||
[901, 902],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.AdviseItemBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(12, workerClient.LastCommand?.Command.AdviseItemBulk.ServerHandle);
|
||||
Assert.Equal([901, 902], workerClient.LastCommand?.Command.AdviseItemBulk.ItemHandles);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results[0].WasSuccessful);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdviseItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.AdviseItemBulkAsync(12, [101], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveItemBulkAsync_ForwardsOneRemoveItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.RemoveItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 11, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 12, WasSuccessful = false, ErrorMessage = "unknown handle" },
|
||||
},
|
||||
}, MxCommandKind.RemoveItemBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<SubscribeResult> results = await session.RemoveItemBulkAsync(
|
||||
12,
|
||||
[11, 12],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.RemoveItemBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal([11, 12], workerClient.LastCommand?.Command.RemoveItemBulk.ItemHandles);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.RemoveItemBulkAsync(12, [11], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnAdviseItemBulkAsync_ForwardsOneUnAdviseItemBulkCommandAndReturnsResults()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnAdviseItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 21, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 22, WasSuccessful = false, ErrorMessage = "not advised" },
|
||||
},
|
||||
}, MxCommandKind.UnAdviseItemBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<SubscribeResult> results = await session.UnAdviseItemBulkAsync(
|
||||
12,
|
||||
[21, 22],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.UnAdviseItemBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal([21, 22], workerClient.LastCommand?.Command.UnAdviseItemBulk.ItemHandles);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
Assert.Equal("not advised", results[1].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnAdviseItemBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.UnAdviseItemBulkAsync(12, [21], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
// SubscribeBulkAsync already has a happy-path test in SessionManagerTests
|
||||
// (GatewaySessionSubscribeBulkAsync_ForwardsOneBulkCommandAndReturnsResults);
|
||||
// this complementary test pins the per-entry failure-surface behaviour.
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.SubscribeBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Good", ItemHandle = 501, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 12, TagAddress = "Galaxy.Bad", ItemHandle = 0, WasSuccessful = false, ErrorMessage = "MXAccess subscribe failed" },
|
||||
},
|
||||
}, MxCommandKind.SubscribeBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<SubscribeResult> results = await session.SubscribeBulkAsync(
|
||||
12,
|
||||
["Galaxy.Good", "Galaxy.Bad"],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results[0].WasSuccessful);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
Assert.Equal("MXAccess subscribe failed", results[1].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.SubscribeBulkAsync(12, ["Tag"], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeBulkAsync_ForwardsOneUnsubscribeBulkCommandAndReturnsResults()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.UnsubscribeBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 31, WasSuccessful = true },
|
||||
new SubscribeResult { ServerHandle = 12, ItemHandle = 32, WasSuccessful = false, ErrorMessage = "unknown handle" },
|
||||
},
|
||||
}, MxCommandKind.UnsubscribeBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<SubscribeResult> results = await session.UnsubscribeBulkAsync(
|
||||
12,
|
||||
[31, 32],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.UnsubscribeBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal([31, 32], workerClient.LastCommand?.Command.UnsubscribeBulk.ItemHandles);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnsubscribeBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.UnsubscribeBulkAsync(12, [31], cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
// Complement the existing happy-path WriteBulk test in SessionManagerTests
|
||||
// with an explicit per-entry failure assertion plus payload-shape pinning.
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteBulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 901, WasSuccessful = true },
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 902, WasSuccessful = false, ErrorMessage = "MXAccess invalid handle" },
|
||||
},
|
||||
}, MxCommandKind.WriteBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<BulkWriteResult> results = await session.WriteBulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteBulkEntry { ItemHandle = 901, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 } },
|
||||
new WriteBulkEntry { ItemHandle = 902, UserId = 5, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 } },
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.WriteBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(2, workerClient.LastCommand?.Command.WriteBulk.Entries.Count);
|
||||
Assert.Equal(901, workerClient.LastCommand?.Command.WriteBulk.Entries[0].ItemHandle);
|
||||
Assert.Equal(11, workerClient.LastCommand?.Command.WriteBulk.Entries[0].Value.Int32Value);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
Assert.Equal("MXAccess invalid handle", results[1].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.WriteBulkAsync(
|
||||
12,
|
||||
new[] { new WriteBulkEntry { ItemHandle = 1, UserId = 1, Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 } } },
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write2BulkAsync_ForwardsOneWrite2BulkCommandAndPreservesTimestampPayload()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.Write2Bulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 701, WasSuccessful = true },
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 702, WasSuccessful = false, ErrorMessage = "MXAccess Write2 failed" },
|
||||
},
|
||||
}, MxCommandKind.Write2Bulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<BulkWriteResult> results = await session.Write2BulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new Write2BulkEntry
|
||||
{
|
||||
ItemHandle = 701,
|
||||
UserId = 5,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 11 },
|
||||
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567890L },
|
||||
},
|
||||
new Write2BulkEntry
|
||||
{
|
||||
ItemHandle = 702,
|
||||
UserId = 5,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 22 },
|
||||
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1234567891L },
|
||||
},
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.Write2Bulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(12, workerClient.LastCommand?.Command.Write2Bulk.ServerHandle);
|
||||
Assert.Equal(2, workerClient.LastCommand?.Command.Write2Bulk.Entries.Count);
|
||||
Assert.Equal(701, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].ItemHandle);
|
||||
Assert.Equal(1234567890L, workerClient.LastCommand?.Command.Write2Bulk.Entries[0].TimestampValue.Int64Value);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write2BulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.Write2BulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new Write2BulkEntry
|
||||
{
|
||||
ItemHandle = 1,
|
||||
UserId = 1,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
|
||||
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L },
|
||||
},
|
||||
},
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_ForwardsOneWriteSecuredBulkCommandAndPreservesCredentialPayload()
|
||||
{
|
||||
// The secured variants carry caller credential identifiers (CurrentUserId /
|
||||
// VerifierUserId). Pin that those survive the gateway round-trip end-to-end —
|
||||
// the over-the-wire command shape must NOT redact or flatten them, only the
|
||||
// *log surface* (see GatewaySession's redaction rules) is allowed to drop them.
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecuredBulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 601, WasSuccessful = true },
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 602, WasSuccessful = false, ErrorMessage = "MXAccess secured-write rejected" },
|
||||
},
|
||||
}, MxCommandKind.WriteSecuredBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<BulkWriteResult> results = await session.WriteSecuredBulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = 601,
|
||||
CurrentUserId = 7,
|
||||
VerifierUserId = 8,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 },
|
||||
},
|
||||
new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = 602,
|
||||
CurrentUserId = 7,
|
||||
VerifierUserId = 8,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 },
|
||||
},
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.WriteSecuredBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(12, workerClient.LastCommand?.Command.WriteSecuredBulk.ServerHandle);
|
||||
Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecuredBulk.Entries.Count);
|
||||
WriteSecuredBulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecuredBulk.Entries[0];
|
||||
Assert.Equal(601, firstEntry.ItemHandle);
|
||||
Assert.Equal(7, firstEntry.CurrentUserId);
|
||||
Assert.Equal(8, firstEntry.VerifierUserId);
|
||||
Assert.Equal(1, firstEntry.Value.Int32Value);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
Assert.Equal("MXAccess secured-write rejected", results[1].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.WriteSecuredBulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = 1,
|
||||
CurrentUserId = 7,
|
||||
VerifierUserId = 8,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
|
||||
},
|
||||
},
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSecured2BulkAsync_ForwardsOneWriteSecured2BulkCommandAndPreservesCredentialAndTimestampPayload()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.WriteSecured2Bulk = new BulkWriteReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 801, WasSuccessful = true },
|
||||
new BulkWriteResult { ServerHandle = 12, ItemHandle = 802, WasSuccessful = false, ErrorMessage = "MXAccess secured2-write rejected" },
|
||||
},
|
||||
}, MxCommandKind.WriteSecured2Bulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<BulkWriteResult> results = await session.WriteSecured2BulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteSecured2BulkEntry
|
||||
{
|
||||
ItemHandle = 801,
|
||||
CurrentUserId = 7,
|
||||
VerifierUserId = 8,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 1 },
|
||||
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000000L },
|
||||
},
|
||||
new WriteSecured2BulkEntry
|
||||
{
|
||||
ItemHandle = 802,
|
||||
CurrentUserId = 7,
|
||||
VerifierUserId = 8,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 2 },
|
||||
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 1700000001L },
|
||||
},
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.WriteSecured2Bulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(2, workerClient.LastCommand?.Command.WriteSecured2Bulk.Entries.Count);
|
||||
WriteSecured2BulkEntry firstEntry = workerClient.LastCommand!.Command.WriteSecured2Bulk.Entries[0];
|
||||
Assert.Equal(801, firstEntry.ItemHandle);
|
||||
Assert.Equal(7, firstEntry.CurrentUserId);
|
||||
Assert.Equal(8, firstEntry.VerifierUserId);
|
||||
Assert.Equal(1, firstEntry.Value.Int32Value);
|
||||
Assert.Equal(1700000000L, firstEntry.TimestampValue.Int64Value);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteSecured2BulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.WriteSecured2BulkAsync(
|
||||
12,
|
||||
new[]
|
||||
{
|
||||
new WriteSecured2BulkEntry
|
||||
{
|
||||
ItemHandle = 1,
|
||||
CurrentUserId = 7,
|
||||
VerifierUserId = 8,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 0 },
|
||||
TimestampValue = new MxValue { DataType = MxDataType.Time, Int64Value = 0L },
|
||||
},
|
||||
},
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_SurfacesPerEntryFailures()
|
||||
{
|
||||
// Complement the existing happy-path ReadBulk test in SessionManagerTests
|
||||
// with the failure-mode case where one tag failed to read.
|
||||
FakeBulkWorkerClient workerClient = WithReply(reply => reply.ReadBulk = new BulkReadReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 12,
|
||||
TagAddress = "Galaxy.Good",
|
||||
ItemHandle = 511,
|
||||
WasSuccessful = true,
|
||||
WasCached = false,
|
||||
Value = new MxValue { DataType = MxDataType.Integer, Int32Value = 42 },
|
||||
},
|
||||
new BulkReadResult
|
||||
{
|
||||
ServerHandle = 12,
|
||||
TagAddress = "Galaxy.Bad",
|
||||
ItemHandle = 0,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = "MXAccess read timed out",
|
||||
},
|
||||
},
|
||||
}, MxCommandKind.ReadBulk);
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
|
||||
IReadOnlyList<BulkReadResult> results = await session.ReadBulkAsync(
|
||||
12,
|
||||
["Galaxy.Good", "Galaxy.Bad"],
|
||||
TimeSpan.FromMilliseconds(750),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(MxCommandKind.ReadBulk, workerClient.LastCommand?.Command.Kind);
|
||||
Assert.Equal(750u, workerClient.LastCommand?.Command.ReadBulk.TimeoutMs);
|
||||
Assert.Equal(["Galaxy.Good", "Galaxy.Bad"], workerClient.LastCommand?.Command.ReadBulk.TagAddresses);
|
||||
Assert.Equal(2, results.Count);
|
||||
Assert.True(results[0].WasSuccessful);
|
||||
Assert.False(results[1].WasSuccessful);
|
||||
Assert.Equal("MXAccess read timed out", results[1].ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadBulkAsync_PropagatesCancellation()
|
||||
{
|
||||
FakeBulkWorkerClient workerClient = new();
|
||||
GatewaySession session = await OpenSessionAsync(workerClient);
|
||||
using CancellationTokenSource cts = new();
|
||||
await cts.CancelAsync();
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await session.ReadBulkAsync(
|
||||
12,
|
||||
["Galaxy.Tag"],
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
cts.Token));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private static FakeBulkWorkerClient WithReply(Action<MxCommandReply> populate, MxCommandKind kind)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
CorrelationId = "correlation-1",
|
||||
Kind = kind,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
};
|
||||
populate(reply);
|
||||
return new FakeBulkWorkerClient
|
||||
{
|
||||
InvokeReply = new WorkerCommandReply { Reply = reply },
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<GatewaySession> OpenSessionAsync(FakeBulkWorkerClient workerClient)
|
||||
{
|
||||
SessionManager manager = CreateManager(workerClient);
|
||||
return await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", CancellationToken.None);
|
||||
}
|
||||
|
||||
private static SessionManager CreateManager(FakeBulkWorkerClient workerClient)
|
||||
{
|
||||
return new SessionManager(
|
||||
new SessionRegistry(),
|
||||
new FakeBulkSessionWorkerClientFactory(workerClient),
|
||||
Options.Create(new GatewayOptions
|
||||
{
|
||||
Sessions = new SessionOptions
|
||||
{
|
||||
DefaultCommandTimeoutSeconds = 30,
|
||||
MaxSessions = 16,
|
||||
DefaultLeaseSeconds = 1800,
|
||||
},
|
||||
Worker = new WorkerOptions
|
||||
{
|
||||
StartupTimeoutSeconds = 30,
|
||||
ShutdownTimeoutSeconds = 10,
|
||||
},
|
||||
}),
|
||||
new GatewayMetrics());
|
||||
}
|
||||
|
||||
private static SessionOpenRequest CreateOpenRequest()
|
||||
{
|
||||
return new SessionOpenRequest(
|
||||
RequestedBackend: null,
|
||||
ClientSessionName: "test-session",
|
||||
ClientCorrelationId: "client-correlation-1",
|
||||
CommandTimeout: Duration.FromTimeSpan(TimeSpan.FromSeconds(5)));
|
||||
}
|
||||
|
||||
private sealed class FakeBulkSessionWorkerClientFactory(IWorkerClient workerClient) : ISessionWorkerClientFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<IWorkerClient> CreateAsync(
|
||||
GatewaySession session,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(workerClient);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeBulkWorkerClient : IWorkerClient
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string SessionId { get; init; } = "session-1";
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? ProcessId { get; init; } = 1234;
|
||||
|
||||
/// <inheritdoc />
|
||||
public WorkerClientState State { get; set; } = WorkerClientState.Ready;
|
||||
|
||||
/// <inheritdoc />
|
||||
public DateTimeOffset LastHeartbeatAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>Gets the number of times Invoke was called on the fake worker client.</summary>
|
||||
public int InvokeCount { get; private set; }
|
||||
|
||||
/// <summary>Gets the last command invoked on the fake worker client.</summary>
|
||||
public WorkerCommand? LastCommand { get; private set; }
|
||||
|
||||
/// <summary>Gets the reply to return for invoke calls on the fake worker client.</summary>
|
||||
public WorkerCommandReply? InvokeReply { get; init; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<WorkerCommandReply> InvokeAsync(
|
||||
WorkerCommand command,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
InvokeCount++;
|
||||
LastCommand = command;
|
||||
if (InvokeReply is not null)
|
||||
{
|
||||
return Task.FromResult(InvokeReply);
|
||||
}
|
||||
|
||||
MxCommandKind kind = command.Command?.Kind ?? MxCommandKind.Unspecified;
|
||||
return Task.FromResult(new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
SessionId = SessionId,
|
||||
CorrelationId = "correlation-1",
|
||||
Kind = kind,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<WorkerEvent> ReadEventsAsync(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
await Task.CompletedTask;
|
||||
yield break;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
||||
{
|
||||
State = WorkerClientState.Closed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Kill(string reason) => State = WorkerClientState.Faulted;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ public sealed class SessionManagerTests
|
||||
[Fact]
|
||||
public async Task OpenSessionAsync_SetsInitialDefaultLease()
|
||||
{
|
||||
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z"));
|
||||
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-29T10:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
|
||||
GatewayOptions options = CreateOptions(defaultLeaseSeconds: 1800);
|
||||
SessionManager manager = CreateManager(
|
||||
new FakeSessionWorkerClientFactory(new FakeWorkerClient()),
|
||||
|
||||
@@ -267,21 +267,45 @@ public sealed class WorkerAlarmRpcDispatcherTests
|
||||
Assert.Equal("Galaxy!A.T2", collected[1].AlarmFullReference);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Server-019 regression: <c>QueryActiveAlarmsAsync</c> used to silently
|
||||
/// <c>yield break</c> when the session id was not in the registry, while the
|
||||
/// peer <c>AcknowledgeAsync</c> returned <c>SessionNotFound</c>. Both methods
|
||||
/// now signal a missing session — <c>QueryActiveAlarms</c> throws a
|
||||
/// <see cref="SessionManagerException"/> with
|
||||
/// <see cref="SessionManagerErrorCode.SessionNotFound"/> (the gateway gRPC
|
||||
/// layer maps it to gRPC <c>NotFound</c>), aligning the dispatcher's
|
||||
/// missing-session contract across the two RPCs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_YieldsEmpty()
|
||||
public async Task QueryActiveAlarmsAsync_WhenSessionMissing_ThrowsSessionNotFound()
|
||||
{
|
||||
SessionRegistry registry = new();
|
||||
WorkerAlarmRpcDispatcher dispatcher = new(registry);
|
||||
|
||||
List<ActiveAlarmSnapshot> collected = new();
|
||||
await foreach (ActiveAlarmSnapshot snap in dispatcher.QueryActiveAlarmsAsync(
|
||||
new QueryActiveAlarmsRequest { SessionId = "missing" },
|
||||
CancellationToken.None))
|
||||
SessionManagerException exception = await Assert.ThrowsAsync<SessionManagerException>(async () =>
|
||||
{
|
||||
collected.Add(snap);
|
||||
}
|
||||
await foreach (ActiveAlarmSnapshot _ in dispatcher.QueryActiveAlarmsAsync(
|
||||
new QueryActiveAlarmsRequest { SessionId = "missing" },
|
||||
CancellationToken.None))
|
||||
{
|
||||
// No yield expected — the throw happens before the first iteration.
|
||||
}
|
||||
});
|
||||
|
||||
Assert.Empty(collected);
|
||||
Assert.Equal(SessionManagerErrorCode.SessionNotFound, exception.ErrorCode);
|
||||
|
||||
// Peer-method parity: AcknowledgeAsync still signals SessionNotFound (as an
|
||||
// in-band ProtocolStatus, since it's a unary RPC). The two methods now agree
|
||||
// that a missing session is an error, not an empty success.
|
||||
AcknowledgeAlarmReply ackReply = await dispatcher.AcknowledgeAsync(
|
||||
new AcknowledgeAlarmRequest
|
||||
{
|
||||
SessionId = "missing",
|
||||
AlarmFullReference = Guid.NewGuid().ToString(),
|
||||
},
|
||||
CancellationToken.None);
|
||||
Assert.Equal(ProtocolStatusCode.SessionNotFound, ackReply.ProtocolStatus.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -341,10 +341,17 @@ public sealed class WorkerClientTests
|
||||
Assert.Equal(previousHeartbeat + TimeSpan.FromSeconds(1), client.LastHeartbeatAt);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the heartbeat monitor faults the client when the heartbeat expires.</summary>
|
||||
/// <summary>
|
||||
/// Verifies that the heartbeat monitor faults the client when the heartbeat expires.
|
||||
/// Uses an injected <see cref="ManualTimeProvider"/> so the grace comparison is deterministic
|
||||
/// instead of depending on real wall-clock advance; the monitor's
|
||||
/// <see cref="WorkerClientOptions.HeartbeatCheckInterval"/> timer stays on the real clock and
|
||||
/// observes the manually-advanced grace on its next tick.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient()
|
||||
{
|
||||
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture));
|
||||
await using PipePair pipePair = await PipePair.CreateAsync();
|
||||
await using WorkerClient client = CreateClient(
|
||||
pipePair,
|
||||
@@ -353,9 +360,12 @@ public sealed class WorkerClientTests
|
||||
HeartbeatGrace = TimeSpan.FromMilliseconds(80),
|
||||
HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20),
|
||||
EventChannelCapacity = 8,
|
||||
});
|
||||
},
|
||||
timeProvider: clock);
|
||||
await CompleteHandshakeAsync(client, pipePair);
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(2));
|
||||
|
||||
await WaitUntilAsync(
|
||||
() => client.State == WorkerClientState.Faulted,
|
||||
TestTimeout);
|
||||
|
||||
+88
@@ -252,6 +252,94 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
|
||||
Assert.Equal(0, sessionManager.InvokeCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the interceptor denies <c>AcknowledgeAlarm</c> calls that lack
|
||||
/// <see cref="GatewayScopes.InvokeWrite"/>. Ack is a write-shaped mutation against
|
||||
/// alarm state, so it carries the same scope as <c>MxCommandKind.Write</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_AcknowledgeAlarmMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)),
|
||||
new GatewayRequestIdentityAccessor());
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.UnaryServerHandler(
|
||||
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
(_, _) => Task.FromResult(new AcknowledgeAlarmReply())));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
|
||||
Assert.Contains(GatewayScopes.InvokeWrite, exception.Status.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an API key holding <c>invoke:write</c> may call <c>AcknowledgeAlarm</c>.</summary>
|
||||
[Fact]
|
||||
public async Task UnaryServerHandler_AcknowledgeAlarmWithScope_RunsHandler()
|
||||
{
|
||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeWrite)),
|
||||
new GatewayRequestIdentityAccessor());
|
||||
bool handlerRan = false;
|
||||
|
||||
AcknowledgeAlarmReply reply = await interceptor.UnaryServerHandler(
|
||||
new AcknowledgeAlarmRequest { SessionId = "session-1", AlarmFullReference = "ref" },
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
(_, _) =>
|
||||
{
|
||||
handlerRan = true;
|
||||
return Task.FromResult(new AcknowledgeAlarmReply());
|
||||
});
|
||||
|
||||
Assert.NotNull(reply);
|
||||
Assert.True(handlerRan);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the interceptor denies <c>QueryActiveAlarms</c> server-streaming calls that
|
||||
/// lack <see cref="GatewayScopes.EventsRead"/>. Active-alarm snapshots are part of the
|
||||
/// alarm/event surface and share the same scope as <c>StreamEvents</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_QueryActiveAlarmsMissingScope_ReturnsPermissionDenied()
|
||||
{
|
||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.InvokeRead)),
|
||||
new GatewayRequestIdentityAccessor());
|
||||
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => interceptor.ServerStreamingServerHandler(
|
||||
new QueryActiveAlarmsRequest { SessionId = "session-1" },
|
||||
new RecordingServerStreamWriter<ActiveAlarmSnapshot>(),
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
(_, _, _) => Task.CompletedTask));
|
||||
|
||||
Assert.Equal(StatusCode.PermissionDenied, exception.StatusCode);
|
||||
Assert.Contains(GatewayScopes.EventsRead, exception.Status.Detail, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an API key holding <c>events:read</c> may call <c>QueryActiveAlarms</c>.</summary>
|
||||
[Fact]
|
||||
public async Task ServerStreamingServerHandler_QueryActiveAlarmsWithScope_RunsHandler()
|
||||
{
|
||||
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
|
||||
new FakeApiKeyVerifier(SuccessWithScopes(GatewayScopes.EventsRead)),
|
||||
new GatewayRequestIdentityAccessor());
|
||||
RecordingServerStreamWriter<ActiveAlarmSnapshot> streamWriter = new();
|
||||
|
||||
await interceptor.ServerStreamingServerHandler(
|
||||
new QueryActiveAlarmsRequest { SessionId = "session-1" },
|
||||
streamWriter,
|
||||
ContextWithAuthorization("Bearer mxgw_operator01_secret"),
|
||||
async (_, writer, _) =>
|
||||
{
|
||||
await writer.WriteAsync(new ActiveAlarmSnapshot());
|
||||
});
|
||||
|
||||
Assert.Single(streamWriter.Messages);
|
||||
}
|
||||
|
||||
private static MxAccessGatewayService CreateService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayRequestIdentityAccessor identityAccessor)
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed class GatewayGrpcScopeResolverTests
|
||||
[InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)]
|
||||
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
|
||||
[InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)]
|
||||
[InlineData(typeof(AcknowledgeAlarmRequest), GatewayScopes.InvokeWrite)]
|
||||
[InlineData(typeof(QueryActiveAlarmsRequest), GatewayScopes.EventsRead)]
|
||||
[InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
|
||||
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
|
||||
namespace MxGateway.Tests.TestSupport;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IConstraintEnforcer"/> for tests that exercise the constraint
|
||||
/// filtering and reply-merging code paths in
|
||||
/// <c>MxAccessGatewayService.ApplyConstraintsAsync</c> and the
|
||||
/// <c>BulkConstraintPlan</c> family. Callers supply predicates that decide
|
||||
/// whether a given tag address or (server, item) handle is denied; recorded
|
||||
/// denials are exposed for assertions.
|
||||
/// </summary>
|
||||
public sealed class PredicateConstraintEnforcer : IConstraintEnforcer
|
||||
{
|
||||
/// <summary>Deny predicate keyed on tag address (returns true to deny).</summary>
|
||||
public Func<string, bool> DenyTag { get; init; } = _ => false;
|
||||
|
||||
/// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary>
|
||||
public Func<int, int, bool> DenyReadHandle { get; init; } = (_, _) => false;
|
||||
|
||||
/// <summary>Deny predicate keyed on (serverHandle, itemHandle) (returns true to deny).</summary>
|
||||
public Func<int, int, bool> DenyWriteHandle { get; init; } = (_, _) => false;
|
||||
|
||||
/// <summary>Recorded denial messages — (commandKind, target) tuples.</summary>
|
||||
public List<(string CommandKind, string Target)> RecordedDenials { get; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (DenyTag(tagAddress))
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(
|
||||
new ConstraintFailure("read-tag", $"Read denied for tag '{tagAddress}'."));
|
||||
}
|
||||
|
||||
return Task.FromResult<ConstraintFailure?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (DenyReadHandle(serverHandle, itemHandle))
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(
|
||||
new ConstraintFailure("read-handle", $"Read denied for handle {itemHandle}."));
|
||||
}
|
||||
|
||||
return Task.FromResult<ConstraintFailure?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (DenyWriteHandle(serverHandle, itemHandle))
|
||||
{
|
||||
return Task.FromResult<ConstraintFailure?>(
|
||||
new ConstraintFailure("write-handle", $"Write denied for handle {itemHandle}."));
|
||||
}
|
||||
|
||||
return Task.FromResult<ConstraintFailure?>(null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RecordDenialAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
string commandKind,
|
||||
string target,
|
||||
ConstraintFailure failure,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RecordedDenials.Add((commandKind, target));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user