fix(CLI-40): scrub the credential from the redacted error's structured reply, route MXACCESS_FAILURE to MxAccess (Rust), fix Go Subscribe terminal-error drop
Code-review follow-up on the CLI-40/41/44 branch. ISSUE 1 (all five, critical): the message-only scrub still leaked the server-echoed credential through the redacted error's structured reply accessor (.NET Reply/Statuses, Java reply()/protocolStatus(), Go MxAccessError.Reply via errors.As, Rust reply()/into_reply(), Python raw_reply). The redacted error now carries a scrubbed clone of the reply (protocol_status.message, diagnostic_message, statuses[].diagnostic_text), with per-language tests asserting the reply accessor no longer contains the credential. ISSUE 2 (Rust, critical): ensure_command_success routed MXACCESS_FAILURE to Error::Command (unlike the other four clients), bypassing attach_secrets and leaking via derived Debug/Display. MXACCESS_FAILURE now routes to Error::MxAccess, fixing the cross-client inconsistency. ISSUE 3 (Go, important): the CLI-44 terminal send was unconditionally non-blocking, dropping a genuine terminal error under a full buffer on the never-drop SubscribeEvents path. It is now reserved-slot-non-blocking only for the cancel-on-overflow path and blocking for the never-drop path. New shared fixture authenticate-user.echoed-credential-mxaccess-failure.reply.json wired into all five suites. Minors: whitespace-secret guard on .NET/Java redact helpers; Java preserves exception subtype on redaction; redaction-helper unit tests (Go/Java/.NET). Docs (ClientBehaviorFixtures.md, ClientLibrariesDesign.md) updated to make the structured-field claim true.
This commit is contained in:
@@ -264,6 +264,74 @@ func TestEventsFullBufferTerminalErrorKeepsRootCause(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeEventsFullBufferDeliversTerminalError is the CLI-44 regression for
|
||||
// the never-drop Subscribe path. SubscribeEvents/SubscribeEventsAfter use
|
||||
// cancelWhenResultBufferFull=false, so ordinary sends are blocking and uncapped and
|
||||
// can fill every slot in the results channel — including the reserved terminal slot.
|
||||
// A genuine terminal Recv error must still be delivered as the final result, never
|
||||
// silently dropped. The server sends eventBufferSize+eventBufferReservedSlots events
|
||||
// (filling every slot) and then returns a genuine gRPC error; with an unconditional
|
||||
// non-blocking terminal send the error is dropped, so this fails red until the send
|
||||
// path blocks for the never-drop mode.
|
||||
func TestSubscribeEventsFullBufferDeliversTerminalError(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
streamStarted: make(chan struct{}),
|
||||
streamDone: make(chan struct{}),
|
||||
streamEventCount: eventBufferSize + eventBufferReservedSlots,
|
||||
streamTerminalErr: status.Error(codes.Internal, "boom"),
|
||||
}
|
||||
client, cleanup := newBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
session := NewSessionForID(client, "session-1")
|
||||
|
||||
subscription, err := session.SubscribeEvents(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("SubscribeEvents() error = %v", err)
|
||||
}
|
||||
defer subscription.Close()
|
||||
<-fake.streamStarted
|
||||
|
||||
// Wait for the server to finish sending every event and return the terminal
|
||||
// error, so the producer goroutine has filled every buffered slot before the
|
||||
// terminal result is processed. That is what makes the dropped-terminal bug
|
||||
// observable: with the buffer full, an unconditional non-blocking send discards
|
||||
// the terminal error.
|
||||
select {
|
||||
case <-fake.streamDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("event stream did not stop after terminal error")
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
|
||||
// Drain fully. Every data event, then the terminal gRPC error as the final
|
||||
// result, must arrive; the channel must not close without yielding it.
|
||||
events := subscription.Events()
|
||||
var last EventResult
|
||||
gotResult := false
|
||||
for {
|
||||
select {
|
||||
case res, ok := <-events:
|
||||
if !ok {
|
||||
if !gotResult {
|
||||
t.Fatal("events channel closed without yielding any result")
|
||||
}
|
||||
var gwErr *GatewayError
|
||||
if !errors.As(last.Err, &gwErr) {
|
||||
t.Fatalf("final event result err is %T (%v), want the terminal *GatewayError; it was dropped", last.Err, last.Err)
|
||||
}
|
||||
if code := status.Code(last.Err); code != codes.Internal {
|
||||
t.Fatalf("final event result gRPC code = %s, want %s", code, codes.Internal)
|
||||
}
|
||||
return
|
||||
}
|
||||
last = res
|
||||
gotResult = true
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("events channel did not close after terminal error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsSurfacesReplayGapSentinelAsTypedSignal(t *testing.T) {
|
||||
fake := &fakeGatewayServer{
|
||||
streamStarted: make(chan struct{}),
|
||||
|
||||
Reference in New Issue
Block a user