diff --git a/docs/GatewayTesting.md b/docs/GatewayTesting.md index 493493e..6cfb81f 100644 --- a/docs/GatewayTesting.md +++ b/docs/GatewayTesting.md @@ -293,6 +293,18 @@ path and writes a JSON report under `artifacts/e2e/`: write command is rejected — e.g. against a gateway whose worker predates write support (`MxAccessCommandExecutor` returning `InvalidRequest` for `Write`/`Write2`/`WriteSecured`/`WriteSecured2`). +8. **Alarm feed + acknowledge** — *opt-in (`-VerifyAlarms`).* Runs after the + stream phase. Exercises the two session-less alarm subcommands against the + gateway's central alarm monitor: `stream-alarms` reads a bounded slice of + the feed (`-AlarmStreamMax`, default 1 — the feed's first message always + arrives immediately, whereas later ones depend on live transitions) and + asserts at least one `AlarmFeedMessage`; `acknowledge-alarm` acknowledges + `-AlarmReference` (default `Galaxy!TestArea.TestMachine_001.TestAlarm001`) + and asserts the RPC round-trips. The native ack outcome is not asserted — + it depends on whether that alarm is currently active. + + It is opt-in because it depends on the gateway's central alarm monitor + being enabled (`MxGateway:Alarms:Enabled`) and a live alarm provider. Each client CLI is driven through one long-lived `batch` process. Every CLI exposes a `batch` subcommand: a process that reads one command line from stdin, @@ -329,6 +341,8 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -SkipB # Write round-trip (opt-in): point at a writable scalar attribute and its # value type. powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyWrite -WriteAttribute TestChangingInt -WriteType int32 +# Alarm feed + acknowledge (opt-in): needs MxGateway:Alarms:Enabled on the gateway. +powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -VerifyAlarms -AlarmReference "Galaxy!TestArea.TestMachine_001.TestAlarm001" # Auth rejection: also assert an insufficient-scope key is denied. powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1 -RejectScopeApiKeyEnv MXGATEWAY_READONLY_API_KEY # Run all five clients concurrently as isolated child processes. diff --git a/scripts/run-client-e2e-tests.ps1 b/scripts/run-client-e2e-tests.ps1 index 6a5e5c5..606c696 100644 --- a/scripts/run-client-e2e-tests.ps1 +++ b/scripts/run-client-e2e-tests.ps1 @@ -7,7 +7,9 @@ Drives the .NET, Go, Rust, Python, and Java client CLIs against a running gateway + worker. For each language the script exercises session open/close, register, bulk subscribe/unsubscribe, per-tag add-item/advise, event streaming, a write round-trip with value assertion, error-path (parity) -checks, and API-key auth rejection. +checks, and API-key auth rejection. With -VerifyAlarms it also exercises the +session-less stream-alarms and acknowledge-alarm subcommands against the +gateway's central alarm monitor. Each client CLI is driven through one long-lived `batch` process: the harness writes one command line to its stdin and reads the JSON result back, so the @@ -60,6 +62,18 @@ param( [string]$WriteType = "int32", [int]$WriteValueBase = 424200, [int]$WriteEchoMaxEvents = 200, + # Alarm feed + acknowledge coverage. Opt-in because it depends on the + # gateway's central alarm monitor being enabled (MxGateway:Alarms:Enabled) + # and a live alarm provider: stream-alarms reads the monitor's snapshot and + # acknowledge-alarm acknowledges -AlarmReference. Both RPCs are session-less + # — they exercise the gateway's always-on monitor, not a client session. + [switch]$VerifyAlarms, + [string]$AlarmReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001", + # Messages to read from the central alarm feed. 1 is enough to confirm the + # subcommand round-trips: the feed's first message (an active-alarm + # snapshot, or snapshot-complete when no alarms are active) always arrives + # immediately, whereas later messages depend on live alarm transitions. + [int]$AlarmStreamMax = 1, # Error-path (parity) checks. [switch]$SkipParity, # API-key auth rejection checks. @@ -118,6 +132,10 @@ if ($WriteEchoMaxEvents -lt 1) { throw "WriteEchoMaxEvents must be greater than zero." } +if ($AlarmStreamMax -lt 1) { + throw "AlarmStreamMax must be greater than zero." +} + foreach ($client in $Clients) { if ($validClients -notcontains $client) { throw "Unsupported client '$client'. Supported clients: $($validClients -join ', ')." @@ -327,6 +345,25 @@ function Get-StreamEvents { } } +# Counts the messages in a stream-alarms reply. The CLIs shape the aggregate +# JSON differently: .NET nests them under `alarms`, Rust under `messages` with +# a `messageCount`, Python under `messages`; Go and Java emit one AlarmFeedMessage +# object per line (Read-JsonObject collapses NDJSON into a bare array). +function Get-AlarmMessageCount { + param( + [string]$Client, + [object]$Json + ) + + switch ($Client) { + "dotnet" { return @($Json.alarms).Count } + "go" { return @($Json).Count } + "rust" { return [int]$Json.messageCount } + "python" { return @($Json.messages).Count } + "java" { return @($Json).Count } + } +} + function Get-PropertyValue { param( [object]$Object, @@ -564,6 +601,13 @@ function Get-ClientCommand { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { $arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("--max-events", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } @@ -600,6 +644,13 @@ function Get-ClientCommand { $arguments += @("-session-id", $Values.sessionId, "-server-handle", "$($Values.serverHandle)", "-item-handle", "$($Values.itemHandle)", "-type", $Values.valueType, "-value", $Values.value) } elseif ($Operation -eq "stream-events") { $arguments += @("-session-id", $Values.sessionId, "-limit", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("-limit", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("-filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("-reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("-comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("-operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("-session-id", $Values.sessionId) } @@ -637,6 +688,13 @@ function Get-ClientCommand { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--value-type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { $arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("--max-events", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } @@ -673,6 +731,13 @@ function Get-ClientCommand { $arguments += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { $arguments += @("--session-id", $Values.sessionId, "--max-events", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout") + } elseif ($Operation -eq "stream-alarms") { + $arguments += @("--max-messages", "$streamMaxEvents", "--timeout", "$pythonStreamTimeout") + if ($Values.ContainsKey("filterPrefix")) { $arguments += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $arguments += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $arguments += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $arguments += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $arguments += @("--session-id", $Values.sessionId) } @@ -712,6 +777,13 @@ function Get-ClientCommand { $cliArgs += @("--session-id", $Values.sessionId, "--server-handle", "$($Values.serverHandle)", "--item-handle", "$($Values.itemHandle)", "--type", $Values.valueType, "--value", $Values.value) } elseif ($Operation -eq "stream-events") { $cliArgs += @("--session-id", $Values.sessionId, "--limit", "$streamMaxEvents") + } elseif ($Operation -eq "stream-alarms") { + $cliArgs += @("--limit", "$streamMaxEvents") + if ($Values.ContainsKey("filterPrefix")) { $cliArgs += @("--filter-prefix", $Values.filterPrefix) } + } elseif ($Operation -eq "acknowledge-alarm") { + $cliArgs += @("--reference", $Values.alarmReference) + if ($Values.ContainsKey("comment")) { $cliArgs += @("--comment", $Values.comment) } + if ($Values.ContainsKey("operator")) { $cliArgs += @("--operator", $Values.operator) } } elseif ($Operation -eq "close-session") { $cliArgs += @("--session-id", $Values.sessionId) } @@ -801,6 +873,36 @@ function Get-DryRunReply { default { return [pscustomobject]@{ events = $events } } } } + "stream-alarms" { + # Synthesize an active-alarm snapshot followed by the + # snapshot-complete sentinel. The reply is shaped per client: + # Go and Java emit one message object per line (Read-JsonObject + # collapses NDJSON to a bare array), Rust aggregates under + # `messages` with a `messageCount`, Python under `messages`, and + # .NET under `alarms`. + $activeAlarm = [pscustomobject]@{ + activeAlarm = [pscustomobject]@{ + alarmFullReference = "Galaxy!TestArea.TestMachine_001.TestAlarm001" + currentState = "ALARM_CONDITION_STATE_ACTIVE" + severity = 500 + } + } + $snapshotComplete = [pscustomobject]@{ snapshotComplete = $true } + $messages = @($activeAlarm, $snapshotComplete) + switch ($Client) { + "go" { return ,$messages } + "java" { return ,$messages } + "rust" { return [pscustomobject]@{ messageCount = $messages.Count; messages = $messages } } + "dotnet" { return [pscustomobject]@{ alarms = $messages } } + default { return [pscustomobject]@{ messages = $messages } } + } + } + "acknowledge-alarm" { + return [pscustomobject]@{ + rawReply = [pscustomobject]@{ hresult = 0; diagnosticMessage = "dry-run ack" } + reply = [pscustomobject]@{ hresult = 0 } + } + } default { return [pscustomobject]@{ ok = $true; reply = [pscustomobject]@{} } } } } @@ -1053,6 +1155,7 @@ function Invoke-ClientFlow { addedItems = @() eventCount = 0 write = $null + alarms = $null parity = @() auth = @() closed = $false @@ -1285,6 +1388,35 @@ function Invoke-ClientFlow { } } + # --- Alarm feed + acknowledge ------------------------------------- + # Session-less RPCs against the gateway's always-on central alarm + # monitor. Opt-in (-VerifyAlarms) because it needs the monitor enabled + # (MxGateway:Alarms:Enabled) and a live alarm provider. + if ($VerifyAlarms) { + $alarmStreamJson = Invoke-ClientOperation -Client $Client -Operation "stream-alarms" -Values @{ + maxEvents = $AlarmStreamMax + } + $alarmMessageCount = Get-AlarmMessageCount -Client $Client -Json $alarmStreamJson + if ($alarmMessageCount -lt 1) { + throw "The $Client stream-alarms command returned no alarm-feed messages." + } + + # The acknowledge round-trips against the central monitor; the + # native ack outcome depends on whether the referenced alarm is + # currently active, so only the RPC's success is asserted here. + Invoke-ClientOperation -Client $Client -Operation "acknowledge-alarm" -Values @{ + alarmReference = $AlarmReference + comment = "e2e-matrix" + operator = "mxgw-e2e" + } | Out-Null + + $clientResult.alarms = [ordered]@{ + streamMessageCount = $alarmMessageCount + acknowledgeReference = $AlarmReference + acknowledged = $true + } + } + # --- Error-path (parity) checks ----------------------------------- # MXAccess parity: an invalid item handle and an unknown session must # both be rejected rather than silently succeeding. @@ -1391,6 +1523,8 @@ function Get-ChildArgumentList { "-WriteType", $WriteType, "-WriteValueBase", "$WriteValueBase", "-WriteEchoMaxEvents", "$WriteEchoMaxEvents", + "-AlarmReference", $AlarmReference, + "-AlarmStreamMax", "$AlarmStreamMax", "-ReportPath", $ChildReportPath, "-EmitReport" ) @@ -1400,6 +1534,7 @@ function Get-ChildArgumentList { if ($SkipStream) { $childArgs += "-SkipStream" } if ($SkipBulk) { $childArgs += "-SkipBulk" } if ($VerifyWrite) { $childArgs += "-VerifyWrite" } + if ($VerifyAlarms) { $childArgs += "-VerifyAlarms" } if ($SkipParity) { $childArgs += "-SkipParity" } if ($SkipAuth) { $childArgs += "-SkipAuth" } if ($DryRun) { $childArgs += "-DryRun" } @@ -1479,6 +1614,7 @@ if ($Parallel -and $Clients.Count -gt 1) { skipStream = [bool]$SkipStream skipBulk = [bool]$SkipBulk verifyWrite = [bool]$VerifyWrite + verifyAlarms = [bool]$VerifyAlarms skipParity = [bool]$SkipParity skipAuth = [bool]$SkipAuth writeAttribute = $WriteAttribute @@ -1540,6 +1676,7 @@ $run = [ordered]@{ skipStream = [bool]$SkipStream skipBulk = [bool]$SkipBulk verifyWrite = [bool]$VerifyWrite + verifyAlarms = [bool]$VerifyAlarms skipParity = [bool]$SkipParity skipAuth = [bool]$SkipAuth writeAttribute = $WriteAttribute