Skip to content

refactor(gateway): hygiene batch — delete dead handler, tighten boundaries, add caller-level chat tests - #2712

Merged
ilblackdragon merged 2 commits into
stagingfrom
refactor/gateway-hygiene-batch
Apr 20, 2026
Merged

ilblackdragon merged 2 commits into
stagingfrom
refactor/gateway-hygiene-batch

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

Summary

Five follow-ups from the ironclaw#2599 tracking comment, bundled because each is small, touches the same file family, and #5 depends on #4's effect (origin gate now precedes the WS upgrade extractor — which is what lets oneshot unit-test the origin rejection).

1. Delete src/channels/web/handlers/static_files.rs (163 LOC dead code)

Every pub fn in this file had a canonical live version the router actually wires:

Dead Canonical
health_handler platform/static_files::health_handler
project_{redirect,index,file}_handler platform/static_files::*
logs_events_handler features/logs::logs_events_handler
gateway_status_handler features/status::gateway_status_handler

Left behind from stage 4b. #[allow(dead_code)] on the pub mod static_files; declaration was a breadcrumb. Nothing imports from handlers::static_files, safe git rm.

2. is_local_origin case-insensitive

features/chat/mod.rs::is_local_origin used exact-case matching. Browsers lowercase Origin in practice, but RFC 7230 §5.4 permits uppercase hostnames and HTTP is case-insensitive in the scheme. The helper now lowercases before parsing. Regression test test_is_local_origin_accepts_uppercase pins http://LOCALHOST, HTTP://localhost, HTTP://127.0.0.1.

3. extensions_install_handler validates req.name via ExtensionName::new

The sibling URL-path handlers (activate, remove, setup, setup_submit) all validate at the boundary; install was the last untyped entry point. The JSON-body name now parses through ExtensionName::new before reaching registry lookup, filesystem paths under ~/.ironclaw/extensions/, or any extension-manager call.

Behavior change: names that previously reached the install pipeline and failed deep now return 400 Invalid extension name: ... at the boundary. test_extensions_install_handler_rejects_malformed_name pins .., ../traversal, slash/name, BadCase, has space.

4. Refactor chat_ws_handler so the Origin gate runs before the WS upgrade extractor

Swap ws: WebSocketUpgrade → ws: Result<WebSocketUpgrade, WebSocketUpgradeRejection>. Two real effects:

  • Security signal is more accurate. Bad-origin requests now always return 403 Forbidden regardless of whether they included upgrade headers. Previously a probe without upgrade headers got 426 Upgrade Required — which told the caller "you're allowed here, just add these headers" instead of "you're not allowed."
  • Unit testing unblocked. tower::ServiceExt::oneshot can't synthesize hyper's OnUpgrade extension, so the previous signature made origin-rejection branches untestable via unit tests. The new signature hands the handler the raw extraction result so origin gating fires first; oneshot now reaches all three origin paths.

5. Four caller-level chat handler tests

Per .claude/rules/testing.md ("Test Through the Caller, Not Just the Helper"):

  • chat_send_handler — 3 tests: forwards to msg_tx (side effect verified via receiver), returns 503 without channel, rate-limits after 30/60s threshold.
  • chat_ws_handler — 3 tests: rejects missing Origin → 403, rejects remote Origin → 403, accepts localhost Origin (asserts 426 as positive signal the gate passed — oneshot can't complete the upgrade, real 101 covered by tests/ws_gateway_integration.rs).
  • chat_threads_handler — 1 test: returns in-memory threads when DB absent (fallback branch).
  • chat_new_thread_handler — 1 test: persists to both session AND conversation store (both side effects verified).

Quality gate

  • cargo fmt --all
  • cargo clippy --all --benches --tests --examples --all-features — zero warnings
  • cargo check -p ironclaw --no-default-features --features libsql --tests — clean
  • cargo test -p ironclaw --lib channels::web — 456 passed (up 10 from staging baseline)
  • cargo test -p ironclaw --test multi_tenant_integration — 40 passed
  • cargo test -p ironclaw --test ws_gateway_integration — 11 passed (all real-TCP WS paths still green with the refactored handler)
  • python3 scripts/check_gateway_boundaries.py + test — clean, 16/16
  • bash scripts/pre-commit-safety.sh — clean

Follow-ups status after this merges

From the #2599 tracking comment:

🤖 Generated with Claude Code

…aries, add caller-level chat tests

Five follow-ups from the ironclaw#2599 review thread, bundled because
each is a small focused change in the same file family and the test
coverage one depends on the boundary-check one's effect (origin gate
now precedes the WS upgrade extractor, which is what lets `oneshot`
unit-test the origin rejection branch).

