mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-12 01:45:14 +02:00
Two serving changes that complete RFC-006's read side:
ServingPolicy carries the policy bundle CONTENT (digest-verified at
snapshot read) instead of a blob path — the catalog may live on object
storage, and the server must not re-read mutable state after the
snapshot. The server grows a PolicySource enum: File for omnigraph.yaml
deployments (unchanged), Inline for cluster boots, wired through
PolicyEngine::load_{graph,server}_from_source.
read_serving_snapshot_from_storage(uri) reads the applied revision
straight from a storage root, and --cluster accepts a scheme-qualified
URI (s3://bucket/prefix): config-free serving — a serving box needs only
the URI and credentials; the ledger and catalog on the bucket ARE the
deployment artifact. Bare paths keep the config-directory behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1022 lines
39 KiB
Rust
1022 lines
39 KiB
Rust
//! Server settings loading and mode inference (single vs multi).
|
|
//! Moved verbatim from tests/server.rs in the modularization.
|
|
|
|
use std::fs;
|
|
|
|
use axum::Router;
|
|
use axum::body::{Body, to_bytes};
|
|
use axum::http::{Method, Request, StatusCode};
|
|
use omnigraph::db::Omnigraph;
|
|
use omnigraph_server::{AppState, build_app};
|
|
use serde_json::Value;
|
|
use tower::ServiceExt;
|
|
|
|
|
|
mod support;
|
|
use support::*;
|
|
|
|
mod multi_graph_startup {
|
|
use super::*;
|
|
use omnigraph::storage::normalize_root_uri;
|
|
use omnigraph_server::{
|
|
GraphHandle, GraphId, GraphKey, GraphRegistry, InsertError, ServerConfig, ServerConfigMode,
|
|
load_server_settings,
|
|
};
|
|
use std::sync::Arc;
|
|
|
|
async fn build_multi_mode_app(graph_ids: &[&str]) -> (Vec<tempfile::TempDir>, Router) {
|
|
let mut dirs = Vec::with_capacity(graph_ids.len());
|
|
let mut handles = Vec::with_capacity(graph_ids.len());
|
|
for id in graph_ids {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let graph_uri = dir.path().join(id).to_str().unwrap().to_string();
|
|
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
|
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
|
handles.push(Arc::new(GraphHandle {
|
|
key: GraphKey::cluster(GraphId::try_from(*id).unwrap()),
|
|
uri: graph_uri,
|
|
engine: Arc::new(engine),
|
|
policy: None,
|
|
queries: None,
|
|
}));
|
|
dirs.push(dir);
|
|
}
|
|
let workload = omnigraph_server::workload::WorkloadController::from_env();
|
|
let state = AppState::new_multi(handles, Vec::new(), None, workload, None).unwrap();
|
|
let app = build_app(state);
|
|
(dirs, app)
|
|
}
|
|
|
|
/// Cluster route `/graphs/{graph_id}/snapshot` resolves to the right
|
|
/// engine. Two graphs side by side; assert each responds to its own
|
|
/// id and does NOT respond to the other's URL.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn cluster_routes_dispatch_per_graph_handle() {
|
|
let (_dirs, app) = build_multi_mode_app(&["alpha", "beta"]).await;
|
|
for id in ["alpha", "beta"] {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri(format!("/graphs/{id}/snapshot?branch=main"))
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp.status(),
|
|
StatusCode::OK,
|
|
"graph '{id}' must respond OK on its cluster snapshot route"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Unknown graph id under the cluster prefix yields 404 (not 500,
|
|
/// not 410 — `Gone` is reserved for the future DELETE flow).
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn cluster_route_for_unknown_graph_returns_404() {
|
|
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs/nonexistent/snapshot?branch=main")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
/// Coverage net for cluster-route regressions across every
|
|
/// protected handler — not just the few that have inner path
|
|
/// params. Bug-1 surfaced because only `/snapshot` was being
|
|
/// exercised in cluster mode, leaving the other six protected
|
|
/// routes implicitly untested. This sweep hits each one and
|
|
/// asserts the response shows the handler was reached: no 404
|
|
/// (router didn't match), no 500 with "Wrong number of path
|
|
/// arguments" (path extractor broke), no 500 with "missing
|
|
/// extension" (routing middleware didn't inject the handle).
|
|
///
|
|
/// Status codes are negative assertions because each handler's
|
|
/// happy-path inputs differ — what matters is "the request
|
|
/// reached the handler," not "the handler returned 200." The
|
|
/// individual handlers' logic is already tested in single mode.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn all_protected_cluster_routes_resolve_to_their_handler() {
|
|
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
|
|
|
// (method, path, body) — one minimal request per protected
|
|
// cluster route. Bodies are valid enough that the router and
|
|
// extractors succeed; whether the engine ultimately returns
|
|
// 200 or 4xx is per-handler and not what this test pins.
|
|
let cases: &[(Method, &str, Option<&str>)] = &[
|
|
(Method::GET, "/graphs/alpha/snapshot?branch=main", None),
|
|
(Method::GET, "/graphs/alpha/schema", None),
|
|
(Method::GET, "/graphs/alpha/branches", None),
|
|
(Method::GET, "/graphs/alpha/commits", None),
|
|
(
|
|
Method::POST,
|
|
"/graphs/alpha/read",
|
|
Some(r#"{"query_source":"query q() { return {} }"}"#),
|
|
),
|
|
(
|
|
Method::POST,
|
|
"/graphs/alpha/change",
|
|
Some(r#"{"query_source":"query q() { return {} }"}"#),
|
|
),
|
|
(
|
|
Method::POST,
|
|
"/graphs/alpha/export",
|
|
Some(r#"{"branch":"main"}"#),
|
|
),
|
|
(
|
|
Method::POST,
|
|
"/graphs/alpha/schema/apply",
|
|
Some(r#"{"schema_source":"","allow_data_loss":false}"#),
|
|
),
|
|
(Method::POST, "/graphs/alpha/ingest", Some(r#"{"data":""}"#)),
|
|
(
|
|
Method::POST,
|
|
"/graphs/alpha/branches/merge",
|
|
Some(r#"{"source":"main","target":"main"}"#),
|
|
),
|
|
];
|
|
|
|
for (method, path, body) in cases {
|
|
let req_body = body
|
|
.map(|s| Body::from(s.to_string()))
|
|
.unwrap_or_else(Body::empty);
|
|
let req = Request::builder()
|
|
.method(method.clone())
|
|
.uri(*path)
|
|
.header("content-type", "application/json")
|
|
.body(req_body)
|
|
.unwrap();
|
|
let resp = app.clone().oneshot(req).await.unwrap();
|
|
let status = resp.status();
|
|
let bytes = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let body_str = String::from_utf8_lossy(&bytes);
|
|
|
|
assert_ne!(
|
|
status,
|
|
StatusCode::NOT_FOUND,
|
|
"{} {} — router didn't match (cluster-route mounting regression). Body: {}",
|
|
method,
|
|
path,
|
|
body_str,
|
|
);
|
|
assert!(
|
|
!(status == StatusCode::INTERNAL_SERVER_ERROR
|
|
&& body_str.contains("Wrong number of path arguments")),
|
|
"{} {} — path extractor broke (Bug-1 class regression). Body: {}",
|
|
method,
|
|
path,
|
|
body_str,
|
|
);
|
|
assert!(
|
|
!(status == StatusCode::INTERNAL_SERVER_ERROR
|
|
&& body_str.to_lowercase().contains("missing extension")),
|
|
"{} {} — routing middleware didn't inject GraphHandle. Body: {}",
|
|
method,
|
|
path,
|
|
body_str,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Regression for the bot-surfaced path-extractor bug: cluster
|
|
/// routes whose inner path also captures a parameter
|
|
/// (`/graphs/{graph_id}/branches/{branch}`,
|
|
/// `/graphs/{graph_id}/commits/{commit_id}`) must extract the
|
|
/// inner param cleanly. Axum 0.8 propagates the outer `{graph_id}`
|
|
/// capture into nested handlers, so a `Path<String>` extractor
|
|
/// would see two values and fail with "Wrong number of path
|
|
/// arguments. Expected 1 but got 2." Today both DELETE branch and
|
|
/// GET commit-by-id break in multi-mode because their handlers
|
|
/// use bare `Path<String>` — this test pins the fix.
|
|
///
|
|
/// The broader `all_protected_cluster_routes_resolve_to_their_handler`
|
|
/// test sweeps the full route surface; this one stays narrowly
|
|
/// targeted at the inner-path-param shape because that's the
|
|
/// specific regression class.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn cluster_routes_with_inner_path_params_deserialize_correctly() {
|
|
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
|
|
|
// Create a branch we can then delete — DELETE /graphs/alpha/branches/feature
|
|
let create_resp = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri("/graphs/alpha/branches")
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(r#"{"name":"feature"}"#))
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
create_resp.status(),
|
|
StatusCode::OK,
|
|
"branch create on the cluster route must succeed before delete can be tested"
|
|
);
|
|
|
|
// DELETE /graphs/{graph_id}/branches/{branch} — exercises a handler
|
|
// whose only Path extractor (`branch`) is inside a nested route
|
|
// that also captures `graph_id`. The handler must pick `branch`
|
|
// by name, not by position.
|
|
let delete_resp = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::DELETE)
|
|
.uri("/graphs/alpha/branches/feature")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let delete_status = delete_resp.status();
|
|
let delete_body = to_bytes(delete_resp.into_body(), usize::MAX).await.unwrap();
|
|
assert_eq!(
|
|
delete_status,
|
|
StatusCode::OK,
|
|
"DELETE /graphs/{{id}}/branches/{{branch}} must extract `branch` cleanly. \
|
|
Body: {}",
|
|
String::from_utf8_lossy(&delete_body),
|
|
);
|
|
|
|
// GET /graphs/{graph_id}/commits/{commit_id} — same shape: the
|
|
// handler's only Path extractor is the inner `commit_id`, which
|
|
// must deserialize by name even though `graph_id` is also in scope.
|
|
// We don't know a real commit_id, but the failure mode under test
|
|
// is path extraction, not commit lookup — a 404 from the engine
|
|
// is fine; a 500 with "Wrong number of path arguments" is the bug.
|
|
let commit_resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs/alpha/commits/0000000000000000")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let commit_status = commit_resp.status();
|
|
let commit_body = to_bytes(commit_resp.into_body(), usize::MAX).await.unwrap();
|
|
let body_str = String::from_utf8_lossy(&commit_body);
|
|
assert!(
|
|
commit_status != StatusCode::INTERNAL_SERVER_ERROR
|
|
|| !body_str.contains("Wrong number of path arguments"),
|
|
"GET /graphs/{{id}}/commits/{{commit_id}} must extract `commit_id` cleanly. \
|
|
Got: {} | {}",
|
|
commit_status,
|
|
body_str,
|
|
);
|
|
}
|
|
|
|
/// Flat routes 404 in multi mode — the router only mounts under
|
|
/// `/graphs/{graph_id}/...` so `/snapshot` doesn't resolve.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn flat_routes_404_in_multi_mode() {
|
|
let (_dirs, app) = build_multi_mode_app(&["alpha"]).await;
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/snapshot?branch=main")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
/// `GraphId` validation runs at startup — a reserved name in
|
|
/// `omnigraph.yaml` produces a clear error rather than getting
|
|
/// rejected per-request.
|
|
#[tokio::test]
|
|
async fn load_server_settings_rejects_reserved_graph_id() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
graphs:
|
|
policies:
|
|
uri: /tmp/g1.omni
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let err = load_server_settings(Some(&config_path), None, None, None, None, false).await.unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("invalid graph id 'policies'"),
|
|
"expected reserved-name rejection, got: {err}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn registry_rejects_duplicate_normalized_graph_uris() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let graph_uri = dir.path().join("same").to_str().unwrap().to_string();
|
|
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
|
let engine = Arc::new(Omnigraph::init(&graph_uri, &schema).await.unwrap());
|
|
|
|
let alpha = Arc::new(GraphHandle {
|
|
key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()),
|
|
uri: graph_uri.clone(),
|
|
engine: Arc::clone(&engine),
|
|
policy: None,
|
|
queries: None,
|
|
});
|
|
let beta = Arc::new(GraphHandle {
|
|
key: GraphKey::cluster(GraphId::try_from("beta").unwrap()),
|
|
uri: format!("file://{graph_uri}/"),
|
|
engine,
|
|
policy: None,
|
|
queries: None,
|
|
});
|
|
|
|
match GraphRegistry::from_handles(vec![alpha, beta]) {
|
|
Err(InsertError::DuplicateUri(uri)) => {
|
|
assert!(
|
|
normalize_root_uri(&uri).is_ok(),
|
|
"duplicate URI should still be parseable, got {uri}"
|
|
);
|
|
}
|
|
Err(err) => panic!("expected DuplicateUri for normalized aliases, got {err:?}"),
|
|
Ok(_) => panic!("expected DuplicateUri for normalized aliases, got Ok"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn registry_stores_canonical_graph_uri() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let graph_uri = dir.path().join("canonical").to_str().unwrap().to_string();
|
|
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
|
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
|
let handle = Arc::new(GraphHandle {
|
|
key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()),
|
|
uri: format!("file://{graph_uri}/"),
|
|
engine: Arc::new(engine),
|
|
policy: None,
|
|
queries: None,
|
|
});
|
|
|
|
let registry = GraphRegistry::from_handles(vec![handle]).unwrap();
|
|
let listed = registry.list();
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].uri, graph_uri);
|
|
}
|
|
|
|
// ── Four-rule mode inference matrix ───────────────────────────────
|
|
|
|
/// Rule 1: CLI positional URI → Single.
|
|
#[tokio::test]
|
|
async fn mode_inference_cli_uri_is_single() {
|
|
let settings = load_server_settings(
|
|
None,
|
|
None,
|
|
Some("/tmp/cli.omni".to_string()),
|
|
None,
|
|
None,
|
|
true, // allow unauth so we get past the runtime-state check
|
|
)
|
|
.await
|
|
.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Single { uri, .. } => assert_eq!(uri, "/tmp/cli.omni"),
|
|
ServerConfigMode::Multi { .. } => panic!("expected Single (rule 1), got Multi"),
|
|
}
|
|
}
|
|
|
|
/// Rule 2: --target picks one graph from `graphs:` map → Single.
|
|
#[tokio::test]
|
|
async fn mode_inference_cli_target_is_single() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
graphs:
|
|
alpha:
|
|
uri: /tmp/alpha.omni
|
|
beta:
|
|
uri: /tmp/beta.omni
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let settings =
|
|
load_server_settings(Some(&config_path), None, None, Some("alpha".into()), None, true)
|
|
.await
|
|
.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Single { uri, .. } => assert_eq!(uri, "/tmp/alpha.omni"),
|
|
ServerConfigMode::Multi { .. } => panic!("expected Single (rule 2), got Multi"),
|
|
}
|
|
}
|
|
|
|
/// Rule 3: `server.graph` set → Single (target picked from config).
|
|
#[tokio::test]
|
|
async fn mode_inference_server_graph_is_single() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
graphs:
|
|
alpha:
|
|
uri: /tmp/alpha.omni
|
|
beta:
|
|
uri: /tmp/beta.omni
|
|
server:
|
|
graph: beta
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Single { uri, .. } => assert_eq!(uri, "/tmp/beta.omni"),
|
|
ServerConfigMode::Multi { .. } => panic!("expected Single (rule 3), got Multi"),
|
|
}
|
|
}
|
|
|
|
/// Rule 4: `--config` + non-empty `graphs:` + no single-mode selector → Multi.
|
|
#[tokio::test]
|
|
async fn mode_inference_config_plus_graphs_is_multi() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
graphs:
|
|
alpha:
|
|
uri: /tmp/alpha.omni
|
|
beta:
|
|
uri: /tmp/beta.omni
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Multi { graphs, .. } => {
|
|
let ids: Vec<&str> = graphs.iter().map(|g| g.graph_id.as_str()).collect();
|
|
// BTreeMap iteration order is alphabetical.
|
|
assert_eq!(ids, vec!["alpha", "beta"]);
|
|
}
|
|
ServerConfigMode::Single { .. } => panic!("expected Multi (rule 4), got Single"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mode_inference_multi_rejects_top_level_policy_file() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
policy:
|
|
file: ./policy.yaml
|
|
graphs:
|
|
alpha:
|
|
uri: /tmp/alpha.omni
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let err = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("top-level") && msg.contains("policy.file") && msg.contains("not honored"),
|
|
"expected top-level-not-honored guidance, got: {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("graphs.<graph_id>"),
|
|
"expected per-graph migration guidance, got: {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("server.policy.file"),
|
|
"expected server policy migration guidance, got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mode_inference_multi_rejects_top_level_queries() {
|
|
// Symmetric to the policy guard: a top-level `queries:` block in
|
|
// multi-graph mode is not honored (each graph uses its own), so it
|
|
// is a loud error rather than a silent no-op.
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
"queries:\n q:\n file: ./q.gq\ngraphs:\n alpha:\n uri: /tmp/alpha.omni\n",
|
|
)
|
|
.unwrap();
|
|
let err = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("queries") && msg.contains("not honored"),
|
|
"top-level queries must be rejected in multi-graph mode: {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn single_mode_named_graph_rejects_top_level_blocks() {
|
|
// Serving a graph by name (`--target`/`server.graph`) uses its
|
|
// per-graph block; a populated top-level block would be silently
|
|
// shadowed, so boot refuses and names the per-graph location.
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
"policy:\n file: ./top.yaml\ngraphs:\n prod:\n uri: /tmp/prod.omni\n",
|
|
)
|
|
.unwrap();
|
|
let err =
|
|
load_server_settings(Some(&config_path), None, None, Some("prod".to_string()), None, true)
|
|
.await
|
|
.unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("prod") && msg.contains("policy.file") && msg.contains("graphs.prod"),
|
|
"named single-mode + top-level policy must refuse, naming the graph: {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn single_mode_named_graph_uses_per_graph_policy_and_queries() {
|
|
// The identity rule: `--target prod` attaches `graphs.prod`'s own
|
|
// policy + queries, not the top-level ones (which are absent here).
|
|
let temp = tempfile::tempdir().unwrap();
|
|
fs::write(
|
|
temp.path().join("prod.gq"),
|
|
"query pq() { match { $u: User } return { $u.name } }",
|
|
)
|
|
.unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
"graphs:\n prod:\n uri: /tmp/prod.omni\n policy:\n file: ./prod-policy.yaml\n \
|
|
queries:\n pq:\n file: ./prod.gq\n",
|
|
)
|
|
.unwrap();
|
|
let settings =
|
|
load_server_settings(Some(&config_path), None, None, Some("prod".to_string()), None, true)
|
|
.await
|
|
.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Single {
|
|
graph_id,
|
|
policy_file,
|
|
queries,
|
|
..
|
|
} => {
|
|
assert_eq!(graph_id, "prod", "named single-mode keeps graph identity");
|
|
assert!(
|
|
policy_file
|
|
.as_ref()
|
|
.is_some_and(|p| p.ends_with("prod-policy.yaml")),
|
|
"per-graph policy attached: {policy_file:?}"
|
|
);
|
|
assert!(queries.lookup("pq").is_some(), "per-graph query attached");
|
|
}
|
|
other => panic!("expected Single mode, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mode_inference_normalizes_multi_graph_uris() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let graph = temp.path().join("alpha.omni");
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
r#"
|
|
graphs:
|
|
alpha:
|
|
uri: file://{}/
|
|
"#,
|
|
graph.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Multi { graphs, .. } => {
|
|
assert_eq!(graphs[0].uri, graph.to_string_lossy());
|
|
}
|
|
ServerConfigMode::Single { .. } => panic!("expected Multi"),
|
|
}
|
|
}
|
|
|
|
/// Rule 5: nothing → error with migration hint.
|
|
#[tokio::test]
|
|
async fn mode_inference_no_inputs_errors_with_migration_hint() {
|
|
let err = load_server_settings(None, None, None, None, None, true).await.unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("no graph to serve"),
|
|
"expected migration-hint error, got: {msg}"
|
|
);
|
|
}
|
|
|
|
/// Rule 4 sub-case: `--config` with empty `graphs:` map and no
|
|
/// single-mode selector → rule 5 fires (no graph to serve).
|
|
#[tokio::test]
|
|
async fn mode_inference_empty_graphs_map_errors() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(&config_path, "server:\n bind: 127.0.0.1:8080\n").unwrap();
|
|
let err = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap_err();
|
|
assert!(err.to_string().contains("no graph to serve"));
|
|
}
|
|
|
|
/// `--config` + `<URI>` together: URI wins → Single (the CLI URI
|
|
/// takes precedence over the config's graphs map).
|
|
#[tokio::test]
|
|
async fn mode_inference_cli_uri_overrides_graphs_map() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
graphs:
|
|
alpha:
|
|
uri: /tmp/alpha.omni
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let settings = load_server_settings(
|
|
Some(&config_path),
|
|
None,
|
|
Some("/tmp/cli-override.omni".to_string()),
|
|
None,
|
|
None,
|
|
true,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Single { uri, .. } => {
|
|
assert_eq!(
|
|
uri, "/tmp/cli-override.omni",
|
|
"CLI URI must win over graphs: map"
|
|
);
|
|
}
|
|
ServerConfigMode::Multi { .. } => {
|
|
panic!("expected Single (CLI URI wins), got Multi")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-graph `policy.file` is resolved relative to the config base_dir.
|
|
#[tokio::test]
|
|
async fn per_graph_policy_file_is_resolved_relative_to_base_dir() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
graphs:
|
|
alpha:
|
|
uri: /tmp/alpha.omni
|
|
policy:
|
|
file: ./policies/alpha.yaml
|
|
beta:
|
|
uri: /tmp/beta.omni
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
|
let graphs = match settings.mode {
|
|
ServerConfigMode::Multi { graphs, .. } => graphs,
|
|
_ => panic!("expected Multi"),
|
|
};
|
|
// graphs is BTreeMap-iter order (alphabetical).
|
|
let alpha = &graphs[0];
|
|
let beta = &graphs[1];
|
|
assert_eq!(alpha.graph_id, "alpha");
|
|
let omnigraph_server::PolicySource::File(alpha_policy) =
|
|
alpha.policy.as_ref().unwrap()
|
|
else {
|
|
panic!("yaml-configured policy must stay file-based");
|
|
};
|
|
assert_eq!(alpha_policy, &temp.path().join("policies/alpha.yaml"));
|
|
assert_eq!(beta.graph_id, "beta");
|
|
assert!(beta.policy.is_none());
|
|
}
|
|
|
|
/// `server.policy.file` resolves alongside the graphs map.
|
|
#[tokio::test]
|
|
async fn server_policy_file_is_resolved_relative_to_base_dir() {
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let config_path = temp.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
r#"
|
|
server:
|
|
policy:
|
|
file: ./server-policy.yaml
|
|
graphs:
|
|
alpha:
|
|
uri: /tmp/alpha.omni
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let settings = load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
|
match settings.mode {
|
|
ServerConfigMode::Multi { server_policy, .. } => {
|
|
let omnigraph_server::PolicySource::File(path) = server_policy.unwrap() else {
|
|
panic!("yaml-configured server policy must stay file-based");
|
|
};
|
|
assert_eq!(path, temp.path().join("server-policy.yaml"));
|
|
}
|
|
_ => panic!("expected Multi"),
|
|
}
|
|
}
|
|
|
|
/// `GET /graphs` must NOT leak the registry in Open mode without
|
|
/// an explicit server policy. Operators who pass `--unauthenticated`
|
|
/// opted into trusting the network for graph DATA, not for leaking
|
|
/// server topology (graph IDs + URIs, which may contain S3 bucket
|
|
/// paths or internal hostnames). Cedar gating the management
|
|
/// surface is the documented contract for `server_graphs_list`
|
|
/// ("don't leak the registry until the operator explicitly
|
|
/// authorizes it"); enforcing that contract in every runtime
|
|
/// state — not just `PolicyEnabled` — is the correct-by-design
|
|
/// closure of the open-mode hole the bot-review pass surfaced.
|
|
///
|
|
/// Today (pre-fix) this returns 200 because `authorize_request`'s
|
|
/// no-policy fallback only denies when `actor.is_some()`, so Open
|
|
/// mode (`actor: None`) falls through to `Ok(())`. The fix in the
|
|
/// next commit tightens the fallback so server-scoped actions
|
|
/// always require explicit policy.
|
|
///
|
|
/// Sort-order coverage previously lived here; it has moved to
|
|
/// `get_graphs_with_server_policy_authorizes_per_cedar` where
|
|
/// the response body is now non-empty and operator-authorized.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn get_graphs_denied_in_open_mode_without_server_policy() {
|
|
let (_dirs, app) = build_multi_mode_app(&["beta", "alpha"]).await;
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let status = resp.status();
|
|
let body = to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let body_str = String::from_utf8_lossy(&body);
|
|
assert_eq!(
|
|
status,
|
|
StatusCode::FORBIDDEN,
|
|
"GET /graphs must require an explicit server policy in every \
|
|
runtime state; Open-mode bypass would leak server topology. \
|
|
Body: {body_str}",
|
|
);
|
|
}
|
|
|
|
/// `GET /graphs` returns 405 in single mode (resource exists in the
|
|
/// API surface, just not operational without a `graphs:` map).
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn get_graphs_returns_405_in_single_mode() {
|
|
let temp = init_loaded_graph().await;
|
|
let graph = graph_path(temp.path());
|
|
let state = AppState::open(graph.to_string_lossy().to_string())
|
|
.await
|
|
.unwrap();
|
|
let app = build_app(state);
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::METHOD_NOT_ALLOWED);
|
|
}
|
|
|
|
/// `GET /graphs` requires bearer auth when tokens are configured.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn get_graphs_requires_bearer_auth_when_configured() {
|
|
use omnigraph_server::{GraphHandle, GraphId, GraphKey};
|
|
// Build a multi-mode app with bearer tokens configured.
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let graph_uri = dir.path().join("alpha").to_str().unwrap().to_string();
|
|
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
|
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
|
let handle = Arc::new(GraphHandle {
|
|
key: GraphKey::cluster(GraphId::try_from("alpha").unwrap()),
|
|
uri: graph_uri,
|
|
engine: Arc::new(engine),
|
|
policy: None,
|
|
queries: None,
|
|
});
|
|
let tokens = vec![("act-andrew".to_string(), "secret-token".to_string())];
|
|
let workload = omnigraph_server::workload::WorkloadController::from_env();
|
|
let state = AppState::new_multi(vec![handle], tokens, None, workload, None).unwrap();
|
|
let app = build_app(state);
|
|
|
|
// No Authorization header → 401.
|
|
let resp_no_auth = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp_no_auth.status(), StatusCode::UNAUTHORIZED);
|
|
|
|
// With auth but no server policy → 403 (default-deny, since
|
|
// GraphList is not Read).
|
|
let resp_authed = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs")
|
|
.header("authorization", "Bearer secret-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp_authed.status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
/// `GET /graphs` with a server policy that allows `graph_list` → 200
|
|
/// and returns the registry sorted alphabetically by `graph_id`.
|
|
/// `GET /graphs` with a server policy that does NOT allow
|
|
/// `graph_list` (viewer group) → 403.
|
|
///
|
|
/// This test owns the alphabetical-sort coverage that previously
|
|
/// lived in `get_graphs_lists_registered_graphs_in_multi_mode`.
|
|
/// That test now asserts denial in Open mode (server-scoped actions
|
|
/// require explicit policy in every runtime state), so the positive
|
|
/// body-shape assertions need a home where the response is
|
|
/// operator-authorized — here.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn get_graphs_with_server_policy_authorizes_per_cedar() {
|
|
use omnigraph_policy::PolicyEngine;
|
|
use omnigraph_server::{GraphHandle, GraphId, GraphKey};
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
// Two graphs deliberately registered in non-alphabetical order
|
|
// so the test would fail if the handler relied on insertion
|
|
// order instead of server-side sorting.
|
|
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
|
let mut handles = Vec::new();
|
|
for id in ["beta", "alpha"] {
|
|
let graph_uri = dir.path().join(id).to_str().unwrap().to_string();
|
|
let engine = Omnigraph::init(&graph_uri, &schema).await.unwrap();
|
|
handles.push(Arc::new(GraphHandle {
|
|
key: GraphKey::cluster(GraphId::try_from(id).unwrap()),
|
|
uri: graph_uri,
|
|
engine: Arc::new(engine),
|
|
policy: None,
|
|
queries: None,
|
|
}));
|
|
}
|
|
|
|
// Server policy: admins can graph_list, viewers cannot.
|
|
let policy_path = dir.path().join("server-policy.yaml");
|
|
fs::write(
|
|
&policy_path,
|
|
r#"
|
|
version: 1
|
|
groups:
|
|
admins: [act-andrew]
|
|
viewers: [act-bruno]
|
|
rules:
|
|
- id: admins-list-graphs
|
|
allow:
|
|
actors: { group: admins }
|
|
actions: [graph_list]
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let server_policy = PolicyEngine::load_server(&policy_path).unwrap();
|
|
|
|
let tokens = vec![
|
|
("act-andrew".to_string(), "andrew-token".to_string()),
|
|
("act-bruno".to_string(), "bruno-token".to_string()),
|
|
];
|
|
let workload = omnigraph_server::workload::WorkloadController::from_env();
|
|
let state =
|
|
AppState::new_multi(handles, tokens, Some(server_policy), workload, None).unwrap();
|
|
let app = build_app(state);
|
|
|
|
// Admin → 200, body returns both graphs alphabetically sorted.
|
|
let resp_admin = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs")
|
|
.header("authorization", "Bearer andrew-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp_admin.status(),
|
|
StatusCode::OK,
|
|
"admin must be allowed graph_list"
|
|
);
|
|
let body = to_bytes(resp_admin.into_body(), usize::MAX).await.unwrap();
|
|
let json: Value = serde_json::from_slice(&body).unwrap();
|
|
let graphs = json["graphs"].as_array().unwrap();
|
|
assert_eq!(graphs.len(), 2, "response must list both registered graphs");
|
|
assert_eq!(
|
|
graphs[0]["graph_id"].as_str().unwrap(),
|
|
"alpha",
|
|
"server must sort graphs alphabetically by graph_id (insertion order was 'beta', 'alpha')"
|
|
);
|
|
assert_eq!(graphs[1]["graph_id"].as_str().unwrap(), "beta");
|
|
|
|
// Viewer → 403
|
|
let resp_viewer = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::GET)
|
|
.uri("/graphs")
|
|
.header("authorization", "Bearer bruno-token")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
resp_viewer.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"viewer must be denied graph_list (Cedar gate)"
|
|
);
|
|
}
|
|
|
|
/// Loads an `omnigraph.yaml` with two graphs and verifies multi-mode
|
|
/// inference plus graph entry resolution. Cluster-route dispatch is
|
|
/// covered by the route tests above.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn server_settings_load_multi_graph_config_entries() {
|
|
let cfg_dir = tempfile::tempdir().unwrap();
|
|
// Real graph storage dirs (the URIs in the config must point to
|
|
// a graph init-able location).
|
|
let alpha_dir = cfg_dir.path().join("alpha.omni");
|
|
let beta_dir = cfg_dir.path().join("beta.omni");
|
|
let schema = fs::read_to_string(fixture("test.pg")).unwrap();
|
|
Omnigraph::init(alpha_dir.to_str().unwrap(), &schema)
|
|
.await
|
|
.unwrap();
|
|
Omnigraph::init(beta_dir.to_str().unwrap(), &schema)
|
|
.await
|
|
.unwrap();
|
|
|
|
let config_path = cfg_dir.path().join("omnigraph.yaml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
r#"
|
|
graphs:
|
|
alpha:
|
|
uri: {alpha}
|
|
beta:
|
|
uri: {beta}
|
|
"#,
|
|
alpha = alpha_dir.display(),
|
|
beta = beta_dir.display(),
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let settings: ServerConfig =
|
|
load_server_settings(Some(&config_path), None, None, None, None, true).await.unwrap();
|
|
assert!(matches!(settings.mode, ServerConfigMode::Multi { .. }));
|
|
|
|
match settings.mode {
|
|
ServerConfigMode::Multi { graphs, .. } => {
|
|
assert_eq!(graphs.len(), 2);
|
|
let ids: Vec<&str> = graphs.iter().map(|g| g.graph_id.as_str()).collect();
|
|
assert_eq!(ids, vec!["alpha", "beta"]);
|
|
}
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|