generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Failing after 3s
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>
99 lines
3.6 KiB
Rust
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:?}");
|
|
}
|