## 1. Delete `handlers/static_files.rs` (163 LOC dead code)

Every `pub` fn in this file had a canonical live version the router
actually wires — `platform::static_files::{health,project_*}` and
`features::{logs::logs_events_handler, status::gateway_status_handler}`
— but the stale file was left behind after the stage 4b migration. The
`#[allow(dead_code)]` annotation on `pub mod static_files;` in
`handlers/mod.rs` was a breadcrumb flagging the file for eventual
removal. Nothing imports from `handlers::static_files`, confirmed via
`rg -l handlers::static_files src/`. Safe `git rm`.

## 2. `is_local_origin` case-insensitive host match

`features/chat/mod.rs::is_local_origin` used exact-case matching on
`localhost / 127.0.0.1 / [::1]`. Browsers normalize the Origin header
to lowercase in practice, but RFC 7230 §5.4 allows uppercase
hostnames, and HTTP is case-insensitive in the scheme as well. The
helper now lowercases the whole origin string before parsing so
`http://LOCALHOST`, `HTTP://localhost`, and mixed-case variants all
resolve through the same match path. Regression test
`test_is_local_origin_accepts_uppercase` pins the uppercase cases and
spot-checks that the existing lowercase cases still pass.

## 3. `extensions_install_handler` validates `req.name` via `ExtensionName::new`

Sibling URL-path handlers (`activate`, `remove`, `setup`,
`setup_submit`) already validate at the boundary; `install` was the
last untyped entry point. The JSON-body `name` now parses through
`ExtensionName::new` before it reaches registry lookup, filesystem
path construction under `~/.ironclaw/extensions/`, or any
extension-manager call. Behavior change: paths that used to silently
reach the install pipeline and fail deep now return 400 at the
boundary with `Invalid extension name: ...`. New test
`test_extensions_install_handler_rejects_malformed_name` covers path
traversal, separators, mixed case, whitespace, and the bare `..`
case.

## 4. Refactor `chat_ws_handler` so the Origin gate runs before the WS upgrade extractor

Swap `ws: WebSocketUpgrade` for `ws: Result<WebSocketUpgrade, _>` so
axum hands the handler the raw extraction result instead of rejecting
before the body runs. This reorders the response precedence from
`extract → origin check` to `origin check → extract`, with two real
effects:

- A caller with a bad Origin now always gets `403 Forbidden`
  regardless of whether they sent upgrade headers. Previously,
  malformed probes without upgrade headers got `426 Upgrade Required`
  — less accurate as a security signal, because it told the caller
  "you're allowed here, just add these headers."
- `tower::ServiceExt::oneshot` can synthesize the new failure path
  (missing `OnUpgrade` hyper extension surfaces as
  `Err(WebSocketUpgradeRejection)`), which is what lets the new unit
  tests exercise the three origin-gating branches without a real TCP
  listener.

## 5. Add four caller-level chat handler tests

