mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
The server already dropped omnigraph.yaml (cluster-only boot). This removes the CLI's last use of the legacy `OmnigraphConfig`: graphs are addressed only via `--store`/`--server`/`--cluster`/`--profile`/operator defaults, and actor, output format, and bearer credentials come from `~/.omnigraph/config.yaml`. After this change no CLI command reads `omnigraph.yaml` except `config migrate`. Resolvers (helpers.rs): drop every legacy fallback — - `resolve_actor` → `--as` > `operator.actor` (no `cli.actor`); - `resolve_read_format` → `--json`/`--format` > alias > `defaults.output`; - `resolve_branch`/`resolve_read_target` → `--branch` > alias > "main"; - `resolve_uri`/`resolve_cli_graph` → scope path only; an absent address is a loud error; - `resolve_remote_bearer_token` → operator keyed chain + `OMNIGRAPH_BEARER_TOKEN`. `GraphClient::resolve`/`resolve_with_policy` drop the `&OmnigraphConfig` param; direct-store access carries no Cedar policy (policy lives in the cluster/server). Flags (cli.rs): remove `--config` from every data/query command; it stays only on `cluster *` (the cluster dir) and `config migrate` (the legacy path). Re-home control-plane tooling to `--cluster` (RFC-011): - `policy validate|test|explain` source the Cedar bundle from the cluster's applied policies; `--graph` picks a graph's bundle; `policy test` takes `--tests <file>`; - `queries list|validate` source the registry + schemas from the cluster serving snapshot; `--graph` scopes to one graph; - `lint` requires `--schema` (offline) or a direct/cluster graph target; - `schema plan`/`lint` route their graph-target through the shared direct-scope resolver so `--store`/`--profile`/`defaults.store` addressing works. Tests migrate from `omnigraph.yaml` fixtures to `--store`/operator-config/ `--cluster` (converged-cluster fixtures); the now-impossible command-path RFC-008 tests are deleted (`config migrate` coverage kept). The `OmnigraphConfig` type, `load_config`/deprecation machinery, and `config migrate` are removed in a follow-up. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1197 lines
37 KiB
Rust
1197 lines
37 KiB
Rust
mod support;
|
|
|
|
use std::fs;
|
|
|
|
use omnigraph::db::Omnigraph;
|
|
use reqwest::blocking::Client;
|
|
use serde_json::json;
|
|
|
|
use support::*;
|
|
|
|
/// Graph id every served test addresses (`--server <url> --graph GRAPH_ID`).
|
|
/// RFC-011: the server is cluster-only, so a graph selector is always required
|
|
/// — even for a single-graph cluster.
|
|
const GRAPH_ID: &str = "knowledge";
|
|
|
|
/// Graph-bound Cedar bundle for the policy-flavored remote tests. `act-bruno`
|
|
/// (team) reads + writes unprotected branches; `act-ragnor` (admins) merges
|
|
/// into protected `main`.
|
|
const REMOTE_POLICY_E2E_YAML: &str = r#"
|
|
version: 1
|
|
groups:
|
|
team: [act-bruno]
|
|
admins: [act-ragnor]
|
|
protected_branches: [main]
|
|
rules:
|
|
- id: team-read
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [read]
|
|
branch_scope: any
|
|
- id: team-branch-create
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [branch_create]
|
|
target_branch_scope: unprotected
|
|
- id: team-write-unprotected
|
|
allow:
|
|
actors: { group: team }
|
|
actions: [change]
|
|
branch_scope: unprotected
|
|
- id: admins-promote
|
|
allow:
|
|
actors: { group: admins }
|
|
actions: [branch_merge]
|
|
target_branch_scope: protected
|
|
"#;
|
|
|
|
/// Server-scoped bundle granting `act-admin` the `graph_list` action so
|
|
/// `GET /graphs` succeeds.
|
|
const GRAPH_LIST_SERVER_POLICY_YAML: &str = r#"
|
|
version: 1
|
|
groups:
|
|
admins: [act-admin]
|
|
rules:
|
|
- id: admins-can-list-graphs
|
|
allow:
|
|
actors: { group: admins }
|
|
actions: [graph_list]
|
|
"#;
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_server_and_cli_end_to_end_flow() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
// The served graph's storage root — used for embedded-side cross checks.
|
|
let served_root = cluster.path().join("graphs").join(format!("{GRAPH_ID}.omni"));
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mutation_file = temp.path().join("system-remote-change.gq");
|
|
fs::write(
|
|
&mutation_file,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let client = Client::new();
|
|
|
|
let health = client
|
|
.get(format!("{}/healthz", server.base_url))
|
|
.send()
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json::<serde_json::Value>()
|
|
.unwrap();
|
|
assert_eq!(health["status"], "ok");
|
|
|
|
let local_snapshot = parse_stdout_json(&output_success(
|
|
cli().arg("snapshot").arg(&served_root).arg("--json"),
|
|
));
|
|
let snapshot = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("snapshot")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(snapshot["branch"], "main");
|
|
assert_eq!(snapshot["tables"], local_snapshot["tables"]);
|
|
|
|
let local_read = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--store")
|
|
.arg(&served_root)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--json"),
|
|
));
|
|
let read_payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(read_payload, local_read);
|
|
assert_eq!(read_payload["row_count"], 1);
|
|
assert_eq!(read_payload["rows"][0]["p.name"], "Alice");
|
|
|
|
// Served write: no `--as` (the server resolves the actor; here the server
|
|
// is `--unauthenticated`, so the actor is the server default).
|
|
let change_payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Mina","age":28}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(change_payload["affected_nodes"], 1);
|
|
|
|
let query_source = fs::read_to_string(fixture("test.gq")).unwrap();
|
|
let http_read = client
|
|
.post(format!("{}/graphs/{GRAPH_ID}/read", server.base_url))
|
|
.json(&json!({
|
|
"branch": "main",
|
|
"query_source": query_source,
|
|
"query_name": "get_person",
|
|
"params": { "name": "Mina" }
|
|
}))
|
|
.send()
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json::<serde_json::Value>()
|
|
.unwrap();
|
|
assert_eq!(http_read["row_count"], 1);
|
|
assert_eq!(http_read["rows"][0]["p.name"], "Mina");
|
|
|
|
let local_verify = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--store")
|
|
.arg(&served_root)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Mina"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(local_verify["row_count"], 1);
|
|
assert_eq!(local_verify["rows"][0]["p.name"], "Mina");
|
|
|
|
// CLI inline source over the HTTP transport (--server). Confirms inline
|
|
// source survives the remote-execution path identically to file-based
|
|
// queries.
|
|
let inline_remote_read = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("-e")
|
|
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Mina"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(inline_remote_read["row_count"], 1);
|
|
assert_eq!(inline_remote_read["rows"][0]["p.name"], "Mina");
|
|
|
|
let inline_remote_change = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query-string")
|
|
.arg("query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Inline","age":42}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(inline_remote_change["affected_nodes"], 1);
|
|
|
|
// `POST /graphs/{id}/query` happy path directly.
|
|
let http_query = client
|
|
.post(format!("{}/graphs/{GRAPH_ID}/query", server.base_url))
|
|
.json(&json!({
|
|
"branch": "main",
|
|
"query": "query find($name: String) { match { $p: Person { name: $name } } return { $p.name } }",
|
|
"params": { "name": "Inline" }
|
|
}))
|
|
.send()
|
|
.unwrap()
|
|
.error_for_status()
|
|
.unwrap()
|
|
.json::<serde_json::Value>()
|
|
.unwrap();
|
|
assert_eq!(http_query["row_count"], 1);
|
|
assert_eq!(http_query["rows"][0]["p.name"], "Inline");
|
|
|
|
// `POST /graphs/{id}/query` rejects mutations with 400.
|
|
let http_query_mutation = client
|
|
.post(format!("{}/graphs/{GRAPH_ID}/query", server.base_url))
|
|
.json(&json!({
|
|
"branch": "main",
|
|
"query": "query bad($name: String, $age: I32) { insert Person { name: $name, age: $age } }",
|
|
"params": { "name": "Nope", "age": 1 }
|
|
}))
|
|
.send()
|
|
.unwrap();
|
|
assert_eq!(http_query_mutation.status(), reqwest::StatusCode::BAD_REQUEST);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_schema_apply_via_cli_updates_graph() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
let served_root = cluster.path().join("graphs").join(format!("{GRAPH_ID}.omni"));
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let next_schema = temp.path().join("next.pg");
|
|
fs::write(
|
|
&next_schema,
|
|
fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
|
" age: I32?\n}",
|
|
" age: I32?\n nickname: String?\n}",
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--schema")
|
|
.arg(&next_schema)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(payload["applied"], true);
|
|
|
|
let db = tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(Omnigraph::open(served_root.to_string_lossy().as_ref()))
|
|
.unwrap();
|
|
assert!(
|
|
db.catalog().node_types["Person"]
|
|
.properties
|
|
.contains_key("nickname")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_schema_apply_rejects_unsupported_plan() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let breaking_schema = temp.path().join("breaking.pg");
|
|
fs::write(
|
|
&breaking_schema,
|
|
fs::read_to_string(fixture("test.pg"))
|
|
.unwrap()
|
|
.replace("age: I32?", "age: I64?"),
|
|
)
|
|
.unwrap();
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--schema")
|
|
.arg(&breaking_schema),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("changing property type"),
|
|
"expected unsupported-plan error, got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_schema_apply_rejects_when_non_main_branch_exists() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
|
|
// Create a non-main branch over the served path so the schema-apply
|
|
// single-branch precondition fails.
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature"),
|
|
);
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let next_schema = temp.path().join("next.pg");
|
|
fs::write(
|
|
&next_schema,
|
|
fs::read_to_string(fixture("test.pg")).unwrap().replace(
|
|
" age: I32?\n}",
|
|
" age: I32?\n nickname: String?\n}",
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let output = output_failure(
|
|
cli()
|
|
.arg("schema")
|
|
.arg("apply")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--schema")
|
|
.arg(&next_schema),
|
|
);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("schema apply requires a graph with only main"),
|
|
"expected single-branch precondition error, got: {stderr}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_read_preserves_projection_order_in_json_and_csv() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let ordered_query = temp.path().join("ordered-remote.gq");
|
|
fs::write(
|
|
&ordered_query,
|
|
r#"
|
|
query ordered_person($name: String) {
|
|
match {
|
|
$p: Person { name: $name }
|
|
}
|
|
return { $p.age, $p.name }
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let json_payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&ordered_query)
|
|
.arg("ordered_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--json"),
|
|
));
|
|
let columns = json_payload["columns"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|value| value.as_str().unwrap())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(columns, vec!["p.age", "p.name"]);
|
|
|
|
let csv = stdout_string(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&ordered_query)
|
|
.arg("ordered_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Alice"}"#)
|
|
.arg("--format")
|
|
.arg("csv"),
|
|
));
|
|
let mut lines = csv.lines();
|
|
assert_eq!(lines.next().unwrap(), "p.age,p.name");
|
|
assert_eq!(lines.next().unwrap(), "30,Alice");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_branch_create_list_merge_flow() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mutation_file = temp.path().join("system-remote-branch-change.gq");
|
|
fs::write(
|
|
&mutation_file,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let initial = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("list")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(initial["branches"], json!(["main"]));
|
|
|
|
let created = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(created["from"], "main");
|
|
assert_eq!(created["name"], "feature");
|
|
|
|
let listed = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("list")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(listed["branches"], json!(["feature", "main"]));
|
|
|
|
let changed = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Zoe","age":33}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(changed["branch"], "feature");
|
|
assert_eq!(changed["affected_nodes"], 1);
|
|
|
|
let merged = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("merge")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("feature")
|
|
.arg("--into")
|
|
.arg("main")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(merged["source"], "feature");
|
|
assert_eq!(merged["target"], "main");
|
|
assert_eq!(merged["outcome"], "fast_forward");
|
|
|
|
let verify = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Zoe"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(verify["row_count"], 1);
|
|
assert_eq!(verify["rows"][0]["p.name"], "Zoe");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_branch_delete_removes_branch() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
|
|
parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature")
|
|
.arg("--json"),
|
|
));
|
|
|
|
let deleted = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("delete")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("feature")
|
|
// Served target is non-local → destructive-confirm gate (RFC-011 D9).
|
|
.arg("--yes")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(deleted["name"], "feature");
|
|
|
|
let listed = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("list")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(listed["branches"], json!(["main"]));
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_export_round_trips_full_branch_graph() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mutation_file = temp.path().join("system-remote-export-change.gq");
|
|
fs::write(
|
|
&mutation_file,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
|
|
query add_friend($from: String, $to: String) {
|
|
insert Knows { from: $from, to: $to }
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature"),
|
|
);
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("insert_person")
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Eve","age":29}"#)
|
|
.arg("--json"),
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("change")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("add_friend")
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--params")
|
|
.arg(r#"{"from":"Alice","to":"Eve"}"#)
|
|
.arg("--json"),
|
|
);
|
|
|
|
let exported = stdout_string(&output_success(
|
|
cli()
|
|
.arg("export")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--jsonl"),
|
|
));
|
|
let export_path = temp.path().join("system-remote-exported.jsonl");
|
|
fs::write(&export_path, &exported).unwrap();
|
|
let imported_graph = temp.path().join("imported-remote-export.omni");
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("init")
|
|
.arg("--schema")
|
|
.arg(fixture("test.pg"))
|
|
.arg(&imported_graph),
|
|
);
|
|
output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--mode")
|
|
.arg("overwrite")
|
|
.arg("--data")
|
|
.arg(&export_path)
|
|
.arg(&imported_graph),
|
|
);
|
|
|
|
let snapshot = parse_stdout_json(&output_success(
|
|
cli().arg("snapshot").arg(&imported_graph).arg("--json"),
|
|
));
|
|
assert_eq!(
|
|
snapshot["tables"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|table| table["table_key"] == "node:Person")
|
|
.unwrap()["row_count"],
|
|
5
|
|
);
|
|
assert_eq!(
|
|
snapshot["tables"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|table| table["table_key"] == "edge:Knows")
|
|
.unwrap()["row_count"],
|
|
4
|
|
);
|
|
|
|
let eve = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--store")
|
|
.arg(&imported_graph)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Eve"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(eve["row_count"], 1);
|
|
assert_eq!(eve["rows"][0]["p.name"], "Eve");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_ingest_creates_review_branch_and_keeps_it_readable() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let ingest_data = temp.path().join("system-remote-ingest.jsonl");
|
|
fs::write(
|
|
&ingest_data,
|
|
r#"{"type":"Person","data":{"name":"Zoe","age":33}}
|
|
{"type":"Person","data":{"name":"Bob","age":26}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let ingest_payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("ingest")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--data")
|
|
.arg(&ingest_data)
|
|
.arg("--branch")
|
|
.arg("feature-ingest")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(ingest_payload["branch"], "feature-ingest");
|
|
assert_eq!(ingest_payload["base_branch"], "main");
|
|
assert_eq!(ingest_payload["branch_created"], true);
|
|
assert_eq!(ingest_payload["mode"], "merge");
|
|
assert_eq!(ingest_payload["tables"][0]["table_key"], "node:Person");
|
|
assert_eq!(ingest_payload["tables"][0]["rows_loaded"], 2);
|
|
|
|
let feature_snapshot = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("snapshot")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--branch")
|
|
.arg("feature-ingest")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(feature_snapshot["branch"], "feature-ingest");
|
|
|
|
let zoe = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--branch")
|
|
.arg("feature-ingest")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Zoe"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(zoe["row_count"], 1);
|
|
assert_eq!(zoe["rows"][0]["p.name"], "Zoe");
|
|
}
|
|
|
|
/// The unified `load` works against remote graphs through the server's
|
|
/// `/ingest` endpoint: without `--from` a missing branch is a hard error
|
|
/// (no implicit fork), with `--from` it forks like ingest did.
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_load_round_trips_and_requires_from_for_new_branches() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let extra = temp.path().join("system-remote-load.jsonl");
|
|
fs::write(
|
|
&extra,
|
|
r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Missing branch without --from: refused remotely, nothing created.
|
|
let failure = output_failure(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--mode")
|
|
.arg("merge")
|
|
.arg("--data")
|
|
.arg(&extra)
|
|
.arg("--branch")
|
|
.arg("feature-load"),
|
|
);
|
|
assert!(
|
|
String::from_utf8_lossy(&failure.stderr).contains("feature-load"),
|
|
"error should name the missing branch"
|
|
);
|
|
|
|
// With --from, the remote load forks and lands the rows.
|
|
let payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("load")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--mode")
|
|
.arg("merge")
|
|
.arg("--data")
|
|
.arg(&extra)
|
|
.arg("--branch")
|
|
.arg("feature-load")
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(payload["branch"], "feature-load");
|
|
assert_eq!(payload["base_branch"], "main");
|
|
assert_eq!(payload["branch_created"], true);
|
|
assert_eq!(payload["nodes_loaded"], 1);
|
|
|
|
let snapshot = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("snapshot")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--branch")
|
|
.arg("feature-load")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(snapshot["branch"], "feature-load");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_ingest_reuses_existing_branch_and_merges_updates() {
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, None);
|
|
let server = spawn_server_with_cluster(cluster.path());
|
|
|
|
output_success(
|
|
cli()
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature-ingest"),
|
|
);
|
|
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let ingest_data = temp.path().join("system-remote-ingest-merge.jsonl");
|
|
fs::write(
|
|
&ingest_data,
|
|
r#"{"type":"Person","data":{"name":"Bob","age":26}}
|
|
{"type":"Person","data":{"name":"Zoe","age":33}}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let ingest_payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("ingest")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--data")
|
|
.arg(&ingest_data)
|
|
.arg("--branch")
|
|
.arg("feature-ingest")
|
|
.arg("--from")
|
|
.arg("missing-base")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(ingest_payload["branch"], "feature-ingest");
|
|
assert_eq!(ingest_payload["base_branch"], "missing-base");
|
|
assert_eq!(ingest_payload["branch_created"], false);
|
|
assert_eq!(ingest_payload["mode"], "merge");
|
|
assert_eq!(ingest_payload["tables"][0]["table_key"], "node:Person");
|
|
assert_eq!(ingest_payload["tables"][0]["rows_loaded"], 2);
|
|
|
|
let bob = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--branch")
|
|
.arg("feature-ingest")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Bob"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(bob["row_count"], 1);
|
|
assert_eq!(bob["rows"][0]["p.age"], 26);
|
|
|
|
let zoe = parse_stdout_json(&output_success(
|
|
cli()
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--branch")
|
|
.arg("feature-ingest")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"Zoe"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(zoe["row_count"], 1);
|
|
assert_eq!(zoe["rows"][0]["p.name"], "Zoe");
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn remote_policy_enforces_branch_first_cli_workflow() {
|
|
// Served policy enforcement: the cluster binds REMOTE_POLICY_E2E_YAML to the
|
|
// graph, and the server maps bearer tokens to actors. The actor is resolved
|
|
// from the token (no `--as` on served writes).
|
|
let cluster = converged_loaded_cluster(GRAPH_ID, Some(REMOTE_POLICY_E2E_YAML));
|
|
let server = spawn_server_with_cluster_env(
|
|
cluster.path(),
|
|
&[(
|
|
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
|
|
r#"{"act-bruno":"team-token","act-ragnor":"admin-token"}"#,
|
|
)],
|
|
);
|
|
let temp = tempfile::tempdir().unwrap();
|
|
let mutation_file = temp.path().join("system-remote-policy-change.gq");
|
|
fs::write(
|
|
&mutation_file,
|
|
r#"
|
|
query insert_person($name: String, $age: I32) {
|
|
insert Person { name: $name, age: $age }
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// Reads are granted to the team group (bruno).
|
|
let snapshot = parse_stdout_json(&output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "team-token")
|
|
.arg("snapshot")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(snapshot["branch"], "main");
|
|
|
|
// bruno cannot change protected main (team-write-unprotected only).
|
|
let denied_main_change = output_failure(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "team-token")
|
|
.arg("change")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("--params")
|
|
.arg(r#"{"name":"PolicyRemote","age":41}"#)
|
|
.arg("--json"),
|
|
);
|
|
let denied_main_stderr = String::from_utf8(denied_main_change.stderr).unwrap();
|
|
assert!(
|
|
denied_main_stderr.contains("denied")
|
|
&& denied_main_stderr.contains("change")
|
|
&& denied_main_stderr.contains("main"),
|
|
"expected change-on-main denial, got: {denied_main_stderr}"
|
|
);
|
|
|
|
// bruno can create an unprotected branch.
|
|
let created = parse_stdout_json(&output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "team-token")
|
|
.arg("branch")
|
|
.arg("create")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--from")
|
|
.arg("main")
|
|
.arg("feature")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(created["name"], "feature");
|
|
|
|
// bruno can change the unprotected branch; actor resolves from the token.
|
|
let changed = parse_stdout_json(&output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "team-token")
|
|
.arg("change")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(&mutation_file)
|
|
.arg("--branch")
|
|
.arg("feature")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"PolicyRemote","age":41}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(changed["branch"], "feature");
|
|
assert_eq!(changed["affected_nodes"], 1);
|
|
assert_eq!(changed["actor_id"], "act-bruno");
|
|
|
|
// bruno cannot merge into protected main (admins-promote only).
|
|
let denied_merge = output_failure(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "team-token")
|
|
.arg("branch")
|
|
.arg("merge")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("feature")
|
|
.arg("--into")
|
|
.arg("main")
|
|
.arg("--json"),
|
|
);
|
|
let denied_merge_stderr = String::from_utf8(denied_merge.stderr).unwrap();
|
|
assert!(
|
|
denied_merge_stderr.contains("denied") && denied_merge_stderr.contains("branch_merge"),
|
|
"expected branch_merge denial, got: {denied_merge_stderr}"
|
|
);
|
|
|
|
// ragnor (admins) can promote into protected main.
|
|
let merged = parse_stdout_json(&output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "admin-token")
|
|
.arg("branch")
|
|
.arg("merge")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("feature")
|
|
.arg("--into")
|
|
.arg("main")
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(merged["target"], "main");
|
|
|
|
let verify = parse_stdout_json(&output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "team-token")
|
|
.arg("read")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--graph")
|
|
.arg(GRAPH_ID)
|
|
.arg("--query")
|
|
.arg(fixture("test.gq"))
|
|
.arg("get_person")
|
|
.arg("--params")
|
|
.arg(r#"{"name":"PolicyRemote"}"#)
|
|
.arg("--json"),
|
|
));
|
|
assert_eq!(verify["row_count"], 1);
|
|
assert_eq!(verify["rows"][0]["p.name"], "PolicyRemote");
|
|
}
|
|
|
|
// ─── MR-668 PR 8 — omnigraph graphs list end-to-end ────────────────────────
|
|
|
|
/// Multi-graph server + CLI `omnigraph graphs list` end-to-end (RFC-011
|
|
/// cluster-only serving).
|
|
///
|
|
/// Steps:
|
|
/// 1. Build a converged cluster serving one graph `alpha` with a
|
|
/// server-scoped policy granting `act-admin` the `graph_list` action.
|
|
/// 2. Spawn the server with `--cluster` + a bearer-token map.
|
|
/// 3. `omnigraph graphs list --server <url>` (admin token) — expect `alpha`.
|
|
/// 4. Addressing the server via `--server <url>` with NO `--graph` errors and
|
|
/// lists the candidate graphs (RFC-011 D7).
|
|
///
|
|
/// Ignored by default — spawning servers needs loopback socket
|
|
/// permissions some sandboxes lack.
|
|
#[test]
|
|
#[ignore = "requires loopback socket permissions in sandboxed runners"]
|
|
fn graphs_list_against_multi_graph_server() {
|
|
let cfg_dir = tempfile::tempdir().unwrap();
|
|
let dir = cfg_dir.path();
|
|
fs::copy(fixture("test.pg"), dir.join("alpha.pg")).unwrap();
|
|
fs::write(dir.join("server.policy.yaml"), GRAPH_LIST_SERVER_POLICY_YAML).unwrap();
|
|
fs::write(
|
|
dir.join("cluster.yaml"),
|
|
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n alpha:\n schema: ./alpha.pg\npolicies:\n server:\n file: ./server.policy.yaml\n applies_to: [cluster]\n",
|
|
)
|
|
.unwrap();
|
|
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
|
|
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
|
|
|
|
let server = spawn_server_with_cluster_env(
|
|
dir,
|
|
&[(
|
|
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
|
|
r#"{"act-admin":"admin-token"}"#,
|
|
)],
|
|
);
|
|
|
|
// `graphs list` lists `alpha`.
|
|
let payload = parse_stdout_json(&output_success(
|
|
cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "admin-token")
|
|
.arg("graphs")
|
|
.arg("list")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("--json"),
|
|
));
|
|
let ids: Vec<&str> = payload["graphs"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|g| g["graph_id"].as_str().unwrap())
|
|
.collect();
|
|
assert_eq!(ids, vec!["alpha"]);
|
|
|
|
// RFC-011 D7: addressing the multi-graph server via `--server <url>` with no
|
|
// `--graph` errors and lists the candidate graphs (the resolver probes
|
|
// GET /graphs; the default-env token authorizes it).
|
|
let no_graph = cli()
|
|
.env("OMNIGRAPH_BEARER_TOKEN", "admin-token")
|
|
.arg("query")
|
|
.arg("--server")
|
|
.arg(&server.base_url)
|
|
.arg("-e")
|
|
.arg("query q { match { $p: Person { name: \"x\" } } return { $p.name } }")
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
!no_graph.status.success(),
|
|
"multi-graph server with no --graph must error"
|
|
);
|
|
let stderr = String::from_utf8_lossy(&no_graph.stderr);
|
|
assert!(
|
|
stderr.contains("alpha") && stderr.contains("--graph <id>"),
|
|
"expected a candidate-listing error naming alpha; got: {stderr}"
|
|
);
|
|
|
|
drop(server);
|
|
}
|