Skip to content

test: add an in-memory fake apiserver and make TunnelService testable - #31

Draft
rawkode wants to merge 1 commit into
developfrom
claude/tunnel-service-tests
Draft

rawkode wants to merge 1 commit into
developfrom
claude/tunnel-service-tests

Conversation

@rawkode

@rawkode rawkode commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

TunnelService's CRUD orchestration — create/update/delete/enable, orphan cleanup, roughly twenty kube API calls across five CRDs — had zero test coverage. Every ProjectControlPlaneClient was built by a real TLS kube::Client against a real cluster URL, so there was no seam to test it against.

The fake apiserver

New crate::fake_apiserver (test-only, #[cfg(test)]) implements tower::Service<http::Request<kube::client::Body>> and is handed directly to kube::Client::new. It understands the REST conventions this crate actually uses against a control plane:

  • list, with the dotted-JSON-path fieldSelector clauses this crate's own selectors use (status.connectionDetails.publicKey.id=..., spec.connectorRef.name=...)
  • get
  • create, honouring metadata.generateName
  • RFC 7386 JSON merge patch on both the main resource and /status — the only patch strategy Patch::Merge produces
  • delete

Both namespaced and cluster-scoped (ConnectorClass) resources. Error responses are shaped like a real apiserver's Status object, which is what kube::Error::Api and this crate's own classify_list_error parse. It does not model RBAC, admission, controllers, or resourceVersion — it's a store, not a cluster.

The seam

ProjectControlPlaneClient now builds its kube::Client through a ClientBuilder closure field instead of calling Self::build_kube_client directly at each call site. Production always uses the real TLS builder; a test can override it. The override lives on DatumCloudClient (#[cfg(test)] pub(crate) fn with_test_client_builder), since every TunnelService method reaches its kube client via datum.project_control_plane_client(). The field and its getter are not cfg-gated — only the setter is — so ProjectControlPlaneClient::new doesn't need a test-only branch of its own.

This means token rotation and the auth-watch background task run through the exact same code as production, just pointed at an in-memory store. The seam only swaps out the TLS handshake.

New tests

In tunnels.rs:

  • create_project — including connector reuse across two tunnels in the same project
  • list_project_with_orphans — separates a referenced connector from a seeded orphan
  • delete_project — removes HTTPProxy, ConnectorAdvertisement, and the connector on its last reference
  • set_enabled_project — toggling the advertisement's existence
  • update_project's no-op-patch idempotency guard, asserted via metadata.generation not moving on an unchanged spec, and moving on a real change
  • cleanup_orphaned_connectors_project — deletes an orphan connector and its leftover advertisement, leaves the referenced one alone

These build a real ListenNode (the same pattern node.rs's own existing test already uses) rather than mocking iroh.

A caveat worth being upfront about

ensure_connector only populates a connector's status.connectionDetails — which find_connector's field selector matches on — once the iroh endpoint has a relay URL. These tests wait for one the same bounded way bin/src/main.rs already does before its own first connector-touching call. That's a real network dependency on relay reachability, layered on top of fake_apiserver by ListenNode — fake_apiserver itself has none. It resolves in well under a second in this environment; the whole new suite adds about six seconds to the run. If relay egress is ever blocked in CI, these seven tests (not the rest of the suite) would hang up to 10s each before failing.

Verification

Check Result
cargo fmt --all --check clean
cargo clippy --workspace --all-targets exit 0, with and without --features integration-tests
cargo test --workspace 97 passed, 0 failed (up from 84), repeated 3 times, no flakiness
Go (connect-plugin) untouched

Generated by Claude Code

TunnelService's CRUD orchestration (create/update/delete/enable/orphan
cleanup, ~20 kube API calls across five CRDs) had zero test coverage.
Every ProjectControlPlaneClient was built by a real TLS kube::Client
against a real cluster URL, so there was no seam to test it against.

New crate::fake_apiserver (test-only, #[cfg(test)]) implements
tower::Service<http::Request<kube::client::Body>> and is handed
directly to kube::Client::new. It understands the REST conventions
this crate actually uses against a control plane: list (with the
dotted-JSON-path fieldSelector clauses this crate's own selectors use),
get, create (honouring metadata.generateName), RFC 7386 JSON merge
patch on both the main resource and /status (the only patch strategy
Patch::Merge produces), and delete, for both namespaced and
cluster-scoped (ConnectorClass) resources. Error responses are shaped
like a real apiserver's Status object, which is what kube::Error::Api
and this crate's own classify_list_error parse. It does not model RBAC,
admission, controllers, or resourceVersion — it is a store, not a
cluster.

The seam to reach it: ProjectControlPlaneClient now builds its
kube::Client through a ClientBuilder closure field instead of calling
Self::build_kube_client directly at each call site — production always
uses the real TLS builder, but a test can override it. The override
lives on DatumCloudClient (a #[cfg(test)] pub(crate) setter,
with_test_client_builder), which is where the override needs to live
since every TunnelService method reaches its kube client via
datum.project_control_plane_client(). The field and its (production-
reachable) getter are not cfg-gated, so ProjectControlPlaneClient::new
doesn't need its own test-only branch; only the setter is. This means
token rotation and the auth-watch background task run through the exact
same code as production, just pointed at an in-memory store — the seam
swaps out the TLS handshake, nothing else.

New tests in tunnels.rs cover create_project (including connector reuse
across two tunnels in the same project), list_project_with_orphans,
delete_project, set_enabled_project, update_project's no-op-patch
idempotency guard (asserted via metadata.generation not moving), and
cleanup_orphaned_connectors_project. They build a real ListenNode (same
pattern node.rs's own test already uses) rather than mocking iroh.

Caveat worth being upfront about: ensure_connector only populates a
connector's status.connectionDetails (which find_connector's field
selector matches on) once the iroh endpoint has a relay URL, so these
tests wait for one the same bounded way bin/src/main.rs already does
before its own first connector-touching call. That is a real network
dependency on relay reachability, layered on top of fake_apiserver by
ListenNode — fake_apiserver itself has none. It resolves in well under
a second here; the whole new suite adds about six seconds to the run.

This commit was created with the assistance of a LLM.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant