omnigraph/crates/omnigraph-cli/tests/system_local.rs
Andrew Altshuler 0bee746a31
feat(cli)!: excise omnigraph.yaml from the CLI; policy/queries tooling reads --cluster (#251)
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>
2026-06-15 21:48:39 +03:00

2650 lines
85 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.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("--store")
.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("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.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("--store")
.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("--store")
.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("--store")
.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("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.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("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.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("--store")
.arg(graph.path())
.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");
let bob = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(graph.path())
.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);
}
/// 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("--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");
let friends = parse_stdout_json(&output_success(
cli()
.arg("read")
.arg("--store")
.arg(&imported_graph)
.arg("--query")
.arg(fixture("test.gq"))
.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 query = query_root.join("test.gq");
fs::copy(fixture("test.gq"), &query).unwrap();
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),
);
// RFC-011: the graph is addressed by `--store <uri>`; the `.gq` path is
// resolved cwd-relative (no omnigraph.yaml `query.roots`).
let read = parse_stdout_json(&output_success(
cli()
.current_dir(query_root)
.arg("read")
.arg("--store")
.arg(&graph_uri)
.arg("--query")
.arg("test.gq")
.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("--store")
.arg(&graph_uri)
.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("--store")
.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("--store")
.arg(graph.path())
.arg("--query")
.arg(fixture("test.gq"))
.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_cwd_relative() {
// RFC-011: omnigraph.yaml `query.roots` search is gone — a `--query`
// path is resolved plainly relative to the process cwd. This pins that
// a bare relative `.gq` filename resolves against `.current_dir`, and
// that the file actually read is the cwd-local one (a same-named query
// elsewhere with different columns is never picked up).
let graph = SystemGraph::loaded();
let root = graph.path().parent().unwrap();
let cwd_dir = root.join("cwd");
let other_dir = root.join("other");
fs::create_dir_all(&cwd_dir).unwrap();
fs::create_dir_all(&other_dir).unwrap();
// The query in the cwd projects (age, name).
write_query_file(
&cwd_dir.join("local.gq"),
r#"
query get_person($name: String) {
match {
$p: Person { name: $name }
}
return { $p.age, $p.name }
}
"#,
);
// A same-named query elsewhere projects only (name): if cwd-relative
// resolution regressed and picked this up, the columns assert fails.
write_query_file(
&other_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(&cwd_dir)
.arg("read")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg("local.gq")
.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("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.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("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.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("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.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("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.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("--store")
.arg(&graph)
.arg("--query")
.arg(&queries)
.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() {
// RFC-011: the read-only policy CLI surfaces source the bundle from a
// cluster's applied policies (`--cluster <dir>` + `--graph <id>`), not
// from an omnigraph.yaml `graphs:` map. These don't mutate the graph;
// they parse and evaluate the effective bundle bound to the graph.
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
// `policy test` has no per-bundle tests file in the cluster model, so
// the cases are supplied explicitly via `--tests`.
let tests_file = cluster.path().join("policy.tests.yaml");
fs::write(&tests_file, POLICY_E2E_TESTS_YAML).unwrap();
let validate = output_success(
cli()
.arg("policy")
.arg("validate")
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge"),
);
assert!(stdout_string(&validate).contains("policy valid:"));
let tests = output_success(
cli()
.arg("policy")
.arg("test")
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.arg("--tests")
.arg(&tests_file),
);
assert!(stdout_string(&tests).contains("policy tests passed: 2 cases"));
let explain = output_success(
cli()
.arg("policy")
.arg("explain")
.arg("--cluster")
.arg(cluster.path())
.arg("--graph")
.arg("knowledge")
.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"));
}
/// Token→actor map for the served-policy tests: the bearer tokens the
/// cluster server resolves to `act-bruno` / `act-ragnor`.
const POLICY_TOKENS_JSON: &str = r#"{"act-bruno":"bruno-tok","act-ragnor":"ragnor-tok"}"#;
#[test]
fn local_cli_change_enforces_engine_layer_policy() {
// RFC-011: a CLI direct-store write carries NO policy — policy lives in
// the cluster/server. So engine-layer policy on a direct write no longer
// exists; this test asserts the faithful migration: the SERVER enforces
// the bundle bound to the served graph, addressed via `--server --graph`
// with a bearer token that resolves to the actor.
//
// Three cases, each discriminating:
//
// 1. No token → the server refuses (401, unauthenticated). The old
// embedded "no actor" footgun does not apply to the served path
// (the actor comes from the token), so this replaces it.
// 2. bruno token, change on protected main → Cedar denies (bruno can
// change unprotected branches; main is protected). Non-zero exit,
// "denied" surfaced from the server error body.
// 3. ragnor token, change on main → Cedar permits (admins-write). Write
// succeeds and the inserted row is readable.
if skip_system_e2e("local_cli_change_enforces_engine_layer_policy") {
return;
}
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
let server = spawn_server_with_cluster_env(
cluster.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
let insert =
"query add($name: String, $age: I32) { insert Person { name: $name, age: $age } }";
// Case 1: no token → the server refuses before any policy check.
let no_token = cli()
.arg("change")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("-e")
.arg(insert)
.arg("--params")
.arg(r#"{"name":"NoTokenPerson","age":1}"#)
.arg("--json")
.output()
.unwrap();
assert!(
!no_token.status.success(),
"unauthenticated served write must be refused: {no_token:?}"
);
// Case 2: bruno token against protected main → denied by the server.
let denied = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("change")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("-e")
.arg(insert)
.arg("--params")
.arg(r#"{"name":"BrunoOnMain","age":2}"#)
.arg("--json")
.output()
.unwrap();
assert!(!denied.status.success(), "bruno/main must be denied");
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: ragnor token against main → permitted by admins-write.
let allowed = parse_stdout_json(&output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("change")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("-e")
.arg(insert)
.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. The read
// uses the bruno token: POLICY_E2E_YAML grants `read` to the `team`
// group (bruno), while admins (ragnor) get write-only rules.
let verify = parse_stdout_json(&output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("read")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--query")
.arg(fixture("test.gq"))
.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_direct_store_write_is_unpoliced_regardless_of_actor() {
// RFC-011: a direct (`--store`) write carries no Cedar policy at all —
// policy lives in the cluster/server. So a write that the SERVED path
// would deny (bruno changing protected main) succeeds on the direct
// path, regardless of the actor. This is the faithful replacement for
// the obsolete `..._positional_uri_does_not_inherit_default_graph_policy`
// premise: a positional/`--store` address has no policy to inherit.
let graph = SystemGraph::loaded();
let mutation_file = insert_person_query(&graph, "system-local-policy-direct.gq");
let allowed = parse_stdout_json(&output_success(
cli()
.arg("--as")
.arg("act-bruno")
.arg("change")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(&mutation_file)
.arg("--params")
.arg(r#"{"name":"DirectStoreBruno","age":4}"#)
.arg("--json"),
));
assert_eq!(allowed["branch"], "main");
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() {
// RFC-011 served re-point: the server enforces the graph-bound bundle on
// a remote load. A load into protected main is a `change`: bruno
// (team-write-unprotected) is denied, ragnor (admins-write) is allowed.
if skip_system_e2e("local_cli_load_enforces_engine_layer_policy") {
return;
}
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
let server = spawn_server_with_cluster_env(
cluster.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
let temp = tempfile::tempdir().unwrap();
let data = temp.path().join("policy-load.jsonl");
fs::write(
&data,
r#"{"type":"Person","data":{"name":"LoadPolicy","age":11}}"#,
)
.unwrap();
// act-bruno: change-on-protected is denied (team-write-unprotected only).
let denied = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("load")
.arg("--mode")
.arg("overwrite")
// `--yes` clears the RFC-011 Decision 9 destructive-write confirmation
// so the policy check (not the confirmation refusal) is what denies.
.arg("--yes")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--data")
.arg(&data)
.arg("--json")
.output()
.unwrap();
assert!(!denied.status.success(), "bruno/main load must be denied");
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()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("load")
.arg("--mode")
.arg("overwrite")
.arg("--yes")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.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() {
// RFC-011 served re-point: ingest into a new branch requires both
// BranchCreate and Change. Bruno has change-unprotected only (no
// branch-ops) — either gate denies. Ragnor has admins-write +
// admins-branch-ops — both fire as ingest creates the branch + loads.
if skip_system_e2e("local_cli_ingest_enforces_engine_layer_policy") {
return;
}
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
let server = spawn_server_with_cluster_env(
cluster.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
let temp = tempfile::tempdir().unwrap();
let data = temp.path().join("policy-ingest.jsonl");
fs::write(
&data,
r#"{"type":"Person","data":{"name":"IngestPolicy","age":12}}"#,
)
.unwrap();
let denied = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("ingest")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--data")
.arg(&data)
.arg("--branch")
.arg("policy-ingest-feature")
.arg("--json")
.output()
.unwrap();
assert!(!denied.status.success(), "bruno ingest must be denied");
let stderr = String::from_utf8_lossy(&denied.stderr);
assert!(
stderr.contains("denied"),
"expected 'denied' for bruno ingest, got: {stderr}"
);
let allowed = parse_stdout_json(&output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("ingest")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.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() {
// RFC-011 served re-point: the server enforces schema_apply against the
// graph-bound bundle. Bruno has no schema_apply rule → denied; ragnor
// has admins-schema-apply → allowed. The schema is additive (a nullable
// property), SDK-compatible with the fixture.
if skip_system_e2e("local_cli_schema_apply_enforces_engine_layer_policy") {
return;
}
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
let server = spawn_server_with_cluster_env(
cluster.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
let new_schema = std::fs::read_to_string(fixture("test.pg"))
.unwrap()
.replace(
" age: I32?\n}",
" age: I32?\n nickname: String?\n}",
);
let temp = tempfile::tempdir().unwrap();
let schema_path = temp.path().join("policy-additive.pg");
std::fs::write(&schema_path, &new_schema).unwrap();
let denied = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("schema")
.arg("apply")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--schema")
.arg(&schema_path)
.arg("--json")
.output()
.unwrap();
assert!(!denied.status.success(), "bruno schema apply must be denied");
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()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("schema")
.arg("apply")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--schema")
.arg(&schema_path)
.arg("--json"),
));
assert_eq!(allowed["applied"], true);
}
#[test]
fn local_cli_schema_apply_rejects_stored_query_breakage_before_publish() {
// RFC-011: stored queries live in the cluster catalog, not omnigraph.yaml.
// The served `schema apply` runs the server's catalog check against the
// applied stored queries; renaming `age`→`years` breaks the bundled
// `find_person` (which projects `$p.age`), so the apply is rejected before
// publish — the schema stays unchanged.
if skip_system_e2e("local_cli_schema_apply_rejects_stored_query_breakage_before_publish") {
return;
}
// A graph-bound bundle that lets ragnor apply schema, plus a stored query
// `find_person` projecting $p.age (the catalog the server checks against).
let cluster = tempfile::tempdir().unwrap();
let dir = cluster.path();
fs::copy(fixture("test.pg"), dir.join("graph.pg")).unwrap();
fs::write(
dir.join("find-person.gq"),
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.age } }",
)
.unwrap();
fs::write(dir.join("graph.policy.yaml"), POLICY_E2E_YAML).unwrap();
fs::write(
dir.join("cluster.yaml"),
"version: 1\nmetadata:\n name: sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n knowledge:\n schema: ./graph.pg\n queries:\n find_person:\n file: ./find-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [knowledge]\n",
)
.unwrap();
output_success(cli().arg("cluster").arg("import").arg("--config").arg(dir));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(dir));
output_success(
cli()
.arg("load")
.arg("--data")
.arg(fixture("test.jsonl"))
.arg("--mode")
.arg("overwrite")
.arg(dir.join("graphs").join("knowledge.omni")),
);
let server = spawn_server_with_cluster_env(
dir,
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
let renamed_schema = std::fs::read_to_string(fixture("test.pg"))
.unwrap()
.replace("age: I32?", "years: I32? @rename_from(\"age\")");
let temp = tempfile::tempdir().unwrap();
let schema_path = temp.path().join("stored-query-breaks.pg");
fs::write(&schema_path, &renamed_schema).unwrap();
let rejected = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("schema")
.arg("apply")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--schema")
.arg(&schema_path)
.arg("--json")
.output()
.unwrap();
assert!(
!rejected.status.success(),
"schema apply that breaks a stored query must be rejected"
);
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}"
);
// The schema stayed unchanged (read it back via the served graph as the
// bruno reader, who holds `team-read`).
let schema = stdout_string(&output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("schema")
.arg("show")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge"),
));
assert!(schema.contains("age: I32?"));
assert!(!schema.contains("years: I32?"));
}
#[test]
fn local_cli_branch_create_enforces_engine_layer_policy() {
// RFC-011 served re-point: bruno has no branch-ops rule → denied;
// ragnor has admins-branch-ops → allowed.
if skip_system_e2e("local_cli_branch_create_enforces_engine_layer_policy") {
return;
}
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
let server = spawn_server_with_cluster_env(
cluster.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
let denied = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("branch")
.arg("create")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--from")
.arg("main")
.arg("bruno-feature")
.output()
.unwrap();
assert!(!denied.status.success(), "bruno branch create must be denied");
let stderr = String::from_utf8_lossy(&denied.stderr);
assert!(
stderr.contains("denied"),
"expected 'denied' for bruno branch create, got: {stderr}"
);
output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("branch")
.arg("create")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--from")
.arg("main")
.arg("ragnor-feature"),
);
}
#[test]
fn local_cli_branch_delete_enforces_engine_layer_policy() {
// RFC-011 served re-point: bruno has no branch-ops rule → denied;
// ragnor has admins-branch-ops → allowed.
if skip_system_e2e("local_cli_branch_delete_enforces_engine_layer_policy") {
return;
}
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
let server = spawn_server_with_cluster_env(
cluster.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
// Pre-create the branch as ragnor so there's something to delete.
output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("branch")
.arg("create")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--from")
.arg("main")
.arg("doomed"),
);
// `--yes` clears the RFC-011 Decision 9 destructive-write confirmation so
// the policy check (not the confirmation refusal) is what denies.
let denied = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("branch")
.arg("delete")
.arg("--yes")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("doomed")
.output()
.unwrap();
assert!(!denied.status.success(), "bruno branch delete must be denied");
let stderr = String::from_utf8_lossy(&denied.stderr);
assert!(
stderr.contains("denied"),
"expected 'denied' for bruno branch delete, got: {stderr}"
);
output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("branch")
.arg("delete")
.arg("--yes")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("doomed"),
);
}
#[test]
fn local_cli_branch_merge_enforces_engine_layer_policy() {
// RFC-011 served re-point: merging into protected main needs
// branch_merge with target_branch_scope protected. bruno has no such
// rule → denied; ragnor has admins-promote → allowed.
if skip_system_e2e("local_cli_branch_merge_enforces_engine_layer_policy") {
return;
}
let cluster = converged_loaded_cluster("knowledge", Some(POLICY_E2E_YAML));
let server = spawn_server_with_cluster_env(
cluster.path(),
&[("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON", POLICY_TOKENS_JSON)],
);
// Pre-create a feature branch as ragnor (admins-branch-ops covers it).
output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("branch")
.arg("create")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("--from")
.arg("main")
.arg("merge-feature"),
);
let denied = cli()
.env("OMNIGRAPH_BEARER_TOKEN", "bruno-tok")
.arg("branch")
.arg("merge")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("merge-feature")
.arg("--into")
.arg("main")
.output()
.unwrap();
assert!(!denied.status.success(), "bruno branch merge must be denied");
let stderr = String::from_utf8_lossy(&denied.stderr);
assert!(
stderr.contains("denied"),
"expected 'denied' for bruno branch merge, got: {stderr}"
);
output_success(
cli()
.env("OMNIGRAPH_BEARER_TOKEN", "ragnor-tok")
.arg("branch")
.arg("merge")
.arg("--server")
.arg(&server.base_url)
.arg("--graph")
.arg("knowledge")
.arg("merge-feature")
.arg("--into")
.arg("main"),
);
}
// ─── RFC-011: operator.actor cascade ──────────────────────────────────────
//
// The CLI actor chain is `--as` > `operator.actor` (in the operator config
// at $OMNIGRAPH_HOME/config.yaml) > none. These two tests pin that order on
// a direct (`--store`) write. RFC-011 makes direct-store writes unpoliced,
// so the assertion is on which `actor_id` the write records, not on a Cedar
// allow/deny — the actor still has to be resolved correctly and stamped onto
// the commit.
/// An operator config (`$OMNIGRAPH_HOME/config.yaml`) carrying just
/// `operator.actor`. Pointing OMNIGRAPH_HOME at the holding dir makes the
/// CLI read it as the operator layer.
fn operator_home_with_actor(actor: &str) -> tempfile::TempDir {
let home = tempfile::tempdir().unwrap();
fs::write(
home.path().join("config.yaml"),
format!("operator:\n actor: {actor}\n"),
)
.unwrap();
home
}
#[test]
fn local_cli_actor_from_config_used_when_no_flag() {
// operator.actor: act-ragnor in the operator config, no --as flag →
// the write records act-ragnor. Proves the operator-layer actor source
// is consulted when `--as` is absent.
let graph = SystemGraph::loaded();
let home = operator_home_with_actor("act-ragnor");
let mutation_file = insert_person_query(&graph, "system-local-cli-actor.gq");
let allowed = parse_stdout_json(&output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("change")
.arg("--store")
.arg(graph.path())
.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() {
// operator.actor: act-ragnor in the config + --as act-bruno on the CLI →
// the write records act-bruno. The flag wins per the precedence rule.
// Without this test, a future change that reverses precedence would ride
// through silently.
let graph = SystemGraph::loaded();
let home = operator_home_with_actor("act-ragnor");
let mutation_file = insert_person_query(&graph, "system-local-cli-actor-override.gq");
let overridden = parse_stdout_json(&output_success(
cli()
.env("OMNIGRAPH_HOME", home.path())
.arg("--as")
.arg("act-bruno")
.arg("change")
.arg("--store")
.arg(graph.path())
.arg("--query")
.arg(&mutation_file)
.arg("--params")
.arg(r#"{"name":"OverrideEve","age":19}"#)
.arg("--json"),
));
assert_eq!(overridden["affected_nodes"], 1);
assert_eq!(overridden["actor_id"], "act-bruno");
}
/// 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() {
// RFC-011 cluster-only: the server boots from a converged cluster
// serving the fixture graph under id `local`; tokens-only boot is
// default-deny, which still permits `read`.
let cluster = converged_loaded_cluster("local", None);
let server = spawn_server_with_cluster_env(
cluster.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("--graph")
.arg("local")
.arg("--query")
.arg(fixture("test.gq"))
.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() {
// RFC-011 cluster-only: build a converged cluster serving graph `local`
// with a stored query `find_person` and a per-graph policy granting the
// operator invoke_query + read (invoke_query is policy-gated — anti-probing
// 404 without the grant).
let cluster = tempfile::tempdir().unwrap();
fs::copy(fixture("test.pg"), cluster.path().join("local.pg")).unwrap();
fs::write(
cluster.path().join("find-person.gq"),
"query find_person($name: String) { match { $p: Person { name: $name } } return { $p.name } }",
)
.unwrap();
fs::write(
cluster.path().join("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",
)
.unwrap();
fs::write(
cluster.path().join("cluster.yaml"),
"version: 1\nmetadata:\n name: alias-sys\nstate:\n backend: cluster\n lock: true\ngraphs:\n local:\n schema: ./local.pg\n queries:\n find_person:\n file: ./find-person.gq\npolicies:\n graph:\n file: ./graph.policy.yaml\n applies_to: [local]\n",
)
.unwrap();
output_success(cli().arg("cluster").arg("import").arg("--config").arg(cluster.path()));
output_success(cli().arg("cluster").arg("apply").arg("--config").arg(cluster.path()));
output_success(
cli()
.arg("load")
.arg("--data")
.arg(fixture("test.jsonl"))
.arg("--mode")
.arg("overwrite")
.arg(cluster.path().join("graphs").join("local.omni")),
);
let server = spawn_server_with_cluster_env(
cluster.path(),
&[(
"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 (RFC-011 D4): `alias <name> [args]` — server,
// graph, stored query, and token all resolve from the operator layer.
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.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:?}");
// RFC-011 D3: invoke the STORED query by name (catalog lane, served-only).
// No `-e`/`--query` — the positional `find_person` is the catalog name.
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("query")
.arg("find_person")
.arg("--server")
.arg("dev")
.arg("--graph")
.arg("local")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
.arg("--json")
.output()
.unwrap();
assert!(output.status.success(), "by-name catalog invocation: {output:?}");
let payload: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(payload["rows"][0]["p.name"], "Alice", "{payload}");
// The verb asserts kind: `mutate <a-read>` is rejected by the server.
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("mutate")
.arg("find_person")
.arg("--server")
.arg("dev")
.arg("--graph")
.arg("local")
.arg("--params")
.arg(r#"{"name":"Alice"}"#)
.output()
.unwrap();
assert!(!output.status.success(), "mutate on a read query must fail");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("'find_person' is a read — use omnigraph query find_person"),
"expected a kind-mismatch error; got: {stderr}"
);
// 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 --store (two ways to address the graph).
// (RFC-011 D3: there is no positional URI anymore — the positional is a
// query name — so the double-addressing contradiction now surfaces between
// the two scope primitives.)
let output = cli()
.env("OMNIGRAPH_HOME", operator_home.path())
.arg("query")
.arg("--store")
.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:?}"
);
}