hephaestus/crates/hephd/tests/client_mode.rs
Erich Blume 497c62a988
Some checks failed
Build / validate (pull_request) Failing after 3s
hephd: OIDC hub authentication — verification side (auth 10a)
Authenticate op exchange at the network boundary (tech-spec §13). The hub
now requires a valid OIDC bearer token on /sync/* and /rpc; local mode is
unchanged (no auth).

- heph-core: Store::authorize_owner_sub — single-tenant gate that claims the
  owner's oidc_sub on first sight, then authorizes only that sub (403 for any
  other identity). LocalStore impl over users.oidc_sub; RemoteStore stub.
- hephd auth module: TokenVerifier trait (mockable seam) + OidcVerifier
  (jsonwebtoken, rust_crypto). Strict validation: RS256 pinned, exact iss +
  aud, exp/nbf, required sub; JWKS discovered + cached, refetched on unknown
  kid (rotation). Claims/AuthError.
- Hub router takes Option<verifier>; an axum middleware on every route
  extracts the Bearer token, verifies it off the async worker, and runs the
  owner gate — 401 missing/invalid, 403 wrong identity, 503 IdP-unreachable.
  Open (no auth) when unconfigured, for local dev.
- main: --oidc-issuer/--oidc-audience enable the hub verifier (server mode).
- Security tests, all offline: stub-verifier middleware (missing/bad/valid +
  owner gate) and an adversarial battery driving OidcVerifier against an
  in-process mock IdP — rejects expired, wrong iss/aud, unknown kid, tampered
  signature, alg confusion (HS256/none), and missing sub. The RSA key + JWKS
  are generated at runtime (rsa/rand/base64 dev-deps) so no key is committed.
- tech-spec: add an end-of-v1 dependency-refresh pass to the roadmap.

108 tests green; clippy -D warnings + fmt + prek clean. Next: client-side
device-code login + keyring (10b).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 15:58:20 -07:00

99 lines
3.6 KiB
Rust

//! Client mode over real HTTP (tech-spec §3.1, slice 9b). A server runs the hub
//! router (which includes `/rpc`) over a temp `LocalStore`; a `RemoteStore`
//! proxies the `Store` API to it and we assert the calls land on the server's
//! store. The server runs on its own runtime thread, so the test thread can use
//! the blocking client without nesting runtimes.
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use heph_core::{Attention, Error, FixedClock, LocalStore, NewNode, NewTask, Store};
use hephd::sync::{self, SharedStore};
use hephd::RemoteStore;
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
/// Start the hub router over a temp `LocalStore` on an ephemeral port; return
/// its base URL. The server thread + temp dir live for the test's duration.
fn start_server() -> String {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let dir = tempfile::tempdir().unwrap();
let store =
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
let shared: SharedStore = Arc::new(Mutex::new(store));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
tx.send(listener.local_addr().unwrap()).unwrap();
let _keep = dir; // keep the temp DB alive while we serve
axum::serve(listener, sync::router(shared, None))
.await
.unwrap();
});
});
let addr = rx.recv_timeout(Duration::from_secs(5)).unwrap();
format!("http://{addr}")
}
#[test]
fn remote_store_proxies_the_store_api() {
let base = start_server();
let mut remote = RemoteStore::new(&base);
// Create + read a node round-trips through HTTP.
let node = remote
.create_node(NewNode::doc("Roof", "shingles need work"))
.unwrap();
let got = remote.get_node(&node.id).unwrap().expect("node on server");
assert_eq!(got.title, "Roof");
assert_eq!(got.body.as_deref(), Some("shingles need work"));
// A missing node is Ok(None), not an error.
assert!(remote.get_node("does-not-exist").unwrap().is_none());
// A body update materializes server-side and is full-text searchable.
remote
.update_node(&node.id, None, Some("new cedar shingles".into()))
.unwrap();
let hits = remote.search("cedar").unwrap();
assert!(hits.iter().any(|h| h.id == node.id), "search missed update");
// Tasks proxy too, and land in the Organizational list.
let task = remote
.create_task(NewTask {
title: "Renew passport".into(),
attention: Some(Attention::Red),
..Default::default()
})
.unwrap();
let fetched = remote
.get_task(&task.node_id)
.unwrap()
.expect("task on server");
assert_eq!(fetched.node_id, task.node_id);
let listed = remote.list(None, None, true).unwrap();
assert!(
listed.iter().any(|t| t.node_id == task.node_id),
"task missing from list"
);
// Read-only aggregates proxy and start empty.
assert!(remote.health().unwrap().active_count >= 1);
assert!(remote.conflicts_list().unwrap().is_empty());
}
#[test]
fn remote_store_preserves_not_found() {
let base = start_server();
let mut remote = RemoteStore::new(&base);
let err = remote
.update_node("missing", Some("x".into()), None)
.unwrap_err();
assert!(matches!(err, Error::NodeNotFound(_)), "got {err:?}");
}