fix: resolve code-review findings (locally verified)
Server-054/055/056, Contracts-020/021/022, Tests-036/038/039, IntegrationTests-030/031/032 (+033 deferred to live rig), Client.Dotnet-026/028/029 (+027 won't-fix), Client.Go-030..034, Client.Python-032..036, Client.Rust-033..038. Key fix: SessionEventDistributor orphaned a subscriber that registered after the pump completed but before disposal (Server-056) -> register paths now complete late registrants under _lifecycleLock; regression test added. The racy dashboard-mirror gRPC test made deterministic (Tests-039). Verified green locally: gateway Tests targeted classes (GatewaySession, SessionEventDistributor, GatewayOptionsValidator, ProtobufContractRoundTrip, GatewaySessionDashboardMirror) + dotnet/go/python/rust client suites.
This commit is contained in:
@@ -3,6 +3,13 @@ using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Client.Cli;
|
||||
|
||||
/// <summary>
|
||||
/// Transport seam used by the CLI to drive gateway and Galaxy Repository
|
||||
/// RPCs, exposing only the operations the command surface needs. The
|
||||
/// production binding is <see cref="MxGatewayCliClientAdapter"/> (wrapping a
|
||||
/// real <c>MxGatewayClient</c>); tests substitute an in-memory fake so the
|
||||
/// command routing can be exercised without a live gateway.
|
||||
/// </summary>
|
||||
public interface IMxGatewayCliClient : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -153,7 +153,10 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
string? apiKey = arguments.GetOptional("api-key");
|
||||
// Client.Dotnet-028: redact the *effective* key — from --api-key or the
|
||||
// --api-key-env environment variable — so an env-var-sourced key echoed
|
||||
// in a transport error never reaches stderr unredacted.
|
||||
string? apiKey = TryResolveApiKey(arguments);
|
||||
string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
|
||||
|
||||
if (forceJsonErrors || arguments.HasFlag("json"))
|
||||
@@ -278,6 +281,29 @@ public static class MxGatewayClientCli
|
||||
}
|
||||
|
||||
private static string ResolveApiKey(CliArguments arguments)
|
||||
{
|
||||
string? apiKey = TryResolveApiKey(arguments);
|
||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
|
||||
?? "MXGATEWAY_API_KEY";
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective API key from <c>--api-key</c> or, failing that,
|
||||
/// the <c>--api-key-env</c>-named environment variable (default
|
||||
/// <c>MXGATEWAY_API_KEY</c>), returning <see langword="null"/> when neither
|
||||
/// is set. Unlike <see cref="ResolveApiKey"/> this never throws, so the
|
||||
/// error-redaction catch block can strip the env-var-sourced key from
|
||||
/// output (Client.Dotnet-028) without re-raising on the absent-key path.
|
||||
/// </summary>
|
||||
private static string? TryResolveApiKey(CliArguments arguments)
|
||||
{
|
||||
string? apiKey = arguments.GetOptional("api-key");
|
||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||
@@ -288,14 +314,7 @@ public static class MxGatewayClientCli
|
||||
string apiKeyEnvironmentName = arguments.GetOptional("api-key-env")
|
||||
?? "MXGATEWAY_API_KEY";
|
||||
|
||||
apiKey = Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
||||
if (!string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Gateway API key is required. Pass --api-key or set {apiKeyEnvironmentName}.");
|
||||
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
|
||||
}
|
||||
|
||||
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
|
||||
@@ -303,7 +322,7 @@ public static class MxGatewayClientCli
|
||||
var cancellation = new CancellationTokenSource();
|
||||
// Long-running streaming commands run until Ctrl+C / cancellation by default;
|
||||
// a caller-supplied --timeout still applies if present.
|
||||
bool isLongRunning = command is "galaxy-watch";
|
||||
bool isLongRunning = command is "galaxy-watch" or "galaxy-browse";
|
||||
string? rawTimeout = arguments.GetOptional("timeout");
|
||||
if (isLongRunning && string.IsNullOrWhiteSpace(rawTimeout))
|
||||
{
|
||||
|
||||
@@ -106,6 +106,48 @@ public sealed class MxGatewayClientCliTests
|
||||
Assert.Contains("[redacted]", error.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Client.Dotnet-028: when the API key is sourced from the env var
|
||||
/// (<c>--api-key-env</c> path, no <c>--api-key</c> flag), the error-redaction
|
||||
/// catch block must still resolve and redact the effective key. Regression
|
||||
/// guard for the catch block reverting to <c>GetOptional("api-key")</c> only,
|
||||
/// which is null on the env-var path and leaves the key unredacted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable()
|
||||
{
|
||||
const string envName = "MXGATEWAY_TEST_API_KEY_028";
|
||||
const string secret = "env-sourced-secret-key";
|
||||
string? previousKey = Environment.GetEnvironmentVariable(envName);
|
||||
Environment.SetEnvironmentVariable(envName, secret);
|
||||
|
||||
try
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
using var error = new StringWriter();
|
||||
|
||||
int exitCode = await MxGatewayClientCli.RunAsync(
|
||||
[
|
||||
"open-session",
|
||||
"--endpoint",
|
||||
"http://localhost:5000",
|
||||
"--api-key-env",
|
||||
envName,
|
||||
],
|
||||
output,
|
||||
error,
|
||||
_ => throw new InvalidOperationException($"boom {secret}"));
|
||||
|
||||
Assert.Equal(1, exitCode);
|
||||
Assert.DoesNotContain(secret, error.ToString());
|
||||
Assert.Contains("[redacted]", error.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable(envName, previousKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||
|
||||
@@ -12,6 +12,12 @@ public sealed class LazyBrowseNode
|
||||
{
|
||||
private readonly GalaxyRepositoryClient _client;
|
||||
private readonly BrowseChildrenOptions _options;
|
||||
// Client.Dotnet-027 (Won't Fix): this gate is used only via WaitAsync/Release and
|
||||
// never via AvailableWaitHandle, so SemaphoreSlim allocates no kernel wait handle —
|
||||
// it holds no unmanaged/OS handle to leak. It is pure managed memory whose lifetime
|
||||
// is the node's, so the type is intentionally not IDisposable: making it disposable
|
||||
// would push per-node disposal onto every tree consumer (thousands of nodes) for no
|
||||
// resource benefit.
|
||||
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
||||
|
||||
// Published once, under _expandLock, when expansion completes. Lock-free readers
|
||||
|
||||
@@ -247,24 +247,78 @@ one line per event in text mode or one JSON object per event with `-json`.
|
||||
The `mxgw-go` CLI emits JSON with redacted API keys for commands that connect to
|
||||
the gateway:
|
||||
|
||||
### Subcommand reference
|
||||
|
||||
Every subcommand wired into the CLI. All accept the common flags
|
||||
(`-endpoint`, `-plaintext`, `-api-key` / `-api-key-env`, `-ca-cert`,
|
||||
`-server-name-override`, `-call-timeout`) and most accept `-json`.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `version` | Print client/contract versions. |
|
||||
| `open-session` | Open a gateway session and print its id. |
|
||||
| `close-session` | Close a session by id. |
|
||||
| `ping` | Round-trip a `PING` command (`-session-id`, `-message`). |
|
||||
| `register` | Register a client name on a session (`-session-id`, `-client-name`). |
|
||||
| `add-item` | Add an item handle (`-session-id`, `-server-handle`, `-item`). |
|
||||
| `advise` | Advise (subscribe) one item (`-session-id`, `-server-handle`, `-item-handle`). |
|
||||
| `subscribe-bulk` | Advise many items in one call. |
|
||||
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
|
||||
| `read-bulk` | Read snapshots for many item handles in one call. |
|
||||
| `write` | Write one value (`-type`, `-value`). |
|
||||
| `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
|
||||
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
|
||||
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
|
||||
| `write-secured2-bulk` | Secured bulk write with a shared timestamp. |
|
||||
| `bench-read-bulk` | Throughput benchmark (`-duration-seconds`, `-warmup-seconds`, `-bulk-size`). |
|
||||
| `stream-events` | Stream item-value events for a session (`-session-id`, `-limit`). |
|
||||
| `stream-alarms` | Stream the alarm feed (`-filter-prefix`, `-limit`). |
|
||||
| `acknowledge-alarm` | Acknowledge an alarm reference. |
|
||||
| `smoke` | End-to-end smoke workflow against one item. |
|
||||
| `galaxy-test-connection` | Probe the Galaxy Repository RPC connection. |
|
||||
| `galaxy-last-deploy` | Print the most recent deploy event. |
|
||||
| `galaxy-discover` | Discover deployed objects. |
|
||||
| `galaxy-watch` | Stream deploy events until Ctrl+C or `-limit`. |
|
||||
| `galaxy-browse` | Lazy/eager browse of the Galaxy object tree. |
|
||||
| `batch` | Read commands from stdin (see below). |
|
||||
|
||||
```powershell
|
||||
go run ./cmd/mxgw-go version -json
|
||||
go run ./cmd/mxgw-go open-session -endpoint localhost:5000 -plaintext -json
|
||||
go run ./cmd/mxgw-go ping -session-id <id> -plaintext -json
|
||||
go run ./cmd/mxgw-go register -session-id <id> -client-name mxgw-go -plaintext -json
|
||||
go run ./cmd/mxgw-go add-item -session-id <id> -server-handle 1 -item Area001.Tag.Value -plaintext -json
|
||||
go run ./cmd/mxgw-go advise -session-id <id> -server-handle 1 -item-handle 1 -plaintext -json
|
||||
go run ./cmd/mxgw-go write -session-id <id> -server-handle 1 -item-handle 1 -type int32 -value 123 -plaintext -json
|
||||
go run ./cmd/mxgw-go write-bulk -session-id <id> -server-handle 1 -item-handles 1,2 -values 10,20 -type int32 -plaintext -json
|
||||
go run ./cmd/mxgw-go read-bulk -session-id <id> -item-handles 1,2 -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-events -session-id <id> -plaintext -json
|
||||
go run ./cmd/mxgw-go stream-alarms -plaintext -json
|
||||
go run ./cmd/mxgw-go smoke -item Area001.Tag.Value -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-test-connection -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-last-deploy -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-discover -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-watch -plaintext -json
|
||||
go run ./cmd/mxgw-go galaxy-browse -plaintext -json
|
||||
```
|
||||
|
||||
Use `-api-key-env MXGATEWAY_API_KEY` or `-api-key <key>` when authentication is
|
||||
enabled. CLI output redacts the key value and never writes the raw secret.
|
||||
|
||||
### `batch` mode
|
||||
|
||||
`batch` reads one command line at a time from stdin and dispatches each through
|
||||
the same routing as the standalone subcommands; it is the interface the
|
||||
cross-language E2E harness drives. After every command's output it writes the
|
||||
end-of-result sentinel line `__MXGW_BATCH_EOR__` to stdout and flushes, so the
|
||||
harness can frame each result. Blank/whitespace-only lines are skipped; only
|
||||
stdin EOF ends the session. Command errors are serialised as a JSON object
|
||||
(`{"error":...,"type":"error"}`) to stdout (not stderr) and still followed by the
|
||||
sentinel, so a failing command does not abort the batch. The input scanner
|
||||
buffer is widened to 16 MiB so a single long command line (e.g. a bulk write with
|
||||
thousands of handles) does not trip bufio's default 64 KiB token-too-long limit;
|
||||
a line that still exceeds 16 MiB surfaces as a framed error and ends the session.
|
||||
|
||||
Use TLS options for a secured gateway:
|
||||
|
||||
```powershell
|
||||
|
||||
@@ -837,7 +837,14 @@ func runStreamEvents(ctx context.Context, args []string, stdout, stderr io.Write
|
||||
defer client.Close()
|
||||
|
||||
session := mxgateway.NewSessionForID(client, *sessionID)
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
|
||||
// Ctrl+C on a long-running stream-events command cancels the gRPC stream
|
||||
// cleanly (the gateway sees codes.Canceled rather than a torn TCP
|
||||
// connection) and the deferred subscription.Close()/client.Close() run.
|
||||
signalCtx, stopSignals := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stopSignals()
|
||||
|
||||
streamCtx, cancelStream := context.WithCancel(signalCtx)
|
||||
defer cancelStream()
|
||||
subscription, err := session.SubscribeEventsAfter(streamCtx, *after)
|
||||
if err != nil {
|
||||
@@ -1035,15 +1042,17 @@ func runSmoke(ctx context.Context, args []string, stdout, stderr io.Writer) erro
|
||||
}
|
||||
|
||||
func closeSmokeSession(ctx context.Context, session *mxgateway.Session, primaryErr error) error {
|
||||
closeCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
// Compute the close timeout once so a single context (and a single
|
||||
// deferred cancel) is allocated: default 5s, shortened to the caller's
|
||||
// remaining deadline when that is sooner.
|
||||
closeTimeout := 5 * time.Second
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if until := time.Until(deadline); until > 0 && until < 5*time.Second {
|
||||
cancel()
|
||||
closeCtx, cancel = context.WithTimeout(context.Background(), until)
|
||||
defer cancel()
|
||||
if until := time.Until(deadline); until > 0 && until < closeTimeout {
|
||||
closeTimeout = until
|
||||
}
|
||||
}
|
||||
closeCtx, cancel := context.WithTimeout(context.Background(), closeTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, closeErr := session.Close(closeCtx)
|
||||
if primaryErr != nil {
|
||||
@@ -1490,6 +1499,12 @@ func runGalaxyWatch(ctx context.Context, args []string, stdout, stderr io.Writer
|
||||
count++
|
||||
if *limit > 0 && count >= *limit {
|
||||
cancelStream()
|
||||
// Drain so the WatchDeployEvents goroutine can exit instead
|
||||
// of blocking on a send into the buffered events channel
|
||||
// while the deferred client.Close() tears the stream down
|
||||
// underneath it (mirrors the signal-cancel branch below).
|
||||
for range events {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case streamErr, ok := <-errs:
|
||||
|
||||
@@ -537,3 +537,43 @@ func TestRunBatchHandlesLongCommandLine(t *testing.T) {
|
||||
t.Fatalf("EOR sentinel count = %d, want 2 (one per command, even when first is too long); out length = %d", count, len(out))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunBenchReadBulkRejectsNonPositiveDuration pins the -duration-seconds
|
||||
// positivity guard so the bench window cannot be configured to zero/negative.
|
||||
func TestRunBenchReadBulkRejectsNonPositiveDuration(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{"bench-read-bulk", "-duration-seconds", "0"}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "duration-seconds must be positive") {
|
||||
t.Fatalf("bench-read-bulk -duration-seconds 0 error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunStreamEventsRequiresSessionID pins the session-id guard so stream-events
|
||||
// fails fast before dialing when no session id is supplied.
|
||||
func TestRunStreamEventsRequiresSessionID(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{"stream-events", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "session-id is required") {
|
||||
t.Fatalf("stream-events without -session-id error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
|
||||
// guard so a write-bulk with unequal item-handles / values counts fails fast
|
||||
// before any dial.
|
||||
func TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
err := runWithIO(t.Context(), []string{
|
||||
"write-bulk",
|
||||
"-session-id", "s1",
|
||||
"-server-handle", "1",
|
||||
"-item-handles", "1,2",
|
||||
"-values", "10",
|
||||
"-type", "int32",
|
||||
"-plaintext",
|
||||
"-api-key", "test",
|
||||
}, &stdout, &stderr)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match values count") {
|
||||
t.Fatalf("write-bulk mismatched handles/values error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,19 +140,21 @@ service requires the `metadata:read` scope on the API key.
|
||||
|
||||
### Browsing lazily
|
||||
|
||||
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
|
||||
For UI trees or OPC UA bridges, use `browse_children_raw` to walk one level at a
|
||||
time instead of loading the full hierarchy with `discover_hierarchy`. Pass an
|
||||
empty request for root objects; subsequent calls set `parent_gobject_id`,
|
||||
`parent_tag_name`, or `parent_contained_path`. Filter fields match
|
||||
`DiscoverHierarchy`. Each response pairs `children` with `child_has_children` so
|
||||
you know which nodes to expand. See
|
||||
you know which nodes to expand. Most callers should prefer the higher-level
|
||||
`browse()` / `LazyBrowseNode` walker below; `browse_children_raw` is the
|
||||
low-level escape hatch for direct page-token control. See
|
||||
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
|
||||
request and filter semantics.
|
||||
|
||||
```python
|
||||
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb2
|
||||
|
||||
reply = await galaxy.browse_children(galaxy_pb2.BrowseChildrenRequest())
|
||||
reply = await galaxy.browse_children_raw(galaxy_pb2.BrowseChildrenRequest())
|
||||
for child, has_children in zip(reply.children, reply.child_has_children):
|
||||
print(child.tag_name, "expand=" + str(has_children))
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from .auth import ApiKey, auth_metadata
|
||||
from .client import GatewayClient
|
||||
from .galaxy import GalaxyRepositoryClient
|
||||
from .galaxy import GalaxyRepositoryClient, LazyBrowseNode
|
||||
from .generated.galaxy_repository_pb2 import (
|
||||
DeployEvent,
|
||||
GalaxyAttribute,
|
||||
@@ -19,19 +19,21 @@ from .errors import (
|
||||
MxGatewayTransportError,
|
||||
MxGatewayWorkerError,
|
||||
)
|
||||
from .options import ClientOptions
|
||||
from .options import BrowseChildrenOptions, ClientOptions
|
||||
from .session import Session
|
||||
from .values import MxValueView, from_mx_value, to_mx_value
|
||||
from .version import __version__
|
||||
|
||||
__all__ = [
|
||||
"ApiKey",
|
||||
"BrowseChildrenOptions",
|
||||
"ClientOptions",
|
||||
"DeployEvent",
|
||||
"GalaxyAttribute",
|
||||
"GalaxyObject",
|
||||
"GalaxyRepositoryClient",
|
||||
"GatewayClient",
|
||||
"LazyBrowseNode",
|
||||
"MxAccessError",
|
||||
"MxGatewayAuthenticationError",
|
||||
"MxGatewayAuthorizationError",
|
||||
|
||||
@@ -769,7 +769,7 @@ def _build_write_bulk_entries(kwargs: dict[str, Any]):
|
||||
"""
|
||||
|
||||
handles = _parse_int_list(kwargs["item_handles"])
|
||||
value_texts = _parse_string_list(kwargs["values"])
|
||||
value_texts = _parse_string_list(kwargs["values"], param_hint="--values")
|
||||
if len(handles) != len(value_texts):
|
||||
raise click.UsageError(
|
||||
f"item-handles count ({len(handles)}) does not match values count ({len(value_texts)})",
|
||||
@@ -1045,8 +1045,7 @@ async def _write2(**kwargs: Any) -> dict[str, Any]:
|
||||
async def _smoke(**kwargs: Any) -> dict[str, Any]:
|
||||
async with await _connect(kwargs) as client:
|
||||
session = await client.open_session(client_session_name=kwargs["client_name"])
|
||||
closed = False
|
||||
try:
|
||||
async with session:
|
||||
server_handle = await session.register(kwargs["client_name"])
|
||||
item_handle = await session.add_item(server_handle, kwargs["item"])
|
||||
await session.advise(server_handle, item_handle)
|
||||
@@ -1061,9 +1060,6 @@ async def _smoke(**kwargs: Any) -> dict[str, Any]:
|
||||
"itemHandle": item_handle,
|
||||
"events": [_message_dict(event) for event in events],
|
||||
}
|
||||
finally:
|
||||
if not closed:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def _galaxy_test_connection(**kwargs: Any) -> dict[str, Any]:
|
||||
@@ -1487,10 +1483,10 @@ def _parse_datetime(raw_value: str) -> datetime:
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_string_list(raw_value: str) -> list[str]:
|
||||
def _parse_string_list(raw_value: str, param_hint: str = "--items") -> list[str]:
|
||||
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if not values:
|
||||
raise click.BadParameter("at least one item is required", param_hint="--items")
|
||||
raise click.BadParameter("at least one item is required", param_hint=param_hint)
|
||||
return values
|
||||
|
||||
|
||||
@@ -1498,7 +1494,12 @@ def _parse_int_list(raw_value: str) -> list[int]:
|
||||
values = [item.strip() for item in raw_value.split(",") if item.strip()]
|
||||
if not values:
|
||||
raise click.BadParameter("at least one item handle is required", param_hint="--item-handles")
|
||||
return [int(item) for item in values]
|
||||
try:
|
||||
return [int(item) for item in values]
|
||||
except ValueError as exc:
|
||||
raise click.BadParameter(
|
||||
f"item handles must be integers: {exc}", param_hint="--item-handles"
|
||||
) from exc
|
||||
|
||||
|
||||
def _message_dict(message: Any) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Regression tests for Client.Python-032..036.
|
||||
|
||||
Each test corresponds to a finding from the 2026-06-16 re-review. Tests are
|
||||
TDD-first — they fail against the pre-fix source and pass against the fixed
|
||||
source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from zb_mom_ww_mxgateway_cli import commands as cli_commands
|
||||
from zb_mom_ww_mxgateway_cli.commands import _parse_int_list, _parse_string_list
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-032 — `_smoke` must not carry the dead `closed` guard variable.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_smoke_does_not_carry_dead_closed_guard() -> None:
|
||||
"""`_smoke` must not reintroduce the dead `closed = False` / `if not closed`
|
||||
guard removed by Client.Python-004. The variable is never reassigned, so the
|
||||
guard misleads readers into expecting an early-close path that never exists.
|
||||
"""
|
||||
|
||||
source = inspect.getsource(cli_commands._smoke)
|
||||
assert "closed = False" not in source, (
|
||||
"_smoke must not reintroduce the dead `closed = False` variable"
|
||||
)
|
||||
assert "if not closed:" not in source, (
|
||||
"_smoke must not reintroduce the dead `if not closed:` guard"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-033 — `_parse_string_list` param_hint must reflect the caller.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_string_list_default_param_hint_is_items() -> None:
|
||||
with pytest.raises(click.BadParameter) as exc:
|
||||
_parse_string_list("")
|
||||
assert exc.value.param_hint == "--items"
|
||||
|
||||
|
||||
def test_parse_string_list_accepts_caller_supplied_param_hint() -> None:
|
||||
"""The write-bulk family passes `--values`, so an empty value must surface a
|
||||
`--values` hint, not the irrelevant `--items` default.
|
||||
"""
|
||||
|
||||
with pytest.raises(click.BadParameter) as exc:
|
||||
_parse_string_list("", param_hint="--values")
|
||||
assert exc.value.param_hint == "--values"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-034 — `_parse_int_list` must re-raise non-numeric tokens as
|
||||
# click.BadParameter, not a raw ValueError traceback.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_int_list_non_numeric_raises_bad_parameter() -> None:
|
||||
with pytest.raises(click.BadParameter) as exc:
|
||||
_parse_int_list("10,abc")
|
||||
assert exc.value.param_hint == "--item-handles"
|
||||
|
||||
|
||||
def test_parse_int_list_happy_path() -> None:
|
||||
assert _parse_int_list("10, 20 ,30") == [10, 20, 30]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-035 — public browse types must be re-exported from the package
|
||||
# root.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_browse_children_options_is_exported_from_package_root() -> None:
|
||||
import zb_mom_ww_mxgateway as pkg
|
||||
|
||||
assert hasattr(pkg, "BrowseChildrenOptions")
|
||||
assert "BrowseChildrenOptions" in pkg.__all__
|
||||
|
||||
|
||||
def test_lazy_browse_node_is_exported_from_package_root() -> None:
|
||||
import zb_mom_ww_mxgateway as pkg
|
||||
|
||||
assert hasattr(pkg, "LazyBrowseNode")
|
||||
assert "LazyBrowseNode" in pkg.__all__
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client.Python-036 — README "Browsing lazily" example must reference a method
|
||||
# that actually exists on GalaxyRepositoryClient.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _readme_path() -> Path:
|
||||
return Path(__file__).resolve().parent.parent / "README.md"
|
||||
|
||||
|
||||
def test_galaxy_client_exposes_browse_children_raw() -> None:
|
||||
"""Guard the method name the README example depends on so future renames
|
||||
break this test rather than only failing at runtime in user code.
|
||||
"""
|
||||
|
||||
from zb_mom_ww_mxgateway import GalaxyRepositoryClient
|
||||
|
||||
assert hasattr(GalaxyRepositoryClient, "browse_children_raw")
|
||||
|
||||
|
||||
def test_readme_browse_example_uses_existing_method() -> None:
|
||||
"""The README's `galaxy.<method>(...BrowseChildrenRequest...)` call must name
|
||||
a method that exists on GalaxyRepositoryClient.
|
||||
"""
|
||||
|
||||
from zb_mom_ww_mxgateway import GalaxyRepositoryClient
|
||||
|
||||
text = _readme_path().read_text(encoding="utf-8")
|
||||
called = set(re.findall(r"galaxy\.([A-Za-z_][A-Za-z0-9_]*)\s*\(", text))
|
||||
assert called, "README must contain at least one galaxy.<method>(...) example"
|
||||
for method in called:
|
||||
assert hasattr(GalaxyRepositoryClient, method), (
|
||||
f"README references galaxy.{method}() but no such method exists"
|
||||
)
|
||||
@@ -161,7 +161,7 @@ cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5
|
||||
|
||||
### Browsing lazily
|
||||
|
||||
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
|
||||
For UI trees or OPC UA bridges, use `browse_children_raw` to walk one level at a
|
||||
time instead of paging the full hierarchy. Pass a default request for root
|
||||
objects; subsequent calls set `parent_gobject_id`, `parent_tag_name`, or
|
||||
`parent_contained_path`. Filter fields match `discover_hierarchy`. Each response
|
||||
@@ -172,7 +172,7 @@ request and filter semantics.
|
||||
```rust
|
||||
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;
|
||||
|
||||
let reply = galaxy.browse_children(BrowseChildrenRequest::default()).await?.into_inner();
|
||||
let reply = galaxy.browse_children_raw(BrowseChildrenRequest::default()).await?;
|
||||
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
|
||||
println!("{} expand={}", child.tag_name, has_children);
|
||||
}
|
||||
|
||||
@@ -349,8 +349,16 @@ mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-siz
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw batch
|
||||
mxgw galaxy {test-connection,last-deploy-time,discover-hierarchy,watch}
|
||||
mxgw galaxy browse [--parent-gobject-id <id>] [--category-id <id>...] [--template-contains <s>...] [--tag-name-glob <glob>] [--include-attributes] [--alarm-bearing-only] [--historized-only] [--depth <n>] [--json]
|
||||
```
|
||||
|
||||
`galaxy browse` walks the hierarchy one level at a time over the raw
|
||||
`BrowseChildren` paging path. `--depth 0` (the default) prints only the
|
||||
requested level; `--depth N` eagerly expands N additional levels beneath each
|
||||
returned node. `--parent-gobject-id` makes `--depth` a no-op (the parent's
|
||||
children are returned as a single level). Omit `--parent-gobject-id` to browse
|
||||
root objects.
|
||||
|
||||
`batch` reads commands from stdin one per line and dispatches each through
|
||||
the normal subcommand path; the loop terminates only on stdin EOF (blank
|
||||
lines log an empty-EOR-bracketed result and continue) so accidental empty
|
||||
|
||||
@@ -46,8 +46,6 @@ enum Command {
|
||||
Version {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[arg(long)]
|
||||
jsonl: bool,
|
||||
},
|
||||
Ping {
|
||||
#[command(flatten)]
|
||||
@@ -458,9 +456,16 @@ struct ConnectionArgs {
|
||||
endpoint: String,
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
/// Name of the environment variable holding the gateway API key. The
|
||||
/// variable's value must be a full gateway key of the form
|
||||
/// `mxgw_<key-id>_<secret>`; it is forwarded verbatim as the Bearer
|
||||
/// token, so do not point this at an unrelated credential.
|
||||
#[arg(long, default_value = "MXGATEWAY_API_KEY")]
|
||||
api_key_env: String,
|
||||
#[arg(long)]
|
||||
/// Use an unencrypted (plaintext h2c) channel. Mutually exclusive with
|
||||
/// `--tls`; supplying both is rejected so an explicit `--tls` cannot be
|
||||
/// silently downgraded.
|
||||
#[arg(long, conflicts_with = "tls")]
|
||||
plaintext: bool,
|
||||
#[arg(long)]
|
||||
tls: bool,
|
||||
@@ -545,7 +550,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
detail: "batch cannot be nested inside another batch session".to_owned(),
|
||||
});
|
||||
}
|
||||
Command::Version { json, .. } => print_version(json),
|
||||
Command::Version { json } => print_version(json),
|
||||
Command::Ping {
|
||||
connection,
|
||||
message,
|
||||
@@ -1214,6 +1219,24 @@ const BROWSE_PAGE_SIZE: i32 = 500;
|
||||
/// Drive `BrowseChildren` paging by hand for a single parent and return the
|
||||
/// flattened child list. Used by the `browse --parent-gobject-id` path, which
|
||||
/// surfaces one level of children rather than the lazy root-tree walk.
|
||||
/// Record a non-empty `next_page_token` in `seen` and reject a repeat. A
|
||||
/// server that returns the same continuation token twice would loop forever,
|
||||
/// so the second sighting is converted to an `InvalidArgument` error. Extracted
|
||||
/// from [`browse_children_one_level`] so the guard can be unit-tested without a
|
||||
/// network client.
|
||||
fn register_page_token(
|
||||
seen: &mut std::collections::HashSet<String>,
|
||||
token: &str,
|
||||
) -> Result<(), Error> {
|
||||
if !seen.insert(token.to_owned()) {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "page_token".to_owned(),
|
||||
detail: format!("galaxy browse children returned repeated page token `{token}`"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn browse_children_one_level(
|
||||
client: &mut GalaxyClient,
|
||||
parent_gobject_id: i32,
|
||||
@@ -1254,14 +1277,7 @@ async fn browse_children_one_level(
|
||||
if page_token.is_empty() {
|
||||
return Ok(children);
|
||||
}
|
||||
if !seen.insert(page_token.clone()) {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "page_token".to_owned(),
|
||||
detail: format!(
|
||||
"galaxy browse children returned repeated page token `{page_token}`"
|
||||
),
|
||||
});
|
||||
}
|
||||
register_page_token(&mut seen, &page_token)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2337,7 +2353,18 @@ where
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::Cli;
|
||||
use super::{Cli, Command};
|
||||
|
||||
/// Pull the flattened `ConnectionArgs` out of a parsed `ping` command so
|
||||
/// `ConnectionArgs::options()` can be exercised directly.
|
||||
fn connection_from_ping(args: &[&str]) -> super::ConnectionArgs {
|
||||
let mut full = vec!["mxgw", "ping"];
|
||||
full.extend_from_slice(args);
|
||||
match Cli::try_parse_from(full).expect("ping parse").command {
|
||||
Command::Ping { connection, .. } => connection,
|
||||
other => panic!("expected ping command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_json_command() {
|
||||
@@ -2345,6 +2372,36 @@ mod tests {
|
||||
assert!(parsed.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_defaults_to_plaintext() {
|
||||
let options = connection_from_ping(&[]).options();
|
||||
assert!(options.plaintext(), "default channel should be plaintext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_tls_flag_disables_plaintext() {
|
||||
let options = connection_from_ping(&["--tls"]).options();
|
||||
assert!(
|
||||
!options.plaintext(),
|
||||
"--tls must select an encrypted channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_plaintext_flag_selects_plaintext() {
|
||||
let options = connection_from_ping(&["--plaintext"]).options();
|
||||
assert!(options.plaintext());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_rejects_tls_and_plaintext_together() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "ping", "--tls", "--plaintext"]);
|
||||
assert!(
|
||||
parsed.is_err(),
|
||||
"--tls and --plaintext must conflict so TLS cannot be silently downgraded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_write_command() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
@@ -2513,6 +2570,50 @@ mod tests {
|
||||
assert_eq!(summary.mean, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_page_token_accepts_distinct_tokens_and_rejects_repeats() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(super::register_page_token(&mut seen, "tok-1").is_ok());
|
||||
assert!(super::register_page_token(&mut seen, "tok-2").is_ok());
|
||||
|
||||
let repeated = super::register_page_token(&mut seen, "tok-1");
|
||||
match repeated {
|
||||
Err(super::Error::InvalidArgument { name, detail }) => {
|
||||
assert_eq!(name, "page_token");
|
||||
assert!(detail.contains("tok-1"), "detail: {detail}");
|
||||
}
|
||||
other => panic!("expected InvalidArgument on repeated token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_trailing_characters() {
|
||||
let err = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Zextra");
|
||||
assert!(err.is_err(), "trailing chars after Z must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_day_zero() {
|
||||
let err = super::parse_rfc3339_timestamp("2026-04-00T15:30:00Z");
|
||||
assert!(err.is_err(), "day 0 must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_month_thirteen() {
|
||||
let err = super::parse_rfc3339_timestamp("2026-13-01T15:30:00Z");
|
||||
assert!(err.is_err(), "month 13 must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_day_out_of_range_for_month() {
|
||||
// April has 30 days.
|
||||
let err = super::parse_rfc3339_timestamp("2026-04-31T15:30:00Z");
|
||||
assert!(err.is_err(), "April 31 must be rejected");
|
||||
// February 29 in a non-leap year.
|
||||
let feb = super::parse_rfc3339_timestamp("2025-02-29T00:00:00Z");
|
||||
assert!(feb.is_err(), "Feb 29 in a non-leap year must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
|
||||
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
|
||||
|
||||
Reference in New Issue
Block a user