mirror of
https://github.com/ruvnet/RuView
synced 2026-07-30 18:41:42 +00:00
c88b7a12d8
Post-release deep review of the merged HOMECORE platform PR (#1451) turned up several real issues, fixed here: - homecore-hap: StoredAccessory's permanent Ed25519 signing seed and StoredSetup's SRP salt/verifier were reachable via derived Debug. Not actively triggered by any current code path, but a future logging/panic-message change (an ordinary thing to add) would have printed the accessory's compromise-forever identity key in plaintext -- there's no rotation mechanism. Added manual, redacted Debug impls matching the existing SetupCode pattern; StoreState/ PairingStore's derived Debug inherits the redaction automatically. New test pins the exact rendered output. - homecore-api: /api/history/period and /api/logbook rejected the default (no filter_entity_id/entity) call shape once a room had more than 32 known entities -- exactly how the real HA frontend calls these endpoints. The MAX_HISTORY_ENTITIES cap now only applies to an explicit, unusually-large filter list; the existing MAX_API_HISTORY_ROWS total-row budget already bounds the actual work regardless of entity count. Two new tests: unfiltered succeeds with 40 known entities, explicit oversized filter is still rejected. - homecore-api: fire_event (both REST and WS) restricted event_type to [a-z0-9_]+, but real HA integrations commonly fire mixed-case, dotted, or hyphenated types (mobile_app.notification_action, ios.action_fired). Factored the check into a single is_valid_event_type() shared by both transports, relaxed to what actually matters for server safety: non-empty, length-bounded, no control characters. - homecore-migrate: write_config_entries/write_device_registry/ write_entity_registry's atomic no-clobber write had no escape hatch -- an operator who fixed a bad source row (or wanted a fresh re-import) had to manually delete prior output first. Added a --force CLI flag (default off, preserving the existing no-clobber default and its explicit malformed_entry_is_an_error_not_a_partial_write test) that atomically replaces an existing destination. First attempt used bare fs::rename, which hit real ERROR_ACCESS_DENIED sharing-violation flakiness on Windows; switched to pre-clearing the destination then publishing through the same hard_link step the default path already uses reliably. All touched crates re-verified: homecore-hap 46 tests (was 45), homecore-api 20+6+6+7=39 tests (was 18+6+6+7=37), homecore-migrate 25 tests (was 24), all 0 failed, clippy clean under -D warnings. Also corrected the public v2051 GitHub release notes: "Wasmtime 36.0.12 component loading" was inaccurate (the crate uses the core-module API with a hand-rolled host ABI, not the Component Model/WIT) -- doc-only, not a code change. Co-Authored-By: claude-flow <ruv@ruv.net>
194 lines
6.5 KiB
Rust
194 lines
6.5 KiB
Rust
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use homecore::HomeCore;
|
|
use homecore_api::{router, LongLivedTokenStore, SharedState};
|
|
use http_body_util::BodyExt;
|
|
use tower::ServiceExt;
|
|
|
|
async fn app() -> (axum::Router, HomeCore) {
|
|
let homecore = HomeCore::new();
|
|
let tokens = LongLivedTokenStore::empty();
|
|
tokens.register("test-token").await;
|
|
let state = SharedState::with_tokens(homecore.clone(), "Test", "test", tokens);
|
|
(router(state), homecore)
|
|
}
|
|
|
|
fn post(uri: &str, body: &str) -> Request<Body> {
|
|
Request::builder()
|
|
.method("POST")
|
|
.uri(uri)
|
|
.header("authorization", "Bearer test-token")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(body.to_owned()))
|
|
.unwrap()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rest_event_is_delivered_to_domain_bus() {
|
|
let (app, homecore) = app().await;
|
|
let mut receiver = homecore.bus().subscribe_domain();
|
|
let response = app
|
|
.oneshot(post("/api/events/test_event", r#"{"answer":42}"#))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let event = receiver.recv().await.unwrap();
|
|
assert_eq!(event.event_type, "test_event");
|
|
assert_eq!(event.event_data["answer"], 42);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rest_template_uses_live_state_environment() {
|
|
let (app, _) = app().await;
|
|
let response = app
|
|
.oneshot(post("/api/template", r#"{"template":"{{ 6 * 7 }}"}"#))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
|
assert_eq!(&bytes[..], b"42");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn compatibility_matrix_is_authenticated() {
|
|
let (app, _) = app().await;
|
|
let response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/homecore/compatibility")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn history_reads_real_recorder_rows() {
|
|
use homecore::{Context, EntityId};
|
|
use homecore_recorder::Recorder;
|
|
|
|
let homecore = HomeCore::new();
|
|
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
|
|
let mut changes = homecore.states().subscribe();
|
|
homecore.states().set(
|
|
EntityId::parse("light.history_probe").unwrap(),
|
|
"on",
|
|
serde_json::json!({"brightness": 123}),
|
|
Context::new(),
|
|
);
|
|
let change = changes.recv().await.unwrap();
|
|
recorder.record_state(&change).await.unwrap();
|
|
|
|
let tokens = LongLivedTokenStore::empty();
|
|
tokens.register("test-token").await;
|
|
let state =
|
|
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
|
|
let response = router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/history/period?filter_entity_id=light.history_probe")
|
|
.header("authorization", "Bearer test-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::OK);
|
|
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
|
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
|
|
assert_eq!(body[0][0]["entity_id"], "light.history_probe");
|
|
assert_eq!(body[0][0]["state"], "on");
|
|
assert_eq!(body[0][0]["attributes"]["brightness"], 123);
|
|
}
|
|
|
|
/// The real HA frontend's history/logbook pages call these endpoints with NO
|
|
/// entity filter by design (meaning "all entities") — a real install
|
|
/// routinely has 50-500+ entities. The 32-entity cap must only reject an
|
|
/// explicit, unusually-large `filter_entity_id`/`entity` list, never the
|
|
/// default unfiltered "all entities" shape.
|
|
#[tokio::test]
|
|
async fn history_and_logbook_unfiltered_are_not_capped_by_entity_count() {
|
|
use homecore::{Context, EntityId};
|
|
use homecore_recorder::Recorder;
|
|
|
|
let homecore = HomeCore::new();
|
|
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
|
|
for i in 0..40 {
|
|
homecore.states().set(
|
|
EntityId::parse(&format!("sensor.probe_{i}")).unwrap(),
|
|
"on",
|
|
serde_json::json!({}),
|
|
Context::new(),
|
|
);
|
|
}
|
|
assert_eq!(homecore.states().all().len(), 40, "sanity: more than MAX_HISTORY_ENTITIES");
|
|
|
|
let tokens = LongLivedTokenStore::empty();
|
|
tokens.register("test-token").await;
|
|
let state =
|
|
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
|
|
let app = router(state);
|
|
|
|
let history_response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/history/period")
|
|
.header("authorization", "Bearer test-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
history_response.status(),
|
|
StatusCode::OK,
|
|
"unfiltered history with >32 known entities must not be rejected"
|
|
);
|
|
|
|
let logbook_response = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/api/logbook")
|
|
.header("authorization", "Bearer test-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
logbook_response.status(),
|
|
StatusCode::OK,
|
|
"unfiltered logbook with >32 known entities must not be rejected"
|
|
);
|
|
}
|
|
|
|
/// An explicit, unusually-large `filter_entity_id` list is still rejected —
|
|
/// only the default "no filter" shape is exempt from the cap.
|
|
#[tokio::test]
|
|
async fn history_explicit_oversized_filter_is_still_rejected() {
|
|
use homecore_recorder::Recorder;
|
|
|
|
let homecore = HomeCore::new();
|
|
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
|
|
let tokens = LongLivedTokenStore::empty();
|
|
tokens.register("test-token").await;
|
|
let state =
|
|
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
|
|
|
|
let filter: String = (0..40).map(|i| format!("sensor.probe_{i}")).collect::<Vec<_>>().join(",");
|
|
let response = router(state)
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri(format!("/api/history/period?filter_entity_id={filter}"))
|
|
.header("authorization", "Bearer test-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
|
}
|