Per `.claude/rules/testing.md` ("Test Through the Caller, Not Just
the Helper") and the #2599 follow-ups explicit list. Each test drives
the handler function itself through a populated `GatewayState`
(now possible because stage-6a / #2704 promoted the state builders
into `test_helpers`):

- `test_chat_send_handler_forwards_message_to_msg_tx` — drives
  `chat_send_handler`, asserts 202 ACCEPTED *and* the message lands
  in the receiver end of `msg_tx`. Helper-level tests on
  `web_incoming_message` alone couldn't catch a wrapper that
  silently dropped the send.
- `test_chat_send_handler_returns_503_without_channel` — pins the
  "channel not started" shape so a refactor that reroutes `msg_tx`
  can't regress the 503.
- `test_chat_send_handler_rate_limits_after_threshold` — 30 OK then
  1 × 429, covering the `PerUserRateLimiter::new(30, 60)` contract.
- `test_chat_ws_handler_{rejects_missing_origin, rejects_remote_origin,
   accepts_localhost_origin}` — three origin-gating cases. The
  accept path asserts 426 (Origin passed, upgrade can't complete in
  oneshot) rather than 101 — the positive signal is *which* rejection
  fires, not that the upgrade completes. Real 101 is still covered by
  `tests/ws_gateway_integration.rs`.
- `test_chat_threads_handler_returns_in_memory_threads_without_db`
  pins the DB-absent fallback branch.
- `test_chat_new_thread_handler_persists_to_db_and_session` asserts
  both side effects fire (session entry + conversations row).

## Quality gate

- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features` — zero warnings
- `cargo check -p ironclaw --no-default-features --features libsql --tests` — clean
- `cargo test -p ironclaw --lib channels::web` — 456 passed (up 10 from staging baseline)
- `cargo test -p ironclaw --test multi_tenant_integration` — 40 passed
- `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed
- `python3 scripts/check_gateway_boundaries.py` + test — clean, 16/16
- `bash scripts/pre-commit-safety.sh` — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 20, 2026 06:06
@github-actions github-actions Bot added scope: channel/web Web gateway channel size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors parts of the web gateway to remove dead code, tighten input/origin validation boundaries, and add caller-level tests for chat and extensions handlers (aligned with the gateway boundary/testing guidance from ironclaw#2599).

Changes:

  • Removed unused legacy static-file handler module and its module export.
  • Hardened boundary validation: is_local_origin becomes case-insensitive; extensions_install_handler validates req.name via ExtensionName::new.
  • Refactored chat_ws_handler to run the Origin gate before the WS-upgrade extractor and added caller-level tests for chat handlers.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

File Description
src/channels/web/handlers/static_files.rs Deleted dead/unused handlers module.
src/channels/web/handlers/mod.rs Removed static_files module export.
src/channels/web/features/extensions/mod.rs Validate install request name via ExtensionName::new; added regression test for malformed names.
src/channels/web/features/chat/mod.rs Origin gate precedes WS upgrade extraction; is_local_origin lowercases; added caller-level tests for chat handlers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/channels/web/features/chat/mod.rs Outdated
Comment on lines +423 to +428
let ws = ws.map_err(|rej| {
use axum::response::IntoResponse;
let resp = rej.into_response();
let status = resp.status();
(status, "WebSocket upgrade failed".to_string())
})?;

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chat_ws_handler converts WebSocketUpgradeRejection into just (StatusCode, String), which drops the rejection response headers/body (e.g., 426 Upgrade Required typically includes WS-specific headers). Consider returning Ok(rej.into_response()) on rejection (or changing the error type) so axum’s full rejection response is preserved.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 03d2d66. Changed the handler's return type to plain axum::response::Response and return rej.into_response() verbatim on upgrade failure — axum's 426 response (with its RFC 7231 §6.5.15 Upgrade header) and 400 variants now pass through untouched. The two origin-rejection early returns also go through .into_response() so the response shape stays consistent. The three caller-level origin tests keep asserting on the status codes (403 / 403 / 426), and the tests/ws_gateway_integration.rs real-TCP suite still passes.

Comment thread src/channels/web/features/chat/mod.rs Outdated
Comment on lines +1670 to +1672
let received = rx
.recv()
.await

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test awaits rx.recv().await without a timeout; if the handler stops sending (regression), the test can hang the entire suite instead of failing. Use a short tokio::time::timeout around recv() (similar to other tests in this module).

Suggested change
let received = rx
.recv()
.await
let received = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv())
.await
.expect("timed out waiting for msg_tx to receive the accepted message")

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 03d2d66. Wrapped the rx.recv() in a 500ms tokio::time::timeout with an explicit .expect("accepted send must enqueue a message promptly") so a regression that returns 202 without sending fails the test instead of hanging the suite.

Comment thread src/channels/web/features/chat/mod.rs Outdated
.expect("within-budget call must succeed");
assert_eq!(status, StatusCode::ACCEPTED);
// Drain so the channel doesn't block sender.
let _ = rx.recv().await;

Copilot AI Apr 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The loop drains the channel via rx.recv().await with no timeout. If a future change causes chat_send_handler to return 202 without actually sending, this will hang rather than fail. Wrap the receive in a small tokio::time::timeout and assert it completed.

Suggested change
let _ = rx.recv().await;
tokio::time::timeout(tokio::time::Duration::from_millis(100), rx.recv())
.await
.expect("accepted send must enqueue a message promptly")
.expect("channel closed before queued message was received");

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 03d2d66. Each of the 30 drain iterations now goes through tokio::time::timeout(Duration::from_millis(100), rx.recv()) with an explicit expect, so a no-send regression fails fast instead of hanging the loop.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the WebSocket handler to improve origin validation and error reporting, updates origin checks to be case-insensitive, and introduces input validation for extension installation names to prevent path traversal. It also removes the static_files module and adds comprehensive unit tests for chat handlers and extension installation. Feedback includes preserving protocol-required headers in WebSocket upgrade rejections by returning the response directly and improving the robustness of origin string normalization by trimming whitespace before case conversion.

Comment thread src/channels/web/features/chat/mod.rs Outdated
Comment on lines +423 to +428
let ws = ws.map_err(|rej| {
use axum::response::IntoResponse;
let resp = rej.into_response();
let status = resp.status();
(status, "WebSocket upgrade failed".to_string())
})?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

By extracting only the status code from the WebSocketUpgradeRejection and returning a new (StatusCode, String) error, the handler discards any headers present in the original rejection response. For 426 Upgrade Required responses, RFC 7231 requires an Upgrade header field to indicate the supported protocols, which Axum's rejection response typically includes. Consider changing the handler's return type to axum::response::Response (and converting other error paths via .into_response()) so that the rejection response can be returned directly, preserving its headers.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 03d2d66 — handler return type is now plain axum::response::Response, the WebSocketUpgradeRejection passes through via rej.into_response(), and the early origin-rejection arms route through .into_response() as well. RFC 7231 §6.5.15 Upgrade header on 426 (and diagnostics on the 400 variants) are preserved verbatim. Copilot independently flagged the same finding on a sibling thread; both resolved by the same change.

// normalize the Origin header to lowercase in practice, but RFC 7230
// §5.4 allows uppercase hostnames. Normalize before parsing so the
// match never depends on the caller's casing.
let origin_lc = origin.to_ascii_lowercase();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation is susceptible to false negatives if the Origin header contains leading or trailing whitespace (e.g., " http://localhost"), as the strip_prefix and subsequent matches! check are whitespace-sensitive. To adhere to the project convention of avoiding unnecessary allocations for invalid or empty inputs, the string should be trimmed and checked for emptiness before calling to_ascii_lowercase(), which is the preferred method for case-insensitive comparisons in this repository.

    let origin = origin.trim();
    if origin.is_empty() {
        return false;
    }
    let origin_lc = origin.to_ascii_lowercase();
References
  1. When canonicalizing a string, perform cheaper validation checks (like length, emptiness, and character set) on a trimmed slice before performing more expensive operations like replace that allocate a new string.
  2. For case-insensitive comparisons, use to_ascii_lowercase() as it is the project convention, especially when the text is known to be ASCII.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Respectfully declining this one.

Trimming origin before parsing would flip the behavior for whitespace-padded inputs from reject to accept. Current flow for e.g. " http://localhost":

  1. to_ascii_lowercase() → " http://localhost" (whitespace preserved).
  2. strip_prefix("http://") fails on the leading space.
  3. host = "" falls through unwrap_or("").
  4. matches!(host, "localhost" | ...) → false, returns 403.

With the suggested trim():

  1. trim() → "http://localhost".
  2. strip_prefix + match → host = "localhost" → returns true, upgrade accepted.

Browsers don't send padded Origin headers in practice (RFC 6454 specifies the serialized form without padding), so callers who send them are either broken clients or probes. Keeping the "reject on odd shape" default is the safer posture — especially on a CSRF gate where the cost of a false reject is a weird-but-fixable client bug and the cost of a false accept is cross-site WS hijacking.

The allocation optimization angle of the suggestion also doesn't buy much — "".to_ascii_lowercase() is essentially free, so the is-empty early-return saves nothing measurable in the hot path. Leaving this as-is.

Three review comments from Copilot and Gemini, grouped by issue:

## 1. Preserve `WebSocketUpgradeRejection` response verbatim (Copilot + Gemini)

`chat_ws_handler` was converting the `WebSocketUpgradeRejection` into
`(StatusCode, String)`, which discarded the rejection response's
headers and body. For `426 Upgrade Required` specifically, RFC 7231
§6.5.15 requires an `Upgrade` header field naming the protocols the
server supports — axum's rejection response includes that header, but
our hand-rolled `(status, "WebSocket upgrade failed")` error dropped
it. Same story for the `400 Bad Request` rejection variants that
include human-readable diagnostics.

Fix: change the handler's return type from
`Result<axum::response::Response, (StatusCode, String)>` to plain
`axum::response::Response`, return `rej.into_response()` verbatim
when the upgrade extractor fails, and route the two origin-rejection
early returns through `.into_response()` as well. The integration
tests in `tests/ws_gateway_integration.rs` still pass unchanged — the
successful-upgrade path routes through `ws.on_upgrade(...)` which
already returns a `Response` — and the three caller-level origin
tests keep their exact status-code assertions (403 / 403 / 426).

## 2. Timeout around `rx.recv()` in `test_chat_send_handler_forwards_message_to_msg_tx` (Copilot)

A future regression that has `chat_send_handler` return 202 without
actually sending on `msg_tx` would hang this test forever instead of
failing fast. Wrap `rx.recv()` in a 500ms `tokio::time::timeout` with
an explicit `.expect("accepted send must enqueue a message promptly")`.

## 3. Timeout around `rx.recv()` in `test_chat_send_handler_rate_limits_after_threshold` (Copilot)

Same shape as #2: the 30-iteration drain loop awaited `rx.recv()`
unbounded. A regression that returns 202-without-send would make the
loop hang. Wrap each drain in a 100ms timeout.

## Declined: `is_local_origin` whitespace trim (Gemini)

Gemini suggested trimming the origin string before `to_ascii_lowercase()`.
Not applying:
- The current path already treats whitespace-padded origins as invalid
  (`strip_prefix` fails on the leading space, falls through to
  `host = ""`, `matches!` returns `false`, 403). Trimming would flip
  that from *reject* to *accept* for inputs like `" http://localhost"`,
  which loosens the check.
- Browsers normalize the Origin header; no compliant client sends
  padded origins, so the behavior change has no legitimate caller.
- The allocation concern is marginal (`"".to_ascii_lowercase()` is
  essentially free, so the is-empty early-return saves nothing
  measurable).

Reply on the thread will explain the reasoning.

## Quality gate

- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features` — zero warnings
- `cargo test -p ironclaw --lib channels::web` — 456 passed
- `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed
- `bash scripts/pre-commit-safety.sh` — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ilblackdragon
ilblackdragon merged commit a69aa54 into staging Apr 20, 2026
17 checks passed
@ilblackdragon
ilblackdragon deleted the refactor/gateway-hygiene-batch branch April 20, 2026 06:36
@henrypark133 henrypark133 mentioned this pull request Apr 21, 2026
This was referenced Apr 22, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…aries, add caller-level chat tests (nearai#2712)

* refactor(gateway): hygiene batch — delete dead handler, tighten boundaries, add caller-level chat tests

Five follow-ups from the ironclaw#2599 review thread, bundled because
each is a small focused change in the same file family and the test
coverage one depends on the boundary-check one's effect (origin gate
now precedes the WS upgrade extractor, which is what lets `oneshot`
unit-test the origin rejection branch).

## 1. Delete `handlers/static_files.rs` (163 LOC dead code)

Every `pub` fn in this file had a canonical live version the router
actually wires — `platform::static_files::{health,project_*}` and
`features::{logs::logs_events_handler, status::gateway_status_handler}`
— but the stale file was left behind after the stage 4b migration. The
`#[allow(dead_code)]` annotation on `pub mod static_files;` in
`handlers/mod.rs` was a breadcrumb flagging the file for eventual
removal. Nothing imports from `handlers::static_files`, confirmed via
`rg -l handlers::static_files src/`. Safe `git rm`.

## 2. `is_local_origin` case-insensitive host match

`features/chat/mod.rs::is_local_origin` used exact-case matching on
`localhost / 127.0.0.1 / [::1]`. Browsers normalize the Origin header
to lowercase in practice, but RFC 7230 §5.4 allows uppercase
hostnames, and HTTP is case-insensitive in the scheme as well. The
helper now lowercases the whole origin string before parsing so
`http://LOCALHOST`, `HTTP://localhost`, and mixed-case variants all
resolve through the same match path. Regression test
`test_is_local_origin_accepts_uppercase` pins the uppercase cases and
spot-checks that the existing lowercase cases still pass.

## 3. `extensions_install_handler` validates `req.name` via `ExtensionName::new`

Sibling URL-path handlers (`activate`, `remove`, `setup`,
`setup_submit`) already validate at the boundary; `install` was the
last untyped entry point. The JSON-body `name` now parses through
`ExtensionName::new` before it reaches registry lookup, filesystem
path construction under `~/.ironclaw/extensions/`, or any
extension-manager call. Behavior change: paths that used to silently
reach the install pipeline and fail deep now return 400 at the
boundary with `Invalid extension name: ...`. New test
`test_extensions_install_handler_rejects_malformed_name` covers path
traversal, separators, mixed case, whitespace, and the bare `..`
case.

## 4. Refactor `chat_ws_handler` so the Origin gate runs before the WS upgrade extractor

Swap `ws: WebSocketUpgrade` for `ws: Result<WebSocketUpgrade, _>` so
axum hands the handler the raw extraction result instead of rejecting
before the body runs. This reorders the response precedence from
`extract → origin check` to `origin check → extract`, with two real
effects:

- A caller with a bad Origin now always gets `403 Forbidden`
  regardless of whether they sent upgrade headers. Previously,
  malformed probes without upgrade headers got `426 Upgrade Required`
  — less accurate as a security signal, because it told the caller
  "you're allowed here, just add these headers."
- `tower::ServiceExt::oneshot` can synthesize the new failure path
  (missing `OnUpgrade` hyper extension surfaces as
  `Err(WebSocketUpgradeRejection)`), which is what lets the new unit
  tests exercise the three origin-gating branches without a real TCP
  listener.

## 5. Add four caller-level chat handler tests

Per `.claude/rules/testing.md` ("Test Through the Caller, Not Just
the Helper") and the nearai#2599 follow-ups explicit list. Each test drives
the handler function itself through a populated `GatewayState`
(now possible because stage-6a / nearai#2704 promoted the state builders
into `test_helpers`):

- `test_chat_send_handler_forwards_message_to_msg_tx` — drives
  `chat_send_handler`, asserts 202 ACCEPTED *and* the message lands
  in the receiver end of `msg_tx`. Helper-level tests on
  `web_incoming_message` alone couldn't catch a wrapper that
  silently dropped the send.
- `test_chat_send_handler_returns_503_without_channel` — pins the
  "channel not started" shape so a refactor that reroutes `msg_tx`
  can't regress the 503.
- `test_chat_send_handler_rate_limits_after_threshold` — 30 OK then
  1 × 429, covering the `PerUserRateLimiter::new(30, 60)` contract.
- `test_chat_ws_handler_{rejects_missing_origin, rejects_remote_origin,
   accepts_localhost_origin}` — three origin-gating cases. The
  accept path asserts 426 (Origin passed, upgrade can't complete in
  oneshot) rather than 101 — the positive signal is *which* rejection
  fires, not that the upgrade completes. Real 101 is still covered by
  `tests/ws_gateway_integration.rs`.
- `test_chat_threads_handler_returns_in_memory_threads_without_db`
  pins the DB-absent fallback branch.
- `test_chat_new_thread_handler_persists_to_db_and_session` asserts
  both side effects fire (session entry + conversations row).

## Quality gate

- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features` — zero warnings
- `cargo check -p ironclaw --no-default-features --features libsql --tests` — clean
- `cargo test -p ironclaw --lib channels::web` — 456 passed (up 10 from staging baseline)
- `cargo test -p ironclaw --test multi_tenant_integration` — 40 passed
- `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed
- `python3 scripts/check_gateway_boundaries.py` + test — clean, 16/16
- `bash scripts/pre-commit-safety.sh` — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(gateway): address PR nearai#2712 review feedback

Three review comments from Copilot and Gemini, grouped by issue:

## 1. Preserve `WebSocketUpgradeRejection` response verbatim (Copilot + Gemini)

`chat_ws_handler` was converting the `WebSocketUpgradeRejection` into
`(StatusCode, String)`, which discarded the rejection response's
headers and body. For `426 Upgrade Required` specifically, RFC 7231
§6.5.15 requires an `Upgrade` header field naming the protocols the
server supports — axum's rejection response includes that header, but
our hand-rolled `(status, "WebSocket upgrade failed")` error dropped
it. Same story for the `400 Bad Request` rejection variants that
include human-readable diagnostics.

Fix: change the handler's return type from
`Result<axum::response::Response, (StatusCode, String)>` to plain
`axum::response::Response`, return `rej.into_response()` verbatim
when the upgrade extractor fails, and route the two origin-rejection
early returns through `.into_response()` as well. The integration
tests in `tests/ws_gateway_integration.rs` still pass unchanged — the
successful-upgrade path routes through `ws.on_upgrade(...)` which
already returns a `Response` — and the three caller-level origin
tests keep their exact status-code assertions (403 / 403 / 426).

## 2. Timeout around `rx.recv()` in `test_chat_send_handler_forwards_message_to_msg_tx` (Copilot)

A future regression that has `chat_send_handler` return 202 without
actually sending on `msg_tx` would hang this test forever instead of
failing fast. Wrap `rx.recv()` in a 500ms `tokio::time::timeout` with
an explicit `.expect("accepted send must enqueue a message promptly")`.

## 3. Timeout around `rx.recv()` in `test_chat_send_handler_rate_limits_after_threshold` (Copilot)

Same shape as #2: the 30-iteration drain loop awaited `rx.recv()`
unbounded. A regression that returns 202-without-send would make the
loop hang. Wrap each drain in a 100ms timeout.

## Declined: `is_local_origin` whitespace trim (Gemini)

Gemini suggested trimming the origin string before `to_ascii_lowercase()`.
Not applying:
- The current path already treats whitespace-padded origins as invalid
  (`strip_prefix` fails on the leading space, falls through to
  `host = ""`, `matches!` returns `false`, 403). Trimming would flip
  that from *reject* to *accept* for inputs like `" http://localhost"`,
  which loosens the check.
- Browsers normalize the Origin header; no compliant client sends
  padded origins, so the behavior change has no legitimate caller.
- The allocation concern is marginal (`"".to_ascii_lowercase()` is
  essentially free, so the is-empty early-return saves nothing
  measurable).

Reply on the thread will explain the reasoning.

## Quality gate

- `cargo fmt --all`
- `cargo clippy --all --benches --tests --examples --all-features` — zero warnings
- `cargo test -p ironclaw --lib channels::web` — 456 passed
- `cargo test -p ironclaw --test ws_gateway_integration` — 11 passed
- `bash scripts/pre-commit-safety.sh` — clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: channel/web Web gateway channel size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants