test(client,adapters): retry policy + streaming terminal arms + credential/header-parameter arms (CLI-03, COV-10, COV-12)

- new tests/retry_policy_wire.rs: POST+500 = 1 upstream hit (method
  gate), GET 500/503/200 = 3 hits (retry-to-success), budget exhaustion
  stops retries under a 50-attempt cap (wall-time + hit-count bounds)
- forward_stream terminal arms on the wire: oversized SSE line ->
  one INTERNAL terminal envelope; mid-stream transport abort (staged
  via a notify gate so the abort is genuinely mid-stream) -> terminal
  envelope after the delivered frame; pending event EOF-flush; dead
  port through both forward() and forward_stream()
- Once-path decode arms: malformed application/json 200 -> INTERNAL
  decode envelope; application/octet-stream 200 -> byte-array envelope
- COV-12 credential arms: ApiKey and Basic malformed values fail
  loudly without echoing secret material; declared header-param
  invalid name/value rejections
- new tests/client_config_reload.rs: config() reflects a reloaded
  config (FWD-12 atomicity half)
This commit is contained in:
2026-08-31 01:00:29 +00:00
parent 847e5ca586
commit 0837e942ab
3 changed files with 579 additions and 0 deletions
+382
View File
@@ -2826,6 +2826,388 @@ mod tests {
);
}
/// FWD-07 Once-path decode arm: a `200` with `application/json` and
/// a body that does not parse surfaces a single `INTERNAL` decode
/// error envelope — never a partial or fabricated success.
#[tokio::test]
async fn malformed_json_body_200_decodes_to_an_internal_error_envelope() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "application/json", b"{not json".to_vec())
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("failed to decode response body"),
"message was: {}",
err.message
);
}
other => panic!("expected INTERNAL decode envelope, got {other:?}"),
}
}
/// FWD-07 Once-path binary arm: a `200` with
/// `application/octet-stream` surfaces the body as a JSON byte
/// array (bounded by the response cap like every other read path).
#[tokio::test]
async fn binary_octet_stream_200_surfaces_as_a_byte_array_envelope() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(
200,
"application/octet-stream",
vec![0x00, 0xFF, 0x10, 0x42],
)
}))
.await;
let envelope = call_forward(&base, noop_context()).await;
match envelope.result {
Ok(value) => assert_eq!(value, json!([0, 255, 16, 66])),
other => panic!("expected a byte-array envelope, got {other:?}"),
}
}
/// COV-10 (a): an upstream SSE line longer than the 1 MiB line cap
/// with no newline terminates `forward_stream` with a single
/// `INTERNAL` terminal envelope, then the stream ends.
#[tokio::test]
async fn oversized_upstream_sse_line_terminates_with_one_internal_envelope() {
let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n";
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
let line = vec![b'a'; SSE_EVENT_BUFFER_CAP + 1];
let _ = sock.write_all(b"data: ").await;
let _ = sock.write_all(&line).await;
let _ = sock.write_all(b"\n\n").await;
let _ = sock.flush().await;
})
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(
envelopes.len(),
1,
"the parse-overflow terminal envelope is the only output"
);
match &envelopes[0].result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("SSE parse error"),
"message was: {}",
err.message
);
}
other => panic!("expected one INTERNAL terminal envelope, got {other:?}"),
}
}
/// COV-10 (b): the responder emits one complete `data:` frame, lets
/// the client consume it, then kills the connection before the
/// declared body length is met — a genuine mid-stream transport
/// abort, not a graceful EOF. The delivered event passes, a single
/// `INTERNAL` terminal error envelope follows, the stream ends.
#[tokio::test]
async fn aborted_socket_mid_stream_emits_a_terminal_error_envelope() {
let head =
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncontent-length: 512\r\n\r\n";
let gate = TestArc::new(tokio::sync::Notify::new());
let notify = TestArc::clone(&gate);
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
let _ = sock.write_all(b"data: {\"n\":1}\n\n").await;
let _ = sock.flush().await;
notify.notified().await;
drop(sock);
})
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
json!({}),
noop_context(),
);
tokio::pin!(stream);
let first = tokio::time::timeout(Duration::from_secs(5), stream.next())
.await
.expect("the complete frame must deliver within the test budget")
.expect("stream must deliver the frame before the abort");
assert_eq!(first.result.clone().unwrap(), json!({"n": 1}));
gate.notify_one();
let terminal = tokio::time::timeout(Duration::from_secs(5), stream.next())
.await
.expect("the terminal envelope must follow within the test budget")
.expect("the stream must end with the terminal envelope");
match terminal.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("SSE stream error"),
"message was: {}",
err.message
);
}
other => panic!("expected the terminal error envelope, got {other:?}"),
}
let ended = tokio::time::timeout(Duration::from_secs(1), stream.next()).await;
assert!(
matches!(ended, Ok(None) | Err(_)),
"the stream ends after the terminal envelope"
);
}
/// COV-10 (c): the responder ends the stream with a pending event
/// (data lines received, no trailing blank line) — the WHATWG
/// EOF-flush dispatches that pending event as a final success
/// envelope at EOF.
#[tokio::test]
async fn pending_event_flushes_at_eof_without_a_trailing_blank_line() {
let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n";
let base = spawn_sse_responder_with_writer(head, move |mut sock| async move {
use tokio::io::AsyncWriteExt;
let _ = sock
.write_all(b"data: {\"n\":1}\n\ndata: {\"final\":true}\n")
.await;
let _ = sock.flush().await;
let _ = sock.shutdown().await;
})
.await;
let stream = forward_stream(
&minimal_client(),
&base,
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(envelopes.len(), 2);
assert_eq!(envelopes[0].result.clone().unwrap(), json!({"n": 1}));
assert_eq!(
envelopes[1].result.clone().unwrap(),
json!({"final": true}),
"EOF-flush dispatches the pending event"
);
}
/// COV-10 (d), Once-path transport arm: a connect-refused upstream
/// surfaces a single `INTERNAL` error envelope through `forward`.
#[tokio::test]
async fn dead_port_forward_yields_an_internal_envelope() {
let envelope = forward(
&minimal_client(),
"http://127.0.0.1:9",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
json!({}),
noop_context(),
)
.await;
match envelope.result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("HTTP request failed"),
"message was: {}",
err.message
);
}
other => panic!("expected INTERNAL transport envelope, got {other:?}"),
}
}
/// COV-10 (d), streaming-path transport arm: the same dead upstream
/// through `forward_stream` surfaces a single terminal `INTERNAL`
/// error envelope (the streaming build/send-failure terminal arm).
#[tokio::test]
async fn dead_port_forward_stream_yields_a_terminal_internal_envelope() {
let stream = forward_stream(
&minimal_client(),
"http://127.0.0.1:9",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&serde_json::json!({"type": "object"}),
&[],
json!({}),
noop_context(),
);
let envelopes = collect_stream(stream).await;
assert_eq!(
envelopes.len(),
1,
"exactly one terminal envelope, then the stream ends"
);
match &envelopes[0].result {
Err(err) => {
assert_eq!(err.code, "INTERNAL", "message was: {}", err.message);
assert!(
err.message.contains("HTTP request failed"),
"message was: {}",
err.message
);
}
other => panic!("expected terminal INTERNAL envelope, got {other:?}"),
}
}
/// COV-12, loud-credential arm (ApiKey): an ApiKey credential value
/// carrying invalid HTTP header bytes fails loudly and never echoes
/// the credential material (FWD-08 family).
#[tokio::test]
async fn api_key_with_invalid_value_fails_loudly_without_echoing_secrets() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "application/json", b"{}".to_vec())
}))
.await;
let ctx = ctx_with_capability("svc", "key\u{0003}-secret-marker".to_string());
let envelope = call_forward_authed(
&base,
ctx,
&Some(HttpAuthScheme::ApiKey {
header_name: "x-api-key".to_string(),
}),
)
.await;
match envelope.result {
Err(err) => {
assert!(
err.message
.contains("refusing to send the request unauthenticated"),
"message was: {}",
err.message
);
assert!(
!err.message.contains("secret-marker") && !err.message.contains("key\u{0003}"),
"error must not echo credential material: {}",
err.message
);
}
other => panic!("expected loud credential error, got {other:?}"),
}
}
/// COV-12, loud-credential arm (Basic): a Basic credential pair
/// with invalid HTTP header bytes fails loudly like Bearer/ApiKey
/// (FWD-08 family).
#[tokio::test]
async fn basic_credential_with_invalid_value_fails_loudly_without_echoing_secrets() {
let base = spawn_responder(TestArc::new(|_parts| {
http_response(200, "application/json", b"{}".to_vec())
}))
.await;
let ctx = ctx_with_capability("svc", "basic\u{0001}-secret-marker".to_string());
let envelope = call_forward_authed(&base, ctx, &Some(HttpAuthScheme::Basic)).await;
match envelope.result {
Err(err) => {
assert!(
err.message
.contains("refusing to send the request unauthenticated"),
"message was: {}",
err.message
);
assert!(
!err.message.contains("secret-marker")
&& !err.message.contains("basic\u{0001}"),
"error must not echo credential material: {}",
err.message
);
}
other => panic!("expected loud credential error, got {other:?}"),
}
}
/// COV-12: a declared header parameter with an invalid *value*
/// (control bytes) is rejected loudly at build time rather than
/// silently dropped from the request (FWD-08 family).
#[test]
fn declared_header_param_with_invalid_value_is_rejected() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"X-Trace": {"type": "string", "wire": "header"}},
});
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
&json!({"X-Trace": "bad\u{0000}value"}),
&ctx,
)
.expect_err("invalid header-param value must fail loudly");
assert!(
err.message.contains("X-Trace") && err.message.contains("invalid value"),
"message was: {}",
err.message
);
}
/// COV-12: a declared header parameter whose name is not a valid
/// HTTP header name is rejected loudly at build time (FWD-08
/// family, the header-param sibling of the default-header arm).
#[test]
fn declared_header_param_with_invalid_name_is_rejected() {
let ctx = noop_context();
let schema = json!({
"type": "object",
"properties": {"bad header": {"type": "string", "wire": "header"}},
});
let err = build_request(
"https://api.example.com",
"/x",
"GET",
&None,
&TestHashMap::new(),
"svc",
&schema,
&json!({"bad header": "v"}),
&ctx,
)
.expect_err("invalid header-param name must fail loudly");
assert!(
err.message.contains("valid HTTP header name"),
"message was: {}",
err.message
);
}
/// CLI-02 (a): a same-host 302 is followed, and the credential
/// header is forwarded to the followed hop — the FWD-03 policy only
/// scrubs headers across *cross-host* hops, so a same-origin hop