mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-06-18 02:24:27 +02:00
* feat(cli): --server accepts a literal URL (RFC-011 Decision 2) `resolve_server_flag` now treats a `--server` value containing `://` as a literal base URL (trailing slash trimmed; `--graph` appends `/graphs/<id>`), bypassing the operator-config `servers:` registry; a bare name still resolves through the registry. This is the replacement the upcoming `--uri http(s)://` deprecation points at, and a small ergonomic win on its own (`--server https://host` with no config entry). Token resolution for a literal-URL server falls to the legacy OMNIGRAPH_BEARER_TOKEN chain, same as a positional URL today. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(cli): address the parity-matrix arms with global --store/--server flags Prep for removing the positional-http→remote dispatch. The parity harness addressed both arms with a positional graph right after the verb (`omnigraph <verb> <addr> <args…>`), which only parses for top-level verbs — for nested subcommands (`schema show`, `branch list`, …) the address landed in the subcommand slot and BOTH arms failed identically, so the test passed vacuously (matching exit codes, never comparing output). Address both arms with the global flags instead — local `--store <graph>` (embedded), remote `--server <url>` (served) — appended after the verb + args, valid regardless of nesting. The previously-vacuous nested-verb parity checks now actually compare embedded vs remote (and pass — parity holds), and the remote arm no longer relies on the positional-URL dispatch that's about to be removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: --as on a served write is a hard error (was a silent no-op) A served write resolves the actor server-side from the bearer token, so `--as` could never set identity there — it was silently ignored. It now errors (in the remote write factory, before any HTTP call), pointing the user at removing `--as` or writing directly with `--store`. Reads don't carry `--as`, so this is write-path only. BREAKING for any script that passed `--as` to a remote write (it was a no-op, so behavior is unchanged except the now-explicit error). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: a positional/--uri http(s):// URL no longer dispatches to a server Remote graphs must be addressed with `--server <url>` (or a named server / a profile binding one). A positional or `--uri` `http(s)://` URL on a data verb now errors instead of silently routing to the remote HTTP client — the scheme no longer carries transport semantics. The discriminator is `via_server`: a remote URL produced by a server scope is fine; a remote URL from a positional/`--uri` source is rejected (`reject_positional_remote` in both GraphClient factories). Storage verbs are unaffected — they already reject remote URIs through `resolve_local_graph` with the existing "direct (storage-native)" error. Migrated the gh-host keyed-credential system test to `--server <url>` (the literal URL still prefix-matches the operator server for token resolution). BREAKING: scripts addressing a server by a bare URL must switch to `--server <url>`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cli)!: remove the --target flag (use --store / --profile / --server) Removes the legacy named-graph flag and threads its parameter out of the whole resolver chain. `--target` resolved a graph name through `omnigraph.yaml`'s `graphs:` map; its replacements (`--store <uri>`, `--profile <name>`, `--server <name>`) all ship. - Drops the 22 `target` clap fields + the `--cluster` exclusion that named it. - Threads `target`/`cli_target` out of `resolve_uri`/`resolve_cli_graph`/ `resolve_local_graph`/`resolve_local_uri`/`resolve_storage_uri`/ `resolve_remote_bearer_token`/`apply_server_flag`/`execute_query_lint`/ `resolve_selected_graph`/`resolve_registry_selection_for_list`/ `execute_queries_{validate,list}`, the two `GraphClient` factories, and `ScopeFlags`/`ResolvedScope`. - Keeps the shared `OmnigraphConfig::resolve_target_uri` 3-arg (server boot uses it); the CLI passes None for the explicit-target arm. The `cli.graph` default (omnigraph.yaml bare-command fallback) is unchanged — its removal belongs to the omnigraph.yaml excision. - Operator/file aliases that bind a `graph` name still work: the name is now resolved to a URI inline (a positional URI wins). - Error messages and `--graph`/`--server`/`--store` help text no longer name `--target`; the queries-list selection hint points at `cli.graph`. BREAKING. Tests updated (named-target resolution rewritten onto `cli.graph`; positional-URI tests unchanged). Full omnigraph-cli suite green (228). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(cli): drop --target and positional-http addressing; --as-on-served is an error Update the user docs for the legacy data-plane addressing removals: - the CLI `--target` flag is gone — address graphs with a positional URI, `--store`, `--profile`, or `--server <name|url>`; - a positional `http(s)://` URI no longer dispatches to a server (use `--server`); - `--as` on a served write is now rejected (was a silent no-op). Touches cli/reference.md (addressing intro, capability table, error examples, scopes), cli/index.md (the remote-read example → --server), operations/maintenance + policy, and the cluster docs' data-plane load guidance. The server's own `--target` boot flag is unchanged (server.md untouched). Also fixes a pre-existing broken maintenance link in search/indexes.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(cli): --store is loudly exclusive with a positional URI / --server; test graphs→Served Address two Greptile findings on the RFC-011 slices: - Slice A (P1): `--store` combined with a positional URI silently dropped the URI (`scope.rs` did `store.or(uri)`); `--store` + `--server` errored with a misleading "positional URI" message. Now both combinations fail loudly with a declared `--store is exclusive with a positional URI and --server` error. - Slice B (P2): the `command_capability` unit test never exercised the one Data→Served refinement (`graphs`); added the assertion so deleting that guard can't pass silently. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2548 lines
77 KiB
Rust
2548 lines
77 KiB
Rust
mod support;
|
||
|
||
use std::env;
|
||
use std::fs;
|
||
|
||
use reqwest::blocking::Client;
|
||
use serde_json::Value;
|
||
|
||
use support::*;
|
||
|
||
const 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-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
|
||
- id: admins-write
|
||
allow:
|
||
actors: { group: admins }
|
||
actions: [change]
|
||
branch_scope: any
|
||
- id: admins-branch-ops
|
||
allow:
|
||
actors: { group: admins }
|
||
actions: [branch_create, branch_delete]
|
||
target_branch_scope: any
|
||
- id: admins-schema-apply
|
||
allow:
|
||
actors: { group: admins }
|
||
actions: [schema_apply]
|
||
target_branch_scope: any
|
||
"#;
|
||
|
||
const POLICY_E2E_TESTS_YAML: &str = r#"
|
||
version: 1
|
||
cases:
|
||
- id: deny-main-change
|
||
actor: act-bruno
|
||
action: change
|
||
branch: main
|
||
expect: deny
|
||
- id: allow-feature-change
|
||
actor: act-bruno
|
||
action: change
|
||
branch: feature
|
||
expect: allow
|
||
"#;
|
||
|
||
fn yaml_string(value: &str) -> String {
|
||
format!("'{}'", value.replace('\'', "''"))
|
||
}
|
||
|
||
fn local_policy_config(graph: &SystemGraph) -> String {
|
||
format!(
|
||
"\
|
||
project:
|
||
name: policy-e2e-local
|
||
graphs:
|
||
local:
|
||
uri: {}
|
||
policy:
|
||
file: ./policy.yaml
|
||
cli:
|
||
graph: local
|
||
branch: main
|
||
query:
|
||
roots:
|
||
- .
|
||
",
|
||
yaml_string(&graph.path().to_string_lossy())
|
||
)
|
||
}
|
||
|
||
fn local_policy_server_graph_config(graph: &SystemGraph) -> String {
|
||
format!(
|
||
"\
|
||
project:
|
||
name: policy-e2e-local
|
||
graphs:
|
||
local:
|
||
uri: {}
|
||
policy:
|
||
file: ./policy.yaml
|
||
server:
|
||
graph: local
|
||
cli:
|
||
branch: main
|
||
query:
|
||
roots:
|
||
- .
|
||
",
|
||
yaml_string(&graph.path().to_string_lossy())
|
||
)
|
||
}
|
||
|
||
fn insert_person_query(graph: &SystemGraph, name: &str) -> std::path::PathBuf {
|
||
graph.write_query(
|
||
name,
|
||
r#"
|
||
query insert_person($name: String, $age: I32) {
|
||
insert Person { name: $name, age: $age }
|
||
}
|
||
"#,
|
||
)
|
||
}
|
||
|
||
fn add_friend_query(graph: &SystemGraph, name: &str) -> std::path::PathBuf {
|
||
graph.write_query(
|
||
name,
|
||
r#"
|
||
query add_friend($from: String, $to: String) {
|
||
insert Knows { from: $from, to: $to }
|
||
}
|
||
"#,
|
||
)
|
||
}
|
||
|
||
fn snapshot_table_row_count(graph: &SystemGraph, table_key: &str) -> u64 {
|
||
snapshot_table_row_count_at(graph.path(), table_key)
|
||
}
|
||
|
||
fn snapshot_table_row_count_at(graph: &std::path::Path, table_key: &str) -> u64 {
|
||
let payload = parse_stdout_json(&output_success(
|
||
cli().arg("snapshot").arg(graph).arg("--json"),
|
||
));
|
||
payload["tables"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|table| table["table_key"] == table_key)
|
||
.unwrap()["row_count"]
|
||
.as_u64()
|
||
.unwrap()
|
||
}
|
||
|
||
fn gemini_base_url() -> String {
|
||
env::var("OMNIGRAPH_GEMINI_BASE_URL")
|
||
.ok()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string())
|
||
}
|
||
|
||
fn embed_text_with_gemini(text: &str, dim: usize) -> Vec<f32> {
|
||
let api_key = env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY must be set");
|
||
let client = Client::new();
|
||
let response = client
|
||
.post(format!(
|
||
"{}/models/gemini-embedding-2-preview:embedContent",
|
||
gemini_base_url().trim_end_matches('/')
|
||
))
|
||
.header("x-goog-api-key", api_key)
|
||
.json(&serde_json::json!({
|
||
"model": "models/gemini-embedding-2-preview",
|
||
"content": {
|
||
"parts": [
|
||
{
|
||
"text": text
|
||
}
|
||
]
|
||
},
|
||
"taskType": "RETRIEVAL_QUERY",
|
||
"outputDimensionality": dim,
|
||
}))
|
||
.send()
|
||
.unwrap()
|
||
.error_for_status()
|
||
.unwrap()
|
||
.json::<Value>()
|
||
.unwrap();
|
||
|
||
response["embedding"]["values"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|value| value.as_f64().unwrap() as f32)
|
||
.collect()
|
||
}
|
||
|
||
fn format_vector(values: &[f32]) -> String {
|
||
values
|
||
.iter()
|
||
.map(|value| format!("{:.8}", value))
|
||
.collect::<Vec<_>>()
|
||
.join(", ")
|
||
}
|
||
|
||
fn s3_test_graph_uri(suite: &str) -> Option<String> {
|
||
let bucket = env::var("OMNIGRAPH_S3_TEST_BUCKET").ok()?;
|
||
let prefix = env::var("OMNIGRAPH_S3_TEST_PREFIX")
|
||
.ok()
|
||
.filter(|value| !value.trim().is_empty())
|
||
.unwrap_or_else(|| "omnigraph-itests".to_string());
|
||
let unique = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.ok()?
|
||
.as_nanos();
|
||
Some(format!("s3://{}/{}/{}/{}", bucket, prefix, suite, unique))
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_end_to_end_init_load_read_change_read_flow() {
|
||
let graph = SystemGraph::initialized();
|
||
let mutation_file = insert_person_query(&graph, "system-local-init-change.gq");
|
||
|
||
output_success(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--data")
|
||
.arg(fixture("test.jsonl"))
|
||
.arg(graph.path()),
|
||
);
|
||
|
||
let read_before = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Alice"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(read_before["row_count"], 1);
|
||
assert_eq!(read_before["rows"][0]["p.name"], "Alice");
|
||
|
||
let change_payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("change")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Eve","age":29}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(change_payload["branch"], "main");
|
||
assert_eq!(change_payload["affected_nodes"], 1);
|
||
|
||
let read_after = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Eve"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(read_after["row_count"], 1);
|
||
assert_eq!(read_after["rows"][0]["p.name"], "Eve");
|
||
|
||
// Inline-source variants of the same read/change flow (CLI `-e` /
|
||
// `--query-string`). Confirms that file-less invocations reach the
|
||
// engine identically, including param binding and `branch=main` defaults.
|
||
let inline_change = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("change")
|
||
.arg(graph.path())
|
||
.arg("-e")
|
||
.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_change["branch"], "main");
|
||
assert_eq!(inline_change["query_name"], "add");
|
||
assert_eq!(inline_change["affected_nodes"], 1);
|
||
|
||
let inline_read = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query-string")
|
||
.arg("query find($name: String) { match { $p: Person { name: $name } } return { $p.name, $p.age } }")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Inline"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(inline_read["row_count"], 1);
|
||
assert_eq!(inline_read["rows"][0]["p.name"], "Inline");
|
||
assert_eq!(inline_read["rows"][0]["p.age"], 42);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_end_to_end_branch_change_merge_flow() {
|
||
let graph = SystemGraph::loaded();
|
||
let mutation_file = insert_person_query(&graph, "system-local-change.gq");
|
||
|
||
output_success(
|
||
cli()
|
||
.arg("branch")
|
||
.arg("create")
|
||
.arg("--uri")
|
||
.arg(graph.path())
|
||
.arg("--from")
|
||
.arg("main")
|
||
.arg("feature"),
|
||
);
|
||
|
||
let change_payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("change")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--branch")
|
||
.arg("feature")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Zoe","age":33}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(change_payload["branch"], "feature");
|
||
assert_eq!(change_payload["affected_nodes"], 1);
|
||
|
||
let feature_read = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--branch")
|
||
.arg("feature")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Zoe"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(feature_read["row_count"], 1);
|
||
assert_eq!(feature_read["rows"][0]["p.name"], "Zoe");
|
||
|
||
let merge_payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("branch")
|
||
.arg("merge")
|
||
.arg("--uri")
|
||
.arg(graph.path())
|
||
.arg("feature")
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(merge_payload["target"], "main");
|
||
|
||
let main_read = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Zoe"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(main_read["row_count"], 1);
|
||
assert_eq!(main_read["rows"][0]["p.name"], "Zoe");
|
||
|
||
// `omnigraph run list` removed. Audit visible via commit list.
|
||
let commits_payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("commit")
|
||
.arg("list")
|
||
.arg(graph.path())
|
||
.arg("--branch")
|
||
.arg("main")
|
||
.arg("--json"),
|
||
));
|
||
assert!(commits_payload["commits"].as_array().unwrap().len() >= 2);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_ingest_creates_review_branch_and_keeps_it_readable() {
|
||
let graph = SystemGraph::loaded();
|
||
let ingest_data = graph.write_jsonl(
|
||
"system-local-ingest.jsonl",
|
||
r#"{"type":"Person","data":{"name":"Zoe","age":33}}
|
||
{"type":"Person","data":{"name":"Bob","age":26}}"#,
|
||
);
|
||
|
||
let ingest_output = output_success(
|
||
cli()
|
||
.arg("ingest")
|
||
.arg("--data")
|
||
.arg(&ingest_data)
|
||
.arg("--branch")
|
||
.arg("feature-ingest")
|
||
.arg(graph.path())
|
||
.arg("--json"),
|
||
);
|
||
// The deprecation warning goes to stderr so --json stdout stays clean.
|
||
assert!(
|
||
String::from_utf8_lossy(&ingest_output.stderr).contains("deprecated"),
|
||
"ingest must warn about its deprecation on stderr"
|
||
);
|
||
let ingest_payload = parse_stdout_json(&ingest_output);
|
||
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(graph.path())
|
||
.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(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.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");
|
||
|
||
let bob = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.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);
|
||
}
|
||
|
||
/// The unified `load` subsumes ingest: `--from` opts into fork-if-missing,
|
||
/// while without it a missing branch is an error — never an implicit fork.
|
||
#[test]
|
||
fn local_cli_load_from_forks_branch_and_missing_branch_errors_without_from() {
|
||
let graph = SystemGraph::loaded();
|
||
let extra = graph.write_jsonl(
|
||
"system-local-load-from.jsonl",
|
||
r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#,
|
||
);
|
||
|
||
// Without --from, a missing branch must fail and create nothing.
|
||
let failure = output_failure(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("merge")
|
||
.arg("--data")
|
||
.arg(&extra)
|
||
.arg("--branch")
|
||
.arg("feature-load")
|
||
.arg(graph.path()),
|
||
);
|
||
assert!(
|
||
String::from_utf8_lossy(&failure.stderr).contains("feature-load"),
|
||
"error should name the missing branch"
|
||
);
|
||
|
||
// With --from, the branch is forked and the load lands on it.
|
||
let payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("merge")
|
||
.arg("--data")
|
||
.arg(&extra)
|
||
.arg("--branch")
|
||
.arg("feature-load")
|
||
.arg("--from")
|
||
.arg("main")
|
||
.arg(graph.path())
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(payload["branch"], "feature-load");
|
||
assert_eq!(payload["base_branch"], "main");
|
||
assert_eq!(payload["branch_created"], true);
|
||
assert_eq!(payload["mode"], "merge");
|
||
assert_eq!(payload["nodes_loaded"], 1);
|
||
|
||
let snapshot = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("snapshot")
|
||
.arg(graph.path())
|
||
.arg("--branch")
|
||
.arg("feature-load")
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(snapshot["branch"], "feature-load");
|
||
}
|
||
|
||
/// `--mode` is required: overwrite is destructive, so the unified `load`
|
||
/// has no implicit default.
|
||
#[test]
|
||
fn local_cli_load_requires_mode_flag() {
|
||
let graph = SystemGraph::loaded();
|
||
let extra = graph.write_jsonl(
|
||
"system-local-load-no-mode.jsonl",
|
||
r#"{"type":"Person","data":{"name":"Zoe","age":33}}"#,
|
||
);
|
||
|
||
let failure = output_failure(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--data")
|
||
.arg(&extra)
|
||
.arg(graph.path()),
|
||
);
|
||
assert!(
|
||
String::from_utf8_lossy(&failure.stderr).contains("--mode"),
|
||
"clap should demand the missing --mode flag"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_export_round_trips_full_branch_graph() {
|
||
let graph = SystemGraph::loaded();
|
||
|
||
output_success(
|
||
cli()
|
||
.arg("branch")
|
||
.arg("create")
|
||
.arg("--uri")
|
||
.arg(graph.path())
|
||
.arg("--from")
|
||
.arg("main")
|
||
.arg("feature"),
|
||
);
|
||
|
||
let feature_data = graph.write_jsonl(
|
||
"system-local-export-feature.jsonl",
|
||
r#"{"type":"Person","data":{"name":"Eve","age":29}}
|
||
{"edge":"Knows","from":"Alice","to":"Eve"}"#,
|
||
);
|
||
output_success(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--data")
|
||
.arg(&feature_data)
|
||
.arg("--branch")
|
||
.arg("feature")
|
||
.arg("--mode")
|
||
.arg("append")
|
||
.arg(graph.path()),
|
||
);
|
||
|
||
let exported = stdout_string(&output_success(
|
||
cli()
|
||
.arg("export")
|
||
.arg(graph.path())
|
||
.arg("--branch")
|
||
.arg("feature")
|
||
.arg("--jsonl"),
|
||
));
|
||
let export_path = graph.write_jsonl("system-local-exported.jsonl", &exported);
|
||
let imported_graph = graph.path().parent().unwrap().join("imported-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),
|
||
);
|
||
|
||
assert_eq!(
|
||
snapshot_table_row_count_at(&imported_graph, "node:Person"),
|
||
5
|
||
);
|
||
assert_eq!(
|
||
snapshot_table_row_count_at(&imported_graph, "node:Company"),
|
||
2
|
||
);
|
||
assert_eq!(
|
||
snapshot_table_row_count_at(&imported_graph, "edge:Knows"),
|
||
4
|
||
);
|
||
assert_eq!(
|
||
snapshot_table_row_count_at(&imported_graph, "edge:WorksAt"),
|
||
2
|
||
);
|
||
|
||
let eve = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(&imported_graph)
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.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");
|
||
|
||
let friends = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(&imported_graph)
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("friends_of")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Alice"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(friends["row_count"], 3);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_s3_end_to_end_init_load_read_flow() {
|
||
let Some(graph_uri) = s3_test_graph_uri("cli-local") else {
|
||
eprintln!("skipping s3 cli test: OMNIGRAPH_S3_TEST_BUCKET is not set");
|
||
return;
|
||
};
|
||
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let query_root = temp.path();
|
||
let config = query_root.join("omnigraph.yaml");
|
||
let query = query_root.join("test.gq");
|
||
fs::copy(fixture("test.gq"), &query).unwrap();
|
||
write_config(
|
||
&config,
|
||
&format!(
|
||
"\
|
||
graphs:
|
||
rustfs:
|
||
uri: '{}'
|
||
cli:
|
||
graph: rustfs
|
||
branch: main
|
||
query:
|
||
roots:
|
||
- .
|
||
policy: {{}}
|
||
",
|
||
graph_uri
|
||
),
|
||
);
|
||
|
||
// current_dir matters: `init` scaffolds an omnigraph.yaml into its cwd,
|
||
// and without this it pollutes the crate dir, breaking unrelated tests
|
||
// (anything resolving a graph target from the cwd config).
|
||
output_success(
|
||
cli()
|
||
.current_dir(query_root)
|
||
.arg("init")
|
||
.arg("--schema")
|
||
.arg(fixture("test.pg"))
|
||
.arg(&graph_uri),
|
||
);
|
||
output_success(
|
||
cli()
|
||
.current_dir(query_root)
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--data")
|
||
.arg(fixture("test.jsonl"))
|
||
.arg(&graph_uri),
|
||
);
|
||
|
||
let read = parse_stdout_json(&output_success(
|
||
cli()
|
||
.current_dir(query_root)
|
||
.arg("read")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--query")
|
||
.arg("test.gq")
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Alice"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(read["row_count"], 1);
|
||
assert_eq!(read["rows"][0]["p.name"], "Alice");
|
||
|
||
let snapshot = parse_stdout_json(&output_success(
|
||
cli()
|
||
.current_dir(query_root)
|
||
.arg("snapshot")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--json"),
|
||
));
|
||
assert!(snapshot["tables"].is_array());
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_failed_load_keeps_target_state_unchanged() {
|
||
let graph = SystemGraph::loaded();
|
||
let bad_data = graph.write_jsonl(
|
||
"system-bad-load.jsonl",
|
||
r#"{"edge":"Knows","from":"Alice","to":"Missing"}"#,
|
||
);
|
||
let person_rows_before = snapshot_table_row_count(&graph, "node:Person");
|
||
let knows_rows_before = snapshot_table_row_count(&graph, "edge:Knows");
|
||
|
||
let output = output_failure(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--data")
|
||
.arg(&bad_data)
|
||
.arg("--mode")
|
||
.arg("append")
|
||
.arg(graph.path()),
|
||
);
|
||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||
assert!(stderr.contains("not found") || stderr.contains("Missing"));
|
||
|
||
assert_eq!(
|
||
snapshot_table_row_count(&graph, "node:Person"),
|
||
person_rows_before
|
||
);
|
||
assert_eq!(
|
||
snapshot_table_row_count(&graph, "edge:Knows"),
|
||
knows_rows_before
|
||
);
|
||
// Failed loads leave no run record (the run lifecycle has been
|
||
// removed); atomicity is verified above by the unchanged target.
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_failed_change_keeps_target_state_unchanged() {
|
||
let graph = SystemGraph::loaded();
|
||
let mutation_file = add_friend_query(&graph, "system-invalid-change.gq");
|
||
|
||
let output = output_failure(
|
||
cli()
|
||
.arg("change")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"from":"Alice","to":"Missing"}"#),
|
||
);
|
||
let stderr = String::from_utf8(output.stderr).unwrap();
|
||
assert!(stderr.contains("not found") || stderr.contains("Missing"));
|
||
|
||
let friends_payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("friends_of")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Alice"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(friends_payload["row_count"], 2);
|
||
// Failed mutations leave no run record (the run lifecycle has been
|
||
// removed); atomicity is verified above by the unchanged target.
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_resolves_relative_query_against_config_base_dir() {
|
||
let graph = SystemGraph::loaded();
|
||
let root = graph.path().parent().unwrap();
|
||
let config_dir = root.join("config");
|
||
let query_dir = config_dir.join("queries");
|
||
let ambient_dir = root.join("ambient");
|
||
fs::create_dir_all(&query_dir).unwrap();
|
||
fs::create_dir_all(&ambient_dir).unwrap();
|
||
|
||
let config = config_dir.join("omnigraph.yaml");
|
||
write_config(
|
||
&config,
|
||
&format!(
|
||
"\
|
||
graphs:
|
||
local:
|
||
uri: '{}'
|
||
cli:
|
||
graph: local
|
||
branch: main
|
||
query:
|
||
roots:
|
||
- queries
|
||
policy: {{}}
|
||
",
|
||
graph.path().display()
|
||
),
|
||
);
|
||
write_query_file(
|
||
&query_dir.join("local.gq"),
|
||
r#"
|
||
query get_person($name: String) {
|
||
match {
|
||
$p: Person { name: $name }
|
||
}
|
||
return { $p.age, $p.name }
|
||
}
|
||
"#,
|
||
);
|
||
write_query_file(
|
||
&ambient_dir.join("local.gq"),
|
||
r#"
|
||
query get_person($name: String) {
|
||
match {
|
||
$p: Person { name: $name }
|
||
}
|
||
return { $p.name }
|
||
}
|
||
"#,
|
||
);
|
||
|
||
let payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.current_dir(&ambient_dir)
|
||
.arg("read")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--query")
|
||
.arg("local.gq")
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Alice"}"#)
|
||
.arg("--json"),
|
||
));
|
||
let columns = payload["columns"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|value| value.as_str().unwrap())
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(columns, vec!["p.age", "p.name"]);
|
||
assert_eq!(payload["rows"][0]["p.age"], 30);
|
||
assert_eq!(payload["rows"][0]["p.name"], "Alice");
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_datetime_and_list_types_round_trip_through_load_read_and_change() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let graph = graph_path(temp.path());
|
||
let schema = temp.path().join("datatypes.pg");
|
||
let data = temp.path().join("datatypes.jsonl");
|
||
let queries = temp.path().join("datatypes.gq");
|
||
|
||
write_query_file(
|
||
&schema,
|
||
r#"
|
||
node Task {
|
||
slug: String @key
|
||
title: String
|
||
due_at: DateTime
|
||
tags: [String]
|
||
scores: [I32]?
|
||
active_days: [Date]?
|
||
}
|
||
"#,
|
||
);
|
||
write_jsonl(
|
||
&data,
|
||
r#"{"type":"Task","data":{"slug":"alpha","title":"Launch prep","due_at":"2026-04-01T08:30:00Z","tags":["launch","priority"],"scores":[1,2],"active_days":["2026-03-30","2026-03-31"]}}
|
||
{"type":"Task","data":{"slug":"beta","title":"Archive","due_at":"2026-05-01T12:00:00Z","tags":["backlog"],"scores":[5],"active_days":["2026-04-01"]}}"#,
|
||
);
|
||
write_query_file(
|
||
&queries,
|
||
r#"
|
||
query due_with_tag($deadline: DateTime, $tag: String) {
|
||
match {
|
||
$t: Task
|
||
$t.due_at <= $deadline
|
||
$t.tags contains $tag
|
||
}
|
||
return { $t.slug, $t.due_at, $t.tags, $t.scores, $t.active_days }
|
||
}
|
||
|
||
query insert_task(
|
||
$slug: String,
|
||
$title: String,
|
||
$due_at: DateTime,
|
||
$tags: [String],
|
||
$scores: [I32],
|
||
$active_days: [Date]
|
||
) {
|
||
insert Task {
|
||
slug: $slug,
|
||
title: $title,
|
||
due_at: $due_at,
|
||
tags: $tags,
|
||
scores: $scores,
|
||
active_days: $active_days
|
||
}
|
||
}
|
||
|
||
query update_task(
|
||
$slug: String,
|
||
$due_at: DateTime,
|
||
$tags: [String],
|
||
$scores: [I32],
|
||
$active_days: [Date]
|
||
) {
|
||
update Task set {
|
||
due_at: $due_at,
|
||
tags: $tags,
|
||
scores: $scores,
|
||
active_days: $active_days
|
||
} where slug = $slug
|
||
}
|
||
|
||
query get_task($slug: String) {
|
||
match { $t: Task { slug: $slug } }
|
||
return { $t.slug, $t.due_at, $t.tags, $t.scores, $t.active_days }
|
||
}
|
||
"#,
|
||
);
|
||
|
||
output_success(cli().arg("init").arg("--schema").arg(&schema).arg(&graph));
|
||
output_success(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg(&graph),
|
||
);
|
||
|
||
let filtered = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(&graph)
|
||
.arg("--query")
|
||
.arg(&queries)
|
||
.arg("--name")
|
||
.arg("due_with_tag")
|
||
.arg("--params")
|
||
.arg(r#"{"deadline":"2026-04-02T00:00:00Z","tag":"launch"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(filtered["row_count"], 1);
|
||
assert_eq!(filtered["rows"][0]["t.slug"], "alpha");
|
||
assert_eq!(filtered["rows"][0]["t.due_at"], "2026-04-01T08:30:00.000Z");
|
||
assert_eq!(
|
||
filtered["rows"][0]["t.tags"],
|
||
serde_json::json!(["launch", "priority"])
|
||
);
|
||
assert_eq!(filtered["rows"][0]["t.scores"], serde_json::json!([1, 2]));
|
||
assert_eq!(
|
||
filtered["rows"][0]["t.active_days"],
|
||
serde_json::json!(["2026-03-30", "2026-03-31"])
|
||
);
|
||
|
||
let insert_payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("change")
|
||
.arg(&graph)
|
||
.arg("--query")
|
||
.arg(&queries)
|
||
.arg("--name")
|
||
.arg("insert_task")
|
||
.arg("--params")
|
||
.arg(
|
||
r#"{"slug":"gamma","title":"Embed prep","due_at":"2026-04-03T09:15:00Z","tags":["embed","launch"],"scores":[3,8],"active_days":["2026-04-02","2026-04-03"]}"#,
|
||
)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(insert_payload["affected_nodes"], 1);
|
||
|
||
let update_payload = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("change")
|
||
.arg(&graph)
|
||
.arg("--query")
|
||
.arg(&queries)
|
||
.arg("--name")
|
||
.arg("update_task")
|
||
.arg("--params")
|
||
.arg(r#"{"slug":"gamma","due_at":"2026-04-04T10:45:00Z","tags":["embed","released"],"scores":[13,21],"active_days":["2026-04-04","2026-04-05"]}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(update_payload["affected_nodes"], 1);
|
||
|
||
let gamma = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(&graph)
|
||
.arg("--query")
|
||
.arg(&queries)
|
||
.arg("--name")
|
||
.arg("get_task")
|
||
.arg("--params")
|
||
.arg(r#"{"slug":"gamma"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(gamma["row_count"], 1);
|
||
assert_eq!(gamma["rows"][0]["t.slug"], "gamma");
|
||
assert_eq!(gamma["rows"][0]["t.due_at"], "2026-04-04T10:45:00.000Z");
|
||
assert_eq!(
|
||
gamma["rows"][0]["t.tags"],
|
||
serde_json::json!(["embed", "released"])
|
||
);
|
||
assert_eq!(gamma["rows"][0]["t.scores"], serde_json::json!([13, 21]));
|
||
assert_eq!(
|
||
gamma["rows"][0]["t.active_days"],
|
||
serde_json::json!(["2026-04-04", "2026-04-05"])
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires GEMINI_API_KEY and network access"]
|
||
fn local_cli_real_gemini_string_nearest_query_returns_expected_match() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let graph = graph_path(temp.path());
|
||
let schema = temp.path().join("gemini.pg");
|
||
let data = temp.path().join("gemini.jsonl");
|
||
let queries = temp.path().join("gemini.gq");
|
||
|
||
write_query_file(
|
||
&schema,
|
||
r#"
|
||
node Doc {
|
||
slug: String @key
|
||
title: String
|
||
embedding: Vector(4) @index
|
||
}
|
||
"#,
|
||
);
|
||
|
||
let alpha = embed_text_with_gemini("alpha", 4);
|
||
let beta = embed_text_with_gemini("beta", 4);
|
||
let gamma = embed_text_with_gemini("gamma", 4);
|
||
write_jsonl(
|
||
&data,
|
||
&format!(
|
||
r#"{{"type":"Doc","data":{{"slug":"alpha-doc","title":"alpha","embedding":[{}]}}}}
|
||
{{"type":"Doc","data":{{"slug":"beta-doc","title":"beta","embedding":[{}]}}}}
|
||
{{"type":"Doc","data":{{"slug":"gamma-doc","title":"gamma","embedding":[{}]}}}}"#,
|
||
format_vector(&alpha),
|
||
format_vector(&beta),
|
||
format_vector(&gamma),
|
||
),
|
||
);
|
||
write_query_file(
|
||
&queries,
|
||
r#"
|
||
query vector_search($q: String) {
|
||
match { $d: Doc }
|
||
return { $d.slug, $d.title }
|
||
order { nearest($d.embedding, $q) }
|
||
limit 3
|
||
}
|
||
"#,
|
||
);
|
||
|
||
output_success(cli().arg("init").arg("--schema").arg(&schema).arg(&graph));
|
||
output_success(
|
||
cli()
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg(&graph),
|
||
);
|
||
|
||
let result = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(&graph)
|
||
.arg("--query")
|
||
.arg(&queries)
|
||
.arg("--name")
|
||
.arg("vector_search")
|
||
.arg("--params")
|
||
.arg(r#"{"q":"alpha"}"#)
|
||
.arg("--json"),
|
||
));
|
||
|
||
assert_eq!(result["row_count"], 3);
|
||
assert_eq!(result["rows"][0]["d.slug"], "alpha-doc");
|
||
}
|
||
|
||
// The publisher CAS conflict shape is verified end-to-end at the engine
|
||
// level in
|
||
// `crates/omnigraph/tests/writes.rs::concurrent_writers_one_succeeds_one_gets_expected_version_mismatch`
|
||
// and at the HTTP boundary in
|
||
// `crates/omnigraph-server/tests/server.rs::change_conflict_returns_manifest_conflict_409`.
|
||
// A CLI-level race would be timing-dependent; with direct-publish the
|
||
// surface is the same engine path the unit test already covers.
|
||
|
||
#[test]
|
||
fn local_cli_policy_tooling_is_end_to_end() {
|
||
// Sanity check for the read-only policy CLI surfaces. These don't
|
||
// mutate the graph; they parse and evaluate the effective policy for
|
||
// named graph selections, including per-graph policy files.
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
let server_graph_config = graph.write_config(
|
||
"omnigraph-policy-server.yaml",
|
||
&local_policy_server_graph_config(&graph),
|
||
);
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
graph.write_config("policy.tests.yaml", POLICY_E2E_TESTS_YAML);
|
||
|
||
for config in [&config, &server_graph_config] {
|
||
let validate = output_success(
|
||
cli()
|
||
.arg("policy")
|
||
.arg("validate")
|
||
.arg("--config")
|
||
.arg(config),
|
||
);
|
||
assert!(stdout_string(&validate).contains("policy valid:"));
|
||
|
||
let tests = output_success(cli().arg("policy").arg("test").arg("--config").arg(config));
|
||
assert!(stdout_string(&tests).contains("policy tests passed: 2 cases"));
|
||
|
||
let explain = output_success(
|
||
cli()
|
||
.arg("policy")
|
||
.arg("explain")
|
||
.arg("--config")
|
||
.arg(config)
|
||
.arg("--actor")
|
||
.arg("act-bruno")
|
||
.arg("--action")
|
||
.arg("change")
|
||
.arg("--branch")
|
||
.arg("main"),
|
||
);
|
||
let explain_stdout = stdout_string(&explain);
|
||
assert!(explain_stdout.contains("decision: deny"));
|
||
assert!(explain_stdout.contains("branch: main"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_change_enforces_engine_layer_policy() {
|
||
// Asserts MR-722 PR #4: when the selected graph has a configured
|
||
// policy file, the CLI loads PolicyEngine into Omnigraph and every
|
||
// direct-engine write hits `enforce(action, scope, actor)` — identical
|
||
// to what the HTTP server gets, regardless of transport.
|
||
//
|
||
// Three cases, each discriminating:
|
||
//
|
||
// 1. Policy installed, no actor source (no `cli.actor` in config,
|
||
// no `--as` flag) → engine-layer footgun guard fires; CLI exits
|
||
// non-zero with a "no actor" message. Silent bypass is the bug
|
||
// PR #4 prevents.
|
||
// 2. Policy installed, `--as act-bruno`, change on main → Cedar
|
||
// denies (bruno can change unprotected branches; main is
|
||
// protected). CLI exits non-zero with a "denied" message.
|
||
// 3. Policy installed, `--as act-ragnor`, change on main →
|
||
// Cedar permits (admins-write rule). Write succeeds and the
|
||
// inserted row is readable.
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
let mutation_file = insert_person_query(&graph, "system-local-policy-change.gq");
|
||
|
||
// Case 1: policy configured, no actor threaded → footgun guard.
|
||
let no_actor = output_failure(
|
||
cli()
|
||
.arg("change")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"name":"NoActorPerson","age":1}"#)
|
||
.arg("--json"),
|
||
);
|
||
let no_actor_stderr = String::from_utf8_lossy(&no_actor.stderr);
|
||
assert!(
|
||
no_actor_stderr.contains("no actor"),
|
||
"expected 'no actor' footgun message, got stderr: {no_actor_stderr}"
|
||
);
|
||
|
||
// Case 2: `--as act-bruno` against protected main → denied.
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("change")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"name":"BrunoOnMain","age":2}"#)
|
||
.arg("--json"),
|
||
);
|
||
let denied_stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
denied_stderr.contains("denied"),
|
||
"expected 'denied' message for bruno/main, got stderr: {denied_stderr}"
|
||
);
|
||
|
||
// Case 3: `--as act-ragnor` against main → permitted by admins-write.
|
||
let allowed = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("change")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"name":"RagnorOnMain","age":3}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(allowed["branch"], "main");
|
||
assert_eq!(allowed["affected_nodes"], 1);
|
||
assert_eq!(allowed["actor_id"], "act-ragnor");
|
||
|
||
// Verify the row landed — proves the write actually committed, not
|
||
// just that enforce returned Ok and silently dropped the work.
|
||
let verify = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("read")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"RagnorOnMain"}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(verify["row_count"], 1);
|
||
assert_eq!(verify["rows"][0]["p.name"], "RagnorOnMain");
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_positional_uri_does_not_inherit_default_graph_policy() {
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
let mutation_file = insert_person_query(&graph, "system-local-policy-positional.gq");
|
||
|
||
let allowed = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("change")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--uri")
|
||
.arg(graph.path())
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"name":"PositionalUriBruno","age":4}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(allowed["affected_nodes"], 1);
|
||
assert_eq!(allowed["actor_id"], "act-bruno");
|
||
}
|
||
|
||
// ─── MR-722 PR A: CLI×writer matrix ───────────────────────────────────────
|
||
//
|
||
// The change writer is covered above by `local_cli_change_enforces_engine_layer_policy`.
|
||
// These tests extend the engine-layer-policy assertion to the other 6
|
||
// writers, asserting each `omnigraph <writer> --as <actor>` invocation
|
||
// reaches the corresponding `_as` method and Cedar evaluates correctly.
|
||
// One denied case (`--as act-bruno`) + one allowed case (`--as act-ragnor`
|
||
// via the `admins-*` rules) per writer; the no-actor footgun is already
|
||
// proved by the change-writer test and applies identically to every
|
||
// other `_as` variant.
|
||
|
||
#[test]
|
||
fn local_cli_load_enforces_engine_layer_policy() {
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
let data = graph.write_jsonl(
|
||
"system-local-policy-load.jsonl",
|
||
r#"{"type":"Person","data":{"name":"LoadPolicy","age":11}}"#,
|
||
);
|
||
|
||
// act-bruno: change-on-protected is denied (team-write-unprotected only).
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg("--json"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
stderr.contains("denied"),
|
||
"expected 'denied' for bruno/main load, got: {stderr}"
|
||
);
|
||
|
||
// act-ragnor: admins-write rule permits change anywhere.
|
||
let allowed = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(allowed["branch"], "main");
|
||
assert!(allowed["nodes_loaded"].as_u64().unwrap() >= 1);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_ingest_enforces_engine_layer_policy() {
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
let data = graph.write_jsonl(
|
||
"system-local-policy-ingest.jsonl",
|
||
r#"{"type":"Person","data":{"name":"IngestPolicy","age":12}}"#,
|
||
);
|
||
|
||
// act-bruno: ingest into a new branch requires both BranchCreate and
|
||
// Change. Bruno has change-unprotected only, and the implicit
|
||
// branch_create fires first when the target branch doesn't exist.
|
||
// Either gate is enough to deny — assert denial without pinning
|
||
// which one fires first.
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("ingest")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg("--branch")
|
||
.arg("policy-ingest-feature")
|
||
.arg("--json"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
stderr.contains("denied"),
|
||
"expected 'denied' for bruno ingest, got: {stderr}"
|
||
);
|
||
|
||
// act-ragnor: admins-write covers Change, admins-branch-ops covers
|
||
// BranchCreate. Both fire as ingest creates the branch + loads.
|
||
let allowed = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("ingest")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg("--branch")
|
||
.arg("policy-ingest-feature")
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(allowed["branch"], "policy-ingest-feature");
|
||
assert_eq!(allowed["branch_created"], true);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_schema_apply_enforces_engine_layer_policy() {
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
|
||
// Additive: add a nullable property; SDK-compatible with the fixture
|
||
// schema. Uses the schema-apply scope (TargetBranch("main")).
|
||
let new_schema = std::fs::read_to_string(fixture("test.pg"))
|
||
.unwrap()
|
||
.replace(
|
||
" age: I32?\n}",
|
||
" age: I32?\n nickname: String?\n}",
|
||
);
|
||
let schema_path = graph.path().join("policy-additive.pg");
|
||
std::fs::write(&schema_path, &new_schema).unwrap();
|
||
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("schema")
|
||
.arg("apply")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--schema")
|
||
.arg(&schema_path)
|
||
.arg("--json"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
stderr.contains("denied"),
|
||
"expected 'denied' for bruno schema apply, got: {stderr}"
|
||
);
|
||
|
||
let allowed = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("schema")
|
||
.arg("apply")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--schema")
|
||
.arg(&schema_path)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(allowed["applied"], true);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_schema_apply_rejects_stored_query_breakage_before_publish() {
|
||
let graph = SystemGraph::loaded();
|
||
graph.write_query(
|
||
"stored-find-person.gq",
|
||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
|
||
);
|
||
let config = graph.write_config(
|
||
"omnigraph-stored-query-schema.yaml",
|
||
&format!(
|
||
"\
|
||
graphs:
|
||
local:
|
||
uri: {}
|
||
queries:
|
||
find_person:
|
||
file: ./stored-find-person.gq
|
||
cli:
|
||
graph: local
|
||
branch: main
|
||
query:
|
||
roots:
|
||
- .
|
||
policy: {{}}
|
||
",
|
||
yaml_string(&graph.path().to_string_lossy())
|
||
),
|
||
);
|
||
let renamed_schema = std::fs::read_to_string(fixture("test.pg"))
|
||
.unwrap()
|
||
.replace("age: I32?", "years: I32? @rename_from(\"age\")");
|
||
let schema_path = graph.write_file("stored-query-breaks.pg", &renamed_schema);
|
||
|
||
let rejected = output_failure(
|
||
cli()
|
||
.arg("schema")
|
||
.arg("apply")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--schema")
|
||
.arg(&schema_path)
|
||
.arg("--json"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&rejected.stderr);
|
||
assert!(
|
||
stderr.contains("find_person") && stderr.contains("schema check"),
|
||
"schema apply should reject the stored-query breakage before publish; stderr: {stderr}"
|
||
);
|
||
|
||
let schema = stdout_string(&output_success(
|
||
cli().arg("schema").arg("show").arg("--config").arg(&config),
|
||
));
|
||
assert!(schema.contains("age: I32?"));
|
||
assert!(!schema.contains("years: I32?"));
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_branch_create_enforces_engine_layer_policy() {
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("branch")
|
||
.arg("create")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--from")
|
||
.arg("main")
|
||
.arg("bruno-feature"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
stderr.contains("denied"),
|
||
"expected 'denied' for bruno branch create, got: {stderr}"
|
||
);
|
||
|
||
output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("branch")
|
||
.arg("create")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--from")
|
||
.arg("main")
|
||
.arg("ragnor-feature"),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_branch_delete_enforces_engine_layer_policy() {
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
|
||
// Pre-create the branch as ragnor so there's something to delete.
|
||
output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("branch")
|
||
.arg("create")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--from")
|
||
.arg("main")
|
||
.arg("doomed"),
|
||
);
|
||
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("branch")
|
||
.arg("delete")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("doomed"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
stderr.contains("denied"),
|
||
"expected 'denied' for bruno branch delete, got: {stderr}"
|
||
);
|
||
|
||
output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("branch")
|
||
.arg("delete")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("doomed"),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_branch_merge_enforces_engine_layer_policy() {
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config("omnigraph-policy.yaml", &local_policy_config(&graph));
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
|
||
// Pre-create a feature branch as ragnor (admins-branch-ops covers it).
|
||
output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("branch")
|
||
.arg("create")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--from")
|
||
.arg("main")
|
||
.arg("merge-feature"),
|
||
);
|
||
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("branch")
|
||
.arg("merge")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("merge-feature")
|
||
.arg("--into")
|
||
.arg("main"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
stderr.contains("denied"),
|
||
"expected 'denied' for bruno branch merge, got: {stderr}"
|
||
);
|
||
|
||
output_success(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-ragnor")
|
||
.arg("branch")
|
||
.arg("merge")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("merge-feature")
|
||
.arg("--into")
|
||
.arg("main"),
|
||
);
|
||
}
|
||
|
||
// ─── MR-722 PR A: cli.actor config-only precedence ────────────────────────
|
||
//
|
||
// The change-writer test above uses `--as` directly. These two tests
|
||
// pin the precedence rule that `main.rs::resolve_cli_actor` implements:
|
||
// `--as` flag > `cli.actor` from `omnigraph.yaml` > None.
|
||
|
||
fn local_policy_config_with_actor(graph: &SystemGraph, actor: &str) -> String {
|
||
// Mirrors `local_policy_config` but adds `cli.actor` so the
|
||
// config-only precedence path is exercised. The `cli:` block
|
||
// already has `graph` and `branch`; appending `actor` here.
|
||
format!(
|
||
"\
|
||
project:
|
||
name: policy-e2e-local
|
||
graphs:
|
||
local:
|
||
uri: {}
|
||
policy:
|
||
file: ./policy.yaml
|
||
cli:
|
||
graph: local
|
||
branch: main
|
||
actor: {}
|
||
query:
|
||
roots:
|
||
- .
|
||
",
|
||
yaml_string(&graph.path().to_string_lossy()),
|
||
actor,
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_actor_from_config_used_when_no_flag() {
|
||
// cli.actor: act-ragnor in omnigraph.yaml, no --as flag → change
|
||
// permitted via admins-write rule. Proves the config-only path
|
||
// works; previously the only proof was structural.
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config(
|
||
"omnigraph-policy.yaml",
|
||
&local_policy_config_with_actor(&graph, "act-ragnor"),
|
||
);
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
let mutation_file = insert_person_query(&graph, "system-local-cli-actor.gq");
|
||
|
||
let allowed = parse_stdout_json(&output_success(
|
||
cli()
|
||
.arg("change")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"name":"ConfigActorEve","age":18}"#)
|
||
.arg("--json"),
|
||
));
|
||
assert_eq!(allowed["affected_nodes"], 1);
|
||
assert_eq!(allowed["actor_id"], "act-ragnor");
|
||
}
|
||
|
||
#[test]
|
||
fn local_cli_actor_flag_overrides_config_actor() {
|
||
// cli.actor: act-ragnor in config + --as act-bruno on CLI → change
|
||
// denied. Flag wins per the precedence rule. Without this test, a
|
||
// future change that reverses precedence would ride through silently.
|
||
let graph = SystemGraph::loaded();
|
||
let config = graph.write_config(
|
||
"omnigraph-policy.yaml",
|
||
&local_policy_config_with_actor(&graph, "act-ragnor"),
|
||
);
|
||
graph.write_config("policy.yaml", POLICY_E2E_YAML);
|
||
let mutation_file = insert_person_query(&graph, "system-local-cli-actor-override.gq");
|
||
|
||
let denied = output_failure(
|
||
cli()
|
||
.arg("--as")
|
||
.arg("act-bruno")
|
||
.arg("change")
|
||
.arg("--config")
|
||
.arg(&config)
|
||
.arg("--query")
|
||
.arg(&mutation_file)
|
||
.arg("--params")
|
||
.arg(r#"{"name":"OverrideEve","age":19}"#)
|
||
.arg("--json"),
|
||
);
|
||
let stderr = String::from_utf8_lossy(&denied.stderr);
|
||
assert!(
|
||
stderr.contains("denied"),
|
||
"expected 'denied' when --as overrides config to bruno, got: {stderr}"
|
||
);
|
||
}
|
||
|
||
/// Phase 5 (RFC-005): "applied means serving" — converge a cluster with the
|
||
/// CLI, boot the real omnigraph-server binary with --cluster, and serve the
|
||
/// applied stored query over HTTP with zero omnigraph.yaml involvement.
|
||
#[test]
|
||
fn local_cluster_apply_then_server_boots_from_cluster_state() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
std::fs::write(
|
||
temp.path().join("people.pg"),
|
||
"\nnode Person {\n name: String @key\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
temp.path().join("people.gq"),
|
||
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
temp.path().join("cluster.yaml"),
|
||
r#"
|
||
version: 1
|
||
graphs:
|
||
knowledge:
|
||
schema: ./people.pg
|
||
queries:
|
||
find_person:
|
||
file: ./people.gq
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
for command in ["import", "apply"] {
|
||
let output = cli()
|
||
.arg("cluster")
|
||
.arg(command)
|
||
.arg("--config")
|
||
.arg(temp.path())
|
||
.arg("--json")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "cluster {command} failed");
|
||
}
|
||
// Seed a row through the graph plane so the stored query has data.
|
||
let data = temp.path().join("seed.jsonl");
|
||
std::fs::write(&data, "{\"type\":\"Person\",\"data\":{\"name\":\"Ada\"}}\n").unwrap();
|
||
let output = cli()
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg(temp.path().join("graphs/knowledge.omni"))
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "graph load failed");
|
||
|
||
let server = spawn_server_with_cluster(temp.path());
|
||
let client = reqwest::blocking::Client::new();
|
||
let queries: serde_json::Value = client
|
||
.get(format!("{}/graphs/knowledge/queries", server.base_url))
|
||
.send()
|
||
.unwrap()
|
||
.json()
|
||
.unwrap();
|
||
assert!(
|
||
queries["queries"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.any(|q| q["name"] == "find_person"),
|
||
"{queries}"
|
||
);
|
||
let response = client
|
||
.post(format!(
|
||
"{}/graphs/knowledge/queries/find_person",
|
||
server.base_url
|
||
))
|
||
.json(&serde_json::json!({"params": {"name": "Ada"}}))
|
||
.send()
|
||
.unwrap();
|
||
assert!(response.status().is_success(), "{:?}", response.status());
|
||
let body: serde_json::Value = response.json().unwrap();
|
||
assert!(body.to_string().contains("Ada"), "{body}");
|
||
}
|
||
|
||
// ---- Comprehensive full-cycle cluster e2e (Phases 1-5 composed) ----
|
||
|
||
/// Run a `cluster` subcommand and return its JSON output. Deliberately does
|
||
/// NOT assert a zero exit code: blocked/unconverged runs (e.g. an `apply`
|
||
/// awaiting an approval) exit non-zero by contract while still emitting the
|
||
/// structured output the caller asserts on (`ok`/`converged`/dispositions).
|
||
/// Commands where failure is never expected must assert on those fields
|
||
/// (every call here checks `ok` or `converged`) or use `cli()` directly with
|
||
/// `status.success()`.
|
||
fn cluster_cli(dir: &std::path::Path, args: &[&str]) -> serde_json::Value {
|
||
let mut command = cli();
|
||
command.arg("cluster");
|
||
for arg in args {
|
||
command.arg(arg);
|
||
}
|
||
let output = command
|
||
.arg("--config")
|
||
.arg(dir)
|
||
.arg("--json")
|
||
.output()
|
||
.unwrap();
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
serde_json::from_str(stdout.trim()).unwrap_or_else(|err| {
|
||
panic!(
|
||
"cluster {args:?} produced unparseable output ({err}): stdout={stdout} stderr={}",
|
||
String::from_utf8_lossy(&output.stderr)
|
||
)
|
||
})
|
||
}
|
||
|
||
fn write_two_graph_cluster(dir: &std::path::Path) {
|
||
std::fs::write(
|
||
dir.join("people.pg"),
|
||
"\nnode Person {\n name: String @key\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("services.pg"),
|
||
"\nnode Service {\n name: String @key\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("people.gq"),
|
||
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("services.gq"),
|
||
"\nquery find_service($name: String) {\n match { $s: Service { name: $name } }\n return { $s.name }\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("cluster.yaml"),
|
||
r#"
|
||
version: 1
|
||
graphs:
|
||
knowledge:
|
||
schema: ./people.pg
|
||
queries:
|
||
find_person:
|
||
file: ./people.gq
|
||
engineering:
|
||
schema: ./services.pg
|
||
queries:
|
||
find_service:
|
||
file: ./services.gq
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
fn seed_graph(dir: &std::path::Path, graph: &str, row: &str) {
|
||
let data = dir.join(format!("{graph}-seed.jsonl"));
|
||
std::fs::write(&data, row).unwrap();
|
||
let output = cli()
|
||
.arg("load")
|
||
.arg("--mode")
|
||
.arg("overwrite")
|
||
.arg("--data")
|
||
.arg(&data)
|
||
.arg(dir.join(format!("graphs/{graph}.omni")))
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
output.status.success(),
|
||
"seed {graph} failed: {}",
|
||
String::from_utf8_lossy(&output.stderr)
|
||
);
|
||
}
|
||
|
||
fn invoke_query(
|
||
client: &Client,
|
||
base_url: &str,
|
||
graph: &str,
|
||
query: &str,
|
||
params: serde_json::Value,
|
||
) -> (u16, serde_json::Value) {
|
||
let response = client
|
||
.post(format!("{base_url}/graphs/{graph}/queries/{query}"))
|
||
.json(&serde_json::json!({ "params": params }))
|
||
.send()
|
||
.unwrap();
|
||
let status = response.status().as_u16();
|
||
let body = response.json().unwrap_or(serde_json::Value::Null);
|
||
(status, body)
|
||
}
|
||
|
||
/// Opt-out for the comprehensive system e2es below. They need no external
|
||
/// services — only the workspace-built `omnigraph`/`omnigraph-server`
|
||
/// binaries (cargo provides them via `CARGO_BIN_EXE_*`), ephemeral localhost
|
||
/// ports, and local-FS temp dirs — but they spawn real server processes and
|
||
/// run multi-stage lifecycles, so constrained sandboxes can suppress them:
|
||
/// `OMNIGRAPH_SKIP_SYSTEM_E2E=1 cargo test ...` (same skip-with-message
|
||
/// pattern as the S3 tests' `OMNIGRAPH_S3_TEST_BUCKET` gate).
|
||
fn skip_system_e2e(test_name: &str) -> bool {
|
||
if std::env::var("OMNIGRAPH_SKIP_SYSTEM_E2E").is_ok_and(|v| !v.is_empty() && v != "0") {
|
||
eprintln!("skipping {test_name}: OMNIGRAPH_SKIP_SYSTEM_E2E is set");
|
||
return true;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// The whole control-plane story in one test: declare two graphs → converge
|
||
/// (apply creates them) → serve → evolve schema+query in one apply → restart
|
||
/// serves the new shape → out-of-band drift converged back → approved graph
|
||
/// delete → restart serves the survivor only → plan empty.
|
||
#[test]
|
||
fn local_cluster_full_lifecycle_declare_serve_evolve_delete() {
|
||
if skip_system_e2e("local_cluster_full_lifecycle_declare_serve_evolve_delete") {
|
||
return;
|
||
}
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let dir = temp.path();
|
||
write_two_graph_cluster(dir);
|
||
|
||
// Phase 1-2: declare + record.
|
||
assert_eq!(cluster_cli(dir, &["import"])["ok"], true);
|
||
// Phase 3-4: one apply creates both graphs and publishes the catalog.
|
||
let converge = cluster_cli(dir, &["apply"]);
|
||
assert_eq!(converge["converged"], true, "{converge}");
|
||
seed_graph(dir, "knowledge", "{\"type\":\"Person\",\"data\":{\"name\":\"Ada\"}}\n");
|
||
seed_graph(dir, "engineering", "{\"type\":\"Service\",\"data\":{\"name\":\"billing\"}}\n");
|
||
|
||
// Phase 5: serve the applied revision.
|
||
let client = Client::new();
|
||
{
|
||
let server = spawn_server_with_cluster(dir);
|
||
let (status, body) = invoke_query(
|
||
&client,
|
||
&server.base_url,
|
||
"knowledge",
|
||
"find_person",
|
||
serde_json::json!({"name": "Ada"}),
|
||
);
|
||
assert_eq!(status, 200, "{body}");
|
||
assert_eq!(body["rows"][0]["p.name"], "Ada", "{body}");
|
||
let (status, body) = invoke_query(
|
||
&client,
|
||
&server.base_url,
|
||
"engineering",
|
||
"find_service",
|
||
serde_json::json!({"name": "billing"}),
|
||
);
|
||
assert_eq!(status, 200, "{body}");
|
||
assert_eq!(body["rows"][0]["s.name"], "billing", "{body}");
|
||
}
|
||
|
||
// Evolve: schema gains a field, the query returns it — one apply, with
|
||
// the migration previewed in plan.
|
||
std::fs::write(
|
||
dir.join("people.pg"),
|
||
"\nnode Person {\n name: String @key\n bio: String?\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("people.gq"),
|
||
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name, $p.bio }\n}\n",
|
||
)
|
||
.unwrap();
|
||
let plan = cluster_cli(dir, &["plan"]);
|
||
let schema_change = plan["changes"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|change| change["resource"] == "schema.knowledge")
|
||
.unwrap();
|
||
assert_eq!(schema_change["migration"]["supported"], true, "{plan}");
|
||
let evolve = cluster_cli(dir, &["apply"]);
|
||
assert_eq!(evolve["converged"], true, "{evolve}");
|
||
|
||
// Restart: the server serves the evolved shape.
|
||
{
|
||
let server = spawn_server_with_cluster(dir);
|
||
let (status, body) = invoke_query(
|
||
&client,
|
||
&server.base_url,
|
||
"knowledge",
|
||
"find_person",
|
||
serde_json::json!({"name": "Ada"}),
|
||
);
|
||
assert_eq!(status, 200, "{body}");
|
||
assert!(
|
||
body["columns"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.any(|column| column == "p.bio"),
|
||
"evolved query must project the new field: {body}"
|
||
);
|
||
}
|
||
|
||
// Out-of-band drift: the live graph evolves behind the cluster's back;
|
||
// refresh observes it, apply converges it back to the declared schema.
|
||
std::fs::write(
|
||
dir.join("rogue.pg"),
|
||
"\nnode Person {\n name: String @key\n bio: String?\n rogue: String?\n}\n",
|
||
)
|
||
.unwrap();
|
||
let output = cli()
|
||
.arg("schema")
|
||
.arg("apply")
|
||
.arg(dir.join("graphs/knowledge.omni"))
|
||
.arg("--schema")
|
||
.arg(dir.join("rogue.pg"))
|
||
.arg("--json")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "out-of-band schema apply failed");
|
||
let refresh = cluster_cli(dir, &["refresh"]);
|
||
assert_eq!(
|
||
refresh["resource_statuses"]["schema.knowledge"]["status"],
|
||
"drifted",
|
||
"{refresh}"
|
||
);
|
||
let heal = cluster_cli(dir, &["apply"]);
|
||
assert_eq!(heal["converged"], true, "{heal}");
|
||
let schema_show = cli()
|
||
.arg("schema")
|
||
.arg("show")
|
||
.arg(dir.join("graphs/knowledge.omni"))
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
schema_show.status.success(),
|
||
"schema show failed: {}",
|
||
String::from_utf8_lossy(&schema_show.stderr)
|
||
);
|
||
let shown = String::from_utf8_lossy(&schema_show.stdout);
|
||
assert!(shown.contains("Person"), "schema show produced no schema: {shown}");
|
||
assert!(
|
||
!shown.contains("rogue"),
|
||
"drift must be soft-dropped back to the declared schema: {shown}"
|
||
);
|
||
|
||
// Retire engineering: gated delete, then the server serves the survivor.
|
||
std::fs::write(
|
||
dir.join("cluster.yaml"),
|
||
r#"
|
||
version: 1
|
||
graphs:
|
||
knowledge:
|
||
schema: ./people.pg
|
||
queries:
|
||
find_person:
|
||
file: ./people.gq
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let blocked = cluster_cli(dir, &["apply"]);
|
||
assert_eq!(blocked["converged"], false, "{blocked}");
|
||
let approve_output = cli()
|
||
.arg("--as")
|
||
.arg("andrew")
|
||
.arg("cluster")
|
||
.arg("approve")
|
||
.arg("graph.engineering")
|
||
.arg("--config")
|
||
.arg(dir)
|
||
.arg("--json")
|
||
.output()
|
||
.unwrap();
|
||
assert!(approve_output.status.success(), "approve failed");
|
||
let delete = cluster_cli(dir, &["apply"]);
|
||
assert_eq!(delete["converged"], true, "{delete}");
|
||
assert!(!dir.join("graphs/engineering.omni").exists());
|
||
|
||
{
|
||
let server = spawn_server_with_cluster(dir);
|
||
let (status, body) = invoke_query(
|
||
&client,
|
||
&server.base_url,
|
||
"knowledge",
|
||
"find_person",
|
||
serde_json::json!({"name": "Ada"}),
|
||
);
|
||
assert_eq!(status, 200, "{body}");
|
||
let response = client
|
||
.post(format!(
|
||
"{}/graphs/engineering/queries/find_service",
|
||
server.base_url
|
||
))
|
||
.json(&serde_json::json!({"params": {"name": "billing"}}))
|
||
.send()
|
||
.unwrap();
|
||
assert_eq!(
|
||
response.status().as_u16(),
|
||
404,
|
||
"a deleted graph must vanish from the serving surface"
|
||
);
|
||
}
|
||
|
||
// The story ends converged: nothing left to do.
|
||
let final_plan = cluster_cli(dir, &["plan"]);
|
||
assert!(
|
||
final_plan["changes"].as_array().unwrap().is_empty(),
|
||
"{final_plan}"
|
||
);
|
||
}
|
||
|
||
/// Applied policy bundles gate serving per their bindings: the cluster-bound
|
||
/// bundle owns the management surface (graph_list), the graph-bound bundle
|
||
/// owns query invocation — enforced over HTTP with bearer-resolved actors.
|
||
#[test]
|
||
fn local_cluster_serving_enforces_applied_policy_bindings() {
|
||
if skip_system_e2e("local_cluster_serving_enforces_applied_policy_bindings") {
|
||
return;
|
||
}
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let dir = temp.path();
|
||
std::fs::write(
|
||
dir.join("people.pg"),
|
||
"\nnode Person {\n name: String @key\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("people.gq"),
|
||
"\nquery find_person($name: String) {\n match { $p: Person { name: $name } }\n return { $p.name }\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("graph.policy.yaml"),
|
||
r#"
|
||
version: 1
|
||
groups:
|
||
readers: ["act-reader"]
|
||
protected_branches: [main]
|
||
rules:
|
||
- id: allow-invoke
|
||
allow:
|
||
actors: { group: readers }
|
||
actions: [invoke_query]
|
||
- id: allow-read
|
||
allow:
|
||
actors: { group: readers }
|
||
actions: [read]
|
||
branch_scope: any
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("server.policy.yaml"),
|
||
r#"
|
||
version: 1
|
||
kind: server
|
||
groups:
|
||
admins: ["act-admin"]
|
||
rules:
|
||
- id: allow-list
|
||
allow:
|
||
actors: { group: admins }
|
||
actions: [graph_list]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
dir.join("cluster.yaml"),
|
||
r#"
|
||
version: 1
|
||
graphs:
|
||
knowledge:
|
||
schema: ./people.pg
|
||
queries:
|
||
find_person:
|
||
file: ./people.gq
|
||
policies:
|
||
graph_rules:
|
||
file: ./graph.policy.yaml
|
||
applies_to: [knowledge]
|
||
server_rules:
|
||
file: ./server.policy.yaml
|
||
applies_to: [cluster]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(cluster_cli(dir, &["import"])["ok"], true);
|
||
let converge = cluster_cli(dir, &["apply"]);
|
||
assert_eq!(converge["converged"], true, "{converge}");
|
||
seed_graph(dir, "knowledge", "{\"type\":\"Person\",\"data\":{\"name\":\"Ada\"}}\n");
|
||
|
||
let server = spawn_server_with_cluster_env(
|
||
dir,
|
||
&[(
|
||
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
|
||
r#"{"act-admin":"admin-token","act-reader":"reader-token"}"#,
|
||
)],
|
||
);
|
||
let client = Client::new();
|
||
let get_graphs = |token: Option<&str>| {
|
||
let mut request = client.get(format!("{}/graphs", server.base_url));
|
||
if let Some(token) = token {
|
||
request = request.bearer_auth(token);
|
||
}
|
||
request.send().unwrap().status().as_u16()
|
||
};
|
||
// Management surface: cluster-bound bundle, admins only.
|
||
assert_eq!(get_graphs(Some("admin-token")), 200);
|
||
assert_eq!(get_graphs(Some("reader-token")), 403);
|
||
assert_eq!(get_graphs(None), 401);
|
||
|
||
// Query invocation: graph-bound bundle, readers only.
|
||
let invoke = |token: &str| {
|
||
client
|
||
.post(format!(
|
||
"{}/graphs/knowledge/queries/find_person",
|
||
server.base_url
|
||
))
|
||
.bearer_auth(token)
|
||
.json(&serde_json::json!({"params": {"name": "Ada"}}))
|
||
.send()
|
||
.unwrap()
|
||
};
|
||
let response = invoke("reader-token");
|
||
assert_eq!(response.status().as_u16(), 200);
|
||
let body: serde_json::Value = response.json().unwrap();
|
||
assert_eq!(body["rows"][0]["p.name"], "Ada", "{body}");
|
||
// Denied invocation is deliberately 404, indistinguishable from an
|
||
// unknown query — the server's anti-probing contract.
|
||
assert_eq!(invoke("admin-token").status().as_u16(), 404);
|
||
}
|
||
|
||
/// Rule 0 (axiom 15): a --cluster server never reads omnigraph.yaml — not
|
||
/// even the implicit cwd search. A MALFORMED config in the process cwd must
|
||
/// not affect boot or serving.
|
||
#[test]
|
||
fn cluster_server_boot_ignores_local_config_in_cwd() {
|
||
let cluster = tempfile::tempdir().unwrap();
|
||
std::fs::write(
|
||
cluster.path().join("people.pg"),
|
||
"\nnode Person {\n name: String @key\n}\n",
|
||
)
|
||
.unwrap();
|
||
std::fs::write(
|
||
cluster.path().join("cluster.yaml"),
|
||
"version: 1\ngraphs:\n knowledge:\n schema: ./people.pg\n",
|
||
)
|
||
.unwrap();
|
||
for command in ["import", "apply"] {
|
||
let output = cli()
|
||
.arg("cluster")
|
||
.arg(command)
|
||
.arg("--config")
|
||
.arg(cluster.path())
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "cluster {command} failed");
|
||
}
|
||
let cwd = tempfile::tempdir().unwrap();
|
||
std::fs::write(cwd.path().join("omnigraph.yaml"), "{{{{ not yaml").unwrap();
|
||
|
||
let server = spawn_server_with_cluster_in(cluster.path(), cwd.path());
|
||
let response = reqwest::blocking::get(format!("{}/healthz", server.base_url)).unwrap();
|
||
assert!(response.status().is_success());
|
||
}
|
||
|
||
/// RFC-007 PR 2: keyed credentials end to end — `login` stores a 0600
|
||
/// credential, the URL-matched server's token chain authenticates remote
|
||
/// reads (env > file), a non-matching URL never sees the token (§D5 rule
|
||
/// 3), and `logout` revokes.
|
||
#[test]
|
||
fn local_cli_keyed_credentials_authenticate_url_matched_server() {
|
||
let graph = SystemGraph::loaded();
|
||
let server = spawn_server_with_env(
|
||
graph.path(),
|
||
&[("OMNIGRAPH_SERVER_BEARER_TOKEN", "secret-tok")],
|
||
);
|
||
let operator_home = tempfile::tempdir().unwrap();
|
||
let write_server_url = |url: &str| {
|
||
fs::write(
|
||
operator_home.path().join("config.yaml"),
|
||
format!("servers:\n test-srv:\n url: {url}\n"),
|
||
)
|
||
.unwrap();
|
||
};
|
||
write_server_url(&server.base_url);
|
||
|
||
let remote_read = |envs: &[(&str, &str)]| {
|
||
let mut command = cli();
|
||
command.env("OMNIGRAPH_HOME", operator_home.path());
|
||
for (name, value) in envs {
|
||
command.env(name, value);
|
||
}
|
||
command
|
||
.arg("read")
|
||
.arg("--server")
|
||
.arg(&server.base_url)
|
||
.arg("--query")
|
||
.arg(fixture("test.gq"))
|
||
.arg("--name")
|
||
.arg("get_person")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Alice"}"#)
|
||
.arg("--json")
|
||
.output()
|
||
.unwrap()
|
||
};
|
||
|
||
// No credential anywhere: the server refuses.
|
||
let output = remote_read(&[]);
|
||
assert!(!output.status.success(), "{output:?}");
|
||
|
||
// login with a WRONG token (via stdin, the documented pipe flow).
|
||
let output = cli()
|
||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||
.arg("login")
|
||
.arg("test-srv")
|
||
.write_stdin("wrong-tok\n")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "{output:?}");
|
||
let output = remote_read(&[]);
|
||
assert!(!output.status.success(), "wrong token must not authenticate");
|
||
|
||
// Re-login rotates to the right token (via --token); 0600 on disk.
|
||
let output = cli()
|
||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||
.arg("login")
|
||
.arg("test-srv")
|
||
.arg("--token")
|
||
.arg("secret-tok")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "{output:?}");
|
||
let credentials = operator_home.path().join("credentials");
|
||
let text = fs::read_to_string(&credentials).unwrap();
|
||
assert!(text.contains("[test-srv]"), "{text}");
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
let mode = fs::metadata(&credentials).unwrap().permissions().mode();
|
||
assert_eq!(mode & 0o777, 0o600, "{:o}", mode & 0o777);
|
||
}
|
||
let output = remote_read(&[]);
|
||
assert!(
|
||
output.status.success(),
|
||
"keyed credential must authenticate the URL-matched server: {output:?}"
|
||
);
|
||
let payload: serde_json::Value =
|
||
serde_json::from_slice(&output.stdout).unwrap();
|
||
assert_eq!(payload["rows"][0]["p.name"], "Alice");
|
||
|
||
// OMNIGRAPH_TOKEN_<NAME> env outranks the credentials file.
|
||
let output = remote_read(&[("OMNIGRAPH_TOKEN_TEST_SRV", "env-wrong")]);
|
||
assert!(
|
||
!output.status.success(),
|
||
"keyed env token must outrank the credentials file"
|
||
);
|
||
|
||
// §D5 rule 3: a URL matching no operator server never sees the token.
|
||
write_server_url("http://127.0.0.1:1");
|
||
let output = remote_read(&[]);
|
||
assert!(
|
||
!output.status.success(),
|
||
"token keyed to another url must not be sent here"
|
||
);
|
||
write_server_url(&server.base_url);
|
||
|
||
// logout revokes; idempotent.
|
||
for _ in 0..2 {
|
||
let output = cli()
|
||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||
.arg("logout")
|
||
.arg("test-srv")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "{output:?}");
|
||
}
|
||
let output = remote_read(&[]);
|
||
assert!(!output.status.success(), "logout must revoke access");
|
||
}
|
||
|
||
/// RFC-007 PR 3: --server targeting and operator aliases (pure bindings to
|
||
/// stored queries) end to end, with the keyed credential from PR 2.
|
||
#[test]
|
||
fn local_cli_operator_alias_and_server_flag_invoke_stored_query() {
|
||
let graph = SystemGraph::loaded();
|
||
graph.write_query(
|
||
"stored-find-person.gq",
|
||
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }",
|
||
);
|
||
// invoke_query is policy-gated (anti-probing 404 without the grant),
|
||
// so the server gets a per-graph bundle granting it to the operator.
|
||
graph.write_file(
|
||
"graph.policy.yaml",
|
||
"version: 1\ngroups:\n ops: [\"act-op\"]\nprotected_branches: [main]\nrules:\n - id: allow-invoke\n allow:\n actors: { group: ops }\n actions: [invoke_query]\n - id: allow-read\n allow:\n actors: { group: ops }\n actions: [read]\n branch_scope: any\n",
|
||
);
|
||
let config = graph.write_config(
|
||
"omnigraph-server.yaml",
|
||
&format!(
|
||
"graphs:\n local:\n uri: {}\n policy:\n file: ./graph.policy.yaml\n queries:\n find_person:\n file: ./stored-find-person.gq\n",
|
||
yaml_string(&graph.path().to_string_lossy())
|
||
),
|
||
);
|
||
let server = spawn_server_with_config_env(
|
||
&config,
|
||
&[(
|
||
"OMNIGRAPH_SERVER_BEARER_TOKENS_JSON",
|
||
r#"{"act-op":"srv-tok"}"#,
|
||
)],
|
||
);
|
||
|
||
let operator_home = tempfile::tempdir().unwrap();
|
||
fs::write(
|
||
operator_home.path().join("config.yaml"),
|
||
format!(
|
||
"servers:\n dev:\n url: {}\naliases:\n who:\n server: dev\n graph: local\n query: find_person\n args: [name]\n",
|
||
server.base_url
|
||
),
|
||
)
|
||
.unwrap();
|
||
fs::write(
|
||
operator_home.path().join("credentials"),
|
||
"[dev]\ntoken = srv-tok\n",
|
||
)
|
||
.unwrap();
|
||
#[cfg(unix)]
|
||
{
|
||
use std::os::unix::fs::PermissionsExt;
|
||
fs::set_permissions(
|
||
operator_home.path().join("credentials"),
|
||
fs::Permissions::from_mode(0o600),
|
||
)
|
||
.unwrap();
|
||
}
|
||
|
||
// The operator alias: name + positional arg, nothing else — server,
|
||
// graph, stored query, and token all resolve from the operator layer.
|
||
let output = cli()
|
||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||
.arg("query")
|
||
.arg("--alias")
|
||
.arg("who")
|
||
.arg("Alice")
|
||
.arg("--json")
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
output.status.success(),
|
||
"operator alias must invoke the stored query: {output:?}"
|
||
);
|
||
let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
|
||
assert_eq!(payload["rows"][0]["p.name"], "Alice", "{payload}");
|
||
|
||
// --server/--graph: the same stored query via explicit targeting.
|
||
let output = cli()
|
||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||
.arg("query")
|
||
.arg("--server")
|
||
.arg("dev")
|
||
.arg("--graph")
|
||
.arg("local")
|
||
.arg("--query-string")
|
||
.arg("query q($name: String) { match { $p: Person { name: $name } } return { $p.name } }")
|
||
.arg("--params")
|
||
.arg(r#"{"name":"Alice"}"#)
|
||
.arg("--json")
|
||
.output()
|
||
.unwrap();
|
||
assert!(output.status.success(), "{output:?}");
|
||
|
||
// Unknown --server errors listing what IS defined.
|
||
let output = cli()
|
||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||
.arg("query")
|
||
.arg("--server")
|
||
.arg("nope")
|
||
.arg("--query-string")
|
||
.arg("query q() { match { $p: Person } return { $p.name } }")
|
||
.output()
|
||
.unwrap();
|
||
assert!(!output.status.success());
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
assert!(stderr.contains("unknown server 'nope'") && stderr.contains("dev"), "{stderr}");
|
||
|
||
// --server is exclusive with a positional URI.
|
||
let output = cli()
|
||
.env("OMNIGRAPH_HOME", operator_home.path())
|
||
.arg("query")
|
||
.arg(&server.base_url)
|
||
.arg("--server")
|
||
.arg("dev")
|
||
.arg("--query-string")
|
||
.arg("query q() { match { $p: Person } return { $p.name } }")
|
||
.output()
|
||
.unwrap();
|
||
assert!(!output.status.success());
|
||
assert!(
|
||
String::from_utf8_lossy(&output.stderr).contains("exclusive"),
|
||
"{output:?}"
|
||
);
|
||
}
|