diff --git a/CMakeLists.txt b/CMakeLists.txt index 3e6599e..eb62bf2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,6 +93,7 @@ if(GTest_FOUND) tests/unit/ErrorHandlingTest.cpp tests/unit/ServerSelectionTest.cpp tests/unit/AuthCodecTest.cpp + tests/unit/HotRodURITest.cpp tests/unit/SaslAuthenticatorTest.cpp ) @@ -164,6 +165,11 @@ if(GTest_FOUND) tests/integration/ErrorHandlingIntegrationTest.cpp ) + # Hot Rod URI end-to-end (single server) — fromUri parse->connect->op chain + add_executable(fromuri_integration_tests + tests/integration/FromUriIntegrationTest.cpp + ) + # Topology integration tests (multi-server cluster) add_executable(topology_integration_tests tests/integration/TopologyChangeTest.cpp @@ -325,6 +331,13 @@ if(GTest_FOUND) # Note: NOT gtest_main - we provide our own main() ) + target_link_libraries(fromuri_integration_tests + PRIVATE + hotrod-client + GTest::gtest + # Note: NOT gtest_main - we provide our own main() + ) + add_test(NAME PingIntegrationTests COMMAND ping_integration_tests) add_test(NAME GetIntegrationTests COMMAND get_integration_tests) add_test(NAME GetWithMetadataIntegrationTests COMMAND getwithmetadata_integration_tests) @@ -336,6 +349,7 @@ if(GTest_FOUND) add_test(NAME ReplaceIntegrationTests COMMAND replace_integration_tests) add_test(NAME ContainsKeyIntegrationTests COMMAND containskey_integration_tests) add_test(NAME ErrorHandlingIntegrationTests COMMAND errorhandling_integration_tests) + add_test(NAME FromUriIntegrationTests COMMAND fromuri_integration_tests) add_test(NAME TopologyIntegrationTests COMMAND topology_integration_tests) add_test(NAME ConcurrentClientsTests COMMAND concurrent_clients_tests) add_test(NAME HashAwareRoutingIntegrationTests COMMAND hash_aware_routing_integration_tests) @@ -389,6 +403,10 @@ if(GTest_FOUND) WORKING_DIRECTORY ${CMAKE_BINARY_DIR} LABELS "integration;docker" ) + set_tests_properties(FromUriIntegrationTests PROPERTIES + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + LABELS "integration;docker" + ) set_tests_properties(TopologyIntegrationTests PROPERTIES WORKING_DIRECTORY ${CMAKE_BINARY_DIR} LABELS "integration;docker;topology;multi-server" @@ -424,7 +442,8 @@ if(GTest_FOUND) PingIntegrationTests GetIntegrationTests GetWithMetadataIntegrationTests PutIntegrationTests RemoveIntegrationTests RemoveWithVersionIntegrationTests ReplaceWithVersionIntegrationTests PutIfAbsentIntegrationTests ReplaceIntegrationTests - ContainsKeyIntegrationTests ErrorHandlingIntegrationTests TopologyIntegrationTests + ContainsKeyIntegrationTests ErrorHandlingIntegrationTests FromUriIntegrationTests + TopologyIntegrationTests ConcurrentClientsTests HashAwareRoutingIntegrationTests ConcurrentMultiServerTests RetryViewIntegrationTests PingRetryIntegrationTests AuthIntegrationTests PROPERTIES TIMEOUT 300 diff --git a/README.md b/README.md index 03b6390..a43543c 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ ctest -C Release --output-on-failure ## Testing -### Unit Tests (223 tests) +### Unit Tests (250 tests) ```bash cd build ./unit_tests @@ -124,7 +124,7 @@ cd build ctest -R UnitTests --output-on-failure ``` -### Integration Tests (89 tests, 18 suites) +### Integration Tests (92 tests, 19 suites) Requires Docker to run Infinispan server: ```bash cd build @@ -153,8 +153,9 @@ ctest --output-on-failure **Current Progress**: Full CRUD (PING/GET/PUT/REMOVE) + SCRAM auth + topology awareness + hash-aware routing + async multiplexing + metadata/version-based operations (Step 10) + typed error handling and user-decided retry, keyed and -keyless (Step 11) — all shipped. **Next:** bulk operations (Step 12) or -multiplexing benchmarks. See [docs/STATUS.md](docs/STATUS.md) for live status. +keyless (Step 11) + Hot Rod URI support (`RemoteCache::fromUri`, issue #9) — all +shipped. **Next:** bulk operations (Step 12) or multiplexing benchmarks. See +[docs/STATUS.md](docs/STATUS.md) for live status. Per-step milestone history (frozen at Step 9) is archived at [docs/archive/PROGRESS.md](docs/archive/PROGRESS.md). @@ -162,9 +163,9 @@ _(Step numbers follow [`../hotrod-foundry/ROADMAP.md`](../hotrod-foundry/ROADMAP [`docs/STATUS.md`](docs/STATUS.md) is authoritative for which steps are done — not any headline count.)_ -**Test Results** (verified 2026-09-25): -- Unit Tests: **223/223 passing** ✅ -- Integration Tests: **89/89 passing** ✅ across 18 suites (against live Infinispan via Docker) +**Test Results** (verified 2026-10-09): +- Unit Tests: **250/250 passing** ✅ +- Integration Tests: **92/92 passing** ✅ across 19 suites (against live Infinispan via Docker) ## Features @@ -193,6 +194,9 @@ any headline count.)_ - ✅ **SASL/SCRAM authentication** (`setAuthentication(...)`) — end-to-end for the SCRAM family (`SCRAM-SHA-1`/`SCRAM-SHA-256`/`SCRAM-SHA-512`), applied to every connection; see [`documentation/topics/security.adoc`](documentation/topics/security.adoc) +- ✅ **Hot Rod URI** (`RemoteCache::fromUri("hotrod://...")`) — build a client from a + connection string (hosts, credentials, `sasl_mechanism`/`client_intelligence`/ + `protocol_version` params); `hotrod://` only for now (TLS and more in issue #10) - ✅ **Integration test framework** (GoogleTest + Docker + multi-node clusters) ### Full CRUD with Smart Routing and Async Operations @@ -229,6 +233,25 @@ for (int i = 0; i < 1000; i++) { } ``` +### Connect from a Hot Rod URI +```cpp +// hotrod://[user:password@]host1[:port1][,host2[:port2]...][?k=v&...] +// Returns a configured, not-yet-connected client (RemoteCache owns a mutex, so +// it is non-movable → unique_ptr). Call connect() yourself. +auto cache = RemoteCache::fromUri( + "hotrod://user:pass@node1:11222,node2:11222" + "?sasl_mechanism=SCRAM-SHA-256&client_intelligence=hash_distribution_aware", + "my-cache"); +cache->connect(); + +// v1 supports hotrod:// only; the first host seeds the topology, credentials +// enable SCRAM, and sasl_mechanism/client_intelligence/protocol_version (Java +// "4.0"/"4.1" form) are honoured. Userinfo and query values are percent-decoded +// (RFC 3986), so a reserved character in a password is written encoded — e.g. a +// password of p@ss is "...://admin:p%40ss@host". hotrods:// (TLS), timeouts, and +// other parameters throw for now (see issue #10). +``` + ### Future > 👉 **The full, authoritative backlog lives in @@ -267,7 +290,7 @@ cpp17-client/ │ └── operations/ # Hot Rod operations (with failover) ├── tests/ # Test suite │ ├── unit/ # 223 unit tests -│ └── integration/ # 89 integration tests, 18 suites (multi-node clusters) +│ └── integration/ # 92 integration tests, 19 suites (multi-node clusters) ├── documentation/ # AsciiDoc user guide (index.adoc + topics/) ├── examples/ # Usage examples │ └── quickstart/ # Simple GET/PUT/REMOVE + retry + auth examples diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 1588f21..9a03c75 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -705,6 +705,163 @@ all green live against a Dockerized auth-enabled Infinispan 16.0.7. Documented i `documentation/index.adoc`) with a runnable snippet in `examples/quickstart/README.md`. +## 2026-10-09 — Hot Rod URI support (`RemoteCache::fromUri`) — issue #9 + +**What shipped.** A connection-string entry point, requested in issue #9 by +Tristan Tarrant (linking the .NET client's "create from URI" docs). New pure, +header-only parser `HotRodURI::parse()` (`include/hotrod/HotRodURI.h`, following +the `ServerSelection.h` / `AuthCodec.h` inline precedent) turns a +`hotrod://[user:password@]host1[:port1][,host2...][?k=v&...]` string into a +validated value object; `RemoteCache::fromUri(uri, cacheName="")` consumes it and +applies the existing flat setters. +20 unit tests (`HotRodURITest`), full suite +243/243, Release `-Werror` clean. No integration test: parsing is pure +(unit-covered) and `fromUri` only wires existing, already-integration-tested +setters. + +**Authoritative behaviour is the Java `HotRodURI`, not the .NET docs** (AGENTS.md +source order). Reading `org.infinispan.client.hotrod.impl.HotRodURI` + +`ConfigurationProperties` + `ProtocolVersion` surfaced one real divergence from +the .NET docs the issue links: +- **`protocol_version` value form.** The .NET docs show `version40`/`version41`. + Java's `ProtocolVersion.parseVersion` matches the `"major.minor"` text form + (`"4.0"`, `"4.1"`, `"AUTO"`). We follow Java → `?protocol_version=4.0`. +- Java query params are generic: every `k=v` is prefixed with + `infinispan.client.hotrod.` and dumped into a `Properties` bag that the + `ConfigurationBuilder` later reads, silently ignoring unknowns. We have no such + property bag (typed flat setters only), so a translation table is unavoidable — + the "generic passthrough" shortcut doesn't exist for us. +- `client_intelligence` values are the enum names (`BASIC`/`TOPOLOGY_AWARE`/ + `HASH_DISTRIBUTION_AWARE`), parsed case-insensitively in Java; we match that. + +**Decisions (agreed with the user).** +1. **Returns `std::unique_ptr`, not a value.** `RemoteCache` owns a + `std::mutex` (`stateMutex_`, Step 11b slice 4), so it is non-movable and cannot + be returned by value. The client is returned **not-yet-connected** — the + caller still calls `connect()`, matching the existing constructor flow. +2. **v1 scope: parse the whole URI, wire only what the client supports today, + throw clearly on the rest.** Only `hotrod://`; the first host seeds the + topology (the rest are parsed into `servers` but unused for now — topology + discovery finds them); credentials enable SCRAM; query whitelist is + `sasl_mechanism` / `client_intelligence` / `protocol_version`. +3. **Throw on unknown/unsupported, do NOT silently ignore (diverges from Java).** + Consistent with the explicit-error choice for the scheme: an unknown param or a + bad value for a known param throws `HotRodClientException{BeforeSend}` rather + than being dropped, so a user who sets `socket_timeout` isn't silently ignored. + Trade-off: less forward-compatible than Java's ignore-unknowns, but fail-fast + and honest about the gap. `BeforeSend` is the phase for a construction-time + error (nothing was sent). +4. **Exception messages stay generic — no GitHub issue numbers in them** (user + directive: avoid having to maintain those references in code). The deferred + list lives in **issue #10**, not in the throw strings. + +**Deferred to issue #10** (each drops its "unsupported" throw as the capability +lands): `hotrods://`/TLS + cert/SNI/hostname-validation params, +`connect_timeout`/`socket_timeout`, multi-server seed list (startup failover +across seeds), non-SCRAM SASL (PLAIN/OAUTHBEARER `token`), +`protocol_version=AUTO`/3.x. + +**Aside (not changed here).** `CMakeLists.txt` links `OpenSSL::SSL` as well as +`OpenSSL::Crypto`, but the code only uses libcrypto (EVP/HMAC/PBKDF2 for SCRAM) — +the data socket is plain TCP (`Connection.cpp`: `SOCK_STREAM`, `::connect`, +`::send`/`::recv`, no `SSL_*`). Confirms STATUS.md's "no TLS". The `OpenSSL::SSL` +link is dead weight; left as a possible future cleanup, folded into the TLS work. + +## 2026-10-09 — Hot Rod URI: end-to-end integration coverage + a single-server caveat + +**What shipped (same session as the parser).** The pure unit tests +(`HotRodURITest`, 20 cases) cover URI string→fields exhaustively, but nothing +proved a client *built from a URI* actually connects and operates. Added that +end-to-end proof: +- `tests/integration/FromUriIntegrationTest.cpp` (single-server, 2 tests): a plain + `hotrod://host:port` client round-trips a value; a `?protocol_version=4.0` client + reports `VERSION_40` **and** round-trips (proves a query param flows all the way + to a working client). +- `AuthIntegrationTest.FromUriWithCredentialsAuthenticates`: credentials + + `?sasl_mechanism=SCRAM-SHA-256` in the URI authenticate end-to-end against the + auth server (reuses the existing `AuthServerEnvironment`, no new auth executable). + +Integration: 89→92 across 19 suites; all verified live this session. + +**Deliberately NOT done: convert the whole test suite to build clients via +`fromUri`.** Tempting ("then parsing is always exercised"), rejected because (1) +the URI can't express every config — `realm`, `serverName`, `proxyToNonOwner`, +`cacheName` (the path is ignored; cache is a separate `fromUri` arg); (2) it would +couple dozens of unrelated tests to the parser, so a parser change could redden +the whole suite — the opposite of the "cheap re-orientation" goal; (3) most tests +use a stack `RemoteCache` while `fromUri` returns a `unique_ptr`. The pure unit +test is the right home for exhaustive parsing coverage; integration only needs the +one end-to-end chain proof. + +**Caveat found: `client_intelligence=hash_distribution_aware` can't be +integration-tested on the single-server fixture.** The first draft of the second +test used that param and failed with `ISPN006017 Operation 'GET' requires +authentication`. Cause: the Dockerized single node (started with `-p 0:11222`, +ephemeral host port) advertises its *internal* container address in the topology, +so a hash-aware client reconnects to an address that, from the host, resolved to a +different server (the host's `ispn-demo`/memory-service on 11222, which requires +auth). This is the same "topology reports internal container addresses" limitation +already recorded for the retry suites (2026-09-25, Step 11c slice 2), not a client +bug. Hash-aware routing is therefore exercised only by the **multi-server** suites; +the single-server `FromUri` test uses the routing-neutral `protocol_version` +instead. (All other single-server suites likewise use BASIC intelligence.) + +## 2026-10-09 — Hot Rod URI: port parser rejected trailing garbage (stoi gap) + +**Bug fixed.** `HotRodURI::parse()` parsed the port with `std::stoi(portStr)`, which +stops at the first non-digit and returns the leading number without error — so +`hotrod://host:8080x` silently became port `8080`, and `:80 ` (trailing space) was +accepted too. Java's authoritative parser uses `Integer.parseInt`, which throws on +any non-numeric content. Fixed by passing the `pos` out-param +(`std::stoi(portStr, &consumed)`) and failing unless `consumed == portStr.size()`, +so the whole token must be digits. The range check (`1..65535`) is unchanged. +New unit test `HotRodURITest.PortWithTrailingGarbageThrows` (`:8080x`, `:80 `); +suite 243→244. Found during a post-merge review of issue #9. + +## 2026-10-09 — Hot Rod URI: percent-decode userinfo + query values (RFC 3986) + +**Bug fixed.** `HotRodURI::parse()` operated on the raw URI substrings end to end +— no `%XX` decoding anywhere. The Java client parses through `java.net.URI` and +reads the *decoded* `getUserInfo()`/`getQuery()`, so the two diverged for any +component carrying a URI-reserved or non-ASCII character. The reserved characters +`@` and `:` are the userinfo/host and user/pass delimiters, so a password +containing them (e.g. `p@ss`) has only one legal URI form — percent-encoded +(`hotrod://admin:p%40ss@node`). Java decoded that back to `p@ss`; this parser +handed the literal string `p%40ss` to `setAuthentication`, so SCRAM hashed the +wrong secret → `ISPN006017 ... requires authentication`. There was no workaround +(a literal `@` in the password misparses the authority), so credentials with +reserved characters were simply unexpressible through `fromUri`. Found during a +post-merge review of issue #9. + +**Fix.** Added a header-only `percentDecode()` helper (`%XX` → byte, everything +else verbatim) and applied it to username, password, and query-parameter values. +Query *keys* stay raw — they are matched against the fixed whitelist names, none +of which contain escapable characters. Suite 244→250 (+6: +`PasswordPercentDecoded`, `UsernamePercentDecoded`, `EncodedColonInPasswordPreserved`, +`PercentDecodeLowercaseHex`, `QueryValuePercentDecoded`, `MalformedPercentEscapeThrows`). +Release `-Werror` clean. + +**Decisions.** +1. **Decode after the raw split, not before (diverges from Java, deliberately).** + Userinfo is split into user/pass on the *raw* string (so an encoded `@`/`:` + isn't mistaken for a delimiter), then each half is decoded. Java instead + decodes the whole userinfo then `split(":")`, which truncates a password at a + decoded `:` and drops the tail — a real Java limitation. Splitting first + preserves `pa%3Ass` → `pa:ss`. For every character that is *not* a colon the + two orders agree, so this only improves the colon case. (`EncodedColonInPasswordPreserved` + locks it in.) +2. **`+` is left as-is, NOT mapped to space.** `java.net.URI` does not treat `+` + as a space — that is `application/x-www-form-urlencoded`, not RFC 3986 URI + decoding. Mapping it would *break* Java parity. +3. **Malformed escapes throw** (`%` not followed by two hex digits), matching + `java.net.URI`'s parse-time rejection and this parser's fail-fast stance, + rather than passing a stray `%` through. `BeforeSend` phase (construction-time). + +**Note.** The three whitelisted query values (`sasl_mechanism`, +`client_intelligence`, `protocol_version`) never contain escapable characters +today, so decoding them is currently a no-op; it is done anyway for Java parity +and to be ready for issue #10's `token=` (OAUTHBEARER), whose values can carry +`=`/`+`/`/`. + ## 2026-10-12 — Integration tests hung on CI: Infinispan `:latest` CLI stopped exiting on EOF **Symptom.** Every CI run from Oct 9 on hung: `GetIntegrationTests` started and @@ -736,3 +893,13 @@ integration` suite passes **18/18 green against real docker on `:latest` (16.3.0 was a rootless-podman networking artifact (reproduced identically on known-good 16.0.7, and absent under real docker), not a version regression — so `:latest` was left unpinned rather than frozen to an old release. + +## 2026-10-19 — FromUri integration suite back-filled with the 16.3 CLI-hang fix + +The `createCacheViaCLI` helper added with the Hot Rod URI work +(`tests/integration/FromUriIntegrationTest.cpp`) was copied from a pre-fix +template, so it still piped `create cache` into `cli.sh` without batch mode and +was missing from the ctest `TIMEOUT 300` group — the exact hang the 2026-10-12 +fix addressed everywhere else. Added `-f -` to its CLI invocation and listed +`FromUriIntegrationTests` in the timeout block so this suite can no longer stall +the CI job on Infinispan `:latest`. diff --git a/docs/STATUS.md b/docs/STATUS.md index ed5016f..8976fe8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -4,7 +4,7 @@ > If any other doc disagrees with this file, this file wins. Point-in-time > snapshots live in [`archive/`](archive/) and are historical only. > -> **Last updated:** 2026-10-12 +> **Last updated:** 2026-10-19 --- @@ -33,6 +33,24 @@ _(Full per-session workflow: [`WORKFLOW.md`](WORKFLOW.md).)_ _The 1–3 concrete things to do next. Keep this short and current._ +0. **Hot Rod URI support COMPLETE (issue #9).** `RemoteCache::fromUri(uri, + cacheName="")` builds a configured (not-yet-connected) client from a + `hotrod://[user:password@]host1[:port1][,host2...][?k=v&...]` connection + string; returns a `std::unique_ptr` (RemoteCache owns a mutex, so + it is non-movable). Parsing lives in a pure, header-only `HotRodURI::parse()` + (`include/hotrod/HotRodURI.h`, 27 unit tests). v1 scope: `hotrod://` only; + first host is the seed (topology discovery finds the rest); credentials enable + SCRAM; userinfo and query values are percent-decoded (RFC 3986, java.net.URI + parity — so an `@`/`/`/`:` in a password written `%40`/`%2F`/`%3A` reaches the + client as its real bytes); query whitelist `sasl_mechanism` / + `client_intelligence` / + `protocol_version` (Java `"4.0"`/`"4.1"` form, case-insensitive intelligence). + Anything unsupported throws a clear `HotRodClientException` (BeforeSend): + `hotrods://`/TLS, unknown params (`connect_timeout`, `socket_timeout`, + `use_ssl`, `token`, …), `protocol_version=AUTO`/3.x. Deferred features tracked + in **issue #10**. Rationale in [`DECISIONS.md`](DECISIONS.md) (2026-10-09). + **Next: pick a roadmap step — Step 12 (bulk ops) or benchmark the multiplexing + path.** 1. **SASL/SCRAM authentication COMPLETE — end-to-end for the whole SCRAM family.** `RemoteCache::setAuthentication(username, password, realm="default", serverName="infinispan", mechanism="SCRAM-SHA-256")` (flat setter, matching @@ -181,7 +199,14 @@ pull from here next. Step numbers follow concurrent throughput; unmeasured so far. - [ ] **Code cleanup:** read header "other params" when `paramCount > 0` (`src/operations/RemoteCache.cpp:140,182`). -- [ ] **TLS/SSL support** (transport encryption). +- [x] **Hot Rod URI support (`fromUri`)** — **shipped 2026-10-09** (issue #9, 27 + unit tests). See *Working and shipped*. +- [ ] **Hot Rod URI — deferred features (issue #10):** `hotrods://`/TLS + + cert/SNI/hostname-validation params, `connect_timeout`/`socket_timeout`, + multi-server seed list (startup failover across seeds), non-SCRAM SASL + (PLAIN/OAUTHBEARER `token`), `protocol_version=AUTO`/3.x. Each drops its + "unsupported" throw from `HotRodURI::parse` once the capability lands. +- [ ] **TLS/SSL support** (transport encryption). Also unblocks `hotrods://` above. **Longer horizon (Step 14+ advanced features):** transactions (XA), client listeners/events, counters, Ickle queries, streaming ops (4.1+), multimap, @@ -197,6 +222,13 @@ multiplexing. See "Working and shipped" below._ ## Current state (verified against code, 2026-09-16) **Working and shipped:** +- **Hot Rod URI (`RemoteCache::fromUri`)** — build a configured client from a + `hotrod://[user:pass@]host[:port][,host...][?k=v]` connection string (issue #9); + pure header-only parser `HotRodURI::parse` (`include/hotrod/HotRodURI.h`). + `hotrod://` scheme only, first host seeds the topology, SCRAM via credentials, + `sasl_mechanism`/`client_intelligence`/`protocol_version` params; userinfo and + query values are percent-decoded (java.net.URI parity); unsupported + schemes/params throw. TLS/timeouts/multi-seed/non-SCRAM deferred (issue #10) - Wire primitives (vInt, vLong, strings, byte arrays) - Protocol 4.0 headers (all conditional fields) - **SASL/SCRAM authentication (end-to-end)** — `setAuthentication(...)` runs the @@ -249,16 +281,30 @@ multiplexing. See "Working and shipped" below._ suite verified green against `:latest` = 16.3.0 on 2026-10-12. See [`DECISIONS.md`](DECISIONS.md). -**Test status (verified 2026-09-25):** -- Unit: **223/223** passing (`./build/unit_tests`, <1s) — +19 for the auth work +**Test status (verified 2026-10-09):** +- Unit: **250/250** passing (`./build/unit_tests`, <1s) — +27 for the Hot Rod URI + parser (`HotRodURITest`: grammar, multi-host, userinfo, the typed query + whitelist, port trailing-garbage rejection, RFC-3986 percent-decoding of + userinfo/query values + malformed-escape rejection, and explicit rejection of + `hotrods://`/unknown-param/`AUTO`-3.x). The + +19 for the auth work (`AuthCodecTest` body round-trips + `SaslAuthenticatorTest`, a parameterized SCRAM-SHA-1/256/512 handshake driven by a fake OpenSSL server, plus mech-not-offered / unsupported-mech / bad-server-signature failure paths); the earlier +17 for `ServerSelectionTest` (`orderKeyCandidates` + `unionNodes`, Step 11b slices 1–3; +3 keyless-ordering tests for `selectAnyServer`, Step 11c slice 1) still stands -- Integration: **89/89** passing across 18 suites (`ctest`, spins up Docker +- Integration: **92/92** passing across 19 suites (`ctest`, spins up Docker Infinispan single-server + multi-node clusters), now also green on Linux CI. + +2 for `FromUriIntegrationTest` (issue #9: a client built via `fromUri` round-trips + a value; a `?protocol_version=4.0` query param flows through to a working client) + and +1 for `AuthIntegrationTest.FromUriWithCredentialsAuthenticates` (credentials + carried in the URI authenticate end-to-end) — verified live this session. These + prove the `fromUri` parse→setter→connect→op chain; exhaustive parsing coverage + stays in the pure unit tests. (Hash-aware `client_intelligence` via URI is NOT + integration-tested on the single-server fixture — the Dockerized single node + advertises internal container addresses, so routing tests use the multi-server + suites; see DECISIONS 2026-10-09.) +3 for `RetryViewIntegrationTest` (Step 11b: `excluding()` routes around an owner; proxy-disabled owners-exhausted throw; the catch→`excluding(e)` retry loop recovers after the primary owner is killed) — verified 3/3 this session. @@ -295,6 +341,7 @@ full list (Steps 10–12, benchmarks, TLS, code TODOs). | What | Where | |------|-------| | Public API | `include/hotrod/RemoteCache.h` | +| Hot Rod URI parser | `include/hotrod/HotRodURI.h` (pure); factory `RemoteCache::fromUri` | | Authentication (SASL/SCRAM) | `include/hotrod/Authentication.h`, `AuthCodec.h`, `SaslAuthenticator.h`; `src/auth/SaslAuthenticator.cpp`, `SCRAM.cpp` | | User guide (AsciiDoc) | `documentation/index.adoc` + `documentation/topics/` | | Retry API (bound view) | `include/hotrod/RetryView.h`, `RetryContext.h`; routing helpers in `ServerSelection.h` | diff --git a/include/hotrod/HotRodURI.h b/include/hotrod/HotRodURI.h new file mode 100644 index 0000000..9aa2f86 --- /dev/null +++ b/include/hotrod/HotRodURI.h @@ -0,0 +1,261 @@ +#pragma once + +#include "HeaderCodec.h" // ClientIntelligence, Protocol::VERSION_* +#include "HotRodClientException.h" // HotRodClientException, ServerAddress, FailurePhase +#include +#include +#include +#include +#include +#include + +namespace hotrod { + +/** + * Parsed, validated Hot Rod connection URI (issue #9). + * + * Mirrors the Java client's org.infinispan.client.hotrod.impl.HotRodURI for the + * subset this client supports today. The grammar is: + * + * hotrod://[user:password@]host1[:port1][,host2[:port2]...][?k=v&...] + * + * Authoritative behaviour is the Java parser (AGENTS.md source order); the .NET + * client docs linked from the issue paraphrase some values (notably the protocol + * version form), so Java wins where they disagree. + * + * This is a *pure* value — no I/O, no RemoteCache dependency — so parsing and + * validation are unit-testable in isolation. RemoteCache::fromUri() consumes it + * and applies the flat setters. + * + * v1 scope (the rest is deferred to issue #10): + * - scheme: only "hotrod://". "hotrods://" (TLS) throws, since the transport has + * no TLS yet. + * - multiple comma-separated servers are parsed into `servers`, but the caller + * uses only the first as the seed (topology discovery finds the rest). + * - query parameters are a typed whitelist; an unknown key or a bad value for a + * known key throws. Deferred params (timeouts, TLS, use_ssl, non-SCRAM SASL) + * are therefore rejected rather than silently ignored. + */ +struct HotRodURI { + std::vector servers; // all parsed, in order (>=1) + std::optional username; + std::optional password; + std::optional saslMechanism; // e.g. "SCRAM-SHA-256" + std::optional clientIntelligence; + std::optional protocolVersion; // Protocol::VERSION_40 / _41 + + /** The first server in the list — the seed used by RemoteCache in v1. */ + const ServerAddress& seed() const { return servers.front(); } + + /** + * Parse and validate a Hot Rod URI string. + * @throws HotRodClientException (FailurePhase::BeforeSend) on any malformed or + * unsupported element. + */ + static HotRodURI parse(const std::string& uri); + +private: + [[noreturn]] static void fail(const std::string& message) { + throw HotRodClientException(message, FailurePhase::BeforeSend); + } + + static std::string toLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; + } + + /** + * Percent-decode a single URI component (RFC 3986): "%XX" → the byte with + * hex value XX, every other character verbatim. Mirrors the decoding the Java + * client gets for free from java.net.URI.getUserInfo()/getQuery(), so a + * credential or value carrying a URI-reserved character (e.g. an '@' or '/' + * in a password, written "%40"/"%2F") reaches the client as its real bytes. + * + * A '+' is left as-is (java.net.URI does NOT treat it as a space — that is + * form-encoding, not URI decoding). A malformed escape ('%' not followed by + * two hex digits) throws, matching java.net.URI's parse-time rejection and + * this parser's fail-fast stance. + * + * @param what component name, used only in the error message. + */ + static std::string percentDecode(const std::string& s, const char* what) { + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] != '%') { + out.push_back(s[i]); + continue; + } + if (i + 2 >= s.size() || + !std::isxdigit(static_cast(s[i + 1])) || + !std::isxdigit(static_cast(s[i + 2]))) { + fail(std::string("invalid percent-encoding in ") + what + ": " + s); + } + const int hi = hexValue(s[i + 1]); + const int lo = hexValue(s[i + 2]); + out.push_back(static_cast((hi << 4) | lo)); + i += 2; + } + return out; + } + + static int hexValue(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return c - 'A' + 10; // caller validated with std::isxdigit + } +}; + +inline HotRodURI HotRodURI::parse(const std::string& uri) { + // --- scheme --- + const auto schemeEnd = uri.find("://"); + if (schemeEnd == std::string::npos) { + fail("not a Hot Rod URI (missing scheme): " + uri); + } + const std::string scheme = toLower(uri.substr(0, schemeEnd)); + if (scheme == "hotrods") { + fail("hotrods:// (TLS) is not supported"); + } + if (scheme != "hotrod") { + fail("not a Hot Rod URI (unsupported scheme '" + scheme + "')"); + } + + std::string rest = uri.substr(schemeEnd + 3); // after "://" + + // --- split off query (?...) --- + std::string query; + if (const auto q = rest.find('?'); q != std::string::npos) { + query = rest.substr(q + 1); + rest = rest.substr(0, q); + } + + // --- strip any path (/...) — Java ignores it; no cache name is carried here --- + if (const auto slash = rest.find('/'); slash != std::string::npos) { + rest = rest.substr(0, slash); + } + + // --- authority: [userinfo@]hosts --- + std::string authority = rest; + HotRodURI result; + + if (const auto at = authority.find('@'); at != std::string::npos) { + const std::string userinfo = authority.substr(0, at); + authority = authority.substr(at + 1); + // Split userinfo on the *raw* string so an encoded '@'/'%40' or ':'/'%3A' + // in the password is not mistaken for a delimiter, then percent-decode + // each half (the Java client works on the decoded java.net.URI.getUserInfo()). + // Decoding after the split — rather than Java's decode-then-split(':') — + // additionally preserves an encoded ':' inside the password instead of + // truncating the tail (a known Java limitation we intentionally improve on). + const auto colon = userinfo.find(':'); + if (colon == std::string::npos) { + result.username = percentDecode(userinfo, "username"); + } else { + result.username = percentDecode(userinfo.substr(0, colon), "username"); + result.password = percentDecode(userinfo.substr(colon + 1), "password"); + } + } + + if (authority.empty()) { + fail("Hot Rod URI has no server host: " + uri); + } + + // --- hosts: host[:port][,host[:port]...] --- + size_t pos = 0; + while (pos <= authority.size()) { + const auto comma = authority.find(',', pos); + const std::string hostPort = + authority.substr(pos, comma == std::string::npos ? std::string::npos : comma - pos); + if (hostPort.empty()) { + fail("Hot Rod URI has an empty server entry: " + uri); + } + ServerAddress addr; + // lastIndexOf(':') mirrors Java; no IPv6-bracket handling (parity). + if (const auto c = hostPort.rfind(':'); c != std::string::npos) { + addr.host = hostPort.substr(0, c); + const std::string portStr = hostPort.substr(c + 1); + try { + size_t consumed = 0; + const int p = std::stoi(portStr, &consumed); + // Reject trailing garbage ("8080x") and surrounding whitespace that + // std::stoi tolerates but Integer.parseInt (the Java parser) does not. + if (consumed != portStr.size() || p < 1 || p > 65535) { + fail("Hot Rod URI has an invalid port '" + portStr + "'"); + } + addr.port = static_cast(p); + } catch (const HotRodClientException&) { + throw; + } catch (const std::exception&) { + fail("Hot Rod URI has an invalid port '" + portStr + "'"); + } + } else { + addr.host = hostPort; + addr.port = 11222; // ConfigurationProperties.DEFAULT_HOTROD_PORT + } + if (addr.host.empty()) { + fail("Hot Rod URI has an empty host: " + uri); + } + result.servers.push_back(std::move(addr)); + + if (comma == std::string::npos) break; + pos = comma + 1; + } + + // --- query parameters (typed whitelist; throw on unknown key / bad value) --- + if (!query.empty()) { + size_t qpos = 0; + while (qpos <= query.size()) { + const auto amp = query.find('&', qpos); + const std::string part = + query.substr(qpos, amp == std::string::npos ? std::string::npos : amp - qpos); + if (!part.empty()) { + const auto eq = part.find('='); + if (eq == std::string::npos) { + fail("invalid Hot Rod URI parameter (expected key=value): " + part); + } + const std::string key = part.substr(0, eq); + // Decode the value (Java reads the decoded java.net.URI.getQuery()). + // The key stays raw: it is matched against the fixed whitelist names + // below, none of which contain escapable characters. + const std::string value = percentDecode(part.substr(eq + 1), + "query parameter value"); + + if (key == "sasl_mechanism") { + // SCRAM-only is enforced later at connect(); pass through here. + result.saslMechanism = value; + } else if (key == "client_intelligence") { + const std::string v = toLower(value); + if (v == "basic") { + result.clientIntelligence = ClientIntelligence::BASIC; + } else if (v == "topology_aware") { + result.clientIntelligence = ClientIntelligence::TOPOLOGY_AWARE; + } else if (v == "hash_distribution_aware") { + result.clientIntelligence = ClientIntelligence::HASH_DISTRIBUTION_AWARE; + } else { + fail("invalid client_intelligence value '" + value + + "' (expected basic, topology_aware, or hash_distribution_aware)"); + } + } else if (key == "protocol_version") { + // Java form "major.minor"; only 4.0/4.1 are supported here. + if (value == "4.0") { + result.protocolVersion = Protocol::VERSION_40; + } else if (value == "4.1") { + result.protocolVersion = Protocol::VERSION_41; + } else { + fail("unsupported protocol_version '" + value + + "' (supported: 4.0, 4.1)"); + } + } else { + fail("unsupported Hot Rod URI parameter: " + key); + } + } + if (amp == std::string::npos) break; + qpos = amp + 1; + } + } + + return result; +} + +} // namespace hotrod diff --git a/include/hotrod/RemoteCache.h b/include/hotrod/RemoteCache.h index 02e2387..97422f8 100644 --- a/include/hotrod/RemoteCache.h +++ b/include/hotrod/RemoteCache.h @@ -42,6 +42,27 @@ class RemoteCache { */ RemoteCache(const std::string& host, uint16_t port, const std::string& cacheName); + /** + * Create a client from a Hot Rod URI (issue #9). + * + * Grammar: hotrod://[user:password@]host1[:port1][,host2...][?k=v&...] + * The returned client is configured but NOT connected — call connect() on it. + * Because RemoteCache is non-movable (it owns a mutex), this returns a + * unique_ptr rather than a value. + * + * v1 scope (see HotRodURI / issue #10 for what is deferred): only the + * hotrod:// scheme; the first host is used as the seed (topology discovery + * finds the rest); credentials enable SCRAM; the client_intelligence, + * protocol_version, and sasl_mechanism query parameters are honoured, any + * other parameter (or hotrods:// / TLS) throws. + * + * @param uri Hot Rod URI string + * @param cacheName Cache to operate on (the URI path is not used for this) + * @throws HotRodClientException on a malformed or unsupported URI + */ + static std::unique_ptr fromUri(const std::string& uri, + const std::string& cacheName = ""); + ~RemoteCache(); /** diff --git a/src/operations/RemoteCache.cpp b/src/operations/RemoteCache.cpp index 028cbab..1c52af3 100644 --- a/src/operations/RemoteCache.cpp +++ b/src/operations/RemoteCache.cpp @@ -3,6 +3,7 @@ #include "hotrod/Codec.h" #include "hotrod/HotRodClientException.h" #include "hotrod/ServerSelection.h" +#include "hotrod/HotRodURI.h" #include #include #include @@ -57,6 +58,44 @@ namespace hotrod connection_ = std::make_shared(host, port, topologyCallback); } + std::unique_ptr RemoteCache::fromUri(const std::string &uri, + const std::string &cacheName) + { + // Pure parse + validation (throws on anything malformed or unsupported). + const HotRodURI parsed = HotRodURI::parse(uri); + + // v1: the first host is the seed; topology discovery finds the rest (#10). + const ServerAddress &seed = parsed.seed(); + auto cache = std::make_unique(seed.host, seed.port, cacheName); + + if (parsed.clientIntelligence) + { + cache->setClientIntelligence(*parsed.clientIntelligence); + } + if (parsed.protocolVersion) + { + cache->setProtocolVersion(*parsed.protocolVersion); + } + if (parsed.username) + { + // Credentials present -> enable SCRAM. Mechanism from the query if given, + // else the setAuthentication default (SCRAM-SHA-256). Non-SCRAM mechanisms + // are rejected later at connect(). + const std::string password = parsed.password.value_or(""); + if (parsed.saslMechanism) + { + cache->setAuthentication(*parsed.username, password, "default", + "infinispan", *parsed.saslMechanism); + } + else + { + cache->setAuthentication(*parsed.username, password); + } + } + + return cache; + } + RemoteCache::~RemoteCache() { if (isConnected()) diff --git a/tests/integration/AuthIntegrationTest.cpp b/tests/integration/AuthIntegrationTest.cpp index a350398..ed1bac0 100644 --- a/tests/integration/AuthIntegrationTest.cpp +++ b/tests/integration/AuthIntegrationTest.cpp @@ -150,6 +150,29 @@ TEST(AuthIntegrationTest, NoCredentialsFails) { cache.disconnect(); } +// 4. Credentials carried in a Hot Rod URI authenticate end-to-end (issue #9). +// Proves the fromUri() parse -> setAuthentication -> connect -> op chain, with +// the SASL mechanism supplied as a query parameter. +TEST(AuthIntegrationTest, FromUriWithCredentialsAuthenticates) { + const std::string uri = + "hotrod://" + AuthServerEnvironment::user + ":" + AuthServerEnvironment::pass + + "@" + AuthServerEnvironment::host + ":" + std::to_string(AuthServerEnvironment::port) + + "?sasl_mechanism=SCRAM-SHA-256"; + + auto cache = RemoteCache::fromUri(uri, kAuthCache); + cache->connect(); + + const ByteArray key{'u', 'r', 'i', 'a', 'u', 't', 'h'}; + const ByteArray value{'v', '1'}; + cache->put(key, value).get(); + auto got = cache->get(key).get(); + + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(*got, value); + + cache->disconnect(); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); ::testing::AddGlobalTestEnvironment(new AuthServerEnvironment()); diff --git a/tests/integration/FromUriIntegrationTest.cpp b/tests/integration/FromUriIntegrationTest.cpp new file mode 100644 index 0000000..ebe53a9 --- /dev/null +++ b/tests/integration/FromUriIntegrationTest.cpp @@ -0,0 +1,90 @@ +#include +#include +#include "hotrod/RemoteCache.h" +#include "hotrod/HeaderCodec.h" +#include "InfinispanTestEnvironment.h" +#include +#include + +using namespace hotrod; +using namespace hotrod::test; + +/** + * Hot Rod URI Integration Tests — RemoteCache::fromUri (issue #9). + * + * The URI *parsing* is exhaustively covered by the pure unit tests + * (tests/unit/HotRodURITest.cpp). This suite closes the other half: that a client + * built from a URI actually connects and operates end-to-end against a live + * server — i.e. the parse -> flat-setter -> connect -> op chain works. + * + * Runs against the single server managed by InfinispanTestEnvironment. + */ + +namespace { + +// Create the target cache (same CLI approach as the other integration suites). +void createCacheViaCLI(const std::string& cacheName) { + std::string cmd = "docker exec " + InfinispanTestEnvironment::containerID + + " bash -c \"echo 'create cache --template=org.infinispan.DIST_SYNC " + cacheName + + "' | /opt/infinispan/bin/cli.sh -c http://admin:password@localhost:11222 -f -\" >/dev/null 2>&1"; + std::ignore = system(cmd.c_str()); +} + +std::string baseUri() { + return "hotrod://" + InfinispanTestEnvironment::host + ":" + + std::to_string(InfinispanTestEnvironment::port); +} + +} // anonymous namespace + +// A client built from a plain hotrod:// URI connects and round-trips a value. +TEST(FromUriIntegrationTest, BasicRoundTripViaUri) { + const std::string cacheName = "fromuri_basic"; + createCacheViaCLI(cacheName); + + auto cache = RemoteCache::fromUri(baseUri(), cacheName); + cache->connect(); + + const ByteArray key{'u', 'r', 'i', 'k', 'e', 'y'}; + const ByteArray value{'u', 'r', 'i', 'v', 'a', 'l'}; + cache->put(key, value).get(); + auto got = cache->get(key).get(); + + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(*got, value); + + cache->disconnect(); +} + +// A query parameter flows through to a working client: both the configured value +// and a live round-trip are asserted. protocol_version is used because it is +// routing-neutral — unlike client_intelligence, it does not change which server +// the client connects to, so it is safe against this single-server fixture +// (hash-aware routing is covered only by the multi-server suites). 4.0 is chosen +// over the 4.1 default so the assertion proves the URI set it, not the default. +TEST(FromUriIntegrationTest, ProtocolVersionParamProducesWorkingClient) { + const std::string cacheName = "fromuri_ver"; + createCacheViaCLI(cacheName); + + const std::string uri = baseUri() + "?protocol_version=4.0"; + auto cache = RemoteCache::fromUri(uri, cacheName); + EXPECT_EQ(cache->getProtocolVersion(), Protocol::VERSION_40); + + cache->connect(); + + const ByteArray key{'v', 'e', 'r'}; + const ByteArray value{'o', 'k'}; + cache->put(key, value).get(); + auto got = cache->get(key).get(); + + ASSERT_TRUE(got.has_value()); + EXPECT_EQ(*got, value); + + cache->disconnect(); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + ::testing::AddGlobalTestEnvironment(new InfinispanTestEnvironment()); + return RUN_ALL_TESTS(); +} diff --git a/tests/unit/HotRodURITest.cpp b/tests/unit/HotRodURITest.cpp new file mode 100644 index 0000000..45411fb --- /dev/null +++ b/tests/unit/HotRodURITest.cpp @@ -0,0 +1,212 @@ +#include +#include "hotrod/HotRodURI.h" + +using namespace hotrod; + +/** + * Unit tests for HotRodURI::parse() — the pure Hot Rod URI parser behind + * RemoteCache::fromUri() (issue #9). + * + * Pure (no I/O), so the grammar, the typed query whitelist, and the explicit + * rejection of the v1-deferred features (issue #10) are tested directly. + * + * Authoritative behaviour is the Java client's HotRodURI (AGENTS.md source order). + */ + +// --- happy paths ------------------------------------------------------------ + +TEST(HotRodURITest, SingleHostDefaultPort) { + HotRodURI u = HotRodURI::parse("hotrod://localhost"); + ASSERT_EQ(u.servers.size(), 1u); + EXPECT_EQ(u.seed().host, "localhost"); + EXPECT_EQ(u.seed().port, 11222); + EXPECT_FALSE(u.username.has_value()); + EXPECT_FALSE(u.clientIntelligence.has_value()); + EXPECT_FALSE(u.protocolVersion.has_value()); +} + +TEST(HotRodURITest, HostWithExplicitPort) { + HotRodURI u = HotRodURI::parse("hotrod://192.168.1.10:11322"); + EXPECT_EQ(u.seed().host, "192.168.1.10"); + EXPECT_EQ(u.seed().port, 11322); +} + +TEST(HotRodURITest, MultipleHostsParsedInOrder) { + HotRodURI u = HotRodURI::parse("hotrod://a:11222,b:11322,c"); + ASSERT_EQ(u.servers.size(), 3u); + EXPECT_EQ(u.servers[0].host, "a"); + EXPECT_EQ(u.servers[0].port, 11222); + EXPECT_EQ(u.servers[1].host, "b"); + EXPECT_EQ(u.servers[1].port, 11322); + EXPECT_EQ(u.servers[2].host, "c"); + EXPECT_EQ(u.servers[2].port, 11222); // default + EXPECT_EQ(&u.seed(), &u.servers[0]); // seed is the first host +} + +TEST(HotRodURITest, UserAndPassword) { + HotRodURI u = HotRodURI::parse("hotrod://admin:secret@localhost:11222"); + ASSERT_TRUE(u.username.has_value()); + ASSERT_TRUE(u.password.has_value()); + EXPECT_EQ(*u.username, "admin"); + EXPECT_EQ(*u.password, "secret"); + EXPECT_EQ(u.seed().host, "localhost"); +} + +TEST(HotRodURITest, UserWithoutPassword) { + HotRodURI u = HotRodURI::parse("hotrod://admin@localhost"); + ASSERT_TRUE(u.username.has_value()); + EXPECT_EQ(*u.username, "admin"); + EXPECT_FALSE(u.password.has_value()); +} + +TEST(HotRodURITest, TrailingPathIsIgnored) { + HotRodURI u = HotRodURI::parse("hotrod://localhost:11222/"); + EXPECT_EQ(u.seed().host, "localhost"); + EXPECT_EQ(u.seed().port, 11222); +} + +// --- percent-decoding (parity with java.net.URI) ---------------------------- + +// A password carrying a URI-reserved character must be percent-encoded to form a +// legal URI; the decoded bytes (not the "%40" text) must reach the client, else +// SCRAM would hash the wrong secret. Mirrors java.net.URI.getUserInfo() decoding. +TEST(HotRodURITest, PasswordPercentDecoded) { + HotRodURI u = HotRodURI::parse("hotrod://admin:p%40ss%2Fword@localhost"); + ASSERT_TRUE(u.password.has_value()); + EXPECT_EQ(*u.password, "p@ss/word"); + EXPECT_EQ(*u.username, "admin"); +} + +TEST(HotRodURITest, UsernamePercentDecoded) { + HotRodURI u = HotRodURI::parse("hotrod://ad%40min@localhost"); + ASSERT_TRUE(u.username.has_value()); + EXPECT_EQ(*u.username, "ad@min"); + EXPECT_FALSE(u.password.has_value()); +} + +// Splitting on the raw string before decoding means an encoded ':' stays part of +// the password rather than being treated as the user:pass delimiter. +TEST(HotRodURITest, EncodedColonInPasswordPreserved) { + HotRodURI u = HotRodURI::parse("hotrod://user:pa%3Ass@localhost"); + EXPECT_EQ(*u.username, "user"); + ASSERT_TRUE(u.password.has_value()); + EXPECT_EQ(*u.password, "pa:ss"); +} + +// Lowercase hex digits decode too. +TEST(HotRodURITest, PercentDecodeLowercaseHex) { + HotRodURI u = HotRodURI::parse("hotrod://u:a%2fb@h"); + EXPECT_EQ(*u.password, "a/b"); +} + +// Query values are decoded as well (java.net.URI.getQuery() is decoded). +TEST(HotRodURITest, QueryValuePercentDecoded) { + HotRodURI u = HotRodURI::parse("hotrod://h?sasl_mechanism=SCRAM%2DSHA%2D256"); + ASSERT_TRUE(u.saslMechanism.has_value()); + EXPECT_EQ(*u.saslMechanism, "SCRAM-SHA-256"); +} + +// A malformed escape (non-hex digits, or a truncated '%') is rejected, matching +// java.net.URI's parse-time rejection and this parser's fail-fast stance. +TEST(HotRodURITest, MalformedPercentEscapeThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://u:a%ZZb@h"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://u:a%4@h"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://u:a%@h"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://u:ab%@h"), HotRodClientException); +} + +// --- query parameters (typed whitelist) ------------------------------------- + +TEST(HotRodURITest, SaslMechanismParam) { + HotRodURI u = HotRodURI::parse("hotrod://u:p@localhost?sasl_mechanism=SCRAM-SHA-512"); + ASSERT_TRUE(u.saslMechanism.has_value()); + EXPECT_EQ(*u.saslMechanism, "SCRAM-SHA-512"); +} + +TEST(HotRodURITest, ClientIntelligenceCaseInsensitive) { + EXPECT_EQ(*HotRodURI::parse("hotrod://h?client_intelligence=basic").clientIntelligence, + ClientIntelligence::BASIC); + EXPECT_EQ(*HotRodURI::parse("hotrod://h?client_intelligence=TOPOLOGY_AWARE").clientIntelligence, + ClientIntelligence::TOPOLOGY_AWARE); + EXPECT_EQ(*HotRodURI::parse("hotrod://h?client_intelligence=Hash_Distribution_Aware").clientIntelligence, + ClientIntelligence::HASH_DISTRIBUTION_AWARE); +} + +TEST(HotRodURITest, ProtocolVersionJavaForm) { + EXPECT_EQ(*HotRodURI::parse("hotrod://h?protocol_version=4.0").protocolVersion, + Protocol::VERSION_40); + EXPECT_EQ(*HotRodURI::parse("hotrod://h?protocol_version=4.1").protocolVersion, + Protocol::VERSION_41); +} + +TEST(HotRodURITest, MultipleQueryParams) { + HotRodURI u = HotRodURI::parse( + "hotrod://u:p@a,b?sasl_mechanism=SCRAM-SHA-256&client_intelligence=hash_distribution_aware&protocol_version=4.1"); + EXPECT_EQ(*u.saslMechanism, "SCRAM-SHA-256"); + EXPECT_EQ(*u.clientIntelligence, ClientIntelligence::HASH_DISTRIBUTION_AWARE); + EXPECT_EQ(*u.protocolVersion, Protocol::VERSION_41); + EXPECT_EQ(u.servers.size(), 2u); +} + +// --- rejections: malformed grammar ------------------------------------------ + +TEST(HotRodURITest, MissingSchemeThrows) { + EXPECT_THROW(HotRodURI::parse("localhost:11222"), HotRodClientException); +} + +TEST(HotRodURITest, EmptyHostThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://"), HotRodClientException); +} + +TEST(HotRodURITest, EmptyServerEntryThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://a,,b"), HotRodClientException); +} + +TEST(HotRodURITest, InvalidPortThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://localhost:notaport"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://localhost:0"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://localhost:99999"), HotRodClientException); +} + +// std::stoi stops at the first non-digit and would silently accept "8080x" as 8080; +// Java's Integer.parseInt throws. The parser must reject trailing garbage too. +TEST(HotRodURITest, PortWithTrailingGarbageThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://localhost:8080x"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://localhost:80 "), HotRodClientException); +} + +TEST(HotRodURITest, QueryParamMissingEqualsThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://h?sasl_mechanism"), HotRodClientException); +} + +// --- rejections: unsupported / deferred features (issue #10) ----------------- + +TEST(HotRodURITest, HotRodsSchemeThrows) { + EXPECT_THROW(HotRodURI::parse("hotrods://localhost:11222"), HotRodClientException); +} + +TEST(HotRodURITest, UnknownParamThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://h?connect_timeout=5000"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://h?socket_timeout=5000"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://h?use_ssl=true"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://h?token=abc"), HotRodClientException); +} + +TEST(HotRodURITest, UnsupportedProtocolVersionThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://h?protocol_version=AUTO"), HotRodClientException); + EXPECT_THROW(HotRodURI::parse("hotrod://h?protocol_version=3.1"), HotRodClientException); +} + +TEST(HotRodURITest, BadClientIntelligenceValueThrows) { + EXPECT_THROW(HotRodURI::parse("hotrod://h?client_intelligence=banana"), HotRodClientException); +} + +// The parse error is surfaced as a BeforeSend failure (nothing was sent). +TEST(HotRodURITest, ParseErrorIsBeforeSend) { + try { + HotRodURI::parse("hotrods://localhost"); + FAIL() << "expected throw"; + } catch (const HotRodClientException& e) { + EXPECT_EQ(e.phase, FailurePhase::BeforeSend); + } +}